diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index d6be991d8f2..bc8fa4ddb5e 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -101,6 +101,7 @@ import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards'; import { MessageMarkAsRequestDto } from '../widgets/dtos/mark-as-request.dto'; import { MarkMessageAsByMarkCommand } from '../widgets/usecases/mark-message-as-by-mark/mark-message-as-by-mark.command'; import { MarkMessageAsByMark } from '../widgets/usecases/mark-message-as-by-mark/mark-message-as-by-mark.usecase'; +import { FeedResponseDto } from '../widgets/dtos/feeds-response.dto'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @@ -476,12 +477,12 @@ export class SubscribersController { @ApiOperation({ summary: 'Get in-app notification feed for a particular subscriber', }) - @ApiOkPaginatedResponse(MessageResponseDto) + @ApiOkPaginatedResponse(FeedResponseDto) async getNotificationsFeed( @UserSession() user: IJwtPayload, @Param('subscriberId') subscriberId: string, @Query() query: GetInAppNotificationsFeedForSubscriberDto - ): Promise> { + ): Promise { let feedsQuery: string[] | undefined; if (query.feedIdentifier) { feedsQuery = Array.isArray(query.feedIdentifier) ? query.feedIdentifier : [query.feedIdentifier]; diff --git a/apps/api/src/app/widgets/dtos/feeds-response.dto.ts b/apps/api/src/app/widgets/dtos/feeds-response.dto.ts new file mode 100644 index 00000000000..4915f18f3b5 --- /dev/null +++ b/apps/api/src/app/widgets/dtos/feeds-response.dto.ts @@ -0,0 +1,131 @@ +import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ActorTypeEnum, ChannelTypeEnum, IActor, INotificationDto } from '@novu/shared'; + +import { SubscriberResponseDto } from '../../subscribers/dtos'; +import { EmailBlock, MessageCTA } from './message-response.dto'; + +class Actor implements IActor { + @ApiProperty() + data: string | null; + + @ApiProperty({ enum: ActorTypeEnum }) + type: ActorTypeEnum; +} + +@ApiExtraModels(EmailBlock, MessageCTA) +export class NotificationDto implements INotificationDto { + @ApiPropertyOptional() + _id: string; + + @ApiProperty() + _templateId: string; + + @ApiProperty() + _environmentId: string; + + @ApiProperty() + _messageTemplateId: string; + + @ApiProperty() + _organizationId: string; + + @ApiProperty() + _notificationId: string; + + @ApiProperty() + _subscriberId: string; + + @ApiProperty() + _feedId: string; + + @ApiProperty() + _jobId: string; + + @ApiPropertyOptional() + createdAt: string; + + @ApiPropertyOptional() + updatedAt: string; + + @ApiPropertyOptional() + expireAt: string; + + @ApiPropertyOptional({ + type: Actor, + }) + actor?: Actor; + + @ApiPropertyOptional({ + type: SubscriberResponseDto, + }) + subscriber?: SubscriberResponseDto; + + @ApiProperty() + transactionId: string; + + @ApiPropertyOptional() + templateIdentifier: string; + + @ApiPropertyOptional() + providerId: string; + + @ApiProperty() + content: string; + + @ApiProperty() + subject?: string; + + @ApiProperty({ + enum: ChannelTypeEnum, + }) + channel: ChannelTypeEnum; + + @ApiProperty() + read: boolean; + + @ApiProperty() + seen: boolean; + + @ApiProperty() + deleted: boolean; + + @ApiPropertyOptional() + deviceTokens?: string[]; + + @ApiProperty({ + type: MessageCTA, + }) + cta: MessageCTA; + + @ApiProperty({ + enum: ['sent', 'error', 'warning'], + }) + status: 'sent' | 'error' | 'warning'; + + @ApiProperty({ + description: 'The payload that was used to send the notification trigger', + }) + payload: Record; + + @ApiProperty({ + description: 'Provider specific overrides used when triggering the notification', + }) + overrides: Record; +} + +export class FeedResponseDto { + @ApiPropertyOptional() + totalCount?: number; + + @ApiProperty() + hasMore: boolean; + + @ApiProperty() + data: NotificationDto[]; + + @ApiProperty() + pageSize: number; + + @ApiProperty() + page: number; +} diff --git a/apps/api/src/app/widgets/dtos/message-response.dto.ts b/apps/api/src/app/widgets/dtos/message-response.dto.ts index 9e7ebb7ab79..cf04dde2e63 100644 --- a/apps/api/src/app/widgets/dtos/message-response.dto.ts +++ b/apps/api/src/app/widgets/dtos/message-response.dto.ts @@ -6,7 +6,9 @@ import { EmailBlockTypeEnum, MessageActionStatusEnum, TextAlignEnum, - INotificationDto, + IMessage, + IMessageCTA, + IMessageAction, } from '@novu/shared'; import { SubscriberResponseDto } from '../../subscribers/dtos'; import { WorkflowResponse } from '../../workflows/dto/workflow-response.dto'; @@ -18,7 +20,7 @@ class EmailBlockStyles { textAlign?: TextAlignEnum; } -class EmailBlock { +export class EmailBlock { @ApiProperty({ enum: ['text', 'button'], }) @@ -53,7 +55,7 @@ class MessageButton { resultContent?: string; } -class MessageAction { +class MessageAction implements IMessageAction { @ApiPropertyOptional({ enum: MessageActionStatusEnum, }) @@ -68,7 +70,7 @@ class MessageAction { @ApiPropertyOptional({ type: MessageActionResult, }) - result?: MessageActionResult; + result: MessageActionResult; } class MessageCTAData { @@ -76,19 +78,21 @@ class MessageCTAData { url?: string; } -class MessageCTA { +export class MessageCTA implements IMessageCTA { @ApiPropertyOptional() - type?: ChannelCTATypeEnum; + type: ChannelCTATypeEnum; + @ApiProperty() data: MessageCTAData; + @ApiPropertyOptional() action?: MessageAction; } @ApiExtraModels(EmailBlock, MessageCTA) -export class MessageResponseDto implements INotificationDto { +export class MessageResponseDto implements IMessage { @ApiPropertyOptional() - _id?: string; + _id: string; @ApiProperty() _templateId: string; @@ -121,8 +125,14 @@ export class MessageResponseDto implements INotificationDto { @ApiPropertyOptional() templateIdentifier?: string; + @ApiProperty() + createdAt: string; + + @ApiPropertyOptional() + lastSeenDate?: string; + @ApiPropertyOptional() - createdAt?: string; + lastReadDate?: string; @ApiProperty({ oneOf: [ @@ -147,6 +157,9 @@ export class MessageResponseDto implements INotificationDto { }) channel: ChannelTypeEnum; + @ApiProperty() + read: boolean; + @ApiProperty() seen: boolean; @@ -168,16 +181,13 @@ export class MessageResponseDto implements INotificationDto { @ApiPropertyOptional() title?: string; - @ApiProperty() - lastSeenDate: string; - @ApiProperty({ type: MessageCTA, }) cta: MessageCTA; - @ApiProperty() - _feedId?: string; + @ApiPropertyOptional() + _feedId?: string | null; @ApiProperty({ enum: ['sent', 'error', 'warning'], diff --git a/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts b/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts index 361af26c3e1..560ca48f342 100644 --- a/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts +++ b/apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts @@ -10,8 +10,8 @@ import { import { MessageRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { GetNotificationsFeedCommand } from './get-notifications-feed.command'; -import { MessagesResponseDto } from '../../dtos/message-response.dto'; import { ApiException } from '../../../shared/exceptions/api.exception'; +import { FeedResponseDto } from '../../dtos/feeds-response.dto'; @Injectable() export class GetNotificationsFeed { @@ -41,7 +41,7 @@ export class GetNotificationsFeed { ...command, }), }) - async execute(command: GetNotificationsFeedCommand): Promise { + async execute(command: GetNotificationsFeedCommand): Promise { const payload = this.getPayloadObject(command.payload); const subscriber = await this.fetchSubscriber({ @@ -78,7 +78,7 @@ export class GetNotificationsFeed { for (const message of feed) { if (message._actorId && message.actor?.type === ActorTypeEnum.USER) { - message.actor.data = this.processUserAvatar(message.actorSubscriber); + message.actor.data = message.actorSubscriber?.avatar || null; } } @@ -103,8 +103,10 @@ export class GetNotificationsFeed { const hasMore = feed.length < totalCount; totalCount = Math.min(totalCount, command.limit); + const data = feed.map((el) => ({ ...el, content: el.content as string })); + return { - data: feed || [], + data, totalCount: totalCount, hasMore: hasMore, pageSize: command.limit, @@ -112,12 +114,6 @@ export class GetNotificationsFeed { }; } - private getHasMore(page: number, LIMIT: number, feed, totalCount) { - const currentPaginationTotal = page * LIMIT + feed.length; - - return currentPaginationTotal < totalCount; - } - @CachedEntity({ builder: (command: { subscriberId: string; _environmentId: string }) => buildSubscriberKey({ @@ -134,8 +130,4 @@ export class GetNotificationsFeed { }): Promise { return await this.subscriberRepository.findBySubscriberId(_environmentId, subscriberId); } - - private processUserAvatar(actorSubscriber?: SubscriberEntity): string | null { - return actorSubscriber?.avatar || null; - } } diff --git a/libs/dal/src/repositories/message/message.entity.ts b/libs/dal/src/repositories/message/message.entity.ts index d2470b55c17..a5f02404631 100644 --- a/libs/dal/src/repositories/message/message.entity.ts +++ b/libs/dal/src/repositories/message/message.entity.ts @@ -31,10 +31,13 @@ export class MessageEntity { template?: NotificationTemplateEntity; - templateIdentifier?: string; + templateIdentifier: string; - createdAt?: string; - expireAt?: string; + createdAt: string; + + expireAt: string; + + updatedAt: string; content: string | IEmailBlock[]; @@ -48,6 +51,8 @@ export class MessageEntity { read: boolean; + deleted: boolean; + email?: string; phone?: string; @@ -56,7 +61,7 @@ export class MessageEntity { directWebhookUrl?: string; - providerId?: string; + providerId: string; deviceTokens?: string[]; diff --git a/libs/shared/src/dto/index.ts b/libs/shared/src/dto/index.ts index 0211e9c62bf..162ee1dc40e 100644 --- a/libs/shared/src/dto/index.ts +++ b/libs/shared/src/dto/index.ts @@ -11,3 +11,4 @@ export * from './workflows'; export * from './tenant'; export * from './workflow-override'; export * from './widget'; +export * from './session'; diff --git a/libs/shared/src/dto/session/index.ts b/libs/shared/src/dto/session/index.ts new file mode 100644 index 00000000000..bef6e4781e2 --- /dev/null +++ b/libs/shared/src/dto/session/index.ts @@ -0,0 +1 @@ +export * from './session.dto'; diff --git a/libs/shared/src/dto/session/session.dto.ts b/libs/shared/src/dto/session/session.dto.ts new file mode 100644 index 00000000000..f971cbcf565 --- /dev/null +++ b/libs/shared/src/dto/session/session.dto.ts @@ -0,0 +1,6 @@ +import { ISubscriberJwt } from '../../entities/user'; + +export interface ISessionDto { + token: string; + profile: ISubscriberJwt; +} diff --git a/libs/shared/src/dto/widget/notification.dto.ts b/libs/shared/src/dto/widget/notification.dto.ts index 4bfca5770e6..a88ca9ad596 100644 --- a/libs/shared/src/dto/widget/notification.dto.ts +++ b/libs/shared/src/dto/widget/notification.dto.ts @@ -1,64 +1,35 @@ -import { ChannelTypeEnum, IEmailBlock, ChannelCTATypeEnum } from '../../types'; +import { ChannelTypeEnum } from '../../types'; import { ISubscriberResponseDto } from '../subscriber'; -import { INotificationTemplate } from '../../entities/notification-template'; -import { ButtonTypeEnum, MessageActionStatusEnum } from '../../entities/messages'; - -interface IMessageActionResult { - payload?: Record; - type?: ButtonTypeEnum; -} - -interface IMessageButton { - type: ButtonTypeEnum; - content: string; - resultContent?: string; -} - -interface IMessageAction { - status?: MessageActionStatusEnum; - buttons?: IMessageButton[]; - result?: IMessageActionResult; -} - -interface IMessageCTAData { - url?: string; -} - -interface IMessageCTA { - type?: ChannelCTATypeEnum; - data: IMessageCTAData; - action?: IMessageAction; -} +import { IActor, IMessageCTA } from '../../entities/messages'; export interface INotificationDto { - _id?: string; + _id: string; _templateId: string; _environmentId: string; _messageTemplateId: string; _organizationId: string; _notificationId: string; _subscriberId: string; + _feedId?: string | null; + _jobId: string; + createdAt: string; + updatedAt: string; + expireAt: string; + lastSeenDate?: string; + lastReadDate?: string; + actor?: IActor; subscriber?: ISubscriberResponseDto; - template?: INotificationTemplate; - templateIdentifier?: string; - createdAt?: string; - content: string | IEmailBlock[]; transactionId: string; - subject?: string; + templateIdentifier: string; + providerId: string; + content: string; channel: ChannelTypeEnum; + read: boolean; seen: boolean; - email?: string; - phone?: string; - directWebhookUrl?: string; - providerId?: string; + deleted: boolean; deviceTokens?: string[]; - title?: string; - lastSeenDate: string; cta: IMessageCTA; - _feedId?: string; status: 'sent' | 'error' | 'warning'; - errorId: string; - errorText: string; payload: Record; overrides: Record; } diff --git a/libs/shared/src/entities/messages/messages.interface.ts b/libs/shared/src/entities/messages/messages.interface.ts index 1077fd497b1..af5ca91e53b 100644 --- a/libs/shared/src/entities/messages/messages.interface.ts +++ b/libs/shared/src/entities/messages/messages.interface.ts @@ -16,11 +16,11 @@ export interface IMessage { channel: ChannelTypeEnum; seen: boolean; read: boolean; - lastSeenDate: string; - lastReadDate: string; + lastSeenDate?: string; + lastReadDate?: string; createdAt: string; cta?: IMessageCTA; - _feedId: string; + _feedId?: string | null; _layoutId?: string; payload: Record; actor?: IActor; diff --git a/packages/client/src/api/api.service.ts b/packages/client/src/api/api.service.ts index 0de7b9e1e18..6cf3cd74f08 100644 --- a/packages/client/src/api/api.service.ts +++ b/packages/client/src/api/api.service.ts @@ -1,9 +1,10 @@ import { - IMessage, ButtonTypeEnum, MessageActionStatusEnum, CustomDataType, IPaginatedResponse, + ISessionDto, + INotificationDto, } from '@novu/shared'; import { HttpClient } from '../http-client'; import { @@ -118,7 +119,7 @@ export class ApiService { async getNotificationsList( page: number, { payload, ...rest }: IStoreQuery = {} - ): Promise> { + ): Promise> { const payloadString = payload ? btoa(JSON.stringify(payload)) : undefined; return await this.httpClient.getFullResponse( @@ -135,7 +136,7 @@ export class ApiService { appId: string, subscriberId: string, hmacHash = null - ) { + ): Promise { return await this.httpClient.post(`/widgets/session/initialize`, { applicationIdentifier: appId, subscriberId: subscriberId, diff --git a/packages/js/jest.config.js b/packages/js/jest.config.cjs similarity index 100% rename from packages/js/jest.config.js rename to packages/js/jest.config.cjs diff --git a/packages/js/src/event-emitter/index.ts b/packages/js/src/event-emitter/index.ts index f4d7ecca0c5..fa4f9d3e4c7 100644 --- a/packages/js/src/event-emitter/index.ts +++ b/packages/js/src/event-emitter/index.ts @@ -1,2 +1,2 @@ -export * from './NovuEventEmitter'; +export * from './novu-event-emitter'; export * from './types'; diff --git a/packages/js/src/event-emitter/NovuEventEmitter.ts b/packages/js/src/event-emitter/novu-event-emitter.ts similarity index 88% rename from packages/js/src/event-emitter/NovuEventEmitter.ts rename to packages/js/src/event-emitter/novu-event-emitter.ts index 50926213d4a..2ddf0b3cec7 100644 --- a/packages/js/src/event-emitter/NovuEventEmitter.ts +++ b/packages/js/src/event-emitter/novu-event-emitter.ts @@ -17,7 +17,7 @@ export class NovuEventEmitter { this.emitter.on(eventName, listener); } - emit(type: Key, event: Events[Key]): void { + emit(type: Key, event?: Events[Key]): void { this.emitter.emit(type, event); } } diff --git a/packages/js/src/event-emitter/types.ts b/packages/js/src/event-emitter/types.ts index c21a290ccf0..92da9efdcce 100644 --- a/packages/js/src/event-emitter/types.ts +++ b/packages/js/src/event-emitter/types.ts @@ -1,11 +1,16 @@ -import { INotificationDto } from '@novu/shared'; +import { ISessionDto } from '@novu/shared'; +import { Notification } from '../feeds/notification'; import { PaginatedResponse } from '../types'; export type Events = { + 'session.initialize.*': undefined; + 'session.initialize.pending': undefined; + 'session.initialize.success': ISessionDto; + 'session.initialize.error': { error: unknown }; 'feeds.fetch.*': undefined; 'feeds.fetch.pending': undefined; - 'feeds.fetch.success': { res: PaginatedResponse }; + 'feeds.fetch.success': { response: PaginatedResponse }; 'feeds.fetch.error': { error: unknown }; }; diff --git a/packages/js/src/feeds/Feeds.ts b/packages/js/src/feeds/Feeds.ts deleted file mode 100644 index cf3d9548327..00000000000 --- a/packages/js/src/feeds/Feeds.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NovuEventEmitter } from '../event-emitter'; - -export class Feeds { - #emitter: NovuEventEmitter; - - constructor(emitter: NovuEventEmitter) { - this.#emitter = emitter; - } -} diff --git a/packages/js/src/feeds/feeds.ts b/packages/js/src/feeds/feeds.ts new file mode 100644 index 00000000000..3d84815a2e6 --- /dev/null +++ b/packages/js/src/feeds/feeds.ts @@ -0,0 +1,34 @@ +import type { PaginatedResponse } from '../types'; +import { BaseModule } from '../utils/base-module'; +import { Notification } from './notification'; + +interface FetchFeedOptions { + page?: number; + feedIdentifier?: string | string[]; + seen?: boolean; + read?: boolean; + limit?: number; + payload?: Record; +} + +export class Feeds extends BaseModule { + async fetch({ page = 1, ...restOptions }: FetchFeedOptions): Promise> { + return this.callWithSession(async () => { + try { + this._emitter.emit('feeds.fetch.pending'); + + const response = await this._apiService.getNotificationsList(page, restOptions); + const modifiedResponse: PaginatedResponse = { + ...response, + data: response.data.map((el) => new Notification(el)), + }; + + this._emitter.emit('feeds.fetch.success', { response: modifiedResponse }); + + return response; + } catch (error) { + this._emitter.emit('feeds.fetch.error', { error }); + } + }); + } +} diff --git a/packages/js/src/feeds/index.ts b/packages/js/src/feeds/index.ts index b9a366457d3..60a46ddfe72 100644 --- a/packages/js/src/feeds/index.ts +++ b/packages/js/src/feeds/index.ts @@ -1 +1 @@ -export * from './Feeds'; +export * from './feeds'; diff --git a/packages/js/src/feeds/notification.ts b/packages/js/src/feeds/notification.ts new file mode 100644 index 00000000000..869df4e67a5 --- /dev/null +++ b/packages/js/src/feeds/notification.ts @@ -0,0 +1,37 @@ +import type { ChannelTypeEnum, IActor, IMessageCTA, INotificationDto, ISubscriberResponseDto } from '@novu/shared'; + +export class Notification { + _id: string; + _subscriberId: string; + _feedId?: string | null; + createdAt: string; + updatedAt: string; + actor?: IActor; + subscriber?: ISubscriberResponseDto; + transactionId: string; + templateIdentifier: string; + content: string; + read: boolean; + seen: boolean; + cta: IMessageCTA; + payload: Record; + overrides: Record; + + constructor(notification: INotificationDto) { + this._id = notification._id; + this._subscriberId = notification._subscriberId; + this._feedId = notification._feedId; + this.createdAt = notification.createdAt; + this.updatedAt = notification.updatedAt; + this.actor = notification.actor; + this.subscriber = notification.subscriber; + this.transactionId = notification.transactionId; + this.templateIdentifier = notification.templateIdentifier; + this.content = notification.content; + this.read = notification.read; + this.seen = notification.seen; + this.cta = notification.cta; + this.payload = notification.payload; + this.overrides = notification.overrides; + } +} diff --git a/packages/js/src/global.d.ts b/packages/js/src/global.d.ts index 2e1d94c9d53..8849385c52a 100644 --- a/packages/js/src/global.d.ts +++ b/packages/js/src/global.d.ts @@ -1,4 +1,4 @@ -import { Novu } from './Novu'; +import { Novu } from './novu'; declare global { interface Window { diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 13bf3395ebb..d01152c8461 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -1 +1 @@ -export * from './Novu'; +export * from './novu'; diff --git a/packages/js/src/novu.test.ts b/packages/js/src/novu.test.ts new file mode 100644 index 00000000000..2feae35c80a --- /dev/null +++ b/packages/js/src/novu.test.ts @@ -0,0 +1,83 @@ +import { Novu } from './novu'; + +const mockFeedResponse = { + data: [], + hasMore: true, + totalCount: 0, + pageSize: 10, + page: 1, +}; + +const initializeSession = jest.fn().mockResolvedValue({ token: 'token', profile: 'profile' }); +const getNotificationsList = jest.fn(() => mockFeedResponse); + +jest.mock('@novu/client', () => ({ + ...jest.requireActual('@novu/client'), + ApiService: jest.fn().mockImplementation(() => { + const apiService = { + isAuthenticated: false, + initializeSession, + setAuthorizationToken: jest.fn(() => { + apiService.isAuthenticated = true; + }), + getNotificationsList, + }; + + return apiService; + }), +})); + +describe('Novu', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('lazy session initialization', () => { + test('should call the queued feeds.fetch after the session is initialized', async () => { + const page = 1; + const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); + const res = await novu.feeds.fetch({ page }); + + expect(initializeSession).toHaveBeenCalledTimes(1); + expect(getNotificationsList).toHaveBeenCalledWith(page, {}); + expect(res).toEqual(mockFeedResponse); + }); + + test('should call the feeds.fetch right away when session is already initialized', async () => { + const page = 1; + const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); + // await for session initialization + await new Promise((resolve) => setTimeout(resolve, 10)); + + const res = await novu.feeds.fetch({ page }); + + expect(initializeSession).toHaveBeenCalledTimes(1); + expect(getNotificationsList).toHaveBeenCalledWith(page, {}); + expect(res).toEqual(mockFeedResponse); + }); + + test('should reject the queued feeds.fetch if session initialization fails', async () => { + const page = 1; + const error = new Error('Failed to initialize session'); + initializeSession.mockRejectedValueOnce(error); + const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); + + const fetchPromise = novu.feeds.fetch({ page }); + + await expect(fetchPromise).rejects.toEqual(error); + }); + + test('should reject the feeds.fetch right away when session initialization has failed', async () => { + const page = 1; + const error = new Error('Failed to initialize session'); + initializeSession.mockRejectedValueOnce(error); + const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); + // await for session initialization + await new Promise((resolve) => setTimeout(resolve, 10)); + + const fetchPromise = novu.feeds.fetch({ page }); + + await expect(fetchPromise).rejects.toEqual(error); + }); + }); +}); diff --git a/packages/js/src/Novu.ts b/packages/js/src/novu.ts similarity index 71% rename from packages/js/src/Novu.ts rename to packages/js/src/novu.ts index b6f90780f12..a974c308e99 100644 --- a/packages/js/src/Novu.ts +++ b/packages/js/src/novu.ts @@ -1,32 +1,39 @@ +import { ApiService } from '@novu/client'; + import { NovuEventEmitter } from './event-emitter'; import type { EventHandler, EventNames, Events } from './event-emitter'; import { Feeds } from './feeds'; import { Session } from './session'; import { Preferences } from './preferences'; +const PRODUCTION_BACKEND_URL = 'https://api.novu.co'; + interface NovuOptions { applicationIdentifier: string; subscriberId: string; subscriberHash?: string; + backendUrl?: string; } export class Novu implements Pick { #emitter: NovuEventEmitter; #session: Session; + #apiService: ApiService; public readonly feeds: Feeds; public readonly preferences: Preferences; constructor(options: NovuOptions) { + this.#apiService = new ApiService(options.backendUrl ?? PRODUCTION_BACKEND_URL); this.#emitter = new NovuEventEmitter(); - this.#session = new Session(this.#emitter, { + this.#session = new Session(this.#emitter, this.#apiService, { applicationIdentifier: options.applicationIdentifier, subscriberId: options.subscriberId, subscriberHash: options.subscriberHash, }); this.#session.initialize(); - this.feeds = new Feeds(this.#emitter); - this.preferences = new Preferences(this.#emitter); + this.feeds = new Feeds(this.#emitter, this.#apiService); + this.preferences = new Preferences(this.#emitter, this.#apiService); } on(eventName: Key, listener: EventHandler): void { diff --git a/packages/js/src/preferences/Preferences.ts b/packages/js/src/preferences/Preferences.ts deleted file mode 100644 index fc527b063eb..00000000000 --- a/packages/js/src/preferences/Preferences.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NovuEventEmitter } from '../event-emitter'; - -export class Preferences { - #emitter: NovuEventEmitter; - - constructor(emitter: NovuEventEmitter) { - this.#emitter = emitter; - } -} diff --git a/packages/js/src/preferences/index.ts b/packages/js/src/preferences/index.ts index ed07911fb00..a17a3da1152 100644 --- a/packages/js/src/preferences/index.ts +++ b/packages/js/src/preferences/index.ts @@ -1 +1 @@ -export * from './Preferences'; +export * from './preferences'; diff --git a/packages/js/src/preferences/preferences.ts b/packages/js/src/preferences/preferences.ts new file mode 100644 index 00000000000..26591e98a9e --- /dev/null +++ b/packages/js/src/preferences/preferences.ts @@ -0,0 +1,13 @@ +import { ApiService } from '@novu/client'; + +import { NovuEventEmitter } from '../event-emitter'; + +export class Preferences { + #emitter: NovuEventEmitter; + #apiService: ApiService; + + constructor(emitter: NovuEventEmitter, apiService: ApiService) { + this.#emitter = emitter; + this.#apiService = apiService; + } +} diff --git a/packages/js/src/session/Session.ts b/packages/js/src/session/Session.ts deleted file mode 100644 index 2d3fc9903b2..00000000000 --- a/packages/js/src/session/Session.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NovuEventEmitter } from '../event-emitter'; - -export interface SessionOptions { - applicationIdentifier: string; - subscriberId: string; - subscriberHash?: string; -} - -export class Session { - #emitter: NovuEventEmitter; - - constructor(emitter: NovuEventEmitter, options: SessionOptions) { - this.#emitter = emitter; - } - - public initialize(): void {} -} diff --git a/packages/js/src/session/index.ts b/packages/js/src/session/index.ts index d398127b4a2..1ae27f417bf 100644 --- a/packages/js/src/session/index.ts +++ b/packages/js/src/session/index.ts @@ -1 +1 @@ -export * from './Session'; +export * from './session'; diff --git a/packages/js/src/session/session.ts b/packages/js/src/session/session.ts new file mode 100644 index 00000000000..cfaeca07d81 --- /dev/null +++ b/packages/js/src/session/session.ts @@ -0,0 +1,38 @@ +import { ApiService } from '@novu/client'; + +import { NovuEventEmitter } from '../event-emitter'; + +export interface SessionOptions { + applicationIdentifier: string; + subscriberId: string; + subscriberHash?: string; +} + +export class Session { + #emitter: NovuEventEmitter; + #apiService: ApiService; + #options: SessionOptions; + + constructor(emitter: NovuEventEmitter, apiService: ApiService, options: SessionOptions) { + this.#emitter = emitter; + this.#apiService = apiService; + this.#options = options; + } + + public async initialize(): Promise { + try { + this.#emitter.emit('session.initialize.pending'); + + const { token, profile } = await this.#apiService.initializeSession( + this.#options.applicationIdentifier, + this.#options.subscriberId, + this.#options.subscriberHash + ); + this.#apiService.setAuthorizationToken(token); + + this.#emitter.emit('session.initialize.success', { token, profile }); + } catch (error) { + this.#emitter.emit('session.initialize.error', { error }); + } + } +} diff --git a/packages/js/src/umd.ts b/packages/js/src/umd.ts index a54098dc26e..64e55653a6d 100644 --- a/packages/js/src/umd.ts +++ b/packages/js/src/umd.ts @@ -1,3 +1,3 @@ -import { Novu } from './Novu'; +import { Novu } from './novu'; window.Novu = Novu; diff --git a/packages/js/src/utils/base-module.ts b/packages/js/src/utils/base-module.ts new file mode 100644 index 00000000000..fe170266a46 --- /dev/null +++ b/packages/js/src/utils/base-module.ts @@ -0,0 +1,48 @@ +import { ApiService } from 'client/dist/cjs'; +import { NovuEventEmitter } from '../event-emitter'; + +interface CallQueueItem { + fn: () => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve: (value: any | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +export class BaseModule { + _apiService: ApiService; + _emitter: NovuEventEmitter; + #callsQueue: CallQueueItem[] = []; + #sessionError: unknown; + + constructor(emitter: NovuEventEmitter, apiService: ApiService) { + this._emitter = emitter; + this._apiService = apiService; + this._emitter.on('session.initialize.success', () => { + this.#callsQueue.forEach(async ({ fn, resolve }) => { + resolve(await fn()); + }); + this.#callsQueue = []; + }); + this._emitter.on('session.initialize.error', ({ error }) => { + this.#sessionError = error; + this.#callsQueue.forEach(({ reject }) => { + reject(error); + }); + this.#callsQueue = []; + }); + } + + async callWithSession(fn: () => Promise): Promise { + if (this._apiService.isAuthenticated) { + return fn(); + } + + if (this.#sessionError) { + return Promise.reject(this.#sessionError); + } + + return new Promise(async (resolve, reject) => { + this.#callsQueue.push({ fn, resolve, reject }); + }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 179e8303ef9..81a50b09b7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38962,7 +38962,7 @@ snapshots: '@emotion/babel-plugin@11.11.0': dependencies: - '@babel/helper-module-imports': 7.22.15 + '@babel/helper-module-imports': 7.24.3 '@babel/runtime': 7.23.2 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 @@ -72565,7 +72565,7 @@ snapshots: terser-webpack-plugin@5.3.9(@swc/core@1.3.107(@swc/helpers@0.5.2))(esbuild@0.18.20)(webpack@5.78.0(@swc/core@1.3.107(@swc/helpers@0.5.2))(esbuild@0.18.20)): dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.1.2 serialize-javascript: 6.0.1 @@ -72599,7 +72599,7 @@ snapshots: terser-webpack-plugin@5.3.9(@swc/core@1.3.107)(esbuild@0.18.17)(webpack@5.88.2(@swc/core@1.3.107)): dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.1.2 serialize-javascript: 6.0.1 @@ -72622,7 +72622,7 @@ snapshots: terser-webpack-plugin@5.3.9(@swc/core@1.3.107)(webpack@5.78.0(@swc/core@1.3.107)): dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.1.2 serialize-javascript: 6.0.1