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,80 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChannelTypeEnum, IPreferenceChannels, IPreferenceOverride, PreferenceOverrideSourceEnum } from '@novu/shared';
import { Type } from 'class-transformer';

export class PreferenceChannelsDto implements IPreferenceChannels {
@ApiProperty({ description: 'Email channel preference' })
email?: boolean;

@ApiProperty({ description: 'SMS channel preference' })
sms?: boolean;

@ApiProperty({ description: 'In-app channel preference' })
in_app?: boolean;

@ApiProperty({ description: 'Push channel preference' })
push?: boolean;

@ApiProperty({ description: 'Chat channel preference' })
chat?: boolean;
}

export class PreferenceOverride implements IPreferenceOverride {
@ApiProperty({
enum: ChannelTypeEnum,
enumName: 'ChannelTypeEnum',
description: 'The channel type for the override',
})
channel: ChannelTypeEnum;

@ApiProperty({
enum: PreferenceOverrideSourceEnum,
enumName: 'PreferenceOverrideSourceEnum',
description: 'The source of the override',
})
source: PreferenceOverrideSourceEnum;
}

export class WorkflowInfoDto {
@ApiProperty({ description: 'Unique identifier of the workflow' })
identifier: string;

@ApiProperty({ description: 'Display name of the workflow' })
name: string;
}

export class GlobalPreferenceDto {
@ApiProperty({ description: 'Whether notifications are enabled globally' })
enabled: boolean;

@ApiProperty({ description: 'Channel-specific preference settings', type: PreferenceChannelsDto })
@Type(() => PreferenceChannelsDto)
channels: PreferenceChannelsDto;
}

export class WorkflowPreferenceDto {
@ApiProperty({ description: 'Whether notifications are enabled for this workflow' })
enabled: boolean;

@ApiProperty({ description: 'Channel-specific preference settings for this workflow', type: PreferenceChannelsDto })
@Type(() => PreferenceChannelsDto)
channels: PreferenceChannelsDto;

@ApiProperty({ description: 'List of preference overrides', type: [PreferenceOverride] })
@Type(() => PreferenceOverride)
overrides: PreferenceOverride[];

@ApiProperty({ description: 'Workflow information', type: WorkflowInfoDto })
@Type(() => WorkflowInfoDto)
workflow: WorkflowInfoDto;
}

export class GetSubscriberPreferencesDto {
@ApiProperty({ description: 'Global preference settings', type: GlobalPreferenceDto })
@Type(() => GlobalPreferenceDto)
global: GlobalPreferenceDto;

@ApiProperty({ description: 'Workflow-specific preference settings', type: [WorkflowPreferenceDto] })
@Type(() => WorkflowPreferenceDto)
workflows: WorkflowPreferenceDto[];
}
3 changes: 0 additions & 3 deletions apps/api/src/app/subscribers-v2/dtos/index.ts

This file was deleted.

133 changes: 133 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,133 @@
import { expect } from 'chai';
import { randomBytes } from 'crypto';
import { UserSession } from '@novu/testing';
import { ChannelTypeEnum } from '@novu/shared';
import { NotificationTemplateEntity } from '@novu/dal';
import {
UpdateSubscriberGlobalPreferencesRequestDto,
UpdateSubscriberPreferenceRequestDto,
SubscriberResponseDto,
} from '@novu/api/models/components';

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

describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (GET) #novu-v2', () => {
let subscriber: SubscriberResponseDto;
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 { GetSubscriberPreferencesDto } 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<GetSubscriberPreferencesDto> {
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 {}
Loading
Loading