Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api-service): get subscriber preferences v2 endpoint #7613

Merged
merged 10 commits into from
Jan 31, 2025
132 changes: 132 additions & 0 deletions apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect } from 'chai';
import { randomBytes } from 'crypto';
import { UserSession } from '@novu/testing';
import { ChannelTypeEnum, IGetSubscriberResponseDto } from '@novu/shared';
import { NotificationTemplateEntity } from '@novu/dal';
import {
UpdateSubscriberGlobalPreferencesRequestDto,
UpdateSubscriberPreferenceRequestDto,
} from '@novu/api/models/components';

const v2Prefix = '/v2';
let session: UserSession;

describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (GET) #novu-v2', () => {
let subscriber: IGetSubscriberResponseDto;
let workflow: NotificationTemplateEntity;

beforeEach(async () => {
const uuid = randomBytes(4).toString('hex');
session = new UserSession();
await session.initialize();
subscriber = await createSubscriberAndValidate(uuid);
workflow = await session.createTemplate({
noFeedId: true,
});
});

it('should fetch subscriber preferences with default values', async () => {
const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);

expect(response.statusCode).to.equal(200);
expect(response.body.data).to.have.property('global');
expect(response.body.data).to.have.property('workflows');

const { global, workflows } = response.body.data;

// Validate global preferences
expect(global).to.have.property('enabled');
expect(global).to.have.property('channels');
expect(global.enabled).to.be.true;

// Validate workflows array
expect(workflows).to.be.an('array');
});

it('should return 404 if subscriber does not exist', async () => {
const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`;
const response = await session.testAgent.get(`${v2Prefix}/subscribers/${invalidSubscriberId}/preferences`);

expect(response.statusCode).to.equal(404);
});

it('should handle subscriber with modified workflow preferences', async () => {
// created workflow has 'email' and 'in-app' channels enabled by default
const workflowId = workflow._id;

// disable email channel for this workflow
const enableEmailPreferenceData: UpdateSubscriberPreferenceRequestDto = {
channel: {
type: ChannelTypeEnum.EMAIL,
enabled: false,
},
};

// TODO: replace with v2 endpoint when available
await session.testAgent
.patch(`/v1/subscribers/${subscriber.subscriberId}/preferences/${workflowId}`)
.send({ ...enableEmailPreferenceData });

const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);

const { global, workflows } = response.body.data;

expect(response.statusCode).to.equal(200);

expect(global.channels).to.deep.equal({ in_app: true, email: true });
expect(workflows).to.have.lengthOf(1);
expect(workflows[0].channels).to.deep.equal({ in_app: true, email: false });
expect(workflows[0].workflow).to.deep.equal({ name: workflow.name, identifier: workflow.triggers[0].identifier });
});

it('should handle subscriber with modified global preferences', async () => {
// disable email channel globally
const enableGlobalEmailPreferenceData: UpdateSubscriberGlobalPreferencesRequestDto = {
preferences: [
{
type: ChannelTypeEnum.EMAIL,
enabled: false,
},
],
};

// TODO: replace with v2 endpoint when available
await session.testAgent
.patch(`/v1/subscribers/${subscriber.subscriberId}/preferences`)
.send({ ...enableGlobalEmailPreferenceData });

const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);

const { global, workflows } = response.body.data;

expect(response.statusCode).to.equal(200);

expect(global.channels).to.deep.equal({ in_app: true, email: false });
expect(workflows).to.have.lengthOf(1);
expect(workflows[0].channels).to.deep.equal({ in_app: true, email: false });
expect(workflows[0].workflow).to.deep.equal({ name: workflow.name, identifier: workflow.triggers[0].identifier });
});
});

async function createSubscriberAndValidate(id: string = '') {
const payload = {
subscriberId: `test-subscriber-${id}`,
firstName: `Test ${id}`,
lastName: 'Subscriber',
email: `test-${id}@subscriber.com`,
phone: '+1234567890',
};

const res = await session.testAgent.post(`/v1/subscribers`).send(payload);
expect(res.status).to.equal(201);

const subscriber = res.body.data;

expect(subscriber.subscriberId).to.equal(payload.subscriberId);
expect(subscriber.firstName).to.equal(payload.firstName);
expect(subscriber.lastName).to.equal(payload.lastName);
expect(subscriber.email).to.equal(payload.email);
expect(subscriber.phone).to.equal(payload.phone);

return subscriber;
}
26 changes: 25 additions & 1 deletion apps/api/src/app/subscribers-v2/subscriber.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ExternalApiAccessible, UserSession } from '@novu/application-generic';
import {
DirectionEnum,
IGetSubscriberPreferencesResponseDto,
IGetSubscriberResponseDto,
IListSubscribersRequestDto,
IListSubscribersResponseDto,
Expand All @@ -26,6 +27,8 @@ import { GetSubscriber } from './usecases/get-subscriber/get-subscriber.usecase'
import { GetSubscriberCommand } from './usecases/get-subscriber/get-subscriber.command';
import { PatchSubscriber } from './usecases/patch-subscriber/patch-subscriber.usecase';
import { PatchSubscriberCommand } from './usecases/patch-subscriber/patch-subscriber.command';
import { GetSubscriberPreferences } from './usecases/get-subscriber-preferences/get-subscriber-preferences.usecase';
import { GetSubscriberPreferencesCommand } from './usecases/get-subscriber-preferences/get-subscriber-preferences.command';

@Controller({ path: '/subscribers', version: '2' })
@UseInterceptors(ClassSerializerInterceptor)
Expand All @@ -35,7 +38,8 @@ export class SubscriberController {
constructor(
private listSubscribersUsecase: ListSubscribersUseCase,
private getSubscriberUsecase: GetSubscriber,
private patchSubscriberUsecase: PatchSubscriber
private patchSubscriberUsecase: PatchSubscriber,
private getSubscriberPreferencesUsecase: GetSubscriberPreferences
) {}

@Get('')
Expand Down Expand Up @@ -101,4 +105,24 @@ export class SubscriberController {
})
);
}

@Get('/:subscriberId/preferences')
@UserAuthentication()
@ExternalApiAccessible()
@ApiOperation({
summary: 'Get subscriber preferences',
description: 'Get subscriber preferences',
})
async getSubscriberPreferences(
@UserSession() user: UserSessionData,
@Param('subscriberId') subscriberId: string
): Promise<IGetSubscriberPreferencesResponseDto> {
return await this.getSubscriberPreferencesUsecase.execute(
GetSubscriberPreferencesCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId,
})
);
}
}
23 changes: 20 additions & 3 deletions apps/api/src/app/subscribers-v2/subscriber.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { Module } from '@nestjs/common';
import { SubscriberRepository } from '@novu/dal';
import { NotificationTemplateRepository, PreferencesRepository, SubscriberRepository } from '@novu/dal';
import {
cacheService,
GetPreferences,
GetSubscriberGlobalPreference,
GetSubscriberPreference,
} from '@novu/application-generic';
import { SubscriberController } from './subscriber.controller';
import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase';
import { GetSubscriber } from './usecases/get-subscriber/get-subscriber.usecase';
import { PatchSubscriber } from './usecases/patch-subscriber/patch-subscriber.usecase';
import { GetSubscriberPreferences } from './usecases/get-subscriber-preferences/get-subscriber-preferences.usecase';

const USE_CASES = [ListSubscribersUseCase, GetSubscriber, PatchSubscriber];
const USE_CASES = [
ListSubscribersUseCase,
GetSubscriber,
PatchSubscriber,
GetSubscriberPreferences,
GetSubscriberGlobalPreference,
GetSubscriberPreference,
GetPreferences,
];

const DAL_MODELS = [SubscriberRepository, NotificationTemplateRepository, PreferencesRepository];

@Module({
controllers: [SubscriberController],
providers: [...USE_CASES, SubscriberRepository],
providers: [...USE_CASES, ...DAL_MODELS, cacheService],
})
export class SubscriberModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command';

export class GetSubscriberPreferencesCommand extends EnvironmentWithSubscriber {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import {
GetSubscriberGlobalPreference,
GetSubscriberGlobalPreferenceCommand,
GetSubscriberPreference,
GetSubscriberPreferenceCommand,
} from '@novu/application-generic';
import { IGetSubscriberPreferencesResponseDto, ISubscriberPreferenceResponse } from '@novu/shared';
import { GetSubscriberPreferencesCommand } from './get-subscriber-preferences.command';

@Injectable()
export class GetSubscriberPreferences {
constructor(
private getSubscriberGlobalPreference: GetSubscriberGlobalPreference,
private getSubscriberPreference: GetSubscriberPreference
) {}

async execute(command: GetSubscriberPreferencesCommand): Promise<IGetSubscriberPreferencesResponseDto> {
const globalPreference = await this.fetchGlobalPreference(command);
const workflowPreferences = await this.fetchWorkflowPreferences(command);

return {
global: globalPreference,
workflows: workflowPreferences,
};
}

private async fetchGlobalPreference(command: GetSubscriberPreferencesCommand) {
const { preference } = await this.getSubscriberGlobalPreference.execute(
GetSubscriberGlobalPreferenceCommand.create({
organizationId: command.organizationId,
environmentId: command.environmentId,
subscriberId: command.subscriberId,
includeInactiveChannels: false,
})
);

return {
enabled: preference.enabled,
channels: preference.channels,
};
}

private async fetchWorkflowPreferences(command: GetSubscriberPreferencesCommand) {
const subscriberWorkflowPreferences = await this.getSubscriberPreference.execute(
GetSubscriberPreferenceCommand.create({
environmentId: command.environmentId,
subscriberId: command.subscriberId,
organizationId: command.organizationId,
includeInactiveChannels: false,
})
);

return subscriberWorkflowPreferences.map(this.mapToWorkflowPreference);
}

private mapToWorkflowPreference(subscriberWorkflowPreference: ISubscriberPreferenceResponse) {
const { preference, template } = subscriberWorkflowPreference;
Copy link
Contributor

Choose a reason for hiding this comment

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

we should have workflow instead of template

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a return type from already existing getSubscriberPreference usecase which is reused in other places across the app.


return {
enabled: preference.enabled,
channels: preference.channels,
overrides: preference.overrides,
workflow: {
identifier: template.triggers[0].identifier,
name: template.name,
},
};
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { SubscriberEntity, SubscriberRepository } from '@novu/dal';

import { IPreferenceChannels, ChannelTypeEnum } from '@novu/shared';
import { GetSubscriberGlobalPreferenceCommand } from './get-subscriber-global-preference.command';
import { buildSubscriberKey, CachedEntity } from '../../services/cache';
import { ApiException } from '../../utils/exceptions';
import { GetPreferences } from '../get-preferences';
import { GetSubscriberPreference } from '../get-subscriber-preference/get-subscriber-preference.usecase';
import { filteredPreference } from '../get-subscriber-template-preference/get-subscriber-template-preference.usecase';
Expand Down Expand Up @@ -111,7 +110,9 @@ export class GetSubscriberGlobalPreference {
);

if (!subscriber) {
throw new ApiException(`Subscriber ${command.subscriberId} not found`);
throw new NotFoundException(
`Subscriber ${command.subscriberId} not found`,
);
}
Comment on lines +113 to +115
Copy link
Contributor

Choose a reason for hiding this comment

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

did you verify that this usecase is not used in worker?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes - from what I see, worker is using just GetSubscriberPreference usecase, but even for that usecase I can't find any use inside worker - I will check if we can remove it possibly in next PR.


return subscriber;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
IPreferenceChannels,
IPreferenceOverride,
} from '../../entities/subscriber-preference/subscriber-preference.interface';

export interface IGetSubscriberPreferencesResponseDto {
global: {
enabled: boolean;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fun fact:
we allow enabled in update DTO, to disable all global preferences as I understand, but we never use it anywhere:

export type UpdateSubscriberGlobalPreferencesRequestDto = {
  /**
   * Enable or disable the subscriber global preferences.
   */
  enabled?: boolean | undefined;
  /**
   * The subscriber global preferences for every ChannelTypeEnum.
   */
  preferences?: Array<ChannelPreference> | undefined;
};

we always return enabled: true so you can't in fact disable all preferences globally.

channels: IPreferenceChannels;
};
workflows: Array<{
enabled: boolean;
channels: IPreferenceChannels;
overrides: IPreferenceOverride[];
workflow: {
identifier: string;
name: string;
};
}>;
}
1 change: 1 addition & 0 deletions packages/shared/src/dto/subscriber/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './subscriber.dto';
export * from './list-subscribers.dto';
export * from './get-subscriber.dto';
export * from './patch-subscriber.dto';
export * from './get-subscriber-preferences.dto';
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { ChannelTypeEnum, PreferenceOverrideSourceEnum, PreferencesTypeEnum } from '../../types';
import { IPreferenceChannelsDto } from '../../dto';
import { INotificationTrigger } from '../notification-trigger';

export interface IPreferenceChannels extends IPreferenceChannelsDto {}
export interface IPreferenceChannels {
email?: boolean;
sms?: boolean;
in_app?: boolean;
chat?: boolean;
push?: boolean;
}

export interface IPreferenceOverride {
channel: ChannelTypeEnum;
Expand All @@ -15,23 +20,7 @@ export interface ISubscriberPreferenceResponse {
type: PreferencesTypeEnum;
}

export interface ISubscriberWorkflowPreferenceResponse extends IPreferenceResponse {
workflow: ITemplateConfiguration;
level: PreferenceLevelEnum.TEMPLATE;
}

export interface IWorkflow extends Omit<ITemplateConfiguration, '_id'> {
id: string;
}
export interface ISubscriberPreferences {
level: PreferenceLevelEnum;
workflow?: IWorkflow;
enabled: boolean;
channels: IPreferenceChannels;
overrides?: IPreferenceOverride[];
}

export interface IPreferenceResponse {
Comment on lines -18 to -34
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not used so I removed it.

interface IPreferenceResponse {
enabled: boolean;
channels: IPreferenceChannels;
overrides: IPreferenceOverride[];
Expand Down
Loading