From df89d10ad713655fd7f03f9fd4730e3f8dde5f9b Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 20:28:27 +0530 Subject: [PATCH 01/25] feat: implemented global preference usecase --- ...bscriber-global-preferences-request.dto.ts | 20 ++++ .../app/subscribers/subscribers.controller.ts | 29 +++++ .../api/src/app/subscribers/usecases/index.ts | 4 + .../index.ts | 2 + ...e-subscriber-global-preferences.command.ts | 12 ++ ...e-subscriber-global-preferences.usecase.ts | 105 ++++++++++++++++++ 6 files changed, 172 insertions(+) create mode 100644 apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts create mode 100644 apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts create mode 100644 apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts create mode 100644 apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts new file mode 100644 index 00000000000..faa7e8928bb --- /dev/null +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; +import { ChannelPreference } from '../../shared/dtos/channel-preference'; + +export class UpdateSubscriberGlobalPreferencesRequestDto { + @ApiPropertyOptional({ + description: 'Enable or disable the subscriber global preferences.', + type: Boolean, + }) + @IsBoolean() + @IsOptional() + enabled?: boolean; + + @ApiPropertyOptional({ + type: [ChannelPreference], + description: 'The subscriber global preferences for every ChannelTypeEnum.', + isArray: true, + }) + preferences?: ChannelPreference[]; +} diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 85f0b987bb1..ce42a60efc4 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -83,6 +83,11 @@ import { MarkAllMessagesAs } from '../widgets/usecases/mark-all-messages-as/mark import { MarkAllMessageAsRequestDto } from './dtos/mark-all-messages-as-request.dto'; import { BulkCreateSubscribers } from './usecases/bulk-create-subscribers/bulk-create-subscribers.usecase'; import { BulkCreateSubscribersCommand } from './usecases/bulk-create-subscribers'; +import { + UpdateSubscriberGlobalPreferences, + UpdateSubscriberGlobalPreferencesCommand, +} from './usecases/update-subscriber-global-preferences'; +import { UpdateSubscriberGlobalPreferencesRequestDto } from './dtos/update-subscriber-global-preferences-request.dto'; @Controller('/subscribers') @ApiTags('Subscribers') @@ -97,6 +102,7 @@ export class SubscribersController { private getSubscribersUsecase: GetSubscribers, private getPreferenceUsecase: GetPreferences, private updatePreferenceUsecase: UpdatePreference, + private updateGlobalPreferenceUsecase: UpdateSubscriberGlobalPreferences, private getNotificationsFeedUsecase: GetNotificationsFeed, private getFeedCountUsecase: GetFeedCount, private markMessageAsUsecase: MarkMessageAs, @@ -370,6 +376,29 @@ export class SubscribersController { return await this.updatePreferenceUsecase.execute(command); } + @Patch('/:subscriberId/preferences') + @ExternalApiAccessible() + @UseGuards(JwtAuthGuard) + @ApiResponse(UpdateSubscriberPreferenceResponseDto) + @ApiOperation({ + summary: 'Update subscriber global preferences', + }) + async updateSubscriberGlobalPreferences( + @UserSession() user: IJwtPayload, + @Param('subscriberId') subscriberId: string, + @Body() body: UpdateSubscriberGlobalPreferencesRequestDto + ) { + const command = UpdateSubscriberGlobalPreferencesCommand.create({ + organizationId: user.organizationId, + subscriberId: subscriberId, + environmentId: user.environmentId, + enabled: body.enabled, + preferences: body.preferences, + }); + + return await this.updateGlobalPreferenceUsecase.execute(command); + } + @ExternalApiAccessible() @UseGuards(JwtAuthGuard) @Get('/:subscriberId/notifications/feed') diff --git a/apps/api/src/app/subscribers/usecases/index.ts b/apps/api/src/app/subscribers/usecases/index.ts index 2035c03160e..a7ea18deadf 100644 --- a/apps/api/src/app/subscribers/usecases/index.ts +++ b/apps/api/src/app/subscribers/usecases/index.ts @@ -3,6 +3,7 @@ import { GetSubscriberTemplatePreference, UpdateSubscriber, CreateSubscriber, + GetSubscriberGlobalPreference, } from '@novu/application-generic'; import { GetSubscribers } from './get-subscribers'; @@ -18,6 +19,7 @@ import { ChatOauth } from './chat-oauth/chat-oauth.usecase'; import { ChatOauthCallback } from './chat-oauth-callback/chat-oauth-callback.usecase'; import { DeleteSubscriberCredentials } from './delete-subscriber-credentials/delete-subscriber-credentials.usecase'; import { BulkCreateSubscribers } from './bulk-create-subscribers/bulk-create-subscribers.usecase'; +import { UpdateSubscriberGlobalPreferences } from './update-subscriber-global-preferences'; export { SearchByExternalSubscriberIds, @@ -42,4 +44,6 @@ export const USE_CASES = [ ChatOauth, DeleteSubscriberCredentials, BulkCreateSubscribers, + UpdateSubscriberGlobalPreferences, + GetSubscriberGlobalPreference, ]; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts new file mode 100644 index 00000000000..59adeb0c6ff --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts @@ -0,0 +1,2 @@ +export * from './update-subscriber-global-preferences.command'; +export * from './update-subscriber-global-preferences.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts new file mode 100644 index 00000000000..2669a51c24d --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; +import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; +import { ChannelPreference } from '../../../shared/dtos/channel-preference'; + +export class UpdateSubscriberGlobalPreferencesCommand extends EnvironmentWithSubscriber { + @IsBoolean() + @IsOptional() + enabled?: boolean; + + @IsDefined() + preferences?: ChannelPreference[]; +} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts new file mode 100644 index 00000000000..520d79fdea7 --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts @@ -0,0 +1,105 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + PreferenceLevelEnum, + SubscriberEntity, + SubscriberPreferenceEntity, + SubscriberPreferenceRepository, + SubscriberRepository, +} from '@novu/dal'; +import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand } from '@novu/application-generic'; + +import { UpdateSubscriberGlobalPreferencesCommand } from './update-subscriber-global-preferences.command'; + +@Injectable() +export class UpdateSubscriberGlobalPreferences { + constructor( + private subscriberPreferenceRepository: SubscriberPreferenceRepository, + private subscriberRepository: SubscriberRepository, + private getSubscriberGlobalPreference: GetSubscriberGlobalPreference + ) {} + + async execute(command: UpdateSubscriberGlobalPreferencesCommand) { + const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); + if (!subscriber) throw new NotFoundException(`Subscriber not found`); + + const userGlobalPreference = await this.subscriberPreferenceRepository.findOne({ + _organizationId: command.organizationId, + _environmentId: command.environmentId, + _subscriberId: subscriber._id, + + level: PreferenceLevelEnum.GLOBAL, + }); + + if (!userGlobalPreference) { + await this.createUserPreference(command, subscriber); + } else { + await this.updateUserPreference(command, subscriber); + } + + return await this.getSubscriberGlobalPreference.execute( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }) + ); + } + + private async createUserPreference( + command: UpdateSubscriberGlobalPreferencesCommand, + subscriber: SubscriberEntity + ): Promise { + const channelObj = {} as Record<'email' | 'sms' | 'in_app' | 'chat' | 'push', boolean>; + if (command.preferences && command.preferences.length > 0) { + for (const preference of command.preferences) { + if (preference.type) { + channelObj[preference.type] = preference.enabled; + } + } + } + + await this.subscriberPreferenceRepository.create({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _subscriberId: subscriber._id, + /* + * Unless explicitly set to false when creating a user preference we want it to be enabled + * even if not passing at first enabled to true. + */ + enabled: command.enabled !== false, + channels: command.preferences && command.preferences.length > 0 ? channelObj : null, + level: PreferenceLevelEnum.GLOBAL, + }); + } + + private async updateUserPreference( + command: UpdateSubscriberGlobalPreferencesCommand, + subscriber: SubscriberEntity + ): Promise { + const updatePayload: Partial = {}; + + if (command.enabled != null) { + updatePayload.enabled = command.enabled; + } + + if (command.preferences && command.preferences.length > 0) { + for (const preference of command.preferences) { + if (preference.type) { + updatePayload[`channels.${preference.type}`] = preference.enabled; + } + } + } + + await this.subscriberPreferenceRepository.update( + { + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _subscriberId: subscriber._id, + level: PreferenceLevelEnum.GLOBAL, + }, + { + $set: updatePayload, + } + ); + } +} From 2002b3f792963688a405e99e0601be0a90cc47e1 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 20:30:24 +0530 Subject: [PATCH 02/25] feat: implemented it on sdks and headless --- .../api/src/app/widgets/widgets.controller.ts | 23 ++ .../send-message/send-message.usecase.ts | 30 ++- .../src/app/workflow/workflow.module.ts | 2 + .../subscriber-preference.entity.ts | 9 +- .../subscriber-preference.schema.ts | 6 +- .../create-execution-details/types/index.ts | 1 + ...et-subscriber-global-preference.command.ts | 7 + ...et-subscriber-global-preference.usecase.ts | 68 ++++++ .../get-subscriber-global-preference/index.ts | 2 + .../application-generic/src/usecases/index.ts | 1 + packages/client/src/api/api.service.ts | 13 ++ packages/client/src/index.ts | 10 +- packages/headless/src/lib/headless.service.ts | 66 +++++- packages/headless/src/lib/types.ts | 5 + packages/headless/src/utils/query-keys.ts | 1 + .../notification-center/src/hooks/index.ts | 1 + .../src/hooks/queryKeys.ts | 1 + .../useFetchUserGlobalPreferencesQueryKey.ts | 11 + .../hooks/useUpdateUserGlobalPreferences.ts | 71 ++++++ pnpm-lock.yaml | 205 ++++++------------ 20 files changed, 395 insertions(+), 138 deletions(-) create mode 100644 packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts create mode 100644 packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts create mode 100644 packages/application-generic/src/usecases/get-subscriber-global-preference/index.ts create mode 100644 packages/notification-center/src/hooks/useFetchUserGlobalPreferencesQueryKey.ts create mode 100644 packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts index decc1d30260..6b31ef47569 100644 --- a/apps/api/src/app/widgets/widgets.controller.ts +++ b/apps/api/src/app/widgets/widgets.controller.ts @@ -54,6 +54,11 @@ import { LimitPipe } from './pipes/limit-pipe/limit-pipe'; import { RemoveAllMessagesCommand } from './usecases/remove-messages/remove-all-messages.command'; import { RemoveAllMessages } from './usecases/remove-messages/remove-all-messages.usecase'; import { RemoveAllMessagesDto } from './dtos/remove-all-messages.dto'; +import { + UpdateSubscriberGlobalPreferences, + UpdateSubscriberGlobalPreferencesCommand, +} from '../subscribers/usecases/update-subscriber-global-preferences'; +import { UpdateSubscriberGlobalPreferencesRequestDto } from '../subscribers/dtos/update-subscriber-global-preferences-request.dto'; @Controller('/widgets') @ApiExcludeController() @@ -69,6 +74,7 @@ export class WidgetsController { private getOrganizationUsecase: GetOrganizationData, private getSubscriberPreferenceUsecase: GetSubscriberPreference, private updateSubscriberPreferenceUsecase: UpdateSubscriberPreference, + private updateSubscriberGlobalPreferenceUsecase: UpdateSubscriberGlobalPreferences, private markAllMessagesAsUsecase: MarkAllMessagesAs, private analyticsService: AnalyticsService ) {} @@ -369,6 +375,23 @@ export class WidgetsController { return await this.updateSubscriberPreferenceUsecase.execute(command); } + @UseGuards(AuthGuard('subscriberJwt')) + @Patch('/preferences') + async updateSubscriberGlobalPreference( + @SubscriberSession() subscriberSession: SubscriberEntity, + @Body() body: UpdateSubscriberGlobalPreferencesRequestDto + ) { + const command = UpdateSubscriberGlobalPreferencesCommand.create({ + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession.subscriberId, + environmentId: subscriberSession._environmentId, + preferences: body.preferences, + enabled: body.enabled, + }); + + return await this.updateSubscriberGlobalPreferenceUsecase.execute(command); + } + @UseGuards(AuthGuard('subscriberJwt')) @Post('/usage/log') async logUsage( diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts index d68528ac9f0..6ed91fe6d94 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts @@ -19,6 +19,8 @@ import { GetSubscriberTemplatePreference, GetSubscriberTemplatePreferenceCommand, Instrument, + GetSubscriberGlobalPreference, + GetSubscriberGlobalPreferenceCommand, } from '@novu/application-generic'; import { JobEntity, @@ -51,6 +53,7 @@ export class SendMessage { private digest: Digest, private createExecutionDetails: CreateExecutionDetails, private getSubscriberTemplatePreferenceUsecase: GetSubscriberTemplatePreference, + private getSubscriberGlobalPreferenceUsecase: GetSubscriberGlobalPreference, private notificationTemplateRepository: NotificationTemplateRepository, private jobRepository: JobRepository, private sendMessageDelay: SendMessageDelay, @@ -200,6 +203,32 @@ export class SendMessage { }); if (!subscriber) throw new PlatformException('Subscriber not found with id ' + job._subscriberId); + const { preference: globalPreference } = await this.getSubscriberGlobalPreferenceUsecase.execute( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: job._organizationId, + environmentId: job._environmentId, + subscriberId: job.subscriberId, + }) + ); + + const globalPreferenceResult = this.stepPreferred(globalPreference, job); + + if (!globalPreferenceResult) { + await this.createExecutionDetails.execute( + CreateExecutionDetailsCommand.create({ + ...CreateExecutionDetailsCommand.getDetailsFromJob(job), + detail: DetailEnum.STEP_FILTERED_BY_GLOBAL_PREFERENCES, + source: ExecutionDetailsSourceEnum.INTERNAL, + status: ExecutionDetailsStatusEnum.SUCCESS, + isTest: false, + isRetry: false, + raw: JSON.stringify(globalPreference), + }) + ); + + return false; + } + const buildCommand = GetSubscriberTemplatePreferenceCommand.create({ organizationId: job._organizationId, subscriberId: subscriber.subscriberId, @@ -209,7 +238,6 @@ export class SendMessage { }); const { preference } = await this.getSubscriberTemplatePreferenceUsecase.execute(buildCommand); - const result = this.stepPreferred(preference, job); if (!result) { diff --git a/apps/worker/src/app/workflow/workflow.module.ts b/apps/worker/src/app/workflow/workflow.module.ts index 5c60966f028..4a2c00b6e5f 100644 --- a/apps/worker/src/app/workflow/workflow.module.ts +++ b/apps/worker/src/app/workflow/workflow.module.ts @@ -15,6 +15,7 @@ import { GetNovuLayout, GetNovuProviderCredentials, GetSubscriberPreference, + GetSubscriberGlobalPreference, GetSubscriberTemplatePreference, ProcessTenant, OldInstanceBullMqService, @@ -80,6 +81,7 @@ const USE_CASES = [ GetNovuProviderCredentials, SelectIntegration, GetSubscriberPreference, + GetSubscriberGlobalPreference, GetSubscriberTemplatePreference, HandleLastFailedJob, MessageMatcher, diff --git a/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts b/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts index eb8c1a7ac13..7576c2d048a 100644 --- a/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts +++ b/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts @@ -13,14 +13,21 @@ export class SubscriberPreferenceEntity { _subscriberId: string; - _templateId: string; + _templateId?: string; enabled: boolean; channels: IPreferenceChannels; + + level: PreferenceLevelEnum; } export type SubscriberPreferenceDBModel = ChangePropsValueType< SubscriberPreferenceEntity, '_environmentId' | '_organizationId' | '_subscriberId' | '_templateId' >; + +export enum PreferenceLevelEnum { + GLOBAL = 'GLOBAL', + TEMPLATE = 'TEMPLATE', +} diff --git a/libs/dal/src/repositories/subscriber-preference/subscriber-preference.schema.ts b/libs/dal/src/repositories/subscriber-preference/subscriber-preference.schema.ts index 4851e68efbf..f53ccc71729 100644 --- a/libs/dal/src/repositories/subscriber-preference/subscriber-preference.schema.ts +++ b/libs/dal/src/repositories/subscriber-preference/subscriber-preference.schema.ts @@ -2,7 +2,7 @@ import * as mongoose from 'mongoose'; import { Schema } from 'mongoose'; import { schemaOptions } from '../schema-default.options'; -import { SubscriberPreferenceDBModel } from './subscriber-preference.entity'; +import { PreferenceLevelEnum, SubscriberPreferenceDBModel } from './subscriber-preference.entity'; const subscriberPreferenceSchema = new Schema( { @@ -47,6 +47,10 @@ const subscriberPreferenceSchema = new Schema( type: Schema.Types.Boolean, }, }, + level: { + type: Schema.Types.String, + enum: PreferenceLevelEnum, + }, }, schemaOptions ); diff --git a/packages/application-generic/src/usecases/create-execution-details/types/index.ts b/packages/application-generic/src/usecases/create-execution-details/types/index.ts index 1c89c8ab2b0..73135d68abd 100644 --- a/packages/application-generic/src/usecases/create-execution-details/types/index.ts +++ b/packages/application-generic/src/usecases/create-execution-details/types/index.ts @@ -29,6 +29,7 @@ export enum DetailEnum { DIGESTED_EVENTS_PROVIDED = 'Steps to get digest events found', DIGEST_TRIGGERED_EVENTS = 'Digest triggered events', STEP_FILTERED_BY_PREFERENCES = 'Step filtered by subscriber preferences', + STEP_FILTERED_BY_GLOBAL_PREFERENCES = 'Step filtered by subscriber global preferences', WEBHOOK_FILTER_FAILED_RETRY = 'Webhook filter failed, retry will be executed', WEBHOOK_FILTER_FAILED_LAST_RETRY = 'Failed to get response from remote webhook filter on last retry', DIGEST_MERGED = 'Digest was merged with other digest', diff --git a/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts new file mode 100644 index 00000000000..54bf8e703cf --- /dev/null +++ b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts @@ -0,0 +1,7 @@ +import { SubscriberEntity } from '@novu/dal'; + +import { EnvironmentWithSubscriber } from '../../commands'; + +export class GetSubscriberGlobalPreferenceCommand extends EnvironmentWithSubscriber { + subscriber?: SubscriberEntity; +} diff --git a/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts new file mode 100644 index 00000000000..d1effba616d --- /dev/null +++ b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { + PreferenceLevelEnum, + SubscriberEntity, + SubscriberPreferenceRepository, + SubscriberRepository, +} from '@novu/dal'; + +import { GetSubscriberGlobalPreferenceCommand } from './get-subscriber-global-preference.command'; +import { buildSubscriberKey, CachedEntity } from '../../services/cache'; +import { ApiException } from '../../utils/exceptions'; + +@Injectable() +export class GetSubscriberGlobalPreference { + constructor( + private subscriberPreferenceRepository: SubscriberPreferenceRepository, + private subscriberRepository: SubscriberRepository + ) {} + + async execute(command: GetSubscriberGlobalPreferenceCommand) { + const subscriber = + command.subscriber ?? + (await this.fetchSubscriber({ + subscriberId: command.subscriberId, + _environmentId: command.environmentId, + })); + + if (!subscriber) { + throw new ApiException(`Subscriber ${command.subscriberId} not found`); + } + + const subscriberPreference = + await this.subscriberPreferenceRepository.findOne({ + _environmentId: command.environmentId, + _subscriberId: subscriber._id, + level: PreferenceLevelEnum.GLOBAL, + }); + + const subscriberChannelPreference = subscriberPreference?.channels; + + return { + preference: { + enabled: subscriberPreference?.enabled ?? true, + channels: subscriberChannelPreference ?? {}, + }, + }; + } + + @CachedEntity({ + builder: (command: { subscriberId: string; _environmentId: string }) => + buildSubscriberKey({ + _environmentId: command._environmentId, + subscriberId: command.subscriberId, + }), + }) + private async fetchSubscriber({ + subscriberId, + _environmentId, + }: { + subscriberId: string; + _environmentId: string; + }): Promise { + return await this.subscriberRepository.findBySubscriberId( + _environmentId, + subscriberId + ); + } +} diff --git a/packages/application-generic/src/usecases/get-subscriber-global-preference/index.ts b/packages/application-generic/src/usecases/get-subscriber-global-preference/index.ts new file mode 100644 index 00000000000..5393899f274 --- /dev/null +++ b/packages/application-generic/src/usecases/get-subscriber-global-preference/index.ts @@ -0,0 +1,2 @@ +export * from './get-subscriber-global-preference.usecase'; +export * from './get-subscriber-global-preference.command'; diff --git a/packages/application-generic/src/usecases/index.ts b/packages/application-generic/src/usecases/index.ts index cec41c573b7..bbbf703b03e 100644 --- a/packages/application-generic/src/usecases/index.ts +++ b/packages/application-generic/src/usecases/index.ts @@ -28,3 +28,4 @@ export * from './conditions-filter'; export * from './switch-environment'; export * from './switch-organization'; export * from './create-user'; +export * from './get-subscriber-global-preference'; diff --git a/packages/client/src/api/api.service.ts b/packages/client/src/api/api.service.ts index 081323989b3..11d86794ec4 100644 --- a/packages/client/src/api/api.service.ts +++ b/packages/client/src/api/api.service.ts @@ -166,4 +166,17 @@ export class ApiService { channel: { type: channelType, enabled }, }); } + + async updateSubscriberGlobalPreference( + preferences: { channelType: string; enabled: boolean }[], + enabled?: boolean + ): Promise { + return await this.httpClient.patch(`/widgets/preferences`, { + preferences: preferences.map((preference) => ({ + ...preference, + type: preference.channelType, + })), + enabled, + }); + } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 038b8858bd7..0d63330f163 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -38,7 +38,15 @@ export interface IUserPreferenceSettings { tags?: string[]; data?: NotificationTemplateCustomData; }; - preference: { enabled: boolean; channels: IPreferenceChannels }; + preference: PreferenceSettingsType; } +export interface IUserGlobalPreferenceSettings { + preference: PreferenceSettingsType; +} + +export type PreferenceSettingsType = { + enabled: boolean; + channels: IPreferenceChannels; +}; export { ApiService } from './api/api.service'; diff --git a/packages/headless/src/lib/headless.service.ts b/packages/headless/src/lib/headless.service.ts index 08c90bd0c67..fb62332ea66 100644 --- a/packages/headless/src/lib/headless.service.ts +++ b/packages/headless/src/lib/headless.service.ts @@ -5,7 +5,12 @@ import { MutationObserverResult, } from '@tanstack/query-core'; import io from 'socket.io-client'; -import { ApiService, IUserPreferenceSettings, IStoreQuery } from '@novu/client'; +import { + ApiService, + IUserPreferenceSettings, + IStoreQuery, + IUserGlobalPreferenceSettings, +} from '@novu/client'; import { IOrganizationEntity, IMessage, @@ -21,6 +26,7 @@ import { SESSION_QUERY_KEY, UNREAD_COUNT_QUERY_KEY, UNSEEN_COUNT_QUERY_KEY, + USER_GLOBAL_PREFERENCES_QUERY_KEY, USER_PREFERENCES_QUERY_KEY, } from '../utils'; import { @@ -30,6 +36,7 @@ import { IMessageId, IUpdateActionVariables, IUpdateUserPreferencesVariables, + IUpdateUserGlobalPreferencesVariables, UpdateResult, } from './types'; @@ -523,6 +530,63 @@ export class HeadlessService { }); } + public async updateUserGlobalPreferences({ + preferences, + enabled, + listener, + onSuccess, + onError, + }: { + preferences: IUpdateUserGlobalPreferencesVariables['preferences']; + enabled?: IUpdateUserGlobalPreferencesVariables['enabled']; + listener: ( + result: UpdateResult< + IUserGlobalPreferenceSettings, + unknown, + IUpdateUserGlobalPreferencesVariables + > + ) => void; + onSuccess?: (settings: IUserGlobalPreferenceSettings) => void; + onError?: (error: unknown) => void; + }) { + this.assertSessionInitialized(); + + const { result, unsubscribe } = this.queryService.subscribeMutation< + IUserGlobalPreferenceSettings, + unknown, + IUpdateUserGlobalPreferencesVariables + >({ + options: { + mutationFn: (variables) => + this.api.updateSubscriberGlobalPreference( + variables.preferences, + variables.enabled + ), + onSuccess: (data) => { + this.queryClient.setQueryData( + USER_GLOBAL_PREFERENCES_QUERY_KEY, + () => data + ); + }, + }, + listener: (res) => this.callUpdateListener(res, listener), + }); + + result + .mutate({ preferences, enabled }) + .then((data) => { + onSuccess?.(data); + + return data; + }) + .catch((error) => { + onError?.(error); + }) + .finally(() => { + unsubscribe(); + }); + } + public async markNotificationsAsRead({ messageId, listener, diff --git a/packages/headless/src/lib/types.ts b/packages/headless/src/lib/types.ts index 1a590248d54..05c7f8e868c 100644 --- a/packages/headless/src/lib/types.ts +++ b/packages/headless/src/lib/types.ts @@ -22,6 +22,11 @@ export interface IUpdateUserPreferencesVariables { checked: boolean; } +export interface IUpdateUserGlobalPreferencesVariables { + preferences: { channelType: string; enabled: boolean }[]; + enabled?: boolean; +} + export interface IUpdateActionVariables { messageId: string; actionButtonType: ButtonTypeEnum; diff --git a/packages/headless/src/utils/query-keys.ts b/packages/headless/src/utils/query-keys.ts index 2b24642c8e5..7e57fc69779 100644 --- a/packages/headless/src/utils/query-keys.ts +++ b/packages/headless/src/utils/query-keys.ts @@ -2,5 +2,6 @@ export const SESSION_QUERY_KEY = ['session']; export const ORGANIZATION_QUERY_KEY = ['organization']; export const NOTIFICATIONS_QUERY_KEY = ['notifications']; export const USER_PREFERENCES_QUERY_KEY = ['user_preferences']; +export const USER_GLOBAL_PREFERENCES_QUERY_KEY = ['user_global_preferences']; export const UNSEEN_COUNT_QUERY_KEY = ['unseen_count']; export const UNREAD_COUNT_QUERY_KEY = ['unread_count']; diff --git a/packages/notification-center/src/hooks/index.ts b/packages/notification-center/src/hooks/index.ts index 400f8aa67f4..30e3e427de0 100644 --- a/packages/notification-center/src/hooks/index.ts +++ b/packages/notification-center/src/hooks/index.ts @@ -6,6 +6,7 @@ export * from './useNovuTheme'; export * from './useNotificationCenter'; export * from './useTranslations'; export * from './useUpdateUserPreferences'; +export * from './useUpdateUserGlobalPreferences'; export * from './useUpdateAction'; export * from './useFetchNotifications'; export * from './useFetchOrganization'; diff --git a/packages/notification-center/src/hooks/queryKeys.ts b/packages/notification-center/src/hooks/queryKeys.ts index 78da8305273..86ac597d55b 100644 --- a/packages/notification-center/src/hooks/queryKeys.ts +++ b/packages/notification-center/src/hooks/queryKeys.ts @@ -2,5 +2,6 @@ export const SESSION_QUERY_KEY = ['session']; export const ORGANIZATION_QUERY_KEY = ['organization']; export const INFINITE_NOTIFICATIONS_QUERY_KEY = ['infinite_notifications']; export const USER_PREFERENCES_QUERY_KEY = ['user_preferences']; +export const USER_GLOBAL_PREFERENCES_QUERY_KEY = ['user_global_preferences']; export const UNSEEN_COUNT_QUERY_KEY = ['unseen_count']; export const FEED_UNSEEN_COUNT_QUERY_KEY = ['feed_unseen_count']; diff --git a/packages/notification-center/src/hooks/useFetchUserGlobalPreferencesQueryKey.ts b/packages/notification-center/src/hooks/useFetchUserGlobalPreferencesQueryKey.ts new file mode 100644 index 00000000000..ad77a688a3c --- /dev/null +++ b/packages/notification-center/src/hooks/useFetchUserGlobalPreferencesQueryKey.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +import { USER_GLOBAL_PREFERENCES_QUERY_KEY } from './queryKeys'; +import { useSetQueryKey } from './useSetQueryKey'; + +export const useFetchUserGlobalPreferencesQueryKey = () => { + const setQueryKey = useSetQueryKey(); + const queryKey = useMemo(() => setQueryKey([...USER_GLOBAL_PREFERENCES_QUERY_KEY]), [setQueryKey]); + + return queryKey; +}; diff --git a/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts b/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts new file mode 100644 index 00000000000..93e32a46812 --- /dev/null +++ b/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts @@ -0,0 +1,71 @@ +import type { IUserGlobalPreferenceSettings } from '@novu/client'; +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import { useFetchUserGlobalPreferencesQueryKey } from './useFetchUserGlobalPreferencesQueryKey'; +import { useNovuContext } from './useNovuContext'; + +interface IUpdateUserGlobalPreferencesVariables { + preferences: { channelType: string; enabled: boolean }[]; + enabled?: boolean; +} + +export const useUpdateUserGlobalPreferences = ({ + onSuccess, + onError, + ...options +}: UseMutationOptions = {}) => { + const queryClient = useQueryClient(); + const { apiService } = useNovuContext(); + const userGlobalPreferencesQueryKey = useFetchUserGlobalPreferencesQueryKey(); + + const updateGlobalPreferenceChecked = useCallback( + ({ enabled, preferences }: IUpdateUserGlobalPreferencesVariables) => { + queryClient.setQueryData(userGlobalPreferencesQueryKey, (old) => { + return { + preference: { + enabled: enabled ?? old.preference.enabled, + channels: { + ...old.preference.channels, + ...preferences.reduce((acc, { channelType, enabled: channelEnabled }) => { + acc[channelType] = channelEnabled; + + return acc; + }, {} as Record), + }, + }, + }; + }); + }, + [queryClient] + ); + + const { mutate, ...result } = useMutation< + IUserGlobalPreferenceSettings, + Error, + IUpdateUserGlobalPreferencesVariables + >((variables) => apiService.updateSubscriberGlobalPreference(variables.preferences, variables.enabled), { + ...options, + onSuccess: (data, variables, context) => { + queryClient.setQueryData(userGlobalPreferencesQueryKey, () => data); + onSuccess?.(data, variables, context); + }, + onError: (error, variables, context) => { + updateGlobalPreferenceChecked({ + enabled: !variables.enabled ?? undefined, + preferences: variables.preferences, + }); + onError?.(error, variables, context); + }, + }); + + const updateUserGlobalPreferences = useCallback( + (variables: IUpdateUserGlobalPreferencesVariables) => { + updateGlobalPreferenceChecked(variables); + mutate(variables); + }, + [updateGlobalPreferenceChecked, mutate] + ); + + return { ...result, updateUserGlobalPreferences }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f21418e9cc..4e5d7a41a6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,82 +1073,81 @@ importers: tsconfig-paths: 4.1.2 typescript: 4.9.5 - enterprise/packages/auth: + docs: specifiers: - '@nestjs/common': '>=9.3.x' - '@nestjs/jwt': '>=9' - '@nestjs/passport': 9.0.3 - '@novu/application-generic': ^0.19.0 - '@novu/dal': ^0.19.0 - '@novu/shared': ^0.19.0 - '@types/chai': ^4.2.11 - '@types/mocha': ^8.0.1 - '@types/node': ^14.6.0 - '@types/sinon': ^9.0.0 - chai: ^4.2.0 - cross-env: ^7.0.3 - mocha: ^8.1.1 - nodemon: ^2.0.3 - passport: 0.6.0 - passport-google-oauth: ^2.0.0 - passport-oauth2: ^1.6.1 - sinon: ^9.2.4 - ts-node: ~10.9.1 - typescript: 4.9.5 - dependencies: - '@nestjs/common': 10.2.2_atc7tu2sld2m3nk4hmwkqn6qde - '@nestjs/jwt': 10.1.0_@nestjs+common@10.2.2 - '@nestjs/passport': 9.0.3_kn4ljbedllcoqpuu4ifhphsdsu - '@novu/application-generic': link:../../../packages/application-generic - '@novu/dal': link:../../../libs/dal - '@novu/shared': link:../../../libs/shared - passport: 0.6.0 - passport-google-oauth: 2.0.0 - passport-oauth2: 1.7.0 - devDependencies: - '@types/chai': 4.3.4 - '@types/mocha': 8.2.3 - '@types/node': 14.18.42 - '@types/sinon': 9.0.11 - chai: 4.3.7 - cross-env: 7.0.3 - mocha: 8.4.0 - nodemon: 2.0.22 - sinon: 9.2.4 - ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna - typescript: 4.9.5 - - enterprise/packages/digest-schedule: - specifiers: - '@novu/shared': ^0.19.0 - '@types/chai': ^4.2.11 - '@types/mocha': ^8.0.1 - '@types/node': ^14.6.0 - '@types/sinon': ^9.0.0 - chai: ^4.2.0 - cross-env: ^7.0.3 - date-fns: ^2.29.2 - mocha: ^8.1.1 - nodemon: ^2.0.3 - rrule: ^2.7.2 - sinon: ^9.2.4 - ts-node: ~10.9.1 + '@docusaurus/core': 2.3.1 + '@docusaurus/module-type-aliases': 2.3.1 + '@docusaurus/plugin-google-gtag': 2.3.1 + '@docusaurus/preset-classic': 2.3.1 + '@docusaurus/theme-common': 2.3.1 + '@mdx-js/react': ^1.6.21 + '@svgr/webpack': ^6.2.1 + '@tsconfig/docusaurus': 1.0.7 + '@types/react': ^17.0.14 + '@types/react-helmet': 6.1.6 + '@types/react-router-dom': 5.3.3 + clsx: ^1.1.1 + docusaurus-plugin-plausible: ^0.0.5 + docusaurus-plugin-sass: 0.2.4 + eslint-config-airbnb-typescript: ^17.0.0 + eslint-config-prettier: ^8.5.0 + file-loader: ^6.2.0 + husky: ^8.0.0 + markdownlint-cli: ^0.33.0 + prettier: ~2.8.0 + prism-react-renderer: ^1.3.1 + react: ^17.0.1 + react-dom: ^17.0.1 + sass: ^1.51.0 + sass-loader: ^13.0.0 + sharp: ^0.31.0 + styled-components: 5.3.11 + stylelint: ^15.0.0 + stylelint-config-css-modules: ^4.1.0 + stylelint-config-recess-order: ^3.0.0 + stylelint-config-recommended-scss: ^6.0.0 + stylelint-config-standard: ^25.0.0 + stylelint-order: ^5.0.0 + stylelint-scss: ^4.2.0 typescript: 4.9.5 + url-loader: ^4.1.1 dependencies: - '@novu/shared': link:../../../libs/shared - date-fns: 2.29.3 - rrule: 2.7.2 + '@docusaurus/core': 2.3.1_7kevo6ecmq3m3j5csvldmvt63m + '@docusaurus/plugin-google-gtag': 2.3.1_7kevo6ecmq3m3j5csvldmvt63m + '@docusaurus/preset-classic': 2.3.1_7hckhe4e7aa4xxsjqgrzg66tfu + '@docusaurus/theme-common': 2.3.1_7kevo6ecmq3m3j5csvldmvt63m + '@mdx-js/react': 1.6.22_react@17.0.2 + '@svgr/webpack': 6.5.1 + clsx: 1.2.1 + docusaurus-plugin-plausible: 0.0.5 + docusaurus-plugin-sass: 0.2.4_wsi5ihigezmhyhjqwfpsgagmau + file-loader: 6.2.0_webpack@5.88.2 + prism-react-renderer: 1.3.5_react@17.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + sass: 1.61.0 + sharp: 0.31.3 + styled-components: 5.3.11_v5ja746gkdtknuc6tj46sve3be + stylelint-config-css-modules: 4.2.0_stylelint@15.10.1 + url-loader: 4.1.1_pbpjnf4ifq5edsddxe3xbm7czm devDependencies: - '@types/chai': 4.3.4 - '@types/mocha': 8.2.3 - '@types/node': 14.18.42 - '@types/sinon': 9.0.11 - chai: 4.3.7 - cross-env: 7.0.3 - mocha: 8.4.0 - nodemon: 2.0.22 - sinon: 9.2.4 - ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna + '@docusaurus/module-type-aliases': 2.3.1_sfoxds7t5ydpegc3knd667wn6m + '@tsconfig/docusaurus': 1.0.7 + '@types/react': 17.0.53 + '@types/react-helmet': 6.1.6 + '@types/react-router-dom': 5.3.3 + eslint-config-airbnb-typescript: 17.0.0_3z3emvisdmqtp5iprvlfwthfia + eslint-config-prettier: 8.8.0_eslint@8.48.0 + husky: 8.0.3 + markdownlint-cli: 0.33.0 + prettier: 2.8.7 + sass-loader: 13.2.2_sass@1.61.0+webpack@5.88.2 + stylelint: 15.10.1 + stylelint-config-recess-order: 3.1.0_stylelint@15.10.1 + stylelint-config-recommended-scss: 6.0.0_2tsnhd7ow7ykkzxj2ufbjyh2ru + stylelint-config-standard: 25.0.0_stylelint@15.10.1 + stylelint-order: 5.0.0_stylelint@15.10.1 + stylelint-scss: 4.6.0_stylelint@15.10.1 typescript: 4.9.5 libs/dal: @@ -13103,26 +13102,6 @@ packages: - webpack-cli dev: true - /@nestjs/common/10.2.2_atc7tu2sld2m3nk4hmwkqn6qde: - resolution: {integrity: sha512-TCOJK2K4FDT3GxFfURjngnjBewS/hizKNFSLBXtX4TTQm0dVQOtESnnVdP14sEiPM6suuWlrGnXW9UDqItGWiQ==} - peerDependencies: - class-transformer: '*' - class-validator: '*' - reflect-metadata: ^0.1.12 - rxjs: ^7.1.0 - peerDependenciesMeta: - class-transformer: - optional: true - class-validator: - optional: true - dependencies: - iterare: 1.2.1 - reflect-metadata: 0.1.13 - rxjs: 7.8.1 - tslib: 2.6.2 - uid: 2.0.2 - dev: false - /@nestjs/common/10.2.2_j3td4gnlgk75ora6o6suo62byy: resolution: {integrity: sha512-TCOJK2K4FDT3GxFfURjngnjBewS/hizKNFSLBXtX4TTQm0dVQOtESnnVdP14sEiPM6suuWlrGnXW9UDqItGWiQ==} peerDependencies: @@ -13352,16 +13331,6 @@ packages: passport: 0.6.0 dev: false - /@nestjs/passport/9.0.3_kn4ljbedllcoqpuu4ifhphsdsu: - resolution: {integrity: sha512-HplSJaimEAz1IOZEu+pdJHHJhQyBOPAYWXYHfAPQvRqWtw4FJF1VXl1Qtk9dcXQX1eKytDtH+qBzNQc19GWNEg==} - peerDependencies: - '@nestjs/common': ^8.0.0 || ^9.0.0 - passport: ^0.4.0 || ^0.5.0 || ^0.6.0 - dependencies: - '@nestjs/common': 10.2.2_atc7tu2sld2m3nk4hmwkqn6qde - passport: 0.6.0 - dev: false - /@nestjs/platform-express/10.2.2_h33h3l6i5mruhsbo3bha6vy2fi: resolution: {integrity: sha512-g5AeXgPQrVm62JOl9FXk0w3Tq1tD4f6ouGikLYs/Aahy0q/Z2HNP9NjXZYpqcjHrpafPYnc3bfBuUwedKW1oHg==} peerDependencies: @@ -27659,7 +27628,7 @@ packages: minimatch: 3.1.2 object.values: 1.1.6 resolve: 1.22.2 - semver: 6.3.0 + semver: 6.3.1 tsconfig-paths: 3.14.2 transitivePeerDependencies: - eslint-import-resolver-typescript @@ -37903,27 +37872,6 @@ packages: passport-oauth2: 1.7.0 dev: false - /passport-google-oauth/2.0.0: - resolution: {integrity: sha512-JKxZpBx6wBQXX1/a1s7VmdBgwOugohH+IxCy84aPTZNq/iIPX6u7Mqov1zY7MKRz3niFPol0KJz8zPLBoHKtYA==} - engines: {node: '>= 0.4.0'} - dependencies: - passport-google-oauth1: 1.0.0 - passport-google-oauth20: 2.0.0 - dev: false - - /passport-google-oauth1/1.0.0: - resolution: {integrity: sha512-qpCEhuflJgYrdg5zZIpAq/K3gTqa1CtHjbubsEsidIdpBPLkEVq6tB1I8kBNcH89RdSiYbnKpCBXAZXX/dtx1Q==} - dependencies: - passport-oauth1: 1.3.0 - dev: false - - /passport-google-oauth20/2.0.0: - resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} - engines: {node: '>= 0.4.0'} - dependencies: - passport-oauth2: 1.7.0 - dev: false - /passport-jwt/4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} dependencies: @@ -37931,15 +37879,6 @@ packages: passport-strategy: 1.0.0 dev: false - /passport-oauth1/1.3.0: - resolution: {integrity: sha512-8T/nX4gwKTw0PjxP1xfD0QhrydQNakzeOpZ6M5Uqdgz9/a/Ag62RmJxnZQ4LkbdXGrRehQHIAHNAu11rCP46Sw==} - engines: {node: '>= 0.4.0'} - dependencies: - oauth: 0.9.15 - passport-strategy: 1.0.0 - utils-merge: 1.0.1 - dev: false - /passport-oauth2/1.7.0: resolution: {integrity: sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==} engines: {node: '>= 0.4.0'} From 6889a86ce5f6af889f4236a5b866ff9dfbb8914c Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 20:31:01 +0530 Subject: [PATCH 03/25] feat: tests --- .../src/app/subscribers/e2e/helpers/index.ts | 9 ++ .../e2e/update-global-preference.e2e.ts | 101 +++++++++++++++ .../headless/src/lib/headless.service.test.ts | 116 +++++++++++++++++- 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts diff --git a/apps/api/src/app/subscribers/e2e/helpers/index.ts b/apps/api/src/app/subscribers/e2e/helpers/index.ts index 4f7ea77b384..0ab317653f7 100644 --- a/apps/api/src/app/subscribers/e2e/helpers/index.ts +++ b/apps/api/src/app/subscribers/e2e/helpers/index.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import { UpdateSubscriberOnlineFlagRequestDto } from '../../dtos/update-subscriber-online-flag-request.dto'; import { UpdateSubscriberPreferenceRequestDto } from '../../../widgets/dtos/update-subscriber-preference-request.dto'; +import { UpdateSubscriberGlobalPreferencesRequestDto } from '../../dtos/update-subscriber-global-preferences-request.dto'; const axiosInstance = axios.create(); @@ -65,3 +66,11 @@ export async function updatePreference( } ); } + +export async function updateGlobalPreferences(data: UpdateSubscriberGlobalPreferencesRequestDto, session: UserSession) { + return await axiosInstance.patch(`${session.serverUrl}/v1/subscribers/${session.subscriberId}/preferences`, data, { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + }); +} diff --git a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts new file mode 100644 index 00000000000..1200730cf95 --- /dev/null +++ b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts @@ -0,0 +1,101 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { NotificationTemplateEntity } from '@novu/dal'; +import { + ChannelTypeEnum, + DigestTypeEnum, + DigestUnitEnum, + IUpdateNotificationTemplateDto, + StepTypeEnum, +} from '@novu/shared'; + +import { getNotificationTemplate, updateNotificationTemplate, getPreference, updateGlobalPreferences } from './helpers'; + +describe('Update Subscribers global preferences - /subscribers/:subscriberId/preferences (PATCH)', function () { + let session: UserSession; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + }); + + it('should send a Bad Request error if preferences property in payload is not right', async function () { + const updateDataEmailFalse = { + preferences: [ + { + type: ChannelTypeEnum.EMAIL, + enabled: 10, + }, + ], + enabled: false, + }; + + try { + const response = await updateGlobalPreferences(updateDataEmailFalse as any, session); + expect(response).to.not.be; + } catch (error) { + expect(error.toJSON()).to.have.include({ + status: 400, + name: 'AxiosError', + message: 'Request failed with status code 400', + }); + } + }); + + it('should send a Bad Request error if enabled property in payload is not right', async function () { + const updateDataEmailFalse = { + preferences: [ + { + type: ChannelTypeEnum.EMAIL, + enabled: false, + }, + ], + enabled: 2, + }; + + try { + const response = await updateGlobalPreferences(updateDataEmailFalse as any, session); + expect(response).to.not.be; + } catch (error) { + expect(error.toJSON()).to.have.include({ + status: 400, + name: 'AxiosError', + message: 'Request failed with status code 400', + }); + } + }); + + it('should update user global preferences', async function () { + const payload = { + enabled: true, + preferences: [{ type: ChannelTypeEnum.EMAIL, enabled: true }], + }; + + const response = await updateGlobalPreferences(payload, session); + + expect(response.data.preference.enabled).to.eql(false); + expect(response.data.preference.channels).to.eql({ + [ChannelTypeEnum.EMAIL]: true, + }); + }); + + it('should update user global preference and disable the flag for the future channels update', async function () { + const disablePreferenceData = { + enabled: false, + }; + + const response = await updateGlobalPreferences(disablePreferenceData, session); + + expect(response.data.preference.enabled).to.eql(false); + + const preferenceChannel = { + preferences: [{ type: ChannelTypeEnum.EMAIL, enabled: true }], + }; + + const res = await updateGlobalPreferences(disablePreferenceData, session); + + expect(res.data.preference.channels).to.eql({ + [ChannelTypeEnum.EMAIL]: true, + }); + }); +}); diff --git a/packages/headless/src/lib/headless.service.test.ts b/packages/headless/src/lib/headless.service.test.ts index 14a44845cf3..9503702994a 100644 --- a/packages/headless/src/lib/headless.service.test.ts +++ b/packages/headless/src/lib/headless.service.test.ts @@ -1,4 +1,8 @@ -import { ApiService, IUserPreferenceSettings } from '@novu/client'; +import { + ApiService, + IUserGlobalPreferenceSettings, + IUserPreferenceSettings, +} from '@novu/client'; import { WebSocketEventEnum } from '@novu/shared'; import io from 'socket.io-client'; @@ -90,6 +94,20 @@ const mockUserPreferenceSetting: IUserPreferenceSettings = { }, }, }; + +const mockUserGlobalPreferenceSetting: IUserGlobalPreferenceSettings = { + preference: { + enabled: true, + channels: { + email: true, + sms: true, + in_app: true, + chat: true, + push: true, + }, + }, +}; + const mockUserPreferences = [mockUserPreferenceSetting]; const mockServiceInstance = { @@ -108,6 +126,9 @@ const mockServiceInstance = { updateSubscriberPreference: jest.fn(() => promiseResolveTimeout(0, mockUserPreferenceSetting) ), + updateSubscriberGlobalPreference: jest.fn(() => + promiseResolveTimeout(0, mockUserGlobalPreferenceSetting) + ), markMessageAs: jest.fn(), removeMessage: jest.fn(), updateAction: jest.fn(), @@ -923,6 +944,99 @@ describe('headless.service', () => { }); }); + describe('updateUserGlobalPreferences', () => { + test('calls updateUserGlobalPreferences successfully', async () => { + const payload = { + enabled: true, + preferences: [ + { + channelType: 'email', + enabled: false, + }, + ], + }; + + const updatedUserGlobalPreferenceSetting = { + preference: { + enabled: true, + channels: { + email: false, + }, + }, + }; + mockServiceInstance.updateSubscriberGlobalPreference.mockImplementationOnce( + () => promiseResolveTimeout(0, updatedUserGlobalPreferenceSetting) + ); + const headlessService = new HeadlessService(options); + + const listener = jest.fn(); + const onSuccess = jest.fn(); + (headlessService as any).session = mockSession; + + headlessService.updateUserGlobalPreferences({ + preferences: payload.preferences, + enabled: payload.enabled, + listener, + onSuccess, + }); + + expect(listener).toBeCalledWith( + expect.objectContaining({ isLoading: true, data: undefined }) + ); + await promiseResolveTimeout(100); + + expect( + mockServiceInstance.updateSubscriberGlobalPreference + ).toBeCalledTimes(1); + + expect(onSuccess).toHaveBeenNthCalledWith( + 1, + updatedUserGlobalPreferenceSetting + ); + }); + + test('handles the error', async () => { + const payload = { + enabled: true, + preferences: [ + { + channelType: 'email', + enabled: true, + }, + ], + }; + + const error = new Error('error'); + mockServiceInstance.updateSubscriberGlobalPreference.mockImplementationOnce( + () => promiseRejectTimeout(0, error) + ); + const headlessService = new HeadlessService(options); + const listener = jest.fn(); + const onError = jest.fn(); + (headlessService as any).session = mockSession; + + headlessService.updateUserGlobalPreferences({ + preferences: payload.preferences, + enabled: payload.enabled, + listener, + onError, + }); + expect(listener).toBeCalledWith( + expect.objectContaining({ isLoading: true, data: undefined }) + ); + await promiseResolveTimeout(100); + + expect( + mockServiceInstance.updateSubscriberGlobalPreference + ).toBeCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + expect(listener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ isLoading: false, data: undefined, error }) + ); + }); + }); + describe('markNotificationsAsRead', () => { test('calls markNotificationsAsRead successfully', async () => { const updatedNotification = { From 278b689aac93f7f51b4be107f03b4a8bd4e8b85c Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 21:20:43 +0530 Subject: [PATCH 04/25] fix: tests --- ...bscriber-global-preferences-request.dto.ts | 6 +- .../e2e/update-global-preference.e2e.ts | 56 ++------------ ...e-subscriber-global-preferences.command.ts | 4 +- pnpm-lock.yaml | 76 +++++++++++++++++-- 4 files changed, 82 insertions(+), 60 deletions(-) diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts index faa7e8928bb..29ae57f002d 100644 --- a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts @@ -1,5 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + import { ChannelPreference } from '../../shared/dtos/channel-preference'; export class UpdateSubscriberGlobalPreferencesRequestDto { @@ -16,5 +17,6 @@ export class UpdateSubscriberGlobalPreferencesRequestDto { description: 'The subscriber global preferences for every ChannelTypeEnum.', isArray: true, }) + @IsOptional() preferences?: ChannelPreference[]; } diff --git a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts index 1200730cf95..8ce87f8bd92 100644 --- a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts @@ -19,52 +19,6 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre await session.initialize(); }); - it('should send a Bad Request error if preferences property in payload is not right', async function () { - const updateDataEmailFalse = { - preferences: [ - { - type: ChannelTypeEnum.EMAIL, - enabled: 10, - }, - ], - enabled: false, - }; - - try { - const response = await updateGlobalPreferences(updateDataEmailFalse as any, session); - expect(response).to.not.be; - } catch (error) { - expect(error.toJSON()).to.have.include({ - status: 400, - name: 'AxiosError', - message: 'Request failed with status code 400', - }); - } - }); - - it('should send a Bad Request error if enabled property in payload is not right', async function () { - const updateDataEmailFalse = { - preferences: [ - { - type: ChannelTypeEnum.EMAIL, - enabled: false, - }, - ], - enabled: 2, - }; - - try { - const response = await updateGlobalPreferences(updateDataEmailFalse as any, session); - expect(response).to.not.be; - } catch (error) { - expect(error.toJSON()).to.have.include({ - status: 400, - name: 'AxiosError', - message: 'Request failed with status code 400', - }); - } - }); - it('should update user global preferences', async function () { const payload = { enabled: true, @@ -73,8 +27,8 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre const response = await updateGlobalPreferences(payload, session); - expect(response.data.preference.enabled).to.eql(false); - expect(response.data.preference.channels).to.eql({ + expect(response.data.data.preference.enabled).to.eql(true); + expect(response.data.data.preference.channels).to.eql({ [ChannelTypeEnum.EMAIL]: true, }); }); @@ -86,15 +40,15 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre const response = await updateGlobalPreferences(disablePreferenceData, session); - expect(response.data.preference.enabled).to.eql(false); + expect(response.data.data.preference.enabled).to.eql(false); const preferenceChannel = { preferences: [{ type: ChannelTypeEnum.EMAIL, enabled: true }], }; - const res = await updateGlobalPreferences(disablePreferenceData, session); + const res = await updateGlobalPreferences(preferenceChannel, session); - expect(res.data.preference.channels).to.eql({ + expect(res.data.data.preference.channels).to.eql({ [ChannelTypeEnum.EMAIL]: true, }); }); diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts index 2669a51c24d..cbb3c529f71 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; +import { IsBoolean, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; import { ChannelPreference } from '../../../shared/dtos/channel-preference'; @@ -7,6 +7,6 @@ export class UpdateSubscriberGlobalPreferencesCommand extends EnvironmentWithSub @IsOptional() enabled?: boolean; - @IsDefined() + @IsOptional() preferences?: ChannelPreference[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e5d7a41a6b..3fe880a4341 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5624,7 +5624,7 @@ packages: dependencies: '@babel/core': 7.20.12 '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.22.5 '@babel/helper-member-expression-to-functions': 7.21.0 '@babel/helper-optimise-call-expression': 7.18.6 @@ -5635,6 +5635,24 @@ packages: - supports-color dev: true + /@babel/helper-create-class-features-plugin/7.21.4_@babel+core@7.21.4: + resolution: {integrity: sha512-46QrX2CQlaFRF4TkwfTt6nJD7IHq8539cCL7SDpqWSDeJKY1xylKKY5F/33mJhLZ3mFvKv2gGrVS6NkyF6qs+Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.21.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.22.5 + '@babel/helper-member-expression-to-functions': 7.21.0 + '@babel/helper-optimise-call-expression': 7.18.6 + '@babel/helper-replace-supers': 7.20.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 + '@babel/helper-split-export-declaration': 7.22.6 + transitivePeerDependencies: + - supports-color + /@babel/helper-create-class-features-plugin/7.21.4_@babel+core@7.22.11: resolution: {integrity: sha512-46QrX2CQlaFRF4TkwfTt6nJD7IHq8539cCL7SDpqWSDeJKY1xylKKY5F/33mJhLZ3mFvKv2gGrVS6NkyF6qs+Q==} engines: {node: '>=6.9.0'} @@ -6184,11 +6202,22 @@ packages: '@babel/core': ^7.13.0 dependencies: '@babel/core': 7.20.12 - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 '@babel/plugin-proposal-optional-chaining': 7.21.0_@babel+core@7.20.12 dev: true + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.20.7_@babel+core@7.21.4: + resolution: {integrity: sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + dependencies: + '@babel/core': 7.21.4 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 + '@babel/plugin-proposal-optional-chaining': 7.21.0_@babel+core@7.21.4 + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.20.7_@babel+core@7.22.11: resolution: {integrity: sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==} engines: {node: '>=6.9.0'} @@ -6540,7 +6569,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.20.12 - '@babel/helper-annotate-as-pure': 7.18.6 + '@babel/helper-annotate-as-pure': 7.22.5 '@babel/helper-create-class-features-plugin': 7.21.4_@babel+core@7.20.12 '@babel/helper-plugin-utils': 7.22.5 '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.20.12 @@ -6548,6 +6577,20 @@ packages: - supports-color dev: true + /@babel/plugin-proposal-private-property-in-object/7.21.0_@babel+core@7.21.4: + resolution: {integrity: sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.21.4_@babel+core@7.21.4 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.21.4 + transitivePeerDependencies: + - supports-color + /@babel/plugin-proposal-private-property-in-object/7.21.0_@babel+core@7.22.11: resolution: {integrity: sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==} engines: {node: '>=6.9.0'} @@ -7111,6 +7154,19 @@ packages: - supports-color dev: true + /@babel/plugin-transform-async-to-generator/7.20.7_@babel+core@7.21.4: + resolution: {integrity: sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.4 + '@babel/helper-module-imports': 7.22.15 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-remap-async-to-generator': 7.18.9_@babel+core@7.21.4 + transitivePeerDependencies: + - supports-color + /@babel/plugin-transform-async-to-generator/7.20.7_@babel+core@7.22.11: resolution: {integrity: sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==} engines: {node: '>=6.9.0'} @@ -7285,6 +7341,16 @@ packages: '@babel/template': 7.22.15 dev: true + /@babel/plugin-transform-computed-properties/7.20.7_@babel+core@7.21.4: + resolution: {integrity: sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.21.4 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/template': 7.22.15 + /@babel/plugin-transform-computed-properties/7.20.7_@babel+core@7.22.11: resolution: {integrity: sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==} engines: {node: '>=6.9.0'} @@ -24837,7 +24903,7 @@ packages: resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} /concat-map/0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /concat-stream/1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -27628,7 +27694,7 @@ packages: minimatch: 3.1.2 object.values: 1.1.6 resolve: 1.22.2 - semver: 6.3.1 + semver: 6.3.0 tsconfig-paths: 3.14.2 transitivePeerDependencies: - eslint-import-resolver-typescript From 3c1392cb3c7ef3eb0015aecb734d87b97124654f Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 21:27:08 +0530 Subject: [PATCH 05/25] docs: headless doc --- .../headless/api-reference.md | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 docs/docs/notification-center/headless/api-reference.md diff --git a/docs/docs/notification-center/headless/api-reference.md b/docs/docs/notification-center/headless/api-reference.md new file mode 100644 index 00000000000..955fe079ae0 --- /dev/null +++ b/docs/docs/notification-center/headless/api-reference.md @@ -0,0 +1,630 @@ +--- +sidebar_position: 2 +sidebar_label: API Reference +--- + +# Headless Notification Center API Reference + +This page contains the complete documentation about the Headless Notification Center package. You can find here the list of all the methods that you can use. + +## initializeSession + +To use the headless service, you'll need to initialize the session first. +This sets the token and starts the socket listener service. + +```ts +headlessService.initializeSession({ + listener: (result: FetchResult) => { + console.log(result); + }, + onSuccess: (session: ISession) => { + console.log(session); + }, + onError: (error: unknown) => { + console.error(error); + }, +}); +``` + +### method args interface + +```ts +interface IInitializeSession { + listener: (result: FetchResult) => void; + onSuccess?: (session: ISession) => void; + onError?: (error: unknown) => void; +} +``` + +## fetchOrganization + +Fetches the details of the current organization + +```ts +headlessService.fetchOrganization({ + listener: (result: FetchResult) => { + console.log(result); + }, + onSuccess: (organization: IOrganizationEntity) => { + console.log(organization); + }, + onError: (error: unknown) => { + console.error(error); + }, +}); +``` + +### method args interface + +```ts +interface IFetchOrganization { + listener: (result: FetchResult) => void; + onSuccess?: (session: IOrganizationEntity) => void; + onError?: (error: unknown) => void; +} +``` + +## fetchUnseenCount + +Fetches the count of unseen messages + +```ts +headlessService.fetchUnseenCount({ + listener: (result: FetchResult<{ count: number }>) => { + console.log(result); + }, + onSuccess: (data: { count: number }) => { + console.log(data); + }, + onError: (error: unknown) => { + console.error(error); + }, +}); +``` + +### method args interface + +```ts +interface IFetchUnseenCount { + listener: (result: FetchResult<{ count: number }>) => void; + onSuccess?: (data: { count: number }) => void; + onError?: (error: unknown) => void; +} +``` + +## fetchUnreadCount + +Fetches the count of unread messages + +```ts +headlessService.fetchUnreadCount({ + listener: (result: FetchResult<{ count: number }>) => { + console.log(result); + }, + onSuccess: (data: { count: number }) => { + console.log(data); + }, + onError: (error: unknown) => { + console.error(error); + }, +}); +``` + +### method args interface + +```ts +interface IFetchUnreadCount { + listener: (result: FetchResult<{ count: number }>) => void; + onSuccess?: (data: { count: number }) => void; + onError?: (error: unknown) => void; +} +``` + +## listenNotificationReceive + +Listens to a new notification being added. +Can be used to retrieve a new notification in real-time and trigger UI changes. + +```ts +headlessService.listenNotificationReceive({ + listener: (message: IMessage) => { + console.log(JSON.stringify(message)); + }, +}); +``` + +### method args interface + +```ts +interface IListenNotificationReceive { + listener: (message: IMessage) => void; +} +``` + +## listenUnseenCountChange + +Listens to the changes of the unseen count. +Can be used to get real time count of the unseen messages. + +```ts +headlessService.listenUnseenCountChange({ + listener: (unseenCount: number) => { + console.log(unseenCount); + }, +}); +``` + +### method args interface + +```ts +interface IListenUnseenCountChanget { + listener: (unseenCount: number) => void; +} +``` + +## listenUnreadCountChange + +Listens to the changes of the unread count. +Can be used to get real time count of the unread messages. + +```ts +headlessService.listenUnreadCountChange({ + listener: (unreadCount: number) => { + console.log(unreadCount); + }, +}); +``` + +### method args interface + +```ts +interface IListenUnreadCountChanget { + listener: (unreadCount: number) => void; +} +``` + +## fetchNotifications + +Retrieves the list of notifications for the subscriber. +Can also be used to get the notifications of a particular tab. + +```ts +headlessService.fetchNotifications({ + listener: (result: FetchResult>) => { + console.log(result); + }, + onSuccess: (response: IPaginatedResponse) => { + console.log(response.data, response.page, response.totalCount, response.pageSize); + }, + page: pageNumber, + query: { feedIdentifier: 'feedId', read: false, seen: true }, + storeId: 'storeId', +}); +``` + +### method args interface + +```ts +interface IFetchNotifications { + page?: number; + storeId?: string; + query?: IStoreQuery; + listener: (result: FetchResult>) => void; + onSuccess?: (messages: IPaginatedResponse) => void; + onError?: (error: unknown) => void; +} +``` + +## fetchUserPreferences + +Fetches the user preferences. +Read more [here](../../platform/preferences.md) + +```ts +headlessService.fetchUserPreferences({ + listener: (result: FetchResult) => { + console.log(result); + }, + onSuccess: (settings: IUserPreferenceSettings[]) => { + console.log(settings); + }, + onError: (error: unknown) => { + console.error(error); + }, +}); +``` + +### method args interface + +```ts +interface IFetchUserPreferences { + listener: (result: FetchResult) => void; + onSuccess?: (settings: IUserPreferenceSettings[]) => void; + onError?: (error: unknown) => void; +} +``` + +## updateUserPreferences + +Updates the user preferences. +Read more [here](../../platform/preferences.md) + +```ts +headlessService.updateUserPreferences({ + listener: ( + result: UpdateResult + ) => { + console.log(result); + }, + onSuccess: (settings: IUserPreferenceSettings) => { + console.log(settings); + }, + onError: (error: unknown) => { + console.error(error); + }, + templateId: 'templateId', + channelType: 'SMS', + checked: true, +}); +``` + +### method args interface + +```ts +interface IUpdateUserPreferences { + templateId: IUpdateUserPreferencesVariables['templateId']; + channelType: IUpdateUserPreferencesVariables['channelType']; + checked: IUpdateUserPreferencesVariables['checked']; + listener: ( + result: UpdateResult + ) => void; + onSuccess?: (settings: IUserPreferenceSettings) => void; + onError?: (error: unknown) => void; +} +``` + +## updateUserGlobalPreferences + +Updates the user's global preferences. +Read more [here](../../platform/preferences.md) + +```ts +headlessService.updateUserGlobalPreferences({ + listener: ( + result: UpdateResult< + IUserGlobalPreferenceSettings, + unknown, + IUpdateUserGlobalPreferencesVariables + > + ) => { + console.log(result); + }, + onSuccess: (settings: IUserGlobalPreferenceSettings) => { + console.log(settings); + }, + onError: (error: unknown) => { + console.error(error); + }, + enabled: true, + preferences: [ + { + type: 'SMS', + enabled: true, + }, + ], +}); +``` + +### method args interface + +```ts +interface IUpdateUserGlobalPreferences { + enabled: IUpdateUserGlobalPreferencesVariables['enabled']; + preferences: IUpdateUserGlobalPreferencesVariables['preferences']; + listener: ( + result: UpdateResult< + IUserGlobalPreferenceSettings, + unknown, + IUpdateUserGlobalPreferencesVariables + > + ) => void; + onSuccess?: (settings: IUserGlobalPreferenceSettings) => void; + onError?: (error: unknown) => void; +} +``` + +## markNotificationsAsRead + +mark a single or multiple notifications as read using the message id. + +```ts +headlessService.markNotificationsAsRead({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: (message: IMessage) => { + console.log(message); + }, + onError: (error: unknown) => { + console.error(error); + }, + messageId: ['messageOne', 'messageTwo'], +}); +``` + +### method args interface + +```ts +interface IMarkNotificationsAsRead { + messageId: IMessageId; + listener: (result: UpdateResult) => void; + onSuccess?: (message: IMessage) => void; + onError?: (error: unknown) => void; +} +``` + +## markNotificationsAsSeen + +mark a single or multiple notifications as seen using the message id. + +```ts +headlessService.markNotificationsAsSeen({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: (message: IMessage) => { + console.log(message); + }, + onError: (error: unknown) => { + console.error(error); + }, + messageId: ['messageOne', 'messageTwo'], +}); +``` + +### method args interface + +```ts +interface IMarkNotificationsAsSeen { + messageId: IMessageId; + listener: (result: UpdateResult) => void; + onSuccess?: (message: IMessage) => void; + onError?: (error: unknown) => void; +} +``` + +## removeNotification + +removes a single notification using the message id. + +```ts +headlessService.removeNotification({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: (message: IMessage) => { + console.log(message); + }, + onError: (error: unknown) => { + console.error(error); + }, + messageId: 'messageOne', +}); +``` + +### method args interface + +```ts +interface IRemoveNotification { + messageId: string; + listener: (result: UpdateResult) => void; + onSuccess?: (message: IMessage) => void; + onError?: (error: unknown) => void; +} +``` + +## updateAction + +updates the action button for the notifications. + +```ts +headlessService.updateAction({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: (data: IMessage) => { + console.log(data); + }, + onError: (error: unknown) => { + console.error(error); + }, + messageId: 'messageOne', + actionButtonType: 'primary', + status: 'done', + payload: { + abc: 'def', + }, +}); +``` + +### method args interface + +```ts +interface IUpdateAction { + messageId: IUpdateActionVariables['messageId']; + actionButtonType: IUpdateActionVariables['actionButtonType']; + status: IUpdateActionVariables['status']; + payload?: IUpdateActionVariables['payload']; + listener: (result: UpdateResult) => void; + onSuccess?: (data: IMessage) => void; + onError?: (error: unknown) => void; +} +``` + +## markAllMessagesAsRead + +Mark subscriber's all unread messages as read. +Can be used to mark all messages as read of a single or multiple feeds by passing the `feedId` + +```ts +headlessService.markAllMessagesAsRead({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: (count: number) => { + console.log(count); + }, + onError: (error: unknown) => { + console.error(error); + }, + feedId: ['feedOne', 'feedTwo'], +}); +``` + +### method args interface + +```ts +interface IMarkAllMessagesAsRead { + listener: (result: UpdateResult) => void; + onSuccess?: (count: number) => void; + onError?: (error: unknown) => void; + feedId?: IFeedId; +} +``` + +## markAllMessagesAsSeen + +Mark subscriber's all unread messages as seen. +Can be used to mark all messages as seen of a single or multiple feeds by passing the `feedId` + +```ts +headlessService.markAllMessagesAsSeen({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: (count: number) => { + console.log(count); + }, + onError: (error: unknown) => { + console.error(error); + }, + feedId: ['feedOne', 'feedTwo'], +}); +``` + +### method args interface + +```ts +interface IMarkAllMessagesAsSeen { + listener: (result: UpdateResult) => void; + onSuccess?: (count: number) => void; + onError?: (error: unknown) => void; + feedId?: IFeedId; +} +``` + +## removeAllNotifications + +Removes all notifications. +Can be used to remove all notifications of a feed by passing the `feedId` + +```ts +headlessService.removeAllNotifications({ + listener: (result: UpdateResult) => { + console.log(result); + }, + onSuccess: () => { + console.log('success'); + }, + onError: (error: unknown) => { + console.error(error); + }, + feedId: 'feedOne', +}); +``` + +### method args interface + +```ts +interface IRemoveAllNotifications { + feedId?: string; + listener: (result: UpdateResult) => void; + onSuccess?: () => void; + onError?: (error: unknown) => void; +} +``` + +--- + +## Types and Interfaces + +```ts +interface IHeadlessServiceOptions { + backendUrl?: string; + socketUrl?: string; + applicationIdentifier: string; + subscriberId?: string; + subscriberHash?: string; + config?: { + retry?: number; + retryDelay?: number; + }; +} + +interface ISession { + token: string; + profile: ISubscriberJwt; +} + +interface ISubscriberJwt { + _id: string; + firstName: string; + lastName: string; + email: string; + subscriberId: string; + organizationId: string; + environmentId: string; + aud: 'widget_user'; +} + +interface IUpdateUserPreferencesVariables { + templateId: string; + channelType: string; + checked: boolean; +} + +enum ButtonTypeEnum { + PRIMARY = 'primary', + SECONDARY = 'secondary', + CLICKED = 'clicked', +} + +enum MessageActionStatusEnum { + PENDING = 'pending', + DONE = 'done', +} + +interface IUpdateActionVariables { + messageId: string; + actionButtonType: ButtonTypeEnum; + status: MessageActionStatusEnum; + payload?: Record; +} + +type FetchResult = Pick< + QueryObserverResult, + 'data' | 'error' | 'status' | 'isLoading' | 'isFetching' | 'isError' +>; + +type UpdateResult = Pick< + MutationObserverResult, + 'data' | 'error' | 'status' | 'isLoading' | 'isError' +>; + +type IMessageId = string | string[]; +type IFeedId = string | string[]; +``` + +:::note +QueryObserverResult and MutationObserverResult are imported from the `@tanstack/query-core` library. +::: From 45fa0972932ac025f39d12ba41e7e6678aacd49f Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 21:35:50 +0530 Subject: [PATCH 06/25] feat: add global preference method to node sdk --- packages/headless/src/lib/types.ts | 2 +- .../node/src/lib/subscribers/subscriber.interface.ts | 8 ++++++++ packages/node/src/lib/subscribers/subscribers.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/headless/src/lib/types.ts b/packages/headless/src/lib/types.ts index 05c7f8e868c..abbcf3a7f64 100644 --- a/packages/headless/src/lib/types.ts +++ b/packages/headless/src/lib/types.ts @@ -23,7 +23,7 @@ export interface IUpdateUserPreferencesVariables { } export interface IUpdateUserGlobalPreferencesVariables { - preferences: { channelType: string; enabled: boolean }[]; + preferences?: { channelType: string; enabled: boolean }[]; enabled?: boolean; } diff --git a/packages/node/src/lib/subscribers/subscriber.interface.ts b/packages/node/src/lib/subscribers/subscriber.interface.ts index bb0d0a42afc..0f36e44cd2f 100644 --- a/packages/node/src/lib/subscribers/subscriber.interface.ts +++ b/packages/node/src/lib/subscribers/subscriber.interface.ts @@ -62,6 +62,14 @@ export interface IUpdateSubscriberPreferencePayload { }; enabled?: boolean; } + +export interface IUpdateSubscriberGlobalPreferencePayload { + preferences?: { + type: ChannelTypeEnum; + enabled: boolean; + }[]; + enabled?: boolean; +} export interface IGetSubscriberNotificationFeedParams { page?: number; limit?: number; diff --git a/packages/node/src/lib/subscribers/subscribers.ts b/packages/node/src/lib/subscribers/subscribers.ts index 50326af4bd2..6bb220bc789 100644 --- a/packages/node/src/lib/subscribers/subscribers.ts +++ b/packages/node/src/lib/subscribers/subscribers.ts @@ -11,6 +11,7 @@ import { IMarkMessageActionFields, ISubscriberPayload, ISubscribers, + IUpdateSubscriberGlobalPreferencePayload, IUpdateSubscriberPreferencePayload, } from './subscriber.interface'; import { WithHttp } from '../novu.interface'; @@ -104,6 +105,15 @@ export class Subscribers extends WithHttp implements ISubscribers { ); } + async updateGlobalPreference( + subscriberId: string, + data: IUpdateSubscriberGlobalPreferencePayload + ) { + return await this.http.patch(`/subscribers/${subscriberId}/preferences`, { + ...data, + }); + } + async getNotificationsFeed( subscriberId: string, { payload, ...rest }: IGetSubscriberNotificationFeedParams = {} From 2c674f0d4995139f8af84ea60e0cf924da722a42 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 21:36:06 +0530 Subject: [PATCH 07/25] docs: for global preference update --- packages/node/README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/node/README.md b/packages/node/README.md index b175ab0410b..86d63457c35 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -338,11 +338,30 @@ await novu.subscribers.updatePreference("subscriberId", "workflowId", { await novu.subscribers.updatePreference("subscriberId", "workflowId", { channel: { type: "email" - enabled: + enabled: false } }) ``` +- #### Update subscriber preference globally +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +// enable in-app channel and disable email channel +await novu.subscribers.updateGlobalPreference("subscriberId", { + enabled: true, + preferences: [{ + type: "in_app" + enabled: true + }, { + type: "email" + enabled: false + }] +}) +``` + - #### Get in-app messages (notifications) feed for a subscriber ```ts From 07cd50becf2475084631b3d43b5ef2494ad29cec Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 23:13:59 +0530 Subject: [PATCH 08/25] fix: tests --- .../usecases/send-message/send-message.usecase.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts index 6ed91fe6d94..76a033a4790 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts @@ -212,7 +212,10 @@ export class SendMessage { ); const globalPreferenceResult = this.stepPreferred(globalPreference, job); - + console.log({ + globalPreferenceResult, + globalPreference, + }); if (!globalPreferenceResult) { await this.createExecutionDetails.execute( CreateExecutionDetailsCommand.create({ @@ -272,9 +275,12 @@ export class SendMessage { private stepPreferred(preference: { enabled: boolean; channels: IPreferenceChannels }, job: JobEntity) { const templatePreferred = preference.enabled; - const channelPreferred = Object.keys(preference.channels).some( - (channelKey) => channelKey === job.type && preference.channels[job.type] - ); + const channels = Object.keys(preference.channels); + // Handles the case where the channel is not defined in the preference. i.e, channels = {} + const channelPreferred = + channels.length > 0 + ? channels.some((channelKey) => channelKey === job.type && preference.channels[job.type]) + : true; return templatePreferred && channelPreferred; } From 8587802e8b6584e022ea46cb98b14230fe135ba1 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 23:15:44 +0530 Subject: [PATCH 09/25] refactor: remove log --- .../workflow/usecases/send-message/send-message.usecase.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts index 76a033a4790..d73cbc31fe6 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message.usecase.ts @@ -212,10 +212,7 @@ export class SendMessage { ); const globalPreferenceResult = this.stepPreferred(globalPreference, job); - console.log({ - globalPreferenceResult, - globalPreference, - }); + if (!globalPreferenceResult) { await this.createExecutionDetails.execute( CreateExecutionDetailsCommand.create({ From 44770faa50a4d5240fc81d5c95a71c3a76413f77 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 20 Sep 2023 23:36:06 +0530 Subject: [PATCH 10/25] fix: tests --- .../dtos/update-subscriber-global-preferences-request.dto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts index 29ae57f002d..72ee27a7d23 100644 --- a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts @@ -15,7 +15,6 @@ export class UpdateSubscriberGlobalPreferencesRequestDto { @ApiPropertyOptional({ type: [ChannelPreference], description: 'The subscriber global preferences for every ChannelTypeEnum.', - isArray: true, }) @IsOptional() preferences?: ChannelPreference[]; From 465cce6242be9d0b7feb4fa58e38b947734bfc10 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 26 Sep 2023 15:27:52 +0530 Subject: [PATCH 11/25] refactor: removed unused imports --- .../e2e/update-global-preference.e2e.ts | 13 +- ...e-subscriber-global-preferences.usecase.ts | 3 +- pnpm-lock.yaml | 140 +++++++++++++++++- 3 files changed, 143 insertions(+), 13 deletions(-) diff --git a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts index 8ce87f8bd92..0d0e45ee1ae 100644 --- a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts @@ -1,15 +1,8 @@ +import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; -import { NotificationTemplateEntity } from '@novu/dal'; -import { - ChannelTypeEnum, - DigestTypeEnum, - DigestUnitEnum, - IUpdateNotificationTemplateDto, - StepTypeEnum, -} from '@novu/shared'; - -import { getNotificationTemplate, updateNotificationTemplate, getPreference, updateGlobalPreferences } from './helpers'; + +import { updateGlobalPreferences } from './helpers'; describe('Update Subscribers global preferences - /subscribers/:subscriberId/preferences (PATCH)', function () { let session: UserSession; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts index 520d79fdea7..823442f8373 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand } from '@novu/application-generic'; import { PreferenceLevelEnum, SubscriberEntity, @@ -6,7 +7,6 @@ import { SubscriberPreferenceRepository, SubscriberRepository, } from '@novu/dal'; -import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand } from '@novu/application-generic'; import { UpdateSubscriberGlobalPreferencesCommand } from './update-subscriber-global-preferences.command'; @@ -26,7 +26,6 @@ export class UpdateSubscriberGlobalPreferences { _organizationId: command.organizationId, _environmentId: command.environmentId, _subscriberId: subscriber._id, - level: PreferenceLevelEnum.GLOBAL, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fe880a4341..16ddfb5ec68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1150,6 +1150,84 @@ importers: stylelint-scss: 4.6.0_stylelint@15.10.1 typescript: 4.9.5 + enterprise/packages/auth: + specifiers: + '@nestjs/common': '>=9.3.x' + '@nestjs/jwt': '>=9' + '@nestjs/passport': 9.0.3 + '@novu/application-generic': ^0.19.0 + '@novu/dal': ^0.19.0 + '@novu/shared': ^0.19.0 + '@types/chai': ^4.2.11 + '@types/mocha': ^8.0.1 + '@types/node': ^14.6.0 + '@types/sinon': ^9.0.0 + chai: ^4.2.0 + cross-env: ^7.0.3 + mocha: ^8.1.1 + nodemon: ^2.0.3 + passport: 0.6.0 + passport-google-oauth: ^2.0.0 + passport-oauth2: ^1.6.1 + sinon: ^9.2.4 + ts-node: ~10.9.1 + typescript: 4.9.5 + dependencies: + '@nestjs/common': 10.2.2_atc7tu2sld2m3nk4hmwkqn6qde + '@nestjs/jwt': 10.1.0_@nestjs+common@10.2.2 + '@nestjs/passport': 9.0.3_kn4ljbedllcoqpuu4ifhphsdsu + '@novu/application-generic': link:../../../packages/application-generic + '@novu/dal': link:../../../libs/dal + '@novu/shared': link:../../../libs/shared + passport: 0.6.0 + passport-google-oauth: 2.0.0 + passport-oauth2: 1.7.0 + devDependencies: + '@types/chai': 4.3.4 + '@types/mocha': 8.2.3 + '@types/node': 14.18.42 + '@types/sinon': 9.0.11 + chai: 4.3.7 + cross-env: 7.0.3 + mocha: 8.4.0 + nodemon: 2.0.22 + sinon: 9.2.4 + ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna + typescript: 4.9.5 + + enterprise/packages/digest-schedule: + specifiers: + '@novu/shared': ^0.19.0 + '@types/chai': ^4.2.11 + '@types/mocha': ^8.0.1 + '@types/node': ^14.6.0 + '@types/sinon': ^9.0.0 + chai: ^4.2.0 + cross-env: ^7.0.3 + date-fns: ^2.29.2 + mocha: ^8.1.1 + nodemon: ^2.0.3 + rrule: ^2.7.2 + sinon: ^9.2.4 + ts-node: ~10.9.1 + typescript: 4.9.5 + dependencies: + '@novu/shared': link:../../../libs/shared + date-fns: 2.29.3 + rrule: 2.7.2 + devDependencies: + '@types/chai': 4.3.4 + '@types/mocha': 8.2.3 + '@types/node': 14.18.42 + '@types/sinon': 9.0.11 + chai: 4.3.7 + cross-env: 7.0.3 + mocha: 8.4.0 + nodemon: 2.0.22 + sinon: 9.2.4 + ts-node: 10.9.1_wh2cnrlliuuxb2etxm2m3ttgna + typescript: 4.9.5 + libs/dal: specifiers: '@aws-sdk/client-s3': ^3.382.0 @@ -13168,6 +13246,26 @@ packages: - webpack-cli dev: true + /@nestjs/common/10.2.2_atc7tu2sld2m3nk4hmwkqn6qde: + resolution: {integrity: sha512-TCOJK2K4FDT3GxFfURjngnjBewS/hizKNFSLBXtX4TTQm0dVQOtESnnVdP14sEiPM6suuWlrGnXW9UDqItGWiQ==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + dependencies: + iterare: 1.2.1 + reflect-metadata: 0.1.13 + rxjs: 7.8.1 + tslib: 2.6.2 + uid: 2.0.2 + dev: false + /@nestjs/common/10.2.2_j3td4gnlgk75ora6o6suo62byy: resolution: {integrity: sha512-TCOJK2K4FDT3GxFfURjngnjBewS/hizKNFSLBXtX4TTQm0dVQOtESnnVdP14sEiPM6suuWlrGnXW9UDqItGWiQ==} peerDependencies: @@ -13397,6 +13495,16 @@ packages: passport: 0.6.0 dev: false + /@nestjs/passport/9.0.3_kn4ljbedllcoqpuu4ifhphsdsu: + resolution: {integrity: sha512-HplSJaimEAz1IOZEu+pdJHHJhQyBOPAYWXYHfAPQvRqWtw4FJF1VXl1Qtk9dcXQX1eKytDtH+qBzNQc19GWNEg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 + dependencies: + '@nestjs/common': 10.2.2_atc7tu2sld2m3nk4hmwkqn6qde + passport: 0.6.0 + dev: false + /@nestjs/platform-express/10.2.2_h33h3l6i5mruhsbo3bha6vy2fi: resolution: {integrity: sha512-g5AeXgPQrVm62JOl9FXk0w3Tq1tD4f6ouGikLYs/Aahy0q/Z2HNP9NjXZYpqcjHrpafPYnc3bfBuUwedKW1oHg==} peerDependencies: @@ -23810,7 +23918,7 @@ packages: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} /buffer-equal-constant-time/1.0.1: - resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=} + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} dev: false /buffer-from/1.1.2: @@ -37938,6 +38046,27 @@ packages: passport-oauth2: 1.7.0 dev: false + /passport-google-oauth/2.0.0: + resolution: {integrity: sha512-JKxZpBx6wBQXX1/a1s7VmdBgwOugohH+IxCy84aPTZNq/iIPX6u7Mqov1zY7MKRz3niFPol0KJz8zPLBoHKtYA==} + engines: {node: '>= 0.4.0'} + dependencies: + passport-google-oauth1: 1.0.0 + passport-google-oauth20: 2.0.0 + dev: false + + /passport-google-oauth1/1.0.0: + resolution: {integrity: sha512-qpCEhuflJgYrdg5zZIpAq/K3gTqa1CtHjbubsEsidIdpBPLkEVq6tB1I8kBNcH89RdSiYbnKpCBXAZXX/dtx1Q==} + dependencies: + passport-oauth1: 1.3.0 + dev: false + + /passport-google-oauth20/2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + dependencies: + passport-oauth2: 1.7.0 + dev: false + /passport-jwt/4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} dependencies: @@ -37945,6 +38074,15 @@ packages: passport-strategy: 1.0.0 dev: false + /passport-oauth1/1.3.0: + resolution: {integrity: sha512-8T/nX4gwKTw0PjxP1xfD0QhrydQNakzeOpZ6M5Uqdgz9/a/Ag62RmJxnZQ4LkbdXGrRehQHIAHNAu11rCP46Sw==} + engines: {node: '>= 0.4.0'} + dependencies: + oauth: 0.9.15 + passport-strategy: 1.0.0 + utils-merge: 1.0.1 + dev: false + /passport-oauth2/1.7.0: resolution: {integrity: sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==} engines: {node: '>= 0.4.0'} From 7176b25838faabb3cc9a183f353d6e8f6f8fe5b3 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 26 Sep 2023 22:38:43 +0530 Subject: [PATCH 12/25] feat: add validation --- .../update-subscriber-global-preferences-request.dto.ts | 6 ++++-- .../update-subscriber-global-preferences.command.ts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts index 72ee27a7d23..0572b978e0f 100644 --- a/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional } from 'class-validator'; - +import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; import { ChannelPreference } from '../../shared/dtos/channel-preference'; export class UpdateSubscriberGlobalPreferencesRequestDto { @@ -17,5 +17,7 @@ export class UpdateSubscriberGlobalPreferencesRequestDto { description: 'The subscriber global preferences for every ChannelTypeEnum.', }) @IsOptional() + @ValidateNested() + @Type(() => ChannelPreference) preferences?: ChannelPreference[]; } diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts index cbb3c529f71..9c3cf879b18 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts @@ -1,4 +1,5 @@ -import { IsBoolean, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; import { ChannelPreference } from '../../../shared/dtos/channel-preference'; @@ -8,5 +9,7 @@ export class UpdateSubscriberGlobalPreferencesCommand extends EnvironmentWithSub enabled?: boolean; @IsOptional() + @ValidateNested() + @Type(() => ChannelPreference) preferences?: ChannelPreference[]; } From 64815d4fce28572c43320022349aaa3aee0c9c4f Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 26 Sep 2023 22:39:05 +0530 Subject: [PATCH 13/25] refactor: update variable type --- .../update-subscriber-global-preferences.usecase.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts index 823442f8373..580e5672423 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand } from '@novu/application-generic'; import { + ChannelTypeEnum, PreferenceLevelEnum, SubscriberEntity, SubscriberPreferenceEntity, @@ -48,7 +49,7 @@ export class UpdateSubscriberGlobalPreferences { command: UpdateSubscriberGlobalPreferencesCommand, subscriber: SubscriberEntity ): Promise { - const channelObj = {} as Record<'email' | 'sms' | 'in_app' | 'chat' | 'push', boolean>; + const channelObj = {} as Record; if (command.preferences && command.preferences.length > 0) { for (const preference of command.preferences) { if (preference.type) { From 673bb50b02a9dbbcfdb30eb294b366c4522e8833 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 26 Sep 2023 22:39:31 +0530 Subject: [PATCH 14/25] test: added tests with validations and multiple channels --- .../e2e/update-global-preference.e2e.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts index 0d0e45ee1ae..0bf030e983d 100644 --- a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts @@ -12,6 +12,40 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre await session.initialize(); }); + it('should validate the payload', async function () { + const badPayload = { + enabled: true, + preferences: false, + }; + + try { + const firstResponse = await updateGlobalPreferences(badPayload as any, session); + expect(firstResponse).to.not.be.ok; + } catch (error) { + expect(error.toJSON()).to.have.include({ + status: 400, + name: 'AxiosError', + message: 'Request failed with status code 400', + }); + } + + const yetAnotherBadPayload = { + enabled: 'hello', + preferences: [{ type: ChannelTypeEnum.EMAIL, enabled: true }], + }; + + try { + const secondResponse = await updateGlobalPreferences(yetAnotherBadPayload as any, session); + expect(secondResponse).to.not.be.ok; + } catch (error) { + expect(error.toJSON()).to.have.include({ + status: 400, + name: 'AxiosError', + message: 'Request failed with status code 400', + }); + } + }); + it('should update user global preferences', async function () { const payload = { enabled: true, @@ -21,11 +55,34 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre const response = await updateGlobalPreferences(payload, session); expect(response.data.data.preference.enabled).to.eql(true); + expect(response.data.data.preference.channels).to.not.eql({ + [ChannelTypeEnum.IN_APP]: true, + }); expect(response.data.data.preference.channels).to.eql({ [ChannelTypeEnum.EMAIL]: true, }); }); + it('should update user global preferences for multiple channels', async function () { + const payload = { + enabled: true, + preferences: [ + { type: ChannelTypeEnum.PUSH, enabled: true }, + { type: ChannelTypeEnum.IN_APP, enabled: false }, + { type: ChannelTypeEnum.SMS, enabled: true }, + ], + }; + + const response = await updateGlobalPreferences(payload, session); + + expect(response.data.data.preference.enabled).to.eql(true); + expect(response.data.data.preference.channels).to.eql({ + [ChannelTypeEnum.PUSH]: true, + [ChannelTypeEnum.IN_APP]: false, + [ChannelTypeEnum.SMS]: true, + }); + }); + it('should update user global preference and disable the flag for the future channels update', async function () { const disablePreferenceData = { enabled: false, From e3c002a3e2072cd84777ab79cb0af2735aab50ca Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Thu, 28 Sep 2023 23:17:31 +0530 Subject: [PATCH 15/25] feat: get global preferences --- .../subscriber-preferences-level.migration.ts | 33 +++++++++ ...get-subscriber-preferences-response.dto.ts | 71 +++++++++++++++++++ apps/api/src/app/subscribers/dtos/index.ts | 2 + ...-subscriber-preferences-by-level.params.ts | 10 +++ apps/api/src/app/subscribers/params/index.ts | 1 + .../app/subscribers/subscribers.controller.ts | 28 +++++++- .../get-preferences.command.ts | 13 ++++ .../get-preferences.usecase.ts | 22 ++++++ .../get-preferences.command.ts | 7 +- .../get-preferences.usecase.ts | 24 ++++++- .../update-subscriber-preference.usecase.ts | 3 + .../subscriber-preference.entity.ts | 4 +- ...-subscriber-template-preference.usecase.ts | 2 + packages/headless/src/lib/types.ts | 8 ++- 14 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 apps/api/migrations/subscriber-preferences-level/subscriber-preferences-level.migration.ts create mode 100644 apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts create mode 100644 apps/api/src/app/subscribers/params/get-subscriber-preferences-by-level.params.ts create mode 100644 apps/api/src/app/subscribers/params/index.ts create mode 100644 apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts create mode 100644 apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts diff --git a/apps/api/migrations/subscriber-preferences-level/subscriber-preferences-level.migration.ts b/apps/api/migrations/subscriber-preferences-level/subscriber-preferences-level.migration.ts new file mode 100644 index 00000000000..76c5fdc4761 --- /dev/null +++ b/apps/api/migrations/subscriber-preferences-level/subscriber-preferences-level.migration.ts @@ -0,0 +1,33 @@ +import '../../src/config'; + +import { NestFactory } from '@nestjs/core'; +import { PreferenceLevelEnum, SubscriberPreferenceRepository } from '@novu/dal'; + +import { AppModule } from '../../src/app.module'; + +export async function addLevelPropertyToSubscriberPreferences() { + // eslint-disable-next-line no-console + console.log('start migration - add level property to subscriber preferences'); + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + const subscriberPreferenceRepository = app.get(SubscriberPreferenceRepository); + // eslint-disable-next-line no-console + console.log('add level: PreferenceLevelEnum.TEMPLATE to all subscriber preferences without level property'); + + await subscriberPreferenceRepository._model.collection.updateMany( + { level: { $exists: false }, _templateId: { $exists: true } }, + { + $set: { level: PreferenceLevelEnum.TEMPLATE }, + } + ); + + // eslint-disable-next-line no-console + console.log('end migration- add level property to subscriber preferences'); + + app.close(); + process.exit(0); +} + +addLevelPropertyToSubscriberPreferences(); diff --git a/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts b/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts new file mode 100644 index 00000000000..c051f64d307 --- /dev/null +++ b/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ChannelTypeEnum, PreferenceOverrideSourceEnum } from '@novu/shared'; +import { PreferenceChannels } from '../../shared/dtos/preference-channels'; + +class TemplateResponse { + @ApiProperty({ + description: 'Unique identifier of the workflow', + type: String, + }) + _id: string; + + @ApiProperty({ + description: 'Name of the workflow', + type: String, + }) + name: string; + + @ApiProperty({ + description: + 'Critical templates will always be delivered to the end user and should be hidden from the subscriber preferences screen', + type: Boolean, + }) + critical: boolean; +} + +class Overrides { + @ApiProperty({ + type: ChannelTypeEnum, + description: 'The channel type which is overriden', + }) + channel: ChannelTypeEnum; + @ApiProperty({ + type: PreferenceOverrideSourceEnum, + description: 'The source of overrides', + }) + source: PreferenceOverrideSourceEnum; +} + +class Preference { + @ApiProperty({ + description: 'Sets if the workflow is fully enabled for all channels or not for the subscriber.', + type: Boolean, + }) + enabled: boolean; + + @ApiProperty({ + type: PreferenceChannels, + description: 'Subscriber preferences for the different channels regarding this workflow', + }) + channels: PreferenceChannels; + + @ApiPropertyOptional({ + type: Overrides, + description: 'Overrides for subscriber preferences for the different channels regarding this workflow', + }) + overrides?: Overrides; +} + +export class GetSubscriberPreferencesResponseDto { + @ApiPropertyOptional({ + type: TemplateResponse, + description: 'The workflow information and if it is critical or not', + }) + template?: TemplateResponse; + + @ApiProperty({ + type: Preference, + description: 'The preferences of the subscriber regarding the related workflow', + }) + preference: Preference; +} diff --git a/apps/api/src/app/subscribers/dtos/index.ts b/apps/api/src/app/subscribers/dtos/index.ts index 0593dafc420..85bbe277b7d 100644 --- a/apps/api/src/app/subscribers/dtos/index.ts +++ b/apps/api/src/app/subscribers/dtos/index.ts @@ -4,3 +4,5 @@ export * from './subscriber-response.dto'; export * from './subscribers-response.dto'; export * from './update-subscriber-channel-request.dto'; export * from './update-subscriber-request.dto'; +export * from './get-subscriber-preferences-response.dto'; +export * from './update-subscriber-global-preferences-request.dto'; diff --git a/apps/api/src/app/subscribers/params/get-subscriber-preferences-by-level.params.ts b/apps/api/src/app/subscribers/params/get-subscriber-preferences-by-level.params.ts new file mode 100644 index 00000000000..18196b59147 --- /dev/null +++ b/apps/api/src/app/subscribers/params/get-subscriber-preferences-by-level.params.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsString } from 'class-validator'; +import { PreferenceLevelEnum } from '@novu/dal'; + +export class GetSubscriberPreferencesByLevelParams { + @IsEnum(PreferenceLevelEnum) + level: PreferenceLevelEnum; + + @IsString() + subscriberId: string; +} diff --git a/apps/api/src/app/subscribers/params/index.ts b/apps/api/src/app/subscribers/params/index.ts new file mode 100644 index 00000000000..cdca2379644 --- /dev/null +++ b/apps/api/src/app/subscribers/params/index.ts @@ -0,0 +1 @@ +export * from './get-subscriber-preferences-by-level.params'; diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index ce42a60efc4..d263861ea35 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -23,7 +23,7 @@ import { } from '@novu/application-generic'; import { ApiOperation, ApiTags, ApiNoContentResponse } from '@nestjs/swagger'; import { ButtonTypeEnum, ChatProviderIdEnum, IJwtPayload } from '@novu/shared'; -import { MessageEntity } from '@novu/dal'; +import { MessageEntity, PreferenceLevelEnum } from '@novu/dal'; import { RemoveSubscriber, RemoveSubscriberCommand } from './usecases/remove-subscriber'; import { JwtAuthGuard } from '../auth/framework/auth.guard'; @@ -33,8 +33,10 @@ import { BulkSubscriberCreateDto, CreateSubscriberRequestDto, DeleteSubscriberResponseDto, + GetSubscriberPreferencesResponseDto, SubscriberResponseDto, UpdateSubscriberChannelRequestDto, + UpdateSubscriberGlobalPreferencesRequestDto, UpdateSubscriberRequestDto, } from './dtos'; import { UpdateSubscriberChannel, UpdateSubscriberChannelCommand } from './usecases/update-subscriber-channel'; @@ -87,7 +89,7 @@ import { UpdateSubscriberGlobalPreferences, UpdateSubscriberGlobalPreferencesCommand, } from './usecases/update-subscriber-global-preferences'; -import { UpdateSubscriberGlobalPreferencesRequestDto } from './dtos/update-subscriber-global-preferences-request.dto'; +import { GetSubscriberPreferencesByLevelParams } from './params'; @Controller('/subscribers') @ApiTags('Subscribers') @@ -346,6 +348,28 @@ export class SubscribersController { organizationId: user.organizationId, subscriberId: subscriberId, environmentId: user.environmentId, + level: PreferenceLevelEnum.TEMPLATE, + }); + + return (await this.getPreferenceUsecase.execute(command)) as UpdateSubscriberPreferenceResponseDto[]; + } + + @Get('/:subscriberId/preferences/:level') + @ExternalApiAccessible() + @UseGuards(JwtAuthGuard) + @ApiResponse(GetSubscriberPreferencesResponseDto, 200, true) + @ApiOperation({ + summary: 'Get subscriber preferences by level', + }) + async getSubscriberPreferenceByLevel( + @UserSession() user: IJwtPayload, + @Param() { level, subscriberId }: GetSubscriberPreferencesByLevelParams + ): Promise { + const command = GetPreferencesCommand.create({ + organizationId: user.organizationId, + subscriberId: subscriberId, + environmentId: user.environmentId, + level: level, }); return await this.getPreferenceUsecase.execute(command); diff --git a/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts new file mode 100644 index 00000000000..0d1a4bdc19e --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts @@ -0,0 +1,13 @@ +import { IsDefined, IsEnum, IsString } from 'class-validator'; +import { PreferenceLevelEnum } from '@novu/dal'; +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class GetPreferencesCommand extends EnvironmentCommand { + @IsString() + @IsDefined() + subscriberId: string; + + @IsEnum(PreferenceLevelEnum) + @IsDefined() + level: PreferenceLevelEnum; +} diff --git a/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts new file mode 100644 index 00000000000..5af1fa03d8b --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { GetSubscriberPreference, GetSubscriberPreferenceCommand } from '@novu/application-generic'; +import { PreferenceLevelEnum } from '@novu/dal'; + +import { GetPreferencesCommand } from './get-preferences.command'; + +@Injectable() +export class GetPreferences { + constructor(private getSubscriberPreferenceUsecase: GetSubscriberPreference) {} + + async execute(command: GetPreferencesCommand) { + if (command.level === PreferenceLevelEnum.GLOBAL) { + } + const preferenceCommand = GetSubscriberPreferenceCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }); + + return await this.getSubscriberPreferenceUsecase.execute(preferenceCommand); + } +} diff --git a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts b/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts index 6a9e257168d..0d1a4bdc19e 100644 --- a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts +++ b/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts @@ -1,8 +1,13 @@ -import { IsDefined, IsString } from 'class-validator'; +import { IsDefined, IsEnum, IsString } from 'class-validator'; +import { PreferenceLevelEnum } from '@novu/dal'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetPreferencesCommand extends EnvironmentCommand { @IsString() @IsDefined() subscriberId: string; + + @IsEnum(PreferenceLevelEnum) + @IsDefined() + level: PreferenceLevelEnum; } diff --git a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts index 57f8c647492..1213f15632e 100644 --- a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts @@ -1,13 +1,33 @@ import { Injectable } from '@nestjs/common'; -import { GetSubscriberPreference, GetSubscriberPreferenceCommand } from '@novu/application-generic'; +import { + GetSubscriberGlobalPreference, + GetSubscriberGlobalPreferenceCommand, + GetSubscriberPreference, + GetSubscriberPreferenceCommand, +} from '@novu/application-generic'; +import { PreferenceLevelEnum } from '@novu/dal'; import { GetPreferencesCommand } from './get-preferences.command'; @Injectable() export class GetPreferences { - constructor(private getSubscriberPreferenceUsecase: GetSubscriberPreference) {} + constructor( + private getSubscriberPreferenceUsecase: GetSubscriberPreference, + private getSubscriberGlobalPreference: GetSubscriberGlobalPreference + ) {} async execute(command: GetPreferencesCommand) { + if (command.level === PreferenceLevelEnum.GLOBAL) { + const globalPreferenceCommand = GetSubscriberGlobalPreferenceCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }); + const globalPreferences = await this.getSubscriberGlobalPreference.execute(globalPreferenceCommand); + + return [globalPreferences]; + } + const preferenceCommand = GetSubscriberPreferenceCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts index 9af25798015..ceac6dbf431 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts @@ -6,6 +6,7 @@ import { SubscriberEntity, SubscriberRepository, MemberRepository, + PreferenceLevelEnum, } from '@novu/dal'; import { AnalyticsService, @@ -91,6 +92,7 @@ export class UpdateSubscriberPreference { */ enabled: command.enabled !== false, channels: command.channel?.type ? channelObj : null, + level: PreferenceLevelEnum.TEMPLATE, }); } @@ -118,6 +120,7 @@ export class UpdateSubscriberPreference { _organizationId: command.organizationId, _subscriberId: subscriber._id, _templateId: command.templateId, + level: PreferenceLevelEnum.TEMPLATE, }, { $set: updatePayload, diff --git a/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts b/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts index 7576c2d048a..c842f4829a4 100644 --- a/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts +++ b/libs/dal/src/repositories/subscriber-preference/subscriber-preference.entity.ts @@ -28,6 +28,6 @@ export type SubscriberPreferenceDBModel = ChangePropsValueType< >; export enum PreferenceLevelEnum { - GLOBAL = 'GLOBAL', - TEMPLATE = 'TEMPLATE', + GLOBAL = 'global', + TEMPLATE = 'template', } diff --git a/packages/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts b/packages/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts index 945343ce2c4..e4e36cc511d 100644 --- a/packages/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts +++ b/packages/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts @@ -5,6 +5,7 @@ import { SubscriberRepository, SubscriberEntity, MessageTemplateRepository, + PreferenceLevelEnum, } from '@novu/dal'; import { ChannelTypeEnum, @@ -53,6 +54,7 @@ export class GetSubscriberTemplatePreference { _environmentId: command.environmentId, _subscriberId: subscriber._id, _templateId: command.template._id, + level: PreferenceLevelEnum.TEMPLATE, }); const subscriberChannelPreference = subscriberPreference?.channels; diff --git a/packages/headless/src/lib/types.ts b/packages/headless/src/lib/types.ts index abbcf3a7f64..8c654cfc255 100644 --- a/packages/headless/src/lib/types.ts +++ b/packages/headless/src/lib/types.ts @@ -2,7 +2,11 @@ import { QueryObserverResult, MutationObserverResult, } from '@tanstack/query-core'; -import { ButtonTypeEnum, MessageActionStatusEnum } from '@novu/shared'; +import { + ButtonTypeEnum, + ChannelTypeEnum, + MessageActionStatusEnum, +} from '@novu/shared'; export interface IHeadlessServiceOptions { backendUrl?: string; @@ -23,7 +27,7 @@ export interface IUpdateUserPreferencesVariables { } export interface IUpdateUserGlobalPreferencesVariables { - preferences?: { channelType: string; enabled: boolean }[]; + preferences?: { channelType: ChannelTypeEnum; enabled: boolean }[]; enabled?: boolean; } From 3c686a62166c9285aa3247c1aab715c17e1d1c66 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 01:07:01 +0530 Subject: [PATCH 16/25] feat: widget controller and hook --- .../app/subscribers/subscribers.controller.ts | 10 ++++---- ...ts => get-preferences-by-level.command.ts} | 2 +- .../get-preferences-by-level.usecase.ts} | 6 ++--- .../get-preferences.usecase.ts | 22 ------------------ .../get-preferences.command.ts | 13 ----------- .../api/src/app/subscribers/usecases/index.ts | 4 ++-- .../api/src/app/widgets/widgets.controller.ts | 21 ++++++++++++++++- packages/client/src/api/api.service.ts | 5 ++++ .../components/novu-provider/NovuProvider.tsx | 1 + .../notification-center/src/hooks/index.ts | 1 + .../hooks/useFetchUserGlobalPreferences.ts | 23 +++++++++++++++++++ .../src/shared/interfaces/index.ts | 1 + 12 files changed, 62 insertions(+), 47 deletions(-) rename apps/api/src/app/subscribers/usecases/get-preferences-by-level/{get-preferences.command.ts => get-preferences-by-level.command.ts} (82%) rename apps/api/src/app/subscribers/usecases/{get-preferences/get-preferences.usecase.ts => get-preferences-by-level/get-preferences-by-level.usecase.ts} (87%) delete mode 100644 apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts delete mode 100644 apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts create mode 100644 packages/notification-center/src/hooks/useFetchUserGlobalPreferences.ts diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index d263861ea35..654a8e97972 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -42,8 +42,8 @@ import { import { UpdateSubscriberChannel, UpdateSubscriberChannelCommand } from './usecases/update-subscriber-channel'; import { GetSubscribers, GetSubscribersCommand } from './usecases/get-subscribers'; import { GetSubscriber, GetSubscriberCommand } from './usecases/get-subscriber'; -import { GetPreferencesCommand } from './usecases/get-preferences/get-preferences.command'; -import { GetPreferences } from './usecases/get-preferences/get-preferences.usecase'; +import { GetPreferencesByLevelCommand } from './usecases/get-preferences-by-level/get-preferences-by-level.command'; +import { GetPreferencesByLevel } from './usecases/get-preferences-by-level/get-preferences-by-level.usecase'; import { UpdatePreference } from './usecases/update-preference/update-preference.usecase'; import { UpdateSubscriberPreferenceCommand } from './usecases/update-subscriber-preference'; import { UpdateSubscriberPreferenceResponseDto } from '../widgets/dtos/update-subscriber-preference-response.dto'; @@ -102,7 +102,7 @@ export class SubscribersController { private removeSubscriberUsecase: RemoveSubscriber, private getSubscriberUseCase: GetSubscriber, private getSubscribersUsecase: GetSubscribers, - private getPreferenceUsecase: GetPreferences, + private getPreferenceUsecase: GetPreferencesByLevel, private updatePreferenceUsecase: UpdatePreference, private updateGlobalPreferenceUsecase: UpdateSubscriberGlobalPreferences, private getNotificationsFeedUsecase: GetNotificationsFeed, @@ -344,7 +344,7 @@ export class SubscribersController { @UserSession() user: IJwtPayload, @Param('subscriberId') subscriberId: string ): Promise { - const command = GetPreferencesCommand.create({ + const command = GetPreferencesByLevelCommand.create({ organizationId: user.organizationId, subscriberId: subscriberId, environmentId: user.environmentId, @@ -365,7 +365,7 @@ export class SubscribersController { @UserSession() user: IJwtPayload, @Param() { level, subscriberId }: GetSubscriberPreferencesByLevelParams ): Promise { - const command = GetPreferencesCommand.create({ + const command = GetPreferencesByLevelCommand.create({ organizationId: user.organizationId, subscriberId: subscriberId, environmentId: user.environmentId, diff --git a/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command.ts similarity index 82% rename from apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts rename to apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command.ts index 0d1a4bdc19e..f1956f68bff 100644 --- a/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.command.ts +++ b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command.ts @@ -2,7 +2,7 @@ import { IsDefined, IsEnum, IsString } from 'class-validator'; import { PreferenceLevelEnum } from '@novu/dal'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; -export class GetPreferencesCommand extends EnvironmentCommand { +export class GetPreferencesByLevelCommand extends EnvironmentCommand { @IsString() @IsDefined() subscriberId: string; diff --git a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase.ts similarity index 87% rename from apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts rename to apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase.ts index 1213f15632e..47358c4e172 100644 --- a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase.ts @@ -7,16 +7,16 @@ import { } from '@novu/application-generic'; import { PreferenceLevelEnum } from '@novu/dal'; -import { GetPreferencesCommand } from './get-preferences.command'; +import { GetPreferencesByLevelCommand } from './get-preferences-by-level.command'; @Injectable() -export class GetPreferences { +export class GetPreferencesByLevel { constructor( private getSubscriberPreferenceUsecase: GetSubscriberPreference, private getSubscriberGlobalPreference: GetSubscriberGlobalPreference ) {} - async execute(command: GetPreferencesCommand) { + async execute(command: GetPreferencesByLevelCommand) { if (command.level === PreferenceLevelEnum.GLOBAL) { const globalPreferenceCommand = GetSubscriberGlobalPreferenceCommand.create({ organizationId: command.organizationId, diff --git a/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts deleted file mode 100644 index 5af1fa03d8b..00000000000 --- a/apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences.usecase.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { GetSubscriberPreference, GetSubscriberPreferenceCommand } from '@novu/application-generic'; -import { PreferenceLevelEnum } from '@novu/dal'; - -import { GetPreferencesCommand } from './get-preferences.command'; - -@Injectable() -export class GetPreferences { - constructor(private getSubscriberPreferenceUsecase: GetSubscriberPreference) {} - - async execute(command: GetPreferencesCommand) { - if (command.level === PreferenceLevelEnum.GLOBAL) { - } - const preferenceCommand = GetSubscriberPreferenceCommand.create({ - organizationId: command.organizationId, - environmentId: command.environmentId, - subscriberId: command.subscriberId, - }); - - return await this.getSubscriberPreferenceUsecase.execute(preferenceCommand); - } -} diff --git a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts b/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts deleted file mode 100644 index 0d1a4bdc19e..00000000000 --- a/apps/api/src/app/subscribers/usecases/get-preferences/get-preferences.command.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsDefined, IsEnum, IsString } from 'class-validator'; -import { PreferenceLevelEnum } from '@novu/dal'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; - -export class GetPreferencesCommand extends EnvironmentCommand { - @IsString() - @IsDefined() - subscriberId: string; - - @IsEnum(PreferenceLevelEnum) - @IsDefined() - level: PreferenceLevelEnum; -} diff --git a/apps/api/src/app/subscribers/usecases/index.ts b/apps/api/src/app/subscribers/usecases/index.ts index a7ea18deadf..7569c1263fc 100644 --- a/apps/api/src/app/subscribers/usecases/index.ts +++ b/apps/api/src/app/subscribers/usecases/index.ts @@ -8,7 +8,7 @@ import { import { GetSubscribers } from './get-subscribers'; import { GetSubscriber } from './get-subscriber'; -import { GetPreferences } from './get-preferences/get-preferences.usecase'; +import { GetPreferencesByLevel } from './get-preferences-by-level/get-preferences-by-level.usecase'; import { RemoveSubscriber } from './remove-subscriber'; import { SearchByExternalSubscriberIds } from './search-by-external-subscriber-ids'; import { UpdatePreference } from './update-preference/update-preference.usecase'; @@ -32,7 +32,7 @@ export const USE_CASES = [ GetSubscriber, GetSubscriberPreference, GetSubscriberTemplatePreference, - GetPreferences, + GetPreferencesByLevel, RemoveSubscriber, SearchByExternalSubscriberIds, UpdatePreference, diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts index 6b31ef47569..cdff461f9b3 100644 --- a/apps/api/src/app/widgets/widgets.controller.ts +++ b/apps/api/src/app/widgets/widgets.controller.ts @@ -16,7 +16,7 @@ import { import { AuthGuard } from '@nestjs/passport'; import { ApiExcludeController, ApiNoContentResponse, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { AnalyticsService, GetSubscriberPreference, GetSubscriberPreferenceCommand } from '@novu/application-generic'; -import { MessageEntity, SubscriberEntity } from '@novu/dal'; +import { MessageEntity, PreferenceLevelEnum, SubscriberEntity } from '@novu/dal'; import { MarkMessagesAsEnum, ButtonTypeEnum, MessageActionStatusEnum } from '@novu/shared'; import { SubscriberSession } from '../shared/framework/user.decorator'; @@ -59,6 +59,8 @@ import { UpdateSubscriberGlobalPreferencesCommand, } from '../subscribers/usecases/update-subscriber-global-preferences'; import { UpdateSubscriberGlobalPreferencesRequestDto } from '../subscribers/dtos/update-subscriber-global-preferences-request.dto'; +import { GetPreferencesByLevel } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase'; +import { GetPreferencesByLevelCommand } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command'; @Controller('/widgets') @ApiExcludeController() @@ -73,6 +75,7 @@ export class WidgetsController { private updateMessageActionsUsecase: UpdateMessageActions, private getOrganizationUsecase: GetOrganizationData, private getSubscriberPreferenceUsecase: GetSubscriberPreference, + private getSubscriberPreferenceByLevelUsecase: GetPreferencesByLevel, private updateSubscriberPreferenceUsecase: UpdateSubscriberPreference, private updateSubscriberGlobalPreferenceUsecase: UpdateSubscriberGlobalPreferences, private markAllMessagesAsUsecase: MarkAllMessagesAs, @@ -356,6 +359,22 @@ export class WidgetsController { return await this.getSubscriberPreferenceUsecase.execute(command); } + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/preferences/:level') + async getSubscriberPreferenceByLevel( + @SubscriberSession() subscriberSession: SubscriberEntity, + @Param('level') level: PreferenceLevelEnum + ) { + const command = GetPreferencesByLevelCommand.create({ + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession.subscriberId, + environmentId: subscriberSession._environmentId, + level, + }); + + return await this.getSubscriberPreferenceByLevelUsecase.execute(command); + } + @UseGuards(AuthGuard('subscriberJwt')) @Patch('/preferences/:templateId') async updateSubscriberPreference( diff --git a/packages/client/src/api/api.service.ts b/packages/client/src/api/api.service.ts index 11d86794ec4..dbdb9611ac4 100644 --- a/packages/client/src/api/api.service.ts +++ b/packages/client/src/api/api.service.ts @@ -12,6 +12,7 @@ import { IUserPreferenceSettings, IUnseenCountQuery, IUnreadCountQuery, + IUserGlobalPreferenceSettings, } from '../index'; export class ApiService { @@ -157,6 +158,10 @@ export class ApiService { return this.httpClient.get('/widgets/preferences'); } + async getUserGlobalPreference(): Promise { + return this.httpClient.get('/widgets/preferences/global'); + } + async updateSubscriberPreference( templateId: string, channelType: string, diff --git a/packages/notification-center/src/components/novu-provider/NovuProvider.tsx b/packages/notification-center/src/components/novu-provider/NovuProvider.tsx index 956f52e120f..8bf018272cb 100644 --- a/packages/notification-center/src/components/novu-provider/NovuProvider.tsx +++ b/packages/notification-center/src/components/novu-provider/NovuProvider.tsx @@ -29,6 +29,7 @@ const DEFAULT_FETCHING_STRATEGY: IFetchingStrategy = { fetchOrganization: true, fetchNotifications: false, fetchUserPreferences: false, + fetchUserGlobalPreferences: false, }; export interface INovuProviderProps { diff --git a/packages/notification-center/src/hooks/index.ts b/packages/notification-center/src/hooks/index.ts index 30e3e427de0..5bd891e36d8 100644 --- a/packages/notification-center/src/hooks/index.ts +++ b/packages/notification-center/src/hooks/index.ts @@ -12,6 +12,7 @@ export * from './useFetchNotifications'; export * from './useFetchOrganization'; export * from './useFeedUnseenCount'; export * from './useFetchUserPreferences'; +export * from './useFetchUserGlobalPreferences'; export * from './useMarkNotificationsAs'; export * from './useRemoveNotification'; export * from './useRemoveAllNotifications'; diff --git a/packages/notification-center/src/hooks/useFetchUserGlobalPreferences.ts b/packages/notification-center/src/hooks/useFetchUserGlobalPreferences.ts new file mode 100644 index 00000000000..0de51708b17 --- /dev/null +++ b/packages/notification-center/src/hooks/useFetchUserGlobalPreferences.ts @@ -0,0 +1,23 @@ +import type { IUserGlobalPreferenceSettings } from '@novu/client'; +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; + +import { useFetchUserGlobalPreferencesQueryKey } from './useFetchUserGlobalPreferencesQueryKey'; +import { useNovuContext } from './useNovuContext'; + +export const useFetchUserGlobalPreferences = ( + options: UseQueryOptions = {} +) => { + const { apiService, isSessionInitialized, fetchingStrategy } = useNovuContext(); + const userGlobalPreferencesQueryKey = useFetchUserGlobalPreferencesQueryKey(); + + const result = useQuery( + userGlobalPreferencesQueryKey, + () => apiService.getUserGlobalPreference(), + { + ...options, + enabled: isSessionInitialized && fetchingStrategy.fetchUserGlobalPreferences, + } + ); + + return result; +}; diff --git a/packages/notification-center/src/shared/interfaces/index.ts b/packages/notification-center/src/shared/interfaces/index.ts index 0bc431e0381..f9f15034ba0 100644 --- a/packages/notification-center/src/shared/interfaces/index.ts +++ b/packages/notification-center/src/shared/interfaces/index.ts @@ -78,6 +78,7 @@ export interface IFetchingStrategy { fetchOrganization: boolean; fetchNotifications: boolean; fetchUserPreferences: boolean; + fetchUserGlobalPreferences: boolean; } export interface INovuProviderContext { From f4117347d6eb4902a700eb6153019c5f50473417 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 11:08:30 +0530 Subject: [PATCH 17/25] feat: prefernce methods in node sdk and headless --- .../subscriber-preference.interface.ts | 5 +++ .../headless/src/lib/headless.service.test.ts | 4 +-- packages/headless/src/lib/headless.service.ts | 35 +++++++++++++++++-- packages/node/README.md | 21 +++++++++++ .../lib/subscribers/subscriber.interface.ts | 10 +++++- .../node/src/lib/subscribers/subscribers.ts | 14 +++++++- .../hooks/useUpdateUserGlobalPreferences.ts | 2 +- 7 files changed, 84 insertions(+), 7 deletions(-) diff --git a/libs/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts b/libs/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts index 4a0628a5e90..10da9d797df 100644 --- a/libs/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts +++ b/libs/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts @@ -28,3 +28,8 @@ export interface ITemplateConfiguration { critical: boolean; tags?: string[]; } + +export enum PreferenceLevelEnum { + GLOBAL = 'global', + TEMPLATE = 'template', +} diff --git a/packages/headless/src/lib/headless.service.test.ts b/packages/headless/src/lib/headless.service.test.ts index 9503702994a..b1deb3e85b3 100644 --- a/packages/headless/src/lib/headless.service.test.ts +++ b/packages/headless/src/lib/headless.service.test.ts @@ -950,7 +950,7 @@ describe('headless.service', () => { enabled: true, preferences: [ { - channelType: 'email', + channelType: ChannelTypeEnum.EMAIL, enabled: false, }, ], @@ -1000,7 +1000,7 @@ describe('headless.service', () => { enabled: true, preferences: [ { - channelType: 'email', + channelType: ChannelTypeEnum.EMAIL, enabled: true, }, ], diff --git a/packages/headless/src/lib/headless.service.ts b/packages/headless/src/lib/headless.service.ts index fb62332ea66..59bd82c8d1f 100644 --- a/packages/headless/src/lib/headless.service.ts +++ b/packages/headless/src/lib/headless.service.ts @@ -111,6 +111,14 @@ export class HeadlessService { queryFn: () => this.api.getUserPreference(), }; + private userGlobalPreferencesQueryOptions: QueryObserverOptions< + IUserGlobalPreferenceSettings[], + unknown + > = { + queryKey: USER_GLOBAL_PREFERENCES_QUERY_KEY, + queryFn: () => this.api.getUserGlobalPreference(), + }; + constructor(private options: IHeadlessServiceOptions) { const backendUrl = options.backendUrl ?? 'https://api.novu.co'; const token = getToken(); @@ -463,6 +471,29 @@ export class HeadlessService { return unsubscribe; } + public fetchUserGlobalPreferences({ + listener, + onSuccess, + onError, + }: { + listener: (result: FetchResult) => void; + onSuccess?: (settings: IUserGlobalPreferenceSettings[]) => void; + onError?: (error: unknown) => void; + }) { + this.assertSessionInitialized(); + + const { unsubscribe } = this.queryService.subscribeQuery({ + options: { + ...this.userGlobalPreferencesQueryOptions, + onSuccess, + onError, + }, + listener: (result) => this.callFetchListener(result, listener), + }); + + return unsubscribe; + } + public async updateUserPreferences({ templateId, channelType, @@ -563,9 +594,9 @@ export class HeadlessService { variables.enabled ), onSuccess: (data) => { - this.queryClient.setQueryData( + this.queryClient.setQueryData( USER_GLOBAL_PREFERENCES_QUERY_KEY, - () => data + () => [data] ); }, }, diff --git a/packages/node/README.md b/packages/node/README.md index 86d63457c35..07f38a86e08 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -319,6 +319,27 @@ const novu = new Novu(''); await novu.subscribers.getPreference("subscriberId") ``` +- #### Get subscriber global preference +```ts +import { Novu } from '@novu/node'; + +const novu = new Novu(''); + +await novu.subscribers.getGlobalPreference("subscriberId" ) +``` + + +- #### Get subscriber preference by level +```ts +import { Novu, PreferenceLevelEnum } from '@novu/node'; + +const novu = new Novu(''); +// Get global level preference +await novu.subscribers.getPreferenceByLevel("subscriberId", PreferenceLevelEnum.GLOBAL) + +// Get template level preference +await novu.subscribers.getPreferenceByLevel("subscriberId", PreferenceLevelEnum.TEMPLATE) +``` - #### Update subscriber preference for a workflow ```ts import { Novu } from '@novu/node'; diff --git a/packages/node/src/lib/subscribers/subscriber.interface.ts b/packages/node/src/lib/subscribers/subscriber.interface.ts index 0f36e44cd2f..d1a90e03f25 100644 --- a/packages/node/src/lib/subscribers/subscriber.interface.ts +++ b/packages/node/src/lib/subscribers/subscriber.interface.ts @@ -5,9 +5,15 @@ import { ButtonTypeEnum, MessageActionStatusEnum, ISubscribersDefine, + PreferenceLevelEnum, } from '@novu/shared'; -export { ISubscriberPayload, ButtonTypeEnum, MessageActionStatusEnum }; +export { + ISubscriberPayload, + ButtonTypeEnum, + MessageActionStatusEnum, + PreferenceLevelEnum, +}; export interface ISubscribers { list(page: number, limit: number); @@ -28,6 +34,8 @@ export interface ISubscribers { unsetCredentials(subscriberId: string, providerId: string); updateOnlineStatus(subscriberId: string, online: boolean); getPreference(subscriberId: string); + getGlobalPreference(subscriberId: string); + getPreferenceByLevel(subscriberId: string, level: PreferenceLevelEnum); updatePreference( subscriberId: string, templateId: string, diff --git a/packages/node/src/lib/subscribers/subscribers.ts b/packages/node/src/lib/subscribers/subscribers.ts index 6bb220bc789..d54635e14c1 100644 --- a/packages/node/src/lib/subscribers/subscribers.ts +++ b/packages/node/src/lib/subscribers/subscribers.ts @@ -4,7 +4,7 @@ import { IChannelCredentials, ISubscribersDefine, } from '@novu/shared'; -import { MarkMessagesAsEnum } from '@novu/shared'; +import { MarkMessagesAsEnum, PreferenceLevelEnum } from '@novu/shared'; import { IGetSubscriberNotificationFeedParams, IMarkFields, @@ -92,6 +92,18 @@ export class Subscribers extends WithHttp implements ISubscribers { return await this.http.get(`/subscribers/${subscriberId}/preferences`); } + async getGlobalPreference(subscriberId: string) { + return await this.http.get( + `/subscribers/${subscriberId}/preferences/${PreferenceLevelEnum.GLOBAL}` + ); + } + + async getPreferenceByLevel(subscriberId: string, level: PreferenceLevelEnum) { + return await this.http.get( + `/subscribers/${subscriberId}/preferences/${level}` + ); + } + async updatePreference( subscriberId: string, templateId: string, diff --git a/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts b/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts index 93e32a46812..66108c9f2d4 100644 --- a/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts +++ b/packages/notification-center/src/hooks/useUpdateUserGlobalPreferences.ts @@ -47,7 +47,7 @@ export const useUpdateUserGlobalPreferences = ({ >((variables) => apiService.updateSubscriberGlobalPreference(variables.preferences, variables.enabled), { ...options, onSuccess: (data, variables, context) => { - queryClient.setQueryData(userGlobalPreferencesQueryKey, () => data); + queryClient.setQueryData(userGlobalPreferencesQueryKey, () => [data]); onSuccess?.(data, variables, context); }, onError: (error, variables, context) => { From a83c3caa15853ad1ebbf58fb89393c8817d41d9e Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 19:20:19 +0530 Subject: [PATCH 18/25] feat: rebase --- pnpm-lock.yaml | 143 ------------------------------------------------- 1 file changed, 143 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16ddfb5ec68..5aad5181de2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,83 +1073,6 @@ importers: tsconfig-paths: 4.1.2 typescript: 4.9.5 - docs: - specifiers: - '@docusaurus/core': 2.3.1 - '@docusaurus/module-type-aliases': 2.3.1 - '@docusaurus/plugin-google-gtag': 2.3.1 - '@docusaurus/preset-classic': 2.3.1 - '@docusaurus/theme-common': 2.3.1 - '@mdx-js/react': ^1.6.21 - '@svgr/webpack': ^6.2.1 - '@tsconfig/docusaurus': 1.0.7 - '@types/react': ^17.0.14 - '@types/react-helmet': 6.1.6 - '@types/react-router-dom': 5.3.3 - clsx: ^1.1.1 - docusaurus-plugin-plausible: ^0.0.5 - docusaurus-plugin-sass: 0.2.4 - eslint-config-airbnb-typescript: ^17.0.0 - eslint-config-prettier: ^8.5.0 - file-loader: ^6.2.0 - husky: ^8.0.0 - markdownlint-cli: ^0.33.0 - prettier: ~2.8.0 - prism-react-renderer: ^1.3.1 - react: ^17.0.1 - react-dom: ^17.0.1 - sass: ^1.51.0 - sass-loader: ^13.0.0 - sharp: ^0.31.0 - styled-components: 5.3.11 - stylelint: ^15.0.0 - stylelint-config-css-modules: ^4.1.0 - stylelint-config-recess-order: ^3.0.0 - stylelint-config-recommended-scss: ^6.0.0 - stylelint-config-standard: ^25.0.0 - stylelint-order: ^5.0.0 - stylelint-scss: ^4.2.0 - typescript: 4.9.5 - url-loader: ^4.1.1 - dependencies: - '@docusaurus/core': 2.3.1_7kevo6ecmq3m3j5csvldmvt63m - '@docusaurus/plugin-google-gtag': 2.3.1_7kevo6ecmq3m3j5csvldmvt63m - '@docusaurus/preset-classic': 2.3.1_7hckhe4e7aa4xxsjqgrzg66tfu - '@docusaurus/theme-common': 2.3.1_7kevo6ecmq3m3j5csvldmvt63m - '@mdx-js/react': 1.6.22_react@17.0.2 - '@svgr/webpack': 6.5.1 - clsx: 1.2.1 - docusaurus-plugin-plausible: 0.0.5 - docusaurus-plugin-sass: 0.2.4_wsi5ihigezmhyhjqwfpsgagmau - file-loader: 6.2.0_webpack@5.88.2 - prism-react-renderer: 1.3.5_react@17.0.2 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - sass: 1.61.0 - sharp: 0.31.3 - styled-components: 5.3.11_v5ja746gkdtknuc6tj46sve3be - stylelint-config-css-modules: 4.2.0_stylelint@15.10.1 - url-loader: 4.1.1_pbpjnf4ifq5edsddxe3xbm7czm - devDependencies: - '@docusaurus/module-type-aliases': 2.3.1_sfoxds7t5ydpegc3knd667wn6m - '@tsconfig/docusaurus': 1.0.7 - '@types/react': 17.0.53 - '@types/react-helmet': 6.1.6 - '@types/react-router-dom': 5.3.3 - eslint-config-airbnb-typescript: 17.0.0_3z3emvisdmqtp5iprvlfwthfia - eslint-config-prettier: 8.8.0_eslint@8.48.0 - husky: 8.0.3 - markdownlint-cli: 0.33.0 - prettier: 2.8.7 - sass-loader: 13.2.2_sass@1.61.0+webpack@5.88.2 - stylelint: 15.10.1 - stylelint-config-recess-order: 3.1.0_stylelint@15.10.1 - stylelint-config-recommended-scss: 6.0.0_2tsnhd7ow7ykkzxj2ufbjyh2ru - stylelint-config-standard: 25.0.0_stylelint@15.10.1 - stylelint-order: 5.0.0_stylelint@15.10.1 - stylelint-scss: 4.6.0_stylelint@15.10.1 - typescript: 4.9.5 - enterprise/packages/auth: specifiers: '@nestjs/common': '>=9.3.x' @@ -5713,24 +5636,6 @@ packages: - supports-color dev: true - /@babel/helper-create-class-features-plugin/7.21.4_@babel+core@7.21.4: - resolution: {integrity: sha512-46QrX2CQlaFRF4TkwfTt6nJD7IHq8539cCL7SDpqWSDeJKY1xylKKY5F/33mJhLZ3mFvKv2gGrVS6NkyF6qs+Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.21.4 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-environment-visitor': 7.22.20 - '@babel/helper-function-name': 7.22.5 - '@babel/helper-member-expression-to-functions': 7.21.0 - '@babel/helper-optimise-call-expression': 7.18.6 - '@babel/helper-replace-supers': 7.20.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 - '@babel/helper-split-export-declaration': 7.22.6 - transitivePeerDependencies: - - supports-color - /@babel/helper-create-class-features-plugin/7.21.4_@babel+core@7.22.11: resolution: {integrity: sha512-46QrX2CQlaFRF4TkwfTt6nJD7IHq8539cCL7SDpqWSDeJKY1xylKKY5F/33mJhLZ3mFvKv2gGrVS6NkyF6qs+Q==} engines: {node: '>=6.9.0'} @@ -6285,17 +6190,6 @@ packages: '@babel/plugin-proposal-optional-chaining': 7.21.0_@babel+core@7.20.12 dev: true - /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.20.7_@babel+core@7.21.4: - resolution: {integrity: sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.13.0 - dependencies: - '@babel/core': 7.21.4 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-skip-transparent-expression-wrappers': 7.20.0 - '@babel/plugin-proposal-optional-chaining': 7.21.0_@babel+core@7.21.4 - /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.20.7_@babel+core@7.22.11: resolution: {integrity: sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==} engines: {node: '>=6.9.0'} @@ -6655,20 +6549,6 @@ packages: - supports-color dev: true - /@babel/plugin-proposal-private-property-in-object/7.21.0_@babel+core@7.21.4: - resolution: {integrity: sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.21.4 - '@babel/helper-annotate-as-pure': 7.22.5 - '@babel/helper-create-class-features-plugin': 7.21.4_@babel+core@7.21.4 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.21.4 - transitivePeerDependencies: - - supports-color - /@babel/plugin-proposal-private-property-in-object/7.21.0_@babel+core@7.22.11: resolution: {integrity: sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==} engines: {node: '>=6.9.0'} @@ -7232,19 +7112,6 @@ packages: - supports-color dev: true - /@babel/plugin-transform-async-to-generator/7.20.7_@babel+core@7.21.4: - resolution: {integrity: sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.21.4 - '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-remap-async-to-generator': 7.18.9_@babel+core@7.21.4 - transitivePeerDependencies: - - supports-color - /@babel/plugin-transform-async-to-generator/7.20.7_@babel+core@7.22.11: resolution: {integrity: sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==} engines: {node: '>=6.9.0'} @@ -7419,16 +7286,6 @@ packages: '@babel/template': 7.22.15 dev: true - /@babel/plugin-transform-computed-properties/7.20.7_@babel+core@7.21.4: - resolution: {integrity: sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/core': 7.21.4 - '@babel/helper-plugin-utils': 7.22.5 - '@babel/template': 7.22.15 - /@babel/plugin-transform-computed-properties/7.20.7_@babel+core@7.22.11: resolution: {integrity: sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==} engines: {node: '>=6.9.0'} From e69bc81d692ed2411611f513853c99babf3cc5e7 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 19:25:25 +0530 Subject: [PATCH 19/25] fix: typo --- ...get-subscriber-preferences-response.dto.ts | 2 +- .../headless/api-reference.md | 630 ------------------ 2 files changed, 1 insertion(+), 631 deletions(-) delete mode 100644 docs/docs/notification-center/headless/api-reference.md diff --git a/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts b/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts index c051f64d307..52ea48a0af5 100644 --- a/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts +++ b/apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts @@ -26,7 +26,7 @@ class TemplateResponse { class Overrides { @ApiProperty({ type: ChannelTypeEnum, - description: 'The channel type which is overriden', + description: 'The channel type which is overridden', }) channel: ChannelTypeEnum; @ApiProperty({ diff --git a/docs/docs/notification-center/headless/api-reference.md b/docs/docs/notification-center/headless/api-reference.md deleted file mode 100644 index 955fe079ae0..00000000000 --- a/docs/docs/notification-center/headless/api-reference.md +++ /dev/null @@ -1,630 +0,0 @@ ---- -sidebar_position: 2 -sidebar_label: API Reference ---- - -# Headless Notification Center API Reference - -This page contains the complete documentation about the Headless Notification Center package. You can find here the list of all the methods that you can use. - -## initializeSession - -To use the headless service, you'll need to initialize the session first. -This sets the token and starts the socket listener service. - -```ts -headlessService.initializeSession({ - listener: (result: FetchResult) => { - console.log(result); - }, - onSuccess: (session: ISession) => { - console.log(session); - }, - onError: (error: unknown) => { - console.error(error); - }, -}); -``` - -### method args interface - -```ts -interface IInitializeSession { - listener: (result: FetchResult) => void; - onSuccess?: (session: ISession) => void; - onError?: (error: unknown) => void; -} -``` - -## fetchOrganization - -Fetches the details of the current organization - -```ts -headlessService.fetchOrganization({ - listener: (result: FetchResult) => { - console.log(result); - }, - onSuccess: (organization: IOrganizationEntity) => { - console.log(organization); - }, - onError: (error: unknown) => { - console.error(error); - }, -}); -``` - -### method args interface - -```ts -interface IFetchOrganization { - listener: (result: FetchResult) => void; - onSuccess?: (session: IOrganizationEntity) => void; - onError?: (error: unknown) => void; -} -``` - -## fetchUnseenCount - -Fetches the count of unseen messages - -```ts -headlessService.fetchUnseenCount({ - listener: (result: FetchResult<{ count: number }>) => { - console.log(result); - }, - onSuccess: (data: { count: number }) => { - console.log(data); - }, - onError: (error: unknown) => { - console.error(error); - }, -}); -``` - -### method args interface - -```ts -interface IFetchUnseenCount { - listener: (result: FetchResult<{ count: number }>) => void; - onSuccess?: (data: { count: number }) => void; - onError?: (error: unknown) => void; -} -``` - -## fetchUnreadCount - -Fetches the count of unread messages - -```ts -headlessService.fetchUnreadCount({ - listener: (result: FetchResult<{ count: number }>) => { - console.log(result); - }, - onSuccess: (data: { count: number }) => { - console.log(data); - }, - onError: (error: unknown) => { - console.error(error); - }, -}); -``` - -### method args interface - -```ts -interface IFetchUnreadCount { - listener: (result: FetchResult<{ count: number }>) => void; - onSuccess?: (data: { count: number }) => void; - onError?: (error: unknown) => void; -} -``` - -## listenNotificationReceive - -Listens to a new notification being added. -Can be used to retrieve a new notification in real-time and trigger UI changes. - -```ts -headlessService.listenNotificationReceive({ - listener: (message: IMessage) => { - console.log(JSON.stringify(message)); - }, -}); -``` - -### method args interface - -```ts -interface IListenNotificationReceive { - listener: (message: IMessage) => void; -} -``` - -## listenUnseenCountChange - -Listens to the changes of the unseen count. -Can be used to get real time count of the unseen messages. - -```ts -headlessService.listenUnseenCountChange({ - listener: (unseenCount: number) => { - console.log(unseenCount); - }, -}); -``` - -### method args interface - -```ts -interface IListenUnseenCountChanget { - listener: (unseenCount: number) => void; -} -``` - -## listenUnreadCountChange - -Listens to the changes of the unread count. -Can be used to get real time count of the unread messages. - -```ts -headlessService.listenUnreadCountChange({ - listener: (unreadCount: number) => { - console.log(unreadCount); - }, -}); -``` - -### method args interface - -```ts -interface IListenUnreadCountChanget { - listener: (unreadCount: number) => void; -} -``` - -## fetchNotifications - -Retrieves the list of notifications for the subscriber. -Can also be used to get the notifications of a particular tab. - -```ts -headlessService.fetchNotifications({ - listener: (result: FetchResult>) => { - console.log(result); - }, - onSuccess: (response: IPaginatedResponse) => { - console.log(response.data, response.page, response.totalCount, response.pageSize); - }, - page: pageNumber, - query: { feedIdentifier: 'feedId', read: false, seen: true }, - storeId: 'storeId', -}); -``` - -### method args interface - -```ts -interface IFetchNotifications { - page?: number; - storeId?: string; - query?: IStoreQuery; - listener: (result: FetchResult>) => void; - onSuccess?: (messages: IPaginatedResponse) => void; - onError?: (error: unknown) => void; -} -``` - -## fetchUserPreferences - -Fetches the user preferences. -Read more [here](../../platform/preferences.md) - -```ts -headlessService.fetchUserPreferences({ - listener: (result: FetchResult) => { - console.log(result); - }, - onSuccess: (settings: IUserPreferenceSettings[]) => { - console.log(settings); - }, - onError: (error: unknown) => { - console.error(error); - }, -}); -``` - -### method args interface - -```ts -interface IFetchUserPreferences { - listener: (result: FetchResult) => void; - onSuccess?: (settings: IUserPreferenceSettings[]) => void; - onError?: (error: unknown) => void; -} -``` - -## updateUserPreferences - -Updates the user preferences. -Read more [here](../../platform/preferences.md) - -```ts -headlessService.updateUserPreferences({ - listener: ( - result: UpdateResult - ) => { - console.log(result); - }, - onSuccess: (settings: IUserPreferenceSettings) => { - console.log(settings); - }, - onError: (error: unknown) => { - console.error(error); - }, - templateId: 'templateId', - channelType: 'SMS', - checked: true, -}); -``` - -### method args interface - -```ts -interface IUpdateUserPreferences { - templateId: IUpdateUserPreferencesVariables['templateId']; - channelType: IUpdateUserPreferencesVariables['channelType']; - checked: IUpdateUserPreferencesVariables['checked']; - listener: ( - result: UpdateResult - ) => void; - onSuccess?: (settings: IUserPreferenceSettings) => void; - onError?: (error: unknown) => void; -} -``` - -## updateUserGlobalPreferences - -Updates the user's global preferences. -Read more [here](../../platform/preferences.md) - -```ts -headlessService.updateUserGlobalPreferences({ - listener: ( - result: UpdateResult< - IUserGlobalPreferenceSettings, - unknown, - IUpdateUserGlobalPreferencesVariables - > - ) => { - console.log(result); - }, - onSuccess: (settings: IUserGlobalPreferenceSettings) => { - console.log(settings); - }, - onError: (error: unknown) => { - console.error(error); - }, - enabled: true, - preferences: [ - { - type: 'SMS', - enabled: true, - }, - ], -}); -``` - -### method args interface - -```ts -interface IUpdateUserGlobalPreferences { - enabled: IUpdateUserGlobalPreferencesVariables['enabled']; - preferences: IUpdateUserGlobalPreferencesVariables['preferences']; - listener: ( - result: UpdateResult< - IUserGlobalPreferenceSettings, - unknown, - IUpdateUserGlobalPreferencesVariables - > - ) => void; - onSuccess?: (settings: IUserGlobalPreferenceSettings) => void; - onError?: (error: unknown) => void; -} -``` - -## markNotificationsAsRead - -mark a single or multiple notifications as read using the message id. - -```ts -headlessService.markNotificationsAsRead({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: (message: IMessage) => { - console.log(message); - }, - onError: (error: unknown) => { - console.error(error); - }, - messageId: ['messageOne', 'messageTwo'], -}); -``` - -### method args interface - -```ts -interface IMarkNotificationsAsRead { - messageId: IMessageId; - listener: (result: UpdateResult) => void; - onSuccess?: (message: IMessage) => void; - onError?: (error: unknown) => void; -} -``` - -## markNotificationsAsSeen - -mark a single or multiple notifications as seen using the message id. - -```ts -headlessService.markNotificationsAsSeen({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: (message: IMessage) => { - console.log(message); - }, - onError: (error: unknown) => { - console.error(error); - }, - messageId: ['messageOne', 'messageTwo'], -}); -``` - -### method args interface - -```ts -interface IMarkNotificationsAsSeen { - messageId: IMessageId; - listener: (result: UpdateResult) => void; - onSuccess?: (message: IMessage) => void; - onError?: (error: unknown) => void; -} -``` - -## removeNotification - -removes a single notification using the message id. - -```ts -headlessService.removeNotification({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: (message: IMessage) => { - console.log(message); - }, - onError: (error: unknown) => { - console.error(error); - }, - messageId: 'messageOne', -}); -``` - -### method args interface - -```ts -interface IRemoveNotification { - messageId: string; - listener: (result: UpdateResult) => void; - onSuccess?: (message: IMessage) => void; - onError?: (error: unknown) => void; -} -``` - -## updateAction - -updates the action button for the notifications. - -```ts -headlessService.updateAction({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: (data: IMessage) => { - console.log(data); - }, - onError: (error: unknown) => { - console.error(error); - }, - messageId: 'messageOne', - actionButtonType: 'primary', - status: 'done', - payload: { - abc: 'def', - }, -}); -``` - -### method args interface - -```ts -interface IUpdateAction { - messageId: IUpdateActionVariables['messageId']; - actionButtonType: IUpdateActionVariables['actionButtonType']; - status: IUpdateActionVariables['status']; - payload?: IUpdateActionVariables['payload']; - listener: (result: UpdateResult) => void; - onSuccess?: (data: IMessage) => void; - onError?: (error: unknown) => void; -} -``` - -## markAllMessagesAsRead - -Mark subscriber's all unread messages as read. -Can be used to mark all messages as read of a single or multiple feeds by passing the `feedId` - -```ts -headlessService.markAllMessagesAsRead({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: (count: number) => { - console.log(count); - }, - onError: (error: unknown) => { - console.error(error); - }, - feedId: ['feedOne', 'feedTwo'], -}); -``` - -### method args interface - -```ts -interface IMarkAllMessagesAsRead { - listener: (result: UpdateResult) => void; - onSuccess?: (count: number) => void; - onError?: (error: unknown) => void; - feedId?: IFeedId; -} -``` - -## markAllMessagesAsSeen - -Mark subscriber's all unread messages as seen. -Can be used to mark all messages as seen of a single or multiple feeds by passing the `feedId` - -```ts -headlessService.markAllMessagesAsSeen({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: (count: number) => { - console.log(count); - }, - onError: (error: unknown) => { - console.error(error); - }, - feedId: ['feedOne', 'feedTwo'], -}); -``` - -### method args interface - -```ts -interface IMarkAllMessagesAsSeen { - listener: (result: UpdateResult) => void; - onSuccess?: (count: number) => void; - onError?: (error: unknown) => void; - feedId?: IFeedId; -} -``` - -## removeAllNotifications - -Removes all notifications. -Can be used to remove all notifications of a feed by passing the `feedId` - -```ts -headlessService.removeAllNotifications({ - listener: (result: UpdateResult) => { - console.log(result); - }, - onSuccess: () => { - console.log('success'); - }, - onError: (error: unknown) => { - console.error(error); - }, - feedId: 'feedOne', -}); -``` - -### method args interface - -```ts -interface IRemoveAllNotifications { - feedId?: string; - listener: (result: UpdateResult) => void; - onSuccess?: () => void; - onError?: (error: unknown) => void; -} -``` - ---- - -## Types and Interfaces - -```ts -interface IHeadlessServiceOptions { - backendUrl?: string; - socketUrl?: string; - applicationIdentifier: string; - subscriberId?: string; - subscriberHash?: string; - config?: { - retry?: number; - retryDelay?: number; - }; -} - -interface ISession { - token: string; - profile: ISubscriberJwt; -} - -interface ISubscriberJwt { - _id: string; - firstName: string; - lastName: string; - email: string; - subscriberId: string; - organizationId: string; - environmentId: string; - aud: 'widget_user'; -} - -interface IUpdateUserPreferencesVariables { - templateId: string; - channelType: string; - checked: boolean; -} - -enum ButtonTypeEnum { - PRIMARY = 'primary', - SECONDARY = 'secondary', - CLICKED = 'clicked', -} - -enum MessageActionStatusEnum { - PENDING = 'pending', - DONE = 'done', -} - -interface IUpdateActionVariables { - messageId: string; - actionButtonType: ButtonTypeEnum; - status: MessageActionStatusEnum; - payload?: Record; -} - -type FetchResult = Pick< - QueryObserverResult, - 'data' | 'error' | 'status' | 'isLoading' | 'isFetching' | 'isError' ->; - -type UpdateResult = Pick< - MutationObserverResult, - 'data' | 'error' | 'status' | 'isLoading' | 'isError' ->; - -type IMessageId = string | string[]; -type IFeedId = string | string[]; -``` - -:::note -QueryObserverResult and MutationObserverResult are imported from the `@tanstack/query-core` library. -::: From 39b8034b1a20b5582d34f0c7d4245ca93b76cf88 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 19:38:00 +0530 Subject: [PATCH 20/25] fix: swagger error --- apps/api/src/app/subscribers/subscribers.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 654a8e97972..6d9fa1473e8 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -21,7 +21,7 @@ import { UpdateSubscriber, UpdateSubscriberCommand, } from '@novu/application-generic'; -import { ApiOperation, ApiTags, ApiNoContentResponse } from '@nestjs/swagger'; +import { ApiOperation, ApiTags, ApiNoContentResponse, ApiParam } from '@nestjs/swagger'; import { ButtonTypeEnum, ChatProviderIdEnum, IJwtPayload } from '@novu/shared'; import { MessageEntity, PreferenceLevelEnum } from '@novu/dal'; @@ -361,6 +361,7 @@ export class SubscribersController { @ApiOperation({ summary: 'Get subscriber preferences by level', }) + @ApiParam({ name: 'level', type: String, required: true }) async getSubscriberPreferenceByLevel( @UserSession() user: IJwtPayload, @Param() { level, subscriberId }: GetSubscriberPreferencesByLevelParams From 7febe1b2202252f521b95d18c5bf8e7654a9d1ef Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 19:53:51 +0530 Subject: [PATCH 21/25] fix: swagger --- apps/api/src/app/subscribers/subscribers.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 6d9fa1473e8..efc5eb97242 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -361,6 +361,7 @@ export class SubscribersController { @ApiOperation({ summary: 'Get subscriber preferences by level', }) + @ApiParam({ name: 'subscriberId', type: String, required: true }) @ApiParam({ name: 'level', type: String, required: true }) async getSubscriberPreferenceByLevel( @UserSession() user: IJwtPayload, From 38574c19e6ed498116ec8066889efad78ff1f48d Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 20:16:53 +0530 Subject: [PATCH 22/25] feat: add default state for missing channels --- ...et-subscriber-global-preference.usecase.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts index d1effba616d..f745afce4aa 100644 --- a/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts +++ b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts @@ -9,6 +9,7 @@ import { import { GetSubscriberGlobalPreferenceCommand } from './get-subscriber-global-preference.command'; import { buildSubscriberKey, CachedEntity } from '../../services/cache'; import { ApiException } from '../../utils/exceptions'; +import { IPreferenceChannels } from '@novu/shared'; @Injectable() export class GetSubscriberGlobalPreference { @@ -37,11 +38,15 @@ export class GetSubscriberGlobalPreference { }); const subscriberChannelPreference = subscriberPreference?.channels; + const channels = this.updatePreferenceStateWithDefault( + subscriberChannelPreference ?? {} + ); + console.log('channels, banda', channels); return { preference: { enabled: subscriberPreference?.enabled ?? true, - channels: subscriberChannelPreference ?? {}, + channels, }, }; } @@ -65,4 +70,16 @@ export class GetSubscriberGlobalPreference { subscriberId ); } + // adds default state for missing channels + private updatePreferenceStateWithDefault(preference: IPreferenceChannels) { + const defaultPreference: IPreferenceChannels = { + email: true, + sms: true, + in_app: true, + chat: true, + push: true, + }; + + return { ...defaultPreference, ...preference }; + } } From 13a30abc428f6a88ae2e777763577520b7675075 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 20:26:08 +0530 Subject: [PATCH 23/25] fix: tests --- .../subscribers/e2e/update-global-preference.e2e.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts index 0bf030e983d..2da13942648 100644 --- a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts @@ -60,6 +60,10 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre }); expect(response.data.data.preference.channels).to.eql({ [ChannelTypeEnum.EMAIL]: true, + [ChannelTypeEnum.SMS]: true, + [ChannelTypeEnum.CHAT]: true, + [ChannelTypeEnum.PUSH]: true, + [ChannelTypeEnum.IN_APP]: true, }); }); @@ -80,6 +84,8 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre [ChannelTypeEnum.PUSH]: true, [ChannelTypeEnum.IN_APP]: false, [ChannelTypeEnum.SMS]: true, + [ChannelTypeEnum.EMAIL]: true, + [ChannelTypeEnum.CHAT]: true, }); }); @@ -100,6 +106,10 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre expect(res.data.data.preference.channels).to.eql({ [ChannelTypeEnum.EMAIL]: true, + [ChannelTypeEnum.SMS]: true, + [ChannelTypeEnum.CHAT]: true, + [ChannelTypeEnum.PUSH]: true, + [ChannelTypeEnum.IN_APP]: true, }); }); }); From 208027fd40ca0b4f7630fc6e42544527379a10a8 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 20:44:27 +0530 Subject: [PATCH 24/25] fix: added missing method --- packages/node/src/lib/subscribers/subscriber.interface.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/node/src/lib/subscribers/subscriber.interface.ts b/packages/node/src/lib/subscribers/subscriber.interface.ts index d1a90e03f25..13a7e97a41d 100644 --- a/packages/node/src/lib/subscribers/subscriber.interface.ts +++ b/packages/node/src/lib/subscribers/subscriber.interface.ts @@ -41,6 +41,10 @@ export interface ISubscribers { templateId: string, data: IUpdateSubscriberPreferencePayload ); + updateGlobalPreference( + subscriberId: string, + data: IUpdateSubscriberGlobalPreferencePayload + ); getNotificationsFeed( subscriberId: string, params: IGetSubscriberNotificationFeedParams From 1c49f467036ef629d9cba0da0dcd60bed2b3abee Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Fri, 29 Sep 2023 20:44:44 +0530 Subject: [PATCH 25/25] refactor: removed logs --- .../get-subscriber-global-preference.usecase.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts index f745afce4aa..cf1c53988b3 100644 --- a/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts +++ b/packages/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts @@ -41,7 +41,6 @@ export class GetSubscriberGlobalPreference { const channels = this.updatePreferenceStateWithDefault( subscriberChannelPreference ?? {} ); - console.log('channels, banda', channels); return { preference: {