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): add create subscriber functionality with validation #7658

Merged
merged 11 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
91 changes: 91 additions & 0 deletions apps/api/src/app/subscribers-v2/dtos/create-subscriber.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
Copy link
Contributor

Choose a reason for hiding this comment

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

👏 Nice and tidy.

import { IsEmail, IsLocale, IsObject, IsOptional, IsString, IsTimeZone, ValidateIf } from 'class-validator';

export class CreateSubscriberRequestDto {
@ApiProperty({
type: String,
description: 'Unique identifier of the subscriber',
})
@IsString()
subscriberId: string;

@ApiPropertyOptional({
type: String,
description: 'First name of the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.firstName !== null)
@IsString()
firstName?: string | null;

@ApiPropertyOptional({
type: String,
description: 'Last name of the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.lastName !== null)
@IsString()
lastName?: string | null;

@ApiPropertyOptional({
type: String,
description: 'Email address of the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.email !== null)
@IsEmail()
email?: string | null;

@ApiPropertyOptional({
type: String,
description: 'Phone number of the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.phone !== null)
@IsString()
phone?: string | null;

@ApiPropertyOptional({
type: String,
description: 'Avatar URL or identifier',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.avatar !== null)
@IsString()
avatar?: string | null;

@ApiPropertyOptional({
type: String,
description: 'Timezone of the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.timezone !== null)
@IsTimeZone()
timezone?: string | null;

@ApiPropertyOptional({
type: String,
description: 'Locale of the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.locale !== null)
@IsLocale()
locale?: string | null;

@ApiPropertyOptional({
type: Object,
description: 'Additional custom data for the subscriber',
nullable: true,
})
@IsOptional()
@ValidateIf((obj) => obj.data !== null)
@IsObject()
data?: Record<string, unknown> | null;
}
57 changes: 57 additions & 0 deletions apps/api/src/app/subscribers-v2/e2e/create-subscriber.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { expect } from 'chai';
import { Novu } from '@novu/api';
import { UserSession } from '@novu/testing';
import { randomBytes } from 'crypto';
import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';

let session: UserSession;

describe('Create Subscriber - /subscribers (POST) #novu-v2', () => {
let novuClient: Novu;

beforeEach(async () => {
session = new UserSession();
await session.initialize();
novuClient = initNovuClassSdk(session);
});

it('should create the subscriber', async () => {
const subscriberId = `test-subscriber-${`${randomBytes(4).toString('hex')}`}`;
const payload = {
subscriberId,
firstName: 'First Name',
lastName: 'Last Name',
locale: 'en_US',
timezone: 'America/New_York',
};

const res = await novuClient.subscribers.create(payload, payload.subscriberId);

const subscriber = res.result;

expect(subscriber.subscriberId).to.equal(payload.subscriberId);
expect(subscriber.firstName).to.equal(payload.firstName);
expect(subscriber.lastName).to.equal(payload.lastName);
expect(subscriber.locale).to.equal(payload.locale);
expect(subscriber.timezone).to.equal(payload.timezone);
});

it('should return 409 if subscriberId already exists', async () => {
const subscriberId = `test-subscriber-${`${randomBytes(4).toString('hex')}`}`;
const payload = {
subscriberId,
firstName: 'First Name',
lastName: 'Last Name',
locale: 'en_US',
timezone: 'America/New_York',
};

await novuClient.subscribers.create(payload, payload.subscriberId);

const { error } = await expectSdkExceptionGeneric(() =>
novuClient.subscribers.create(payload, payload.subscriberId)
);

expect(error?.statusCode).to.equal(409);
});
});
30 changes: 28 additions & 2 deletions apps/api/src/app/subscribers-v2/subscribers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Get,
Param,
Patch,
Post,
Query,
UseInterceptors,
} from '@nestjs/common';
Expand All @@ -27,7 +28,7 @@ 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 { CreateSubscriberRequestDto, 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';
Expand All @@ -36,6 +37,8 @@ import { PatchSubscriberPreferencesDto } from './dtos/patch-subscriber-preferenc
import { UpdateSubscriberPreferencesCommand } from './usecases/update-subscriber-preferences/update-subscriber-preferences.command';
import { UpdateSubscriberPreferences } from './usecases/update-subscriber-preferences/update-subscriber-preferences.usecase';
import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator';
import { CreateSubscriber } from './usecases/create-subscriber/create-subscriber.usecase';
import { CreateSubscriberCommand } from './usecases/create-subscriber/create-subscriber.command';

@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION)
@Controller({ path: '/subscribers', version: '2' })
Expand All @@ -50,7 +53,8 @@ export class SubscribersController {
private patchSubscriberUsecase: PatchSubscriber,
private removeSubscriberUsecase: RemoveSubscriber,
private getSubscriberPreferencesUsecase: GetSubscriberPreferences,
private updateSubscriberPreferencesUsecase: UpdateSubscriberPreferences
private updateSubscriberPreferencesUsecase: UpdateSubscriberPreferences,
private createSubscriberUsecase: CreateSubscriber
) {}

@Get('')
Expand Down Expand Up @@ -101,6 +105,28 @@ export class SubscribersController {
);
}

@Post('')
@UserAuthentication()
@ExternalApiAccessible()
@ApiOperation({
summary: 'Create subscriber',
description: 'Create subscriber with the given data',
})
@ApiResponse(SubscriberResponseDto)
@SdkMethodName('create')
async createSubscriber(
@UserSession() user: UserSessionData,
@Body() body: CreateSubscriberRequestDto
): Promise<SubscriberResponseDto> {
return await this.createSubscriberUsecase.execute(
CreateSubscriberCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
createSubscriberRequestDto: body,
})
);
}

@Patch('/:subscriberId')
@UserAuthentication()
@ExternalApiAccessible()
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app/subscribers-v2/subscribers.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { UpdateSubscriberPreferences } from './usecases/update-subscriber-prefer
import { UpdatePreferences } from '../inbox/usecases/update-preferences/update-preferences.usecase';
import { GetSubscriberGlobalPreference } from '../subscribers/usecases/get-subscriber-global-preference';
import { GetSubscriberPreference } from '../subscribers/usecases/get-subscriber-preference';
import { CreateSubscriber } from './usecases/create-subscriber/create-subscriber.usecase';

const USE_CASES = [
ListSubscribersUseCase,
Expand All @@ -40,6 +41,7 @@ const USE_CASES = [
UpdatePreferences,
GetSubscriberTemplatePreference,
UpsertPreferences,
CreateSubscriber,
];

const DAL_MODELS = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
import { CreateSubscriberRequestDto } from '../../dtos/create-subscriber.dto';

export class CreateSubscriberCommand extends EnvironmentCommand {
@ValidateNested()
@Type(() => CreateSubscriberRequestDto)
createSubscriberRequestDto: CreateSubscriberRequestDto;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { SubscriberRepository } from '@novu/dal';
import { SubscriberResponseDto } from '../../../subscribers/dtos';
import { mapSubscriberEntityToDto } from '../list-subscribers/map-subscriber-entity-to.dto';
import { CreateSubscriberCommand } from './create-subscriber.command';

@Injectable()
export class CreateSubscriber {
constructor(private subscriberRepository: SubscriberRepository) {}

async execute(command: CreateSubscriberCommand): Promise<SubscriberResponseDto> {
const existingSubscriber = await this.subscriberRepository.findOne({
subscriberId: command.createSubscriberRequestDto.subscriberId,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
});

if (existingSubscriber) {
throw new ConflictException(`Subscriber: ${command.createSubscriberRequestDto.subscriberId} already exists`);
}

const createdSubscriber = await this.subscriberRepository.create({
...command.createSubscriberRequestDto,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
});

return mapSubscriberEntityToDto(createdSubscriber);
}
}
14 changes: 7 additions & 7 deletions apps/api/src/app/subscribers/e2e/get-preferences.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('Get Subscribers workflow preferences - /subscribers/:subscriberId/pref
});

it('should get subscriber workflow preferences with inactive channels by default', async function () {
const response = await novuClient.subscribers.preferences.list(session.subscriberId);
const response = await novuClient.subscribers.preferences.listLegacy(session.subscriberId);
const data = response.result[0];

expect(data.preference.channels).to.deep.equal({
Expand All @@ -34,7 +34,7 @@ describe('Get Subscribers workflow preferences - /subscribers/:subscriberId/pref
});

it('should get subscriber workflow preferences with inactive channels when includeInactiveChannels is true', async function () {
const response = await novuClient.subscribers.preferences.list(session.subscriberId, true);
const response = await novuClient.subscribers.preferences.listLegacy(session.subscriberId, true);
const data = response.result[0];

expect(data.preference.channels).to.deep.equal({
Expand All @@ -47,7 +47,7 @@ describe('Get Subscribers workflow preferences - /subscribers/:subscriberId/pref
});

it('should get subscriber workflow preferences with active channels when includeInactiveChannels is false', async function () {
const response = await novuClient.subscribers.preferences.list(session.subscriberId, false);
const response = await novuClient.subscribers.preferences.listLegacy(session.subscriberId, false);
const data = response.result[0];

expect(data.preference.channels).to.deep.equal({
Expand All @@ -58,7 +58,7 @@ describe('Get Subscribers workflow preferences - /subscribers/:subscriberId/pref

it('should handle un existing subscriberId', async function () {
const { error } = await expectSdkExceptionGeneric(() =>
novuClient.subscribers.preferences.list('unexisting-subscriber-id')
novuClient.subscribers.preferences.listLegacy('unexisting-subscriber-id')
);
expect(error).to.be.ok;
expect(error?.message).to.contain('not found');
Expand All @@ -83,7 +83,7 @@ describe('Get Subscribers preferences by level - /subscribers/:subscriberId/pref

levels.forEach((level) => {
it(`should get subscriber ${level} preferences with inactive channels by default`, async function () {
const response = await novuClient.subscribers.preferences.retrieveByLevel({
const response = await novuClient.subscribers.preferences.retrieveByLevelLegacy({
preferenceLevel: level,
subscriberId: session.subscriberId,
});
Expand All @@ -99,7 +99,7 @@ describe('Get Subscribers preferences by level - /subscribers/:subscriberId/pref
});

it(`should get subscriber ${level} preferences with inactive channels when includeInactiveChannels is true`, async function () {
const response = await novuClient.subscribers.preferences.retrieveByLevel({
const response = await novuClient.subscribers.preferences.retrieveByLevelLegacy({
preferenceLevel: level,
subscriberId: session.subscriberId,
includeInactiveChannels: {
Expand All @@ -118,7 +118,7 @@ describe('Get Subscribers preferences by level - /subscribers/:subscriberId/pref
});

it(`should get subscriber ${level} preferences with active channels when includeInactiveChannels is false`, async function () {
const response = await novuClient.subscribers.preferences.retrieveByLevel({
const response = await novuClient.subscribers.preferences.retrieveByLevelLegacy({
preferenceLevel: level,
subscriberId: session.subscriberId,
includeInactiveChannels: {
Expand Down
14 changes: 8 additions & 6 deletions apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre
};

try {
const firstResponse = await novuClient.subscribers.preferences.updateGlobal(
Copy link
Contributor

Choose a reason for hiding this comment

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

question: why do we have different data structure patterns?
above we have listLegacy and retrieveByLevelLegacy with suffix legacy but here we have nested object of legacy.

Copy link
Contributor

Choose a reason for hiding this comment

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

@BiswaViraj ☝️

Copy link
Member Author

Choose a reason for hiding this comment

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

I am not sure about that, it was already present before, after rebase the tests were failing so i just updated the e2e tests to use the available methods

const firstResponse = await novuClient.subscribers.preferences.legacy.updateGlobal(
badPayload as any,
session.subscriberId
);
Expand All @@ -43,7 +43,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre
preferences: [{ type: ChannelTypeEnum.Email, enabled: true }],
};

const response = await novuClient.subscribers.preferences.updateGlobal(payload, session.subscriberId);
const response = await novuClient.subscribers.preferences.legacy.updateGlobal(payload, session.subscriberId);

expect(response.result.preference.enabled).to.eql(true);
expect(response.result.preference.channels).to.not.eql({
Expand All @@ -68,7 +68,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre
],
};

const response = await novuClient.subscribers.preferences.updateGlobal(payload, session.subscriberId);
const response = await novuClient.subscribers.preferences.legacy.updateGlobal(payload, session.subscriberId);

expect(response.result.preference.enabled).to.eql(true);
expect(response.result.preference.channels).to.deep.eq({
Expand Down Expand Up @@ -111,7 +111,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre

await session.switchToDevEnvironment();
// update the subscriber global preferences in dev environment
const response = await novuClient.subscribers.preferences.updateGlobal(
const response = await novuClient.subscribers.preferences.legacy.updateGlobal(
{
enabled: true,
preferences: [{ type: ChannelTypeEnumInShared.IN_APP, enabled: false }],
Expand All @@ -129,7 +129,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre
} as PreferenceChannels);

// get the subscriber preferences in dev environment
const getDevPreferencesResponse = await novuClient.subscribers.preferences.list(session.subscriberId);
const getDevPreferencesResponse = await novuClient.subscribers.preferences.listLegacy(session.subscriberId);
const devPreferences = getDevPreferencesResponse.result;
expect(devPreferences.every((item) => !!item.preference.channels.inApp)).to.be.false;

Expand All @@ -138,7 +138,9 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre
// get the subscriber preferences in prod environment
session.apiKey = session.environment.apiKeys[0].key;
const novuClientForProduction = initNovuClassSdk(session);
const getProdPreferencesResponse = await novuClientForProduction.subscribers.preferences.list(session.subscriberId);
const getProdPreferencesResponse = await novuClientForProduction.subscribers.preferences.listLegacy(
session.subscriberId
);
const prodPreferences = getProdPreferencesResponse.result;
expect(prodPreferences.every((item) => !!item.preference.channels.inApp)).to.be.true;
});
Expand Down
Loading
Loading