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
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[];
}
3 changes: 0 additions & 3 deletions apps/api/src/app/subscribers-v2/dtos/index.ts

This file was deleted.

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;
}
28 changes: 26 additions & 2 deletions apps/api/src/app/subscribers-v2/subscribers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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('')
Expand Down Expand Up @@ -136,4 +140,24 @@ export class SubscribersController {
})
);
}

@Get('/:subscriberId/preferences')
@UserAuthentication()
@ExternalApiAccessible()
@ApiOperation({
summary: 'Get subscriber preferences',
description: 'Get subscriber preferences',
})
async getSubscriberPreferences(
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've named it retrieve, I think thats the convention

@UserSession() user: UserSessionData,
@Param('subscriberId') subscriberId: string
): Promise<GetSubscriberPreferencesResponseDto> {
return await this.getSubscriberPreferencesUsecase.execute(
GetSubscriberPreferencesCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId,
})
);
}
}
40 changes: 33 additions & 7 deletions apps/api/src/app/subscribers-v2/subscribers.module.ts
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],
})
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;
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
Expand Up @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InstrumentUsecase } from '@novu/application-generic';
import { SubscriberRepository } from '@novu/dal';
import { ListSubscribersCommand } from './list-subscribers.command';
import { ListSubscribersResponseDto } from '../../dtos';
import { ListSubscribersResponseDto } from '../../dtos/list-subscribers-response.dto';
import { DirectionEnum } from '../../../shared/dtos/base-responses';
import { mapSubscriberEntityToDto } from './map-subscriber-entity-to.dto';

Expand Down
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
Loading
Loading