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
Prev Previous commit
Merge branch 'next' into add-debug-logs-for-get-usage-usecase
  • Loading branch information
djabarovgeorge committed Jan 20, 2026
commit 1781e92c226cafb0fbe94a45ce90345808ef9de8
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@
>
<img src="https://img.shields.io/npm/v/@novu/react" alt="NPM">
</a>
<!-- TODO: Replace this with @novu/api as soon as the NPM download traffic switches to the new package -->
<a href="https://www.npmjs.com/package/@novu/node" target="_blank" rel="noopener noreferrer"
<a href="https://www.npmjs.com/package/@novu/react" target="_blank" rel="noopener noreferrer"
>
<img src="https://img.shields.io/npm/dm/@novu/node" alt="npm downloads">
<img src="https://img.shields.io/npm/dm/@novu/react" alt="npm downloads">
</a>
</p>

Expand Down Expand Up @@ -64,6 +63,8 @@
·
<a href="https://novu.co/contact-us/?utm_campaign=github-readme" target="_blank" rel="noopener noreferrer"
>Contact us</a>
.
<a href="https://www.recent.dev">Recent.dev</a>
</p>

## ⭐️ Why Novu?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (G
});

it('should isolate preferences per context', async () => {
// Set global preference
// Set global preference for context B
await novuClient.subscribers.preferences.update(
{
channels: { email: false, inApp: false },
context: { tenant: 'globex' },
},
subscriber.subscriberId
);
Expand All @@ -175,20 +176,21 @@ describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (G
subscriber.subscriberId
);

// List with context A
// List with context A - should see workflow override and default global
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
expect(responseA.result.global.channels.email).to.equal(true); // No global set for this context, uses default

// List with context B (should see global)
// List with context B - should see the global preference set for this context
const responseB = await novuClient.subscribers.preferences.list({
subscriberId: subscriber.subscriberId,
contextKeys: ['tenant:globex'],
});
expect(responseB.result.workflows[0].channels.email).to.equal(false); // Inherits global
expect(responseB.result.global.channels.email).to.equal(false); // Global preference for tenant:globex
expect(responseB.result.workflows[0].channels.email).to.equal(false); // Inherits from global
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,23 +316,6 @@ describe('Patch Subscriber Preferences - /subscribers/:subscriberId/preferences
expect(responseB.result.workflows[0].channels.email).to.equal(true);
});

it('should reject context for global preferences', async () => {
const patchData: PatchSubscriberPreferencesDto = {
// No workflowId = global preference
channels: {
email: false,
},
context: { tenant: 'acme' }, // Should be rejected
};

const { error } = await expectSdkExceptionGeneric(() =>
novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId)
);

expect(error?.statusCode).to.equal(400);
expect(error?.message).to.include('Context cannot be used with global preferences');
});

it('should bulk update with context', async () => {
const bulkUpdateData: BulkUpdateSubscriberPreferencesDto = {
context: { tenant: 'acme' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class GetSubscriberPreferences {
environmentId: command.environmentId,
subscriberId: command.subscriberId,
includeInactiveChannels: false,
contextKeys: command.contextKeys,
})
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { FeatureFlagsService, GetWorkflowByIdsCommand, GetWorkflowByIdsUseCase } from '@novu/application-generic';
import { ContextRepository } from '@novu/dal';
import { ContextPayload, FeatureFlagsKeysEnum, PreferenceLevelEnum, WorkflowCriticalityEnum } from '@novu/shared';
Expand All @@ -20,8 +20,6 @@ export class UpdateSubscriberPreferences {
) {}

async execute(command: UpdateSubscriberPreferencesCommand): Promise<GetSubscriberPreferencesDto> {
this.validateContextRequiresWorkflow(command);

const contextKeys = await this.resolveContexts(command.environmentId, command.organizationId, command.context);

let workflowId: string | undefined;
Expand Down Expand Up @@ -64,14 +62,6 @@ export class UpdateSubscriberPreferences {
});
}

private validateContextRequiresWorkflow(command: UpdateSubscriberPreferencesCommand): void {
if (command.context && !command.workflowIdOrInternalId) {
throw new BadRequestException(
'Context cannot be used with global preferences. Please provide a workflowId to update workflow preferences with context.'
);
}
}

private async resolveContexts(
environmentId: string,
organizationId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class GetSubscriberGlobalPreference {
environmentId: command.environmentId,
organizationId: command.organizationId,
subscriberId: subscriber._id,
contextKeys: command.contextKeys,
});

const channelsWithDefaults = this.buildDefaultPreferences(subscriberGlobalPreference.channels);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export class GetSubscriberPreference {
...baseQuery,
_subscriberId: subscriberId,
type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL,
...contextQuery,
},
undefined,
readOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class GetPreferences {
environmentId: string;
organizationId: string;
subscriberId: string;
contextKeys?: string[];
}): Promise<{
enabled: boolean;
channels: IPreferenceChannels;
Expand Down Expand Up @@ -186,6 +187,7 @@ export class GetPreferences {
...baseQuery,
_subscriberId: command.subscriberId,
type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL,
...contextQuery,
},
undefined,
queryOptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export class UpsertPreferences {
type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL,
returnPreference: command.returnPreference,
schedule: isSubscribersScheduleEnabled ? command.schedule : undefined,
contextKeys: command.contextKeys,
});
}

Expand Down Expand Up @@ -176,6 +177,7 @@ export class UpsertPreferences {
const isContextScoped = [
PreferencesTypeEnum.SUBSCRIBER_WORKFLOW,
PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW,
PreferencesTypeEnum.SUBSCRIBER_GLOBAL,
].includes(command.type);

return await this.preferencesRepository.create({
Expand Down Expand Up @@ -258,11 +260,7 @@ export class UpsertPreferences {
organizationId: string
): Promise<Record<string, unknown>> {
// Non-context-scoped types (universal/workflow-level) - no context filter
const nonContextScopedTypes = [
PreferencesTypeEnum.WORKFLOW_RESOURCE,
PreferencesTypeEnum.USER_WORKFLOW,
PreferencesTypeEnum.SUBSCRIBER_GLOBAL,
];
const nonContextScopedTypes = [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW];

if (nonContextScopedTypes.includes(type)) {
return {};
Expand Down
4 changes: 3 additions & 1 deletion libs/dal/src/repositories/preferences/preferences.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,16 @@ preferencesSchema.plugin(mongooseDelete, {
});

// Subscriber Global Preferences
// Ensures one global preference per subscriber (SUBSCRIBER_GLOBAL type)
// Ensures one global preference per subscriber per context (SUBSCRIBER_GLOBAL type)
// Includes contextKeys to allow multiple preferences for different contexts
// Partial filter ensures this only applies to SUBSCRIBER_GLOBAL type,
// preventing conflicts with other preference types
preferencesSchema.index(
{
_environmentId: 1,
_subscriberId: 1,
type: 1,
contextKeys: 1,
},
{
unique: true,
Expand Down
6 changes: 6 additions & 0 deletions packages/js/src/ui/helpers/browser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export function requestLock(id: string, cb: (id: string) => void) {
if (typeof navigator === 'undefined' || !('locks' in navigator) || !navigator.locks) {
cb(id);

return () => {};
}

let isFulfilled = false;
let promiseResolve: () => void;

Expand Down
Loading
You are viewing a condensed version of this merge commit. You can view the full changes here.