diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index 1e1b3383c3e..fafa17173bc 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -173,6 +173,7 @@ export class InboxController { organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, + contextKeys: subscriberSession.contextKeys, tags: query.tags, severity: query.severity, criticality: query.criticality, @@ -188,6 +189,7 @@ export class InboxController { organizationId: subscriberSession._organizationId, environmentId: subscriberSession._environmentId, subscriberId: subscriberSession.subscriberId, + contextKeys: subscriberSession.contextKeys, includeInactiveChannels: false, subscriber: subscriberSession, }) @@ -376,6 +378,7 @@ export class InboxController { organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, + contextKeys: subscriberSession.contextKeys, level: PreferenceLevelEnum.GLOBAL, chat: body.chat, email: body.email, @@ -403,6 +406,7 @@ export class InboxController { organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, + contextKeys: subscriberSession.contextKeys, preferences: body.preferences, }) ); @@ -420,6 +424,7 @@ export class InboxController { organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, + contextKeys: subscriberSession.contextKeys, level: PreferenceLevelEnum.TEMPLATE, all: { ...(body.enabled !== undefined && { enabled: body.enabled }), @@ -450,6 +455,7 @@ export class InboxController { organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, + contextKeys: subscriberSession.contextKeys, level: PreferenceLevelEnum.TEMPLATE, subscriptionIdentifier, all: { diff --git a/apps/api/src/app/inbox/usecases/bulk-update-preferences/bulk-update-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/bulk-update-preferences/bulk-update-preferences.usecase.ts index ee3b4bc7991..5dc6309a477 100644 --- a/apps/api/src/app/inbox/usecases/bulk-update-preferences/bulk-update-preferences.usecase.ts +++ b/apps/api/src/app/inbox/usecases/bulk-update-preferences/bulk-update-preferences.usecase.ts @@ -102,6 +102,7 @@ export class BulkUpdatePreferences { organizationId: command.organizationId, subscriberId: command.subscriberId, environmentId: command.environmentId, + contextKeys: command.contextKeys, level: PreferenceLevelEnum.TEMPLATE, subscriptionIdentifier: preference.subscriptionIdentifier, ...(isUpdatingSubscriptionPreference && { diff --git a/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts index de444a97ea5..6d679c07182 100644 --- a/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts +++ b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts @@ -107,6 +107,7 @@ describe('GetInboxPreferences', () => { environmentId: 'env-1', organizationId: 'org-1', subscriberId: 'test-mockSubscriber', + contextKeys: [], criticality: WorkflowCriticalityEnum.NON_CRITICAL, }); @@ -133,6 +134,7 @@ describe('GetInboxPreferences', () => { organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, + contextKeys: [], includeInactiveChannels: false, subscriber: { _id: 'test-mockSubscriber', @@ -151,6 +153,7 @@ describe('GetInboxPreferences', () => { environmentId: command.environmentId, subscriberId: command.subscriberId, organizationId: command.organizationId, + contextKeys: [], tags: undefined, severity: undefined, includeInactiveChannels: false, @@ -220,6 +223,7 @@ describe('GetInboxPreferences', () => { environmentId: 'env-1', organizationId: 'org-1', subscriberId: 'test-mockSubscriber', + contextKeys: [], tags: ['newsletter', 'security'], severity: [SeverityLevelEnum.HIGH], criticality: WorkflowCriticalityEnum.NON_CRITICAL, @@ -248,6 +252,7 @@ describe('GetInboxPreferences', () => { organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, + contextKeys: [], includeInactiveChannels: false, subscriber: { _id: 'test-mockSubscriber', @@ -266,6 +271,7 @@ describe('GetInboxPreferences', () => { environmentId: command.environmentId, subscriberId: command.subscriberId, organizationId: command.organizationId, + contextKeys: [], tags: command.tags, severity: command.severity, includeInactiveChannels: false, diff --git a/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts index 04e09b6b371..c8312787b05 100644 --- a/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts +++ b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts @@ -35,6 +35,7 @@ export class GetInboxPreferences { organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, + contextKeys: command.contextKeys, includeInactiveChannels: false, subscriber, }) @@ -56,6 +57,7 @@ export class GetInboxPreferences { environmentId: command.environmentId, subscriberId: command.subscriberId, organizationId: command.organizationId, + contextKeys: command.contextKeys, tags: command.tags, severity, subscriber, diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index 4be18f889b7..d105b22c268 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -237,6 +237,7 @@ export class Session { environment, defaultSchedule: command.requestData.defaultSchedule, subscriber: subscriberEntity, + contextKeys, }); const [{ removeNovuBranding }, maxSnoozeDurationHours, schedule] = await Promise.all([ @@ -292,10 +293,12 @@ export class Session { environment, defaultSchedule, subscriber, + contextKeys, }: { environment: EnvironmentEntity; defaultSchedule?: ScheduleDto; subscriber: SubscriberEntity; + contextKeys: string[]; }): Promise { const isSubscribersScheduleEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED, @@ -326,6 +329,7 @@ export class Session { environmentId: environment._id, subscriber, subscriberId: subscriber.subscriberId, + contextKeys, level: PreferenceLevelEnum.GLOBAL, includeInactiveChannels: false, schedule: defaultSchedule, diff --git a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts index bc70d1c963d..832b7e2945e 100644 --- a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts +++ b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts @@ -1,4 +1,5 @@ import { + FeatureFlagsService, GetSubscriberTemplatePreference, GetWorkflowByIdsUseCase, SendWebhookMessage, @@ -54,6 +55,7 @@ describe('UpdatePreferences', () => { let sendWebhookMessageMock: sinon.SinonStubbedInstance; let topicSubscribersRepositoryMock: sinon.SinonStubbedInstance; let preferencesRepositoryMock: sinon.SinonStubbedInstance; + let featureFlagsServiceMock: sinon.SinonStubbedInstance; beforeEach(() => { subscriberRepositoryMock = sinon.createStubInstance(SubscriberRepository); getSubscriberGlobalPreferenceMock = sinon.createStubInstance(GetSubscriberGlobalPreference); @@ -63,6 +65,7 @@ describe('UpdatePreferences', () => { sendWebhookMessageMock = sinon.createStubInstance(SendWebhookMessage); topicSubscribersRepositoryMock = sinon.createStubInstance(TopicSubscribersRepository); preferencesRepositoryMock = sinon.createStubInstance(PreferencesRepository); + featureFlagsServiceMock = sinon.createStubInstance(FeatureFlagsService); updatePreferences = new UpdatePreferences( subscriberRepositoryMock as any, @@ -72,7 +75,8 @@ describe('UpdatePreferences', () => { getWorkflowByIdsUsecase as any, sendWebhookMessageMock as any, topicSubscribersRepositoryMock as any, - preferencesRepositoryMock as any + preferencesRepositoryMock as any, + featureFlagsServiceMock as any ); }); @@ -105,6 +109,7 @@ describe('UpdatePreferences', () => { environmentId: 'env-1', organizationId: 'org-1', subscriberId: 'test-mockSubscriber', + contextKeys: [], level: PreferenceLevelEnum.GLOBAL, chat: true, includeInactiveChannels: false, @@ -121,6 +126,7 @@ describe('UpdatePreferences', () => { environmentId: command.environmentId, organizationId: command.organizationId, subscriberId: mockedSubscriber.subscriberId, + contextKeys: [], includeInactiveChannels: false, }), ]); diff --git a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts index ccce0731a64..df03933219b 100644 --- a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts +++ b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { + FeatureFlagsService, GetPreferences, GetSubscriberTemplatePreference, GetSubscriberTemplatePreferenceCommand, @@ -13,7 +14,9 @@ import { UpsertSubscriberWorkflowPreferencesCommand, } from '@novu/application-generic'; import { + EnforceEnvOrOrgIds, NotificationTemplateEntity, + PreferencesDBModel, PreferencesRepository, SubscriberEntity, SubscriberRepository, @@ -21,6 +24,7 @@ import { } from '@novu/dal'; import { buildWorkflowPreferences, + FeatureFlagsKeysEnum, IPreferenceChannels, PreferenceLevelEnum, PreferencesTypeEnum, @@ -31,6 +35,7 @@ import { WorkflowPreferences, WorkflowPreferencesPartial, } from '@novu/shared'; +import { FilterQuery } from 'mongoose'; import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand, @@ -48,7 +53,8 @@ export class UpdatePreferences { private getWorkflowByIdsUsecase: GetWorkflowByIdsUseCase, private sendWebhookMessage: SendWebhookMessage, private topicSubscribersRepository: TopicSubscribersRepository, - private preferencesRepository: PreferencesRepository + private preferencesRepository: PreferencesRepository, + private featureFlagsService: FeatureFlagsService ) {} @InstrumentUsecase() @@ -131,6 +137,7 @@ export class UpdatePreferences { organizationId: command.organizationId, environmentId: command.environmentId, _subscriberId: subscriber._id, + contextKeys: command.contextKeys, workflowId, subscriptionId: internalSubscriptionId, schedule: command.schedule, @@ -161,13 +168,18 @@ export class UpdatePreferences { command.workflowIdOrIdentifier && workflow ) { - const preferenceEntity = await this.preferencesRepository.findOne({ + const contextQuery = await this.buildContextExactMatchQuery(command.contextKeys, command.organizationId); + + const query: FilterQuery & EnforceEnvOrOrgIds = { _environmentId: command.environmentId, _subscriberId: subscriber._id, _templateId: workflow._id, _topicSubscriptionId: internalSubscriptionId, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, - }); + ...contextQuery, + }; + + const preferenceEntity = await this.preferencesRepository.findOne(query); const builtPreferences = buildWorkflowPreferences(preferenceEntity?.preferences); const channels = GetPreferences.mapWorkflowPreferencesToChannelPreferences(preferenceEntity?.preferences || {}); @@ -200,7 +212,8 @@ export class UpdatePreferences { subscriber, includeInactiveChannels: command.includeInactiveChannels, subscriptionId: internalSubscriptionId, - }) + contextKeys: command.contextKeys, + } as GetSubscriberTemplatePreferenceCommand) ); return { @@ -225,6 +238,7 @@ export class UpdatePreferences { environmentId: command.environmentId, subscriberId: command.subscriberId, includeInactiveChannels: command.includeInactiveChannels, + contextKeys: command.contextKeys, }) ); @@ -240,6 +254,7 @@ export class UpdatePreferences { organizationId: string; _subscriberId: string; environmentId: string; + contextKeys?: string[]; workflowId?: string; subscriptionId?: string; schedule?: Schedule; @@ -270,6 +285,7 @@ export class UpdatePreferences { templateId: item.workflowId, topicSubscriptionId: item.subscriptionId, preferences, + contextKeys: item.contextKeys, returnPreference: false, }) ); @@ -285,6 +301,7 @@ export class UpdatePreferences { _subscriberId: item._subscriberId, templateId: item.workflowId, preferences, + contextKeys: item.contextKeys, returnPreference: false, }) ); @@ -300,7 +317,33 @@ export class UpdatePreferences { _subscriberId: item._subscriberId, returnPreference: false, schedule: item.schedule, + contextKeys: item.contextKeys, }) ); } + + private async buildContextExactMatchQuery( + contextKeys: string[] | undefined, + organizationId: string + ): Promise> { + const useContextFiltering = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, + defaultValue: false, + organization: { _id: organizationId }, + }); + + if (!useContextFiltering) { + return {}; + } + + if (contextKeys === undefined || contextKeys.length === 0) { + return { + $or: [{ contextKeys: { $exists: false } }, { contextKeys: [] }], + }; + } + + return { + contextKeys: { $all: contextKeys, $size: contextKeys.length }, + }; + } } diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts index 93f42e56580..269ebe6e4e3 100644 --- a/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts @@ -1,6 +1,6 @@ -import { EnvironmentWithSubscriber } from '@novu/application-generic'; import { SubscriberEntity } from '@novu/dal'; import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; +import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class GetSubscriberGlobalPreferenceCommand extends EnvironmentWithSubscriber { @IsBoolean() diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts index 86ea24f6338..50f4d8c8710 100644 --- a/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { + FeatureFlagsService, filteredPreference, GetPreferences, GetPreferencesResponseDto, @@ -20,6 +21,7 @@ import { } from '@novu/dal'; import { ChannelTypeEnum, + FeatureFlagsKeysEnum, IPreferenceChannels, ISubscriberPreferenceResponse, PreferencesTypeEnum, @@ -33,7 +35,8 @@ export class GetSubscriberPreference { constructor( private subscriberRepository: SubscriberRepository, private notificationTemplateRepository: NotificationTemplateRepository, - private preferencesRepository: PreferencesRepository + private preferencesRepository: PreferencesRepository, + private featureFlagsService: FeatureFlagsService ) {} @InstrumentUsecase() @@ -62,6 +65,7 @@ export class GetSubscriberPreference { } = await this.findAllPreferences({ environmentId: command.environmentId, organizationId: command.organizationId, + contextKeys: command.contextKeys, subscriberId: subscriber._id, workflowIds, }); @@ -246,11 +250,13 @@ export class GetSubscriberPreference { organizationId, subscriberId, workflowIds, + contextKeys, }: { environmentId: string; organizationId: string; subscriberId: string; workflowIds: string[]; + contextKeys?: string[]; }) { const baseQuery = { _environmentId: environmentId, @@ -258,6 +264,7 @@ export class GetSubscriberPreference { }; const readOptions = { readPreference: 'secondaryPreferred' as const }; + const contextQuery = await this.buildContextExactMatchQuery(contextKeys, organizationId); const [ workflowResourcePreferences, @@ -289,6 +296,7 @@ export class GetSubscriberPreference { _subscriberId: subscriberId, _templateId: { $in: workflowIds }, type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ...contextQuery, }, undefined, readOptions @@ -311,4 +319,31 @@ export class GetSubscriberPreference { subscriberGlobalPreference: subscriberGlobalPreferences[0] ?? null, }; } + + private async buildContextExactMatchQuery( + contextKeys: string[] | undefined, + organizationId: string + ): Promise> { + const useContextFiltering = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, + defaultValue: false, + organization: { _id: organizationId }, + }); + + if (!useContextFiltering) { + return {}; // FF OFF: no context filtering (pre-feature behavior) + } + + // undefined or empty array = match only "no context" preferences + if (contextKeys === undefined || contextKeys.length === 0) { + return { + $or: [{ contextKeys: { $exists: false } }, { contextKeys: [] }], + }; + } + + // non-empty array = exact match + return { + contextKeys: { $all: contextKeys, $size: contextKeys.length }, + }; + } } diff --git a/apps/worker/src/app/workflow/usecases/execute-bridge-job/execute-bridge-job.usecase.ts b/apps/worker/src/app/workflow/usecases/execute-bridge-job/execute-bridge-job.usecase.ts index 0f79a060d34..41d0adb1f0a 100644 --- a/apps/worker/src/app/workflow/usecases/execute-bridge-job/execute-bridge-job.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/execute-bridge-job/execute-bridge-job.usecase.ts @@ -6,12 +6,14 @@ import { dashboardSanitizeControlValues, ExecuteBridgeRequest, ExecuteBridgeRequestCommand, + FeatureFlagsService, Instrument, InstrumentUsecase, PinoLogger, } from '@novu/application-generic'; import { ControlValuesRepository, + EnvironmentEntity, EnvironmentRepository, JobEntity, JobRepository, @@ -33,13 +35,24 @@ import { ControlValuesLevelEnum, ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, + FeatureFlagsKeysEnum, ITriggerPayload, JobStatusEnum, ResourceOriginEnum, ResourceTypeEnum, } from '@novu/shared'; +import { LRUCache } from 'lru-cache'; import { ExecuteBridgeJobCommand } from './execute-bridge-job.command'; +type EnvironmentCacheData = Pick; + +const environmentCache = new LRUCache({ + max: 500, + ttl: 1000 * 60, +}); + +const environmentInflightRequests = new Map>(); + @Injectable() export class ExecuteBridgeJob { constructor( @@ -50,7 +63,8 @@ export class ExecuteBridgeJob { private controlValuesRepository: ControlValuesRepository, private createExecutionDetails: CreateExecutionDetails, private executeBridgeRequest: ExecuteBridgeRequest, - private logger: PinoLogger + private logger: PinoLogger, + private featureFlagsService: FeatureFlagsService ) { this.logger.setContext(this.constructor.name); } @@ -90,13 +104,7 @@ export class ExecuteBridgeJob { throw new Error('Step id is not set'); } - const environment = await this.environmentRepository.findOne( - { - _id: command.environmentId, - _organizationId: command.organizationId, - }, - 'echo apiKeys _id' - ); + const environment = await this.getEnvironment(command.environmentId, command.organizationId); if (!environment) { throw new Error(`Environment id ${command.environmentId} is not found`); @@ -334,4 +342,59 @@ export class ExecuteBridgeJob { }, }; } + + @Instrument() + private async getEnvironment( + environmentId: string, + organizationId: string + ): Promise { + const cacheKey = `${organizationId}:${environmentId}`; + + const isFeatureFlagEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, + defaultValue: false, + environment: { _id: environmentId }, + organization: { _id: organizationId }, + component: 'worker-environment', + }); + + if (isFeatureFlagEnabled) { + const cached = environmentCache.get(cacheKey); + if (cached) { + return cached; + } + + const inflightRequest = environmentInflightRequests.get(cacheKey); + if (inflightRequest) { + return inflightRequest; + } + } + + const fetchPromise = this.environmentRepository + .findOne( + { + _id: environmentId, + _organizationId: organizationId, + }, + 'echo apiKeys _id' + ) + .then((environment) => { + if (environment && isFeatureFlagEnabled) { + environmentCache.set(cacheKey, environment); + } + + return environment; + }) + .finally(() => { + if (isFeatureFlagEnabled) { + environmentInflightRequests.delete(cacheKey); + } + }); + + if (isFeatureFlagEnabled) { + environmentInflightRequests.set(cacheKey, fetchPromise); + } + + return fetchPromise; + } } 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 9437d740f37..d2455994535 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 @@ -374,6 +374,7 @@ export class SendMessage { subscriber, tenant: job.tenant, includeInactiveChannels: false, + contextKeys: job.contextKeys, }) ); subscriberPreference = preference; diff --git a/apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts b/apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts index 6be0fe80a89..2a899daae50 100644 --- a/apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/subscriber-job-bound/subscriber-job-bound.usecase.ts @@ -169,6 +169,7 @@ export class SubscriberJobBound { organizationId, subscriberId: subscriberProcessed._id, templateId, + contextKeys, }) ); critical = preferences.preferences.all.readOnly; diff --git a/libs/application-generic/package.json b/libs/application-generic/package.json index 664b2b09f88..0ed09d92ef4 100644 --- a/libs/application-generic/package.json +++ b/libs/application-generic/package.json @@ -87,6 +87,7 @@ "json-logic-js": "^2.0.5", "jsonwebtoken": "9.0.3", "lodash": "^4.17.15", + "lru-cache": "^11.2.4", "mixpanel": "^0.17.0", "nanoid": "^3.1.20", "nestjs-otel": "6.1.1", diff --git a/libs/application-generic/src/commands/project.command.ts b/libs/application-generic/src/commands/project.command.ts index 99326f0e8ae..b72612cf42e 100644 --- a/libs/application-generic/src/commands/project.command.ts +++ b/libs/application-generic/src/commands/project.command.ts @@ -1,5 +1,5 @@ import { DirectionEnum, KeysOfT, UserSessionData } from '@novu/shared'; -import { IsDefined, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsArray, IsDefined, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; import { BaseCommand } from './base.command'; @@ -80,6 +80,11 @@ export abstract class EnvironmentWithSubscriber extends BaseCommand { @IsNotEmpty() readonly subscriberId: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly contextKeys?: string[]; } export abstract class EnvironmentCommand extends BaseCommand { diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.command.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.command.ts index 2a4560cc896..35ec797ed61 100644 --- a/libs/application-generic/src/usecases/get-preferences/get-preferences.command.ts +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.command.ts @@ -11,4 +11,5 @@ export class GetPreferencesCommand extends EnvironmentCommand { * ensuring only workflow-level preferences are considered to avoid unintended side effects. */ excludeSubscriberPreferences?: boolean = false; + contextKeys?: string[]; } diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts index 20eedf0e9c7..678121e6175 100644 --- a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts @@ -110,6 +110,7 @@ export class GetPreferences { subscriberId: command.subscriberId, templateId: command.templateId, excludeSubscriberPreferences: command.excludeSubscriberPreferences, + contextKeys: command.contextKeys, }) ); } catch (e) { @@ -166,6 +167,8 @@ export class GetPreferences { ]; if (command.subscriberId) { + const contextQuery = await this.buildContextExactMatchQuery(command.contextKeys, command.organizationId); + queries.push( this.preferencesRepository.findOne( { @@ -173,6 +176,7 @@ export class GetPreferences { _subscriberId: command.subscriberId, _templateId: command.templateId, type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ...contextQuery, }, undefined, queryOptions @@ -217,4 +221,31 @@ export class GetPreferences { return result; } + + private async buildContextExactMatchQuery( + contextKeys: string[] | undefined, + organizationId: string + ): Promise> { + const useContextFiltering = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, + defaultValue: false, + organization: { _id: organizationId }, + }); + + if (!useContextFiltering) { + return {}; // FF OFF: no context filtering (pre-feature behavior) + } + + // undefined or empty array = match only "no context" preferences + if (contextKeys === undefined || contextKeys.length === 0) { + return { + $or: [{ contextKeys: { $exists: false } }, { contextKeys: [] }], + }; + } + + // non-empty array = exact match + return { + contextKeys: { $all: contextKeys, $size: contextKeys.length }, + }; + } } diff --git a/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts b/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts index 38e92470ba5..df29308d34d 100644 --- a/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts +++ b/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts @@ -93,6 +93,7 @@ export class GetSubscriberTemplatePreference { organizationId: command.organizationId, subscriberId, templateId: command.template._id, + contextKeys: command.contextKeys, }); const subscriberWorkflowChannels = GetPreferences.mapWorkflowPreferencesToChannelPreferences( diff --git a/libs/application-generic/src/usecases/trigger-event/trigger-event.usecase.ts b/libs/application-generic/src/usecases/trigger-event/trigger-event.usecase.ts index 44b620ec501..83068b9589e 100644 --- a/libs/application-generic/src/usecases/trigger-event/trigger-event.usecase.ts +++ b/libs/application-generic/src/usecases/trigger-event/trigger-event.usecase.ts @@ -9,6 +9,7 @@ import { } from '@novu/dal'; import { AddressingTypeEnum, + FeatureFlagsKeysEnum, ISubscribersDefine, ITenantDefine, TriggerRecipientSubscriber, @@ -16,11 +17,13 @@ import { } from '@novu/shared'; import { addBreadcrumb } from '@sentry/node'; import { toMerged } from 'es-toolkit'; +import { LRUCache } from 'lru-cache'; import { Instrument, InstrumentUsecase } from '../../instrumentation'; import { PinoLogger } from '../../logging'; import type { EventType, Trace } from '../../services/analytic-logs'; import { LogRepository, mapEventTypeToTitle, TraceLogRepository } from '../../services/analytic-logs'; import { AnalyticsService } from '../../services/analytics.service'; +import { FeatureFlagsService } from '../../services/feature-flags'; import { CreateOrUpdateSubscriberCommand, CreateOrUpdateSubscriberUseCase } from '../create-or-update-subscriber'; import { ProcessTenant, ProcessTenantCommand } from '../process-tenant'; import { TriggerBroadcastCommand } from '../trigger-broadcast/trigger-broadcast.command'; @@ -33,6 +36,13 @@ function getActiveWorker() { return process.env.ACTIVE_WORKER; } +const workflowCache = new LRUCache({ + max: 1000, + ttl: 1000 * 30, +}); + +const workflowInflightRequests = new Map>(); + @Injectable() export class TriggerEvent { constructor( @@ -46,7 +56,8 @@ export class TriggerEvent { private analyticsService: AnalyticsService, private traceLogRepository: TraceLogRepository, private contextRepository: ContextRepository, - private verifyPayload: VerifyPayload + private verifyPayload: VerifyPayload, + private featureFlagsService: FeatureFlagsService ) { this.logger.setContext(this.constructor.name); } @@ -316,9 +327,11 @@ export class TriggerEvent { }) { const lastTriggeredAt = new Date(); - const workflow = await this.notificationTemplateRepository.findByTriggerIdentifier( + const workflow = await this.findWorkflowByTriggerIdentifier( + command.triggerIdentifier, command.environmentId, - command.triggerIdentifier + command.organizationId, + command.payload?.__source ); if (workflow) { @@ -356,6 +369,59 @@ export class TriggerEvent { return workflow; } + @Instrument() + private async findWorkflowByTriggerIdentifier( + triggerIdentifier: string, + environmentId: string, + organizationId: string, + source?: string + ): Promise { + const cacheKey = `${environmentId}:${triggerIdentifier}`; + + const isFeatureFlagEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, + defaultValue: false, + environment: { _id: environmentId }, + organization: { _id: organizationId }, + component: 'api-trigger-event', + }); + + const isCacheEnabled = isFeatureFlagEnabled && !source; + + if (isCacheEnabled) { + const cached = workflowCache.get(cacheKey); + if (cached) { + return cached; + } + + const inflightRequest = workflowInflightRequests.get(cacheKey); + if (inflightRequest) { + return inflightRequest; + } + } + + const fetchPromise = this.notificationTemplateRepository + .findByTriggerIdentifier(environmentId, triggerIdentifier) + .then((workflow) => { + if (workflow && isCacheEnabled) { + workflowCache.set(cacheKey, workflow); + } + + return workflow; + }) + .finally(() => { + if (isCacheEnabled) { + workflowInflightRequests.delete(cacheKey); + } + }); + + if (isCacheEnabled) { + workflowInflightRequests.set(cacheKey, fetchPromise); + } + + return fetchPromise; + } + @Instrument() private async validateTransactionIdProperty(transactionId: string, environmentId: string): Promise { const found = (await this.jobRepository.findOne( diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts index 7a232a96965..4511953ca56 100644 --- a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; +import { EnforceEnvOrOrgIds, PreferencesDBModel, PreferencesEntity, PreferencesRepository } from '@novu/dal'; import { FeatureFlagsKeysEnum, PreferencesTypeEnum, WorkflowPreferences, WorkflowPreferencesPartial, } from '@novu/shared'; +import { FilterQuery } from 'mongoose'; import { Instrument } from '../../instrumentation'; import { FeatureFlagsService } from '../../services/feature-flags/feature-flags.service'; import { deepMerge } from '../../utils'; @@ -112,6 +113,7 @@ export class UpsertPreferences { _subscriberId: command._subscriberId, environmentId: command.environmentId, organizationId: command.organizationId, + contextKeys: command.contextKeys, preferences: command.preferences, templateId: command.templateId, type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, @@ -147,6 +149,7 @@ export class UpsertPreferences { topicSubscriptionId: command.topicSubscriptionId, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, returnPreference: command.returnPreference, + contextKeys: command.contextKeys, }); } @@ -161,6 +164,20 @@ export class UpsertPreferences { } private async createPreferences(command: UpsertPreferencesCommand): Promise { + const useContextFiltering = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, + defaultValue: false, + organization: { _id: command.organizationId }, + }); + + // Determine contextKeys based on preference type AND feature flag + // Non-context-scoped types (universal/workflow-level): undefined (no field) + // Context-scoped types (subscriber-level): [] or ["key"] + const isContextScoped = [ + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, + ].includes(command.type); + return await this.preferencesRepository.create({ _subscriberId: command._subscriberId, _userId: command.userId, @@ -171,6 +188,7 @@ export class UpsertPreferences { preferences: command.preferences, type: command.type, schedule: command.schedule, + contextKeys: useContextFiltering && isContextScoped ? (command.contextKeys ?? []) : undefined, }); } @@ -215,13 +233,57 @@ export class UpsertPreferences { } private async getPreference(command: UpsertPreferencesCommand): Promise { - return await this.preferencesRepository.findOne({ + const contextQuery = await this.buildContextExactMatchQuery( + command.contextKeys, + command.type, + command.organizationId + ); + + const query: FilterQuery & EnforceEnvOrOrgIds = { _environmentId: command.environmentId, _organizationId: command.organizationId, _subscriberId: command._subscriberId, _topicSubscriptionId: command.topicSubscriptionId, _templateId: command.templateId, type: command.type, + ...contextQuery, + }; + + return await this.preferencesRepository.findOne(query); + } + + private async buildContextExactMatchQuery( + contextKeys: string[] | undefined, + type: PreferencesTypeEnum, + organizationId: string + ): Promise> { + // Non-context-scoped types (universal/workflow-level) - no context filter + const nonContextScopedTypes = [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ]; + + if (nonContextScopedTypes.includes(type)) { + return {}; + } + + const useContextFiltering = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, + defaultValue: false, + organization: { _id: organizationId }, }); + + if (!useContextFiltering) { + return {}; + } + + // undefined or empty array = match only "no context" preferences + if (contextKeys === undefined || contextKeys.length === 0) { + return { $or: [{ contextKeys: { $exists: false } }, { contextKeys: [] }] }; + } + + // Match records with exact same context keys (order-independent) + return { contextKeys: { $all: contextKeys, $size: contextKeys.length } }; } } diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts index b8df3ba22c3..ea36379b22e 100644 --- a/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts @@ -1,5 +1,5 @@ import { Schedule } from '@novu/shared'; -import { IsBoolean, IsMongoId, IsNotEmpty, IsOptional } from 'class-validator'; +import { IsArray, IsBoolean, IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { UpsertPreferencesPartialBaseCommand } from './upsert-preferences.command'; export class UpsertSubscriberGlobalPreferencesCommand extends UpsertPreferencesPartialBaseCommand { @@ -13,4 +13,9 @@ export class UpsertSubscriberGlobalPreferencesCommand extends UpsertPreferencesP @IsOptional() readonly schedule?: Schedule; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly contextKeys?: string[]; } diff --git a/libs/dal/src/repositories/preferences/preferences.entity.ts b/libs/dal/src/repositories/preferences/preferences.entity.ts index 28f779fcf5d..5d5ef3f5e39 100644 --- a/libs/dal/src/repositories/preferences/preferences.entity.ts +++ b/libs/dal/src/repositories/preferences/preferences.entity.ts @@ -33,6 +33,8 @@ export class PreferencesEntity { schedule?: Schedule; + contextKeys?: string[]; + createdAt?: string; updatedAt?: string; diff --git a/libs/dal/src/repositories/preferences/preferences.schema.ts b/libs/dal/src/repositories/preferences/preferences.schema.ts index fb5a5752d63..b41fbb564f2 100644 --- a/libs/dal/src/repositories/preferences/preferences.schema.ts +++ b/libs/dal/src/repositories/preferences/preferences.schema.ts @@ -73,6 +73,10 @@ const preferencesSchema = new Schema( }, }, schedule: Schema.Types.Mixed, + contextKeys: { + type: [Schema.Types.String], + default: undefined, + }, }, { ...schemaOptions, minimize: false } ); diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 79d2be98b34..956b435d06c 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -73,6 +73,7 @@ export enum FeatureFlagsKeysEnum { IS_CONTEXTUAL_HELP_DRAWER_ENABLED = 'IS_CONTEXTUAL_HELP_DRAWER_ENABLED', IS_SUBSCRIPTION_PREFERENCES_ENABLED = 'IS_SUBSCRIPTION_PREFERENCES_ENABLED', IS_LRU_CACHE_ENABLED = 'IS_LRU_CACHE_ENABLED', + IS_CONTEXT_PREFERENCES_ENABLED = 'IS_CONTEXT_PREFERENCES_ENABLED', // String flags CF_SCHEDULER_MODE = 'CF_SCHEDULER_MODE', // Values: "off" | "shadow" | "live" | "complete" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58919457ec9..92adbe1fe51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2666,6 +2666,9 @@ importers: lodash: specifier: ^4.17.15 version: 4.17.21 + lru-cache: + specifier: ^11.2.4 + version: 11.2.4 mixpanel: specifier: ^0.17.0 version: 0.17.0 @@ -2753,7 +2756,7 @@ importers: version: 16.5.0 jest: specifier: ^27.1.0 - version: 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) + version: 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)) npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -2768,16 +2771,16 @@ importers: version: 9.2.4 ts-jest: specifier: ^27.0.5 - version: 27.1.5(@babel/core@7.28.0)(@types/jest@29.5.2)(babel-jest@27.5.1(@babel/core@7.28.0))(esbuild@0.23.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)))(typescript@5.6.2) + version: 27.1.5(@babel/core@7.28.0)(@types/jest@29.5.2)(babel-jest@27.5.1(@babel/core@7.28.0))(esbuild@0.23.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)))(typescript@5.6.2) ts-node: specifier: ~10.9.1 - version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2) + version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2) typescript: specifier: 5.6.2 version: 5.6.2 vitest: specifier: ^2.1.9 - version: 2.1.9(@edge-runtime/vm@4.0.2)(@types/node@22.15.13)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) + version: 2.1.9(@edge-runtime/vm@4.0.2)(@types/node@20.19.10)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) optionalDependencies: '@novu/ee-shared-services': specifier: workspace:* @@ -3261,7 +3264,7 @@ importers: devDependencies: '@tailwindcss/typography': specifier: ^0.5.15 - version: 0.5.19(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3))) + version: 0.5.19(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2))) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.47) @@ -3270,13 +3273,13 @@ importers: version: 8.4.47 tailwind-scrollbar: specifier: ^3.1.0 - version: 3.1.0(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3))) + version: 3.1.0(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2))) tailwindcss: specifier: ^3.4.14 - version: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)) + version: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3))) + version: 1.0.7(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2))) libs/maily-tsconfig: {} @@ -3561,13 +3564,13 @@ importers: version: 5.0.10 ts-node: specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3) + version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.8.3) typescript: specifier: ^5.0.0 version: 5.8.3 vitest: specifier: ^2.1.9 - version: 2.1.9(@edge-runtime/vm@4.0.2)(@types/node@22.15.13)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) + version: 2.1.9(@edge-runtime/vm@4.0.2)(@types/node@20.19.10)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) packages/framework: dependencies: @@ -3949,13 +3952,13 @@ importers: version: 3.0.1 ts-node: specifier: ~10.9.1 - version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2) + version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2) typescript: specifier: 5.6.2 version: 5.6.2 vitest: specifier: ^1.2.1 - version: 1.6.1(@edge-runtime/vm@4.0.2)(@types/node@22.15.13)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) + version: 1.6.1(@edge-runtime/vm@4.0.2)(@types/node@20.19.10)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) packages/providers: dependencies: @@ -4130,7 +4133,7 @@ importers: version: 3.0.2 ts-node: specifier: ~10.9.1 - version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2) + version: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2) typedoc: specifier: ^0.24.0 version: 0.24.6(typescript@5.6.2) @@ -4139,7 +4142,7 @@ importers: version: 5.6.2 vitest: specifier: 2.1.9 - version: 2.1.9(@edge-runtime/vm@4.0.2)(@types/node@22.15.13)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) + version: 2.1.9(@edge-runtime/vm@4.0.2)(@types/node@20.19.10)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) packages/react: dependencies: @@ -26764,6 +26767,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.10.4: @@ -38258,43 +38262,6 @@ snapshots: - ts-node - utf-8-validate - '@jest/core@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))': - dependencies: - '@jest/console': 27.5.1 - '@jest/reporters': 27.5.1 - '@jest/test-result': 27.5.1 - '@jest/transform': 27.5.1 - '@jest/types': 27.5.1 - '@types/node': 20.19.10 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.8.1 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 27.5.1 - jest-config: 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - jest-haste-map: 27.5.1 - jest-message-util: 27.5.1 - jest-regex-util: 27.5.1 - jest-resolve: 27.5.1 - jest-resolve-dependencies: 27.5.1 - jest-runner: 27.5.1 - jest-runtime: 27.5.1 - jest-snapshot: 27.5.1 - jest-util: 27.5.1 - jest-validate: 27.5.1 - jest-watcher: 27.5.1 - micromatch: 4.0.8 - rimraf: 3.0.2 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2))': dependencies: '@jest/console': 29.7.0 @@ -46068,10 +46035,10 @@ snapshots: postcss: 8.4.47 tailwindcss: 4.0.12 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)))': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)) + tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)) '@tanem/svg-injector@10.1.68': dependencies: @@ -55941,27 +55908,6 @@ snapshots: - ts-node - utf-8-validate - jest-cli@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)): - dependencies: - '@jest/core': 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - '@jest/test-result': 27.5.1 - '@jest/types': 27.5.1 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - import-local: 3.1.0 - jest-config: 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - jest-util: 27.5.1 - jest-validate: 27.5.1 - prompts: 2.4.2 - yargs: 16.2.0 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - jest-cli@29.7.0(@types/node@20.19.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)): dependencies: '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)) @@ -56034,40 +55980,6 @@ snapshots: - supports-color - utf-8-validate - jest-config@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)): - dependencies: - '@babel/core': 7.28.0 - '@jest/test-sequencer': 27.5.1 - '@jest/types': 27.5.1 - babel-jest: 27.5.1(@babel/core@7.28.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 27.5.1 - jest-environment-jsdom: 27.5.1 - jest-environment-node: 27.5.1 - jest-get-type: 27.5.1 - jest-jasmine2: 27.5.1 - jest-regex-util: 27.5.1 - jest-resolve: 27.5.1 - jest-runner: 27.5.1 - jest-util: 27.5.1 - jest-validate: 27.5.1 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 27.5.1 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - ts-node: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2) - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - utf-8-validate - jest-config@29.7.0(@types/node@20.19.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)): dependencies: '@babel/core': 7.28.0 @@ -56925,18 +56837,6 @@ snapshots: - ts-node - utf-8-validate - jest@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)): - dependencies: - '@jest/core': 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - import-local: 3.1.0 - jest-cli: 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - jest@29.7.0(@types/node@20.19.10)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)): dependencies: '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)) @@ -61141,14 +61041,6 @@ snapshots: postcss: 8.4.47 ts-node: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2) - postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)): - dependencies: - lilconfig: 3.1.3 - yaml: 2.6.1 - optionalDependencies: - postcss: 8.4.47 - ts-node: 10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3) - postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(yaml@2.6.1): dependencies: lilconfig: 3.1.3 @@ -65435,9 +65327,9 @@ snapshots: tailwind-merge@2.6.0: {} - tailwind-scrollbar@3.1.0(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3))): + tailwind-scrollbar@3.1.0(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2))): dependencies: - tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)) + tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)) tailwind-variants@0.3.0(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2))): dependencies: @@ -65452,10 +65344,6 @@ snapshots: dependencies: tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3))): - dependencies: - tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)) - tailwindcss-animate@1.0.7(tailwindcss@4.0.12): dependencies: tailwindcss: 4.0.12 @@ -65514,33 +65402,6 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)): - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.2 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.6 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.4.47 - postcss-import: 15.1.0(postcss@8.4.47) - postcss-js: 4.0.1(postcss@8.4.47) - postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3)) - postcss-nested: 6.2.0(postcss@8.4.47) - postcss-selector-parser: 6.1.2 - resolve: 1.22.8 - sucrase: 3.35.0 - transitivePeerDependencies: - - ts-node - tailwindcss@4.0.12: {} tapable@1.1.3: {} @@ -66015,24 +65876,6 @@ snapshots: babel-jest: 27.5.1(@babel/core@7.28.0) esbuild: 0.23.1 - ts-jest@27.1.5(@babel/core@7.28.0)(@types/jest@29.5.2)(babel-jest@27.5.1(@babel/core@7.28.0))(esbuild@0.23.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)))(typescript@5.6.2): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - jest: 27.5.1(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2)) - jest-util: 27.5.1 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.6.3 - typescript: 5.6.2 - yargs-parser: 20.2.9 - optionalDependencies: - '@babel/core': 7.28.0 - '@types/jest': 29.5.2 - babel-jest: 27.5.1(@babel/core@7.28.0) - esbuild: 0.23.1 - ts-jest@27.1.5(@babel/core@7.28.0)(@types/jest@30.0.0)(babel-jest@27.5.1(@babel/core@7.28.0))(esbuild@0.23.1)(jest@30.0.5(@types/node@20.19.10)(babel-plugin-macros@3.1.0)(esbuild-register@3.5.0(esbuild@0.23.1))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.6.2)))(typescript@5.6.2): dependencies: bs-logger: 0.2.6 @@ -66181,27 +66024,27 @@ snapshots: optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.15) - ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2): + ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@20.19.10)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 22.15.13 + '@types/node': 20.19.10 acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.6.2 + typescript: 5.8.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.15) - ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/node@22.15.13)(typescript@5.6.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 @@ -66215,11 +66058,12 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.8.3 + typescript: 5.6.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.15) + optional: true ts-node@9.1.1(typescript@5.6.2): dependencies: @@ -67216,24 +67060,6 @@ snapshots: - supports-color - terser - vite-node@1.6.1(@types/node@22.15.13)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6): - dependencies: - cac: 6.7.14 - debug: 4.4.0(supports-color@8.1.1) - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.21(@types/node@22.15.13)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - vite-node@2.1.9(@types/node@20.19.10)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6): dependencies: cac: 6.7.14 @@ -67430,43 +67256,6 @@ snapshots: - supports-color - terser - vitest@1.6.1(@edge-runtime/vm@4.0.2)(@types/node@22.15.13)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6): - dependencies: - '@vitest/expect': 1.6.1 - '@vitest/runner': 1.6.1 - '@vitest/snapshot': 1.6.1 - '@vitest/spy': 1.6.1 - '@vitest/utils': 1.6.1 - acorn-walk: 8.3.2 - chai: 4.4.1 - debug: 4.4.0(supports-color@8.1.1) - execa: 8.0.1 - local-pkg: 0.5.1 - magic-string: 0.30.17 - pathe: 1.1.2 - picocolors: 1.1.1 - std-env: 3.9.0 - strip-literal: 2.1.1 - tinybench: 2.9.0 - tinypool: 0.8.4 - vite: 5.4.21(@types/node@22.15.13)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) - vite-node: 1.6.1(@types/node@22.15.13)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6) - why-is-node-running: 2.3.0 - optionalDependencies: - '@edge-runtime/vm': 4.0.2 - '@types/node': 22.15.13 - happy-dom: 20.0.11 - jsdom: 25.0.0 - transitivePeerDependencies: - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - vitest@2.1.9(@edge-runtime/vm@4.0.2)(@types/node@20.19.10)(happy-dom@20.0.11)(jsdom@25.0.0)(less@4.2.0)(lightningcss@1.29.2)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.47))(terser@5.31.6): dependencies: '@vitest/expect': 2.1.9