Skip to content

Commit

Permalink
feat(api-service): add create subscriber functionality with validation (
Browse files Browse the repository at this point in the history
  • Loading branch information
BiswaViraj authored Feb 6, 2025
1 parent d1fdc35 commit e511901
Show file tree
Hide file tree
Showing 33 changed files with 2,231 additions and 88 deletions.
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';
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(
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

0 comments on commit e511901

Please sign in to comment.