Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
9 changes: 5 additions & 4 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@
"pubid",
"PUID",
"pulumi",
"pulsecron",
"Pushpad",
"Pushwoosh",
"pychecker",
Expand Down Expand Up @@ -851,11 +852,11 @@
"*.riv",
"*/**/.vscode/settings.json",
"*/**/CHANGELOG.md",
"apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts",
"**/*.e2e.ts",
"**/*.spec.ts",
"angular.json",
"apps/api/src/.env.test",
"apps/api/src/app/analytics/usecases/hubspot-identify-form/hubspot-identify-form.usecase.ts",
"apps/api/src/app/translations/e2e/v2/**/*",
"apps/api/src/app/workflows-v2/maily-test-data.ts",
"apps/api/src/app/workflows-v2/usecases/validate-content/validate-placeholders/validate-placeholder.usecase.ts",
"apps/api/src/app/workflows-v2/util/json-schema-mock.ts",
Expand Down Expand Up @@ -887,7 +888,7 @@
"apps/dashboard/src/components/variable/constants.ts",
"apps/dashboard/src/utils/locales.ts",
"apps/dashboard/src/components/primitives/constants.ts",
"enterprise/workers/socket/worker-configuration.d.ts",
"enterprise/workers/scheduler/worker-configuration.d.ts",
"enterprise/workers/socket/worker-configuration.d.ts",
"enterprise/workers/scheduler/worker-configuration.d.ts"
]
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { LayoutEntity, LayoutRepository } from '@novu/dal';

import { PromoteTypeChangeCommand } from '../promote-type-change.command';
Expand All @@ -8,14 +8,19 @@ export class PromoteLayoutChange {
constructor(private layoutRepository: LayoutRepository) {}

async execute(command: PromoteTypeChangeCommand) {
const itemId = command.item._id;
if (!itemId) {
throw new BadRequestException('Item must have an _id to promote layout change');
}

let item = await this.layoutRepository.findOne({
_environmentId: command.environmentId,
_parentId: command.item._id,
_parentId: itemId,
});

// For the scenario where the layout is deleted and an active default layout change was pending
if (!item) {
item = await this.layoutRepository.findDeletedByParentId(command.item._id, command.environmentId);
item = await this.layoutRepository.findDeletedByParentId(itemId, command.environmentId);
}

const newItem = command.item as LayoutEntity;
Expand All @@ -41,7 +46,7 @@ export class PromoteLayoutChange {

const count = await this.layoutRepository.count({
_organizationId: command.organizationId,
_id: command.item._id,
_id: itemId,
});

if (count === 0) {
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,6 +1,11 @@
import { BadRequestException, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { AnalyticsService } from '@novu/application-generic';
import { EnvironmentRepository, NotificationTemplateRepository, SubscriberRepository } from '@novu/dal';
import { AnalyticsService, FeatureFlagsService } from '@novu/application-generic';
import {
ContextRepository,
EnvironmentRepository,
NotificationTemplateRepository,
SubscriberRepository,
} from '@novu/dal';
import { PreferenceLevelEnum, TriggerTypeEnum } from '@novu/shared';
import { expect } from 'chai';
import sinon from 'sinon';
Expand Down Expand Up @@ -80,18 +85,26 @@ describe('BulkUpdatePreferences', () => {
let notificationTemplateRepositoryMock: sinon.SinonStubbedInstance<NotificationTemplateRepository>;
let updatePreferencesUsecaseMock: sinon.SinonStubbedInstance<UpdatePreferences>;
let environmentRepositoryMock: sinon.SinonStubbedInstance<EnvironmentRepository>;
let contextRepositoryMock: sinon.SinonStubbedInstance<ContextRepository>;
let featureFlagsServiceMock: sinon.SinonStubbedInstance<FeatureFlagsService>;

beforeEach(() => {
subscriberRepositoryMock = sinon.createStubInstance(SubscriberRepository);
analyticsServiceMock = sinon.createStubInstance(AnalyticsService);
notificationTemplateRepositoryMock = sinon.createStubInstance(NotificationTemplateRepository);
updatePreferencesUsecaseMock = sinon.createStubInstance(UpdatePreferences);
environmentRepositoryMock = sinon.createStubInstance(EnvironmentRepository);
contextRepositoryMock = sinon.createStubInstance(ContextRepository);
featureFlagsServiceMock = sinon.createStubInstance(FeatureFlagsService);

bulkUpdatePreferences = new BulkUpdatePreferences(
notificationTemplateRepositoryMock as any,
subscriberRepositoryMock as any,
analyticsServiceMock as any,
updatePreferencesUsecaseMock as any,
environmentRepositoryMock as any
environmentRepositoryMock as any,
contextRepositoryMock as any,
featureFlagsServiceMock as any
);
});

Expand Down
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
Expand Up @@ -174,7 +174,7 @@ export class SnoozeNotification {
status: JobStatusEnum.PENDING,
delay,
createdAt: Date.now().toString(),
id: JobRepository.createObjectId(),
_id: JobRepository.createObjectId(),
_parentId: null,
payload: {
...originalJob.payload,
Expand Down
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;
}
Loading
Loading