-
Notifications
You must be signed in to change notification settings - Fork 4.1k
feat(api-service): get subscriber preferences v2 endpoint #7613
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
Changes from 2 commits
d3ed304
d112829
7c9ed61
518bd9c
35018db
0330809
ad52649
d7aa6de
9cd9e56
ad4666b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'); | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
|
||
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 }); | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 }); | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
} |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a return type from already existing |
||
|
||
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'; | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did you verify that this usecase is not used in worker? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes - from what I see, worker is using just |
||
|
||
return subscriber; | ||
|
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; | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fun fact: 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 Line 110 in 368050c
|
||||
channels: IPreferenceChannels; | ||||
}; | ||||
workflows: Array<{ | ||||
enabled: boolean; | ||||
channels: IPreferenceChannels; | ||||
overrides: IPreferenceOverride[]; | ||||
workflow: { | ||||
identifier: string; | ||||
name: string; | ||||
}; | ||||
}>; | ||||
} |
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; | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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[]; | ||
|
Uh oh!
There was an error while loading. Please reload this page.