-
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 5 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,40 @@ | ||
import { ApiProperty } from '@nestjs/swagger'; | ||
import { IPreferenceChannels, IPreferenceOverride } from '@novu/shared'; | ||
|
||
class WorkflowInfoDto { | ||
@ApiProperty({ description: 'Unique identifier of the workflow' }) | ||
identifier: string; | ||
|
||
@ApiProperty({ description: 'Display name of the workflow' }) | ||
name: string; | ||
} | ||
|
||
class GlobalPreferenceDto { | ||
@ApiProperty({ description: 'Whether notifications are enabled globally' }) | ||
enabled: boolean; | ||
|
||
@ApiProperty({ description: 'Channel-specific preference settings' }) | ||
channels: IPreferenceChannels; | ||
} | ||
|
||
class WorkflowPreferenceDto { | ||
@ApiProperty({ description: 'Whether notifications are enabled for this workflow' }) | ||
enabled: boolean; | ||
|
||
@ApiProperty({ description: 'Channel-specific preference settings for this workflow' }) | ||
channels: IPreferenceChannels; | ||
|
||
@ApiProperty({ description: 'List of preference overrides', isArray: true }) | ||
overrides: IPreferenceOverride[]; | ||
|
||
@ApiProperty({ description: 'Workflow information' }) | ||
workflow: WorkflowInfoDto; | ||
} | ||
|
||
export class GetSubscriberPreferencesResponseDto { | ||
@ApiProperty({ description: 'Global preference settings', type: GlobalPreferenceDto }) | ||
global: GlobalPreferenceDto; | ||
|
||
@ApiProperty({ description: 'Workflow-specific preference settings', type: [WorkflowPreferenceDto] }) | ||
workflows: WorkflowPreferenceDto[]; | ||
} |
This file was deleted.
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 |
---|---|---|
|
@@ -20,15 +20,18 @@ 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'; | ||
import { ListSubscribersQueryDto } from './dtos/list-subscribers-query.dto'; | ||
import { ListSubscribersResponseDto } from './dtos'; | ||
import { ListSubscribersResponseDto } from './dtos/list-subscribers-response.dto'; | ||
import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; | ||
import { DirectionEnum } from '../shared/dtos/base-responses'; | ||
import { PatchSubscriberRequestDto } from './dtos/patch-subscriber.dto'; | ||
import { SubscriberResponseDto } from '../subscribers/dtos'; | ||
import { RemoveSubscriberCommand } from './usecases/remove-subscriber/remove-subscriber.command'; | ||
import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase'; | ||
import { RemoveSubscriberResponseDto } from './dtos/remove-subscriber.dto'; | ||
import { GetSubscriberPreferencesResponseDto } from './dtos/get-subscriber-preferences.dto'; | ||
|
||
@Controller({ path: '/subscribers', version: '2' }) | ||
@UseInterceptors(ClassSerializerInterceptor) | ||
|
@@ -40,7 +43,8 @@ export class SubscribersController { | |
private listSubscribersUsecase: ListSubscribersUseCase, | ||
private getSubscriberUsecase: GetSubscriber, | ||
private patchSubscriberUsecase: PatchSubscriber, | ||
private removeSubscriberUsecase: RemoveSubscriber | ||
private removeSubscriberUsecase: RemoveSubscriber, | ||
private getSubscriberPreferencesUsecase: GetSubscriberPreferences | ||
) {} | ||
|
||
@Get('') | ||
|
@@ -136,4 +140,24 @@ export class SubscribersController { | |
}) | ||
); | ||
} | ||
|
||
@Get('/:subscriberId/preferences') | ||
@UserAuthentication() | ||
@ExternalApiAccessible() | ||
@ApiOperation({ | ||
summary: 'Get subscriber preferences', | ||
description: 'Get subscriber preferences', | ||
}) | ||
async getSubscriberPreferences( | ||
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. Do we want also to define a custom sdk method name? I'm wondering how it will name it by default. 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. I've named it |
||
@UserSession() user: UserSessionData, | ||
@Param('subscriberId') subscriberId: string | ||
): Promise<GetSubscriberPreferencesResponseDto> { | ||
return await this.getSubscriberPreferencesUsecase.execute( | ||
GetSubscriberPreferencesCommand.create({ | ||
environmentId: user.environmentId, | ||
organizationId: user.organizationId, | ||
subscriberId, | ||
}) | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,18 +1,44 @@ | ||
import { Module } from '@nestjs/common'; | ||
import { SubscriberRepository } from '@novu/dal'; | ||
import { SubscribersController } from './subscribers.controller'; | ||
import { | ||
NotificationTemplateRepository, | ||
PreferencesRepository, | ||
SubscriberRepository, | ||
TopicSubscribersRepository, | ||
} from '@novu/dal'; | ||
import { | ||
cacheService, | ||
GetPreferences, | ||
GetSubscriberGlobalPreference, | ||
GetSubscriberPreference, | ||
InvalidateCacheService, | ||
} from '@novu/application-generic'; | ||
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'; | ||
import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase'; | ||
import { SharedModule } from '../shared/shared.module'; | ||
import { PreferencesModule } from '../preferences/preferences.module'; | ||
import { SubscribersController } from './subscribers.controller'; | ||
|
||
const USE_CASES = [ | ||
ListSubscribersUseCase, | ||
GetSubscriber, | ||
PatchSubscriber, | ||
RemoveSubscriber, | ||
GetSubscriberPreferences, | ||
GetSubscriberGlobalPreference, | ||
GetSubscriberPreference, | ||
GetPreferences, | ||
]; | ||
|
||
const USE_CASES = [ListSubscribersUseCase, GetSubscriber, PatchSubscriber, RemoveSubscriber]; | ||
const DAL_MODELS = [ | ||
SubscriberRepository, | ||
NotificationTemplateRepository, | ||
PreferencesRepository, | ||
TopicSubscribersRepository, | ||
]; | ||
|
||
@Module({ | ||
controllers: [SubscribersController], | ||
imports: [SharedModule, PreferencesModule], | ||
providers: [...USE_CASES, SubscriberRepository], | ||
providers: [...USE_CASES, ...DAL_MODELS, cacheService, InvalidateCacheService], | ||
ChmaraX marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}) | ||
export class SubscribersModule {} |
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,71 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { | ||
GetSubscriberGlobalPreference, | ||
GetSubscriberGlobalPreferenceCommand, | ||
GetSubscriberPreference, | ||
GetSubscriberPreferenceCommand, | ||
} from '@novu/application-generic'; | ||
import { ISubscriberPreferenceResponse } from '@novu/shared'; | ||
import { GetSubscriberPreferencesCommand } from './get-subscriber-preferences.command'; | ||
import { GetSubscriberPreferencesResponseDto } from '../../dtos/get-subscriber-preferences.dto'; | ||
|
||
@Injectable() | ||
export class GetSubscriberPreferences { | ||
constructor( | ||
private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, | ||
private getSubscriberPreference: GetSubscriberPreference | ||
) {} | ||
|
||
async execute(command: GetSubscriberPreferencesCommand): Promise<GetSubscriberPreferencesResponseDto> { | ||
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; | ||
|
Uh oh!
There was an error while loading. Please reload this page.