Skip to content

Commit 2e87ab7

Browse files
feat(api): add workflow trigger identifier parity (#6657)
1 parent 5d2e876 commit 2e87ab7

File tree

16 files changed

+88
-66
lines changed

16 files changed

+88
-66
lines changed

apps/api/migrations/integration-scheme-update/add-integration-identifier-migration.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// June 27th, 2023
22

33
import shortid from 'shortid';
4-
import slugify from 'slugify';
54

65
import { providers } from '@novu/shared';
76
import { EnvironmentRepository, IntegrationEntity, IntegrationRepository } from '@novu/dal';
7+
import { slugifyName } from '@novu/application-generic';
88

99
export const ENVIRONMENT_NAME_TO_SHORT_NAME = { ['Development']: 'dev', ['Production']: 'prod', ['undefined']: '' };
1010

@@ -101,7 +101,7 @@ export function genIntegrationIdentificationDetails({
101101
const defaultName = providers.find((provider) => provider.id === providerId)?.displayName ?? providerIdCapitalized;
102102

103103
const name = existingName ?? defaultName;
104-
const identifier = existingIdentifier ?? `${slugify(name, { lower: true, strict: true })}-${shortid.generate()}`;
104+
const identifier = existingIdentifier ?? `${slugifyName(name)}-${shortid.generate()}`;
105105

106106
return { name, identifier };
107107
}

apps/api/migrations/layout-identifier-update/add-layout-identifier-migration.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// August 14th, 2023
22

33
import shortid from 'shortid';
4-
import slugify from 'slugify';
54

65
import { LayoutRepository, OrganizationRepository } from '@novu/dal';
6+
import { slugifyName } from '@novu/application-generic';
77

88
export async function addLayoutIdentifierMigration() {
99
// eslint-disable-next-line no-console
@@ -27,7 +27,7 @@ export async function addLayoutIdentifierMigration() {
2727
const bulkWriteOps = layouts
2828
.map((layout) => {
2929
const { _id, name } = layout;
30-
const identifier = `${slugify(name, { lower: true, strict: true })}-${shortid.generate()}`;
30+
const identifier = `${slugifyName(name)}-${shortid.generate()}`;
3131

3232
return [
3333
{

apps/api/migrations/novu-integrations/novu-integrations.migration.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/shared';
1010
import { NestFactory } from '@nestjs/core';
1111
import { AppModule } from '../../src/app.module';
12-
import slugify from 'slugify';
12+
import { slugifyName } from '@novu/application-generic';
1313
import shortid from 'shortid';
1414

1515
const organizationRepository = new OrganizationRepository();
@@ -47,11 +47,11 @@ const createNovuIntegration = async (
4747
providerId,
4848
channel,
4949
name,
50-
identifier: `${slugify(name, { lower: true, strict: true })}-${shortid.generate()}`,
50+
identifier: `${slugifyName(name)}-${shortid.generate()}`,
5151
active: countChannelIntegrations === 0,
5252
});
5353

54-
console.log('Created Integration' + response._id);
54+
console.log(`Created Integration${response._id}`);
5555
};
5656

5757
export async function createNovuIntegrations() {
@@ -78,10 +78,10 @@ export async function createNovuIntegrations() {
7878
await createNovuIntegration(environment, ChannelTypeEnum.SMS);
7979
await createNovuIntegration(environment, ChannelTypeEnum.EMAIL);
8080

81-
console.log('Processed environment' + environment._id);
81+
console.log(`Processed environment${environment._id}`);
8282
}
8383

84-
console.log('Processed organization' + organization._id);
84+
console.log(`Processed organization${organization._id}`);
8585
}
8686

8787
// eslint-disable-next-line no-console

apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common';
22
import shortid from 'shortid';
3-
import slugify from 'slugify';
43
import { IntegrationEntity, IntegrationRepository, DalException, IntegrationQuery } from '@novu/dal';
54
import {
65
ChannelTypeEnum,
@@ -17,6 +16,7 @@ import {
1716
InvalidateCacheService,
1817
areNovuSmsCredentialsSet,
1918
areNovuEmailCredentialsSet,
19+
slugifyName,
2020
} from '@novu/application-generic';
2121

2222
import { CreateIntegrationCommand } from './create-integration.command';
@@ -148,7 +148,7 @@ export class CreateIntegration {
148148
const defaultName =
149149
providers.find((provider) => provider.id === command.providerId)?.displayName ?? providerIdCapitalized;
150150
const name = command.name ?? defaultName;
151-
const identifier = command.identifier ?? `${slugify(name, { lower: true, strict: true })}-${shortid.generate()}`;
151+
const identifier = command.identifier ?? `${slugifyName(name)}-${shortid.generate()}`;
152152

153153
const query: IntegrationQuery = {
154154
name,

apps/api/src/app/workflows-v1/notification-template.controller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export class NotificationTemplateController {
106106
name: body.name,
107107
tags: body.tags,
108108
description: body.description,
109-
identifier: body.identifier,
109+
workflowId: body.identifier,
110110
critical: body.critical,
111111
preferenceSettings: body.preferenceSettings,
112112
steps: body.steps,

apps/api/src/app/workflows-v1/workflow-v1.controller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class WorkflowControllerV1 {
111111
name: body.name,
112112
tags: body.tags,
113113
description: body.description,
114-
identifier: body.identifier,
114+
workflowId: body.identifier,
115115
critical: body.critical,
116116
preferenceSettings: body.preferenceSettings,
117117
steps: body.steps,

apps/api/src/app/workflows-v2/mappers/notification-template-mapper.ts

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function toResponseWorkflowDto(
3131
preferences: preferencesDto,
3232
steps: getSteps(template, stepIdToControlValuesMap),
3333
name: template.name,
34+
workflowId: template.triggers[0].identifier,
3435
description: template.description,
3536
origin: template.origin || WorkflowOriginEnum.EXTERNAL,
3637
type: template.type || ('MISSING-TYPE-ISSUE' as unknown as WorkflowTypeEnum),

apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts

+8-25
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
UpsertControlValuesUseCase,
2222
UpsertPreferences,
2323
UpsertUserWorkflowPreferencesCommand,
24+
slugifyName,
2425
} from '@novu/application-generic';
2526
import {
2627
CreateWorkflowDto,
@@ -33,7 +34,6 @@ import {
3334
WorkflowTypeEnum,
3435
} from '@novu/shared';
3536
import { UpsertWorkflowCommand } from './upsert-workflow.command';
36-
import { WorkflowAlreadyExistException } from '../../exceptions/workflow-already-exist';
3737
import { StepUpsertMechanismFailedMissingIdException } from '../../exceptions/step-upsert-mechanism-failed-missing-id.exception';
3838
import { toResponseWorkflowDto } from '../../mappers/notification-template-mapper';
3939

@@ -64,11 +64,11 @@ export class UpsertWorkflowUseCase {
6464
private getPreferencesUseCase: GetPreferences
6565
) {}
6666
async execute(command: UpsertWorkflowCommand): Promise<WorkflowResponseDto> {
67-
const workflowForUpdate = await this.getWorkflowIfUpdateAndExist(command);
68-
if (!workflowForUpdate && (await this.workflowExistByExternalId(command))) {
69-
throw new WorkflowAlreadyExistException(command);
70-
}
71-
const workflow = await this.createOrUpdateWorkflow(workflowForUpdate, command);
67+
const existingWorkflow = await this.notificationTemplateRepository.findOne({
68+
_id: command.workflowDatabaseIdForUpdate,
69+
_environmentId: command.user.environmentId,
70+
});
71+
const workflow = await this.createOrUpdateWorkflow(existingWorkflow, command);
7272
const stepIdToControlValuesMap = await this.upsertControlValues(workflow, command);
7373
const preferences = await this.upsertPreference(command, workflow);
7474

@@ -188,28 +188,10 @@ export class UpsertWorkflowUseCase {
188188
description: workflowDto.description || '',
189189
tags: workflowDto.tags || [],
190190
critical: false,
191+
triggerIdentifier: slugifyName(workflowDto.name),
191192
};
192193
}
193194

194-
private async getWorkflowIfUpdateAndExist(upsertCommand: UpsertWorkflowCommand) {
195-
if (upsertCommand.workflowDatabaseIdForUpdate) {
196-
return await this.notificationTemplateRepository.findByIdQuery({
197-
id: upsertCommand.workflowDatabaseIdForUpdate,
198-
environmentId: upsertCommand.user.environmentId,
199-
});
200-
}
201-
}
202-
203-
private async workflowExistByExternalId(upsertCommand: UpsertWorkflowCommand) {
204-
const { environmentId } = upsertCommand.user;
205-
const workflowByDbId = await this.notificationTemplateRepository.findByTriggerIdentifier(
206-
environmentId,
207-
upsertCommand.workflowDto.name
208-
);
209-
210-
return !!workflowByDbId;
211-
}
212-
213195
private convertCreateToUpdateCommand(
214196
command: UpsertWorkflowCommand,
215197
existingWorkflow: NotificationTemplateEntity
@@ -229,6 +211,7 @@ export class UpsertWorkflowUseCase {
229211
description: workflowDto.description,
230212
tags: workflowDto.tags,
231213
active: workflowDto.active ?? true,
214+
workflowId: workflowDto.workflowId,
232215
};
233216
}
234217

apps/api/src/app/workflows-v2/workflow.controller.e2e.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@novu/shared';
1717
import { randomBytes } from 'crypto';
1818
import { JsonSchema } from '@novu/framework';
19+
import { slugifyName } from '@novu/application-generic';
1920

2021
const v2Prefix = '/v2';
2122
const PARTIAL_UPDATED_NAME = 'Updated';
@@ -59,13 +60,14 @@ describe('Workflow Controller E2E API Testing', () => {
5960
});
6061

6162
describe('Create Workflow Permutations', () => {
62-
it('should not allow creating two workflows for the same user with the same name', async () => {
63+
it('should allow creating two workflows for the same user with the same name', async () => {
6364
const nameSuffix = `Test Workflow${new Date().toString()}`;
6465
await createWorkflowAndValidate(nameSuffix);
6566
const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto(nameSuffix);
6667
const res = await session.testAgent.post(`${v2Prefix}/workflows`).send(createWorkflowDto);
67-
expect(res.status).to.be.equal(400);
68-
expect(res.text).to.contain('Workflow with the same name already exists');
68+
expect(res.status).to.be.equal(201);
69+
const workflowCreated: WorkflowResponseDto = res.body.data;
70+
expect(workflowCreated.workflowId).to.include(`${slugifyName(nameSuffix)}-`);
6971
});
7072
});
7173

@@ -147,7 +149,7 @@ describe('Workflow Controller E2E API Testing', () => {
147149
});
148150
});
149151

150-
it('should return results without query', async () => {
152+
it('should return results without query', async () => {
151153
const uuid = generateUUID();
152154
await create10Workflows(uuid);
153155
const listWorkflowResponse = await getAllAndValidate({
@@ -243,6 +245,7 @@ function buildCreateWorkflowDto(nameSuffix: string): CreateWorkflowDto {
243245
return {
244246
__source: WorkflowCreationSourceEnum.EDITOR,
245247
name: TEST_WORKFLOW_NAME + nameSuffix,
248+
workflowId: `${slugifyName(TEST_WORKFLOW_NAME + nameSuffix)}`,
246249
description: 'This is a test workflow',
247250
active: true,
248251
tags: TEST_TAGS,
@@ -585,5 +588,10 @@ function buildUpdateRequest(workflowCreated: WorkflowResponseDto): UpdateWorkflo
585588
'type'
586589
) as UpdateWorkflowDto;
587590

588-
return { ...updateRequest, name: TEST_WORKFLOW_UPDATED_NAME, steps };
591+
return {
592+
...updateRequest,
593+
name: TEST_WORKFLOW_UPDATED_NAME,
594+
workflowId: `${slugifyName(TEST_WORKFLOW_UPDATED_NAME)}`,
595+
steps,
596+
};
589597
}

libs/application-generic/src/usecases/create-workflow/create-workflow.command.ts

+10
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ export class CreateWorkflowCommand extends EnvironmentWithUserCommand {
9494
type: WorkflowTypeEnum;
9595

9696
origin: WorkflowOriginEnum;
97+
98+
/**
99+
* Optional identifier for the workflow trigger.
100+
* This allows overriding the default trigger identifier generation strategy in the use case.
101+
* If provided, the use case will use this value instead of generating one.
102+
* If not provided, the use case will generate a trigger identifier based on its internal logic.
103+
*/
104+
@IsOptional()
105+
@IsString()
106+
triggerIdentifier?: string;
97107
}
98108

99109
export class ChannelCTACommand {

libs/application-generic/src/usecases/create-workflow/create-workflow.usecase.ts

+25-18
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
NotFoundException,
88
} from '@nestjs/common';
99
import { ModuleRef } from '@nestjs/core';
10-
import slugify from 'slugify';
1110
import shortid from 'shortid';
1211

1312
import {
@@ -43,6 +42,7 @@ import {
4342
CreateMessageTemplateCommand,
4443
} from '../message-template';
4544
import { ApiException, PlatformException } from '../../utils/exceptions';
45+
import { slugifyName } from '../../utils';
4646

4747
@Injectable()
4848
export class CreateWorkflow {
@@ -63,23 +63,7 @@ export class CreateWorkflow {
6363
const command = blueprintCommand ?? usecaseCommand;
6464
this.validatePayload(command);
6565

66-
let triggerIdentifier: string;
67-
if (command.type === WorkflowTypeEnum.BRIDGE)
68-
/*
69-
* Bridge workflows need to have the identifier preserved to ensure that
70-
* the Framework-defined identifier is the source of truth.
71-
*/
72-
triggerIdentifier = command.name;
73-
else {
74-
/**
75-
* For non-bridge workflows, we use a slugified version of the workflow name
76-
* as the trigger identifier to provide a better trigger DX.
77-
*/
78-
triggerIdentifier = `${slugify(command.name, {
79-
lower: true,
80-
strict: true,
81-
})}`;
82-
}
66+
const triggerIdentifier = this.generateTriggerIdentifier(command);
8367

8468
const parentChangeId: string =
8569
NotificationTemplateRepository.createObjectId();
@@ -136,6 +120,29 @@ export class CreateWorkflow {
136120
return storedWorkflow;
137121
}
138122

123+
private generateTriggerIdentifier(command: CreateWorkflowCommand) {
124+
if (command.triggerIdentifier) {
125+
return command.triggerIdentifier;
126+
}
127+
128+
let triggerIdentifier: string;
129+
if (command.type === WorkflowTypeEnum.BRIDGE)
130+
/*
131+
* Bridge workflows need to have the identifier preserved to ensure that
132+
* the Framework-defined identifier is the source of truth.
133+
*/
134+
triggerIdentifier = command.name;
135+
else {
136+
/**
137+
* For non-bridge workflows, we use a slugified version of the workflow name
138+
* as the trigger identifier to provide a better trigger DX.
139+
*/
140+
triggerIdentifier = slugifyName(command.name);
141+
}
142+
143+
return triggerIdentifier;
144+
}
145+
139146
private validatePayload(command: CreateWorkflowCommand) {
140147
const variants = command.steps
141148
? command.steps?.flatMap((step) => step.variants || [])

libs/application-generic/src/usecases/workflow/update-workflow/update-workflow.command.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class UpdateWorkflowCommand extends EnvironmentWithUserCommand {
4242

4343
@IsString()
4444
@IsOptional()
45-
identifier?: string;
45+
workflowId?: string;
4646

4747
@IsBoolean()
4848
@IsOptional()

libs/application-generic/src/usecases/workflow/update-workflow/update-workflow.usecase.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -93,19 +93,19 @@ export class UpdateWorkflow {
9393
updatePayload.description = command.description;
9494
}
9595

96-
if (command.identifier) {
96+
if (command.workflowId) {
9797
const isExistingIdentifier =
9898
await this.notificationTemplateRepository.findByTriggerIdentifier(
9999
command.environmentId,
100-
command.identifier,
100+
command.workflowId,
101101
);
102102

103103
if (isExistingIdentifier && isExistingIdentifier._id !== command.id) {
104104
throw new BadRequestException(
105-
`Workflow with identifier ${command.identifier} already exists`,
105+
`Workflow with identifier ${command.workflowId} already exists`,
106106
);
107107
} else {
108-
updatePayload['triggers.0.identifier'] = command.identifier;
108+
updatePayload['triggers.0.identifier'] = command.workflowId;
109109
}
110110
}
111111

libs/application-generic/src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './bridge';
1111
export * from './subscriber';
1212
export * from './variants';
1313
export * from './deepmerge';
14+
export * from './slugify-name';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import slugify from 'slugify';
2+
3+
export const slugifyName = (identifier: string): string => {
4+
return slugify(identifier, {
5+
lower: true,
6+
strict: true,
7+
});
8+
};

packages/shared/src/dto/workflows/workflow-commons-fields.ts

+4
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export class WorkflowCommonsFields {
6464
@IsDefined()
6565
name: string;
6666

67+
@IsString()
68+
@IsDefined()
69+
workflowId: string;
70+
6771
@IsString()
6872
@IsOptional()
6973
description?: string;

0 commit comments

Comments
 (0)