Skip to content

Commit 5d756e5

Browse files
authored
feat(api-service): get subscriber preferences v2 endpoint (#7613)
1 parent 978bb79 commit 5d756e5

24 files changed

+1455
-61
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import { PreferenceChannels } from '../../shared/dtos/preference-channels';
4+
import { Overrides } from '../../subscribers/dtos/get-subscriber-preferences-response.dto';
5+
6+
export class WorkflowInfoDto {
7+
@ApiProperty({ description: 'Unique identifier of the workflow' })
8+
identifier: string;
9+
10+
@ApiProperty({ description: 'Display name of the workflow' })
11+
name: string;
12+
}
13+
14+
export class GlobalPreferenceDto {
15+
@ApiProperty({ description: 'Whether notifications are enabled globally' })
16+
enabled: boolean;
17+
18+
@ApiProperty({ description: 'Channel-specific preference settings', type: PreferenceChannels })
19+
@Type(() => PreferenceChannels)
20+
channels: PreferenceChannels;
21+
}
22+
23+
export class WorkflowPreferenceDto {
24+
@ApiProperty({ description: 'Whether notifications are enabled for this workflow' })
25+
enabled: boolean;
26+
27+
@ApiProperty({ description: 'Channel-specific preference settings for this workflow', type: PreferenceChannels })
28+
@Type(() => PreferenceChannels)
29+
channels: PreferenceChannels;
30+
31+
@ApiProperty({ description: 'List of preference overrides', type: [Overrides] })
32+
@Type(() => Overrides)
33+
overrides: Overrides[];
34+
35+
@ApiProperty({ description: 'Workflow information', type: WorkflowInfoDto })
36+
@Type(() => WorkflowInfoDto)
37+
workflow: WorkflowInfoDto;
38+
}
39+
40+
export class GetSubscriberPreferencesDto {
41+
@ApiProperty({ description: 'Global preference settings', type: GlobalPreferenceDto })
42+
@Type(() => GlobalPreferenceDto)
43+
global: GlobalPreferenceDto;
44+
45+
@ApiProperty({ description: 'Workflow-specific preference settings', type: [WorkflowPreferenceDto] })
46+
@Type(() => WorkflowPreferenceDto)
47+
workflows: WorkflowPreferenceDto[];
48+
}

apps/api/src/app/subscribers-v2/dtos/index.ts

-3
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { expect } from 'chai';
2+
import { randomBytes } from 'crypto';
3+
import { UserSession } from '@novu/testing';
4+
import { ChannelTypeEnum } from '@novu/shared';
5+
import { NotificationTemplateEntity } from '@novu/dal';
6+
import {
7+
UpdateSubscriberGlobalPreferencesRequestDto,
8+
UpdateSubscriberPreferenceRequestDto,
9+
SubscriberResponseDto,
10+
} from '@novu/api/models/components';
11+
import { Novu } from '@novu/api';
12+
import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
13+
14+
const v2Prefix = '/v2';
15+
let session: UserSession;
16+
17+
describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (GET) #novu-v2', () => {
18+
let novuClient: Novu;
19+
let subscriber: SubscriberResponseDto;
20+
let workflow: NotificationTemplateEntity;
21+
22+
beforeEach(async () => {
23+
const uuid = randomBytes(4).toString('hex');
24+
session = new UserSession();
25+
await session.initialize();
26+
novuClient = initNovuClassSdk(session);
27+
subscriber = await createSubscriberAndValidate(uuid);
28+
workflow = await session.createTemplate({
29+
noFeedId: true,
30+
});
31+
});
32+
33+
it('should fetch subscriber preferences with default values', async () => {
34+
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);
35+
36+
expect(response.result).to.have.property('global');
37+
expect(response.result).to.have.property('workflows');
38+
39+
const { global, workflows } = response.result;
40+
41+
// Validate global preferences
42+
expect(global).to.have.property('enabled');
43+
expect(global).to.have.property('channels');
44+
expect(global.enabled).to.be.true;
45+
46+
// Validate workflows array
47+
expect(workflows).to.be.an('array');
48+
expect(workflows).to.have.lengthOf(1);
49+
});
50+
51+
it('should return 404 if subscriber does not exist', async () => {
52+
const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`;
53+
const { error } = await expectSdkExceptionGeneric(() =>
54+
novuClient.subscribers.preferences.retrieve(invalidSubscriberId)
55+
);
56+
57+
expect(error?.statusCode).to.equal(404);
58+
});
59+
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+
};
71+
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;
80+
81+
expect(response.statusCode).to.equal(200);
82+
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 });
87+
});
88+
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,
96+
},
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 });
104+
105+
const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);
106+
107+
const { global, workflows } = response.body.data;
108+
109+
expect(response.statusCode).to.equal(200);
110+
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 });
115+
});
116+
});
117+
118+
async function createSubscriberAndValidate(id: string = '') {
119+
const payload = {
120+
subscriberId: `test-subscriber-${id}`,
121+
firstName: `Test ${id}`,
122+
lastName: 'Subscriber',
123+
email: `test-${id}@subscriber.com`,
124+
phone: '+1234567890',
125+
};
126+
127+
const res = await session.testAgent.post(`/v1/subscribers`).send(payload);
128+
expect(res.status).to.equal(201);
129+
130+
const subscriber = res.body.data;
131+
132+
expect(subscriber.subscriberId).to.equal(payload.subscriberId);
133+
expect(subscriber.firstName).to.equal(payload.firstName);
134+
expect(subscriber.lastName).to.equal(payload.lastName);
135+
expect(subscriber.email).to.equal(payload.email);
136+
expect(subscriber.phone).to.equal(payload.phone);
137+
138+
return subscriber;
139+
}

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

+29-2
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,18 @@ import { GetSubscriber } from './usecases/get-subscriber/get-subscriber.usecase'
2020
import { GetSubscriberCommand } from './usecases/get-subscriber/get-subscriber.command';
2121
import { PatchSubscriber } from './usecases/patch-subscriber/patch-subscriber.usecase';
2222
import { PatchSubscriberCommand } from './usecases/patch-subscriber/patch-subscriber.command';
23+
import { GetSubscriberPreferences } from './usecases/get-subscriber-preferences/get-subscriber-preferences.usecase';
24+
import { GetSubscriberPreferencesCommand } from './usecases/get-subscriber-preferences/get-subscriber-preferences.command';
2325
import { ListSubscribersQueryDto } from './dtos/list-subscribers-query.dto';
24-
import { ListSubscribersResponseDto } from './dtos';
26+
import { ListSubscribersResponseDto } from './dtos/list-subscribers-response.dto';
2527
import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators';
2628
import { DirectionEnum } from '../shared/dtos/base-responses';
2729
import { PatchSubscriberRequestDto } from './dtos/patch-subscriber.dto';
2830
import { SubscriberResponseDto } from '../subscribers/dtos';
2931
import { RemoveSubscriberCommand } from './usecases/remove-subscriber/remove-subscriber.command';
3032
import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase';
3133
import { RemoveSubscriberResponseDto } from './dtos/remove-subscriber.dto';
34+
import { GetSubscriberPreferencesDto } from './dtos/get-subscriber-preferences.dto';
3235

3336
@Controller({ path: '/subscribers', version: '2' })
3437
@UseInterceptors(ClassSerializerInterceptor)
@@ -40,7 +43,8 @@ export class SubscribersController {
4043
private listSubscribersUsecase: ListSubscribersUseCase,
4144
private getSubscriberUsecase: GetSubscriber,
4245
private patchSubscriberUsecase: PatchSubscriber,
43-
private removeSubscriberUsecase: RemoveSubscriber
46+
private removeSubscriberUsecase: RemoveSubscriber,
47+
private getSubscriberPreferencesUsecase: GetSubscriberPreferences
4448
) {}
4549

4650
@Get('')
@@ -136,4 +140,27 @@ export class SubscribersController {
136140
})
137141
);
138142
}
143+
144+
@Get('/:subscriberId/preferences')
145+
@UserAuthentication()
146+
@ExternalApiAccessible()
147+
@ApiOperation({
148+
summary: 'Get subscriber preferences',
149+
description: 'Get subscriber preferences',
150+
})
151+
@ApiResponse(GetSubscriberPreferencesDto)
152+
@SdkGroupName('Subscribers.Preferences')
153+
@SdkMethodName('retrieve')
154+
async getSubscriberPreferences(
155+
@UserSession() user: UserSessionData,
156+
@Param('subscriberId') subscriberId: string
157+
): Promise<GetSubscriberPreferencesDto> {
158+
return await this.getSubscriberPreferencesUsecase.execute(
159+
GetSubscriberPreferencesCommand.create({
160+
environmentId: user.environmentId,
161+
organizationId: user.organizationId,
162+
subscriberId,
163+
})
164+
);
165+
}
139166
}
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
11
import { Module } from '@nestjs/common';
2-
import { SubscriberRepository } from '@novu/dal';
3-
import { SubscribersController } from './subscribers.controller';
2+
import {
3+
NotificationTemplateRepository,
4+
PreferencesRepository,
5+
SubscriberRepository,
6+
TopicSubscribersRepository,
7+
} from '@novu/dal';
8+
import {
9+
cacheService,
10+
GetPreferences,
11+
GetSubscriberGlobalPreference,
12+
GetSubscriberPreference,
13+
InvalidateCacheService,
14+
} from '@novu/application-generic';
415
import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase';
516
import { GetSubscriber } from './usecases/get-subscriber/get-subscriber.usecase';
617
import { PatchSubscriber } from './usecases/patch-subscriber/patch-subscriber.usecase';
18+
import { GetSubscriberPreferences } from './usecases/get-subscriber-preferences/get-subscriber-preferences.usecase';
719
import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase';
8-
import { SharedModule } from '../shared/shared.module';
9-
import { PreferencesModule } from '../preferences/preferences.module';
20+
import { SubscribersController } from './subscribers.controller';
21+
22+
const USE_CASES = [
23+
ListSubscribersUseCase,
24+
GetSubscriber,
25+
PatchSubscriber,
26+
RemoveSubscriber,
27+
GetSubscriberPreferences,
28+
GetSubscriberGlobalPreference,
29+
GetSubscriberPreference,
30+
GetPreferences,
31+
];
1032

11-
const USE_CASES = [ListSubscribersUseCase, GetSubscriber, PatchSubscriber, RemoveSubscriber];
33+
const DAL_MODELS = [
34+
SubscriberRepository,
35+
NotificationTemplateRepository,
36+
PreferencesRepository,
37+
TopicSubscribersRepository,
38+
];
1239

1340
@Module({
1441
controllers: [SubscribersController],
15-
imports: [SharedModule, PreferencesModule],
16-
providers: [...USE_CASES, SubscriberRepository],
42+
providers: [...USE_CASES, ...DAL_MODELS, cacheService, InvalidateCacheService],
1743
})
1844
export class SubscribersModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command';
2+
3+
export class GetSubscriberPreferencesCommand extends EnvironmentWithSubscriber {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { Injectable } from '@nestjs/common';
2+
import {
3+
GetSubscriberGlobalPreference,
4+
GetSubscriberGlobalPreferenceCommand,
5+
GetSubscriberPreference,
6+
GetSubscriberPreferenceCommand,
7+
} from '@novu/application-generic';
8+
import { ISubscriberPreferenceResponse } from '@novu/shared';
9+
import { plainToInstance } from 'class-transformer';
10+
import { GetSubscriberPreferencesCommand } from './get-subscriber-preferences.command';
11+
import {
12+
GetSubscriberPreferencesDto,
13+
GlobalPreferenceDto,
14+
WorkflowPreferenceDto,
15+
} from '../../dtos/get-subscriber-preferences.dto';
16+
17+
@Injectable()
18+
export class GetSubscriberPreferences {
19+
constructor(
20+
private getSubscriberGlobalPreference: GetSubscriberGlobalPreference,
21+
private getSubscriberPreference: GetSubscriberPreference
22+
) {}
23+
24+
async execute(command: GetSubscriberPreferencesCommand): Promise<GetSubscriberPreferencesDto> {
25+
const globalPreference = await this.fetchGlobalPreference(command);
26+
const workflowPreferences = await this.fetchWorkflowPreferences(command);
27+
28+
return plainToInstance(GetSubscriberPreferencesDto, {
29+
global: globalPreference,
30+
workflows: workflowPreferences,
31+
});
32+
}
33+
34+
private async fetchGlobalPreference(command: GetSubscriberPreferencesCommand): Promise<GlobalPreferenceDto> {
35+
const { preference } = await this.getSubscriberGlobalPreference.execute(
36+
GetSubscriberGlobalPreferenceCommand.create({
37+
organizationId: command.organizationId,
38+
environmentId: command.environmentId,
39+
subscriberId: command.subscriberId,
40+
includeInactiveChannels: false,
41+
})
42+
);
43+
44+
return {
45+
enabled: preference.enabled,
46+
channels: preference.channels,
47+
};
48+
}
49+
50+
private async fetchWorkflowPreferences(command: GetSubscriberPreferencesCommand) {
51+
const subscriberWorkflowPreferences = await this.getSubscriberPreference.execute(
52+
GetSubscriberPreferenceCommand.create({
53+
environmentId: command.environmentId,
54+
subscriberId: command.subscriberId,
55+
organizationId: command.organizationId,
56+
includeInactiveChannels: false,
57+
})
58+
);
59+
60+
return subscriberWorkflowPreferences.map(this.mapToWorkflowPreference);
61+
}
62+
63+
private mapToWorkflowPreference(subscriberWorkflowPreference: ISubscriberPreferenceResponse): WorkflowPreferenceDto {
64+
const { preference, template } = subscriberWorkflowPreference;
65+
66+
return {
67+
enabled: preference.enabled,
68+
channels: preference.channels,
69+
overrides: preference.overrides,
70+
workflow: {
71+
identifier: template.triggers[0].identifier,
72+
name: template.name,
73+
},
74+
};
75+
}
76+
}

apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
22
import { InstrumentUsecase } from '@novu/application-generic';
33
import { SubscriberRepository } from '@novu/dal';
44
import { ListSubscribersCommand } from './list-subscribers.command';
5-
import { ListSubscribersResponseDto } from '../../dtos';
5+
import { ListSubscribersResponseDto } from '../../dtos/list-subscribers-response.dto';
66
import { DirectionEnum } from '../../../shared/dtos/base-responses';
77
import { mapSubscriberEntityToDto } from './map-subscriber-entity-to.dto';
88

0 commit comments

Comments
 (0)