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 11 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
2 changes: 1 addition & 1 deletion .source
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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IPreferenceChannels } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsMongoId, IsOptional } from 'class-validator';

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()
@IsMongoId()
workflowId?: string;
}
111 changes: 111 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,111 @@
import { expect } from 'chai';
import { randomBytes } from 'crypto';
import { UserSession } from '@novu/testing';
import { NotificationTemplateEntity } from '@novu/dal';
import { 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 show all available templates in preferences response', async () => {
// Create multiple templates
const workflow2 = await session.createTemplate({ noFeedId: true });
const workflow3 = await session.createTemplate({ noFeedId: true });

const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);

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

expect(workflows).to.have.lengthOf(3); // Should show all available templates
const templateIds = workflows.map((_wf) => _wf.workflow.identifier);
expect(templateIds).to.include(workflow.triggers[0].identifier);
expect(templateIds).to.include(workflow2.triggers[0].identifier);
expect(templateIds).to.include(workflow3.triggers[0].identifier);
});

it('should inherit channel preferences from global settings when no workflow override exists', async () => {
// First set global preferences
await session.testAgent.patch(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`).send({
channels: {
email: false,
in_app: true,
},
});

// Then create a new template
const newWorkflow = await session.createTemplate({ noFeedId: true });

// Check preferences
const response = await session.testAgent.get(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`);

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

const newWorkflowPrefs = workflows.find((_wf) => _wf.workflow.identifier === newWorkflow.triggers[0].identifier);

Check warning on line 84 in apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (Prefs)
// New workflow should inherit global settings
expect(newWorkflowPrefs.channels).to.deep.equal({ email: false, in_app: true });
});
});

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
@@ -0,0 +1,126 @@
import { expect } from 'chai';
import { randomBytes } from 'crypto';
import { UserSession } from '@novu/testing';
import { NotificationTemplateEntity } from '@novu/dal';
import { SubscriberResponseDto } from '@novu/api/models/components';
import { PatchSubscriberPreferencesDto } from '../dtos/patch-subscriber-preferences.dto';

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

describe('Patch Subscriber Preferences - /subscribers/:subscriberId/preferences (PATCH) #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 patch workflow channel preferences', async () => {
const workflowId = workflow._id;
const patchData: PatchSubscriberPreferencesDto = {
channels: {
email: false,
in_app: true,
},
workflowId,
};

const response = await session.testAgent
.patch(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`)
.send(patchData);

expect(response.statusCode).to.equal(200);
expect(response.body.data).to.have.property('workflows');

const { global, workflows } = response.body.data;

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 patch global channel preferences', async () => {
const patchData: PatchSubscriberPreferencesDto = {
channels: {
email: false,
in_app: false,
},
};

const response = await session.testAgent
.patch(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`)
.send(patchData);

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;

expect(global.channels).to.deep.equal({ in_app: false, email: false });
expect(workflows).to.have.lengthOf(1);
expect(workflows[0].channels).to.deep.equal({ in_app: false, email: false });
expect(workflows[0].workflow).to.deep.equal({ 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 response = await session.testAgent
.patch(`${v2Prefix}/subscribers/${invalidSubscriberId}/preferences`)
.send(patchData);

expect(response.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',
};

const response = await session.testAgent
.patch(`${v2Prefix}/subscribers/${subscriber.subscriberId}/preferences`)
.send(patchData);

expect(response.statusCode).to.equal(400);
});
});

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;
}
Loading
Loading