Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(api-service): context bound preferences (admin facing API)
  • Loading branch information
ChmaraX committed Jan 9, 2026
commit 5f9b46bb09d0794dcd36ad456d18a0341c02263d
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"generate:sdk": " (cd ../../libs/internal-sdk && speakeasy run --skip-compile --minimal --skip-versioning) && (cd ../../libs/internal-sdk && pnpm build) ",
"test": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' NODE_ENV=test NOVU_ENTERPRISE=true CLERK_ENABLED=true mocha --timeout 15000 --require ts-node/register --exit 'src/**/*.spec.ts'",
"test:e2e:novu-v0": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' NODE_ENV=test mocha --timeout 15000 --retries 3 --grep '#novu-v0' --require ts-node/register --exit --file e2e/setup.ts src/**/*.e2e{,-ee}.ts",
"test:e2e:novu-v2": "cross-env NODE_ENV=test CI_EE_TEST=true CLERK_ENABLED=true NODE_OPTIONS=--max_old_space_size=8192 mocha --timeout 30000 --retries 3 --grep '#novu-v2' --require ./swc-register.js --exit --file e2e/setup.ts 'src/**/*.e2e{,-ee}.ts' 'e2e/enterprise/**/*.e2e.ts'",
"test:e2e:novu-v2": "cross-env NODE_ENV=test CI_EE_TEST=true CLERK_ENABLED=true NODE_OPTIONS=--max_old_space_size=8192 mocha --timeout 30000 --retries 3 --grep '#novu-v2' --require ./swc-register.js --exit --file e2e/setup.ts 'src/**/subscribers-v2/e2e/**/*.e2e{,-ee}.ts' 'e2e/enterprise/**/subscribers-v2/e2e/**/*.e2e.ts'",
"migration": "cross-env NODE_ENV=local MIGRATION=true ts-node --transpileOnly",
"clickhouse:migrate:local": "clickhouse-migrations migrate --host=http://localhost:8123 --user=default --password= --db=novu-local --migrations-home=./migrations/clickhouse-migrations",
"clickhouse:migrate:prod": "clickhouse-migrations migrate --migrations-home=./migrations/clickhouse-migrations",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// apps/api/src/app/inbox/usecases/bulk-update-preferences/bulk-update-preferences.command.ts

import { IsValidContextPayload } from '@novu/application-generic';
import { ContextPayload } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsArray, IsDefined } from 'class-validator';
import { IsArray, IsDefined, IsOptional } from 'class-validator';

import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command';
import { BulkUpdatePreferenceItemDto } from '../../dtos/bulk-update-preferences-request.dto';
Expand All @@ -11,4 +13,8 @@ export class BulkUpdatePreferencesCommand extends EnvironmentWithSubscriber {
@IsArray()
@Type(() => BulkUpdatePreferenceItemDto)
readonly preferences: BulkUpdatePreferenceItemDto[];

@IsOptional()
@IsValidContextPayload({ maxCount: 5 })
readonly context?: ContextPayload;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { AnalyticsService, InstrumentUsecase } from '@novu/application-generic';
import { AnalyticsService, FeatureFlagsService, InstrumentUsecase } from '@novu/application-generic';
import {
BaseRepository,
ContextRepository,
EnvironmentRepository,
NotificationTemplateEntity,
NotificationTemplateRepository,
SubscriberRepository,
} from '@novu/dal';
import { PreferenceLevelEnum } from '@novu/shared';
import { ContextPayload, FeatureFlagsKeysEnum, PreferenceLevelEnum } from '@novu/shared';
import { BulkUpdatePreferenceItemDto } from '../../dtos/bulk-update-preferences-request.dto';
import { AnalyticsEventsEnum } from '../../utils';
import { InboxPreference } from '../../utils/types';
Expand All @@ -24,11 +25,15 @@ export class BulkUpdatePreferences {
private subscriberRepository: SubscriberRepository,
private analyticsService: AnalyticsService,
private updatePreferencesUsecase: UpdatePreferences,
private environmentRepository: EnvironmentRepository
private environmentRepository: EnvironmentRepository,
private contextRepository: ContextRepository,
private featureFlagsService: FeatureFlagsService
) {}

@InstrumentUsecase()
async execute(command: BulkUpdatePreferencesCommand): Promise<InboxPreference[]> {
const contextKeys = await this.resolveContexts(command.environmentId, command.organizationId, command.context);

const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId);
if (!subscriber) throw new NotFoundException(`Subscriber with id: ${command.subscriberId} is not found`);

Expand Down Expand Up @@ -102,7 +107,7 @@ export class BulkUpdatePreferences {
organizationId: command.organizationId,
subscriberId: command.subscriberId,
environmentId: command.environmentId,
contextKeys: command.contextKeys,
contextKeys,
level: PreferenceLevelEnum.TEMPLATE,
subscriptionIdentifier: preference.subscriptionIdentifier,
...(isUpdatingSubscriptionPreference && {
Expand Down Expand Up @@ -131,4 +136,33 @@ export class BulkUpdatePreferences {

return updatedPreferences;
}

private async resolveContexts(
environmentId: string,
organizationId: string,
context?: ContextPayload
): Promise<string[] | undefined> {
// Check if context preferences feature is enabled
const isEnabled = await this.featureFlagsService.getFlag({
key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED,
defaultValue: false,
organization: { _id: organizationId },
});

if (!isEnabled) {
return undefined; // Ignore context when FF is off
}

if (!context) {
return [];
}

const contexts = await this.contextRepository.findOrCreateContextsFromPayload(
environmentId,
organizationId,
context
);

return contexts.map((ctx) => ctx.key);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { parseSlugId } from '@novu/application-generic';
import { IsValidContextPayload, parseSlugId } from '@novu/application-generic';
import { ContextPayload } from '@novu/shared';
import { Transform, Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsDefined, IsString, ValidateNested } from 'class-validator';
import { ArrayMaxSize, IsArray, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator';
import { ApiContextPayload } from '../../shared/framework/swagger';
import { PatchPreferenceChannelsDto } from './patch-subscriber-preferences.dto';

export class BulkUpdateSubscriberPreferenceItemDto {
Expand Down Expand Up @@ -30,4 +32,9 @@ export class BulkUpdateSubscriberPreferencesDto {
@Type(() => BulkUpdateSubscriberPreferenceItemDto)
@ValidateNested({ each: true })
readonly preferences: BulkUpdateSubscriberPreferenceItemDto[];

@ApiContextPayload()
@IsOptional()
@IsValidContextPayload({ maxCount: 5 })
context?: ContextPayload;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { WorkflowCriticalityEnum } from '@novu/shared';
import { IsEnum, IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator';

export class GetSubscriberPreferencesRequestDto {
@IsEnum(WorkflowCriticalityEnum)
Expand All @@ -10,4 +11,25 @@ export class GetSubscriberPreferencesRequestDto {
default: WorkflowCriticalityEnum.NON_CRITICAL,
})
criticality?: WorkflowCriticalityEnum = WorkflowCriticalityEnum.NON_CRITICAL;

@IsOptional()
@Transform(({ value }) => {
// No parameter = no filter
if (value === undefined) return undefined;

// Empty string = filter for records with no (default) context
if (value === '') return [];

// Normalize to array and remove empty strings
const array = Array.isArray(value) ? value : [value];
return array.filter((v) => v !== '');
})
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
description: 'Context keys for filtering preferences (e.g., ["tenant:acme"])',
type: [String],
example: ['tenant:acme'],
})
contextKeys?: string[];
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { parseSlugId } from '@novu/application-generic';
import { IPreferenceChannels } from '@novu/shared';
import { IsValidContextPayload, parseSlugId } from '@novu/application-generic';
import { ContextPayload, IPreferenceChannels } from '@novu/shared';
import { Transform, Type } from 'class-transformer';
import { IsOptional, ValidateNested } from 'class-validator';
import { ScheduleDto } from '../../shared/dtos/schedule';
import { ApiContextPayload } from '../../shared/framework/swagger';

export class PatchPreferenceChannelsDto implements IPreferenceChannels {
@ApiProperty({ description: 'Email channel preference' })
Expand Down Expand Up @@ -41,4 +42,9 @@ export class PatchSubscriberPreferencesDto {
@ValidateNested()
@Type(() => ScheduleDto)
schedule?: ScheduleDto;

@ApiContextPayload()
@IsOptional()
@IsValidContextPayload({ maxCount: 5 })
context?: ContextPayload;
}
113 changes: 109 additions & 4 deletions apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
let workflow: NotificationTemplateEntity;

beforeEach(async () => {
(process.env as any).IS_CONTEXT_PREFERENCES_ENABLED = 'true';
const uuid = randomBytes(4).toString('hex');
session = new UserSession();
await session.initialize();
Expand All @@ -24,8 +25,12 @@
});
});

afterEach(() => {
delete (process.env as any).IS_CONTEXT_PREFERENCES_ENABLED;
});

it('should fetch subscriber preferences with default values', async () => {
const response = await novuClient.subscribers.preferences.list(subscriber.subscriberId);
const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId });

const { global, workflows } = response.result;

Expand All @@ -37,7 +42,7 @@
it('should return 404 if subscriber does not exist', async () => {
const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`;
const { error } = await expectSdkExceptionGeneric(() =>
novuClient.subscribers.preferences.list(invalidSubscriberId)
novuClient.subscribers.preferences.list({ subscriberId: invalidSubscriberId })
);

expect(error?.statusCode).to.equal(404);
Expand All @@ -48,7 +53,7 @@
const workflow2 = await session.createTemplate({ noFeedId: true });
const workflow3 = await session.createTemplate({ noFeedId: true });

const response = await novuClient.subscribers.preferences.list(subscriber.subscriberId);
const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId });

const { workflows } = response.result;

Expand All @@ -75,7 +80,7 @@
const newWorkflow = await session.createTemplate({ noFeedId: true });

// Check preferences
const response = await novuClient.subscribers.preferences.list(subscriber.subscriberId);
const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId });

const { workflows } = response.result;

Expand All @@ -85,6 +90,106 @@
// New workflow should inherit global settings
expect(newWorkflowPreferences?.channels).to.deep.equal({ email: false, inApp: true });
});

it('should filter preferences by contextKeys', async () => {
// Create preference for context A
await novuClient.subscribers.preferences.update(
{
workflowId: workflow._id,
channels: { email: false },
context: { tenant: 'acme' },
},
subscriber.subscriberId
);

// Create preference for context B
const workflow2 = await session.createTemplate({ noFeedId: true });
await novuClient.subscribers.preferences.update(
{
workflowId: workflow2._id,
channels: { email: false },
context: { tenant: 'globex' },

Check warning on line 111 in apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (globex)
},
subscriber.subscriberId
);

// List with context A filter
const responseA = await novuClient.subscribers.preferences.list({
subscriberId: subscriber.subscriberId,
contextKeys: ['tenant:acme'],
});

// Should return BOTH workflows (all workflows always returned regardless of context)
const workflowIdentifiers = responseA.result.workflows.map((w) => w.workflow.identifier);
expect(workflowIdentifiers).to.include(workflow.triggers[0].identifier);
expect(workflowIdentifiers).to.include(workflow2.triggers[0].identifier);

// workflow1 uses tenant:acme preference (email: false)
const wf1 = responseA.result.workflows.find((w) => w.workflow.identifier === workflow.triggers[0].identifier);
expect(wf1?.channels.email).to.equal(false);

// workflow2 falls back to global/default (email: true by default)
const wf2 = responseA.result.workflows.find((w) => w.workflow.identifier === workflow2.triggers[0].identifier);
expect(wf2?.channels.email).to.equal(true);
});

it('should return default preferences when no context-specific preference exists', async () => {
// Create workflow preference for context A
await novuClient.subscribers.preferences.update(
{
workflowId: workflow._id,
channels: { email: false },
context: { tenant: 'acme' },
},
subscriber.subscriberId
);

// List with different context B (no specific preference exists)
const response = await novuClient.subscribers.preferences.list({
subscriberId: subscriber.subscriberId,
contextKeys: ['tenant:globex'],

Check warning on line 150 in apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (globex)
});

// Should return workflow with default/inherited settings
expect(response.result.workflows).to.have.lengthOf(1);
// Default should be enabled
expect(response.result.workflows[0].channels.email).to.equal(true);
});

it('should isolate preferences per context', async () => {
// Set global preference
await novuClient.subscribers.preferences.update(
{
channels: { email: false, inApp: false },
},
subscriber.subscriberId
);

// Create workflow preference for context A (override email)
await novuClient.subscribers.preferences.update(
{
workflowId: workflow._id,
channels: { email: true }, // Override to true
context: { tenant: 'acme' },
},
subscriber.subscriberId
);

// List with context A
const responseA = await novuClient.subscribers.preferences.list({
subscriberId: subscriber.subscriberId,
contextKeys: ['tenant:acme'],
});
expect(responseA.result.workflows[0].channels.email).to.equal(true);
expect(responseA.result.global.channels.email).to.equal(false); // Global unchanged

// List with context B (should see global)
const responseB = await novuClient.subscribers.preferences.list({
subscriberId: subscriber.subscriberId,
contextKeys: ['tenant:globex'],

Check warning on line 189 in apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts

View workflow job for this annotation

GitHub Actions / Spell check

Unknown word (globex)
});
expect(responseB.result.workflows[0].channels.email).to.equal(false); // Inherits global
});
});

async function createSubscriberAndValidate(id: string = '') {
Expand Down
Loading
Loading