Skip to content

Commit afd325e

Browse files
authored
feat(api-service): patch subscriber preferences v2 endpoint (#7629)
1 parent 5d756e5 commit afd325e

25 files changed

+1064
-106
lines changed

apps/api/src/app/subscribers-v2/dtos/get-subscriber-preferences.dto.ts

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { PreferenceChannels } from '../../shared/dtos/preference-channels';
44
import { Overrides } from '../../subscribers/dtos/get-subscriber-preferences-response.dto';
55

66
export class WorkflowInfoDto {
7+
@ApiProperty({ description: 'Workflow slug' })
8+
slug: string;
9+
710
@ApiProperty({ description: 'Unique identifier of the workflow' })
811
identifier: string;
912

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IPreferenceChannels } from '@novu/shared';
3+
import { Type, Transform } from 'class-transformer';
4+
import { IsMongoId, IsOptional } from 'class-validator';
5+
import { parseSlugId } from '../../workflows-v2/pipes/parse-slug-id';
6+
7+
export class PatchPreferenceChannelsDto implements IPreferenceChannels {
8+
@ApiProperty({ description: 'Email channel preference' })
9+
email?: boolean;
10+
11+
@ApiProperty({ description: 'SMS channel preference' })
12+
sms?: boolean;
13+
14+
@ApiProperty({ description: 'In-app channel preference' })
15+
in_app?: boolean;
16+
17+
@ApiProperty({ description: 'Push channel preference' })
18+
push?: boolean;
19+
20+
@ApiProperty({ description: 'Chat channel preference' })
21+
chat?: boolean;
22+
}
23+
24+
export class PatchSubscriberPreferencesDto {
25+
@ApiProperty({ description: 'Channel-specific preference settings', type: PatchPreferenceChannelsDto })
26+
@Type(() => PatchPreferenceChannelsDto)
27+
channels: PatchPreferenceChannelsDto;
28+
29+
@ApiProperty({
30+
description: 'If provided, update workflow specific preferences, otherwise update global preferences',
31+
required: false,
32+
})
33+
@IsOptional()
34+
@Transform(({ value }) => parseSlugId(value))
35+
@IsMongoId()
36+
workflowId?: string;
37+
}

apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts

+32-60
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
import { expect } from 'chai';
22
import { randomBytes } from 'crypto';
33
import { UserSession } from '@novu/testing';
4-
import { ChannelTypeEnum } from '@novu/shared';
54
import { NotificationTemplateEntity } from '@novu/dal';
6-
import {
7-
UpdateSubscriberGlobalPreferencesRequestDto,
8-
UpdateSubscriberPreferenceRequestDto,
9-
SubscriberResponseDto,
10-
} from '@novu/api/models/components';
5+
import { SubscriberResponseDto } from '@novu/api/models/components';
116
import { Novu } from '@novu/api';
127
import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
138

14-
const v2Prefix = '/v2';
159
let session: UserSession;
1610

1711
describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (GET) #novu-v2', () => {
@@ -33,17 +27,9 @@ describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (G
3327
it('should fetch subscriber preferences with default values', async () => {
3428
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);
3529

36-
expect(response.result).to.have.property('global');
37-
expect(response.result).to.have.property('workflows');
38-
3930
const { global, workflows } = response.result;
4031

41-
// Validate global preferences
42-
expect(global).to.have.property('enabled');
43-
expect(global).to.have.property('channels');
4432
expect(global.enabled).to.be.true;
45-
46-
// Validate workflows array
4733
expect(workflows).to.be.an('array');
4834
expect(workflows).to.have.lengthOf(1);
4935
});
@@ -57,61 +43,47 @@ describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (G
5743
expect(error?.statusCode).to.equal(404);
5844
});
5945

60-
it('should handle subscriber with modified workflow preferences', async () => {
61-
// created workflow has 'email' and 'in-app' channels enabled by default
62-
const workflowId = workflow._id;
63-
64-
// disable email channel for this workflow
65-
const enableEmailPreferenceData: UpdateSubscriberPreferenceRequestDto = {
66-
channel: {
67-
type: ChannelTypeEnum.EMAIL,
68-
enabled: false,
69-
},
70-
};
46+
it('should show all available workflowsin preferences response', async () => {
47+
// Create multiple templates
48+
const workflow2 = await session.createTemplate({ noFeedId: true });
49+
const workflow3 = await session.createTemplate({ noFeedId: true });
7150

72-
// TODO: replace with v2 endpoint when available
73-
await session.testAgent
74-
.patch(`/v1/subscribers/${subscriber.subscriberId}/preferences/${workflowId}`)
75-
.send({ ...enableEmailPreferenceData });
76-
77-
const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);
78-
79-
const { global, workflows } = response.body.data;
51+
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);
8052

81-
expect(response.statusCode).to.equal(200);
53+
const { workflows } = response.result;
8254

83-
expect(global.channels).to.deep.equal({ in_app: true, email: true });
84-
expect(workflows).to.have.lengthOf(1);
85-
expect(workflows[0].channels).to.deep.equal({ in_app: true, email: false });
86-
expect(workflows[0].workflow).to.deep.equal({ name: workflow.name, identifier: workflow.triggers[0].identifier });
55+
expect(workflows).to.have.lengthOf(3); // Should show all available workflows
56+
const workflowIdentifiers = workflows.map((_wf) => _wf.workflow.identifier);
57+
expect(workflowIdentifiers).to.include(workflow.triggers[0].identifier);
58+
expect(workflowIdentifiers).to.include(workflow2.triggers[0].identifier);
59+
expect(workflowIdentifiers).to.include(workflow3.triggers[0].identifier);
8760
});
8861

89-
it('should handle subscriber with modified global preferences', async () => {
90-
// disable email channel globally
91-
const enableGlobalEmailPreferenceData: UpdateSubscriberGlobalPreferencesRequestDto = {
92-
preferences: [
93-
{
94-
type: ChannelTypeEnum.EMAIL,
95-
enabled: false,
62+
it('should inherit channel preferences from global settings when no workflow override exists', async () => {
63+
// First set global preferences
64+
await novuClient.subscribers.preferences.update(
65+
{
66+
channels: {
67+
email: false,
68+
inApp: true,
9669
},
97-
],
98-
};
99-
100-
// TODO: replace with v2 endpoint when available
101-
await session.testAgent
102-
.patch(`/v1/subscribers/${subscriber.subscriberId}/preferences`)
103-
.send({ ...enableGlobalEmailPreferenceData });
70+
},
71+
subscriber.subscriberId
72+
);
10473

105-
const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);
74+
// Then create a new template
75+
const newWorkflow = await session.createTemplate({ noFeedId: true });
10676

107-
const { global, workflows } = response.body.data;
77+
// Check preferences
78+
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);
10879

109-
expect(response.statusCode).to.equal(200);
80+
const { workflows } = response.result;
11081

111-
expect(global.channels).to.deep.equal({ in_app: true, email: false });
112-
expect(workflows).to.have.lengthOf(1);
113-
expect(workflows[0].channels).to.deep.equal({ in_app: true, email: false });
114-
expect(workflows[0].workflow).to.deep.equal({ name: workflow.name, identifier: workflow.triggers[0].identifier });
82+
const newWorkflowPreferences = workflows.find(
83+
(_wf) => _wf.workflow.identifier === newWorkflow.triggers[0].identifier
84+
);
85+
// New workflow should inherit global settings
86+
expect(newWorkflowPreferences?.channels).to.deep.equal({ email: false, inApp: true });
11587
});
11688
});
11789

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { expect } from 'chai';
2+
import { randomBytes } from 'crypto';
3+
import { UserSession } from '@novu/testing';
4+
import { NotificationTemplateEntity } from '@novu/dal';
5+
import { SubscriberResponseDto, PatchSubscriberPreferencesDto } from '@novu/api/models/components';
6+
import { Novu } from '@novu/api';
7+
import {
8+
expectSdkExceptionGeneric,
9+
expectSdkValidationExceptionGeneric,
10+
initNovuClassSdk,
11+
} from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
12+
13+
let session: UserSession;
14+
15+
describe('Patch Subscriber Preferences - /subscribers/:subscriberId/preferences (PATCH) #novu-v2', () => {
16+
let novuClient: Novu;
17+
let subscriber: SubscriberResponseDto;
18+
let workflow: NotificationTemplateEntity;
19+
20+
beforeEach(async () => {
21+
const uuid = randomBytes(4).toString('hex');
22+
session = new UserSession();
23+
await session.initialize();
24+
novuClient = initNovuClassSdk(session);
25+
subscriber = await createSubscriberAndValidate(uuid);
26+
workflow = await session.createTemplate({
27+
noFeedId: true,
28+
});
29+
});
30+
31+
it('should patch workflow channel preferences', async () => {
32+
const workflowId = workflow._id;
33+
34+
const patchData: PatchSubscriberPreferencesDto = {
35+
channels: {
36+
email: false,
37+
inApp: true,
38+
},
39+
workflowId,
40+
};
41+
42+
const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId);
43+
44+
const { global, workflows } = response.result;
45+
46+
expect(global.channels).to.deep.equal({ inApp: true, email: true });
47+
expect(workflows).to.have.lengthOf(1);
48+
expect(workflows[0].channels).to.deep.equal({ inApp: true, email: false });
49+
expect(workflows[0].workflow).to.deep.include({ name: workflow.name, identifier: workflow.triggers[0].identifier });
50+
});
51+
52+
it('should patch global channel preferences', async () => {
53+
const patchData: PatchSubscriberPreferencesDto = {
54+
channels: {
55+
email: false,
56+
inApp: false,
57+
},
58+
};
59+
60+
const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId);
61+
62+
const { global, workflows } = response.result;
63+
64+
expect(global.channels).to.deep.equal({ inApp: false, email: false });
65+
expect(workflows).to.have.lengthOf(1);
66+
expect(workflows[0].channels).to.deep.equal({ inApp: false, email: false });
67+
expect(workflows[0].workflow).to.deep.include({ name: workflow.name, identifier: workflow.triggers[0].identifier });
68+
});
69+
70+
it('should return 404 when patching non-existent subscriber preferences', async () => {
71+
const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`;
72+
const patchData: PatchSubscriberPreferencesDto = {
73+
channels: {
74+
email: false,
75+
},
76+
};
77+
78+
const { error } = await expectSdkExceptionGeneric(() =>
79+
novuClient.subscribers.preferences.update(patchData, invalidSubscriberId)
80+
);
81+
82+
expect(error?.statusCode).to.equal(404);
83+
});
84+
85+
it('should return 400 when patching with invalid workflow id', async () => {
86+
const patchData: PatchSubscriberPreferencesDto = {
87+
channels: {
88+
email: false,
89+
},
90+
workflowId: 'invalid-workflow-id',
91+
};
92+
93+
try {
94+
await expectSdkValidationExceptionGeneric(() =>
95+
novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId)
96+
);
97+
} catch (e) {
98+
// TODO: fix in SDK util
99+
expect(e).to.be.an.instanceOf(Error);
100+
}
101+
});
102+
});
103+
104+
async function createSubscriberAndValidate(id: string = '') {
105+
const payload = {
106+
subscriberId: `test-subscriber-${id}`,
107+
firstName: `Test ${id}`,
108+
lastName: 'Subscriber',
109+
email: `test-${id}@subscriber.com`,
110+
phone: '+1234567890',
111+
};
112+
113+
const res = await session.testAgent.post(`/v1/subscribers`).send(payload);
114+
expect(res.status).to.equal(201);
115+
116+
const subscriber = res.body.data;
117+
118+
expect(subscriber.subscriberId).to.equal(payload.subscriberId);
119+
expect(subscriber.firstName).to.equal(payload.firstName);
120+
expect(subscriber.lastName).to.equal(payload.lastName);
121+
expect(subscriber.email).to.equal(payload.email);
122+
expect(subscriber.phone).to.equal(payload.phone);
123+
124+
return subscriber;
125+
}

apps/api/src/app/subscribers-v2/subscribers.controller.ts

+32-2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import { RemoveSubscriberCommand } from './usecases/remove-subscriber/remove-sub
3232
import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase';
3333
import { RemoveSubscriberResponseDto } from './dtos/remove-subscriber.dto';
3434
import { GetSubscriberPreferencesDto } from './dtos/get-subscriber-preferences.dto';
35+
import { PatchSubscriberPreferencesDto } from './dtos/patch-subscriber-preferences.dto';
36+
import { UpdateSubscriberPreferencesCommand } from './usecases/update-subscriber-preferences/update-subscriber-preferences.command';
37+
import { UpdateSubscriberPreferences } from './usecases/update-subscriber-preferences/update-subscriber-preferences.usecase';
3538

3639
@Controller({ path: '/subscribers', version: '2' })
3740
@UseInterceptors(ClassSerializerInterceptor)
@@ -44,7 +47,8 @@ export class SubscribersController {
4447
private getSubscriberUsecase: GetSubscriber,
4548
private patchSubscriberUsecase: PatchSubscriber,
4649
private removeSubscriberUsecase: RemoveSubscriber,
47-
private getSubscriberPreferencesUsecase: GetSubscriberPreferences
50+
private getSubscriberPreferencesUsecase: GetSubscriberPreferences,
51+
private updateSubscriberPreferencesUsecase: UpdateSubscriberPreferences
4852
) {}
4953

5054
@Get('')
@@ -146,7 +150,7 @@ export class SubscribersController {
146150
@ExternalApiAccessible()
147151
@ApiOperation({
148152
summary: 'Get subscriber preferences',
149-
description: 'Get subscriber preferences',
153+
description: 'Get subscriber global and workflow specific preferences',
150154
})
151155
@ApiResponse(GetSubscriberPreferencesDto)
152156
@SdkGroupName('Subscribers.Preferences')
@@ -163,4 +167,30 @@ export class SubscribersController {
163167
})
164168
);
165169
}
170+
171+
@Patch('/:subscriberId/preferences')
172+
@UserAuthentication()
173+
@ExternalApiAccessible()
174+
@ApiOperation({
175+
summary: 'Update subscriber global or workflow specific preferences',
176+
description: 'Update subscriber global or workflow specific preferences',
177+
})
178+
@ApiResponse(GetSubscriberPreferencesDto)
179+
@SdkGroupName('Subscribers.Preferences')
180+
@SdkMethodName('update')
181+
async updateSubscriberPreferences(
182+
@UserSession() user: UserSessionData,
183+
@Param('subscriberId') subscriberId: string,
184+
@Body() body: PatchSubscriberPreferencesDto
185+
): Promise<GetSubscriberPreferencesDto> {
186+
return await this.updateSubscriberPreferencesUsecase.execute(
187+
UpdateSubscriberPreferencesCommand.create({
188+
environmentId: user.environmentId,
189+
organizationId: user.organizationId,
190+
subscriberId,
191+
workflowId: body.workflowId,
192+
channels: body.channels,
193+
})
194+
);
195+
}
166196
}

0 commit comments

Comments
 (0)