-
Notifications
You must be signed in to change notification settings - Fork 4k
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
Changes from all commits
1f4dad0
839580a
b4ae0ff
8a841af
24e48bd
53a5e93
56271bf
d3e1f2d
67fc09e
5ea91e0
7a91a68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
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); | ||
}); | ||
}); |
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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,7 +27,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre | |
}; | ||
|
||
try { | ||
const firstResponse = await novuClient.subscribers.preferences.updateGlobal( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: why do we have different data structure patterns? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @BiswaViraj ☝️ There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
); | ||
|
@@ -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({ | ||
|
@@ -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({ | ||
|
@@ -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 }], | ||
|
@@ -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; | ||
|
||
|
@@ -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; | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👏 Nice and tidy.