Skip to content
Merged
Show file tree
Hide file tree
Changes from 68 commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
b8c5f84
fix: wip
scopsy Jun 22, 2025
528eaf3
fix: wip
scopsy Jun 22, 2025
53772dd
fix: fix
scopsy Jun 22, 2025
1638fef
l
scopsy Jun 22, 2025
72afe8c
fix: it
scopsy Jun 22, 2025
3424b16
diz: q
scopsy Jun 22, 2025
43da6f5
fix
scopsy Jun 22, 2025
45e754b
feat: add test
scopsy Jun 22, 2025
44e0b91
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jun 29, 2025
3f354c6
Update pnpm-lock.yaml
scopsy Jun 29, 2025
8ff0cc6
Replace WorkflowOriginEnum with ResourceOriginEnum
scopsy Jun 29, 2025
3a2b4d1
Add step-level diff support to environment diff
scopsy Jun 29, 2025
7f15289
Refactor diff to return per-entity results with metadata
scopsy Jun 29, 2025
ac0283b
Simplify diff summary by merging step and workflow counts
scopsy Jun 29, 2025
ad4ccc7
Update workflow-sync.strategy.ts
scopsy Jun 29, 2025
df59a32
Support workflow deletion during environment sync
scopsy Jun 29, 2025
54ee1bc
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jun 29, 2025
98f9613
Include normalized step data in step diff changes
scopsy Jun 29, 2025
52e2a6f
Rename 'old' to 'previous' in diff-related code
scopsy Jun 29, 2025
b81a247
Remove duration and timing fields from sync results
scopsy Jun 29, 2025
cd1fed5
Update publish-environment.dto.ts
scopsy Jun 29, 2025
cf4f2eb
Refactor publish summary property names for clarity
scopsy Jun 29, 2025
ed2c256
Remove includeInactive option from environment diff
scopsy Jun 29, 2025
7edda2a
Remove includeInactive option from environment publishing
scopsy Jun 29, 2025
2ea9b77
Remove skipExisting option from publish environment flow
scopsy Jun 29, 2025
22d7fd1
Remove batchSize option from publish environment flow
scopsy Jun 29, 2025
3f1873f
Remove unused sync result helpers from base strategy
scopsy Jun 29, 2025
010836d
Update workflow-sync.strategy.ts
scopsy Jun 29, 2025
aad74b5
Refactor workflow sync strategy with modular operations
scopsy Jun 29, 2025
9dba03a
Add userContext to workflow sync diff operations
scopsy Jun 29, 2025
cb95974
Update workflow-sync.constants.ts
scopsy Jun 29, 2025
60053e8
Refactor sync types to use strong typing and enums
scopsy Jun 29, 2025
1dff48e
Use EntityTypeEnum.WORKFLOW for entityType
scopsy Jun 29, 2025
6ef9e8c
Refactor entity to resource terminology in sync logic
scopsy Jun 29, 2025
8db68c7
Use workflow identifier instead of _id in sync and diff ops
scopsy Jun 29, 2025
179db1d
Refactor diff DTOs and types for source/target clarity
scopsy Jun 29, 2025
a221f0c
Update environments-v2-diff.e2e.ts
scopsy Jun 29, 2025
df08c56
Refactor workflow diff to unify step actions and types
scopsy Jun 29, 2025
f354ae6
Rename 'diffs' and 'changes' fields for consistency
scopsy Jun 29, 2025
aca20df
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jun 29, 2025
68d6f00
Refactor workflow sync builders for resource type support
scopsy Jun 29, 2025
6647a41
Merge branch 'nv-6155-api-for-env-level-change' of https://github.com…
scopsy Jun 29, 2025
9595d51
Refactor workflow normalization and update permissions
scopsy Jun 29, 2025
a5edf84
Refactor workflow sync to use repository service
scopsy Jun 29, 2025
a8c41ea
Update workflow diff permissions and refactor types
scopsy Jun 29, 2025
eab59b8
Update environments.controller.ts
scopsy Jun 29, 2025
abd3694
Update tests to use new summary fields in publish API
scopsy Jun 29, 2025
0ee276f
Add updatedBy user tracking to workflows
scopsy Jul 2, 2025
804d002
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jul 2, 2025
37876f4
Add updatedBy user info to environment diff results
scopsy Jul 2, 2025
64f7872
Add updatedAt fields to workflow diff results
scopsy Jul 2, 2025
30570ce
Refactor resource diff structure to use nested objects
scopsy Jul 2, 2025
f94cbbc
Add environment type support and defaulting logic
scopsy Jul 3, 2025
262a45c
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jul 3, 2025
5b8689b
Update environment.schema.ts
scopsy Jul 3, 2025
e91dafd
Remove payload schema fields from workflow sync DTOs
scopsy Jul 3, 2025
cfcbc79
Add MongoDB session support for transactional sync
scopsy Jul 4, 2025
f847a09
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jul 6, 2025
76b244c
Add feature flag for new environment type mechanism
scopsy Jul 6, 2025
6c20904
Refactor environment validation into dedicated service
scopsy Jul 6, 2025
60cc0ce
Support non-replica set MongoDB by allowing null sessions
scopsy Jul 6, 2025
8971867
Exclude session property from command serialization
scopsy Jul 6, 2025
7b089c3
Update get-my-environments.usecase.ts
scopsy Jul 6, 2025
f22f3b7
Add userId to GetMyEnvironmentsCommand
scopsy Jul 6, 2025
5ba3a43
Improve workflow comparison and command serialization
scopsy Jul 6, 2025
64eca22
Add updatedBy and type fields to workflow and environment DTOs
scopsy Jul 6, 2025
17674e3
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jul 8, 2025
aa954ed
Exclude LayoutsController from Swagger docs
scopsy Jul 8, 2025
ebd3546
Merge branch 'next' into nv-6155-api-for-env-level-change
scopsy Jul 14, 2025
656db5f
Remove TransactionalSyncService and refactor usage
scopsy Jul 14, 2025
0e1abdb
Refactor environment diff and publish endpoints
scopsy Jul 14, 2025
af44cad
Refactor environment e2e tests and update SDK formatting
scopsy Jul 14, 2025
771c1b1
Refactor workflow creation in e2e tests to use SDK
scopsy Jul 14, 2025
4054757
Add session support to workflow and template queries
scopsy Jul 14, 2025
b249f62
Update publish test to reflect skipped workflow
scopsy Jul 14, 2025
d2cf7a2
Update .source
scopsy Jul 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"compression": "^1.7.4",
"cross-env": "^7.0.3",
"date-fns": "^2.29.2",
"deep-object-diff": "^1.1.9",
"dotenv": "^16.5.0",
"envalid": "^8.0.0",
"handlebars": "^4.7.7",
Expand Down Expand Up @@ -119,6 +120,7 @@
"@nestjs/schematics": "10.1.4",
"@nestjs/testing": "10.4.18",
"@stoplight/spectral-cli": "^6.15.0",
"@swc-node/register": "^1.10.10",
"@types/async": "^3.2.1",
"@types/bcrypt": "^3.0.0",
"@types/bull": "^3.15.8",
Expand All @@ -131,7 +133,6 @@
"@types/passport-jwt": "^3.0.3",
"@types/sinon": "^9.0.0",
"@types/supertest": "^2.0.8",
"@swc-node/register": "^1.10.10",
"async": "^3.2.0",
"chai": "^4.2.0",
"chai-subset": "^1.6.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { EnvironmentTypeEnum } from '@novu/shared';
import { ApiKeyDto } from './api-key.dto';

export class EnvironmentResponseDto {
Expand Down Expand Up @@ -30,6 +31,14 @@ export class EnvironmentResponseDto {
})
identifier: string;

@ApiPropertyOptional({
enum: EnvironmentTypeEnum,
description: 'Type of the environment',
example: EnvironmentTypeEnum.PROD,
nullable: true,
})
type: EnvironmentTypeEnum;
Comment on lines +34 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@ApiPropertyOptional({
enum: EnvironmentTypeEnum,
description: 'Type of the environment',
example: EnvironmentTypeEnum.PROD,
nullable: true,
})
type: EnvironmentTypeEnum;
@ApiPropertyOptional({
enum: EnvironmentTypeEnum,
description: 'Type of the environment',
example: EnvironmentTypeEnum.PROD,
})
type: EnvironmentTypeEnum;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@djabarovgeorge from what i've i've read in the docs, nullable !== required, where apiproprty optional only handeled the undefined state, nullable is different. and is explicitly added.

Image


@ApiPropertyOptional({
type: ApiKeyDto,
isArray: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export class EnvironmentsControllerV1 {
organizationId: user.organizationId,
environmentId: user.environmentId,
returnApiKeys: canAccessApiKeys,
userId: user._id,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsBoolean, IsDefined, IsHexColor, IsMongoId, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsDefined, IsEnum, IsHexColor, IsMongoId, IsOptional, IsString } from 'class-validator';
import { EnvironmentTypeEnum } from '@novu/shared';
import { OrganizationCommand } from '../../../shared/commands/organization.command';

export class CreateEnvironmentCommand extends OrganizationCommand {
Expand All @@ -14,6 +15,10 @@ export class CreateEnvironmentCommand extends OrganizationCommand {
@IsHexColor()
color?: string;

@IsOptional()
@IsEnum(EnvironmentTypeEnum)
type?: EnvironmentTypeEnum;

@IsBoolean()
@IsDefined()
system: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect } from 'chai';

import { EnvironmentRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { ApiServiceLevelEnum, NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared';
import { ApiServiceLevelEnum, NOVU_ENCRYPTION_SUB_MASK, EnvironmentTypeEnum, FeatureFlagsKeysEnum } from '@novu/shared';

async function createEnv(name: string, session) {
const demoEnvironment = {
Expand All @@ -23,6 +23,14 @@ describe('Create Environment - /environments (POST)', async () => {
noEnvironment: true,
});
session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS);

// Enable the new change mechanism by default for normal tests
(process.env as any).IS_NEW_CHANGE_MECHANISM_ENABLED = 'true';
});

after(async () => {
// Clean up the feature flag
delete (process.env as any).IS_NEW_CHANGE_MECHANISM_ENABLED;
});

it('should create environment entity correctly', async () => {
Expand All @@ -48,6 +56,107 @@ describe('Create Environment - /environments (POST)', async () => {
expect(dbApp.apiKeys[0]._userId).to.equal(session.user._id);
});

it('should create environment with correct default type', async () => {
const demoEnvironment = {
name: 'Test Environment',
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

expect(body.data.name).to.eq(demoEnvironment.name);
expect(body.data.type).to.eq(EnvironmentTypeEnum.PROD);

const dbApp = await environmentRepository.findOne({ _id: body.data._id });
expect(dbApp?.type).to.equal(EnvironmentTypeEnum.PROD);
});

it('should create Development environment with DEV type', async () => {
const demoEnvironment = {
name: 'Development',
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

expect(body.data.name).to.eq(demoEnvironment.name);
expect(body.data.type).to.eq(EnvironmentTypeEnum.DEV);

const dbApp = await environmentRepository.findOne({ _id: body.data._id });
expect(dbApp?.type).to.equal(EnvironmentTypeEnum.DEV);
});

it('should create Production environment with PROD type', async () => {
const demoEnvironment = {
name: 'Production',
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

expect(body.data.name).to.eq(demoEnvironment.name);
expect(body.data.type).to.eq(EnvironmentTypeEnum.PROD);

const dbApp = await environmentRepository.findOne({ _id: body.data._id });
expect(dbApp?.type).to.equal(EnvironmentTypeEnum.PROD);
});

it('should default custom environments to PROD type', async () => {
const demoEnvironment = {
name: 'Staging Environment',
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

expect(body.data.name).to.eq(demoEnvironment.name);
expect(body.data.type).to.eq(EnvironmentTypeEnum.PROD);

const dbApp = await environmentRepository.findOne({ _id: body.data._id });
expect(dbApp?.type).to.equal(EnvironmentTypeEnum.PROD);
});

it('should create all environments with DEV type when IS_NEW_CHANGE_MECHANISM_ENABLED is disabled', async () => {
// Set the feature flag to disabled
(process.env as any).IS_NEW_CHANGE_MECHANISM_ENABLED = 'false';

const testCases = [
{ name: 'Development', expectedType: EnvironmentTypeEnum.DEV },
{ name: 'Production', expectedType: EnvironmentTypeEnum.DEV },
{ name: 'Staging', expectedType: EnvironmentTypeEnum.DEV },
{ name: 'Custom Environment', expectedType: EnvironmentTypeEnum.DEV },
];

for (const testCase of testCases) {
const demoEnvironment = {
name: testCase.name,
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

expect(body.data.name).to.eq(demoEnvironment.name);
expect(body.data.type).to.eq(testCase.expectedType);

const dbApp = await environmentRepository.findOne({ _id: body.data._id });
expect(dbApp?.type).to.equal(testCase.expectedType);
}

// Reset the feature flag to enabled for other tests
(process.env as any).IS_NEW_CHANGE_MECHANISM_ENABLED = 'true';
});

it('should apply default type to existing environments without type field', async () => {
// Create an environment and manually remove the type field to simulate old data
const demoEnvironment = {
name: 'Legacy Environment',
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

// Manually remove the type field to simulate legacy data
await environmentRepository.update({ _id: body.data._id }, { $unset: { type: 1 } });

// Fetch the environment - should have default type applied
const fetchedEnv = await environmentRepository.findOne({ _id: body.data._id });
expect(fetchedEnv?.type).to.equal(EnvironmentTypeEnum.PROD);
});

it('should fail when no name provided', async () => {
const demoEnvironment = {};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(400);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { BadRequestException, Injectable, UnprocessableEntityException } from '@
import { createHash } from 'crypto';
import { nanoid } from 'nanoid';

import { encryptApiKey } from '@novu/application-generic';
import { encryptApiKey, FeatureFlagsService } from '@novu/application-generic';
import { EnvironmentEntity, EnvironmentRepository, NotificationGroupRepository } from '@novu/dal';

import { EnvironmentEnum, PROTECTED_ENVIRONMENTS } from '@novu/shared';
import { EnvironmentEnum, EnvironmentTypeEnum, FeatureFlagsKeysEnum, PROTECTED_ENVIRONMENTS } from '@novu/shared';
import { CreateNovuIntegrationsCommand } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.command';
import { CreateNovuIntegrations } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase';
import { CreateDefaultLayout, CreateDefaultLayoutCommand } from '../../../layouts-v1/usecases';
Expand All @@ -20,7 +20,8 @@ export class CreateEnvironment {
private notificationGroupRepository: NotificationGroupRepository,
private generateUniqueApiKey: GenerateUniqueApiKey,
private createDefaultLayoutUsecase: CreateDefaultLayout,
private createNovuIntegrationsUsecase: CreateNovuIntegrations
private createNovuIntegrationsUsecase: CreateNovuIntegrations,
private featureFlagsService: FeatureFlagsService
) {}

async execute(command: CreateEnvironmentCommand): Promise<EnvironmentResponseDto> {
Expand Down Expand Up @@ -63,12 +64,15 @@ export class CreateEnvironment {
throw new BadRequestException('Color property is required');
}

const type = await this.getEnvironmentType(command.name, command.organizationId, command.type);

const environment = await this.environmentRepository.create({
_organizationId: command.organizationId,
name: normalizedName,
identifier: nanoid(12),
_parentId: command.parentEnvironmentId,
color,
type,
apiKeys: [
{
key: encryptedApiKey,
Expand Down Expand Up @@ -128,6 +132,7 @@ export class CreateEnvironment {
dto._organizationId = environment._organizationId;
dto.identifier = environment.identifier;
dto._parentId = environment._parentId;
dto.type = environment.type;

if (environment.apiKeys && environment.apiKeys.length > 0 && returnApiKeys) {
dto.apiKeys = environment.apiKeys.map((apiKey) => ({
Expand All @@ -139,10 +144,34 @@ export class CreateEnvironment {

return dto;
}

private getEnvironmentColor(name: string, commandColor?: string): string | undefined {
if (name === EnvironmentEnum.DEVELOPMENT) return '#ff8547';
if (name === EnvironmentEnum.PRODUCTION) return '#7e52f4';

return commandColor;
}

private async getEnvironmentType(
name: string,
organizationId: string,
commandType?: EnvironmentTypeEnum
): Promise<EnvironmentTypeEnum> {
if (commandType) return commandType;

const isNewChangeMechanismEnabled = await this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_NEW_CHANGE_MECHANISM_ENABLED,
organization: { _id: organizationId },
defaultValue: false,
});

if (!isNewChangeMechanismEnabled) {
return EnvironmentTypeEnum.DEV;
}

if (name === EnvironmentEnum.DEVELOPMENT) return EnvironmentTypeEnum.DEV;
if (name === EnvironmentEnum.PRODUCTION) return EnvironmentTypeEnum.PROD;

return EnvironmentTypeEnum.PROD;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ export class GetMyEnvironmentsCommand extends BaseCommand {

@IsOptional()
readonly returnApiKeys: boolean;

@IsOptional()
readonly userId: string;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Injectable, NotFoundException, Scope } from '@nestjs/common';

import { EnvironmentEntity, EnvironmentRepository } from '@novu/dal';
import { decryptApiKey, PinoLogger } from '@novu/application-generic';
import { ShortIsPrefixEnum, EnvironmentEnum } from '@novu/shared';
import { decryptApiKey, PinoLogger, FeatureFlagsService } from '@novu/application-generic';
import { ShortIsPrefixEnum, EnvironmentEnum, FeatureFlagsKeysEnum, EnvironmentTypeEnum } from '@novu/shared';

import { GetMyEnvironmentsCommand } from './get-my-environments.command';
import { EnvironmentResponseDto } from '../../dtos/environment-response.dto';
Expand All @@ -14,7 +14,8 @@ import { buildSlug } from '../../../shared/helpers/build-slug';
export class GetMyEnvironments {
constructor(
private environmentRepository: EnvironmentRepository,
private logger: PinoLogger
private logger: PinoLogger,
private featureFlagsService: FeatureFlagsService
) {
this.logger.setContext(this.constructor.name);
}
Expand All @@ -27,11 +28,18 @@ export class GetMyEnvironments {
if (!environments?.length)
throw new NotFoundException(`No environments were found for organization ${command.organizationId}`);

const isNewChangeMechanismEnabled = await this.isNewChangeMechanismEnabled(command);

return environments.map((environment) => {
const processedEnvironment = { ...environment };

processedEnvironment.apiKeys = command.returnApiKeys ? this.decryptApiKeys(environment.apiKeys) : [];

// Override environment type to DEV if feature flag is disabled
if (!isNewChangeMechanismEnabled) {
processedEnvironment.type = EnvironmentTypeEnum.DEV;
}

const shortEnvName = shortenEnvironmentName(processedEnvironment.name);

return {
Expand All @@ -41,6 +49,14 @@ export class GetMyEnvironments {
});
}

private async isNewChangeMechanismEnabled(command: GetMyEnvironmentsCommand): Promise<boolean> {
return await this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_NEW_CHANGE_MECHANISM_ENABLED,
defaultValue: false,
organization: { _id: command.organizationId },
});
}

private decryptApiKeys(apiKeys: EnvironmentEntity['apiKeys']) {
return apiKeys.map((apiKey) => ({
...apiKey,
Expand Down
Loading
Loading