Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
61de316
feat(react): add firstName, lastName, and email fields to session and…
djabarovgeorge Apr 16, 2025
f250438
Merge branch 'next' into nv-5683-support-of-upserting-subscriber-attr…
djabarovgeorge Apr 20, 2025
b8ba213
feat(api): add HMAC validation and allowUpdate flag for subscriber cr…
djabarovgeorge Apr 22, 2025
30c5e16
refactor(api): wip consolidate subscriber attributes into a single ob…
djabarovgeorge Apr 22, 2025
5cae6e1
feat(api): enhance subscriber handling with new DTO and validation fo…
djabarovgeorge Apr 22, 2025
4889a70
feat(api): extend subscriber session with additional attributes inclu…
djabarovgeorge Apr 22, 2025
a09d3fe
refactor(api): streamline subscriber update logic and remove redundan…
djabarovgeorge Apr 22, 2025
3ac2464
refactor(api): update subscriber DTO to enforce subscriberId and stre…
djabarovgeorge Apr 23, 2025
2e6151e
refactor(api): simplify subscriber DTO handling in inbox controller b…
djabarovgeorge Apr 23, 2025
44e29cf
refactor(api): enhance subscriber DTO handling in inbox controller wi…
djabarovgeorge Apr 23, 2025
329ffcd
refactor(api): unify subscriber handling across components with impro…
djabarovgeorge Apr 24, 2025
59aebd5
refactor(api): add backward compatibility support for subscriberId in…
djabarovgeorge Apr 24, 2025
490e471
refactor(api): replace SubscriberCommand with SubscriberDto in sessio…
djabarovgeorge Apr 24, 2025
694b245
Merge branch 'next' into nv-5683-support-of-upserting-subscriber-attr…
djabarovgeorge Apr 25, 2025
c85fe5f
fix(base-command): return converted object instead of data in validat…
djabarovgeorge Apr 26, 2025
76b94e5
fix(base-module): update subscriber structure in session initializati…
djabarovgeorge Apr 26, 2025
b8e8a37
fix(tests): update subscriber structure in Novu session initializatio…
djabarovgeorge Apr 26, 2025
178cae0
fix(tests): refactor subscriber structure in session command tests
djabarovgeorge Apr 26, 2025
8391ff0
fix(tests): enhance session initialization tests with subscriber obje…
djabarovgeorge Apr 27, 2025
9c2ebd0
fix(docs): update subscriberId to subscriber in README files for cons…
djabarovgeorge Apr 28, 2025
4642f85
fix(tests): remove console logs from session e2e tests for cleaner ou…
djabarovgeorge Apr 28, 2025
3c9339c
Merge branch 'next' into nv-5683-support-of-upserting-subscriber-attr…
djabarovgeorge Apr 28, 2025
98fb72c
Merge branch 'next' into nv-5683-support-of-upserting-subscriber-attr…
djabarovgeorge Apr 28, 2025
72ef94b
fix(api): update subscriberId reference to subscriber in session use …
djabarovgeorge Apr 28, 2025
9694143
fix(api): refactor subscriberId to subscriber object in session command
djabarovgeorge Apr 28, 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
53 changes: 51 additions & 2 deletions apps/api/src/app/inbox/dtos/subscriber-session-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
import { IsDefined, IsOptional, IsString } from 'class-validator';
import { IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class SubscriberSessionRequestDto {
@IsString()
@IsDefined()
readonly applicationIdentifier: string;

@IsString()
@IsOptional()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing API decorators

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

do we need to add them if the route has ApiExcludeController?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the Inbox set of endpoint without OpenAPI or Speakeasy SDK yet.

// TODO: Backward compatibility support - remove in future versions (see NV-5801)
/** @deprecated Use subscriber instead */
readonly subscriberId?: string;

@IsString()
@IsOptional()
readonly subscriberHash?: string;

@IsOptional()
@ValidateNested()
@Type(() => SubscriberDto)
readonly subscriber?: SubscriberDto | string;
}

export class SubscriberDto {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can't we use an existing DTO, or at least give it a diffrent name than subscriber DTO when we havem ultiple already

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Please point me in the direction of the existing DTO.

@IsOptional()
@IsString()
readonly id?: string;

@IsDefined()
@IsString()
readonly subscriberId: string;

@IsOptional()
@IsString()
readonly firstName?: string;

@IsOptional()
readonly subscriberHash?: string;
@IsString()
readonly lastName?: string;

@IsOptional()
@IsString()
readonly email?: string;
Comment thread
SokratisVidros marked this conversation as resolved.

@IsOptional()
@IsString()
readonly phone?: string;

@IsOptional()
@IsString()
readonly avatar?: string;

@IsOptional()
readonly data?: Record<string, unknown>;

@IsOptional()
@IsString()
readonly timezone?: string;
Comment thread
djabarovgeorge marked this conversation as resolved.

@IsOptional()
@IsString()
readonly locale?: string;
}
240 changes: 232 additions & 8 deletions apps/api/src/app/inbox/e2e/session.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IntegrationRepository } from '@novu/dal';
import { IntegrationRepository, SubscriberRepository } from '@novu/dal';
import { ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
Expand All @@ -9,6 +9,7 @@ import {
createHash,
InvalidateCacheService,
} from '@novu/application-generic';
import { randomBytes } from 'crypto';

const integrationRepository = new IntegrationRepository();
const mockSubscriberId = '12345';
Expand All @@ -17,12 +18,14 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
let session: UserSession;
let cacheService: CacheService;
let invalidateCache: InvalidateCacheService;
let subscriberRepository: SubscriberRepository;

before(async () => {
const cacheInMemoryProviderService = new CacheInMemoryProviderService();
cacheService = new CacheService(cacheInMemoryProviderService);
await cacheService.initialize();
invalidateCache = new InvalidateCacheService(cacheService);
subscriberRepository = new SubscriberRepository();
});

beforeEach(async () => {
Expand All @@ -42,19 +45,30 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
applicationIdentifier,
subscriberId,
subscriberHash,
subscriber,
origin,
}: {
applicationIdentifier: string;
subscriberId: string;
subscriberId?: string;
subscriberHash?: string;
subscriber?: Record<string, unknown>;
origin?: string;
}) => {
return await session.testAgent.post('/v1/inbox/session').send({
const request = session.testAgent.post('/v1/inbox/session');

if (origin) {
request.set('origin', origin);
}

return await request.send({
applicationIdentifier,
subscriberId,
subscriberHash,
subscriber,
});
};

it('should initialize session', async function () {
it('should initialize session', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
Expand All @@ -73,7 +87,7 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
expect(body.data.totalUnreadCount).to.equal(0);
});

it('should initialize session with HMAC', async function () {
it('should initialize session with HMAC', async () => {
const secretKey = session.environment.apiKeys[0].key;
const subscriberHash = createHash(secretKey, mockSubscriberId);

Expand All @@ -88,7 +102,193 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
expect(body.data.totalUnreadCount).to.equal(0);
});

it('should throw an error when invalid applicationIdentifier provided', async function () {
it('should initialize session with subscriber object', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
hmac: false,
},
invalidateCache
);

const subscriber = {
subscriberId: mockSubscriberId,
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
};

const { body, status } = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriber,
});

expect(status).to.equal(201);
expect(body.data.token).to.be.ok;
expect(body.data.totalUnreadCount).to.equal(0);
});

it('should create a new subscriber if it does not exist', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
hmac: false,
},
invalidateCache
);
const subscriberId = `user-subscriber-id-${`${randomBytes(4).toString('hex')}`}`;

const newRandomSubscriber = {
subscriberId,
firstName: 'Mike',
lastName: 'Tyson',
email: 'mike@example.com',
};

const res = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriber: newRandomSubscriber,
});

const { status, body } = res;

expect(status).to.equal(201);
expect(body.data.token).to.be.ok;
expect(body.data.totalUnreadCount).to.equal(0);

const storedSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId);
expect(storedSubscriber).to.exist;
if (!storedSubscriber) {
throw new Error('Subscriber exists but was not found');
}

expect(storedSubscriber.firstName).to.equal(newRandomSubscriber.firstName);
expect(storedSubscriber.lastName).to.equal(newRandomSubscriber.lastName);
expect(storedSubscriber.email).to.equal(newRandomSubscriber.email);
});

it('should upsert a subscriber', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
hmac: false,
},
invalidateCache
);
const subscriberId = `user-subscriber-id-${`${randomBytes(4).toString('hex')}`}`;

const newRandomSubscriber = {
subscriberId,
firstName: 'Mike',
lastName: 'Tyson',
email: 'mike@example.com',
};

const { body, status } = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriber: newRandomSubscriber,
});

expect(status).to.equal(201);
expect(body.data.token).to.be.ok;
expect(body.data.totalUnreadCount).to.equal(0);

const storedSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId);
expect(storedSubscriber).to.exist;
if (!storedSubscriber) {
throw new Error('Subscriber exists but was not found');
}

expect(storedSubscriber.firstName).to.equal(newRandomSubscriber.firstName);
expect(storedSubscriber.lastName).to.equal(newRandomSubscriber.lastName);
expect(storedSubscriber.email).to.equal(newRandomSubscriber.email);

const updatedSubscriber = {
subscriberId,
firstName: 'Mike 2',
lastName: 'Tyson 2',
email: 'mike2@example.com',
};

const secretKey = session.environment.apiKeys[0].key;
const subscriberHash = createHash(secretKey, subscriberId);
const { body: updatedBody, status: updatedStatus } = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriber: updatedSubscriber,
subscriberHash,
});

expect(updatedStatus).to.equal(201);
expect(updatedBody.data.token).to.be.ok;
expect(updatedBody.data.totalUnreadCount).to.equal(0);

const updatedStoredSubscriber = await subscriberRepository.findBySubscriberId(
session.environment._id,
subscriberId
);
expect(updatedStoredSubscriber).to.exist;
if (!updatedStoredSubscriber) {
throw new Error('Subscriber exists but was not found');
}

expect(updatedStoredSubscriber.firstName).to.equal(updatedSubscriber.firstName);
expect(updatedStoredSubscriber.lastName).to.equal(updatedSubscriber.lastName);
expect(updatedStoredSubscriber.email).to.equal(updatedSubscriber.email);

const { body: upsertWithoutHmac, status: upsertedStatusWithoutHmac } = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriber: {
subscriberId,
firstName: 'Mike 3',
lastName: 'Tyson 3',
email: 'mike3@example.com',
},
});

expect(upsertedStatusWithoutHmac).to.equal(201);
expect(upsertWithoutHmac.data.token).to.be.ok;
expect(upsertWithoutHmac.data.totalUnreadCount).to.equal(0);

const updatedStoredSubscriber2 = await subscriberRepository.findBySubscriberId(
session.environment._id,
subscriberId
);
expect(updatedStoredSubscriber2).to.exist;
if (!updatedStoredSubscriber2) {
throw new Error('Subscriber exists but was not found');
}

expect(updatedStoredSubscriber2.firstName).to.not.equal('Mike 3');
expect(updatedStoredSubscriber2.lastName).to.not.equal('Tyson 3');
expect(updatedStoredSubscriber2.email).to.not.equal('mike3@example.com');
});

it('should initialize session with origin header', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
hmac: false,
},
invalidateCache
);

const origin = 'https://example.com';
const { body, status } = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriberId: mockSubscriberId,
origin,
});

expect(status).to.equal(201);
expect(body.data.token).to.be.ok;
expect(body.data.totalUnreadCount).to.equal(0);
});

it('should throw an error when invalid applicationIdentifier provided', async () => {
const { body, status } = await initializeSession({
applicationIdentifier: 'some-not-existing-id',
subscriberId: mockSubscriberId,
Expand All @@ -98,7 +298,7 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
expect(body.message).to.contain('Please provide a valid application identifier');
});

it('should throw an error when no active integrations', async function () {
it('should throw an error when no active integrations', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
Expand All @@ -117,7 +317,7 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
expect(body.message).to.contain('The active in-app integration could not be found');
});

it('should throw an error when invalid subscriberHash provided', async function () {
it('should throw an error when invalid subscriberHash provided', async () => {
const invalidSecretKey = 'invalid-secret-key';
const subscriberHash = createHash(invalidSecretKey, mockSubscriberId);

Expand All @@ -130,6 +330,30 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
expect(status).to.equal(400);
expect(body.message).to.contain('Please provide a valid HMAC hash');
});

it('should throw an error when subscriber object is missing subscriberId', async () => {
await setIntegrationConfig(
{
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
hmac: false,
},
invalidateCache
);
const subscriber = {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
};

const { body, status } = await initializeSession({
applicationIdentifier: session.environment.identifier,
subscriber,
});

expect(status).to.equal(422);
expect(body.message).to.contain('Validation Error');
});
});

async function setIntegrationConfig(
Expand Down
Loading
Loading