diff --git a/apps/api/src/app/auth/ee.auth.module.config.ts b/apps/api/src/app/auth/ee.auth.module.config.ts index 5e6d38c91a5..fe87bbd6a24 100644 --- a/apps/api/src/app/auth/ee.auth.module.config.ts +++ b/apps/api/src/app/auth/ee.auth.module.config.ts @@ -1,5 +1,5 @@ import { MiddlewareConsumer, ModuleMetadata } from '@nestjs/common'; -import { cacheService, PlatformException } from '@novu/application-generic'; +import { cacheService, featureFlagsService, InMemoryLRUCacheService, PlatformException } from '@novu/application-generic'; import { RootEnvironmentGuard } from './framework/root-environment-guard.service'; import { AuthService } from './services/auth.service'; import { ApiKeyStrategy } from './services/passport/apikey.strategy'; @@ -23,6 +23,8 @@ export function getEEModuleConfig(): ModuleMetadata { JwtSubscriberStrategy, AuthService, cacheService, + featureFlagsService, + InMemoryLRUCacheService, RootEnvironmentGuard, ], exports: [...eeAuthModule.exports, RootEnvironmentGuard, AuthService], diff --git a/apps/api/src/app/auth/services/passport/apikey.strategy.ts b/apps/api/src/app/auth/services/passport/apikey.strategy.ts index 0cd4164a664..2964c6ec21e 100644 --- a/apps/api/src/app/auth/services/passport/apikey.strategy.ts +++ b/apps/api/src/app/auth/services/passport/apikey.strategy.ts @@ -1,24 +1,22 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; -import { FeatureFlagsService, HttpRequestHeaderKeysEnum } from '@novu/application-generic'; +import { + FeatureFlagsService, + HttpRequestHeaderKeysEnum, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, +} from '@novu/application-generic'; import { ApiAuthSchemeEnum, FeatureFlagsKeysEnum, UserSessionData } from '@novu/shared'; import { createHash } from 'crypto'; -import { LRUCache } from 'lru-cache'; import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; import { AuthService } from '../auth.service'; -const apiKeyUserCache = new LRUCache({ - max: 1000, - ttl: 1000 * 60, -}); - -const apiKeyInflightRequests = new Map>(); - @Injectable() export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) { constructor( private readonly authService: AuthService, - private readonly featureFlagsService: FeatureFlagsService + private readonly featureFlagsService: FeatureFlagsService, + private readonly inMemoryLRUCacheService: InMemoryLRUCacheService ) { super( { header: HttpRequestHeaderKeysEnum.AUTHORIZATION, prefix: `${ApiAuthSchemeEnum.API_KEY} ` }, @@ -42,51 +40,20 @@ export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) { private async validateApiKey(apiKey: string): Promise { const hashedApiKey = createHash('sha256').update(apiKey).digest('hex'); - const isLruCacheEnabled = await this.featureFlagsService.getFlag({ - key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, - defaultValue: false, - environment: { _id: 'system' }, - component: 'api-key-auth', - }); - - if (isLruCacheEnabled) { - const cached = apiKeyUserCache.get(hashedApiKey); - if (cached) { - await this.checkKillSwitch(cached); - - return cached; - } - - const inflightRequest = apiKeyInflightRequests.get(hashedApiKey); - if (inflightRequest) { - return inflightRequest; + const user = await this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.API_KEY_USER, + hashedApiKey, + () => this.authService.getUserByApiKey(apiKey), + { + environmentId: 'system', } - } - - const fetchPromise = this.authService - .getUserByApiKey(apiKey) - .then(async (user) => { - if (user && isLruCacheEnabled) { - apiKeyUserCache.set(hashedApiKey, user); - } - - if (user) { - await this.checkKillSwitch(user); - } - - return user; - }) - .finally(() => { - if (isLruCacheEnabled) { - apiKeyInflightRequests.delete(hashedApiKey); - } - }); + ); - if (isLruCacheEnabled) { - apiKeyInflightRequests.set(hashedApiKey, fetchPromise); + if (user) { + await this.checkKillSwitch(user); } - return fetchPromise; + return user; } private async checkKillSwitch(user: UserSessionData): Promise { diff --git a/apps/api/src/app/environments-v1/novu-bridge.module.ts b/apps/api/src/app/environments-v1/novu-bridge.module.ts index 4e05daded85..35198da8792 100644 --- a/apps/api/src/app/environments-v1/novu-bridge.module.ts +++ b/apps/api/src/app/environments-v1/novu-bridge.module.ts @@ -6,6 +6,7 @@ import { FeatureFlagsService, GetDecryptedSecretKey, GetLayoutUseCase as GetLayoutUseCaseV1, + InMemoryLRUCacheService, TraceLogRepository, } from '@novu/application-generic'; @@ -84,6 +85,7 @@ export const featureFlagsService = { ClickHouseService, CreateExecutionDetails, featureFlagsService, + InMemoryLRUCacheService, ], }) export class NovuBridgeModule {} diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts index f48431f0fa9..c3ac088b197 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts @@ -2,6 +2,8 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { emailControlSchema, FeatureFlagsService, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, Instrument, InstrumentUsecase, PinoLogger, @@ -16,16 +18,9 @@ import { } from '@novu/dal'; import { workflow } from '@novu/framework/express'; import { ActionStep, ChannelStep, PostActionEnum, Schema, Step, StepOutput, Workflow } from '@novu/framework/internal'; -import { - EnvironmentTypeEnum, - FeatureFlagsKeysEnum, - LAYOUT_PREVIEW_EMAIL_STEP, - LAYOUT_PREVIEW_WORKFLOW_ID, - StepTypeEnum, -} from '@novu/shared'; +import { EnvironmentTypeEnum, LAYOUT_PREVIEW_EMAIL_STEP, LAYOUT_PREVIEW_WORKFLOW_ID, StepTypeEnum } from '@novu/shared'; import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import _ from 'lodash'; -import { LRUCache } from 'lru-cache'; import { evaluateRules } from '../../../shared/services/query-parser/query-parser.service'; import { isMatchingJsonSchema } from '../../../workflows-v2/util/jsonToSchema'; import { @@ -43,19 +38,6 @@ import { ConstructFrameworkWorkflowCommand } from './construct-framework-workflo const LOG_CONTEXT = 'ConstructFrameworkWorkflow'; -const workflowCache = new LRUCache({ - max: 1000, - ttl: 1000 * 60, -}); - -const organizationCache = new LRUCache({ - max: 500, - ttl: 1000 * 60, -}); - -const workflowInflightRequests = new Map>(); -const organizationInflightRequests = new Map>(); - @Injectable() export class ConstructFrameworkWorkflow { constructor( @@ -71,7 +53,8 @@ export class ConstructFrameworkWorkflow { private delayOutputRendererUseCase: DelayOutputRendererUsecase, private digestOutputRendererUseCase: DigestOutputRendererUsecase, private throttleOutputRendererUseCase: ThrottleOutputRendererUsecase, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) {} @InstrumentUsecase() @@ -391,53 +374,33 @@ export class ConstructFrameworkWorkflow { workflowId: string, shouldUseCache: boolean ): Promise { - const cacheKey = `${environmentId}:${workflowId}`; - - const isFeatureEnabled = await this.featureFlagsService.getFlag({ - key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, - defaultValue: false, - environment: { _id: environmentId }, - component: 'bridge-workflow', - }); - - const useCache = shouldUseCache && isFeatureEnabled; - - if (useCache) { - const cached = workflowCache.get(cacheKey); - if (cached) { - return cached; - } - - const inflightRequest = workflowInflightRequests.get(cacheKey); - if (inflightRequest) { - return inflightRequest; - } - } - - const fetchPromise = this.workflowsRepository - .findByTriggerIdentifier(environmentId, workflowId, null, false) - .then((foundWorkflow) => { + const workflow = await this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.WORKFLOW, + `${environmentId}:${workflowId}`, + async () => { + const foundWorkflow = await this.workflowsRepository.findByTriggerIdentifier( + environmentId, + workflowId, + null, + false + ); if (!foundWorkflow) { throw new InternalServerErrorException(`Workflow ${workflowId} not found`); } - if (useCache) { - workflowCache.set(cacheKey, foundWorkflow); - } - return foundWorkflow; - }) - .finally(() => { - if (useCache) { - workflowInflightRequests.delete(cacheKey); - } - }); + }, + { + environmentId, + skipCache: !shouldUseCache, + } + ); - if (useCache) { - workflowInflightRequests.set(cacheKey, fetchPromise); + if (!workflow) { + throw new InternalServerErrorException(`Workflow ${workflowId} not found`); } - return fetchPromise; + return workflow; } private async getOrganization( @@ -445,48 +408,18 @@ export class ConstructFrameworkWorkflow { shouldUseCache: boolean, environmentId: string ): Promise { - const isFeatureEnabled = await this.featureFlagsService.getFlag({ - key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, - defaultValue: false, - environment: { _id: environmentId }, - organization: { _id: organizationId }, - component: 'bridge-org', - }); - - const useCache = shouldUseCache && isFeatureEnabled; - - if (useCache) { - const cached = organizationCache.get(organizationId); - if (cached) { - return cached; - } - - const inflightRequest = organizationInflightRequests.get(organizationId); - if (inflightRequest) { - return inflightRequest; + const organization = await this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.ORGANIZATION, + organizationId, + () => this.communityOrganizationRepository.findById(organizationId), + { + environmentId, + organizationId, + skipCache: !shouldUseCache, } - } - - const fetchPromise = this.communityOrganizationRepository - .findById(organizationId) - .then((organization) => { - if (organization && useCache) { - organizationCache.set(organizationId, organization); - } - - return organization || undefined; - }) - .finally(() => { - if (useCache) { - organizationInflightRequests.delete(organizationId); - } - }); - - if (useCache) { - organizationInflightRequests.set(organizationId, fetchPromise); - } + ); - return fetchPromise; + return organization || undefined; } private async processSkipOption( diff --git a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts index a5eab669225..6c357f973d4 100644 --- a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts +++ b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts @@ -7,6 +7,8 @@ import { ExecuteBridgeRequestCommand, ExecuteBridgeRequestDto, FeatureFlagsService, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, Instrument, InstrumentUsecase, IWorkflowDataDto, @@ -35,7 +37,6 @@ import { } from '@novu/shared'; import Ajv, { ValidateFunction } from 'ajv'; import addFormats from 'ajv-formats'; -import { LRUCache } from 'lru-cache'; import { generateTransactionId } from '../../../shared/helpers/generate-transaction-id'; import { PayloadValidationException } from '../../exceptions/payload-validation-exception'; import { RecipientSchema, RecipientsSchema } from '../../utils/trigger-recipient-validation'; @@ -51,27 +52,10 @@ const ajv = new Ajv({ }); addFormats(ajv); -const validatorCache = new LRUCache({ - max: 5000, - ttl: 1000 * 60 * 60, -}); - function getSchemaHash(schema: object): string { return createHash('sha256').update(JSON.stringify(schema)).digest('hex'); } -function getCompiledValidator(schema: object): ValidateFunction { - const hash = getSchemaHash(schema); - let validate = validatorCache.get(hash); - - if (!validate) { - validate = ajv.compile(schema); - validatorCache.set(hash, validate); - } - - return validate; -} - @Injectable() export class ParseEventRequest { constructor( @@ -84,7 +68,8 @@ export class ParseEventRequest { private logger: PinoLogger, private featureFlagService: FeatureFlagsService, private traceLogRepository: TraceLogRepository, - protected moduleRef: ModuleRef + protected moduleRef: ModuleRef, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) { this.logger.setContext(this.constructor.name); } @@ -489,7 +474,7 @@ export class ParseEventRequest { @Instrument() private validateAndApplyPayloadDefaults(payload: Record, schema: object): Record { - const validate = getCompiledValidator(schema); + const validate = this.getCompiledValidator(schema); const payloadWithDefaults = JSON.parse(JSON.stringify(payload)); const valid = validate(payloadWithDefaults); @@ -499,4 +484,16 @@ export class ParseEventRequest { return payloadWithDefaults; } + + private getCompiledValidator(schema: object): ValidateFunction { + const hash = getSchemaHash(schema); + let validate = this.inMemoryLRUCacheService.getIfCached(InMemoryLRUCacheStore.VALIDATOR, hash) as ValidateFunction; + + if (!validate) { + validate = ajv.compile(schema); + this.inMemoryLRUCacheService.set(InMemoryLRUCacheStore.VALIDATOR, hash, validate); + } + + return validate; + } } diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts index 628e6ecc208..6d6d7cafb05 100644 --- a/apps/api/src/app/shared/shared.module.ts +++ b/apps/api/src/app/shared/shared.module.ts @@ -14,6 +14,7 @@ import { ExecuteBridgeRequest, featureFlagsService, GetDecryptedSecretKey, + InMemoryLRUCacheService, InvalidateCacheService, LoggerModule, QueuesModule, @@ -138,6 +139,7 @@ const PROVIDERS = [ dalService, DalServiceHealthIndicator, featureFlagsService, + InMemoryLRUCacheService, InvalidateCacheService, storageService, ...DAL_MODELS, diff --git a/apps/api/src/app/subscribers-v2/subscribers.module.ts b/apps/api/src/app/subscribers-v2/subscribers.module.ts index 1f334965838..6c2ae0a3894 100644 --- a/apps/api/src/app/subscribers-v2/subscribers.module.ts +++ b/apps/api/src/app/subscribers-v2/subscribers.module.ts @@ -8,6 +8,7 @@ import { GetPreferences, GetSubscriberTemplatePreference, GetWorkflowByIdsUseCase, + InMemoryLRUCacheService, InvalidateCacheService, UpdateSubscriber, UpdateSubscriberChannel, @@ -87,6 +88,7 @@ const DAL_MODELS = [ CommunityOrganizationRepository, featureFlagsService, EnvironmentRepository, + InMemoryLRUCacheService, ], }) export class SubscribersModule {} diff --git a/apps/api/src/app/subscribers-v2/usecases/get-subscriber-preferences/get-subscriber-preferences.usecase.ts b/apps/api/src/app/subscribers-v2/usecases/get-subscriber-preferences/get-subscriber-preferences.usecase.ts index 5b8dc028a95..6feabaaf484 100644 --- a/apps/api/src/app/subscribers-v2/usecases/get-subscriber-preferences/get-subscriber-preferences.usecase.ts +++ b/apps/api/src/app/subscribers-v2/usecases/get-subscriber-preferences/get-subscriber-preferences.usecase.ts @@ -1,4 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InMemoryLRUCacheService, InMemoryLRUCacheStore, Instrument } from '@novu/application-generic'; +import { + NotificationTemplateEntity, + NotificationTemplateRepository, + SubscriberEntity, + SubscriberRepository, +} from '@novu/dal'; import { ISubscriberPreferenceResponse, ShortIsPrefixEnum, WorkflowCriticalityEnum } from '@novu/shared'; import { plainToInstance } from 'class-transformer'; import { buildSlug } from '../../../shared/helpers/build-slug'; @@ -19,12 +26,32 @@ import { GetSubscriberPreferencesCommand } from './get-subscriber-preferences.co export class GetSubscriberPreferences { constructor( private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, - private getSubscriberPreference: GetSubscriberPreference + private getSubscriberPreference: GetSubscriberPreference, + private subscriberRepository: SubscriberRepository, + private notificationTemplateRepository: NotificationTemplateRepository, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) {} async execute(command: GetSubscriberPreferencesCommand): Promise { - const globalPreference = await this.fetchGlobalPreference(command); - const workflowPreferences = await this.fetchWorkflowPreferences(command); + const subscriber = await this.subscriberRepository.findBySubscriberId( + command.environmentId, + command.subscriberId, + true, + '_id' + ); + + if (!subscriber) { + throw new NotFoundException(`Subscriber with id: ${command.subscriberId} not found`); + } + + const workflowList = await this.getActiveWorkflows({ + organizationId: command.organizationId, + environmentId: command.environmentId, + critical: command.criticality === WorkflowCriticalityEnum.CRITICAL ? true : undefined, + }); + + const globalPreference = await this.fetchGlobalPreference(command, subscriber, workflowList); + const workflowPreferences = await this.fetchWorkflowPreferences(command, subscriber, workflowList); return plainToInstance(GetSubscriberPreferencesDto, { global: globalPreference, @@ -33,7 +60,9 @@ export class GetSubscriberPreferences { } private async fetchGlobalPreference( - command: GetSubscriberPreferencesCommand + command: GetSubscriberPreferencesCommand, + subscriber: SubscriberEntity, + workflowList: NotificationTemplateEntity[] ): Promise { const { preference } = await this.getSubscriberGlobalPreference.execute( GetSubscriberGlobalPreferenceCommand.create({ @@ -42,6 +71,8 @@ export class GetSubscriberPreferences { subscriberId: command.subscriberId, includeInactiveChannels: false, contextKeys: command.contextKeys, + subscriber, + workflowList, }) ); @@ -50,7 +81,11 @@ export class GetSubscriberPreferences { }; } - private async fetchWorkflowPreferences(command: GetSubscriberPreferencesCommand) { + private async fetchWorkflowPreferences( + command: GetSubscriberPreferencesCommand, + subscriber: SubscriberEntity, + workflowList: NotificationTemplateEntity[] + ) { const subscriberWorkflowPreferences = await this.getSubscriberPreference.execute( GetSubscriberPreferenceCommand.create({ environmentId: command.environmentId, @@ -59,6 +94,8 @@ export class GetSubscriberPreferences { includeInactiveChannels: false, criticality: command.criticality ?? WorkflowCriticalityEnum.NON_CRITICAL, contextKeys: command.contextKeys, + subscriber, + workflowList, }) ); @@ -83,4 +120,44 @@ export class GetSubscriberPreferences { }, }; } + + @Instrument() + private async getActiveWorkflows({ + organizationId, + environmentId, + critical, + }: { + organizationId: string; + environmentId: string; + critical?: boolean; + }): Promise { + const cacheKey = `${organizationId}:${environmentId}`; + const cacheVariant = this.buildCacheVariant(critical); + + return this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.ACTIVE_WORKFLOWS, + cacheKey, + async () => + await this.notificationTemplateRepository.filterActive({ + organizationId, + environmentId, + tags: undefined, + severity: undefined, + critical, + }), + { + organizationId, + environmentId, + cacheVariant, + } + ); + } + + private buildCacheVariant(critical?: boolean): string { + const filters = { + ...(critical !== undefined && { critical }), + }; + + return Object.keys(filters).length > 0 ? JSON.stringify(filters) : 'default'; + } } 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 269ebe6e4e3..c023c6e4e63 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,4 +1,4 @@ -import { SubscriberEntity } from '@novu/dal'; +import { NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; @@ -8,5 +8,8 @@ export class GetSubscriberGlobalPreferenceCommand extends EnvironmentWithSubscri includeInactiveChannels: boolean; @IsOptional() - subscriber?: SubscriberEntity; + subscriber?: Pick; + + @IsOptional() + workflowList?: NotificationTemplateEntity[]; } diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts index f06a08c3790..46bb991632a 100644 --- a/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts @@ -63,15 +63,17 @@ export class GetSubscriberGlobalPreference { return Object.values(ChannelTypeEnum); } - const workflowList = await this.notificationTemplateRepository.filterActive({ - organizationId: command.organizationId, - environmentId: command.environmentId, - tags: undefined, - critical: undefined, - severity: undefined, - select: '_id steps.active steps._templateId', - limit: 100, - }); + const workflowList = + command.workflowList ?? + (await this.notificationTemplateRepository.filterActive({ + organizationId: command.organizationId, + environmentId: command.environmentId, + tags: undefined, + critical: undefined, + severity: undefined, + select: '_id steps.active steps._templateId', + limit: 100, + })); const activeChannels = new Set(); @@ -101,15 +103,13 @@ export class GetSubscriberGlobalPreference { return Array.from(channelSet); } - @CachedResponse({ - builder: (command: { subscriberId: string; _environmentId: string }) => - buildSubscriberKey({ - _environmentId: command._environmentId, - subscriberId: command.subscriberId, - }), - }) - private async getSubscriber(command: GetSubscriberGlobalPreferenceCommand): Promise { - const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); + private async getSubscriber(command: GetSubscriberGlobalPreferenceCommand): Promise> { + const subscriber = await this.subscriberRepository.findBySubscriberId( + command.environmentId, + command.subscriberId, + false, + '_id' + ); if (!subscriber) { throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); @@ -117,6 +117,7 @@ export class GetSubscriberGlobalPreference { return subscriber; } + // adds default state for missing channels private buildDefaultPreferences(preference: IPreferenceChannels) { const defaultPreference: IPreferenceChannels = { diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.command.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.command.ts index 04f98c3474c..9214cb54d9b 100644 --- a/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.command.ts +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.command.ts @@ -1,5 +1,5 @@ import { EnvironmentWithSubscriber } from '@novu/application-generic'; -import { SubscriberEntity } from '@novu/dal'; +import { NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/shared'; import { IsArray, IsBoolean, IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; @@ -23,5 +23,8 @@ export class GetSubscriberPreferenceCommand extends EnvironmentWithSubscriber { criticality: WorkflowCriticalityEnum; @IsOptional() - subscriber?: SubscriberEntity; + subscriber?: Pick; + + @IsOptional() + workflowList?: NotificationTemplateEntity[]; } 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 fb47edaa0b4..b62a4c09047 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 @@ -4,6 +4,8 @@ import { filteredPreference, GetPreferences, GetPreferencesResponseDto, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, Instrument, InstrumentUsecase, MergePreferences, @@ -17,6 +19,7 @@ import { NotificationTemplateRepository, PreferencesEntity, PreferencesRepository, + SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { @@ -25,6 +28,7 @@ import { IPreferenceChannels, ISubscriberPreferenceResponse, PreferencesTypeEnum, + SeverityLevelEnum, WorkflowCriticalityEnum, } from '@novu/shared'; import { chunk } from 'es-toolkit'; @@ -36,24 +40,27 @@ export class GetSubscriberPreference { private subscriberRepository: SubscriberRepository, private notificationTemplateRepository: NotificationTemplateRepository, private preferencesRepository: PreferencesRepository, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) {} @InstrumentUsecase() async execute(command: GetSubscriberPreferenceCommand): Promise { - const subscriber = + const subscriber: Pick | null = command.subscriber ?? - (await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId)); + (await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId, false, '_id')); if (!subscriber) { throw new NotFoundException(`Subscriber with id: ${command.subscriberId} not found`); } - const workflowList = await this.notificationTemplateRepository.filterActive({ - organizationId: command.organizationId, - environmentId: command.environmentId, - tags: command.tags, - severity: command.severity, - }); + const workflowList = + command.workflowList ?? + (await this.getActiveWorkflows({ + organizationId: command.organizationId, + environmentId: command.environmentId, + tags: command.tags, + severity: command.severity, + })); const workflowIds = workflowList.map((wf) => wf._id); @@ -328,4 +335,46 @@ export class GetSubscriberPreference { subscriberGlobalPreference: subscriberGlobalPreferences[0] ?? null, }; } + + @Instrument() + private async getActiveWorkflows({ + organizationId, + environmentId, + tags, + severity, + }: { + organizationId: string; + environmentId: string; + tags?: string[]; + severity?: SeverityLevelEnum[]; + }): Promise { + const cacheKey = `${organizationId}:${environmentId}`; + const cacheVariant = this.buildCacheVariant(tags, severity); + + return this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.ACTIVE_WORKFLOWS, + cacheKey, + () => + this.notificationTemplateRepository.filterActive({ + organizationId, + environmentId, + tags, + severity, + }), + { + organizationId, + environmentId, + cacheVariant, + } + ); + } + + private buildCacheVariant(tags?: string[], severity?: SeverityLevelEnum[]): string { + const filters = { + ...(tags && tags.length > 0 && { tags: [...tags].sort() }), + ...(severity && severity.length > 0 && { severity: [...severity].sort() }), + }; + + return Object.keys(filters).length > 0 ? JSON.stringify(filters) : 'default'; + } } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 426e0486749..f2e218fb807 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,7 +1,7 @@ { "name": "@novu/dashboard", "private": true, - "version": "3.13.0", + "version": "3.13.2", "type": "module", "scripts": { "start": "vite", diff --git a/apps/worker/src/app/shared/shared.module.ts b/apps/worker/src/app/shared/shared.module.ts index 2fb88583c23..b46465746a6 100644 --- a/apps/worker/src/app/shared/shared.module.ts +++ b/apps/worker/src/app/shared/shared.module.ts @@ -18,6 +18,7 @@ import { featureFlagsService, GetDecryptedSecretKey, GetTenant, + InMemoryLRUCacheService, InvalidateCacheService, LoggerModule, MetricsModule, @@ -113,6 +114,7 @@ const PROVIDERS = [ DalServiceHealthIndicator, DigestFilterSteps, featureFlagsService, + InMemoryLRUCacheService, InvalidateCacheService, StorageHelperService, storageService, 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 6bc0dc21307..fa187cad19d 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 @@ -4,9 +4,12 @@ import { CreateExecutionDetailsCommand, DetailEnum, dashboardSanitizeControlValues, + EnvironmentCacheData, ExecuteBridgeRequest, ExecuteBridgeRequestCommand, FeatureFlagsService, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, Instrument, InstrumentUsecase, PinoLogger, @@ -35,24 +38,13 @@ 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( @@ -64,7 +56,8 @@ export class ExecuteBridgeJob { private createExecutionDetails: CreateExecutionDetails, private executeBridgeRequest: ExecuteBridgeRequest, private logger: PinoLogger, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) { this.logger.setContext(this.constructor.name); } @@ -345,53 +338,22 @@ 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; + return this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.ENVIRONMENT, + `${organizationId}:${environmentId}`, + () => + this.environmentRepository.findOne( + { + _id: environmentId, + _organizationId: organizationId, + }, + 'echo apiKeys _id' + ), + { + environmentId, + organizationId, + cacheVariant: '_id:apiKeys:echo', } - } - - 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/run-job/run-job.usecase.ts b/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts index 992c1996dba..6be58cea66f 100644 --- a/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts @@ -1,4 +1,4 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateExecutionDetails, CreateExecutionDetailsCommand, @@ -7,6 +7,8 @@ import { GetSubscriberSchedule, GetSubscriberScheduleCommand, getJobDigest, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, Instrument, InstrumentUsecase, PinoLogger, @@ -34,7 +36,6 @@ import { import { setUser } from '@sentry/node'; import { differenceInMilliseconds } from 'date-fns'; import { formatInTimeZone } from 'date-fns-tz'; -import { LRUCache } from 'lru-cache'; import { EXCEPTION_MESSAGE_ON_WEBHOOK_FILTER, PlatformException, shouldHaltOnStepFailure } from '../../../shared/utils'; import { AddJob } from '../add-job'; import { PartialNotificationEntity } from '../add-job/add-job.command'; @@ -49,13 +50,6 @@ import { calculateNextAvailableTime, isWithinSchedule } from './schedule-validat const nr = require('newrelic'); -const workflowCache = new LRUCache({ - max: 1000, - ttl: 1000 * 30, -}); - -const workflowInflightRequests = new Map>(); - export type SelectedWorkflowFields = Pick; /** @@ -84,7 +78,8 @@ export class RunJob { private logger: PinoLogger, private subscriberRepository: SubscriberRepository, private featureFlagsService: FeatureFlagsService, - private executeBridgeJob: ExecuteBridgeJob + private executeBridgeJob: ExecuteBridgeJob, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) { this.logger.setContext(this.constructor.name); } @@ -414,51 +409,27 @@ export class RunJob { environmentId: string, organizationId: string, source?: string - ): Promise { - const cacheKey = `${environmentId}:${templateId}`; - - const isFeatureFlagEnabled = await this.featureFlagsService.getFlag({ - key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, - defaultValue: false, - environment: { _id: environmentId }, - organization: { _id: organizationId }, - component: 'worker-workflow', - }); - - const isCacheEnabled = isFeatureFlagEnabled && !source; - - if (isCacheEnabled) { - const cached = workflowCache.get(cacheKey); - if (cached) { - return cached; - } - - const inflightRequest = workflowInflightRequests.get(cacheKey); - if (inflightRequest) { - return inflightRequest; + ): Promise { + const workflow = await this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.WORKFLOW, + `${environmentId}:${templateId}`, + async () => { + const result = await this.notificationTemplateRepository.findById(templateId, environmentId); + + return result; + }, + { + environmentId, + organizationId, + skipCache: !!source, } - } - - const fetchPromise = this.notificationTemplateRepository - .findById(templateId, environmentId) - .then((workflow) => { - if (workflow && isCacheEnabled) { - workflowCache.set(cacheKey, workflow); - } - - return workflow ?? undefined; - }) - .finally(() => { - if (isCacheEnabled) { - workflowInflightRequests.delete(cacheKey); - } - }); + ); - if (isCacheEnabled) { - workflowInflightRequests.set(cacheKey, fetchPromise); + if (!workflow) { + throw new NotFoundException(`Workflow ${templateId} not found`); } - return fetchPromise; + return workflow; } private isUnsnoozeJob(job: JobEntity) { 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 3c81efb2705..c175d08b394 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 @@ -9,6 +9,8 @@ import { FeatureFlagsService, GetPreferences, GetPreferencesCommand, + InMemoryLRUCacheService, + InMemoryLRUCacheStore, Instrument, InstrumentUsecase, LogRepository, @@ -39,19 +41,11 @@ import { } from '@novu/shared'; import type { RulesLogic } from 'json-logic-js'; import jsonLogic from 'json-logic-js'; -import { LRUCache } from 'lru-cache'; import { StoreSubscriberJobs, StoreSubscriberJobsCommand } from '../store-subscriber-jobs'; import { SubscriberJobBoundCommand } from './subscriber-job-bound.command'; const LOG_CONTEXT = 'SubscriberJobBoundUseCase'; -const workflowCache = new LRUCache({ - max: 1000, - ttl: 1000 * 30, -}); - -const workflowInflightRequests = new Map>(); - @Injectable() export class SubscriberJobBound { constructor( @@ -65,7 +59,8 @@ export class SubscriberJobBound { private traceLogRepository: TraceLogRepository, private getPreferences: GetPreferences, private preferencesRepository: PreferencesRepository, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) { this.logger.setContext(this.constructor.name); } @@ -319,50 +314,16 @@ export class SubscriberJobBound { organizationId: string; source?: string; }): Promise { - const cacheKey = `${environmentId}:${_id}`; - - const isFeatureFlagEnabled = await this.featureFlagsService.getFlag({ - key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, - defaultValue: false, - environment: { _id: environmentId }, - organization: { _id: organizationId }, - component: 'worker-workflow', - }); - - const isCacheEnabled = isFeatureFlagEnabled && !source; - - if (isCacheEnabled) { - const cached = workflowCache.get(cacheKey); - if (cached) { - return cached; - } - - const inflightRequest = workflowInflightRequests.get(cacheKey); - if (inflightRequest) { - return inflightRequest; + return this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.WORKFLOW, + `${environmentId}:${_id}`, + () => this.notificationTemplateRepository.findById(_id, environmentId), + { + environmentId, + organizationId, + skipCache: !!source, } - } - - const fetchPromise = this.notificationTemplateRepository - .findById(_id, environmentId) - .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; + ); } @InstrumentUsecase() diff --git a/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.service.spec.ts b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.service.spec.ts new file mode 100644 index 00000000000..0c506a9fe1d --- /dev/null +++ b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.service.spec.ts @@ -0,0 +1,346 @@ +import { Test } from '@nestjs/testing'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { FeatureFlagsService } from '../feature-flags'; +import { InMemoryLRUCacheService } from './in-memory-lru-cache.service'; +import { InMemoryLRUCacheStore } from './in-memory-lru-cache.store'; + +describe('InMemoryLRUCacheService', () => { + let service: InMemoryLRUCacheService; + let featureFlagsService: jest.Mocked; + + beforeEach(async () => { + const mockFeatureFlagsService = { + getFlag: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + InMemoryLRUCacheService, + { + provide: FeatureFlagsService, + useValue: mockFeatureFlagsService, + }, + ], + }).compile(); + + service = module.get(InMemoryLRUCacheService); + featureFlagsService = module.get(FeatureFlagsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + service.invalidateAll(InMemoryLRUCacheStore.WORKFLOW); + service.invalidateAll(InMemoryLRUCacheStore.ORGANIZATION); + service.invalidateAll(InMemoryLRUCacheStore.VALIDATOR); + }); + + describe('get', () => { + it('should fetch and cache value when cache is enabled', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn = jest.fn().mockResolvedValue({ id: '123', name: 'test' }); + + const result = await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key1', fetchFn, { + environmentId: 'env1', + organizationId: 'org1', + }); + + expect(result).toEqual({ id: '123', name: 'test' }); + expect(fetchFn).toHaveBeenCalledTimes(1); + + const cachedResult = await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key1', fetchFn, { + environmentId: 'env1', + organizationId: 'org1', + }); + + expect(cachedResult).toEqual({ id: '123', name: 'test' }); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('should not cache null or undefined values', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn = jest.fn().mockResolvedValue(null); + + const result = await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key2', fetchFn, { + environmentId: 'env1', + }); + + expect(result).toBeNull(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key2', fetchFn, { + environmentId: 'env1', + }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('should bypass cache when skipCache is true', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn = jest.fn().mockResolvedValue({ id: '456' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key3', fetchFn, { + environmentId: 'env1', + }); + + expect(fetchFn).toHaveBeenCalledTimes(1); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key3', fetchFn, { + environmentId: 'env1', + skipCache: true, + }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('should deduplicate concurrent requests', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + let resolveCount = 0; + const fetchFn = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolveCount++; + resolve({ id: resolveCount }); + }, 10); + }) + ); + + const [result1, result2, result3] = await Promise.all([ + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key4', fetchFn, { environmentId: 'env1' }), + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key4', fetchFn, { environmentId: 'env1' }), + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key4', fetchFn, { environmentId: 'env1' }), + ]); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(result1).toEqual({ id: 1 }); + expect(result2).toEqual({ id: 1 }); + expect(result3).toEqual({ id: 1 }); + }); + + it('should bypass cache when feature flag is disabled', async () => { + featureFlagsService.getFlag.mockResolvedValue(false); + const fetchFn = jest.fn().mockResolvedValue({ id: '789' }); + + const result = await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key5', fetchFn, { + environmentId: 'env1', + }); + + expect(result).toEqual({ id: '789' }); + expect(fetchFn).toHaveBeenCalledTimes(1); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key5', fetchFn, { + environmentId: 'env1', + }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('should skip feature flag check for VALIDATOR store', async () => { + const fetchFn = jest.fn().mockResolvedValue({ validator: 'fn' }); + + await service.get(InMemoryLRUCacheStore.VALIDATOR, 'key6', fetchFn); + + expect(featureFlagsService.getFlag).not.toHaveBeenCalled(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('should handle different stores independently', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const workflowFn = jest.fn().mockResolvedValue({ type: 'workflow' }); + const orgFn = jest.fn().mockResolvedValue({ type: 'org' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key7', workflowFn, { environmentId: 'env1' }); + await service.get(InMemoryLRUCacheStore.ORGANIZATION, 'key7', orgFn, { environmentId: 'env1' }); + + expect(workflowFn).toHaveBeenCalledTimes(1); + expect(orgFn).toHaveBeenCalledTimes(1); + }); + + it('should isolate cache entries by cacheVariant', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn1 = jest.fn().mockResolvedValue({ id: '1', projection: 'variant1' }); + const fetchFn2 = jest.fn().mockResolvedValue({ id: '2', projection: 'variant2' }); + + const result1 = await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-variant', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'variant1', + }); + + const result2 = await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-variant', fetchFn2, { + environmentId: 'env1', + cacheVariant: 'variant2', + }); + + expect(fetchFn1).toHaveBeenCalledTimes(1); + expect(fetchFn2).toHaveBeenCalledTimes(1); + expect(result1).toEqual({ id: '1', projection: 'variant1' }); + expect(result2).toEqual({ id: '2', projection: 'variant2' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-variant', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'variant1', + }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-variant', fetchFn2, { + environmentId: 'env1', + cacheVariant: 'variant2', + }); + + expect(fetchFn1).toHaveBeenCalledTimes(1); + expect(fetchFn2).toHaveBeenCalledTimes(1); + }); + + it('should deduplicate concurrent requests per variant', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + let resolveCount1 = 0; + let resolveCount2 = 0; + const fetchFn1 = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolveCount1++; + resolve({ id: resolveCount1, variant: 'v1' }); + }, 10); + }) + ); + const fetchFn2 = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolveCount2++; + resolve({ id: resolveCount2, variant: 'v2' }); + }, 10); + }) + ); + + const [result1a, result1b, result1c] = await Promise.all([ + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-dedup', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'v1', + }), + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-dedup', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'v1', + }), + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-dedup', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'v1', + }), + ]); + + const [result2a, result2b] = await Promise.all([ + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-dedup', fetchFn2, { + environmentId: 'env1', + cacheVariant: 'v2', + }), + service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-dedup', fetchFn2, { + environmentId: 'env1', + cacheVariant: 'v2', + }), + ]); + + expect(fetchFn1).toHaveBeenCalledTimes(1); + expect(fetchFn2).toHaveBeenCalledTimes(1); + expect(result1a).toEqual({ id: 1, variant: 'v1' }); + expect(result1b).toEqual({ id: 1, variant: 'v1' }); + expect(result1c).toEqual({ id: 1, variant: 'v1' }); + expect(result2a).toEqual({ id: 1, variant: 'v2' }); + expect(result2b).toEqual({ id: 1, variant: 'v2' }); + }); + }); + + describe('getIfCached', () => { + it('should return undefined for non-existent key', () => { + const result = service.getIfCached(InMemoryLRUCacheStore.WORKFLOW, 'nonexistent'); + + expect(result).toBeUndefined(); + }); + + it('should return cached value without calling fetch', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn = jest.fn().mockResolvedValue({ id: 'abc' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key8', fetchFn, { environmentId: 'env1' }); + + const cached = service.getIfCached(InMemoryLRUCacheStore.WORKFLOW, 'key8'); + + expect(cached).toEqual({ id: 'abc' }); + }); + }); + + describe('invalidate', () => { + it('should remove specific key from cache', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn = jest.fn().mockResolvedValue({ id: 'xyz' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key9', fetchFn, { environmentId: 'env1' }); + + expect(fetchFn).toHaveBeenCalledTimes(1); + + service.invalidate(InMemoryLRUCacheStore.WORKFLOW, 'key9'); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key9', fetchFn, { environmentId: 'env1' }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('should invalidate all variants for a base key', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn1 = jest.fn().mockResolvedValue({ id: '1', variant: 'v1' }); + const fetchFn2 = jest.fn().mockResolvedValue({ id: '2', variant: 'v2' }); + const fetchFnBase = jest.fn().mockResolvedValue({ id: 'base' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate', fetchFnBase, { environmentId: 'env1' }); + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'v1', + }); + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate', fetchFn2, { + environmentId: 'env1', + cacheVariant: 'v2', + }); + + expect(fetchFnBase).toHaveBeenCalledTimes(1); + expect(fetchFn1).toHaveBeenCalledTimes(1); + expect(fetchFn2).toHaveBeenCalledTimes(1); + + service.invalidate(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate'); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate', fetchFnBase, { environmentId: 'env1' }); + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate', fetchFn1, { + environmentId: 'env1', + cacheVariant: 'v1', + }); + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key-invalidate', fetchFn2, { + environmentId: 'env1', + cacheVariant: 'v2', + }); + + expect(fetchFnBase).toHaveBeenCalledTimes(2); + expect(fetchFn1).toHaveBeenCalledTimes(2); + expect(fetchFn2).toHaveBeenCalledTimes(2); + }); + }); + + describe('invalidateAll', () => { + it('should clear entire store', async () => { + featureFlagsService.getFlag.mockResolvedValue(true); + const fetchFn1 = jest.fn().mockResolvedValue({ id: '1' }); + const fetchFn2 = jest.fn().mockResolvedValue({ id: '2' }); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key10', fetchFn1, { environmentId: 'env1' }); + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key11', fetchFn2, { environmentId: 'env1' }); + + expect(fetchFn1).toHaveBeenCalledTimes(1); + expect(fetchFn2).toHaveBeenCalledTimes(1); + + service.invalidateAll(InMemoryLRUCacheStore.WORKFLOW); + + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key10', fetchFn1, { environmentId: 'env1' }); + await service.get(InMemoryLRUCacheStore.WORKFLOW, 'key11', fetchFn2, { environmentId: 'env1' }); + + expect(fetchFn1).toHaveBeenCalledTimes(2); + expect(fetchFn2).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.service.ts b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.service.ts new file mode 100644 index 00000000000..386f38f49b1 --- /dev/null +++ b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.service.ts @@ -0,0 +1,166 @@ +import { Injectable } from '@nestjs/common'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { LRUCache } from 'lru-cache'; +import { FeatureFlagsService } from '../feature-flags'; +import { + CacheStoreDataTypeMap, + InMemoryLRUCacheStore, + STORE_CONFIGS, + StoreConfig, +} from './in-memory-lru-cache.store'; + +type EntityStore = { + cache: LRUCache; + inflightRequests: Map>; + config: StoreConfig; +}; + +type GetOptions = { + environmentId?: string; + organizationId?: string; + skipCache?: boolean; + cacheVariant?: string; +}; + +const STORES = new Map(); + +@Injectable() +export class InMemoryLRUCacheService { + constructor(private featureFlagsService: FeatureFlagsService) {} + + async get( + storeName: TStore, + key: string, + fetchFn: () => Promise, + opts?: GetOptions + ): Promise { + const store = this.getOrCreateStore(storeName); + const isCacheEnabled = await this.isCacheEnabled(store.config, opts); + + if (!isCacheEnabled || opts?.skipCache) { + return fetchFn(); + } + + const effectiveKey = this.resolveKey(key, opts?.cacheVariant); + + const cached = store.cache.get(effectiveKey); + if (cached !== undefined) { + return cached; + } + + const inflightRequest = store.inflightRequests.get(effectiveKey); + if (inflightRequest) { + return inflightRequest; + } + + const fetchPromise = fetchFn() + .then((result) => { + if (result !== null && result !== undefined) { + store.cache.set(effectiveKey, result); + } + + return result; + }) + .finally(() => { + store.inflightRequests.delete(effectiveKey); + }); + + store.inflightRequests.set(effectiveKey, fetchPromise); + + return fetchPromise; + } + + getIfCached( + storeName: TStore, + key: string + ): CacheStoreDataTypeMap[TStore] | undefined { + const store = STORES.get(storeName); + if (!store) { + return undefined; + } + + const keyValue = store.cache.get(key) as CacheStoreDataTypeMap[TStore] | undefined; + + return keyValue; + } + + invalidate(storeName: InMemoryLRUCacheStore, key: string): void { + const store = STORES.get(storeName); + if (!store) { + return; + } + + for (const cacheKey of store.cache.keys()) { + if (cacheKey === key || cacheKey.startsWith(`${key}:v:`)) { + store.cache.delete(cacheKey); + } + } + } + + invalidateAll(storeName: InMemoryLRUCacheStore): void { + const store = STORES.get(storeName); + if (store) { + store.cache.clear(); + store.inflightRequests.clear(); + } + } + + set( + storeName: TStore, + key: string, + value: CacheStoreDataTypeMap[TStore] + ): void { + const store = this.getOrCreateStore(storeName); + store.cache.set(key, value); + } + + private resolveKey(key: string, cacheVariant?: string): string { + return cacheVariant ? `${key}:v:${cacheVariant}` : key; + } + + private getOrCreateStore(storeName: InMemoryLRUCacheStore): EntityStore { + let store = STORES.get(storeName) as EntityStore | undefined; + + if (!store) { + const config = STORE_CONFIGS[storeName]; + + store = { + cache: new LRUCache({ + max: config.max, + ttl: config.ttl, + }), + inflightRequests: new Map>(), + config, + }; + STORES.set(storeName, store as EntityStore); + } + + return store; + } + + private async isCacheEnabled(config: StoreConfig, opts?: GetOptions): Promise { + if (config.skipFeatureFlag) { + return true; + } + + if (!opts?.environmentId && !opts?.organizationId) { + return false; + } + + try { + const flagContext = { + key: FeatureFlagsKeysEnum.IS_LRU_CACHE_ENABLED, + defaultValue: false, + component: config.featureFlagComponent, + ...(opts.environmentId && { environment: { _id: opts.environmentId } }), + ...(opts.organizationId && { organization: { _id: opts.organizationId } }), + }; + + const flag = await this.featureFlagsService.getFlag(flagContext); + + return flag; + } catch { + return false; + } + } +} diff --git a/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts new file mode 100644 index 00000000000..68159a8b66f --- /dev/null +++ b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts @@ -0,0 +1,68 @@ +import type { EnvironmentEntity, NotificationTemplateEntity, OrganizationEntity } from '@novu/dal'; +import type { UserSessionData } from '@novu/shared'; + +export enum InMemoryLRUCacheStore { + WORKFLOW = 'workflow', + ORGANIZATION = 'organization', + ENVIRONMENT = 'environment', + API_KEY_USER = 'api-key-user', + VALIDATOR = 'validator', + ACTIVE_WORKFLOWS = 'active-workflows', +} + +export type WorkflowCacheData = NotificationTemplateEntity | null; +export type OrganizationCacheData = OrganizationEntity | null; +export type EnvironmentCacheData = Pick | null; +export type ApiKeyUserCacheData = UserSessionData | null; +export type ValidatorCacheData = unknown; +export type ActiveWorkflowsCacheData = NotificationTemplateEntity[]; + +export type CacheStoreDataTypeMap = { + [InMemoryLRUCacheStore.WORKFLOW]: WorkflowCacheData; + [InMemoryLRUCacheStore.ORGANIZATION]: OrganizationCacheData; + [InMemoryLRUCacheStore.ENVIRONMENT]: EnvironmentCacheData; + [InMemoryLRUCacheStore.API_KEY_USER]: ApiKeyUserCacheData; + [InMemoryLRUCacheStore.VALIDATOR]: ValidatorCacheData; + [InMemoryLRUCacheStore.ACTIVE_WORKFLOWS]: ActiveWorkflowsCacheData; +}; + +export type StoreConfig = { + max: number; + ttl: number; + featureFlagComponent: string; + skipFeatureFlag?: boolean; +}; + +export const STORE_CONFIGS: Record = { + [InMemoryLRUCacheStore.WORKFLOW]: { + max: 1000, + ttl: 1000 * 30, + featureFlagComponent: 'workflow', + }, + [InMemoryLRUCacheStore.ORGANIZATION]: { + max: 500, + ttl: 1000 * 60, + featureFlagComponent: 'organization', + }, + [InMemoryLRUCacheStore.ENVIRONMENT]: { + max: 500, + ttl: 1000 * 60, + featureFlagComponent: 'environment', + }, + [InMemoryLRUCacheStore.API_KEY_USER]: { + max: 1000, + ttl: 1000 * 60, + featureFlagComponent: 'api-key-user', + }, + [InMemoryLRUCacheStore.VALIDATOR]: { + max: 5000, + ttl: 1000 * 60 * 60, + featureFlagComponent: 'validator', + skipFeatureFlag: true, + }, + [InMemoryLRUCacheStore.ACTIVE_WORKFLOWS]: { + max: 300, + ttl: 1000 * 60, + featureFlagComponent: 'active-workflows', + }, +}; diff --git a/libs/application-generic/src/services/in-memory-lru-cache/index.ts b/libs/application-generic/src/services/in-memory-lru-cache/index.ts new file mode 100644 index 00000000000..36cfe1f49e0 --- /dev/null +++ b/libs/application-generic/src/services/in-memory-lru-cache/index.ts @@ -0,0 +1,2 @@ +export * from './in-memory-lru-cache.service'; +export * from './in-memory-lru-cache.store'; diff --git a/libs/application-generic/src/services/index.ts b/libs/application-generic/src/services/index.ts index efed94abcd7..8b900e2131f 100644 --- a/libs/application-generic/src/services/index.ts +++ b/libs/application-generic/src/services/index.ts @@ -19,6 +19,7 @@ export * from './cloudflare-scheduler'; export * from './content.service'; export * from './cron'; export * from './feature-flags'; +export * from './in-memory-lru-cache'; export * from './in-memory-provider'; export { MessageInteractionResult, 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 a9dcc311c8a..5caa86fbcdb 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 @@ -46,7 +46,15 @@ export class GetPreferences { @InstrumentUsecase() async execute(command: GetPreferencesCommand): Promise { - const items = await this.getPreferencesFromDb(command); + const useOptimizedFetch = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_PREFERENCE_FETCH_OPTIMIZATION_ENABLED, + defaultValue: false, + organization: { _id: command.organizationId }, + }); + + const items = useOptimizedFetch + ? await this.getPreferencesFromDbOptimized(command) + : await this.getPreferencesFromDb(command); const mergedPreferences = MergePreferences.execute( MergePreferencesCommand.create({ @@ -231,4 +239,77 @@ export class GetPreferences { return result; } + + @Instrument() + private async getPreferencesFromDbOptimized(command: GetPreferencesCommand): Promise { + const baseQuery = { + _environmentId: command.environmentId, + _organizationId: command.organizationId, + }; + + const queryOptions = { readPreference: 'secondaryPreferred' as const }; + + const orConditions: Array> = [ + { + _templateId: command.templateId, + type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW] }, + }, + ]; + + if (command.subscriberId) { + const useContextFiltering = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, + defaultValue: false, + organization: { _id: command.organizationId }, + }); + + const contextQuery = this.preferencesRepository.buildContextExactMatchQuery(command.contextKeys, { + enabled: useContextFiltering, + }); + + orConditions.push( + { + _subscriberId: command.subscriberId, + _templateId: command.templateId, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ...contextQuery, + }, + { + _subscriberId: command.subscriberId, + type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ...contextQuery, + } + ); + } + + const allPreferences = await this.preferencesRepository.find( + { + ...baseQuery, + $or: orConditions, + }, + undefined, + queryOptions + ); + + const result: PreferenceSet = {}; + + for (const preference of allPreferences) { + switch (preference.type) { + case PreferencesTypeEnum.WORKFLOW_RESOURCE: + result.workflowResourcePreference = preference as PreferenceSet['workflowResourcePreference']; + break; + case PreferencesTypeEnum.USER_WORKFLOW: + result.workflowUserPreference = preference as PreferenceSet['workflowUserPreference']; + break; + case PreferencesTypeEnum.SUBSCRIBER_WORKFLOW: + result.subscriberWorkflowPreference = preference as PreferenceSet['subscriberWorkflowPreference']; + break; + case PreferencesTypeEnum.SUBSCRIBER_GLOBAL: + result.subscriberGlobalPreference = preference as PreferenceSet['subscriberGlobalPreference']; + break; + } + } + + return result; + } } 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 03dd2d2a4f0..f2fd7deb57d 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,7 +9,6 @@ import { } from '@novu/dal'; import { AddressingTypeEnum, - FeatureFlagsKeysEnum, ISubscribersDefine, ITenantDefine, TriggerRecipientSubscriber, @@ -17,13 +16,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, RequestTraceInput } 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 { InMemoryLRUCacheService, InMemoryLRUCacheStore } from '../../services/in-memory-lru-cache'; import { CreateOrUpdateSubscriberCommand, CreateOrUpdateSubscriberUseCase } from '../create-or-update-subscriber'; import { ProcessTenant, ProcessTenantCommand } from '../process-tenant'; import { TriggerBroadcastCommand } from '../trigger-broadcast/trigger-broadcast.command'; @@ -36,13 +35,6 @@ 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( @@ -57,7 +49,8 @@ export class TriggerEvent { private traceLogRepository: TraceLogRepository, private contextRepository: ContextRepository, private verifyPayload: VerifyPayload, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private inMemoryLRUCacheService: InMemoryLRUCacheService ) { this.logger.setContext(this.constructor.name); } @@ -376,50 +369,16 @@ export class TriggerEvent { 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; + return this.inMemoryLRUCacheService.get( + InMemoryLRUCacheStore.WORKFLOW, + `${environmentId}:${triggerIdentifier}`, + () => this.notificationTemplateRepository.findByTriggerIdentifier(environmentId, triggerIdentifier), + { + environmentId, + organizationId, + skipCache: !!source, } - } - - 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() diff --git a/packages/js/CHANGELOG.md b/packages/js/CHANGELOG.md index 8649b461667..5c6e129a4ee 100644 --- a/packages/js/CHANGELOG.md +++ b/packages/js/CHANGELOG.md @@ -1,3 +1,15 @@ +## v3.14.0 (2026-02-12) + +### 🚀 Features + +- **js, react, api-service:** In-app notifications timeframe filter fixes NV-7045 ([#9873](https://github.com/novuhq/novu/pull/9873)) +- **js:** allow passing socket options to the novu js configuration ([#9896](https://github.com/novuhq/novu/pull/9896)) + +### ❤️ Thank You + +- Dima Grossman @scopsy +- Gabriel Pan Gantes @Gabrielpanga + ## v3.13.0 (2026-01-28) ### 🚀 Features diff --git a/packages/js/package.json b/packages/js/package.json index 40084d9dc25..215c817a9ff 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,6 +1,6 @@ { "name": "@novu/js", - "version": "3.13.0", + "version": "3.14.0", "repository": "https://github.com/novuhq/novu", "description": "Novu JavaScript SDK for ", "author": "", diff --git a/packages/nextjs/CHANGELOG.md b/packages/nextjs/CHANGELOG.md index af8108245ab..77ebafe334f 100644 --- a/packages/nextjs/CHANGELOG.md +++ b/packages/nextjs/CHANGELOG.md @@ -1,3 +1,7 @@ +## v3.14.0 (2026-02-12) + +This was a version bump only for @novu/nextjs to align it with other projects, there were no code changes. + ## v3.13.0 (2026-01-28) This was a version bump only for @novu/nextjs to align it with other projects, there were no code changes. diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index f683b2a68b3..2e62dd09d8e 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@novu/nextjs", - "version": "3.13.0", + "version": "3.14.0", "repository": { "type": "git", "url": "https://github.com/novuhq/novu", diff --git a/packages/react-native/CHANGELOG.md b/packages/react-native/CHANGELOG.md index 9e11e513301..209ef1d0cb0 100644 --- a/packages/react-native/CHANGELOG.md +++ b/packages/react-native/CHANGELOG.md @@ -1,3 +1,7 @@ +## v3.14.0 (2026-02-12) + +This was a version bump only for @novu/react-native to align it with other projects, there were no code changes. + ## v3.13.0 (2026-01-28) This was a version bump only for @novu/react-native to align it with other projects, there were no code changes. diff --git a/packages/react-native/package.json b/packages/react-native/package.json index c6534580f2d..eb869b52080 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@novu/react-native", - "version": "3.13.0", + "version": "3.14.0", "repository": "https://github.com/novuhq/novu", "description": "Novu's React Native SDK for building custom inbox notification experiences", "author": "", diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 20f3b971f87..a4a988c049b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,3 +1,18 @@ +## v3.14.0 (2026-02-12) + +### 🚀 Features + +- **js, react, api-service:** In-app notifications timeframe filter fixes NV-7045 ([#9873](https://github.com/novuhq/novu/pull/9873)) + +### 🩹 Fixes + +- **api-service:** add support of dot in workflow id fixes NV-7092 ([#9974](https://github.com/novuhq/novu/pull/9974)) + +### ❤️ Thank You + +- Dima Grossman @scopsy +- Pawan Jain + ## v3.13.0 (2026-01-28) This was a version bump only for @novu/react to align it with other projects, there were no code changes. diff --git a/packages/react/package.json b/packages/react/package.json index 76ccd4a5aad..3ca8719bc60 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@novu/react", - "version": "3.13.0", + "version": "3.14.0", "repository": { "type": "git", "url": "https://github.com/novuhq/novu", diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index efa5191b1b8..99dba662a6a 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -77,6 +77,7 @@ export enum FeatureFlagsKeysEnum { IS_SUBSCRIPTION_PREFERENCES_ENABLED = 'IS_SUBSCRIPTION_PREFERENCES_ENABLED', IS_LRU_CACHE_ENABLED = 'IS_LRU_CACHE_ENABLED', IS_CONTEXT_PREFERENCES_ENABLED = 'IS_CONTEXT_PREFERENCES_ENABLED', + IS_PREFERENCE_FETCH_OPTIMIZATION_ENABLED = 'IS_PREFERENCE_FETCH_OPTIMIZATION_ENABLED', IS_ANALYTIC_V2_LOGS_READ_GLOBAL_ENABLED = 'IS_ANALYTIC_V2_LOGS_READ_GLOBAL_ENABLED', IS_ANALYTIC_V2_MESSAGE_DELIVERY_READ_ENABLED = 'IS_ANALYTIC_V2_MESSAGE_DELIVERY_READ_ENABLED', IS_ANALYTIC_V2_ACTIVE_SUBSCRIBER_TREND_READ_ENABLED = 'IS_ANALYTIC_V2_ACTIVE_SUBSCRIBER_TREND_READ_ENABLED',