diff --git a/.cspell.json b/.cspell.json index d9d5d50bd57..b96e205cec6 100644 --- a/.cspell.json +++ b/.cspell.json @@ -702,7 +702,12 @@ "xkeysib", "xyflow", "zulip", - "zwnj" + "zwnj", + "lstrip", + "rstrip", + "truncatewords", + "xmlschema", + "jsonify" ], "flagWords": [], "patterns": [ diff --git a/apps/api/migrations/preference-centralization/preference-centralization-migration.ts b/apps/api/migrations/preference-centralization/preference-centralization-migration.ts index daecd368cd5..7ee0131d85f 100644 --- a/apps/api/migrations/preference-centralization/preference-centralization-migration.ts +++ b/apps/api/migrations/preference-centralization/preference-centralization-migration.ts @@ -1,6 +1,6 @@ /* eslint-disable max-len */ -/* eslint-disable no-cond-assign */ /* eslint-disable no-console */ + import '../../src/config'; import { NestFactory } from '@nestjs/core'; @@ -19,24 +19,14 @@ import { UpsertSubscriberWorkflowPreferencesCommand, } from '@novu/application-generic'; import { buildWorkflowPreferencesFromPreferenceChannels, DEFAULT_WORKFLOW_PREFERENCES } from '@novu/shared'; +import async from 'async'; import { AppModule } from '../../src/app.module'; -const BATCH_SIZE = 500; - -/** - * Sleep for a random amount of time between 80% and 120% of the provided duration. - * @param ms - The duration to sleep for in milliseconds. - * @returns A promise that resolves after the randomized sleep duration. - */ -const sleep = (ms: number) => { - const randomFactor = 1 + (Math.random() - 0.5) * 0.4; // Random factor between 0.8 and 1.2 - const randomizedMs = ms * randomFactor; - - return new Promise((resolve) => { - setTimeout(resolve, randomizedMs); - }); -}; +const RETRIEVAL_BATCH_SIZE = 1000; +const PROCESS_BATCH_SIZE = 2500; +const MAX_QUEUE_DEPTH = 25000; +const MAX_QUEUE_TIMEOUT = 100; const counter: Record = { subscriberGlobal: { success: 0, error: 0 }, @@ -62,13 +52,24 @@ process.on('SIGINT', () => { /** * Migration to centralize workflow and subscriber preferences. + * Preferences are migrated in the following order: + * + * - workflow preferences + * -> preferences with workflow-resource type + * -> preferences with user-workflow type * - subscriber global preference * -> preferences with subscriber global type * - subscriber workflow preferences * -> preferences with subscriber workflow type - * - workflow preferences - * -> preferences with workflow-resource type - * -> preferences with user-workflow type + * + * Subscriber workflow preferences must be migrated after global preferences because + * the upsert subscriber global preferences will delete the subscriber workflow preferences + * with a matching channel. + * + * Depending on the size of your dataset, the following additional indexes will help with of the + * Subscriber Preference Migration: + * - { level: 1 } + * - { level: 1, _id: 1 } */ export async function preferenceCentralization(startWorkflowId?: string, startSubscriberId?: string) { const app = await NestFactory.create(AppModule, { @@ -83,7 +84,7 @@ export async function preferenceCentralization(startWorkflowId?: string, startSu // Set up a logging interval to log the counter and last processed IDs every 10 seconds const logInterval = setInterval(() => { console.log('Current migration status:'); - console.log({ counter }); + console.log({ timestamp: new Date().toISOString(), counter }); if (lastProcessedWorkflowId) { console.log(`Last processed workflow preference ID: ${lastProcessedWorkflowId}`); } @@ -107,51 +108,12 @@ export async function preferenceCentralization(startWorkflowId?: string, startSu app.close(); } -async function processWorkflowBatch( - batch: NotificationTemplateEntity[], - upsertPreferences: UpsertPreferences, - workflowPreferenceRepository: NotificationTemplateRepository -) { - await Promise.all( - batch.map(async (workflowPreference) => { - try { - await workflowPreferenceRepository.withTransaction(async (tx) => { - const workflowPreferenceToUpsert = UpsertWorkflowPreferencesCommand.create({ - templateId: workflowPreference._id.toString(), - environmentId: workflowPreference._environmentId.toString(), - organizationId: workflowPreference._organizationId.toString(), - preferences: DEFAULT_WORKFLOW_PREFERENCES, - }); - - await upsertPreferences.upsertWorkflowPreferences(workflowPreferenceToUpsert); - - const userWorkflowPreferenceToUpsert = UpsertUserWorkflowPreferencesCommand.create({ - userId: workflowPreference._creatorId.toString(), - templateId: workflowPreference._id.toString(), - environmentId: workflowPreference._environmentId.toString(), - organizationId: workflowPreference._organizationId.toString(), - preferences: buildWorkflowPreferencesFromPreferenceChannels( - workflowPreference.critical, - workflowPreference.preferenceSettings - ), - }); - - await upsertPreferences.upsertUserWorkflowPreferences(userWorkflowPreferenceToUpsert); - }); - - counter.workflow.success += 1; - lastProcessedWorkflowId = workflowPreference._id.toString(); - } catch (error) { - console.error(error); - console.error({ - failedWorkflowId: workflowPreference._id, - }); - counter.workflow.error += 1; - } - }) - ); -} - +/** + * Migrate workflow preferences. + * - workflow preferences + * -> preferences with workflow-resource type + * -> preferences with user-workflow type + */ async function migrateWorkflowPreferences( workflowPreferenceRepository: NotificationTemplateRepository, upsertPreferences: UpsertPreferences, @@ -163,125 +125,243 @@ async function migrateWorkflowPreferences( console.log(`Starting from workflow preference ID: ${startWorkflowId}`); query = { _id: { $gt: startWorkflowId } }; } - const workflowPreferenceCursor = await workflowPreferenceRepository._model - .find(query) - .select({ _id: 1, _environmentId: 1, _organizationId: 1, _creatorId: 1, critical: 1, preferenceSettings: 1 }) - .sort({ _id: 1 }) - .read('secondaryPreferred') - .cursor({ batchSize: BATCH_SIZE }); - - let batch: NotificationTemplateEntity[] = []; - let document: any; - while ((document = await workflowPreferenceCursor.next())) { - batch.push(document); - - if (batch.length === BATCH_SIZE) { - await processWorkflowBatch(batch, upsertPreferences, workflowPreferenceRepository); - batch = []; + + const recordQueue = async.queue(async (record, callback) => { + await processWorkflowRecord(record, upsertPreferences, workflowPreferenceRepository); + callback(); + }, PROCESS_BATCH_SIZE); + + let hasMore = true; + let skip = 0; + while (hasMore) { + if (recordQueue.length() >= MAX_QUEUE_DEPTH) { + await new Promise((resolve) => { + setTimeout(resolve, MAX_QUEUE_TIMEOUT); + }); + continue; + } + const batch = await workflowPreferenceRepository._model + .find(query) + .select({ + _id: 1, + _environmentId: 1, + _organizationId: 1, + _creatorId: 1, + critical: 1, + preferenceSettings: 1, + }) + .sort({ _id: 1 }) + .skip(skip) + .limit(RETRIEVAL_BATCH_SIZE) + .read('secondaryPreferred'); + + if (batch.length > 0) { + recordQueue.push(batch as unknown as NotificationTemplateEntity[]); + skip += RETRIEVAL_BATCH_SIZE; + } else { + hasMore = false; } } - // Process any remaining documents in the batch - if (batch.length > 0) { - await processWorkflowBatch(batch, upsertPreferences, workflowPreferenceRepository); - } + // Wait for all records to be processed + await recordQueue.drain(); console.log('end workflow preference migration'); } -async function processSubscriberBatch(batch: SubscriberPreferenceEntity[], upsertPreferences: UpsertPreferences) { - await Promise.all( - batch.map(async (subscriberPreference) => { - try { - if (subscriberPreference.level === PreferenceLevelEnum.GLOBAL) { - const preferenceToUpsert = UpsertSubscriberGlobalPreferencesCommand.create({ - _subscriberId: subscriberPreference._subscriberId.toString(), - environmentId: subscriberPreference._environmentId.toString(), - organizationId: subscriberPreference._organizationId.toString(), - preferences: buildWorkflowPreferencesFromPreferenceChannels(false, subscriberPreference.channels), - }); - - await upsertPreferences.upsertSubscriberGlobalPreferences(preferenceToUpsert); - - counter.subscriberGlobal.success += 1; - } else if (subscriberPreference.level === PreferenceLevelEnum.TEMPLATE) { - if (!subscriberPreference._templateId) { - console.error( - `Invalid templateId ${subscriberPreference._templateId} for id ${subscriberPreference._id} for subscriber ${subscriberPreference._subscriberId}` - ); - counter.subscriberWorkflow.error += 1; - - return; - } - const preferenceToUpsert = UpsertSubscriberWorkflowPreferencesCommand.create({ - _subscriberId: subscriberPreference._subscriberId.toString(), - templateId: subscriberPreference._templateId.toString(), - environmentId: subscriberPreference._environmentId.toString(), - organizationId: subscriberPreference._organizationId.toString(), - preferences: buildWorkflowPreferencesFromPreferenceChannels(false, subscriberPreference.channels), - }); - - await upsertPreferences.upsertSubscriberWorkflowPreferences(preferenceToUpsert); - - counter.subscriberWorkflow.success += 1; - } else { - console.error( - `Invalid preference level ${subscriberPreference.level} for id ${subscriberPreference._subscriberId}` - ); - counter.subscriberUnknown.error += 1; - } - lastProcessedSubscriberId = subscriberPreference._id.toString(); - } catch (error) { - console.error(error); - console.error({ - failedSubscriberPreferenceId: subscriberPreference._id, - failedSubscriberId: subscriberPreference._subscriberId, - }); - if (subscriberPreference.level === PreferenceLevelEnum.GLOBAL) { - counter.subscriberGlobal.error += 1; - } else if (subscriberPreference.level === PreferenceLevelEnum.TEMPLATE) { - counter.subscriberWorkflow.error += 1; - } - } - }) - ); +async function processWorkflowRecord( + workflowPreference: NotificationTemplateEntity, + upsertPreferences: UpsertPreferences, + workflowPreferenceRepository: NotificationTemplateRepository +) { + try { + await workflowPreferenceRepository.withTransaction(async (tx) => { + const workflowPreferenceToUpsert = UpsertWorkflowPreferencesCommand.create({ + templateId: workflowPreference._id.toString(), + environmentId: workflowPreference._environmentId.toString(), + organizationId: workflowPreference._organizationId.toString(), + preferences: DEFAULT_WORKFLOW_PREFERENCES, + }); + + await upsertPreferences.upsertWorkflowPreferences(workflowPreferenceToUpsert); + + const userWorkflowPreferenceToUpsert = UpsertUserWorkflowPreferencesCommand.create({ + userId: workflowPreference._creatorId.toString(), + templateId: workflowPreference._id.toString(), + environmentId: workflowPreference._environmentId.toString(), + organizationId: workflowPreference._organizationId.toString(), + preferences: buildWorkflowPreferencesFromPreferenceChannels( + workflowPreference.critical, + workflowPreference.preferenceSettings + ), + }); + + await upsertPreferences.upsertUserWorkflowPreferences(userWorkflowPreferenceToUpsert); + }); + + counter.workflow.success += 1; + lastProcessedWorkflowId = workflowPreference._id.toString(); + } catch (error) { + console.error(error); + console.error({ + failedWorkflowId: workflowPreference._id, + }); + counter.workflow.error += 1; + } } +/** + * Migrate subscriber preferences. + * - global subscriber preferences + * -> preferences with subscriber global type + * - template subscriber preferences + * -> preferences with subscriber template type + */ async function migrateSubscriberPreferences( subscriberPreferenceRepository: SubscriberPreferenceRepository, upsertPreferences: UpsertPreferences, startSubscriberId?: string ) { console.log('start subscriber preference migration'); - let query = {}; + + console.log('start processing global subscriber preferences'); + // Process global level preferences first + await processSubscriberPreferencesByLevel( + subscriberPreferenceRepository, + upsertPreferences, + PreferenceLevelEnum.GLOBAL, + startSubscriberId + ); + console.log('end processing global subscriber preferences'); + + console.log('start processing template subscriber preferences'); + // Then process template level preferences + await processSubscriberPreferencesByLevel( + subscriberPreferenceRepository, + upsertPreferences, + PreferenceLevelEnum.TEMPLATE, + startSubscriberId + ); + console.log('end processing template subscriber preferences'); + + console.log('end subscriber preference migration'); +} + +async function processSubscriberPreferencesByLevel( + subscriberPreferenceRepository: SubscriberPreferenceRepository, + upsertPreferences: UpsertPreferences, + level: PreferenceLevelEnum, + startSubscriberId?: string +) { + console.log(`start processing subscriber preferences with level: ${level}`); + let query: { level: PreferenceLevelEnum; _id?: { $gt: string } } = { + level, + }; if (startSubscriberId) { console.log(`Starting from subscriber preference ID: ${startSubscriberId}`); - query = { _id: { $gt: startSubscriberId } }; + query = { ...query, _id: { $gt: startSubscriberId } }; } - const subscriberPreferenceCursor = await subscriberPreferenceRepository._model - .find(query) - .select({ _id: 1, _environmentId: 1, _organizationId: 1, _subscriberId: 1, _templateId: 1, level: 1, channels: 1 }) - .sort({ _id: 1 }) - .read('secondaryPreferred') - .cursor({ batchSize: BATCH_SIZE }); - - let batch: SubscriberPreferenceEntity[] = []; - let document: any; - while ((document = await subscriberPreferenceCursor.next())) { - batch.push(document); - - if (batch.length === BATCH_SIZE) { - await processSubscriberBatch(batch, upsertPreferences); - batch = []; + + const recordQueue = async.queue(async (record, callback) => { + await processSubscriberRecord(record, upsertPreferences); + callback(); + }, PROCESS_BATCH_SIZE); + + let hasMore = true; + let skip = 0; + while (hasMore) { + if (recordQueue.length() >= MAX_QUEUE_DEPTH) { + await new Promise((resolve) => { + setTimeout(resolve, MAX_QUEUE_TIMEOUT); + }); + continue; } - } - // Process any remaining documents in the batch - if (batch.length > 0) { - await processSubscriberBatch(batch, upsertPreferences); + const batch = await subscriberPreferenceRepository._model + .find(query) + .select({ + _id: 1, + _environmentId: 1, + _organizationId: 1, + _subscriberId: 1, + _templateId: 1, + level: 1, + channels: 1, + }) + .sort({ _id: 1 }) + .skip(skip) + .limit(RETRIEVAL_BATCH_SIZE) + .read('secondaryPreferred'); + + if (batch.length > 0) { + recordQueue.push(batch as unknown as SubscriberPreferenceEntity[]); + skip += RETRIEVAL_BATCH_SIZE; + } else { + hasMore = false; + } } - console.log('end subscriber preference migration'); + // Wait for all records to be processed + await recordQueue.drain(); + + console.log(`end processing subscriber preferences with level: ${level}`); +} + +async function processSubscriberRecord( + subscriberPreference: SubscriberPreferenceEntity, + upsertPreferences: UpsertPreferences +) { + try { + if (subscriberPreference.level === PreferenceLevelEnum.GLOBAL) { + const preferenceToUpsert = UpsertSubscriberGlobalPreferencesCommand.create({ + _subscriberId: subscriberPreference._subscriberId.toString(), + environmentId: subscriberPreference._environmentId.toString(), + organizationId: subscriberPreference._organizationId.toString(), + preferences: buildWorkflowPreferencesFromPreferenceChannels(false, subscriberPreference.channels), + }); + + await upsertPreferences.upsertSubscriberGlobalPreferences(preferenceToUpsert); + + counter.subscriberGlobal.success += 1; + } else if (subscriberPreference.level === PreferenceLevelEnum.TEMPLATE) { + if (!subscriberPreference._templateId) { + console.error( + `Invalid templateId ${subscriberPreference._templateId} for id ${subscriberPreference._id} for subscriber ${subscriberPreference._subscriberId}` + ); + counter.subscriberWorkflow.error += 1; + + return; + } + const preferenceToUpsert = UpsertSubscriberWorkflowPreferencesCommand.create({ + _subscriberId: subscriberPreference._subscriberId.toString(), + templateId: subscriberPreference._templateId.toString(), + environmentId: subscriberPreference._environmentId.toString(), + organizationId: subscriberPreference._organizationId.toString(), + preferences: buildWorkflowPreferencesFromPreferenceChannels(false, subscriberPreference.channels), + }); + + await upsertPreferences.upsertSubscriberWorkflowPreferences(preferenceToUpsert); + + counter.subscriberWorkflow.success += 1; + } else { + console.error( + `Invalid preference level ${subscriberPreference.level} for id ${subscriberPreference._subscriberId}` + ); + counter.subscriberUnknown.error += 1; + } + lastProcessedSubscriberId = subscriberPreference._id.toString(); + } catch (error) { + console.error(error); + console.error({ + failedSubscriberPreferenceId: subscriberPreference._id, + failedSubscriberId: subscriberPreference._subscriberId, + }); + if (subscriberPreference.level === PreferenceLevelEnum.GLOBAL) { + counter.subscriberGlobal.error += 1; + } else if (subscriberPreference.level === PreferenceLevelEnum.TEMPLATE) { + counter.subscriberWorkflow.error += 1; + } + } } // Call the function with optional starting IDs diff --git a/apps/api/package.json b/apps/api/package.json index 2b3d03a288c..2985cf2de30 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -46,10 +46,10 @@ "@novu/dal": "workspace:*", "@novu/framework": "workspace:*", "@novu/node": "workspace:*", + "@novu/notifications": "workspace:*", "@novu/shared": "workspace:*", "@novu/stateless": "workspace:*", "@novu/testing": "workspace:*", - "@novu/notifications": "workspace:*", "@sendgrid/mail": "^8.1.0", "@sentry/browser": "^8.33.1", "@sentry/hub": "^7.114.0", @@ -107,6 +107,7 @@ "@nestjs/schematics": "10.1.4", "@nestjs/testing": "10.4.1", "@stoplight/spectral-cli": "^6.11.0", + "@types/async": "^3.2.1", "@types/bcrypt": "^3.0.0", "@types/bull": "^3.15.8", "@types/chai": "^4.2.11", @@ -117,6 +118,7 @@ "@types/passport-jwt": "^3.0.3", "@types/sinon": "^9.0.0", "@types/supertest": "^2.0.8", + "async": "^3.2.0", "chai": "^4.2.0", "mocha": "^10.2.0", "sinon": "^9.2.4", diff --git a/apps/api/src/app/change/changes.controller.ts b/apps/api/src/app/change/changes.controller.ts index 33d8ee233e6..7b04f225f7c 100644 --- a/apps/api/src/app/change/changes.controller.ts +++ b/apps/api/src/app/change/changes.controller.ts @@ -1,6 +1,7 @@ import { Body, ClassSerializerInterceptor, Controller, Get, Param, Post, Query, UseInterceptors } from '@nestjs/common'; import { ApiRateLimitCostEnum, UserSessionData } from '@novu/shared'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { ApplyChange, ApplyChangeCommand } from './usecases'; import { GetChanges } from './usecases/get-changes/get-changes.usecase'; @@ -24,6 +25,7 @@ import { SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; @UseInterceptors(ClassSerializerInterceptor) @UserAuthentication() @ApiTags('Changes') +@ApiExcludeController() export class ChangesController { constructor( private applyChange: ApplyChange, diff --git a/apps/api/src/app/environments-v2/environments.controller.ts b/apps/api/src/app/environments-v2/environments.controller.ts index 8ea8f4673d8..468fd41cd84 100644 --- a/apps/api/src/app/environments-v2/environments.controller.ts +++ b/apps/api/src/app/environments-v2/environments.controller.ts @@ -1,6 +1,7 @@ import { ClassSerializerInterceptor, Controller, Get, Param, UseInterceptors } from '@nestjs/common'; import { UserSessionData } from '@novu/shared'; import { ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { GetEnvironmentTags, GetEnvironmentTagsCommand } from './usecases/get-environment-tags'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; @@ -13,6 +14,7 @@ import { GetEnvironmentTagsDto } from './dtos/get-environment-tags.dto'; @UseInterceptors(ClassSerializerInterceptor) @UserAuthentication() @ApiTags('Environments') +@ApiExcludeController() export class EnvironmentsController { constructor(private getEnvironmentTagsUsecase: GetEnvironmentTags) {} diff --git a/apps/api/src/app/feeds/feeds.controller.ts b/apps/api/src/app/feeds/feeds.controller.ts index 16a1938bcba..bdde9070382 100644 --- a/apps/api/src/app/feeds/feeds.controller.ts +++ b/apps/api/src/app/feeds/feeds.controller.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/common'; import { UserSessionData } from '@novu/shared'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateFeed } from './usecases/create-feed/create-feed.usecase'; import { CreateFeedCommand } from './usecases/create-feed/create-feed.command'; @@ -28,6 +29,7 @@ import { UserAuthentication } from '../shared/framework/swagger/api.key.security @UseInterceptors(ClassSerializerInterceptor) @UserAuthentication() @ApiTags('Feeds') +@ApiExcludeController() export class FeedsController { constructor( private createFeedUsecase: CreateFeed, diff --git a/apps/api/src/app/layouts/layouts.controller.ts b/apps/api/src/app/layouts/layouts.controller.ts index 4779e1c36b0..02665b08de1 100644 --- a/apps/api/src/app/layouts/layouts.controller.ts +++ b/apps/api/src/app/layouts/layouts.controller.ts @@ -15,6 +15,7 @@ import { import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { OrderByEnum, OrderDirectionEnum, UserSessionData } from '@novu/shared'; import { GetLayoutCommand, GetLayoutUseCase, OtelSpan } from '@novu/application-generic'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { ApiBadRequestResponse, ApiCommonResponses, @@ -56,6 +57,7 @@ import { SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; @Controller('/layouts') @ApiTags('Layouts') @UserAuthentication() +@ApiExcludeController() export class LayoutsController { constructor( private createLayoutUseCase: CreateLayoutUseCase, diff --git a/apps/api/src/app/organization/organization.controller.ts b/apps/api/src/app/organization/organization.controller.ts index 50404a6d41b..f77afb23070 100644 --- a/apps/api/src/app/organization/organization.controller.ts +++ b/apps/api/src/app/organization/organization.controller.ts @@ -13,6 +13,7 @@ import { import { OrganizationEntity } from '@novu/dal'; import { MemberRoleEnum, UserSessionData } from '@novu/shared'; import { ApiExcludeEndpoint, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateOrganizationDto } from './dtos/create-organization.dto'; import { CreateOrganizationCommand } from './usecases/create-organization/create-organization.command'; @@ -48,6 +49,7 @@ import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.dec @UserAuthentication() @ApiTags('Organizations') @ApiCommonResponses() +@ApiExcludeController() export class OrganizationController { constructor( private createOrganizationUsecase: CreateOrganization, diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts index 06121822068..88bcd77e1a1 100644 --- a/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts @@ -1,15 +1,23 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsObject, IsOptional, IsString } from 'class-validator'; +import { IsDefined, IsEnum, IsObject, IsOptional, IsString } from 'class-validator'; import { ChatProviderIdEnum, ISubscriberChannel, PushProviderIdEnum } from '@novu/shared'; import { ChannelCredentials } from '../../shared/dtos/subscriber-channel'; +export function getEnumValues(enumObj: T): Array { + return Object.values(enumObj || {}) as Array; +} export class UpdateSubscriberChannelRequestDto implements ISubscriberChannel { @ApiProperty({ - enum: [ChatProviderIdEnum, PushProviderIdEnum], + enum: [...getEnumValues(ChatProviderIdEnum), ...getEnumValues(PushProviderIdEnum)], description: 'The provider identifier for the credentials', }) - @IsString() + @IsEnum( + { ...ChatProviderIdEnum, ...PushProviderIdEnum }, + { + message: 'providerId must be a valid provider ID', + } + ) @IsDefined() providerId: ChatProviderIdEnum | PushProviderIdEnum; diff --git a/apps/api/src/app/tenant/tenant.controller.ts b/apps/api/src/app/tenant/tenant.controller.ts index a055d3e974b..e263e24da72 100644 --- a/apps/api/src/app/tenant/tenant.controller.ts +++ b/apps/api/src/app/tenant/tenant.controller.ts @@ -26,6 +26,7 @@ import { UpdateTenant, UpdateTenantCommand, } from '@novu/application-generic'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { @@ -62,6 +63,7 @@ const v2TenantsApiDescription = ' Tenants is not supported in code first version @ApiTags('Tenants') @UseInterceptors(ClassSerializerInterceptor) @UserAuthentication() +@ApiExcludeController() export class TenantController { constructor( private createTenantUsecase: CreateTenant, diff --git a/apps/api/src/app/workflows-v1/workflow-v1.controller.ts b/apps/api/src/app/workflows-v1/workflow-v1.controller.ts index 77df980baf5..7d33635642f 100644 --- a/apps/api/src/app/workflows-v1/workflow-v1.controller.ts +++ b/apps/api/src/app/workflows-v1/workflow-v1.controller.ts @@ -26,6 +26,7 @@ import { } from '@novu/shared'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { GetNotificationTemplates } from './usecases/get-notification-templates/get-notification-templates.usecase'; import { GetNotificationTemplatesCommand } from './usecases/get-notification-templates/get-notification-templates.command'; @@ -45,7 +46,7 @@ import { WorkflowResponse } from './dto/workflow-response.dto'; import { WorkflowsResponseDto } from './dto/workflows.response.dto'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { WorkflowsRequestDto } from './dto/workflows-request.dto'; -import { ApiCommonResponses, ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator'; +import { ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator'; import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto'; import { CreateWorkflowQuery } from './queries'; import { DeleteNotificationTemplateCommand } from './usecases/delete-notification-template/delete-notification-template.command'; @@ -57,7 +58,7 @@ import { SdkGroupName } from '../shared/framework/swagger/sdk.decorators'; /** * @deprecated use controllers in /workflows directory */ -@ApiCommonResponses() +@ApiExcludeController() @Controller('/workflows') @UseInterceptors(ClassSerializerInterceptor) @UserAuthentication() diff --git a/apps/dashboard/src/components/primitives/editor.tsx b/apps/dashboard/src/components/primitives/editor.tsx index d794910ea11..33ab4ffbcb5 100644 --- a/apps/dashboard/src/components/primitives/editor.tsx +++ b/apps/dashboard/src/components/primitives/editor.tsx @@ -16,94 +16,106 @@ const editorVariants = cva('h-full w-full flex-1 [&_.cm-focused]:outline-none', }, }); -const baseTheme = EditorView.baseTheme({ - '&light': { - backgroundColor: 'transparent', - }, - '.cm-tooltip-autocomplete .cm-completionIcon-variable': { - '&:before': { - content: 'Suggestions', +type baseThemeOptions = { + asInput?: boolean; +}; +const baseTheme = (options: baseThemeOptions) => + EditorView.baseTheme({ + '&light': { + backgroundColor: 'transparent', }, - '&:after': { - content: "''", - height: '16px', + ...(options.asInput + ? { + '.cm-scroller': { + overflow: 'hidden', + }, + } + : {}), + '.cm-tooltip-autocomplete .cm-completionIcon-variable': { + '&:before': { + content: 'Suggestions', + }, + '&:after': { + content: "''", + height: '16px', + width: '16px', + display: 'block', + backgroundRepeat: 'no-repeat', + backgroundImage: `url('${functionIcon}')`, + }, + }, + '.cm-tooltip-autocomplete.cm-tooltip': { + position: 'relative', + overflow: 'hidden', + borderRadius: 'var(--radius)', + border: '1px solid var(--neutral-100)', + backgroundColor: 'hsl(var(--background))', + boxShadow: '0px 1px 3px 0px rgba(16, 24, 40, 0.10), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)', + '&:before': { + content: "''", + top: '0', + left: '0', + right: '0', + height: '30px', + display: 'block', + backgroundRepeat: 'no-repeat', + backgroundImage: `url('${autocompleteHeader}')`, + }, + '&:after': { + content: "''", + bottom: '30px', + left: '0', + right: '0', + height: '30px', + display: 'block', + backgroundRepeat: 'no-repeat', + backgroundImage: `url('${autocompleteFooter}')`, + }, + }, + '.cm-tooltip-autocomplete.cm-tooltip > ul[role="listbox"]': { + display: 'flex', + flexDirection: 'column', + gap: '2px', + maxHeight: '12rem', + margin: '4px 0', + padding: '4px', + }, + '.cm-tooltip-autocomplete.cm-tooltip > ul > li[role="option"]': { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '4px', + fontSize: '12px', + fontWeight: '500', + lineHeight: '16px', + minHeight: '24px', + color: 'var(--foreground-950)', + borderRadius: 'calc(var(--radius) - 2px)', + }, + '.cm-tooltip-autocomplete.cm-tooltip > ul > li[aria-selected="true"]': { + backgroundColor: 'hsl(var(--neutral-100))', + }, + '.cm-tooltip-autocomplete.cm-tooltip .cm-completionIcon': { + padding: '0', width: '16px', - display: 'block', - backgroundRepeat: 'no-repeat', - backgroundImage: `url('${functionIcon}')`, + height: '16px', }, - }, - '.cm-tooltip-autocomplete.cm-tooltip': { - position: 'relative', - overflow: 'hidden', - borderRadius: 'var(--radius)', - border: '1px solid var(--neutral-100)', - backgroundColor: 'hsl(var(--background))', - boxShadow: '0px 1px 3px 0px rgba(16, 24, 40, 0.10), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)', - '&:before': { - content: "''", - top: '0', - left: '0', - right: '0', - height: '30px', - display: 'block', - backgroundRepeat: 'no-repeat', - backgroundImage: `url('${autocompleteHeader}')`, + '.cm-line span.cm-matchingBracket': { + backgroundColor: 'hsl(var(--highlighted) / 0.1)', }, - '&:after': { - content: "''", - bottom: '30px', - left: '0', - right: '0', - height: '30px', - display: 'block', - backgroundRepeat: 'no-repeat', - backgroundImage: `url('${autocompleteFooter}')`, + 'div.cm-content': { + padding: 0, }, - }, - '.cm-tooltip-autocomplete.cm-tooltip > ul[role="listbox"]': { - display: 'flex', - flexDirection: 'column', - gap: '2px', - maxHeight: '12rem', - margin: '4px 0', - padding: '4px', - }, - '.cm-tooltip-autocomplete.cm-tooltip > ul > li[role="option"]': { - display: 'flex', - alignItems: 'center', - gap: '8px', - padding: '4px', - fontSize: '12px', - fontWeight: '500', - lineHeight: '16px', - minHeight: '24px', - color: 'var(--foreground-950)', - borderRadius: 'calc(var(--radius) - 2px)', - }, - '.cm-tooltip-autocomplete.cm-tooltip > ul > li[aria-selected="true"]': { - backgroundColor: 'hsl(var(--neutral-100))', - }, - '.cm-tooltip-autocomplete.cm-tooltip .cm-completionIcon': { - padding: '0', - width: '16px', - height: '16px', - }, - '.cm-line span.cm-matchingBracket': { - backgroundColor: 'hsl(var(--highlighted) / 0.1)', - }, - 'div.cm-content': { - padding: 0, - }, - 'div.cm-gutters': { - backgroundColor: 'transparent', - borderRight: 'none', - color: 'hsl(var(--foreground-400))', - }, -}); + 'div.cm-gutters': { + backgroundColor: 'transparent', + borderRight: 'none', + color: 'hsl(var(--foreground-400))', + }, + }); type EditorProps = { value: string; + asInput?: boolean; placeholder?: string; className?: string; height?: string; @@ -120,6 +132,7 @@ export const Editor = React.forwardRef<{ focus: () => void; blur: () => void }, className, height, size, + asInput, fontFamily, onChange, extensions, @@ -151,7 +164,7 @@ export const Editor = React.forwardRef<{ focus: () => void; blur: () => void }, }, [fontFamily]); const { setContainer, view } = useCodeMirror({ - extensions: [...(extensions ?? []), baseTheme], + extensions: [...(extensions ?? []), baseTheme({ asInput })], height, placeholder, basicSetup: { diff --git a/apps/dashboard/src/components/primitives/popover.tsx b/apps/dashboard/src/components/primitives/popover.tsx index 5544d06ddef..ae64cc19ffb 100644 --- a/apps/dashboard/src/components/primitives/popover.tsx +++ b/apps/dashboard/src/components/primitives/popover.tsx @@ -23,7 +23,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - `bg-background text-foreground-950 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-72 overflow-auto rounded-md border p-4 shadow-md outline-none ${arrowClipPathClassName}`, + `bg-background text-foreground-950 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-72 rounded-md border p-4 shadow-md outline-none ${arrowClipPathClassName}`, className )} {...props} diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 4a8549ce63e..37ce6903e1c 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -19,7 +19,7 @@ import { buildRoute, LEGACY_ROUTES, ROUTES } from '@/utils/routes'; import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; import { TelemetryEvent } from '@/utils/telemetry'; import { useTelemetry } from '@/hooks/use-telemetry'; -import { SidebarContent } from '@/components/side-navigation/Sidebar'; +import { SidebarContent } from '@/components/side-navigation/sidebar'; const linkVariants = cva( `flex items-center gap-2 text-sm py-1.5 px-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer`, diff --git a/apps/dashboard/src/components/side-navigation/Sidebar.tsx b/apps/dashboard/src/components/side-navigation/sidebar.tsx similarity index 100% rename from apps/dashboard/src/components/side-navigation/Sidebar.tsx rename to apps/dashboard/src/components/side-navigation/sidebar.tsx diff --git a/apps/dashboard/src/components/workflow-editor/configure-workflow.tsx b/apps/dashboard/src/components/workflow-editor/configure-workflow.tsx index f809573120a..81c824f1051 100644 --- a/apps/dashboard/src/components/workflow-editor/configure-workflow.tsx +++ b/apps/dashboard/src/components/workflow-editor/configure-workflow.tsx @@ -17,7 +17,7 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '../pri import { Switch } from '../primitives/switch'; import { useWorkflowEditorContext } from '@/components/workflow-editor/hooks'; import { cn } from '@/utils/ui'; -import { SidebarContent, SidebarHeader } from '@/components/side-navigation/Sidebar'; +import { SidebarContent, SidebarHeader } from '@/components/side-navigation/sidebar'; import { PageMeta } from '../page-meta'; import { ConfirmationModal } from '../confirmation-modal'; import { PauseModalDescription, PAUSE_MODAL_TITLE } from '@/components/pause-workflow-dialog'; diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-content.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-content.tsx index 27db37c1036..3bd87912f10 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-content.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-content.tsx @@ -1,17 +1,17 @@ +import { StepTypeEnum } from '@novu/shared'; import { useMemo } from 'react'; +import { RiArrowRightSLine, RiPencilRuler2Fill } from 'react-icons/ri'; import { Link } from 'react-router-dom'; -import { RiArrowRightSLine, RiArrowRightUpLine, RiPencilRuler2Fill } from 'react-icons/ri'; -import { StepTypeEnum } from '@novu/shared'; -import { Button } from '../../primitives/button'; -import { Separator } from '../../primitives/separator'; -import { CommonFields } from './common-fields'; -import { SidebarContent } from '@/components/side-navigation/Sidebar'; -import { ConfigureInAppPreview } from './configure-in-app-preview'; -import { SdkBanner } from './sdk-banner'; -import { useStep } from './use-step'; -import { getFirstControlsErrorMessage, getFirstBodyErrorMessage } from '../step-utils'; +import { Button } from '@/components/primitives/button'; +import { Separator } from '@/components/primitives/separator'; +import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { getFirstBodyErrorMessage, getFirstControlsErrorMessage } from '@/components/workflow-editor/step-utils'; +import { CommonFields } from '@/components/workflow-editor/steps/common-fields'; +import { SdkBanner } from '@/components/workflow-editor/steps/sdk-banner'; +import { useStep } from '@/components/workflow-editor/steps/use-step'; import { EXCLUDED_EDITOR_TYPES } from '@/utils/constants'; +import { ConfigureInAppStepTemplate } from '@/components/workflow-editor/steps/in-app/configure-in-app-step-template'; export const ConfigureStepContent = () => { const { step } = useStep(); @@ -22,54 +22,7 @@ export const ConfigureStepContent = () => { ); if (step?.type === StepTypeEnum.IN_APP) { - return ( - <> - - - - - - - - - - {!firstError && } - - {firstError && ( - <> - - -
- Action required - - Help? - -
- - - -
- - )} - - ); + return ; } return ( diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx index deed3adaeb2..5663084f7c7 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx @@ -4,7 +4,7 @@ import { RiArrowLeftSLine, RiCloseFill, RiDeleteBin2Line } from 'react-icons/ri' import { motion } from 'framer-motion'; import { Button } from '@/components/primitives/button'; import { Separator } from '@/components/primitives/separator'; -import { SidebarFooter, SidebarHeader } from '@/components/side-navigation/Sidebar'; +import { SidebarFooter, SidebarHeader } from '@/components/side-navigation/sidebar'; import { useWorkflowEditorContext } from '@/components/workflow-editor/hooks'; import { useEnvironment } from '@/context/environment/hooks'; import { buildRoute, ROUTES } from '@/utils/routes'; diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-in-app-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/configure-in-app-step-preview.tsx similarity index 97% rename from apps/dashboard/src/components/workflow-editor/steps/configure-in-app-preview.tsx rename to apps/dashboard/src/components/workflow-editor/steps/in-app/configure-in-app-step-preview.tsx index 95e559f0269..edbbdfcdb03 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-in-app-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/configure-in-app-step-preview.tsx @@ -13,7 +13,7 @@ import { import { useStepEditorContext } from '@/components/workflow-editor/steps/hooks'; import { InAppRenderOutput } from '@novu/shared'; -export function ConfigureInAppPreview() { +export function ConfigureInAppStepPreview() { const { previewStep, data, isPending: isPreviewPending } = usePreviewStep(); const { step, isPendingStep } = useStepEditorContext(); diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/configure-in-app-step-template.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/configure-in-app-step-template.tsx new file mode 100644 index 00000000000..31bea7a37cd --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/configure-in-app-step-template.tsx @@ -0,0 +1,61 @@ +import { Button } from '@/components/primitives/button'; +import { Separator } from '@/components/primitives/separator'; +import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { CommonFields } from '@/components/workflow-editor/steps/common-fields'; +import { ConfigureInAppStepPreview } from '@/components/workflow-editor/steps/in-app/configure-in-app-step-preview'; +import { Step } from '@/utils/types'; +import { RiPencilRuler2Fill, RiArrowRightSLine, RiArrowRightUpLine } from 'react-icons/ri'; +import { Link } from 'react-router-dom'; + +type ConfigureInAppStepTemplateProps = { + step: Step; + issue?: string; +}; +export const ConfigureInAppStepTemplate = (props: ConfigureInAppStepTemplateProps) => { + const { step, issue } = props; + + return ( + <> + + + + + + + + + + {!issue && } + + {issue && ( + <> + + +
+ Action required + + Help? + +
+ + + +
+ + )} + + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/url-input.tsx b/apps/dashboard/src/components/workflow-editor/url-input.tsx index de09192f4df..895eddd7fdb 100644 --- a/apps/dashboard/src/components/workflow-editor/url-input.tsx +++ b/apps/dashboard/src/components/workflow-editor/url-input.tsx @@ -41,10 +41,11 @@ export const URLInput = ({ control={control} name={urlKey} render={({ field }) => ( - + {asEditor ? ( (context: CompletionContext): CompletionResult | null => { @@ -20,24 +108,32 @@ export const completions = // Get the content inside the braces up to the cursor position const insideBraces = state.sliceDoc(lastOpenBrace + 2, pos); - // Match a word or variable part near the cursor - const word = context.matchBefore(/[\w.]+/); + // Detect the position of the last `|` relative to the cursor + const pipeIndex = insideBraces.lastIndexOf('|'); - // Check if there's already content before the current word - let contentBeforeWord = ''; - if (word) { - contentBeforeWord = insideBraces.slice(0, word.from - (lastOpenBrace + 2)); - } else { - contentBeforeWord = insideBraces; - } + if (pipeIndex !== -1 && pos > lastOpenBrace + 2 + pipeIndex) { + // Cursor is after the pipe (`|`) + const afterPipe = insideBraces.slice(pipeIndex + 1).trimStart(); - if (contentBeforeWord.trim().length > 0) { - // There is already content inside the braces before the current word - return null; + // Filter the list of filters based on the user's input + const matchingFilters = filters.filter((f) => f.label.toLowerCase().startsWith(afterPipe.toLowerCase())); + + // Suggest filters if content after the pipe is incomplete + if (/^[\w.]*$/.test(afterPipe)) { + return { + from: pos - afterPipe.length, // Start from where the filter name starts + to: pos, // Extend to the current cursor position + options: matchingFilters.map((f) => ({ + label: f.label, + type: 'function', + })), + }; + } } - // If no word is matched and the block is empty, return all variables - if (!word && pos === lastOpenBrace + 2) { + // If no pipe (|) is present, suggest variables + const word = context.matchBefore(/[\w.]+/); // Match variable names only + if (!word && insideBraces.trim() === '') { return { from: pos, options: variables.map((v) => ({ @@ -47,10 +143,11 @@ export const completions = }; } - // Show suggestions while typing a valid word + // Suggest variables if typing a valid variable name if (word) { return { from: word.from, + to: word.to ?? pos, options: variables.map((v) => ({ label: v.label, type: 'variable', @@ -58,5 +155,5 @@ export const completions = }; } - return null; // Fallback to null if no conditions match + return null; // No suggestions in other cases }; diff --git a/packages/js/src/ui/components/Inbox.tsx b/packages/js/src/ui/components/Inbox.tsx index a6f7680b390..69dc67f89fc 100644 --- a/packages/js/src/ui/components/Inbox.tsx +++ b/packages/js/src/ui/components/Inbox.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, Match, Show, Switch } from 'solid-js'; +import { type OffsetOptions, type Placement } from '@floating-ui/dom'; import { useInboxContext } from '../context'; import { useStyle } from '../helpers'; import type { @@ -19,6 +20,8 @@ export type InboxProps = { onNotificationClick?: NotificationClickHandler; onPrimaryActionClick?: NotificationActionClickHandler; onSecondaryActionClick?: NotificationActionClickHandler; + placement?: Placement; + placementOffset?: OffsetOptions; }; export enum InboxPage { @@ -93,7 +96,7 @@ export const Inbox = (props: InboxProps) => { const isOpen = () => props?.open ?? isOpened(); return ( - + (