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): patch subscriber preferences v2 endpoint #7629

Merged
merged 22 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d3ed304
feat(api-service): subscriber preferences WIP
ChmaraX Jan 29, 2025
d112829
feat(api-service): add e2e tests
ChmaraX Jan 30, 2025
7c9ed61
Merge branch 'next' into nv-5278-api-get-preferences-endpoint
ChmaraX Jan 30, 2025
518bd9c
feat(api-service): merge with upstream
ChmaraX Jan 30, 2025
35018db
fix(api-service): import
ChmaraX Jan 30, 2025
0330809
fix(api-service): dtos and types
ChmaraX Jan 30, 2025
ad52649
feat(api-service): serialzie response
ChmaraX Jan 30, 2025
184f820
feat(api-service): Add clerk analytical hooks for memberships (#7614)
scopsy Jan 30, 2025
63b64e7
fix(api): Exclude support controller from OpenAPI
SokratisVidros Jan 30, 2025
c32f4b4
feat(api-service): patch subscriber preferences v2 endpoint
ChmaraX Jan 30, 2025
f82dd3b
feat(api-service): patch subscriber preferences v2 endpoint
ChmaraX Jan 30, 2025
1e2fbae
chore: spellcheck
ChmaraX Jan 30, 2025
d7aa6de
feat(api-service): add SDK retrieve methods
ChmaraX Jan 31, 2025
9cd9e56
feat(api-service): speakeasy SDKs
ChmaraX Jan 31, 2025
3b23894
Merge branch 'nv-5278-api-get-preferences-endpoint' into nv-5279-api-…
ChmaraX Jan 31, 2025
1a5f299
feat(api-service): add SDK methods
ChmaraX Jan 31, 2025
7209264
feat(api-service): add SDK methods
ChmaraX Jan 31, 2025
8106df7
feat(api-service): replace legacy update method
ChmaraX Jan 31, 2025
ad4666b
fix: reuse dtos
ChmaraX Jan 31, 2025
84f4b0f
Merge branch 'nv-5278-api-get-preferences-endpoint' into nv-5279-api-…
ChmaraX Jan 31, 2025
f8b681a
Merge branch 'next' into nv-5279-api-patchput-preferences-endpoint
ChmaraX Jan 31, 2025
993fa2f
feat(api-service): add slug identifier
ChmaraX Jan 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { PreferenceChannels } from '../../shared/dtos/preference-channels';
import { Overrides } from '../../subscribers/dtos/get-subscriber-preferences-response.dto';

export class WorkflowInfoDto {
@ApiProperty({ description: 'Workflow slug' })
slug: string;

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { IPreferenceChannels } from '@novu/shared';
import { Type, Transform } from 'class-transformer';
import { IsMongoId, IsOptional } from 'class-validator';
import { parseSlugId } from '../../workflows-v2/pipes/parse-slug-id';

export class PatchPreferenceChannelsDto 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 PatchSubscriberPreferencesDto {
@ApiProperty({ description: 'Channel-specific preference settings', type: PatchPreferenceChannelsDto })
@Type(() => PatchPreferenceChannelsDto)
channels: PatchPreferenceChannelsDto;

@ApiProperty({
description: 'If provided, update workflow specific preferences, otherwise update global preferences',
Copy link
Contributor

Choose a reason for hiding this comment

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

👏

required: false,
})
@IsOptional()
@Transform(({ value }) => parseSlugId(value))
@IsMongoId()
workflowId?: string;
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
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';
import { SubscriberResponseDto } from '@novu/api/models/components';
import { Novu } from '@novu/api';
import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';

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

describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (GET) #novu-v2', () => {
Expand All @@ -33,17 +27,9 @@ describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (G
it('should fetch subscriber preferences with default values', async () => {
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);

expect(response.result).to.have.property('global');
expect(response.result).to.have.property('workflows');

const { global, workflows } = response.result;

// 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');
expect(workflows).to.have.lengthOf(1);
});
Expand All @@ -57,61 +43,47 @@ describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (G
expect(error?.statusCode).to.equal(404);
});

it('should handle subscriber with modified workflow preferences', async () => {
Copy link
Contributor Author

@ChmaraX ChmaraX Jan 30, 2025

Choose a reason for hiding this comment

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

I removed GET&PATCH overlapping tests from GET endpoint and replaced them with tests testing unique GET behaviour

// 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,
},
};
it('should show all available workflowsin preferences response', async () => {
// Create multiple templates
const workflow2 = await session.createTemplate({ noFeedId: true });
const workflow3 = await session.createTemplate({ noFeedId: true });

// 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;
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);

expect(response.statusCode).to.equal(200);
const { workflows } = response.result;

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 });
expect(workflows).to.have.lengthOf(3); // Should show all available workflows
const workflowIdentifiers = workflows.map((_wf) => _wf.workflow.identifier);
expect(workflowIdentifiers).to.include(workflow.triggers[0].identifier);
expect(workflowIdentifiers).to.include(workflow2.triggers[0].identifier);
expect(workflowIdentifiers).to.include(workflow3.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,
it('should inherit channel preferences from global settings when no workflow override exists', async () => {
// First set global preferences
await novuClient.subscribers.preferences.update(
{
channels: {
email: false,
inApp: true,
},
],
};

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

const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);
// Then create a new template
const newWorkflow = await session.createTemplate({ noFeedId: true });

const { global, workflows } = response.body.data;
// Check preferences
const response = await novuClient.subscribers.preferences.retrieve(subscriber.subscriberId);

expect(response.statusCode).to.equal(200);
const { workflows } = response.result;

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 });
const newWorkflowPreferences = workflows.find(
(_wf) => _wf.workflow.identifier === newWorkflow.triggers[0].identifier
);
// New workflow should inherit global settings
expect(newWorkflowPreferences?.channels).to.deep.equal({ email: false, inApp: true });
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { expect } from 'chai';
import { randomBytes } from 'crypto';
import { UserSession } from '@novu/testing';
import { NotificationTemplateEntity } from '@novu/dal';
import { SubscriberResponseDto, PatchSubscriberPreferencesDto } from '@novu/api/models/components';
import { Novu } from '@novu/api';
import {
expectSdkExceptionGeneric,
expectSdkValidationExceptionGeneric,
initNovuClassSdk,
} from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';

let session: UserSession;

describe('Patch Subscriber Preferences - /subscribers/:subscriberId/preferences (PATCH) #novu-v2', () => {
let novuClient: Novu;
let subscriber: SubscriberResponseDto;
let workflow: NotificationTemplateEntity;

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

it('should patch workflow channel preferences', async () => {
const workflowId = workflow._id;

const patchData: PatchSubscriberPreferencesDto = {
channels: {
email: false,
inApp: true,
},
workflowId,
};

const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId);

const { global, workflows } = response.result;

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

it('should patch global channel preferences', async () => {
const patchData: PatchSubscriberPreferencesDto = {
channels: {
email: false,
inApp: false,
},
};

const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId);

const { global, workflows } = response.result;

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

it('should return 404 when patching non-existent subscriber preferences', async () => {
const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`;
const patchData: PatchSubscriberPreferencesDto = {
channels: {
email: false,
},
};

const { error } = await expectSdkExceptionGeneric(() =>
novuClient.subscribers.preferences.update(patchData, invalidSubscriberId)
);

expect(error?.statusCode).to.equal(404);
});

it('should return 400 when patching with invalid workflow id', async () => {
const patchData: PatchSubscriberPreferencesDto = {
channels: {
email: false,
},
workflowId: 'invalid-workflow-id',
};

try {
await expectSdkValidationExceptionGeneric(() =>
novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId)
);
} catch (e) {
// TODO: fix in SDK util
expect(e).to.be.an.instanceOf(Error);
}
});
});

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;
}
34 changes: 32 additions & 2 deletions apps/api/src/app/subscribers-v2/subscribers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import { RemoveSubscriberCommand } from './usecases/remove-subscriber/remove-sub
import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase';
import { RemoveSubscriberResponseDto } from './dtos/remove-subscriber.dto';
import { GetSubscriberPreferencesDto } from './dtos/get-subscriber-preferences.dto';
import { PatchSubscriberPreferencesDto } from './dtos/patch-subscriber-preferences.dto';
import { UpdateSubscriberPreferencesCommand } from './usecases/update-subscriber-preferences/update-subscriber-preferences.command';
import { UpdateSubscriberPreferences } from './usecases/update-subscriber-preferences/update-subscriber-preferences.usecase';

@Controller({ path: '/subscribers', version: '2' })
@UseInterceptors(ClassSerializerInterceptor)
Expand All @@ -44,7 +47,8 @@ export class SubscribersController {
private getSubscriberUsecase: GetSubscriber,
private patchSubscriberUsecase: PatchSubscriber,
private removeSubscriberUsecase: RemoveSubscriber,
private getSubscriberPreferencesUsecase: GetSubscriberPreferences
private getSubscriberPreferencesUsecase: GetSubscriberPreferences,
private updateSubscriberPreferencesUsecase: UpdateSubscriberPreferences
) {}

@Get('')
Expand Down Expand Up @@ -146,7 +150,7 @@ export class SubscribersController {
@ExternalApiAccessible()
@ApiOperation({
summary: 'Get subscriber preferences',
description: 'Get subscriber preferences',
description: 'Get subscriber global and workflow specific preferences',
})
@ApiResponse(GetSubscriberPreferencesDto)
@SdkGroupName('Subscribers.Preferences')
Expand All @@ -163,4 +167,30 @@ export class SubscribersController {
})
);
}

@Patch('/:subscriberId/preferences')
@UserAuthentication()
@ExternalApiAccessible()
@ApiOperation({
summary: 'Update subscriber global or workflow specific preferences',
description: 'Update subscriber global or workflow specific preferences',
})
@ApiResponse(GetSubscriberPreferencesDto)
@SdkGroupName('Subscribers.Preferences')
@SdkMethodName('update')
async updateSubscriberPreferences(
@UserSession() user: UserSessionData,
@Param('subscriberId') subscriberId: string,
@Body() body: PatchSubscriberPreferencesDto
): Promise<GetSubscriberPreferencesDto> {
return await this.updateSubscriberPreferencesUsecase.execute(
UpdateSubscriberPreferencesCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId,
workflowId: body.workflowId,
channels: body.channels,
})
);
}
}
Loading
Loading