From 1039b9636cc02d078b8f33f3162bdf177bb27ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 5 Sep 2025 15:47:02 +0200 Subject: [PATCH 01/12] feat(api-service): subscribers schedule api --- .../dtos/subscriber-session-request.dto.ts | 6 + .../dtos/subscriber-session-response.dto.ts | 3 +- .../dtos/update-preferences-request.dto.ts | 9 +- apps/api/src/app/inbox/e2e/session.e2e.ts | 431 +++++++++++++++++- .../app/inbox/e2e/update-preferences.e2e.ts | 298 ++++++++++++ apps/api/src/app/inbox/inbox.controller.ts | 28 +- apps/api/src/app/inbox/usecases/index.ts | 2 + .../inbox/usecases/session/session.usecase.ts | 68 ++- .../update-preferences.command.ts | 5 +- .../update-preferences.usecase.ts | 7 +- apps/api/src/app/inbox/utils/types.ts | 2 + apps/api/src/app/shared/dtos/schedule.ts | 187 ++++++++ .../is-time-12-hour-format.validator.ts | 29 ++ .../weekly-schedule-disabled.validator.ts | 58 +++ .../dtos/patch-subscriber-preferences.dto.ts | 12 +- .../dtos/subscriber-global-preference.dto.ts | 8 +- .../subscribers-v2/subscribers.controller.ts | 36 +- .../get-subscriber-preferences.usecase.ts | 3 +- .../update-subscriber-preferences.command.ts | 5 + .../update-subscriber-preferences.usecase.ts | 1 + .../dtos/subscriber-preference.dto.ts | 4 +- ...et-subscriber-global-preference.usecase.ts | 33 +- .../get-subscriber-schedule.command.ts | 9 + .../get-subscriber-schedule.usecase.ts | 26 ++ .../usecases/get-subscriber-schedule/index.ts | 2 + .../get-preferences/get-preferences.dto.ts | 12 +- .../get-preferences.usecase.ts | 45 +- .../merge-preferences.usecase.ts | 1 + .../upsert-preferences.usecase.ts | 23 +- ...t-subscriber-global-preferences.command.ts | 4 + .../preferences/preferences.entity.ts | 4 +- .../preferences/preferences.schema.ts | 1 + .../subscriber-preference.interface.ts | 3 +- packages/shared/src/types/feature-flags.ts | 1 + .../src/types/workflow-channel-preferences.ts | 34 ++ 35 files changed, 1342 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/app/shared/dtos/schedule.ts create mode 100644 apps/api/src/app/shared/validators/is-time-12-hour-format.validator.ts create mode 100644 apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts create mode 100644 apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts create mode 100644 apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts create mode 100644 apps/api/src/app/subscribers/usecases/get-subscriber-schedule/index.ts diff --git a/apps/api/src/app/inbox/dtos/subscriber-session-request.dto.ts b/apps/api/src/app/inbox/dtos/subscriber-session-request.dto.ts index cbadbc56e3b..9030474ff0a 100644 --- a/apps/api/src/app/inbox/dtos/subscriber-session-request.dto.ts +++ b/apps/api/src/app/inbox/dtos/subscriber-session-request.dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { ScheduleDto } from '../../shared/dtos/schedule'; export class SubscriberSessionRequestDto { @IsString() @@ -20,6 +21,11 @@ export class SubscriberSessionRequestDto { @ValidateNested() @Type(() => SubscriberDto) readonly subscriber?: SubscriberDto | string; + + @IsOptional() + @ValidateNested() + @Type(() => ScheduleDto) + readonly defaultSchedule?: ScheduleDto; } export class SubscriberDto { diff --git a/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts b/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts index c97c996c6d6..ad490174040 100644 --- a/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts +++ b/apps/api/src/app/inbox/dtos/subscriber-session-response.dto.ts @@ -1,4 +1,4 @@ -import { SeverityLevelEnum } from '@novu/shared'; +import { Schedule, SeverityLevelEnum } from '@novu/shared'; type SeverityCounts = { [SeverityLevelEnum.HIGH]: number; @@ -21,4 +21,5 @@ export class SubscriberSessionResponseDto { readonly maxSnoozeDurationHours: number; readonly isDevelopmentMode: boolean; readonly applicationIdentifier?: string; + readonly schedule?: Schedule; } diff --git a/apps/api/src/app/inbox/dtos/update-preferences-request.dto.ts b/apps/api/src/app/inbox/dtos/update-preferences-request.dto.ts index 85f822b7896..73a06dd7f27 100644 --- a/apps/api/src/app/inbox/dtos/update-preferences-request.dto.ts +++ b/apps/api/src/app/inbox/dtos/update-preferences-request.dto.ts @@ -1,4 +1,6 @@ -import { IsBoolean, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; +import { ScheduleDto } from '../../shared/dtos/schedule'; export class UpdatePreferencesRequestDto { @IsOptional() @@ -20,4 +22,9 @@ export class UpdatePreferencesRequestDto { @IsOptional() @IsBoolean() readonly push?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => ScheduleDto) + readonly schedule?: ScheduleDto; } diff --git a/apps/api/src/app/inbox/e2e/session.e2e.ts b/apps/api/src/app/inbox/e2e/session.e2e.ts index 41e96188844..f545ad95fd5 100644 --- a/apps/api/src/app/inbox/e2e/session.e2e.ts +++ b/apps/api/src/app/inbox/e2e/session.e2e.ts @@ -21,6 +21,7 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { let cacheService: CacheService; let invalidateCache: InvalidateCacheService; let subscriberRepository: SubscriberRepository; + const isSubscribersScheduleEnabled = process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED; before(async () => { const cacheInMemoryProviderService = new CacheInMemoryProviderService(); @@ -41,13 +42,17 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { }, invalidateCache ); - // @ts-ignore + // @ts-expect-error process.env.IS_NOTIFICATION_SEVERITY_ENABLED = 'true'; + // @ts-expect-error + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true'; }); afterEach(() => { - // @ts-ignore + // @ts-expect-error process.env.IS_NOTIFICATION_SEVERITY_ENABLED = isNotificationSeverityEnabled; + // @ts-expect-error + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = isSubscribersScheduleEnabled; }); const initializeSession = async ({ @@ -56,12 +61,14 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { subscriberHash, subscriber, origin, + defaultSchedule, }: { applicationIdentifier: string; subscriberId?: string; subscriberHash?: string; subscriber?: Record; origin?: string; + defaultSchedule?: Record; }) => { const request = session.testAgent.post('/v1/inbox/session'); @@ -74,6 +81,7 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { subscriberId, subscriberHash, subscriber, + defaultSchedule, }); }; @@ -635,6 +643,425 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { expect(body.data.unreadCount.severity.low).to.equal(0); expect(body.data.unreadCount.severity.none).to.equal(1); }); + + describe('defaultSchedule functionality', () => { + it('should initialize session with valid defaultSchedule', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + tuesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + wednesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + thursday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + friday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-test-${randomBytes(4).toString('hex')}`, + subscriber: { + subscriberId: `schedule-test-${randomBytes(4).toString('hex')}`, + firstName: 'Schedule', + lastName: 'Test', + }, + defaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.exist; + expect(body.data.schedule.isEnabled).to.equal(true); + expect(body.data.schedule.weeklySchedule).to.exist; + expect(body.data.schedule.weeklySchedule.monday.isEnabled).to.equal(true); + expect(body.data.schedule.weeklySchedule.monday.hours[0].start).to.equal('09:00 AM'); + expect(body.data.schedule.weeklySchedule.monday.hours[0].end).to.equal('05:00 PM'); + expect(body.data.schedule.weeklySchedule.tuesday.isEnabled).to.equal(true); + expect(body.data.schedule.weeklySchedule.tuesday.hours[0].start).to.equal('09:00 AM'); + expect(body.data.schedule.weeklySchedule.tuesday.hours[0].end).to.equal('05:00 PM'); + }); + + it('should initialize session with defaultSchedule when isEnabled is false', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: false, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-disabled-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.exist; + expect(body.data.schedule.isEnabled).to.equal(false); + expect(body.data.schedule.weeklySchedule).to.not.exist; + }); + + it('should fail validation when isEnabled is false but weeklySchedule is provided', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: false, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-validation-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(422); + expect(body.message).to.equal('Validation Error'); + expect(body.errors).to.exist; + expect(body.errors.general).to.exist; + expect(body.errors.general.messages).to.be.an('array'); + expect(body.errors.general.messages[0]).to.contain( + 'weeklySchedule should not be provided when isEnabled is false' + ); + }); + + it('should create schedule with isEnabled true when weeklySchedule is not provided', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-enabled-only-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.exist; + expect(body.data.schedule.isEnabled).to.equal(true); + expect(body.data.schedule.weeklySchedule).to.not.exist; + }); + + it('should fail validation when isEnabled is true but weeklySchedule is empty', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: {}, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-empty-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(422); + expect(body.message).to.equal('Validation Error'); + expect(body.errors).to.exist; + expect(body.errors.general).to.exist; + expect(body.errors.general.messages).to.be.an('array'); + expect(body.errors.general.messages[0]).to.contain( + 'weeklySchedule must contain at least one day configuration when isEnabled is true' + ); + }); + + it('should fail validation with invalid time format', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '25:00', end: '17:00' }], // Invalid 24-hour format + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-invalid-time-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(422); + expect(body.message).to.equal('Validation Error'); + expect(body.errors).to.exist; + expect(body.errors.general).to.exist; + expect(body.errors.general.messages).to.be.an('array'); + expect(body.errors.general.messages.some((msg: string) => msg.includes('must be in 12-hour format'))).to.be.true; + }); + + it('should fail validation with invalid day name', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: { + invalidDay: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `schedule-invalid-day-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(422); + expect(body.message).to.equal('Validation Error'); + expect(body.errors).to.exist; + expect(body.errors.general).to.exist; + expect(body.errors.general.messages).to.be.an('array'); + expect(body.errors.general.messages[0]).to.contain('weeklySchedule contains invalid day names'); + }); + + it('should not set defaultSchedule when subscriber already has a schedule', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const subscriberId = `existing-schedule-${randomBytes(4).toString('hex')}`; + + // First, create a subscriber with a schedule + const existingSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '08:00 AM', end: '04:00 PM' }], + }, + }, + }; + + await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId, + defaultSchedule: existingSchedule, + }); + + // Now try to set a different defaultSchedule + const newDefaultSchedule = { + isEnabled: true, + weeklySchedule: { + tuesday: { + isEnabled: true, + hours: [{ start: '10:00 AM', end: '06:00 PM' }], + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId, + defaultSchedule: newDefaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.exist; + expect(body.data.schedule.weeklySchedule.monday).to.exist; // Should keep existing schedule + expect(body.data.schedule.weeklySchedule.tuesday).to.not.exist; // Should not use new defaultSchedule + }); + + it('should handle multiple time ranges in a day', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [ + { start: '09:00 AM', end: '12:00 PM' }, + { start: '01:00 PM', end: '05:00 PM' }, + ], + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `multiple-ranges-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.exist; + expect(body.data.schedule.weeklySchedule.monday.hours).to.have.length(2); + expect(body.data.schedule.weeklySchedule.monday.hours[0].start).to.equal('09:00 AM'); + expect(body.data.schedule.weeklySchedule.monday.hours[0].end).to.equal('12:00 PM'); + expect(body.data.schedule.weeklySchedule.monday.hours[1].start).to.equal('01:00 PM'); + expect(body.data.schedule.weeklySchedule.monday.hours[1].end).to.equal('05:00 PM'); + }); + + it('should handle different time formats (with/without leading zero)', async () => { + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '9:00 AM', end: '5:00 PM' }], // Without leading zero + }, + tuesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], // With leading zero + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `time-format-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.exist; + expect(body.data.schedule.weeklySchedule.monday.hours[0].start).to.equal('9:00 AM'); + expect(body.data.schedule.weeklySchedule.tuesday.hours[0].start).to.equal('09:00 AM'); + }); + + it('should not create schedule when feature flag is disabled', async () => { + // Disable the feature flag + // @ts-expect-error process.env is not typed + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'false'; + + await setIntegrationConfig( + { + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }, + invalidateCache + ); + + const defaultSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const { body, status } = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriberId: `feature-flag-disabled-${randomBytes(4).toString('hex')}`, + defaultSchedule, + }); + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + expect(body.data.schedule).to.not.exist; + + // Re-enable the feature flag for other tests + // @ts-expect-error process.env is not typed + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true'; + }); + }); }); async function setIntegrationConfig( diff --git a/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts b/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts index b960de60589..976905be12f 100644 --- a/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts +++ b/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts @@ -4,10 +4,18 @@ import { expect } from 'chai'; describe('Update global preferences - /inbox/preferences (PATCH) #novu-v2', () => { let session: UserSession; + const isSubscribersScheduleEnabled = process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED; beforeEach(async () => { session = new UserSession(); await session.initialize(); + // @ts-expect-error + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true'; + }); + + afterEach(() => { + // @ts-expect-error + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = isSubscribersScheduleEnabled; }); it('should throw error when made unauthorized call', async () => { @@ -87,6 +95,296 @@ describe('Update global preferences - /inbox/preferences (PATCH) #novu-v2', () = expect(responseSecond.body.data.channels.chat).to.equal(undefined); expect(responseSecond.body.data.level).to.equal(PreferenceLevelEnum.GLOBAL); }); + + describe('schedule functionality', () => { + it('should update global preferences with schedule', async () => { + const schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + tuesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + wednesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + thursday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + friday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + email: true, + in_app: true, + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(200); + expect(response.body.data.schedule).to.exist; + expect(response.body.data.schedule.isEnabled).to.equal(true); + expect(response.body.data.schedule.weeklySchedule).to.exist; + expect(response.body.data.schedule.weeklySchedule.monday.isEnabled).to.equal(true); + expect(response.body.data.schedule.weeklySchedule.monday.hours[0].start).to.equal('09:00 AM'); + expect(response.body.data.schedule.weeklySchedule.monday.hours[0].end).to.equal('05:00 PM'); + expect(response.body.data.schedule.weeklySchedule.tuesday.isEnabled).to.equal(true); + expect(response.body.data.schedule.weeklySchedule.tuesday.hours[0].start).to.equal('09:00 AM'); + expect(response.body.data.schedule.weeklySchedule.tuesday.hours[0].end).to.equal('05:00 PM'); + expect(response.body.data.level).to.equal(PreferenceLevelEnum.GLOBAL); + }); + + it('should update schedule with disabled state', async () => { + const schedule = { + isEnabled: false, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(200); + expect(response.body.data.schedule).to.exist; + expect(response.body.data.schedule.isEnabled).to.equal(false); + expect(response.body.data.schedule.weeklySchedule).to.not.exist; + }); + + it('should update schedule with multiple time ranges', async () => { + const schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [ + { start: '09:00 AM', end: '12:00 PM' }, + { start: '01:00 PM', end: '05:00 PM' }, + ], + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(200); + expect(response.body.data.schedule).to.exist; + expect(response.body.data.schedule.weeklySchedule.monday.hours).to.have.length(2); + expect(response.body.data.schedule.weeklySchedule.monday.hours[0].start).to.equal('09:00 AM'); + expect(response.body.data.schedule.weeklySchedule.monday.hours[0].end).to.equal('12:00 PM'); + expect(response.body.data.schedule.weeklySchedule.monday.hours[1].start).to.equal('01:00 PM'); + expect(response.body.data.schedule.weeklySchedule.monday.hours[1].end).to.equal('05:00 PM'); + }); + + it('should fail validation when isEnabled is false but weeklySchedule is provided', async () => { + const schedule = { + isEnabled: false, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(422); + expect(response.body.message).to.equal('Validation Error'); + expect(response.body.errors.general.messages).to.be.an('array'); + expect(response.body.errors.general.messages[0]).to.contain( + 'weeklySchedule should not be provided when isEnabled is false' + ); + }); + + it('should fail validation when isEnabled is true but weeklySchedule is empty', async () => { + const schedule = { + isEnabled: true, + weeklySchedule: {}, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(422); + expect(response.body.message).to.equal('Validation Error'); + expect(response.body.errors.general.messages).to.be.an('array'); + expect(response.body.errors.general.messages[0]).to.contain( + 'weeklySchedule must contain at least one day configuration when isEnabled is true' + ); + }); + + it('should fail validation with invalid time format', async () => { + const schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '25:00', end: '17:00' }], // Invalid 24-hour format + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(422); + expect(response.body.message).to.equal('Validation Error'); + expect(response.body.errors.general.messages).to.be.an('array'); + expect(response.body.errors.general.messages.some((msg: string) => msg.includes('must be in 12-hour format'))).to + .be.true; + }); + + it('should fail validation with invalid day name', async () => { + const schedule = { + isEnabled: true, + weeklySchedule: { + invalidDay: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(422); + expect(response.body.message).to.equal('Validation Error'); + expect(response.body.errors.general.messages).to.be.an('array'); + expect(response.body.errors.general.messages[0]).to.contain('weeklySchedule contains invalid day names'); + }); + + it('should handle schedule with isEnabled true but no weeklySchedule', async () => { + const schedule = { + isEnabled: true, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(200); + expect(response.body.data.schedule).to.exist; + expect(response.body.data.schedule.isEnabled).to.equal(true); + expect(response.body.data.schedule.weeklySchedule).to.not.exist; + }); + + it('should update existing schedule', async () => { + // First, set a schedule + const initialSchedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule: initialSchedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + // Then update it + const updatedSchedule = { + isEnabled: true, + weeklySchedule: { + tuesday: { + isEnabled: true, + hours: [{ start: '10:00 AM', end: '06:00 PM' }], + }, + wednesday: { + isEnabled: true, + hours: [{ start: '08:00 AM', end: '04:00 PM' }], + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + schedule: updatedSchedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(200); + expect(response.body.data.schedule.weeklySchedule.monday).to.not.exist; + expect(response.body.data.schedule.weeklySchedule.tuesday).to.exist; + expect(response.body.data.schedule.weeklySchedule.tuesday.hours[0].start).to.equal('10:00 AM'); + expect(response.body.data.schedule.weeklySchedule.wednesday).to.exist; + expect(response.body.data.schedule.weeklySchedule.wednesday.hours[0].start).to.equal('08:00 AM'); + }); + + it('should handle schedule update with channels update', async () => { + const schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + const response = await session.testAgent + .patch('/v1/inbox/preferences') + .send({ + email: false, + in_app: true, + schedule, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(response.status).to.equal(200); + expect(response.body.data.channels.email).to.equal(undefined); + expect(response.body.data.channels.in_app).to.equal(undefined); + expect(response.body.data.schedule).to.exist; + expect(response.body.data.schedule.isEnabled).to.equal(true); + expect(response.body.data.schedule.weeklySchedule.monday).to.exist; + }); + }); }); describe('Update workflow preferences - /inbox/preferences/:workflowId (PATCH)', () => { diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index df2143605e4..784f9757a67 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -19,6 +19,7 @@ import { AddressingTypeEnum, MessageActionStatusEnum, PreferenceLevelEnum, + Schedule, TriggerRequestCategoryEnum, UserSessionData, } from '@novu/shared'; @@ -30,6 +31,10 @@ import { ApiCommonResponses } from '../shared/framework/response.decorator'; import { KeylessAccessible } from '../shared/framework/swagger/keyless.security'; import { SubscriberSession, UserSession } from '../shared/framework/user.decorator'; import { RequestWithReqId } from '../shared/middleware/request-id.middleware'; +import { + GetSubscriberGlobalPreference, + GetSubscriberGlobalPreferenceCommand, +} from '../subscribers/usecases/get-subscriber-global-preference'; import { ActionTypeRequestDto } from './dtos/action-type-request.dto'; import { BulkUpdatePreferencesRequestDto } from './dtos/bulk-update-preferences-request.dto'; import { GetNotificationsCountRequestDto } from './dtos/get-notifications-count-request.dto'; @@ -87,7 +92,8 @@ export class InboxController { private snoozeNotificationUsecase: SnoozeNotification, private unsnoozeNotificationUsecase: UnsnoozeNotification, private markNotificationsAsSeenUsecase: MarkNotificationsAsSeen, - private parseEventRequest: ParseEventRequest + private parseEventRequest: ParseEventRequest, + private getSubscriberGlobalPreference: GetSubscriberGlobalPreference ) {} @KeylessAccessible() @@ -166,6 +172,25 @@ export class InboxController { ); } + @UseGuards(AuthGuard('subscriberJwt')) + @Get('/preferences/global') + async getSchedule(@SubscriberSession() subscriberSession: SubscriberEntity): Promise { + const globalPreference = await this.getSubscriberGlobalPreference.execute( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: subscriberSession._organizationId, + environmentId: subscriberSession._environmentId, + subscriberId: subscriberSession.subscriberId, + includeInactiveChannels: false, + subscriber: subscriberSession, + }) + ); + + return { + level: PreferenceLevelEnum.GLOBAL, + ...globalPreference.preference, + }; + } + @UseGuards(AuthGuard('subscriberJwt')) @Patch('/notifications/:id/read') async markNotificationAsRead( @@ -323,6 +348,7 @@ export class InboxController { in_app: body.in_app, push: body.push, sms: body.sms, + schedule: body.schedule, includeInactiveChannels: false, }) ); diff --git a/apps/api/src/app/inbox/usecases/index.ts b/apps/api/src/app/inbox/usecases/index.ts index 4b8b74b6d0e..dfbd12f5cd7 100644 --- a/apps/api/src/app/inbox/usecases/index.ts +++ b/apps/api/src/app/inbox/usecases/index.ts @@ -11,6 +11,7 @@ import { GenerateUniqueApiKey } from '../../environments-v1/usecases/generate-un import { ParseEventRequest } from '../../events/usecases/parse-event-request'; import { VerifyPayload } from '../../events/usecases/verify-payload'; import { GetSubscriberGlobalPreference } from '../../subscribers/usecases/get-subscriber-global-preference'; +import { GetSubscriberSchedule } from '../../subscribers/usecases/get-subscriber-schedule'; import { BulkUpdatePreferences } from './bulk-update-preferences/bulk-update-preferences.usecase'; import { GetInboxPreferences } from './get-inbox-preferences/get-inbox-preferences.usecase'; import { GetNotifications } from './get-notifications/get-notifications.usecase'; @@ -50,4 +51,5 @@ export const USE_CASES = [ StorageHelperService, MessageInteractionService, WorkflowRunService, + GetSubscriberSchedule, ]; diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index ca4e41e6eec..db375cfd004 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -30,6 +30,7 @@ import { MessageTemplateRepository, NotificationTemplateRepository, PreferencesRepository, + SubscriberEntity, } from '@novu/dal'; import { ApiServiceLevelEnum, @@ -40,9 +41,11 @@ import { FeatureNameEnum, getFeatureForTierAsNumber, InAppProviderIdEnum, + PreferenceLevelEnum, PreferencesTypeEnum, ResourceOriginEnum, ResourceTypeEnum, + Schedule, StepTypeEnum, } from '@novu/shared'; import { createHash } from 'crypto'; @@ -54,13 +57,20 @@ import { CreateNovuIntegrationsCommand } from '../../../integrations/usecases/cr import { CreateNovuIntegrations } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; import { GetOrganizationSettingsCommand } from '../../../organization/usecases/get-organization-settings/get-organization-settings.command'; import { GetOrganizationSettings } from '../../../organization/usecases/get-organization-settings/get-organization-settings.usecase'; +import { ScheduleDto } from '../../../shared/dtos/schedule'; import { isHmacValid } from '../../../shared/helpers/is-valid-hmac'; +import { + GetSubscriberSchedule, + GetSubscriberScheduleCommand, +} from '../../../subscribers/usecases/get-subscriber-schedule'; import { SubscriberDto, SubscriberSessionRequestDto } from '../../dtos/subscriber-session-request.dto'; import { SubscriberSessionResponseDto } from '../../dtos/subscriber-session-response.dto'; import { AnalyticsEventsEnum } from '../../utils'; import { validateHmacEncryption } from '../../utils/encryption'; import { NotificationsCountCommand } from '../notifications-count/notifications-count.command'; import { NotificationsCount } from '../notifications-count/notifications-count.usecase'; +import { UpdatePreferencesCommand } from '../update-preferences/update-preferences.command'; +import { UpdatePreferences } from '../update-preferences/update-preferences.usecase'; import { SessionCommand } from './session.command'; const ALLOWED_ORIGINS_REGEX = new RegExp(process.env.FRONT_BASE_URL || ''); @@ -91,7 +101,9 @@ export class Session { private upsertControlValuesUseCase: UpsertControlValuesUseCase, private getOrganizationSettingsUsecase: GetOrganizationSettings, private logger: PinoLogger, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private getSubscriberSchedule: GetSubscriberSchedule, + private updatePreferencesUsecase: UpdatePreferences ) { this.logger.setContext(this.constructor.name); } @@ -242,6 +254,12 @@ export class Session { ); } + const schedule = await this.createDefaultSchedule({ + environment, + defaultSchedule: command.requestData.defaultSchedule, + subscriber: subscriberEntity, + }); + return { applicationIdentifier: environment.identifier, token, @@ -250,9 +268,57 @@ export class Session { removeNovuBranding, maxSnoozeDurationHours, isDevelopmentMode: environment.name.toLowerCase() !== 'production', + schedule, }; } + private async createDefaultSchedule({ + environment, + defaultSchedule, + subscriber, + }: { + environment: EnvironmentEntity; + defaultSchedule?: ScheduleDto; + subscriber: SubscriberEntity; + }): Promise { + const isSubscribersScheduleEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED, + defaultValue: false, + environment: { _id: environment._id }, + organization: { _id: environment._organizationId }, + }); + + if (!isSubscribersScheduleEnabled) { + return undefined; + } + + const schedule = await this.getSubscriberSchedule.execute( + GetSubscriberScheduleCommand.create({ + organizationId: environment._organizationId, + environmentId: environment._id, + _subscriberId: subscriber._id, + }) + ); + + if (schedule || !defaultSchedule) { + return schedule; + } + + const updatedGlobalPreference = await this.updatePreferencesUsecase.execute( + UpdatePreferencesCommand.create({ + organizationId: environment._organizationId, + environmentId: environment._id, + subscriber, + subscriberId: subscriber.subscriberId, + level: PreferenceLevelEnum.GLOBAL, + includeInactiveChannels: false, + schedule: defaultSchedule, + }) + ); + + return updatedGlobalPreference.schedule; + } + private validateRequestData(requestData: SubscriberSessionRequestDto): void { if (!requestData.applicationIdentifier && this.extractSubscriberInfo(requestData, true)?.subscriberId) { throw new UnprocessableEntityException( diff --git a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.command.ts b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.command.ts index be21892f96e..fbd859024e8 100644 --- a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.command.ts +++ b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.command.ts @@ -1,5 +1,5 @@ import { EnvironmentEntity, NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; -import { PreferenceLevelEnum } from '@novu/shared'; +import { PreferenceLevelEnum, Schedule } from '@novu/shared'; import { IsBoolean, IsDefined, IsEnum, IsOptional, ValidateIf } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; @@ -44,4 +44,7 @@ export class UpdatePreferencesCommand extends EnvironmentWithSubscriber { @IsOptional() readonly environment?: EnvironmentEntity; + + @IsOptional() + readonly schedule?: Schedule; } diff --git a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts index 0fc3c89c46c..f8fedc44c4e 100644 --- a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts +++ b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts @@ -16,6 +16,7 @@ import { SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { IPreferenceChannels, PreferenceLevelEnum, + Schedule, SeverityLevelEnum, WebhookEventEnum, WebhookObjectTypeEnum, @@ -105,6 +106,7 @@ export class UpdatePreferences { environmentId: command.environmentId, _subscriberId: subscriber._id, workflowId, + schedule: command.schedule, }); this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.UPDATE_PREFERENCES, '', { @@ -179,9 +181,8 @@ export class UpdatePreferences { ); return { + ...preference, level: PreferenceLevelEnum.GLOBAL, - enabled: preference.enabled, - channels: preference.channels, }; } @@ -192,6 +193,7 @@ export class UpdatePreferences { _subscriberId: string; environmentId: string; workflowId?: string; + schedule?: Schedule; }): Promise { const preferences: WorkflowPreferencesPartial = { channels: Object.entries(item.channels).reduce( @@ -222,6 +224,7 @@ export class UpdatePreferences { organizationId: item.organizationId, _subscriberId: item._subscriberId, returnPreference: false, + schedule: item.schedule, }) ); } diff --git a/apps/api/src/app/inbox/utils/types.ts b/apps/api/src/app/inbox/utils/types.ts index 3fc2f381db9..0aa09c95697 100644 --- a/apps/api/src/app/inbox/utils/types.ts +++ b/apps/api/src/app/inbox/utils/types.ts @@ -4,6 +4,7 @@ import type { IPreferenceChannels, PreferenceLevelEnum, Redirect, + Schedule, SeverityLevelEnum, } from '@novu/shared'; @@ -79,4 +80,5 @@ export type InboxPreference = { data?: CustomDataType; severity: SeverityLevelEnum; }; + schedule?: Schedule; }; diff --git a/apps/api/src/app/shared/dtos/schedule.ts b/apps/api/src/app/shared/dtos/schedule.ts new file mode 100644 index 00000000000..89ac35b428e --- /dev/null +++ b/apps/api/src/app/shared/dtos/schedule.ts @@ -0,0 +1,187 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsTime12HourFormat } from '../validators/is-time-12-hour-format.validator'; +import { WeeklyScheduleValidation } from '../validators/weekly-schedule-disabled.validator'; + +export class TimeRangeDto { + @ApiProperty({ + type: String, + description: 'Start time', + example: '09:00 AM', + }) + @IsString() + @IsTime12HourFormat() + readonly start: string; + + @ApiProperty({ + type: String, + description: 'End time', + example: '05:00 PM', + }) + @IsString() + @IsTime12HourFormat() + readonly end: string; +} +export class DayScheduleDto { + @ApiProperty({ + type: Boolean, + description: 'Day schedule enabled', + example: true, + }) + @IsBoolean() + readonly isEnabled: boolean; + + @ApiPropertyOptional({ + type: [TimeRangeDto], + description: 'Hours', + example: [{ start: '09:00 AM', end: '05:00 PM' }], + }) + @IsOptional() + @ValidateNested() + @Type(() => TimeRangeDto) + readonly hours?: TimeRangeDto[]; +} + +export class WeeklyScheduleDto { + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Monday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly monday?: DayScheduleDto; + + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Tuesday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly tuesday?: DayScheduleDto; + + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Wednesday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly wednesday?: DayScheduleDto; + + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Thursday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly thursday?: DayScheduleDto; + + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Friday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly friday?: DayScheduleDto; + + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Saturday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly saturday?: DayScheduleDto; + + @ApiPropertyOptional({ + type: DayScheduleDto, + description: 'Sunday schedule', + example: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => DayScheduleDto) + readonly sunday?: DayScheduleDto; +} + +export class ScheduleDto { + @ApiProperty({ + type: Boolean, + description: 'Schedule enabled', + example: true, + }) + @IsBoolean() + readonly isEnabled: boolean; + + @ApiPropertyOptional({ + type: WeeklyScheduleDto, + description: 'Weekly schedule', + example: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + tuesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + wednesday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + thursday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + friday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + saturday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + sunday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => WeeklyScheduleDto) + @WeeklyScheduleValidation() + readonly weeklySchedule?: WeeklyScheduleDto; +} diff --git a/apps/api/src/app/shared/validators/is-time-12-hour-format.validator.ts b/apps/api/src/app/shared/validators/is-time-12-hour-format.validator.ts new file mode 100644 index 00000000000..9ca28725970 --- /dev/null +++ b/apps/api/src/app/shared/validators/is-time-12-hour-format.validator.ts @@ -0,0 +1,29 @@ +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; + +export function IsTime12HourFormat(validationOptions?: ValidationOptions) { + return (object: any, propertyName: string) => { + registerDecorator({ + name: 'isTime12HourFormat', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + if (typeof value !== 'string') { + return false; + } + + // Regex pattern for 12-hour format: HH:MM AM/PM + // Accepts: 01:00 AM through 12:59 PM + // With optional leading zero: 1:00 AM or 01:00 AM + const time12HourRegex = /^(0?[1-9]|1[0-2]):[0-5][0-9]\s?(AM|PM)$/i; + + return time12HourRegex.test(value); + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be in 12-hour format (e.g., 09:00 AM or 9:00 AM)`; + }, + }, + }); + }; +} diff --git a/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts b/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts new file mode 100644 index 00000000000..c033d6fd899 --- /dev/null +++ b/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts @@ -0,0 +1,58 @@ +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; + +const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + +export function WeeklyScheduleValidation(validationOptions?: ValidationOptions) { + return (object: object, propertyName: string) => { + registerDecorator({ + name: 'weeklyScheduleDisabled', + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown, args: ValidationArguments) { + const obj = args.object as { isEnabled?: boolean }; + + // If isEnabled is false and weeklySchedule exists, validation fails + if (obj.isEnabled === false && value !== undefined && value !== null) { + return false; + } + if (obj.isEnabled === true && (value === undefined || value === null)) { + return false; + } + if (obj.isEnabled === true && value && Object.keys(value).length === 0) { + return false; + } + if (obj.isEnabled === true && value && Object.keys(value).some((key) => !weekdays.includes(key))) { + return false; + } + + return true; + }, + defaultMessage(args: ValidationArguments) { + const obj = args.object as { isEnabled?: boolean }; + const value = args.value; + + if (obj.isEnabled === false && value !== undefined && value !== null) { + return 'weeklySchedule should not be provided when isEnabled is false'; + } + + if (obj.isEnabled === true && (value === undefined || value === null)) { + return 'weeklySchedule is required when isEnabled is true'; + } + + if (obj.isEnabled === true && value && Object.keys(value).length === 0) { + return 'weeklySchedule must contain at least one day configuration when isEnabled is true'; + } + + if (obj.isEnabled === true && value && Object.keys(value).some((key) => !weekdays.includes(key))) { + const invalidKeys = Object.keys(value).filter((key) => !weekdays.includes(key)); + return `weeklySchedule contains invalid day names: ${invalidKeys.join(', ')}. Valid days are: ${weekdays.join(', ')}`; + } + + return 'weeklySchedule validation failed'; + }, + }, + }); + }; +} diff --git a/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts b/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts index a307ce08f48..e972315af6c 100644 --- a/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts +++ b/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts @@ -1,8 +1,9 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; import { parseSlugId } from '@novu/application-generic'; import { IPreferenceChannels } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; -import { IsOptional } from 'class-validator'; +import { IsOptional, ValidateNested } from 'class-validator'; +import { ScheduleDto } from '../../shared/dtos/schedule'; export class PatchPreferenceChannelsDto implements IPreferenceChannels { @ApiProperty({ description: 'Email channel preference' }) @@ -34,4 +35,11 @@ export class PatchSubscriberPreferencesDto { @IsOptional() @Transform(({ value }) => parseSlugId(value)) workflowId?: string; + + @ApiHideProperty() + /* @ApiPropertyOptional({ description: 'Subscriber schedule', type: ScheduleDto }) */ + @IsOptional() + @ValidateNested() + @Type(() => ScheduleDto) + schedule?: ScheduleDto; } diff --git a/apps/api/src/app/subscribers-v2/dtos/subscriber-global-preference.dto.ts b/apps/api/src/app/subscribers-v2/dtos/subscriber-global-preference.dto.ts index 3d91da66046..a5667fc2735 100644 --- a/apps/api/src/app/subscribers-v2/dtos/subscriber-global-preference.dto.ts +++ b/apps/api/src/app/subscribers-v2/dtos/subscriber-global-preference.dto.ts @@ -1,7 +1,8 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsNotEmpty, ValidateNested } from 'class-validator'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; +import { ScheduleDto } from '../../shared/dtos/schedule'; export class SubscriberGlobalPreferenceDto { @ApiProperty({ description: 'Whether notifications are enabled globally' }) @@ -13,4 +14,9 @@ export class SubscriberGlobalPreferenceDto { @ValidateNested() @Type(() => SubscriberPreferenceChannels) channels: SubscriberPreferenceChannels; + + @ApiPropertyOptional({ description: 'Subscriber schedule', type: ScheduleDto }) + @ValidateNested() + @Type(() => ScheduleDto) + schedule?: ScheduleDto; } diff --git a/apps/api/src/app/subscribers-v2/subscribers.controller.ts b/apps/api/src/app/subscribers-v2/subscribers.controller.ts index 9dac1b22f77..d7d71697343 100644 --- a/apps/api/src/app/subscribers-v2/subscribers.controller.ts +++ b/apps/api/src/app/subscribers-v2/subscribers.controller.ts @@ -39,6 +39,10 @@ import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { SubscriberResponseDto } from '../subscribers/dtos'; +import { + GetSubscriberGlobalPreference, + GetSubscriberGlobalPreferenceCommand, +} from '../subscribers/usecases/get-subscriber-global-preference'; import { ListSubscriberSubscriptionsQueryDto } from '../topics-v2/dtos/list-subscriber-subscriptions-query.dto'; import { ListTopicSubscriptionsResponseDto } from '../topics-v2/dtos/list-topic-subscriptions-response.dto'; import { ListSubscriberSubscriptionsCommand } from '../topics-v2/usecases/list-subscriber-subscriptions/list-subscriber-subscriptions.command'; @@ -53,6 +57,7 @@ import { ListSubscribersResponseDto } from './dtos/list-subscribers-response.dto import { PatchSubscriberRequestDto } from './dtos/patch-subscriber.dto'; import { PatchSubscriberPreferencesDto } from './dtos/patch-subscriber-preferences.dto'; import { RemoveSubscriberResponseDto } from './dtos/remove-subscriber.dto'; +import { SubscriberGlobalPreferenceDto } from './dtos/subscriber-global-preference.dto'; import { ChatOauthCallbackCommand } from './usecases/chat-oauth-callback/chat-oauth-callback.command'; import { ResponseTypeEnum } from './usecases/chat-oauth-callback/chat-oauth-callback.response'; import { ChatOauthCallback } from './usecases/chat-oauth-callback/chat-oauth-callback.usecase'; @@ -91,7 +96,8 @@ export class SubscribersController { private listSubscriberSubscriptionsUsecase: ListSubscriberSubscriptionsUseCase, private chatOauthCallbackUsecase: ChatOauthCallback, private generateChatOauthUrlUsecase: GenerateChatOauthUrl, - private featureFlagsService: FeatureFlagsService + private featureFlagsService: FeatureFlagsService, + private getSubscriberGlobalPreference: GetSubscriberGlobalPreference ) {} @Get('') @@ -278,6 +284,33 @@ export class SubscribersController { ); } + @Get('/:subscriberId/preferences/global') + @ExternalApiAccessible() + /* @ApiOperation({ + summary: 'Retrieve subscriber global preference', + description: `Retrieve subscriber global preference. This API returns all five global channels preferences and subscriber schedule.`, + }) + @ApiResponse(SubscriberGlobalPreferenceDto) + @SdkGroupName('Subscribers.Preferences') + @SdkMethodName('list') */ + @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) + @RequireAuthentication() + async getSchedule( + @UserSession() user: UserSessionData, + @Param('subscriberId') subscriberId: string + ): Promise { + const globalPreference = await this.getSubscriberGlobalPreference.execute( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: user.organizationId, + environmentId: user.environmentId, + subscriberId: subscriberId, + includeInactiveChannels: false, + }) + ); + + return globalPreference.preference; + } + @Patch('/:subscriberId/preferences/bulk') @ExternalApiAccessible() @ApiOperation({ @@ -339,6 +372,7 @@ export class SubscribersController { subscriberId, workflowIdOrInternalId: body.workflowId, channels: body.channels, + schedule: body.schedule, }) ); } 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 fefffcb1a75..4eae18d8272 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 @@ -45,8 +45,7 @@ export class GetSubscriberPreferences { ); return { - enabled: preference.enabled, - channels: preference.channels, + ...preference, }; } diff --git a/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts b/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts index b4ec4d2705d..54c09c31ee1 100644 --- a/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts +++ b/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts @@ -1,6 +1,7 @@ import { Type } from 'class-transformer'; import { IsDefined, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; +import { ScheduleDto } from '../../../shared/dtos/schedule'; import { PatchPreferenceChannelsDto } from '../../dtos/patch-subscriber-preferences.dto'; export class UpdateSubscriberPreferencesCommand extends EnvironmentWithSubscriber { @@ -11,4 +12,8 @@ export class UpdateSubscriberPreferencesCommand extends EnvironmentWithSubscribe @IsDefined() @Type(() => PatchPreferenceChannelsDto) readonly channels: PatchPreferenceChannelsDto; + + @IsOptional() + @Type(() => ScheduleDto) + readonly schedule?: ScheduleDto; } diff --git a/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.usecase.ts b/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.usecase.ts index 2da5013a6e5..5d67a5593ee 100644 --- a/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.usecase.ts +++ b/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.usecase.ts @@ -38,6 +38,7 @@ export class UpdateSubscriberPreferences { workflowIdOrIdentifier: workflowId, includeInactiveChannels: false, ...command.channels, + schedule: command.schedule, }) ); diff --git a/apps/api/src/app/subscribers/dtos/subscriber-preference.dto.ts b/apps/api/src/app/subscribers/dtos/subscriber-preference.dto.ts index 8428380ec54..e243a0df89f 100644 --- a/apps/api/src/app/subscribers/dtos/subscriber-preference.dto.ts +++ b/apps/api/src/app/subscribers/dtos/subscriber-preference.dto.ts @@ -16,8 +16,8 @@ export class SubscriberPreferenceDto { channels: SubscriberPreferenceChannels; @ApiPropertyOptional({ - type: SubscriberPreferenceOverrideDto, + type: [SubscriberPreferenceOverrideDto], description: 'Overrides for subscriber preferences for the different channels regarding this workflow', }) - overrides?: SubscriberPreferenceOverrideDto; + overrides?: SubscriberPreferenceOverrideDto[]; } 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 296a5146310..a56912bdae6 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 @@ -27,7 +27,11 @@ export class GetSubscriberGlobalPreference { const activeChannels = await this.getActiveChannels(command); - const subscriberGlobalPreference = await this.getSubscriberGlobalPreference(command, subscriber._id); + const subscriberGlobalPreference = await this.getPreferences.getSubscriberGlobalPreference({ + environmentId: command.environmentId, + organizationId: command.organizationId, + subscriberId: subscriber._id, + }); const channelsWithDefaults = this.buildDefaultPreferences(subscriberGlobalPreference.channels); @@ -42,36 +46,11 @@ export class GetSubscriberGlobalPreference { preference: { enabled: subscriberGlobalPreference.enabled, channels, + schedule: subscriberGlobalPreference.schedule, }, }; } - @Instrument() - private async getSubscriberGlobalPreference( - command: GetSubscriberGlobalPreferenceCommand, - subscriberId: string - ): Promise<{ - channels: IPreferenceChannels; - enabled: boolean; - }> { - const subscriberGlobalChannels = await this.getPreferences.getPreferenceChannels({ - environmentId: command.environmentId, - organizationId: command.organizationId, - subscriberId, - }); - - return { - channels: subscriberGlobalChannels ?? { - email: true, - sms: true, - in_app: true, - chat: true, - push: true, - }, - enabled: true, - }; - } - @Instrument() private async getActiveChannels(command: GetSubscriberGlobalPreferenceCommand): Promise { const subscriberWorkflowPreferences = await this.getSubscriberPreference.execute( diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts new file mode 100644 index 00000000000..739f94c9cfc --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts @@ -0,0 +1,9 @@ +import { EnvironmentCommand } from '@novu/application-generic'; +import { IsDefined, IsString } from 'class-validator'; + +export class GetSubscriberScheduleCommand extends EnvironmentCommand { + // database _id + @IsString() + @IsDefined() + _subscriberId: string; +} diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts new file mode 100644 index 00000000000..ff2af69edcd --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { PreferencesRepository } from '@novu/dal'; +import { PreferencesTypeEnum, Schedule } from '@novu/shared'; +import { GetSubscriberScheduleCommand } from './get-subscriber-schedule.command'; + +@Injectable() +export class GetSubscriberSchedule { + constructor(private preferencesRepository: PreferencesRepository) {} + + @InstrumentUsecase() + async execute(command: GetSubscriberScheduleCommand): Promise { + const subscriberGlobalPreference = await this.preferencesRepository.findOne( + { + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _subscriberId: command._subscriberId, + type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + }, + undefined, + { readPreference: 'secondaryPreferred' } + ); + + return subscriberGlobalPreference?.schedule; + } +} diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/index.ts b/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/index.ts new file mode 100644 index 00000000000..b411a610a3a --- /dev/null +++ b/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/index.ts @@ -0,0 +1,2 @@ +export * from './get-subscriber-schedule.command'; +export * from './get-subscriber-schedule.usecase'; diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts index 8f4f5252c2a..40edb3f876d 100644 --- a/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts @@ -1,14 +1,22 @@ -import { PreferencesTypeEnum, WorkflowPreferences, WorkflowPreferencesPartial } from '@novu/shared'; +import { + PreferencesTypeEnum, + Schedule, + SubscriberGlobalPreference, + WorkflowPreferences, + WorkflowPreferencesPartial, +} from '@novu/shared'; export class GetPreferencesResponseDto { preferences: WorkflowPreferences; + schedule?: Schedule; + type: PreferencesTypeEnum; source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: WorkflowPreferences; [PreferencesTypeEnum.USER_WORKFLOW]: WorkflowPreferences | null; - [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: WorkflowPreferencesPartial | null; + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: SubscriberGlobalPreference | null; [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: WorkflowPreferencesPartial | null; }; } 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 4e59ac254a6..f6d16a4211a 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 @@ -2,12 +2,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; import { buildWorkflowPreferences, + FeatureFlagsKeysEnum, IPreferenceChannels, PreferencesTypeEnum, + Schedule, WorkflowPreferences, WorkflowPreferencesPartial, } from '@novu/shared'; -import { InstrumentUsecase } from '../../instrumentation'; +import { Instrument, InstrumentUsecase } from '../../instrumentation'; +import { FeatureFlagsService } from '../../services/feature-flags'; import { MergePreferencesCommand } from '../merge-preferences/merge-preferences.command'; import { MergePreferences } from '../merge-preferences/merge-preferences.usecase'; import { GetPreferencesCommand } from './get-preferences.command'; @@ -36,7 +39,10 @@ class PreferencesNotFoundException extends BadRequestException { @Injectable() export class GetPreferences { - constructor(private preferencesRepository: PreferencesRepository) {} + constructor( + private preferencesRepository: PreferencesRepository, + private featureFlagsService: FeatureFlagsService + ) {} @InstrumentUsecase() async execute(command: GetPreferencesCommand): Promise { @@ -51,20 +57,43 @@ export class GetPreferences { return mergedPreferences; } - /** Get only simple, channel-level enablement flags */ - public async getPreferenceChannels(command: { + @Instrument() + public async getSubscriberGlobalPreference(command: { environmentId: string; organizationId: string; subscriberId: string; - templateId?: string; - }): Promise { + }): Promise<{ + enabled: boolean; + channels: IPreferenceChannels; + schedule?: Schedule; + }> { const result = await this.safeExecute(command); if (!result) { - return undefined; + return { + channels: { + email: true, + sms: true, + in_app: true, + chat: true, + push: true, + }, + enabled: true, + }; } - return GetPreferences.mapWorkflowPreferencesToChannelPreferences(result.preferences); + const isSubscribersScheduleEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED, + defaultValue: false, + environment: { _id: command.environmentId }, + organization: { _id: command.organizationId }, + }); + + return { + enabled: true, + channels: GetPreferences.mapWorkflowPreferencesToChannelPreferences(result.preferences), + schedule: isSubscribersScheduleEnabled ? result.schedule : undefined, + }; } public async safeExecute(command: GetPreferencesCommand): Promise { diff --git a/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts index 42753a2757b..b29fa86a676 100644 --- a/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts @@ -49,6 +49,7 @@ export class MergePreferences { return { preferences: mergedPreferences.preferences, + schedule: mergedPreferences.schedule, type: mergedPreferences.type, source, }; diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts index 8fb35a3b387..3a0f51cf8ba 100644 --- a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts @@ -1,7 +1,13 @@ import { Injectable } from '@nestjs/common'; import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; -import { PreferencesTypeEnum, WorkflowPreferences, WorkflowPreferencesPartial } from '@novu/shared'; +import { + FeatureFlagsKeysEnum, + PreferencesTypeEnum, + WorkflowPreferences, + WorkflowPreferencesPartial, +} from '@novu/shared'; import { Instrument } from '../../instrumentation'; +import { FeatureFlagsService } from '../../services/feature-flags/feature-flags.service'; import { deepMerge } from '../../utils'; import { UpsertSubscriberGlobalPreferencesCommand } from './upsert-subscriber-global-preferences.command'; import { UpsertSubscriberWorkflowPreferencesCommand } from './upsert-subscriber-workflow-preferences.command'; @@ -29,7 +35,10 @@ type UpsertPreferencesCommand = Omit< @Injectable() export class UpsertPreferences { - constructor(private preferencesRepository: PreferencesRepository) {} + constructor( + private preferencesRepository: PreferencesRepository, + private featureFlagsService: FeatureFlagsService + ) {} @Instrument() public async upsertWorkflowPreferences(command: UpsertWorkflowPreferencesCommand): Promise { @@ -49,6 +58,13 @@ export class UpsertPreferences { public async upsertSubscriberGlobalPreferences(command: UpsertSubscriberGlobalPreferencesCommand) { await this.deleteSubscriberWorkflowChannelPreferences(command); + const isSubscribersScheduleEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED, + defaultValue: false, + environment: { _id: command.environmentId }, + organization: { _id: command.organizationId }, + }); + return this.upsert({ _subscriberId: command._subscriberId, environmentId: command.environmentId, @@ -56,6 +72,7 @@ export class UpsertPreferences { preferences: command.preferences, type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, returnPreference: command.returnPreference, + schedule: isSubscribersScheduleEnabled ? command.schedule : undefined, }); } @@ -137,6 +154,7 @@ export class UpsertPreferences { _templateId: command.templateId, preferences: command.preferences, type: command.type, + schedule: command.schedule, }); } @@ -157,6 +175,7 @@ export class UpsertPreferences { { $set: { preferences: mergedPreferences, + schedule: command.schedule, _userId: command.userId, }, } diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts index 29297acbff1..b8df3ba22c3 100644 --- a/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts @@ -1,3 +1,4 @@ +import { Schedule } from '@novu/shared'; import { IsBoolean, IsMongoId, IsNotEmpty, IsOptional } from 'class-validator'; import { UpsertPreferencesPartialBaseCommand } from './upsert-preferences.command'; @@ -9,4 +10,7 @@ export class UpsertSubscriberGlobalPreferencesCommand extends UpsertPreferencesP @IsOptional() @IsBoolean() readonly returnPreference?: boolean = true; + + @IsOptional() + readonly schedule?: Schedule; } diff --git a/libs/dal/src/repositories/preferences/preferences.entity.ts b/libs/dal/src/repositories/preferences/preferences.entity.ts index bef7eabf422..3b84232f9d7 100644 --- a/libs/dal/src/repositories/preferences/preferences.entity.ts +++ b/libs/dal/src/repositories/preferences/preferences.entity.ts @@ -1,4 +1,4 @@ -import type { WorkflowPreferencesPartial } from '@novu/shared'; +import type { Schedule, WorkflowPreferencesPartial } from '@novu/shared'; import { PreferencesTypeEnum } from '@novu/shared'; import type { ChangePropsValueType } from '../../types'; import type { EnvironmentId } from '../environment'; @@ -27,4 +27,6 @@ export class PreferencesEntity { type: PreferencesTypeEnum; preferences: WorkflowPreferencesPartial; + + schedule?: Schedule; } diff --git a/libs/dal/src/repositories/preferences/preferences.schema.ts b/libs/dal/src/repositories/preferences/preferences.schema.ts index 49aaac463cf..a5fc83be915 100644 --- a/libs/dal/src/repositories/preferences/preferences.schema.ts +++ b/libs/dal/src/repositories/preferences/preferences.schema.ts @@ -65,6 +65,7 @@ const preferencesSchema = new Schema( }, }, }, + schedule: Schema.Types.Mixed, }, { ...schemaOptions, minimize: false } ); diff --git a/packages/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts b/packages/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts index 403a879bee6..73e2d7e0183 100644 --- a/packages/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts +++ b/packages/shared/src/entities/subscriber-preference/subscriber-preference.interface.ts @@ -1,5 +1,5 @@ import { SeverityLevelEnum } from '../../consts'; -import { ChannelTypeEnum, PreferenceOverrideSourceEnum, PreferencesTypeEnum } from '../../types'; +import { ChannelTypeEnum, PreferenceOverrideSourceEnum, PreferencesTypeEnum, Schedule } from '../../types'; import { INotificationTrigger } from '../notification-trigger'; export interface IPreferenceChannels { @@ -25,6 +25,7 @@ interface IPreferenceResponse { enabled: boolean; channels: IPreferenceChannels; overrides: IPreferenceOverride[]; + schedule?: Schedule; } export interface ITemplateConfiguration { diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index a3ca314453c..64cfdeae79e 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -69,6 +69,7 @@ export enum FeatureFlagsKeysEnum { IS_WORKFLOW_RUN_PAGE_MIGRATION_ENABLED = 'IS_WORKFLOW_RUN_PAGE_MIGRATION_ENABLED', IS_NOTIFICATION_SEVERITY_ENABLED = 'IS_NOTIFICATION_SEVERITY_ENABLED', IS_EXECUTION_DETAILS_CLICKHOUSE_ONLY_ENABLED = 'IS_EXECUTION_DETAILS_CLICKHOUSE_ONLY_ENABLED', + IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'IS_SUBSCRIBERS_SCHEDULE_ENABLED', // Numeric flags MAX_WORKFLOW_LIMIT_NUMBER = 'MAX_WORKFLOW_LIMIT_NUMBER', diff --git a/packages/shared/src/types/workflow-channel-preferences.ts b/packages/shared/src/types/workflow-channel-preferences.ts index dc1967ef012..8049f1ba32d 100644 --- a/packages/shared/src/types/workflow-channel-preferences.ts +++ b/packages/shared/src/types/workflow-channel-preferences.ts @@ -74,3 +74,37 @@ export type WorkflowPreferences = { /** A partial set of workflow preferences. */ export type WorkflowPreferencesPartial = DeepPartial; + +export type SubscriberGlobalPreference = WorkflowPreferencesPartial & { + /** + * A preference for the schedule. + * + * If no preference is specified, the schedule will be disabled by default. + */ + schedule?: Schedule; +}; + +export type TimeRange = { + start: string; + end: string; +}; + +export type DaySchedule = { + isEnabled: boolean; + hours?: Array; +}; + +export type WeeklySchedule = { + monday?: DaySchedule; + tuesday?: DaySchedule; + wednesday?: DaySchedule; + thursday?: DaySchedule; + friday?: DaySchedule; + saturday?: DaySchedule; + sunday?: DaySchedule; +}; + +export type Schedule = { + isEnabled: boolean; + weeklySchedule?: WeeklySchedule; +}; From 6b6a5ff57d48cd2b980836d77b0ebf375030a779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 5 Sep 2025 15:48:15 +0200 Subject: [PATCH 02/12] chore(api-service): remove unused import --- apps/api/src/app/inbox/inbox.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index 784f9757a67..c8a0a304923 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -19,7 +19,6 @@ import { AddressingTypeEnum, MessageActionStatusEnum, PreferenceLevelEnum, - Schedule, TriggerRequestCategoryEnum, UserSessionData, } from '@novu/shared'; From 4faf03653b0cb4fe4ddaccad19cd7f286d87a556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 5 Sep 2025 16:03:15 +0200 Subject: [PATCH 03/12] chore(api-service): fix function name --- apps/api/src/app/subscribers-v2/subscribers.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/subscribers-v2/subscribers.controller.ts b/apps/api/src/app/subscribers-v2/subscribers.controller.ts index d7d71697343..8f7c3834c2f 100644 --- a/apps/api/src/app/subscribers-v2/subscribers.controller.ts +++ b/apps/api/src/app/subscribers-v2/subscribers.controller.ts @@ -295,7 +295,7 @@ export class SubscribersController { @SdkMethodName('list') */ @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) @RequireAuthentication() - async getSchedule( + async getGlobalPreference( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string ): Promise { From fc0191aa9b7a885df60546f381bdc74e90fd88b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Sat, 6 Sep 2025 00:34:28 +0200 Subject: [PATCH 04/12] feat(js): schedule sub module --- packages/js/scripts/size-limit.mjs | 2 +- packages/js/src/api/inbox-service.ts | 18 +++- packages/js/src/cache/schedule-cache.ts | 81 +++++++++++++++++ packages/js/src/event-emitter/types.ts | 11 ++- packages/js/src/preferences/helpers.ts | 69 ++++++++++++++- packages/js/src/preferences/index.ts | 2 + .../js/src/preferences/preference-schedule.ts | 86 +++++++++++++++++++ packages/js/src/preferences/preferences.ts | 7 ++ packages/js/src/preferences/schedule.ts | 50 +++++++++++ packages/js/src/preferences/types.ts | 14 ++- packages/js/src/types.ts | 24 ++++++ 11 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 packages/js/src/cache/schedule-cache.ts create mode 100644 packages/js/src/preferences/preference-schedule.ts create mode 100644 packages/js/src/preferences/schedule.ts diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index 1bab4d7687b..92fd7ee0b5f 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -20,7 +20,7 @@ const modules = [ { name: 'UMD gzip', filePath: umdGzipPath, - limitInBytes: 50_000, + limitInBytes: 52_000, }, ]; diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index 58702d4ef0c..0225b0bbc55 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -7,6 +7,7 @@ import type { Session, SeverityLevelEnum, Subscriber, + WeeklySchedule, WorkflowCriticalityEnum, } from '../types'; import { HttpClient, HttpClientOptions } from './http-client'; @@ -263,8 +264,15 @@ export class InboxService { return this.#httpClient.patch(`${INBOX_ROUTE}/preferences/bulk`, { preferences }); } - updateGlobalPreferences(channels: ChannelPreference): Promise { - return this.#httpClient.patch(`${INBOX_ROUTE}/preferences`, channels); + updateGlobalPreferences( + preferences: ChannelPreference & { + schedule?: { + isEnabled: boolean; + weeklySchedule?: WeeklySchedule; + }; + } + ): Promise { + return this.#httpClient.patch(`${INBOX_ROUTE}/preferences`, preferences); } updateWorkflowPreferences({ @@ -277,7 +285,11 @@ export class InboxService { return this.#httpClient.patch(`${INBOX_ROUTE}/preferences/${workflowId}`, channels); } - triggerHelloWorldEvent(): Promise { + fetchGlobalPreferences(): Promise { + return this.#httpClient.get(`${INBOX_ROUTE}/preferences/global`); + } + + triggerHelloWorldEvent(): Promise { const payload = { name: 'hello-world', to: { diff --git a/packages/js/src/cache/schedule-cache.ts b/packages/js/src/cache/schedule-cache.ts new file mode 100644 index 00000000000..0473eb58521 --- /dev/null +++ b/packages/js/src/cache/schedule-cache.ts @@ -0,0 +1,81 @@ +import { NovuEventEmitter, PreferenceScheduleEvents } from '../event-emitter'; +import { Schedule } from '../preferences'; +import { InMemoryCache } from './in-memory-cache'; +import type { Cache } from './types'; + +// these events should update the schedule in the cache +const updateEvents: PreferenceScheduleEvents[] = [ + 'preference.schedule.update.pending', + 'preference.schedule.update.resolved', +]; + +const getCacheKey = (): string => { + return 'schedule'; +}; + +export class ScheduleCache { + #emitter: NovuEventEmitter; + #cache: Cache; + + constructor({ emitterInstance }: { emitterInstance: NovuEventEmitter }) { + this.#emitter = emitterInstance; + for (const event of updateEvents) { + this.#emitter.on(event, this.handleScheduleEvent); + } + this.#cache = new InMemoryCache(); + } + + private updateScheduleInCache = (key: string, data: Schedule): boolean => { + const schedule = this.#cache.get(key); + if (!schedule) { + return false; + } + + this.#cache.set(key, data); + + return true; + }; + + private handleScheduleEvent = ({ data }: { data?: Schedule }): void => { + if (!data) { + return; + } + + const uniqueFilterKeys = new Set(); + const keys = this.#cache.keys(); + for (const key of keys) { + const hasUpdatedSchedule = this.updateScheduleInCache(key, data); + + const updatedSchedule = this.#cache.get(key); + if (!hasUpdatedSchedule || !updatedSchedule) { + continue; + } + + uniqueFilterKeys.add(key); + } + + for (const key of uniqueFilterKeys) { + this.#emitter.emit('preference.schedule.get.updated', { + data: this.#cache.get(key)!, + }); + } + }; + + has(): boolean { + return this.#cache.get(getCacheKey()) !== undefined; + } + + set(data: Schedule): void { + this.#cache.set(getCacheKey(), data); + } + + getAll(): Schedule | undefined { + if (this.has()) { + return this.#cache.get(getCacheKey()); + } + } + + clearAll(): void { + this.#cache.clear(); + } +} diff --git a/packages/js/src/event-emitter/types.ts b/packages/js/src/event-emitter/types.ts index 707b1e304bf..b23875464cf 100644 --- a/packages/js/src/event-emitter/types.ts +++ b/packages/js/src/event-emitter/types.ts @@ -15,7 +15,8 @@ import type { UnsnoozeArgs, } from '../notifications'; import { Preference } from '../preferences/preference'; -import { ListPreferencesArgs, UpdatePreferenceArgs } from '../preferences/types'; +import { Schedule } from '../preferences/schedule'; +import { ListPreferencesArgs, UpdatePreferenceArgs, UpdateScheduleArgs } from '../preferences/types'; import type { InitializeSessionArgs } from '../session'; import { Session, WebSocketEvent } from '../types'; @@ -74,6 +75,8 @@ type NotificationsReadArchivedAllEvents = BaseEvents< type PreferencesFetchEvents = BaseEvents<'preferences.list', ListPreferencesArgs, Preference[]>; type PreferenceUpdateEvents = BaseEvents<'preference.update', UpdatePreferenceArgs, Preference>; type PreferencesBulkUpdateEvents = BaseEvents<'preferences.bulk_update', Array, Preference[]>; +type PreferenceScheduleGetEvents = BaseEvents<'preference.schedule.get', undefined, Schedule>; +type PreferenceScheduleUpdateEvents = BaseEvents<'preference.schedule.update', UpdateScheduleArgs, Schedule>; type SocketConnectEvents = BaseEvents<'socket.connect', { socketUrl: string }, undefined>; export type NotificationReceivedEvent = `notifications.${WebSocketEvent.RECEIVED}`; export type NotificationUnseenEvent = `notifications.${WebSocketEvent.UNSEEN}`; @@ -106,7 +109,10 @@ export type Events = SessionInitializeEvents & 'preferences.list.updated': { data: Preference[] }; } & PreferenceUpdateEvents & PreferencesBulkUpdateEvents & - SocketConnectEvents & + PreferenceScheduleGetEvents & + PreferenceScheduleUpdateEvents & { + 'preference.schedule.get.updated': { data: Schedule }; + } & SocketConnectEvents & SocketEvents & NotificationReadEvents & NotificationUnreadEvents & @@ -138,5 +144,6 @@ export type NotificationEvents = keyof (NotificationReadEvents & NotificationsArchivedAllEvents & NotificationsReadArchivedAllEvents); export type PreferenceEvents = keyof (PreferenceUpdateEvents & PreferencesBulkUpdateEvents); +export type PreferenceScheduleEvents = keyof (PreferenceScheduleGetEvents & PreferenceScheduleUpdateEvents); export type EventHandler = (event: T) => void; diff --git a/packages/js/src/preferences/helpers.ts b/packages/js/src/preferences/helpers.ts index 5a00978811a..0729b97b589 100644 --- a/packages/js/src/preferences/helpers.ts +++ b/packages/js/src/preferences/helpers.ts @@ -1,11 +1,13 @@ import { InboxService } from '../api'; import { PreferencesCache } from '../cache/preferences-cache'; +import { ScheduleCache } from '../cache/schedule-cache'; import type { NovuEventEmitter } from '../event-emitter'; import type { ChannelPreference, Result } from '../types'; import { ChannelType, PreferenceLevel } from '../types'; import { NovuError } from '../utils/errors'; import { Preference } from './preference'; -import type { UpdatePreferenceArgs } from './types'; +import { Schedule } from './schedule'; +import type { UpdatePreferenceArgs, UpdateScheduleArgs } from './types'; type UpdatePreferenceParams = { emitter: NovuEventEmitter; @@ -23,6 +25,14 @@ type BulkUpdatePreferenceParams = { args: Array; }; +type UpdateScheduleParams = { + emitter: NovuEventEmitter; + apiService: InboxService; + cache: ScheduleCache; + useCache: boolean; + args: UpdateScheduleArgs; +}; + export const updatePreference = async ({ emitter, apiService, @@ -190,3 +200,60 @@ const optimisticUpdateWorkflowPreferences = ({ } }); }; + +export const updateSchedule = async ({ + emitter, + apiService, + cache, + useCache, + args, +}: UpdateScheduleParams): Result => { + try { + const { isEnabled, weeklySchedule } = args; + const optimisticallyUpdatedSchedule = new Schedule( + { + isEnabled, + weeklySchedule, + }, + { + emitterInstance: emitter, + inboxServiceInstance: apiService, + cache, + useCache, + } + ); + emitter.emit('preference.schedule.update.pending', { args, data: optimisticallyUpdatedSchedule }); + + // Call the API to update global preferences + const response = await apiService.updateGlobalPreferences({ + schedule: { + isEnabled, + weeklySchedule, + }, + }); + + // Create new Schedule instance with updated data + const updatedSchedule = new Schedule( + { + isEnabled: response.schedule?.isEnabled, + weeklySchedule: response.schedule?.weeklySchedule, + }, + { + emitterInstance: emitter, + inboxServiceInstance: apiService, + cache, + useCache, + } + ); + + emitter.emit('preference.schedule.update.resolved', { + args, + data: updatedSchedule, + }); + + return { data: updatedSchedule }; + } catch (error) { + emitter.emit('preference.schedule.update.resolved', { args, error }); + return { error: new NovuError('Failed to update preference', error) }; + } +}; diff --git a/packages/js/src/preferences/index.ts b/packages/js/src/preferences/index.ts index a17a3da1152..26fbc2ee307 100644 --- a/packages/js/src/preferences/index.ts +++ b/packages/js/src/preferences/index.ts @@ -1 +1,3 @@ +export * from './preference-schedule'; export * from './preferences'; +export * from './schedule'; diff --git a/packages/js/src/preferences/preference-schedule.ts b/packages/js/src/preferences/preference-schedule.ts new file mode 100644 index 00000000000..20f04e79b79 --- /dev/null +++ b/packages/js/src/preferences/preference-schedule.ts @@ -0,0 +1,86 @@ +import { InboxService } from '../api'; +import { BaseModule } from '../base-module'; +import { ScheduleCache } from '../cache/schedule-cache'; +import { NovuEventEmitter } from '../event-emitter'; +import { Result } from '../types'; +import { updateSchedule } from './helpers'; +import { Schedule } from './schedule'; +import { UpdateScheduleArgs } from './types'; + +export class PreferenceSchedule extends BaseModule { + #useCache: boolean; + + readonly cache: ScheduleCache; + + constructor({ + useCache, + inboxServiceInstance, + eventEmitterInstance, + }: { + useCache: boolean; + inboxServiceInstance: InboxService; + eventEmitterInstance: NovuEventEmitter; + }) { + super({ + eventEmitterInstance, + inboxServiceInstance, + }); + this.cache = new ScheduleCache({ + emitterInstance: this._emitter, + }); + this.#useCache = useCache; + } + + async get(): Result { + return this.callWithSession(async () => { + try { + let data: Schedule | undefined = this.#useCache ? this.cache.getAll() : undefined; + this._emitter.emit('preference.schedule.get.pending', { args: undefined, data }); + + if (!data) { + const globalPreference = await this._inboxService.fetchGlobalPreferences(); + + data = new Schedule( + { + isEnabled: globalPreference?.schedule?.isEnabled, + weeklySchedule: globalPreference?.schedule?.weeklySchedule, + }, + { + emitterInstance: this._emitter, + inboxServiceInstance: this._inboxService, + cache: this.cache, + useCache: this.#useCache, + } + ); + + if (this.#useCache) { + this.cache.set(data); + data = this.cache.getAll(); + } + } + + this._emitter.emit('preference.schedule.get.resolved', { + args: undefined, + data, + }); + + return { data }; + } catch (error) { + this._emitter.emit('preference.schedule.get.resolved', { args: undefined, error }); + throw error; + } + }); + } + + async update(args: UpdateScheduleArgs): Result { + return this.callWithSession(() => + updateSchedule({ + emitter: this._emitter, + apiService: this._inboxService, + cache: this.cache, + useCache: this.#useCache, + args, + }) + ); + } +} diff --git a/packages/js/src/preferences/preferences.ts b/packages/js/src/preferences/preferences.ts index ddfa4a6c1f3..5b12a83beb6 100644 --- a/packages/js/src/preferences/preferences.ts +++ b/packages/js/src/preferences/preferences.ts @@ -5,12 +5,14 @@ import { NovuEventEmitter } from '../event-emitter'; import { Result, WorkflowCriticalityEnum } from '../types'; import { bulkUpdatePreference, updatePreference } from './helpers'; import { Preference } from './preference'; +import { PreferenceSchedule } from './preference-schedule'; import type { BasePreferenceArgs, InstancePreferenceArgs, ListPreferencesArgs, UpdatePreferenceArgs } from './types'; export class Preferences extends BaseModule { #useCache: boolean; readonly cache: PreferencesCache; + readonly schedule: PreferenceSchedule; constructor({ useCache, @@ -29,6 +31,11 @@ export class Preferences extends BaseModule { emitterInstance: this._emitter, }); this.#useCache = useCache; + this.schedule = new PreferenceSchedule({ + useCache, + inboxServiceInstance, + eventEmitterInstance, + }); } async list(args: ListPreferencesArgs = {}): Result { diff --git a/packages/js/src/preferences/schedule.ts b/packages/js/src/preferences/schedule.ts new file mode 100644 index 00000000000..6c59fa590ab --- /dev/null +++ b/packages/js/src/preferences/schedule.ts @@ -0,0 +1,50 @@ +import { InboxService } from '../api'; +import { ScheduleCache } from '../cache/schedule-cache'; +import { NovuEventEmitter } from '../event-emitter'; +import { Result, WeeklySchedule } from '../types'; +import { updateSchedule } from './helpers'; +import { UpdateScheduleArgs } from './types'; + +type ScheduleLike = Pick; + +export class Schedule { + #emitter: NovuEventEmitter; + #apiService: InboxService; + #cache: ScheduleCache; + #useCache: boolean; + + readonly isEnabled: boolean | undefined; + readonly weeklySchedule: WeeklySchedule | undefined; + + constructor( + schedule: ScheduleLike, + { + emitterInstance, + inboxServiceInstance, + cache, + useCache, + }: { + emitterInstance: NovuEventEmitter; + inboxServiceInstance: InboxService; + cache: ScheduleCache; + useCache: boolean; + } + ) { + this.#emitter = emitterInstance; + this.#apiService = inboxServiceInstance; + this.#cache = cache; + this.#useCache = useCache; + this.isEnabled = schedule.isEnabled; + this.weeklySchedule = schedule.weeklySchedule; + } + + async update(args: UpdateScheduleArgs): Result { + return updateSchedule({ + emitter: this.#emitter, + apiService: this.#apiService, + cache: this.#cache, + useCache: this.#useCache, + args, + }); + } +} diff --git a/packages/js/src/preferences/types.ts b/packages/js/src/preferences/types.ts index 688f0a47f41..01638529832 100644 --- a/packages/js/src/preferences/types.ts +++ b/packages/js/src/preferences/types.ts @@ -1,4 +1,11 @@ -import { ChannelPreference, Preference, PreferenceLevel, SeverityLevelEnum, WorkflowCriticalityEnum } from '../types'; +import { + ChannelPreference, + Preference, + PreferenceLevel, + SeverityLevelEnum, + WeeklySchedule, + WorkflowCriticalityEnum, +} from '../types'; export type FetchPreferencesArgs = { level?: PreferenceLevel; @@ -24,3 +31,8 @@ export type InstancePreferenceArgs = { }; export type UpdatePreferenceArgs = BasePreferenceArgs | InstancePreferenceArgs; + +export type UpdateScheduleArgs = { + isEnabled: boolean; + weeklySchedule?: WeeklySchedule; +}; diff --git a/packages/js/src/types.ts b/packages/js/src/types.ts index 8e14bf7c8e0..2f2bd46e130 100644 --- a/packages/js/src/types.ts +++ b/packages/js/src/types.ts @@ -178,12 +178,36 @@ export type PaginatedResponse = { page: number; }; +export type TimeRange = { + start: string; + end: string; +}; + +export type DaySchedule = { + isEnabled: boolean; + hours?: Array; +}; + +export type WeeklySchedule = { + monday?: DaySchedule; + tuesday?: DaySchedule; + wednesday?: DaySchedule; + thursday?: DaySchedule; + friday?: DaySchedule; + saturday?: DaySchedule; + sunday?: DaySchedule; +}; + export type PreferencesResponse = { level: PreferenceLevel; enabled: boolean; channels: ChannelPreference; overrides?: IPreferenceOverride[]; workflow?: Workflow; + schedule?: { + isEnabled: boolean; + weeklySchedule?: WeeklySchedule; + }; }; export enum PreferenceOverrideSourceEnum { From 592725e877c1aa632b79a37d6e37281f9997b156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Mon, 8 Sep 2025 12:51:22 +0200 Subject: [PATCH 05/12] chore(api-service): fixed failing unit tests --- .../api/src/app/inbox/usecases/session/session.spec.ts | 10 +++++++++- apps/api/src/app/preferences/preferences.spec.ts | 4 ++++ .../get-subscriber-global-preference.usecase.ts | 6 ++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/inbox/usecases/session/session.spec.ts b/apps/api/src/app/inbox/usecases/session/session.spec.ts index 7bcbd471526..318f063e844 100644 --- a/apps/api/src/app/inbox/usecases/session/session.spec.ts +++ b/apps/api/src/app/inbox/usecases/session/session.spec.ts @@ -24,10 +24,12 @@ import { AuthService } from '../../../auth/services/auth.service'; import { GenerateUniqueApiKey } from '../../../environments-v1/usecases/generate-unique-api-key/generate-unique-api-key.usecase'; import { CreateNovuIntegrations } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; import { GetOrganizationSettings } from '../../../organization/usecases/get-organization-settings/get-organization-settings.usecase'; +import { GetSubscriberSchedule } from '../../../subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase'; import { SubscriberSessionResponseDto } from '../../dtos/subscriber-session-response.dto'; import { AnalyticsEventsEnum } from '../../utils'; import * as encryption from '../../utils/encryption'; import { NotificationsCount } from '../notifications-count/notifications-count.usecase'; +import { UpdatePreferences } from '../update-preferences/update-preferences.usecase'; import { SessionCommand } from './session.command'; import { Session } from './session.usecase'; @@ -70,6 +72,8 @@ describe('Session', () => { let logger: sinon.SinonStubbedInstance; let featureFlagsService: sinon.SinonStubbedInstance; let messageRepository: sinon.SinonStubbedInstance; + let getSubscriberSchedule: sinon.SinonStubbedInstance; + let updatePreferencesUsecase: sinon.SinonStubbedInstance; beforeEach(() => { environmentRepository = sinon.createStubInstance(EnvironmentRepository); @@ -92,6 +96,8 @@ describe('Session', () => { logger = sinon.createStubInstance(PinoLogger); featureFlagsService = sinon.createStubInstance(FeatureFlagsService); messageRepository = sinon.createStubInstance(MessageRepository); + getSubscriberSchedule = sinon.createStubInstance(GetSubscriberSchedule); + updatePreferencesUsecase = sinon.createStubInstance(UpdatePreferences); session = new Session( environmentRepository as any, @@ -113,7 +119,9 @@ describe('Session', () => { upsertControlValuesUseCase as any, getOrganizationSettingsUsecase as any, logger as any, - featureFlagsService as any + featureFlagsService as any, + getSubscriberSchedule as any, + updatePreferencesUsecase as any ); }); diff --git a/apps/api/src/app/preferences/preferences.spec.ts b/apps/api/src/app/preferences/preferences.spec.ts index 1dbfc629c28..02aeed957ba 100644 --- a/apps/api/src/app/preferences/preferences.spec.ts +++ b/apps/api/src/app/preferences/preferences.spec.ts @@ -336,6 +336,7 @@ describe('Preferences', () => { }, }, }, + schedule: undefined, type: PreferencesTypeEnum.WORKFLOW_RESOURCE, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { @@ -430,6 +431,7 @@ describe('Preferences', () => { }, }, }, + schedule: undefined, type: PreferencesTypeEnum.USER_WORKFLOW, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { @@ -546,6 +548,7 @@ describe('Preferences', () => { }, }, }, + schedule: undefined, type: PreferencesTypeEnum.USER_WORKFLOW, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { @@ -685,6 +688,7 @@ describe('Preferences', () => { }, }, }, + schedule: undefined, type: PreferencesTypeEnum.USER_WORKFLOW, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { 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 a56912bdae6..b527c4513da 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 @@ -8,7 +8,7 @@ import { InstrumentUsecase, } from '@novu/application-generic'; import { SubscriberEntity, SubscriberRepository } from '@novu/dal'; -import { ChannelTypeEnum, IPreferenceChannels, WorkflowCriticalityEnum } from '@novu/shared'; +import { ChannelTypeEnum, IPreferenceChannels, Schedule, WorkflowCriticalityEnum } from '@novu/shared'; import { GetSubscriberPreferenceCommand } from '../get-subscriber-preference'; import { GetSubscriberPreference } from '../get-subscriber-preference/get-subscriber-preference.usecase'; import { GetSubscriberGlobalPreferenceCommand } from './get-subscriber-global-preference.command'; @@ -22,7 +22,9 @@ export class GetSubscriberGlobalPreference { ) {} @InstrumentUsecase() - async execute(command: GetSubscriberGlobalPreferenceCommand) { + async execute( + command: GetSubscriberGlobalPreferenceCommand + ): Promise<{ preference: { enabled: boolean; channels: IPreferenceChannels; schedule?: Schedule } }> { const subscriber = command.subscriber ?? (await this.getSubscriber(command)); const activeChannels = await this.getActiveChannels(command); From 4a3cd916fe10d1659076fd24237bc123c8c04f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Tue, 9 Sep 2025 17:20:43 +0200 Subject: [PATCH 06/12] feat(js): inbox subscribers schedule --- apps/api/src/app/inbox/e2e/session.e2e.ts | 36 --- .../app/inbox/e2e/update-preferences.e2e.ts | 26 -- .../weekly-schedule-disabled.validator.ts | 15 -- .../components/auth/inbox-preview-content.tsx | 1 + .../dashboard/src/components/inbox-button.tsx | 5 + packages/js/src/api/inbox-service.ts | 2 +- packages/js/src/cache/preferences-cache.ts | 59 ++++- packages/js/src/preferences/helpers.ts | 12 +- .../js/src/preferences/preference-schedule.ts | 6 +- packages/js/src/preferences/preference.ts | 14 +- packages/js/src/preferences/preferences.ts | 9 + packages/js/src/preferences/schedule.ts | 13 +- packages/js/src/preferences/types.ts | 2 +- .../elements/Preferences/DayScheduleCopy.tsx | 200 +++++++++++++++ .../elements/Preferences/Preferences.tsx | 2 + .../elements/Preferences/ScheduleRow.tsx | 207 +++++++++++++++ .../elements/Preferences/ScheduleTable.tsx | 239 ++++++++++++++++++ .../components/elements/Preferences/utils.ts | 11 + .../src/ui/components/primitives/Checkbox.tsx | 54 ++++ .../js/src/ui/components/primitives/Input.tsx | 1 + .../primitives/Popover/PopoverRoot.tsx | 27 +- .../ui/components/primitives/TimeSelect.tsx | 103 ++++++++ .../js/src/ui/components/primitives/index.ts | 2 + .../components/shared/IconRendererWrapper.tsx | 16 ++ packages/js/src/ui/config/appearanceKeys.ts | 38 +++ .../js/src/ui/config/defaultLocalization.ts | 17 ++ packages/js/src/ui/icons/CalendarSchedule.tsx | 12 + packages/js/src/ui/icons/Copy.tsx | 12 + packages/js/src/ui/icons/index.ts | 2 + packages/js/src/ui/types.ts | 32 ++- packages/js/src/utils/strings.ts | 3 + 31 files changed, 1079 insertions(+), 99 deletions(-) create mode 100644 packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx create mode 100644 packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx create mode 100644 packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx create mode 100644 packages/js/src/ui/components/elements/Preferences/utils.ts create mode 100644 packages/js/src/ui/components/primitives/Checkbox.tsx create mode 100644 packages/js/src/ui/components/primitives/TimeSelect.tsx create mode 100644 packages/js/src/ui/icons/CalendarSchedule.tsx create mode 100644 packages/js/src/ui/icons/Copy.tsx create mode 100644 packages/js/src/utils/strings.ts diff --git a/apps/api/src/app/inbox/e2e/session.e2e.ts b/apps/api/src/app/inbox/e2e/session.e2e.ts index f545ad95fd5..ea58b098c7d 100644 --- a/apps/api/src/app/inbox/e2e/session.e2e.ts +++ b/apps/api/src/app/inbox/e2e/session.e2e.ts @@ -732,42 +732,6 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { expect(body.data.schedule.weeklySchedule).to.not.exist; }); - it('should fail validation when isEnabled is false but weeklySchedule is provided', async () => { - await setIntegrationConfig( - { - _environmentId: session.environment._id, - _organizationId: session.environment._organizationId, - hmac: false, - }, - invalidateCache - ); - - const defaultSchedule = { - isEnabled: false, - weeklySchedule: { - monday: { - isEnabled: true, - hours: [{ start: '09:00 AM', end: '05:00 PM' }], - }, - }, - }; - - const { body, status } = await initializeSession({ - applicationIdentifier: session.environment.identifier, - subscriberId: `schedule-validation-${randomBytes(4).toString('hex')}`, - defaultSchedule, - }); - - expect(status).to.equal(422); - expect(body.message).to.equal('Validation Error'); - expect(body.errors).to.exist; - expect(body.errors.general).to.exist; - expect(body.errors.general.messages).to.be.an('array'); - expect(body.errors.general.messages[0]).to.contain( - 'weeklySchedule should not be provided when isEnabled is false' - ); - }); - it('should create schedule with isEnabled true when weeklySchedule is not provided', async () => { await setIntegrationConfig( { diff --git a/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts b/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts index 976905be12f..0196a59a8f5 100644 --- a/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts +++ b/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts @@ -194,32 +194,6 @@ describe('Update global preferences - /inbox/preferences (PATCH) #novu-v2', () = expect(response.body.data.schedule.weeklySchedule.monday.hours[1].end).to.equal('05:00 PM'); }); - it('should fail validation when isEnabled is false but weeklySchedule is provided', async () => { - const schedule = { - isEnabled: false, - weeklySchedule: { - monday: { - isEnabled: true, - hours: [{ start: '09:00 AM', end: '05:00 PM' }], - }, - }, - }; - - const response = await session.testAgent - .patch('/v1/inbox/preferences') - .send({ - schedule, - }) - .set('Authorization', `Bearer ${session.subscriberToken}`); - - expect(response.status).to.equal(422); - expect(response.body.message).to.equal('Validation Error'); - expect(response.body.errors.general.messages).to.be.an('array'); - expect(response.body.errors.general.messages[0]).to.contain( - 'weeklySchedule should not be provided when isEnabled is false' - ); - }); - it('should fail validation when isEnabled is true but weeklySchedule is empty', async () => { const schedule = { isEnabled: true, diff --git a/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts b/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts index c033d6fd899..d84131fa50f 100644 --- a/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts +++ b/apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts @@ -13,13 +13,6 @@ export function WeeklyScheduleValidation(validationOptions?: ValidationOptions) validate(value: unknown, args: ValidationArguments) { const obj = args.object as { isEnabled?: boolean }; - // If isEnabled is false and weeklySchedule exists, validation fails - if (obj.isEnabled === false && value !== undefined && value !== null) { - return false; - } - if (obj.isEnabled === true && (value === undefined || value === null)) { - return false; - } if (obj.isEnabled === true && value && Object.keys(value).length === 0) { return false; } @@ -33,14 +26,6 @@ export function WeeklyScheduleValidation(validationOptions?: ValidationOptions) const obj = args.object as { isEnabled?: boolean }; const value = args.value; - if (obj.isEnabled === false && value !== undefined && value !== null) { - return 'weeklySchedule should not be provided when isEnabled is false'; - } - - if (obj.isEnabled === true && (value === undefined || value === null)) { - return 'weeklySchedule is required when isEnabled is true'; - } - if (obj.isEnabled === true && value && Object.keys(value).length === 0) { return 'weeklySchedule must contain at least one day configuration when isEnabled is true'; } diff --git a/apps/dashboard/src/components/auth/inbox-preview-content.tsx b/apps/dashboard/src/components/auth/inbox-preview-content.tsx index c2a8832631c..6cbafdffa09 100644 --- a/apps/dashboard/src/components/auth/inbox-preview-content.tsx +++ b/apps/dashboard/src/components/auth/inbox-preview-content.tsx @@ -63,6 +63,7 @@ export function InboxPreviewContent() { notificationListEmptyNotice: { marginTop: '-32px', }, + scheduleContainer: 'hidden', }, }, tabs: defaultTabs, diff --git a/apps/dashboard/src/components/inbox-button.tsx b/apps/dashboard/src/components/inbox-button.tsx index 1183c6c053e..a4e43bc0b0a 100644 --- a/apps/dashboard/src/components/inbox-button.tsx +++ b/apps/dashboard/src/components/inbox-button.tsx @@ -128,6 +128,11 @@ export const InboxButton = () => { 'preferences.title': `Preferences${localizationTestSuffix}`, 'notifications.emptyNotice': `${isTestPage ? 'This is a test inbox. Send a notification to preview it in real-time.' : 'No notifications'}`, }} + appearance={{ + elements: { + scheduleContainer: 'hidden', + }, + }} > diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index 0225b0bbc55..8c1ac5ce51a 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -267,7 +267,7 @@ export class InboxService { updateGlobalPreferences( preferences: ChannelPreference & { schedule?: { - isEnabled: boolean; + isEnabled?: boolean; weeklySchedule?: WeeklySchedule; }; } diff --git a/packages/js/src/cache/preferences-cache.ts b/packages/js/src/cache/preferences-cache.ts index a9cb779abc0..c09b9674b79 100644 --- a/packages/js/src/cache/preferences-cache.ts +++ b/packages/js/src/cache/preferences-cache.ts @@ -1,4 +1,5 @@ -import { NovuEventEmitter, PreferenceEvents } from '../event-emitter'; +import { NovuEventEmitter, PreferenceEvents, PreferenceScheduleEvents } from '../event-emitter'; +import { Schedule } from '../preferences'; import { Preference } from '../preferences/preference'; import { ListPreferencesArgs } from '../preferences/types'; import { PreferenceLevel } from '../types'; @@ -13,6 +14,11 @@ const updateEvents: PreferenceEvents[] = [ 'preferences.bulk_update.resolved', ]; +const scheduleUpdateEvents: PreferenceScheduleEvents[] = [ + 'preference.schedule.update.pending', + 'preference.schedule.update.resolved', +]; + const excludeEmpty = ({ tags, severity }: ListPreferencesArgs) => Object.entries({ tags, severity }).reduce((acc, [key, value]) => { if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) { @@ -34,9 +40,12 @@ export class PreferencesCache { constructor({ emitterInstance }: { emitterInstance: NovuEventEmitter }) { this.#emitter = emitterInstance; - updateEvents.forEach((event) => { + for (const event of updateEvents) { this.#emitter.on(event, this.handlePreferenceEvent); - }); + } + for (const event of scheduleUpdateEvents) { + this.#emitter.on(event, this.handleScheduleEvent); + } this.#cache = new InMemoryCache(); } @@ -62,6 +71,50 @@ export class PreferencesCache { return true; }; + private updatePreferenceSchedule = (key: string, data: Schedule): boolean => { + const preferences = this.#cache.get(key); + if (!preferences) { + return false; + } + + const index = preferences.findIndex((el) => el.level === PreferenceLevel.GLOBAL); + if (index === -1) { + return false; + } + + const updatedPreferences = [...preferences]; + updatedPreferences[index].schedule = data; + + this.#cache.set(key, updatedPreferences); + + return true; + }; + + private handleScheduleEvent = ({ data }: { data?: Schedule }): void => { + if (!data) { + return; + } + + const cacheKeys = this.#cache.keys(); + const uniqueFilterKeys = new Set(); + for (const key of cacheKeys) { + const hasUpdatedPreference = this.updatePreferenceSchedule(key, data); + + const updatedPreference = this.#cache.get(key); + if (!hasUpdatedPreference || !updatedPreference) { + continue; + } + + uniqueFilterKeys.add(key); + } + + for (const key of uniqueFilterKeys) { + this.#emitter.emit('preferences.list.updated', { + data: this.#cache.get(key) ?? [], + }); + } + }; + private handlePreferenceEvent = ({ data }: { data?: Preference | Preference[] }): void => { if (!data) { return; diff --git a/packages/js/src/preferences/helpers.ts b/packages/js/src/preferences/helpers.ts index 0729b97b589..614832350f7 100644 --- a/packages/js/src/preferences/helpers.ts +++ b/packages/js/src/preferences/helpers.ts @@ -13,6 +13,7 @@ type UpdatePreferenceParams = { emitter: NovuEventEmitter; apiService: InboxService; cache: PreferencesCache; + scheduleCache: ScheduleCache; useCache: boolean; args: UpdatePreferenceArgs; }; @@ -21,6 +22,7 @@ type BulkUpdatePreferenceParams = { emitter: NovuEventEmitter; apiService: InboxService; cache: PreferencesCache; + scheduleCache: ScheduleCache; useCache: boolean; args: Array; }; @@ -37,6 +39,7 @@ export const updatePreference = async ({ emitter, apiService, cache, + scheduleCache, useCache, args, }: UpdatePreferenceParams): Result => { @@ -60,6 +63,7 @@ export const updatePreference = async ({ emitterInstance: emitter, inboxServiceInstance: apiService, cache, + scheduleCache, useCache, } ) @@ -70,7 +74,7 @@ export const updatePreference = async ({ if (workflowId) { response = await apiService.updateWorkflowPreferences({ workflowId, channels }); } else { - optimisticUpdateWorkflowPreferences({ emitter, apiService, cache, useCache, args }); + optimisticUpdateWorkflowPreferences({ emitter, apiService, cache, scheduleCache, useCache, args }); response = await apiService.updateGlobalPreferences(channels); } @@ -78,6 +82,7 @@ export const updatePreference = async ({ emitterInstance: emitter, inboxServiceInstance: apiService, cache, + scheduleCache, useCache, }); emitter.emit('preference.update.resolved', { args, data: preference }); @@ -94,6 +99,7 @@ export const bulkUpdatePreference = async ({ emitter, apiService, cache, + scheduleCache, useCache, args, }: BulkUpdatePreferenceParams): Result => { @@ -118,6 +124,7 @@ export const bulkUpdatePreference = async ({ emitterInstance: emitter, inboxServiceInstance: apiService, cache, + scheduleCache, useCache, } ) @@ -145,6 +152,7 @@ export const bulkUpdatePreference = async ({ emitterInstance: emitter, inboxServiceInstance: apiService, cache, + scheduleCache, useCache, }) ); @@ -162,6 +170,7 @@ const optimisticUpdateWorkflowPreferences = ({ emitter, apiService, cache, + scheduleCache, useCache, args, }: UpdatePreferenceParams): void => { @@ -184,6 +193,7 @@ const optimisticUpdateWorkflowPreferences = ({ emitterInstance: emitter, inboxServiceInstance: apiService, cache, + scheduleCache, useCache, }) : undefined; diff --git a/packages/js/src/preferences/preference-schedule.ts b/packages/js/src/preferences/preference-schedule.ts index 20f04e79b79..036067cdeb7 100644 --- a/packages/js/src/preferences/preference-schedule.ts +++ b/packages/js/src/preferences/preference-schedule.ts @@ -13,10 +13,12 @@ export class PreferenceSchedule extends BaseModule { readonly cache: ScheduleCache; constructor({ + cache, useCache, inboxServiceInstance, eventEmitterInstance, }: { + cache: ScheduleCache; useCache: boolean; inboxServiceInstance: InboxService; eventEmitterInstance: NovuEventEmitter; @@ -25,9 +27,7 @@ export class PreferenceSchedule extends BaseModule { eventEmitterInstance, inboxServiceInstance, }); - this.cache = new ScheduleCache({ - emitterInstance: this._emitter, - }); + this.cache = cache; this.#useCache = useCache; } diff --git a/packages/js/src/preferences/preference.ts b/packages/js/src/preferences/preference.ts index 9a0984657e8..a700c7b70cc 100644 --- a/packages/js/src/preferences/preference.ts +++ b/packages/js/src/preferences/preference.ts @@ -1,22 +1,26 @@ +import { ScheduleCache } from 'src/cache/schedule-cache'; import { InboxService } from '../api'; import { PreferencesCache } from '../cache/preferences-cache'; import { NovuEventEmitter } from '../event-emitter'; import { ChannelPreference, PreferenceLevel, Prettify, Result, Workflow } from '../types'; import { updatePreference } from './helpers'; +import { Schedule, ScheduleLike } from './schedule'; import { UpdatePreferenceArgs } from './types'; -type PreferenceLike = Pick; +type PreferenceLike = Pick & { schedule?: ScheduleLike }; export class Preference { #emitter: NovuEventEmitter; #apiService: InboxService; #cache: PreferencesCache; + #scheduleCache: ScheduleCache; #useCache: boolean; readonly level: PreferenceLevel; readonly enabled: boolean; readonly channels: ChannelPreference; readonly workflow?: Workflow; + schedule: Schedule; constructor( preference: PreferenceLike, @@ -24,22 +28,29 @@ export class Preference { emitterInstance, inboxServiceInstance, cache, + scheduleCache, useCache, }: { emitterInstance: NovuEventEmitter; inboxServiceInstance: InboxService; cache: PreferencesCache; + scheduleCache: ScheduleCache; useCache: boolean; } ) { this.#emitter = emitterInstance; this.#apiService = inboxServiceInstance; this.#cache = cache; + this.#scheduleCache = scheduleCache; this.#useCache = useCache; this.level = preference.level; this.enabled = preference.enabled; this.channels = preference.channels; this.workflow = preference.workflow; + this.schedule = new Schedule( + { ...preference.schedule }, + { emitterInstance, inboxServiceInstance, cache: scheduleCache, useCache } + ); } update({ @@ -55,6 +66,7 @@ export class Preference { emitter: this.#emitter, apiService: this.#apiService, cache: this.#cache, + scheduleCache: this.#scheduleCache, useCache: this.#useCache, args: { workflowId: this.workflow?.id, diff --git a/packages/js/src/preferences/preferences.ts b/packages/js/src/preferences/preferences.ts index 5b12a83beb6..74ac399e0cc 100644 --- a/packages/js/src/preferences/preferences.ts +++ b/packages/js/src/preferences/preferences.ts @@ -1,6 +1,7 @@ import { InboxService } from '../api'; import { BaseModule } from '../base-module'; import { PreferencesCache } from '../cache/preferences-cache'; +import { ScheduleCache } from '../cache/schedule-cache'; import { NovuEventEmitter } from '../event-emitter'; import { Result, WorkflowCriticalityEnum } from '../types'; import { bulkUpdatePreference, updatePreference } from './helpers'; @@ -12,6 +13,7 @@ export class Preferences extends BaseModule { #useCache: boolean; readonly cache: PreferencesCache; + readonly scheduleCache: ScheduleCache; readonly schedule: PreferenceSchedule; constructor({ @@ -30,8 +32,12 @@ export class Preferences extends BaseModule { this.cache = new PreferencesCache({ emitterInstance: this._emitter, }); + this.scheduleCache = new ScheduleCache({ + emitterInstance: this._emitter, + }); this.#useCache = useCache; this.schedule = new PreferenceSchedule({ + cache: this.scheduleCache, useCache, inboxServiceInstance, eventEmitterInstance, @@ -56,6 +62,7 @@ export class Preferences extends BaseModule { emitterInstance: this._emitter, inboxServiceInstance: this._inboxService, cache: this.cache, + scheduleCache: this.scheduleCache, useCache: this.#useCache, }) ); @@ -84,6 +91,7 @@ export class Preferences extends BaseModule { emitter: this._emitter, apiService: this._inboxService, cache: this.cache, + scheduleCache: this.scheduleCache, useCache: this.#useCache, args, }) @@ -98,6 +106,7 @@ export class Preferences extends BaseModule { emitter: this._emitter, apiService: this._inboxService, cache: this.cache, + scheduleCache: this.scheduleCache, useCache: this.#useCache, args, }) diff --git a/packages/js/src/preferences/schedule.ts b/packages/js/src/preferences/schedule.ts index 6c59fa590ab..ad8a76c1744 100644 --- a/packages/js/src/preferences/schedule.ts +++ b/packages/js/src/preferences/schedule.ts @@ -5,7 +5,7 @@ import { Result, WeeklySchedule } from '../types'; import { updateSchedule } from './helpers'; import { UpdateScheduleArgs } from './types'; -type ScheduleLike = Pick; +export type ScheduleLike = Partial>; export class Schedule { #emitter: NovuEventEmitter; @@ -44,7 +44,16 @@ export class Schedule { apiService: this.#apiService, cache: this.#cache, useCache: this.#useCache, - args, + args: { + isEnabled: this.isEnabled, + ...((args.weeklySchedule || this.weeklySchedule) && { + weeklySchedule: { + ...this.weeklySchedule, + ...args.weeklySchedule, + }, + }), + ...args, + }, }); } } diff --git a/packages/js/src/preferences/types.ts b/packages/js/src/preferences/types.ts index 01638529832..4a990a89776 100644 --- a/packages/js/src/preferences/types.ts +++ b/packages/js/src/preferences/types.ts @@ -33,6 +33,6 @@ export type InstancePreferenceArgs = { export type UpdatePreferenceArgs = BasePreferenceArgs | InstancePreferenceArgs; export type UpdateScheduleArgs = { - isEnabled: boolean; + isEnabled?: boolean; weeklySchedule?: WeeklySchedule; }; diff --git a/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx b/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx new file mode 100644 index 00000000000..7b503af149a --- /dev/null +++ b/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx @@ -0,0 +1,200 @@ +import { Accessor, createEffect, createMemo, createSignal, createUniqueId, For } from 'solid-js'; +import { WeeklySchedule } from 'src/types'; +import { Schedule } from '../../../../preferences'; +import { useLocalization } from '../../../../ui/context/LocalizationContext'; +import { cn } from '../../../../ui/helpers'; +import { useStyle } from '../../../../ui/helpers/useStyle'; +import { Copy } from '../../../../ui/icons'; +import { AppearanceCallback } from '../../../../ui/types'; +import { Button, Checkbox, Dropdown } from '../../primitives'; +import { Tooltip } from '../../primitives/Tooltip'; +import { IconRenderer } from '../../shared/IconRendererWrapper'; +import { weekDays } from './utils'; + +const NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT = 'novu.close-day-schedule-copy-component'; + +type DayScheduleCopyProps = { + day: Accessor; + schedule: Accessor; + disabled?: boolean; +}; + +export const DayScheduleCopy = (props: DayScheduleCopyProps) => { + const id = createUniqueId(); + const style = useStyle(); + const { t } = useLocalization(); + const [isOpen, setIsOpen] = createSignal(false); + const [selectedDays, setSelectedDays] = createSignal>([props.day()]); + const [isAllSelected, setIsAllSelected] = createSignal(false); + const allWeekDaysSelected = createMemo(() => selectedDays().length === weekDays.length); + const reset = () => { + setSelectedDays([props.day()]); + setIsAllSelected(false); + setIsOpen(false); + }; + const onOpenChange = createMemo(() => (isOpen: boolean) => { + if (isOpen) { + // close other copy times to dropdowns + document.dispatchEvent(new CustomEvent(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, { detail: { id } })); + } + setTimeout(() => { + // set is open after a short delay to ensure nicer animation + if (!isOpen) { + reset(); + } else { + setIsOpen(isOpen); + } + }, 50); + }); + + createEffect(() => { + const listener = (event: CustomEvent<{ id: string }>) => { + const data = event.detail; + if (data.id !== id) { + reset(); + } + }; + + // @ts-expect-error custom event + document.addEventListener(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, listener); + + return () => { + // @ts-expect-error custom event + document.removeEventListener(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, listener); + }; + }); + + return ( + + ( + + + + + + [0], + })} + data-localization="preferences.schedule.dayScheduleCopy.title" + > + {t('preferences.schedule.dayScheduleCopy.title')} + + [0], + })} + data-localization="preferences.schedule.dayScheduleCopy.selectAll" + > + { + setIsAllSelected(checked); + setSelectedDays(checked ? weekDays : [props.day()]); + }} + /> + {t('preferences.schedule.dayScheduleCopy.selectAll')} + + + {(day) => ( + [0], + })} + data-localization={`preferences.schedule.${day}`} + > + + setSelectedDays(value ? [...selectedDays(), day] : selectedDays().filter((d) => d !== day)) + } + checked={selectedDays().includes(day) || day === props.day()} + disabled={day === props.day()} + /> + {t(`preferences.schedule.${day}`)} + + )} + +
[0], + })} + > + +
+
+
+ )} + /> + + {t('preferences.schedule.copyTimesTo')} + +
+ ); +}; diff --git a/packages/js/src/ui/components/elements/Preferences/Preferences.tsx b/packages/js/src/ui/components/elements/Preferences/Preferences.tsx index 1817fc841c1..67c3daf30a5 100644 --- a/packages/js/src/ui/components/elements/Preferences/Preferences.tsx +++ b/packages/js/src/ui/components/elements/Preferences/Preferences.tsx @@ -10,6 +10,7 @@ import { DefaultPreferences } from './DefaultPreferences'; import { GroupedPreferences } from './GroupedPreferences'; import { PreferencesListSkeleton } from './PreferencesListSkeleton'; import { PreferencesRow } from './PreferencesRow'; +import { ScheduleRow } from './ScheduleRow'; /* This is also going to be exported as a separate component. Keep it pure. */ export const Preferences = () => { @@ -118,6 +119,7 @@ export const Preferences = () => { preference={allPreferences().globalPreference} onChange={() => updatePreference(allPreferences().globalPreference)} /> + 0} fallback={ diff --git a/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx b/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx new file mode 100644 index 00000000000..8420bec1076 --- /dev/null +++ b/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx @@ -0,0 +1,207 @@ +import { Accessor, createMemo, createSignal, JSX, Setter, Show } from 'solid-js'; +import { Schedule } from '../../../../preferences/schedule'; +import { Preference } from '../../../../types'; +import { useLocalization } from '../../../context'; +import { useStyle } from '../../../helpers/useStyle'; +import { ArrowDropDown, CalendarSchedule } from '../../../icons'; +import { Info } from '../../../icons/Info'; +import { AppearanceCallback } from '../../../types'; +import { Collapsible } from '../../primitives/Collapsible'; +import { Switch } from '../../primitives/Switch'; +import { IconRenderer } from '../../shared/IconRendererWrapper'; +import { ScheduleTable } from './ScheduleTable'; + +const ScheduleRowHeader = (props: { + schedule: Accessor; + children: JSX.Element; + isOpened: Accessor; + setIsOpened: Setter; +}) => { + const style = useStyle(); + + return ( + + ); +}; + +const ScheduleRowLabel = (props: { schedule: Accessor; isOpened: Accessor }) => { + const style = useStyle(); + const { t } = useLocalization(); + + return ( +
[0], + })} + > + [0], + })} + fallback={CalendarSchedule} + /> + [0], + })} + data-open={props.isOpened()} + data-localization="preferences.schedule.title" + > + {t('preferences.schedule.title')} + +
+ ); +}; + +const ScheduleRowActions = (props: { schedule: Accessor; isOpened: Accessor }) => { + const style = useStyle(); + + return ( +
[0], + })} + > + + props.schedule()?.update({ + isEnabled: state === 'enabled', + }) + } + /> + [0], + })} + data-open={props.isOpened()} + > + + +
+ ); +}; + +const ScheduleRowBody = (props: { isOpened: Accessor; globalPreference: Preference | undefined }) => { + const style = useStyle(); + const { t } = useLocalization(); + const schedule = createMemo(() => props.globalPreference?.schedule); + + return ( +
[0], + })} + > + [0], + })} + data-localization="preferences.schedule.description" + > + {t('preferences.schedule.description')} + + +
[0], + })} + > + [0], + })} + fallback={Info} + /> + + {t('preferences.schedule.info')} + +
+
+ ); +}; + +type ScheduleRowProps = { + globalPreference?: Preference; +}; + +export const ScheduleRow = (props: ScheduleRowProps) => { + const style = useStyle(); + const [isOpened, setIsOpened] = createSignal(false); + + const channels = createMemo(() => Object.keys(props.globalPreference?.channels ?? {})); + const schedule = createMemo(() => props.globalPreference?.schedule); + + return ( + 0}> +
[0], + })} + > + + + + + + + +
+
+ ); +}; diff --git a/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx b/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx new file mode 100644 index 00000000000..67b8b4904c0 --- /dev/null +++ b/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx @@ -0,0 +1,239 @@ +import { createMemo, Index, JSX } from 'solid-js'; +import { Schedule } from '../../../../preferences'; +import { Preference } from '../../../../preferences/preference'; +import { useLocalization } from '../../../../ui/context/LocalizationContext'; +import { cn } from '../../../../ui/helpers'; +import { useStyle } from '../../../../ui/helpers/useStyle'; +import { AppearanceCallback } from '../../../../ui/types'; +import { TimeSelect } from '../../primitives'; +import { Switch } from '../../primitives/Switch'; +import { DayScheduleCopy } from './DayScheduleCopy'; +import { weekDays } from './utils'; + +type ScheduleTableHeaderProps = { + schedule?: Schedule; + children: JSX.Element; +}; + +const ScheduleTableHeader = (props: ScheduleTableHeaderProps) => { + const style = useStyle(); + return ( +
[0], + })} + > + {props.children} +
+ ); +}; + +type ScheduleTableHeaderColumnProps = { + schedule?: Schedule; + children: JSX.Element; + class?: string; + dataLocalization?: string; +}; + +const ScheduleTableHeaderColumn = (props: ScheduleTableHeaderColumnProps) => { + const style = useStyle(); + return ( +
[0], + })} + data-localization={props.dataLocalization} + > + {props.children} +
+ ); +}; + +type ScheduleTableBodyProps = { + schedule?: Schedule; + children: JSX.Element; +}; + +const ScheduleTableBody = (props: ScheduleTableBodyProps) => { + const style = useStyle(); + return ( +
[0], + })} + > + {props.children} +
+ ); +}; + +type ScheduleTableRowProps = { + schedule?: Schedule; + children: JSX.Element; +}; + +const ScheduleTableRow = (props: ScheduleTableRowProps) => { + const style = useStyle(); + return ( +
[0], + })} + > + {props.children} +
+ ); +}; + +type ScheduleTableCellProps = { + schedule?: Schedule; + children: JSX.Element; + class?: string; +}; +const ScheduleBodyColumn = (props: ScheduleTableCellProps) => { + const style = useStyle(); + return ( +
[0], + })} + > + {props.children} +
+ ); +}; + +type ScheduleTableProps = { + globalPreference?: Preference; +}; + +export const ScheduleTable = (props: ScheduleTableProps) => { + const style = useStyle(); + const { t } = useLocalization(); + const isScheduleDisabled = createMemo(() => !props.globalPreference?.schedule?.isEnabled); + const schedule = createMemo(() => props.globalPreference?.schedule); + + return ( +
[0], + })} + > + + + {t('preferences.schedule.days')} + + + {t('preferences.schedule.from')} + + + {t('preferences.schedule.to')} + + + + + {(day) => { + const isDayDisabled = createMemo(() => !schedule()?.weeklySchedule?.[day()]?.isEnabled); + + return ( + + + { + const isEnabled = state === 'enabled'; + const hasNoHours = (schedule()?.weeklySchedule?.[day()]?.hours?.length ?? 0) === 0; + + schedule()?.update({ + weeklySchedule: { + ...schedule()?.weeklySchedule, + [day()]: { + ...schedule()?.weeklySchedule?.[day()], + isEnabled, + ...(hasNoHours && { hours: [{ start: '09:00 AM', end: '05:00 PM' }] }), + }, + }, + }); + }} + disabled={isScheduleDisabled()} + /> + + {t(`preferences.schedule.${day()}`)} + + + + + { + schedule()?.update({ + weeklySchedule: { + ...schedule()?.weeklySchedule, + [day()]: { + ...schedule()?.weeklySchedule?.[day()], + hours: [ + { + start: value, + end: schedule()?.weeklySchedule?.[day()]?.hours?.[0]?.end, + }, + ], + }, + }, + }); + }} + /> + + + { + schedule()?.update({ + weeklySchedule: { + ...schedule()?.weeklySchedule, + [day()]: { + ...schedule()?.weeklySchedule?.[day()], + hours: [ + { + start: schedule()?.weeklySchedule?.[day()]?.hours?.[0]?.start, + end: value, + }, + ], + }, + }, + }); + }} + /> + + + ); + }} + + +
+ ); +}; diff --git a/packages/js/src/ui/components/elements/Preferences/utils.ts b/packages/js/src/ui/components/elements/Preferences/utils.ts new file mode 100644 index 00000000000..53e93898624 --- /dev/null +++ b/packages/js/src/ui/components/elements/Preferences/utils.ts @@ -0,0 +1,11 @@ +import { WeeklySchedule } from '../../../../types'; + +export const weekDays: Array = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +]; diff --git a/packages/js/src/ui/components/primitives/Checkbox.tsx b/packages/js/src/ui/components/primitives/Checkbox.tsx new file mode 100644 index 00000000000..96033ce36b0 --- /dev/null +++ b/packages/js/src/ui/components/primitives/Checkbox.tsx @@ -0,0 +1,54 @@ +import * as CheckboxPrimitive from '@kobalte/core/checkbox'; +import type { PolymorphicProps } from '@kobalte/core/polymorphic'; +import type { ValidComponent } from 'solid-js'; +import { Match, Switch, splitProps } from 'solid-js'; +import { cn } from '../../helpers/utils'; + +type CheckboxRootProps = CheckboxPrimitive.CheckboxRootProps & { + class?: string | undefined; +}; + +const Checkbox = (props: PolymorphicProps>) => { + const [local, others] = splitProps(props as CheckboxRootProps, ['class']); + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export { Checkbox }; diff --git a/packages/js/src/ui/components/primitives/Input.tsx b/packages/js/src/ui/components/primitives/Input.tsx index a2897b8b1e4..bc02edc0ecc 100644 --- a/packages/js/src/ui/components/primitives/Input.tsx +++ b/packages/js/src/ui/components/primitives/Input.tsx @@ -16,6 +16,7 @@ export const inputVariants = cva( size: { default: 'nt-h-9', sm: 'nt-h-8 nt-text-sm', + xs: 'nt-h-7 nt-text-xs', }, }, defaultVariants: { diff --git a/packages/js/src/ui/components/primitives/Popover/PopoverRoot.tsx b/packages/js/src/ui/components/primitives/Popover/PopoverRoot.tsx index a9f4ba12334..298cc8b641b 100644 --- a/packages/js/src/ui/components/primitives/Popover/PopoverRoot.tsx +++ b/packages/js/src/ui/components/primitives/Popover/PopoverRoot.tsx @@ -1,4 +1,4 @@ -import { autoUpdate, flip, OffsetOptions, offset, Placement, shift, ShiftOptions } from '@floating-ui/dom'; +import { autoUpdate, flip, OffsetOptions, offset, Placement, shift } from '@floating-ui/dom'; import { useFloating } from 'solid-floating-ui'; import { Accessor, createContext, createMemo, createSignal, JSX, Setter, useContext } from 'solid-js'; @@ -7,7 +7,7 @@ type PopoverRootProps = { children?: JSX.Element; fallbackPlacements?: Placement[]; placement?: Placement; - onOpenChange?: Setter; + onOpenChange?: (isOpen: boolean) => void; offset?: OffsetOptions; }; @@ -26,7 +26,6 @@ const PopoverContext = createContext(undefined) export function PopoverRoot(props: PopoverRootProps) { const [uncontrolledIsOpen, setUncontrolledIsOpen] = createSignal(props.open ?? false); - const onOpenChange = () => props.onOpenChange ?? setUncontrolledIsOpen; const open = () => props.open ?? uncontrolledIsOpen(); const [reference, setReference] = createSignal(null); const [floating, setFloating] = createSignal(null); @@ -36,14 +35,14 @@ export function PopoverRoot(props: PopoverRootProps) { placement: props.placement, whileElementsMounted: autoUpdate, middleware: [ - offset(10), - flip({ fallbackPlacements: props.fallbackPlacements }), + offset(10), + flip({ fallbackPlacements: props.fallbackPlacements }), // Configure shift to prevent layout overflow and UI shifts shift({ padding: 8, crossAxis: false, // Prevent horizontal shifting that causes layout gaps - mainAxis: true // Allow vertical shifting only - }) + mainAxis: true, // Allow vertical shifting only + }), ], }); const floatingStyles = createMemo(() => ({ @@ -53,11 +52,21 @@ export function PopoverRoot(props: PopoverRootProps) { })); const onClose = () => { - onOpenChange()(false); + if (props.onOpenChange) { + props.onOpenChange(false); + return; + } + + setUncontrolledIsOpen(false); }; const onToggle = () => { - onOpenChange()((prev) => !prev); + if (props.onOpenChange) { + props.onOpenChange(!props.open); + return; + } + + setUncontrolledIsOpen((prev) => !prev); }; return ( diff --git a/packages/js/src/ui/components/primitives/TimeSelect.tsx b/packages/js/src/ui/components/primitives/TimeSelect.tsx new file mode 100644 index 00000000000..6cdd5865f69 --- /dev/null +++ b/packages/js/src/ui/components/primitives/TimeSelect.tsx @@ -0,0 +1,103 @@ +import { createMemo, For, Show } from 'solid-js'; +import { cn } from '../../../ui/helpers'; +import { useStyle } from '../../../ui/helpers/useStyle'; +import { Check } from '../../../ui/icons'; +import { Dropdown, dropdownItemVariants } from '../primitives'; +import { inputVariants } from '../primitives/Input'; +import { IconRenderer } from '../shared/IconRendererWrapper'; + +type TimeSelectProps = { + disabled?: boolean; + value?: string; + onChange?: (value: string) => void; +}; + +const hours = Array.from({ length: 48 }, (_, i) => { + const hour = Math.floor(i / 2); + const minute = i % 2 === 0 ? '00' : '30'; + const period = hour < 12 ? 'AM' : 'PM'; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const formattedHour = displayHour.toString().padStart(2, '0'); + + return `${formattedHour}:${minute} ${period}`; +}); + +type TimeSelectItemProps = { + hour: string; + isSelected: boolean; + onClick: () => void; +}; + +const TimeSelectItem = (props: TimeSelectItemProps) => { + const style = useStyle(); + + return ( + + {props.hour} + + + + + ); +}; + +export const TimeSelect = (props: TimeSelectProps) => { + const style = useStyle(); + const time = createMemo(() => props.value?.split(' ')[0]); + const amPm = createMemo(() => { + if (!time()) return ''; + + return props.value?.split(' ')[1] === 'PM' ? 'PM' : 'AM'; + }); + + return ( + + + + {time() ?? '-'} + {amPm() && {amPm()}} + + + + + {(hour) => ( + props.onChange?.(hour)} /> + )} + + + + ); +}; diff --git a/packages/js/src/ui/components/primitives/index.ts b/packages/js/src/ui/components/primitives/index.ts index ce3f70a48a8..74990e9d0d6 100644 --- a/packages/js/src/ui/components/primitives/index.ts +++ b/packages/js/src/ui/components/primitives/index.ts @@ -1,7 +1,9 @@ export * from './Button'; +export * from './Checkbox'; export * from './CopyToClipboard'; export * from './DatePicker'; export * from './Dropdown'; export * from './Motion'; export * from './Popover'; export * from './Tabs'; +export * from './TimeSelect'; diff --git a/packages/js/src/ui/components/shared/IconRendererWrapper.tsx b/packages/js/src/ui/components/shared/IconRendererWrapper.tsx index be641f38c3a..d06727fc2fd 100644 --- a/packages/js/src/ui/components/shared/IconRendererWrapper.tsx +++ b/packages/js/src/ui/components/shared/IconRendererWrapper.tsx @@ -19,3 +19,19 @@ export const IconRendererWrapper = (props: IconRendererWrapperProps) => {
); }; + +type IconRendererProps = { + iconKey: IconKey; + class?: string; + fallback: (props?: JSX.HTMLAttributes) => JSX.Element; +}; + +export const IconRenderer = (props: IconRendererProps) => { + return ( + } + /> + ); +}; diff --git a/packages/js/src/ui/config/appearanceKeys.ts b/packages/js/src/ui/config/appearanceKeys.ts index 1de91550caf..0d8cee5eab8 100644 --- a/packages/js/src/ui/config/appearanceKeys.ts +++ b/packages/js/src/ui/config/appearanceKeys.ts @@ -240,6 +240,44 @@ export const appearanceKeys = [ 'preferencesList__skeletonSwitchThumb', 'preferencesList__skeletonText', + // Schedule + 'scheduleContainer', + 'scheduleHeader', + 'scheduleLabelContainer', + 'scheduleLabelIcon', + 'scheduleLabel', + 'scheduleActionsContainer', + 'scheduleActionsContainerRight', + 'scheduleBody', + 'scheduleDescription', + 'scheduleTable', + 'scheduleTableHeader', + 'scheduleHeaderColumn', + 'scheduleTableBody', + 'scheduleBodyRow', + 'scheduleBodyColumn', + 'scheduleInfoContainer', + 'scheduleInfoIcon', + 'scheduleInfo', + + // Day Schedule Copy + 'dayScheduleCopyTitle', + 'dayScheduleCopyIcon', + 'dayScheduleCopySelectAll', + 'dayScheduleCopyDay', + 'dayScheduleCopyFooterContainer', + 'dayScheduleCopy__dropdownTrigger', + 'dayScheduleCopy__dropdownContent', + + // Time Select + 'timeSelect__dropdownTrigger', + 'timeSelect__time', + 'timeSelect__dropdownContent', + 'timeSelect__dropdownItem', + 'timeSelect__dropdownItemLabel', + 'timeSelect__dropdownItemLabelContainer', + 'timeSelect__dropdownItemCheck__icon', + // Notification Snooze 'notificationSnooze__dropdownContent', 'notificationSnooze__dropdownItem', diff --git a/packages/js/src/ui/config/defaultLocalization.ts b/packages/js/src/ui/config/defaultLocalization.ts index 4b57d8b7448..88a437b06e5 100644 --- a/packages/js/src/ui/config/defaultLocalization.ts +++ b/packages/js/src/ui/config/defaultLocalization.ts @@ -28,6 +28,23 @@ export const defaultLocalization = { 'preferences.title': 'Preferences', 'preferences.emptyNotice': 'No notification specific preferences yet.', 'preferences.global': 'Global Preferences', + 'preferences.schedule.title': 'Schedule', + 'preferences.schedule.description': 'Allow notifications between:', + 'preferences.schedule.info': 'Critical and In-app notifications still reach you outside your schedule.', + 'preferences.schedule.days': 'Days', + 'preferences.schedule.from': 'From', + 'preferences.schedule.to': 'To', + 'preferences.schedule.copyTimesTo': 'Copy times to', + 'preferences.schedule.sunday': 'Sunday', + 'preferences.schedule.monday': 'Monday', + 'preferences.schedule.tuesday': 'Tuesday', + 'preferences.schedule.wednesday': 'Wednesday', + 'preferences.schedule.thursday': 'Thursday', + 'preferences.schedule.friday': 'Friday', + 'preferences.schedule.saturday': 'Saturday', + 'preferences.schedule.dayScheduleCopy.title': 'Copy times to:', + 'preferences.schedule.dayScheduleCopy.selectAll': 'Select all', + 'preferences.schedule.dayScheduleCopy.apply': 'Apply', 'preferences.workflow.disabled.notice': 'Contact admin to enable subscription management for this critical notification.', 'preferences.workflow.disabled.tooltip': 'Contact admin to edit', diff --git a/packages/js/src/ui/icons/CalendarSchedule.tsx b/packages/js/src/ui/icons/CalendarSchedule.tsx new file mode 100644 index 00000000000..fc881dacd24 --- /dev/null +++ b/packages/js/src/ui/icons/CalendarSchedule.tsx @@ -0,0 +1,12 @@ +import { JSX } from 'solid-js'; + +export const CalendarSchedule = (props?: JSX.HTMLAttributes) => { + return ( + + + + ); +}; diff --git a/packages/js/src/ui/icons/Copy.tsx b/packages/js/src/ui/icons/Copy.tsx new file mode 100644 index 00000000000..f8c21a98d9b --- /dev/null +++ b/packages/js/src/ui/icons/Copy.tsx @@ -0,0 +1,12 @@ +import { JSX } from 'solid-js'; + +export const Copy = (props?: JSX.HTMLAttributes) => { + return ( + + + + ); +}; diff --git a/packages/js/src/ui/icons/index.ts b/packages/js/src/ui/icons/index.ts index 5897ce9a307..caac9180ee1 100644 --- a/packages/js/src/ui/icons/index.ts +++ b/packages/js/src/ui/icons/index.ts @@ -3,10 +3,12 @@ export * from './ArrowDropDown'; export * from './ArrowLeft'; export * from './ArrowRight'; export * from './Bell'; +export * from './CalendarSchedule'; export * from './Chat'; export * from './Check'; export * from './Clock'; export * from './Cogs'; +export * from './Copy'; export * from './Dots'; export * from './Email'; export * from './InApp'; diff --git a/packages/js/src/ui/types.ts b/packages/js/src/ui/types.ts index 2d356a2800f..b24c9faab08 100644 --- a/packages/js/src/ui/types.ts +++ b/packages/js/src/ui/types.ts @@ -1,3 +1,4 @@ +import { Schedule } from 'src/preferences'; import type { Notification } from '../notifications'; import { Novu } from '../novu'; import { @@ -126,6 +127,33 @@ export type AppearanceCallback = { preferenceGroup?: { name: string; preferences: Preference[] }; }) => string; + // Schedule + scheduleContainer: (context: { schedule?: Schedule }) => string; + scheduleHeader: (context: { schedule?: Schedule }) => string; + scheduleLabelContainer: (context: { schedule?: Schedule }) => string; + scheduleLabelIcon: (context: { schedule?: Schedule }) => string; + scheduleLabel: (context: { schedule?: Schedule }) => string; + scheduleActionsContainer: (context: { schedule?: Schedule }) => string; + scheduleActionsContainerRight: (context: { schedule?: Schedule }) => string; + scheduleBody: (context: { schedule?: Schedule }) => string; + scheduleDescription: (context: { schedule?: Schedule }) => string; + scheduleTable: (context: { schedule?: Schedule }) => string; + scheduleTableHeader: (context: { schedule?: Schedule }) => string; + scheduleHeaderColumn: (context: { schedule?: Schedule }) => string; + scheduleTableBody: (context: { schedule?: Schedule }) => string; + scheduleBodyRow: (context: { schedule?: Schedule }) => string; + scheduleBodyColumn: (context: { schedule?: Schedule }) => string; + scheduleInfoContainer: (context: { schedule?: Schedule }) => string; + scheduleInfoIcon: (context: { schedule?: Schedule }) => string; + scheduleInfo: (context: { schedule?: Schedule }) => string; + + // Day Schedule Copy + dayScheduleCopyTitle: (context: { schedule?: Schedule }) => string; + dayScheduleCopyIcon: (context: { schedule?: Schedule }) => string; + dayScheduleCopySelectAll: (context: { schedule?: Schedule }) => string; + dayScheduleCopyDay: (context: { schedule?: Schedule }) => string; + dayScheduleCopyFooterContainer: (context: { schedule?: Schedule }) => string; + // Preferences Group preferencesGroupContainer: (context: { preferenceGroup: { name: string; preferences: Preference[] } }) => string; preferencesGroupHeader: (context: { preferenceGroup: { name: string; preferences: Preference[] } }) => string; @@ -211,7 +239,9 @@ export type IconKey = | 'arrowDown' | 'routeFill' | 'info' - | 'nodeTree'; + | 'nodeTree' + | 'calendarSchedule' + | 'copy'; export type IconRenderer = (el: HTMLDivElement, props: { class?: string }) => () => void; diff --git a/packages/js/src/utils/strings.ts b/packages/js/src/utils/strings.ts new file mode 100644 index 00000000000..45c8e8e431f --- /dev/null +++ b/packages/js/src/utils/strings.ts @@ -0,0 +1,3 @@ +export const capitalize = (str: string) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; From 50414a97630491cc582348096a6b3fe7d456db9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 10 Sep 2025 10:59:55 +0200 Subject: [PATCH 07/12] chore(root): satisfy cspell --- .cspell.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index 5190d2133c5..bb785400ffa 100644 --- a/.cspell.json +++ b/.cspell.json @@ -762,7 +762,8 @@ "Brandname", "telco", "unifonic", - "smsmode" + "smsmode", + "kobalte" ], "flagWords": [], "patterns": [ From d49cb441699c30820be2c7a2858005488550f9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Wed, 10 Sep 2025 13:25:46 +0200 Subject: [PATCH 08/12] feat(react,js): default schedule and useSchedule hook --- packages/js/src/api/inbox-service.ts | 4 ++ packages/js/src/index.ts | 5 ++ packages/js/src/novu.ts | 1 + packages/js/src/preferences/preference.ts | 2 +- packages/js/src/preferences/schedule.ts | 7 ++- packages/js/src/session/session.ts | 3 +- packages/js/src/session/types.ts | 3 +- packages/js/src/types.ts | 7 +++ packages/nextjs/src/server/index.ts | 1 + packages/react/src/components/Inbox.tsx | 3 + packages/react/src/hooks/NovuProvider.tsx | 17 +++++- packages/react/src/hooks/index.ts | 1 + packages/react/src/hooks/useSchedule.ts | 73 +++++++++++++++++++++++ packages/react/src/index.ts | 4 +- packages/react/src/server/index.tsx | 12 +++- packages/react/src/utils/types.ts | 3 +- 16 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/hooks/useSchedule.ts diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index 8c1ac5ce51a..91553267a9c 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -1,6 +1,7 @@ import type { ActionTypeEnum, ChannelPreference, + DefaultSchedule, InboxNotification, NotificationFilter, PreferencesResponse, @@ -29,15 +30,18 @@ export class InboxService { applicationIdentifier, subscriberHash, subscriber, + defaultSchedule, }: { applicationIdentifier?: string; subscriberHash?: string; subscriber?: Subscriber; + defaultSchedule?: DefaultSchedule; }): Promise { const response = (await this.#httpClient.post(`${INBOX_ROUTE}/session`, { applicationIdentifier, subscriberHash, subscriber, + defaultSchedule, })) as Session; this.#httpClient.setAuthorizationToken(response.token); this.#httpClient.setKeylessHeader(response.applicationIdentifier); diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index e82255d7896..113db182200 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -3,6 +3,8 @@ export { Novu } from './novu'; export { ChannelPreference, ChannelType, + DaySchedule, + DefaultSchedule, FiltersCountResponse, InboxNotification, ListNotificationsResponse, @@ -14,11 +16,14 @@ export { Preference, PreferenceLevel, PreferencesResponse, + Schedule, SeverityLevelEnum, StandardNovuOptions, Subscriber, + TimeRange, UnreadCount, WebSocketEvent, + WeeklySchedule, WorkflowCriticalityEnum, } from './types'; export { diff --git a/packages/js/src/novu.ts b/packages/js/src/novu.ts index 28137ba18b5..fe8ffccfec0 100644 --- a/packages/js/src/novu.ts +++ b/packages/js/src/novu.ts @@ -44,6 +44,7 @@ export class Novu implements Pick { applicationIdentifier: options.applicationIdentifier || '', subscriberHash: options.subscriberHash, subscriber: buildSubscriber({ subscriberId: options.subscriberId, subscriber: options.subscriber }), + defaultSchedule: options.defaultSchedule, }, this.#inboxService, this.#emitter diff --git a/packages/js/src/preferences/preference.ts b/packages/js/src/preferences/preference.ts index a700c7b70cc..b0161197789 100644 --- a/packages/js/src/preferences/preference.ts +++ b/packages/js/src/preferences/preference.ts @@ -1,6 +1,6 @@ -import { ScheduleCache } from 'src/cache/schedule-cache'; import { InboxService } from '../api'; import { PreferencesCache } from '../cache/preferences-cache'; +import { ScheduleCache } from '../cache/schedule-cache'; import { NovuEventEmitter } from '../event-emitter'; import { ChannelPreference, PreferenceLevel, Prettify, Result, Workflow } from '../types'; import { updatePreference } from './helpers'; diff --git a/packages/js/src/preferences/schedule.ts b/packages/js/src/preferences/schedule.ts index ad8a76c1744..cba545f1115 100644 --- a/packages/js/src/preferences/schedule.ts +++ b/packages/js/src/preferences/schedule.ts @@ -39,20 +39,21 @@ export class Schedule { } async update(args: UpdateScheduleArgs): Result { + const hasWeeklySchedule = !!args.weeklySchedule || !!this.weeklySchedule; + return updateSchedule({ emitter: this.#emitter, apiService: this.#apiService, cache: this.#cache, useCache: this.#useCache, args: { - isEnabled: this.isEnabled, - ...((args.weeklySchedule || this.weeklySchedule) && { + isEnabled: args.isEnabled ?? this.isEnabled, + ...(hasWeeklySchedule && { weeklySchedule: { ...this.weeklySchedule, ...args.weeklySchedule, }, }), - ...args, }, }); } diff --git a/packages/js/src/session/session.ts b/packages/js/src/session/session.ts index 839dc264a91..99a5d2946ef 100644 --- a/packages/js/src/session/session.ts +++ b/packages/js/src/session/session.ts @@ -64,7 +64,7 @@ export class Session { if (options) { this.#options = options; } - const { subscriber, subscriberHash, applicationIdentifier } = this.#options; + const { subscriber, subscriberHash, applicationIdentifier, defaultSchedule } = this.#options; let currentTimezone; if (isBrowser()) { currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -89,6 +89,7 @@ export class Session { subscriberId: subscriber?.subscriberId ?? '', timezone: subscriber?.timezone ?? currentTimezone, }, + defaultSchedule, }); if (response?.applicationIdentifier?.startsWith('pk_keyless_')) { diff --git a/packages/js/src/session/types.ts b/packages/js/src/session/types.ts index b71a95c131d..9db5f9e9947 100644 --- a/packages/js/src/session/types.ts +++ b/packages/js/src/session/types.ts @@ -1,4 +1,4 @@ -import { Subscriber } from '../types'; +import { DefaultSchedule, Subscriber } from '../types'; export type KeylessInitializeSessionArgs = {} & { [K in string]?: never }; // empty object,disallows all unknown keys @@ -8,4 +8,5 @@ export type InitializeSessionArgs = applicationIdentifier: string; subscriber: Subscriber; subscriberHash?: string; + defaultSchedule?: DefaultSchedule; }; diff --git a/packages/js/src/types.ts b/packages/js/src/types.ts index 2f2bd46e130..01be81ffbd3 100644 --- a/packages/js/src/types.ts +++ b/packages/js/src/types.ts @@ -2,6 +2,7 @@ import { NovuError } from './utils/errors'; export type { FiltersCountResponse, ListNotificationsResponse, Notification } from './notifications'; export type { Preference } from './preferences/preference'; +export type { Schedule } from './preferences/schedule'; export type { NovuError } from './utils/errors'; declare global { @@ -198,6 +199,11 @@ export type WeeklySchedule = { sunday?: DaySchedule; }; +export type DefaultSchedule = { + isEnabled?: boolean; + weeklySchedule?: WeeklySchedule; +}; + export type PreferencesResponse = { level: PreferenceLevel; enabled: boolean; @@ -240,6 +246,7 @@ export type StandardNovuOptions = { apiUrl?: string; socketUrl?: string; useCache?: boolean; + defaultSchedule?: DefaultSchedule; } & ( | { // TODO: Backward compatibility support - remove in future versions (see NV-5801) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 2487d20cc34..fa4c24f4161 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -13,5 +13,6 @@ export { useNotifications, useNovu, usePreferences, + useSchedule, WorkflowCriticalityEnum, } from '@novu/react/server'; diff --git a/packages/react/src/components/Inbox.tsx b/packages/react/src/components/Inbox.tsx index 39c9dbe5599..c4ce796e623 100644 --- a/packages/react/src/components/Inbox.tsx +++ b/packages/react/src/components/Inbox.tsx @@ -112,6 +112,7 @@ export const Inbox = React.memo((props: InboxProps) => { backendUrl: props.backendUrl, socketUrl: props.socketUrl, subscriber, + defaultSchedule: props.defaultSchedule, } satisfies StandardNovuOptions; return ( @@ -136,6 +137,7 @@ const InboxChild = withRenderer( backendUrl, socketUrl, subscriber, + defaultSchedule, } = props; const novu = useNovu(); @@ -153,6 +155,7 @@ const InboxChild = withRenderer( backendUrl, socketUrl, subscriber: buildSubscriber({ subscriberId, subscriber }), + defaultSchedule, }, }; }, [ diff --git a/packages/react/src/hooks/NovuProvider.tsx b/packages/react/src/hooks/NovuProvider.tsx index 5d1a3c8445b..49fd62dea3c 100644 --- a/packages/react/src/hooks/NovuProvider.tsx +++ b/packages/react/src/hooks/NovuProvider.tsx @@ -2,9 +2,9 @@ import { Novu, NovuOptions } from '@novu/js'; import { buildSubscriber } from '@novu/js/internal'; import { createContext, ReactNode, useContext, useEffect, useMemo } from 'react'; -// @ts-ignore +// @ts-expect-error const version = PACKAGE_VERSION; -// @ts-ignore +// @ts-expect-error const name = PACKAGE_NAME; const baseUserAgent = `${name}@${version}`; @@ -43,7 +43,17 @@ export const InternalNovuProvider = (props: NovuProviderProps & { userAgentType: const applicationIdentifier = props.applicationIdentifier || ''; const subscriberObj = buildSubscriber({ subscriberId: props.subscriberId, subscriber: props.subscriber }); - const { children, subscriberId, subscriberHash, backendUrl, apiUrl, socketUrl, useCache, userAgentType } = props; + const { + children, + subscriberId, + subscriberHash, + backendUrl, + apiUrl, + socketUrl, + useCache, + userAgentType, + defaultSchedule, + } = props; const novu = useMemo( () => @@ -56,6 +66,7 @@ export const InternalNovuProvider = (props: NovuProviderProps & { userAgentType: useCache, __userAgent: `${baseUserAgent} ${userAgentType}`, subscriber: subscriberObj, + defaultSchedule, }), [applicationIdentifier, subscriberHash, backendUrl, apiUrl, socketUrl, useCache, userAgentType] ); diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index f3f27495c60..a3c4e47f707 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -22,3 +22,4 @@ export { NovuProvider, useNovu } from './NovuProvider'; export * from './useCounts'; export * from './useNotifications'; export * from './usePreferences'; +export * from './useSchedule'; diff --git a/packages/react/src/hooks/useSchedule.ts b/packages/react/src/hooks/useSchedule.ts new file mode 100644 index 00000000000..91312677443 --- /dev/null +++ b/packages/react/src/hooks/useSchedule.ts @@ -0,0 +1,73 @@ +import { NovuError, Schedule } from '@novu/js'; +import { useEffect, useState } from 'react'; +import { useNovu } from './NovuProvider'; + +export type UseScheduleProps = { + onSuccess?: (data: Schedule) => void; + onError?: (error: NovuError) => void; +}; + +export type UseScheduleResult = { + schedule?: Schedule; + error?: NovuError; + isLoading: boolean; // initial loading + isFetching: boolean; // the request is in flight + refetch: () => Promise; +}; + +export const useSchedule = (props?: UseScheduleProps): UseScheduleResult => { + const { onSuccess, onError } = props || {}; + const [data, setData] = useState(); + const { preferences, on } = useNovu(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [isFetching, setIsFetching] = useState(false); + + const sync = (event: { data?: Schedule }) => { + if (!event.data) { + return; + } + setData(event.data); + }; + + useEffect(() => { + fetchSchedule(); + + const listUpdatedCleanup = on('preference.schedule.get.updated', sync); + const listPendingCleanup = on('preference.schedule.get.pending', sync); + const listResolvedCleanup = on('preference.schedule.get.resolved', sync); + + return () => { + listUpdatedCleanup(); + listPendingCleanup(); + listResolvedCleanup(); + }; + }, []); + + const fetchSchedule = async () => { + setIsFetching(true); + const response = await preferences.schedule.get(); + if (response.error) { + setError(response.error); + onError?.(response.error); + } else { + onSuccess?.(response.data!); + } + setIsLoading(false); + setIsFetching(false); + }; + + const refetch = () => { + preferences.schedule.cache.clearAll(); + + return fetchSchedule(); + }; + + return { + schedule: data, + error, + isLoading, + isFetching, + refetch, + }; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f8d4af3e260..c8447d56c50 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,10 +45,10 @@ export type { UseCountsResult, UseNotificationsProps, UseNotificationsResult, - UsePreferencesProps, UsePreferencesResult, + UseScheduleProps as UsePreferencesProps, } from './hooks'; -export { useCounts, useNotifications, useNovu, usePreferences } from './hooks'; +export { useCounts, useNotifications, useNovu, usePreferences, useSchedule } from './hooks'; export type { BaseProps, diff --git a/packages/react/src/server/index.tsx b/packages/react/src/server/index.tsx index e8e554e4673..f90d4925492 100644 --- a/packages/react/src/server/index.tsx +++ b/packages/react/src/server/index.tsx @@ -5,6 +5,8 @@ import type { UseNotificationsResult, UsePreferencesProps, UsePreferencesResult, + UseScheduleProps, + UseScheduleResult, } from '../hooks'; import type { NovuProviderProps } from '../hooks/NovuProvider'; import type { UseCountsProps, UseCountsResult } from '../hooks/useCounts'; @@ -63,6 +65,14 @@ export function usePreferences(_: UsePreferencesProps): UsePreferencesResult { }; } +export function useSchedule(_: UseScheduleProps): UseScheduleResult { + return { + isLoading: false, + isFetching: false, + refetch: () => Promise.resolve(), + }; +} + export type { ChannelPreference, ChannelType, @@ -102,8 +112,8 @@ export type { UseCountsResult, UseNotificationsProps, UseNotificationsResult, - UsePreferencesProps, UsePreferencesResult, + UseScheduleProps as UsePreferencesProps, } from '../hooks'; export type { diff --git a/packages/react/src/utils/types.ts b/packages/react/src/utils/types.ts index cf550a1cdd2..eca72d8d606 100644 --- a/packages/react/src/utils/types.ts +++ b/packages/react/src/utils/types.ts @@ -1,4 +1,4 @@ -import type { Subscriber, UnreadCount } from '@novu/js'; +import type { DefaultSchedule, Subscriber, UnreadCount } from '@novu/js'; import type { IconKey, InboxProps, @@ -62,6 +62,7 @@ type StandardBaseProps = { tabs?: Array; preferencesFilter?: PreferencesFilter; preferenceGroups?: PreferenceGroups; + defaultSchedule?: DefaultSchedule; routerPush?: RouterPush; } & ( | { From ee09c0f6cf827dfafbedd7d5c0a59301b5800705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 11 Sep 2025 12:25:36 +0200 Subject: [PATCH 09/12] feat(dashboard): allow updating subscribers schedule --- .../dtos/patch-subscriber-preferences.dto.ts | 9 +- .../subscribers-v2/subscribers.controller.ts | 1 + .../update-subscriber-preferences.command.ts | 6 +- .../preferences/day-schedule-copy.tsx | 148 +++++ .../subscribers/preferences/preferences.tsx | 34 +- .../preferences/schedule-table.tsx | 214 +++++++ .../preferences/subscribers-schedule.tsx | 119 ++++ .../subscribers/preferences/utils.ts | 11 + .../use-optimistic-channel-preferences.ts | 99 ++++ .../hooks/use-optimistic-schedule-update.ts | 83 +++ libs/internal-sdk/.speakeasy/gen.yaml | 2 + .../src/funcs/subscribersGlobalPreference.ts | 213 +++++++ libs/internal-sdk/src/lib/config.ts | 32 +- .../src/models/components/index.ts | 504 ++++++++-------- .../patchsubscriberpreferencesdto.ts | 35 +- .../src/models/components/scheduledto.ts | 549 ++++++++++++++++++ .../subscriberglobalpreferencedto.ts | 35 +- .../src/models/components/timerangedto.ts | 62 ++ .../src/models/operations/index.ts | 165 +++--- ...ubscriberscontrollergetglobalpreference.ts | 167 ++++++ libs/internal-sdk/src/react-query/index.ts | 169 +++--- .../subscribersGlobalPreference.ts | 122 ++++ libs/internal-sdk/src/sdk/subscribers.ts | 96 ++- .../elements/Preferences/DayScheduleCopy.tsx | 2 +- .../elements/Preferences/ScheduleRow.tsx | 29 +- .../elements/Preferences/ScheduleTable.tsx | 8 +- .../ui/components/primitives/TimeSelect.tsx | 4 +- packages/js/src/ui/config/appearanceKeys.ts | 3 +- .../js/src/ui/config/defaultLocalization.ts | 2 + packages/js/src/ui/types.ts | 3 +- 30 files changed, 2372 insertions(+), 554 deletions(-) create mode 100644 apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx create mode 100644 apps/dashboard/src/components/subscribers/preferences/schedule-table.tsx create mode 100644 apps/dashboard/src/components/subscribers/preferences/subscribers-schedule.tsx create mode 100644 apps/dashboard/src/components/subscribers/preferences/utils.ts create mode 100644 apps/dashboard/src/hooks/use-optimistic-channel-preferences.ts create mode 100644 apps/dashboard/src/hooks/use-optimistic-schedule-update.ts create mode 100644 libs/internal-sdk/src/funcs/subscribersGlobalPreference.ts create mode 100644 libs/internal-sdk/src/models/components/scheduledto.ts create mode 100644 libs/internal-sdk/src/models/components/timerangedto.ts create mode 100644 libs/internal-sdk/src/models/operations/subscriberscontrollergetglobalpreference.ts create mode 100644 libs/internal-sdk/src/react-query/subscribersGlobalPreference.ts diff --git a/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts b/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts index e972315af6c..3cc89f47556 100644 --- a/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts +++ b/apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts @@ -1,4 +1,4 @@ -import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { parseSlugId } from '@novu/application-generic'; import { IPreferenceChannels } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; @@ -23,9 +23,9 @@ export class PatchPreferenceChannelsDto implements IPreferenceChannels { } export class PatchSubscriberPreferencesDto { - @ApiProperty({ description: 'Channel-specific preference settings', type: PatchPreferenceChannelsDto }) + @ApiPropertyOptional({ description: 'Channel-specific preference settings', type: PatchPreferenceChannelsDto }) @Type(() => PatchPreferenceChannelsDto) - channels: PatchPreferenceChannelsDto; + channels?: PatchPreferenceChannelsDto; @ApiProperty({ description: @@ -36,8 +36,7 @@ export class PatchSubscriberPreferencesDto { @Transform(({ value }) => parseSlugId(value)) workflowId?: string; - @ApiHideProperty() - /* @ApiPropertyOptional({ description: 'Subscriber schedule', type: ScheduleDto }) */ + @ApiPropertyOptional({ description: 'Subscriber schedule', type: ScheduleDto }) @IsOptional() @ValidateNested() @Type(() => ScheduleDto) diff --git a/apps/api/src/app/subscribers-v2/subscribers.controller.ts b/apps/api/src/app/subscribers-v2/subscribers.controller.ts index 8f7c3834c2f..a1f608fddf1 100644 --- a/apps/api/src/app/subscribers-v2/subscribers.controller.ts +++ b/apps/api/src/app/subscribers-v2/subscribers.controller.ts @@ -295,6 +295,7 @@ export class SubscribersController { @SdkMethodName('list') */ @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) @RequireAuthentication() + @SdkMethodName('globalPreference') async getGlobalPreference( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string diff --git a/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts b/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts index 54c09c31ee1..0cf8130dc36 100644 --- a/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts +++ b/apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsDefined, IsOptional, IsString } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; import { ScheduleDto } from '../../../shared/dtos/schedule'; import { PatchPreferenceChannelsDto } from '../../dtos/patch-subscriber-preferences.dto'; @@ -9,9 +9,9 @@ export class UpdateSubscriberPreferencesCommand extends EnvironmentWithSubscribe @IsString() readonly workflowIdOrInternalId?: string; - @IsDefined() + @IsOptional() @Type(() => PatchPreferenceChannelsDto) - readonly channels: PatchPreferenceChannelsDto; + readonly channels?: PatchPreferenceChannelsDto; @IsOptional() @Type(() => ScheduleDto) diff --git a/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx b/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx new file mode 100644 index 00000000000..3fb8fa15156 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx @@ -0,0 +1,148 @@ +import { ScheduleDto } from '@novu/api/models/components'; +import { Schedule, WeeklySchedule } from '@novu/shared'; +import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { RiFileCopyLine } from 'react-icons/ri'; +import { Button } from '@/components/primitives/button'; +import { Checkbox } from '@/components/primitives/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; +import { capitalize } from '@/utils/string'; +import { cn } from '@/utils/ui'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../../primitives/tooltip'; +import { weekDays } from './utils'; + +const NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT = 'novu.close-day-schedule-copy-component'; + +type DayScheduleCopyProps = { + onScheduleUpdate: (schedule: ScheduleDto) => Promise; + day: keyof WeeklySchedule; + schedule?: Schedule | undefined; + disabled?: boolean; +}; + +export const DayScheduleCopy = ({ day, schedule, disabled, onScheduleUpdate }: DayScheduleCopyProps) => { + const id = useId(); + const [isOpen, setIsOpen] = useState(false); + const [selectedDays, setSelectedDays] = useState>([day]); + const [isAllSelected, setIsAllSelected] = useState(false); + const allWeekDaysSelected = useMemo(() => selectedDays.length === weekDays.length, [selectedDays]); + const reset = useCallback(() => { + setSelectedDays([day]); + setIsAllSelected(false); + setIsOpen(false); + }, [day]); + const onOpenChange = useCallback( + (isOpen: boolean) => { + console.log('isOpen', isOpen); + if (isOpen) { + // close other copy times to dropdowns + document.dispatchEvent(new CustomEvent(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, { detail: { id } })); + } + setTimeout(() => { + // set is open after a short delay to ensure nicer animation + if (!isOpen) { + reset(); + } else { + setIsOpen(isOpen); + } + }, 50); + }, + [id, reset] + ); + + useEffect(() => { + const listener = (event: CustomEvent<{ id: string }>) => { + const data = event.detail; + if (data.id !== id) { + reset(); + } + }; + + // @ts-expect-error custom event + document.addEventListener(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, listener); + + return () => { + // @ts-expect-error custom event + document.removeEventListener(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, listener); + }; + }, [id, reset]); + + return ( + + + + + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > +

Copy times to:

+ + { + if (typeof checked !== 'boolean') return; + setIsAllSelected(checked); + setSelectedDays(checked ? weekDays : [day]); + }} + /> + Select all + + {weekDays.map((weekDay) => ( + + + setSelectedDays(value ? [...selectedDays, weekDay] : selectedDays.filter((d) => d !== weekDay)) + } + disabled={weekDay === day} + /> + {capitalize(weekDay)} + + ))} +
+ +
+
+
+
+ Copy times to +
+ ); +}; diff --git a/apps/dashboard/src/components/subscribers/preferences/preferences.tsx b/apps/dashboard/src/components/subscribers/preferences/preferences.tsx index bc4677d224b..fde2bd6ad56 100644 --- a/apps/dashboard/src/components/subscribers/preferences/preferences.tsx +++ b/apps/dashboard/src/components/subscribers/preferences/preferences.tsx @@ -1,18 +1,19 @@ -import { GetSubscriberPreferencesDto, PatchPreferenceChannelsDto } from '@novu/api/models/components'; +import { GetSubscriberPreferencesDto } from '@novu/api/models/components'; import { ChannelTypeEnum } from '@novu/shared'; import { motion } from 'motion/react'; import { useMemo } from 'react'; -import { RiQuestionLine } from 'react-icons/ri'; -import { showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { RiLoader4Line, RiQuestionLine } from 'react-icons/ri'; +import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { SidebarContent } from '@/components/side-navigation/sidebar'; import { PreferencesItem } from '@/components/subscribers/preferences/preferences-item'; import { WorkflowPreferences } from '@/components/subscribers/preferences/workflow-preferences'; -import { usePatchSubscriberPreferences } from '@/hooks/use-patch-subscriber-preferences'; +import { useOptimisticChannelPreferences } from '@/hooks/use-optimistic-channel-preferences'; import { useTelemetry } from '@/hooks/use-telemetry'; import { itemVariants, sectionVariants } from '@/utils/animation'; import { TelemetryEvent } from '@/utils/telemetry'; import { PreferencesBlank } from './preferences-blank'; +import { SubscribersSchedule } from './subscribers-schedule'; type PreferencesProps = { subscriberPreferences: GetSubscriberPreferencesDto; @@ -24,11 +25,15 @@ export const Preferences = (props: PreferencesProps) => { const { subscriberPreferences, subscriberId, readOnly = false } = props; const track = useTelemetry(); - const { patchSubscriberPreferences } = usePatchSubscriberPreferences({ + const { updateChannelPreferences, isPending } = useOptimisticChannelPreferences({ + subscriberId, onSuccess: () => { showSuccessToast('Subscriber preferences updated successfully'); track(TelemetryEvent.SUBSCRIBER_PREFERENCES_UPDATED); }, + onError: () => { + showErrorToast('Failed to update preferences. Please try again.'); + }, }); const { workflows, globalChannelsKeys, hasZeroPreferences } = useMemo(() => { @@ -41,13 +46,6 @@ export const Preferences = (props: PreferencesProps) => { return { global, workflows, globalChannelsKeys, hasZeroPreferences }; }, [subscriberPreferences]); - const handleChannelToggle = async (channels: PatchPreferenceChannelsDto, workflowId?: string) => { - await patchSubscriberPreferences({ - subscriberId, - preferences: { channels, workflowId }, - }); - }; - if (hasZeroPreferences) { return ; } @@ -73,6 +71,7 @@ export const Preferences = (props: PreferencesProps) => {

+ {isPending && } @@ -82,12 +81,18 @@ export const Preferences = (props: PreferencesProps) => { channel={channel} readOnly={readOnly} enabled={enabled} - onChange={(checked: boolean) => handleChannelToggle({ [channel]: checked })} + onChange={(checked: boolean) => updateChannelPreferences({ [channel]: checked })} /> ))} + + + + + +
Workflow Preferences @@ -102,6 +107,7 @@ export const Preferences = (props: PreferencesProps) => {

+ {isPending && }
@@ -109,7 +115,7 @@ export const Preferences = (props: PreferencesProps) => { ))} diff --git a/apps/dashboard/src/components/subscribers/preferences/schedule-table.tsx b/apps/dashboard/src/components/subscribers/preferences/schedule-table.tsx new file mode 100644 index 00000000000..17c7d9c3451 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/preferences/schedule-table.tsx @@ -0,0 +1,214 @@ +import { ScheduleDto, SubscriberGlobalPreferenceDto } from '@novu/api/models/components'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; +import { showErrorToast } from '@/components/primitives/sonner-helpers'; +import { Switch } from '@/components/primitives/switch'; +import { capitalize } from '@/utils/string'; +import { cn } from '@/utils/ui'; +import { DayScheduleCopy } from './day-schedule-copy'; +import { weekDays } from './utils'; + +const hours = Array.from({ length: 48 }, (_, i) => { + const hour = Math.floor(i / 2); + const minute = i % 2 === 0 ? '00' : '30'; + const period = hour < 12 ? 'AM' : 'PM'; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const formattedHour = displayHour.toString().padStart(2, '0'); + + return `${formattedHour}:${minute} ${period}`; +}); + +type ScheduleTableHeaderProps = { + children: React.ReactNode; +}; + +const ScheduleTableHeader = (props: ScheduleTableHeaderProps) => { + return
{props.children}
; +}; + +type ScheduleTableHeaderColumnProps = { + children: React.ReactNode; + className?: string; +}; + +const ScheduleTableHeaderColumn = (props: ScheduleTableHeaderColumnProps) => { + return
{props.children}
; +}; + +type ScheduleTableBodyProps = { + children: React.ReactNode; +}; + +const ScheduleTableBody = (props: ScheduleTableBodyProps) => { + return
{props.children}
; +}; + +type ScheduleTableRowProps = { + children: React.ReactNode; +}; + +const ScheduleTableRow = (props: ScheduleTableRowProps) => { + return
{props.children}
; +}; + +type ScheduleTableCellProps = { + children: React.ReactNode; + className?: string; +}; +const ScheduleBodyColumn = (props: ScheduleTableCellProps) => { + return
{props.children}
; +}; + +type ScheduleTableProps = { + globalPreference: SubscriberGlobalPreferenceDto; + onScheduleUpdate: (schedule: ScheduleDto) => Promise; +}; + +export const ScheduleTable = (props: ScheduleTableProps) => { + const { globalPreference, onScheduleUpdate } = props; + const { schedule } = globalPreference; + const isScheduleDisabled = !schedule?.isEnabled; + + return ( +
+ + Days + From + To + + + {weekDays.map((day) => { + const isDayDisabled = !schedule?.weeklySchedule?.[day]?.isEnabled; + + return ( + + + { + try { + const updatedWeeklySchedule = { + ...schedule?.weeklySchedule, + [day]: { + ...schedule?.weeklySchedule?.[day], + isEnabled: checked, + hours: schedule?.weeklySchedule?.[day]?.hours || [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }; + + await onScheduleUpdate({ + isEnabled: schedule?.isEnabled ?? false, + weeklySchedule: updatedWeeklySchedule, + }); + } catch { + showErrorToast('Failed to update day schedule. Please try again.'); + } + }} + /> + + {capitalize(day)} + + + + + + + + + + + ); + })} + +
+ ); +}; diff --git a/apps/dashboard/src/components/subscribers/preferences/subscribers-schedule.tsx b/apps/dashboard/src/components/subscribers/preferences/subscribers-schedule.tsx new file mode 100644 index 00000000000..b02c90a0bba --- /dev/null +++ b/apps/dashboard/src/components/subscribers/preferences/subscribers-schedule.tsx @@ -0,0 +1,119 @@ +import { SubscriberGlobalPreferenceDto } from '@novu/api/models/components'; +import { motion } from 'motion/react'; +import { useState } from 'react'; +import { + RiCalendarScheduleLine, + RiContractUpDownLine, + RiExpandUpDownLine, + RiInformationLine, + RiLoader4Line, +} from 'react-icons/ri'; +import { Card, CardContent, CardHeader } from '@/components/primitives/card'; +import { showErrorToast } from '@/components/primitives/sonner-helpers'; +import { Switch } from '@/components/primitives/switch'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; +import { useOptimisticScheduleUpdate } from '@/hooks/use-optimistic-schedule-update'; +import { cn } from '@/utils/ui'; +import { ScheduleTable } from './schedule-table'; + +type SubscribersScheduleProps = { + globalPreference: SubscriberGlobalPreferenceDto; + subscriberId: string; +}; + +export const SubscribersSchedule = (props: SubscribersScheduleProps) => { + const { globalPreference, subscriberId } = props; + const [isExpanded, setIsExpanded] = useState(false); + + const { updateSchedule, isPending } = useOptimisticScheduleUpdate({ + subscriberId, + onError: () => { + showErrorToast('Failed to update schedule. Please try again.'); + }, + }); + return ( + + setIsExpanded(!isExpanded)} + > +
+ + Subscriber's schedule + + + + + + + + Set subscriber schedule. External notification channels are paused outside this time, except inbox and + critical ones. + + + {isPending && } +
+
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + { + try { + await updateSchedule({ + isEnabled: checked, + weeklySchedule: globalPreference.schedule?.weeklySchedule, + }); + } catch { + showErrorToast('Failed to update schedule. Please try again.'); + } + }} + /> + + {isExpanded ? ( + + ) : ( + + )} +
+
+ + + Allow notifications between: + { + await updateSchedule(schedule); + }} + /> +
+ + + Critical and In-app notifications still reach you outside your schedule. + +
+
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/subscribers/preferences/utils.ts b/apps/dashboard/src/components/subscribers/preferences/utils.ts new file mode 100644 index 00000000000..de5ad411e70 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/preferences/utils.ts @@ -0,0 +1,11 @@ +import { WeeklySchedule } from '@novu/shared'; + +export const weekDays: Array = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; diff --git a/apps/dashboard/src/hooks/use-optimistic-channel-preferences.ts b/apps/dashboard/src/hooks/use-optimistic-channel-preferences.ts new file mode 100644 index 00000000000..8cc8febc5eb --- /dev/null +++ b/apps/dashboard/src/hooks/use-optimistic-channel-preferences.ts @@ -0,0 +1,99 @@ +import { GetSubscriberPreferencesDto, PatchPreferenceChannelsDto } from '@novu/api/models/components'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patchSubscriberPreferences } from '@/api/subscribers'; +import { useAuth } from '@/context/auth/hooks'; +import { useEnvironment } from '@/context/environment/hooks'; +import { QueryKeys } from '@/utils/query-keys'; +import { OmitEnvironmentFromParameters } from '@/utils/types'; + +type PatchSubscriberPreferencesParameters = OmitEnvironmentFromParameters; + +type UseOptimisticChannelPreferencesProps = { + subscriberId: string; + onSuccess?: () => void; + onError?: (error: unknown) => void; +}; + +export const useOptimisticChannelPreferences = ({ + subscriberId, + onSuccess, + onError, +}: UseOptimisticChannelPreferencesProps) => { + const queryClient = useQueryClient(); + const { currentOrganization } = useAuth(); + const { currentEnvironment } = useEnvironment(); + + const queryKey = [ + QueryKeys.fetchSubscriberPreferences, + currentOrganization?._id, + currentEnvironment?._id, + subscriberId, + ]; + + const { mutateAsync, isPending } = useMutation({ + mutationFn: (args: PatchSubscriberPreferencesParameters) => { + if (!currentEnvironment) { + throw new Error('Environment is not available'); + } + return patchSubscriberPreferences({ environment: currentEnvironment, ...args }); + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + + const previousData = queryClient.getQueryData(queryKey); + + if (previousData) { + const optimisticData: GetSubscriberPreferencesDto = { + ...previousData, + global: { + ...previousData.global, + channels: { + ...previousData.global.channels, + ...variables.preferences.channels, + }, + }, + workflows: previousData.workflows.map((workflow) => { + if (variables.preferences.workflowId && workflow.workflow.slug === variables.preferences.workflowId) { + return { + ...workflow, + channels: { + ...workflow.channels, + ...variables.preferences.channels, + }, + }; + } + return workflow; + }), + }; + + queryClient.setQueryData(queryKey, optimisticData); + } + + return { previousData }; + }, + onError: (error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKey, context.previousData); + } + onError?.(error); + }, + onSuccess: () => { + onSuccess?.(); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const updateChannelPreferences = async (channels: PatchPreferenceChannelsDto, workflowId?: string) => { + return mutateAsync({ + subscriberId, + preferences: { channels, workflowId }, + }); + }; + + return { + updateChannelPreferences, + isPending, + }; +}; diff --git a/apps/dashboard/src/hooks/use-optimistic-schedule-update.ts b/apps/dashboard/src/hooks/use-optimistic-schedule-update.ts new file mode 100644 index 00000000000..0f800e8b7ce --- /dev/null +++ b/apps/dashboard/src/hooks/use-optimistic-schedule-update.ts @@ -0,0 +1,83 @@ +import { GetSubscriberPreferencesDto, ScheduleDto } from '@novu/api/models/components'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patchSubscriberPreferences } from '@/api/subscribers'; +import { useAuth } from '@/context/auth/hooks'; +import { useEnvironment } from '@/context/environment/hooks'; +import { QueryKeys } from '@/utils/query-keys'; +import { OmitEnvironmentFromParameters } from '@/utils/types'; + +type PatchSubscriberPreferencesParameters = OmitEnvironmentFromParameters; + +type UseOptimisticScheduleUpdateProps = { + subscriberId: string; + onSuccess?: () => void; + onError?: (error: unknown) => void; +}; + +export const useOptimisticScheduleUpdate = ({ subscriberId, onSuccess, onError }: UseOptimisticScheduleUpdateProps) => { + const queryClient = useQueryClient(); + const { currentOrganization } = useAuth(); + const { currentEnvironment } = useEnvironment(); + + const queryKey = [ + QueryKeys.fetchSubscriberPreferences, + currentOrganization?._id, + currentEnvironment?._id, + subscriberId, + ]; + + const { mutateAsync, isPending } = useMutation({ + mutationFn: (args: PatchSubscriberPreferencesParameters) => { + if (!currentEnvironment) { + throw new Error('Environment is not available'); + } + return patchSubscriberPreferences({ environment: currentEnvironment, ...args }); + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + + const previousData = queryClient.getQueryData(queryKey); + if (previousData) { + const optimisticData: GetSubscriberPreferencesDto = { + ...previousData, + global: { + ...previousData.global, + schedule: { + ...previousData.global.schedule, + ...variables.preferences.schedule, + isEnabled: variables.preferences.schedule?.isEnabled ?? previousData.global.schedule?.isEnabled ?? false, + }, + }, + }; + + queryClient.setQueryData(queryKey, optimisticData); + } + + return { previousData }; + }, + onError: (error, _variables, context) => { + if (context?.previousData) { + queryClient.setQueryData(queryKey, context.previousData); + } + onError?.(error); + }, + onSuccess: () => { + onSuccess?.(); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const updateSchedule = async (schedule: ScheduleDto) => { + return mutateAsync({ + subscriberId, + preferences: { schedule }, + }); + }; + + return { + updateSchedule, + isPending, + }; +}; diff --git a/libs/internal-sdk/.speakeasy/gen.yaml b/libs/internal-sdk/.speakeasy/gen.yaml index 3b3f5aaf6cb..64069e07d8e 100755 --- a/libs/internal-sdk/.speakeasy/gen.yaml +++ b/libs/internal-sdk/.speakeasy/gen.yaml @@ -16,6 +16,7 @@ generation: auth: oAuth2ClientCredentialsEnabled: false oAuth2PasswordEnabled: false + hoistGlobalSecurity: true sdkHooksConfigAccess: true tests: generateTests: true @@ -56,6 +57,7 @@ typescript: outputModelSuffix: output packageName: '@novu/api' responseFormat: flat + sseFlatResponse: false templateVersion: v2 usageSDKInitImports: [] useIndexModules: true diff --git a/libs/internal-sdk/src/funcs/subscribersGlobalPreference.ts b/libs/internal-sdk/src/funcs/subscribersGlobalPreference.ts new file mode 100644 index 00000000000..f9009362735 --- /dev/null +++ b/libs/internal-sdk/src/funcs/subscribersGlobalPreference.ts @@ -0,0 +1,213 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import { NovuCore } from '../core.js'; +import { encodeSimple } from '../lib/encodings.js'; +import * as M from '../lib/matchers.js'; +import { compactMap } from '../lib/primitives.js'; +import { safeParse } from '../lib/schemas.js'; +import { RequestOptions } from '../lib/sdks.js'; +import { extractSecurity, resolveGlobalSecurity } from '../lib/security.js'; +import { pathToFunc } from '../lib/url.js'; +import { + ConnectionError, + InvalidRequestError, + RequestAbortedError, + RequestTimeoutError, + UnexpectedClientError, +} from '../models/errors/httpclienterrors.js'; +import * as errors from '../models/errors/index.js'; +import { NovuError } from '../models/errors/novuerror.js'; +import { ResponseValidationError } from '../models/errors/responsevalidationerror.js'; +import { SDKValidationError } from '../models/errors/sdkvalidationerror.js'; +import * as operations from '../models/operations/index.js'; +import { APICall, APIPromise } from '../types/async.js'; +import { Result } from '../types/fp.js'; + +export function subscribersGlobalPreference( + client: NovuCore, + subscriberId: string, + idempotencyKey?: string | undefined, + options?: RequestOptions +): APIPromise< + Result< + operations.SubscribersControllerGetGlobalPreferenceResponse, + | errors.ErrorDto + | errors.ValidationErrorDto + | NovuError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + > +> { + return new APIPromise($do(client, subscriberId, idempotencyKey, options)); +} + +async function $do( + client: NovuCore, + subscriberId: string, + idempotencyKey?: string | undefined, + options?: RequestOptions +): Promise< + [ + Result< + operations.SubscribersControllerGetGlobalPreferenceResponse, + | errors.ErrorDto + | errors.ValidationErrorDto + | NovuError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >, + APICall, + ] +> { + const input: operations.SubscribersControllerGetGlobalPreferenceRequest = { + subscriberId: subscriberId, + idempotencyKey: idempotencyKey, + }; + + const parsed = safeParse( + input, + (value) => operations.SubscribersControllerGetGlobalPreferenceRequest$outboundSchema.parse(value), + 'Input validation failed' + ); + if (!parsed.ok) { + return [parsed, { status: 'invalid' }]; + } + const payload = parsed.value; + const body = null; + + const pathParams = { + subscriberId: encodeSimple('subscriberId', payload.subscriberId, { + explode: false, + charEncoding: 'percent', + }), + }; + + const path = pathToFunc('/v2/subscribers/{subscriberId}/preferences/global')(pathParams); + + const headers = new Headers( + compactMap({ + Accept: 'application/json', + 'idempotency-key': encodeSimple('idempotency-key', payload['idempotency-key'], { + explode: false, + charEncoding: 'none', + }), + }) + ); + + const securityInput = await extractSecurity(client._options.security); + const requestSecurity = resolveGlobalSecurity(securityInput); + + const context = { + options: client._options, + baseURL: options?.serverURL ?? client._baseURL ?? '', + operationID: 'SubscribersController_getGlobalPreference', + oAuth2Scopes: [], + + resolvedSecurity: requestSecurity, + + securitySource: client._options.security, + retryConfig: options?.retries || + client._options.retryConfig || { + strategy: 'backoff', + backoff: { + initialInterval: 1000, + maxInterval: 30000, + exponent: 1.5, + maxElapsedTime: 3600000, + }, + retryConnectionErrors: true, + } || { strategy: 'none' }, + retryCodes: options?.retryCodes || ['408', '409', '429', '5XX'], + }; + + const requestRes = client._createRequest( + context, + { + security: requestSecurity, + method: 'GET', + baseURL: options?.serverURL, + path: path, + headers: headers, + body: body, + userAgent: client._options.userAgent, + timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1, + }, + options + ); + if (!requestRes.ok) { + return [requestRes, { status: 'invalid' }]; + } + const req = requestRes.value; + + const doResult = await client._do(req, { + context, + errorCodes: [ + '400', + '401', + '403', + '404', + '405', + '409', + '413', + '414', + '415', + '422', + '429', + '4XX', + '500', + '503', + '5XX', + ], + retryConfig: context.retryConfig, + retryCodes: context.retryCodes, + }); + if (!doResult.ok) { + return [doResult, { status: 'request-error', request: req }]; + } + const response = doResult.value; + + const responseFields = { + HttpMeta: { Response: response, Request: req }, + }; + + const [result] = await M.match< + operations.SubscribersControllerGetGlobalPreferenceResponse, + | errors.ErrorDto + | errors.ValidationErrorDto + | NovuError + | ResponseValidationError + | ConnectionError + | RequestAbortedError + | RequestTimeoutError + | InvalidRequestError + | UnexpectedClientError + | SDKValidationError + >( + M.json(200, operations.SubscribersControllerGetGlobalPreferenceResponse$inboundSchema, { key: 'Result' }), + M.jsonErr(414, errors.ErrorDto$inboundSchema), + M.jsonErr([400, 401, 403, 404, 405, 409, 413, 415], errors.ErrorDto$inboundSchema, { hdrs: true }), + M.jsonErr(422, errors.ValidationErrorDto$inboundSchema, { hdrs: true }), + M.fail(429), + M.jsonErr(500, errors.ErrorDto$inboundSchema, { hdrs: true }), + M.fail(503), + M.fail('4XX'), + M.fail('5XX') + )(response, req, { extraFields: responseFields }); + if (!result.ok) { + return [result, { status: 'complete', request: req, response }]; + } + + return [result, { status: 'complete', request: req, response }]; +} diff --git a/libs/internal-sdk/src/lib/config.ts b/libs/internal-sdk/src/lib/config.ts index 2b206c113cc..4ff2e76e197 100644 --- a/libs/internal-sdk/src/lib/config.ts +++ b/libs/internal-sdk/src/lib/config.ts @@ -2,28 +2,22 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -import * as components from "../models/components/index.js"; -import { HTTPClient } from "./http.js"; -import { Logger } from "./logger.js"; -import { RetryConfig } from "./retries.js"; -import { Params, pathToFunc } from "./url.js"; +import * as components from '../models/components/index.js'; +import { HTTPClient } from './http.js'; +import { Logger } from './logger.js'; +import { RetryConfig } from './retries.js'; +import { Params, pathToFunc } from './url.js'; /** * Contains the list of servers available to the SDK */ -export const ServerList = [ - "https://api.novu.co", - "https://eu.api.novu.co", -] as const; +export const ServerList = ['https://api.novu.co', 'https://eu.api.novu.co'] as const; export type SDKOptions = { /** * The security details required to authenticate the SDK */ - security?: - | components.Security - | (() => Promise) - | undefined; + security?: components.Security | (() => Promise) | undefined; httpClient?: HTTPClient; /** @@ -56,7 +50,7 @@ export function serverURLFromOptions(options: SDKOptions): URL | null { if (serverIdx < 0 || serverIdx >= ServerList.length) { throw new Error(`Invalid server index ${serverIdx}`); } - serverURL = ServerList[serverIdx] || ""; + serverURL = ServerList[serverIdx] || ''; } const u = pathToFunc(serverURL)(params); @@ -64,9 +58,9 @@ export function serverURLFromOptions(options: SDKOptions): URL | null { } export const SDK_METADATA = { - language: "typescript", - openapiDocVersion: "2.3.0", - sdkVersion: "0.1.21", - genVersion: "2.695.1", - userAgent: "speakeasy-sdk/typescript 0.1.21 2.695.1 2.3.0 @novu/api", + language: 'typescript', + openapiDocVersion: '3.9.0', + sdkVersion: '0.1.21', + genVersion: '2.698.4', + userAgent: 'speakeasy-sdk/typescript 0.1.21 2.698.4 3.9.0 @novu/api', } as const; diff --git a/libs/internal-sdk/src/models/components/index.ts b/libs/internal-sdk/src/models/components/index.ts index 452bd2728f0..ac86d8f747c 100644 --- a/libs/internal-sdk/src/models/components/index.ts +++ b/libs/internal-sdk/src/models/components/index.ts @@ -2,254 +2,256 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -export * from "./actiondto.js"; -export * from "./activitiesresponsedto.js"; -export * from "./activitynotificationexecutiondetailresponsedto.js"; -export * from "./activitynotificationjobresponsedto.js"; -export * from "./activitynotificationresponsedto.js"; -export * from "./activitynotificationstepresponsedto.js"; -export * from "./activitynotificationsubscriberresponsedto.js"; -export * from "./activitynotificationtemplateresponsedto.js"; -export * from "./activitytopicdto.js"; -export * from "./actorfeeditemdto.js"; -export * from "./actortypeenum.js"; -export * from "./apikeydto.js"; -export * from "./autoconfigureintegrationresponsedto.js"; -export * from "./bridgeconfigurationdto.js"; -export * from "./builderfieldtypeenum.js"; -export * from "./bulkcreatesubscriberresponsedto.js"; -export * from "./bulksubscribercreatedto.js"; -export * from "./bulktriggereventdto.js"; -export * from "./bulkupdatesubscriberpreferenceitemdto.js"; -export * from "./bulkupdatesubscriberpreferencesdto.js"; -export * from "./buttontypeenum.js"; -export * from "./channelcredentials.js"; -export * from "./channelcredentialsdto.js"; -export * from "./channelctatypeenum.js"; -export * from "./channelpreferencedto.js"; -export * from "./channelsettingsdto.js"; -export * from "./channeltypeenum.js"; -export * from "./chatcontroldto.js"; -export * from "./chatcontrolsmetadataresponsedto.js"; -export * from "./chatorpushproviderenum.js"; -export * from "./chatrenderoutput.js"; -export * from "./chatstepresponsedto.js"; -export * from "./chatstepupsertdto.js"; -export * from "./configurationsdto.js"; -export * from "./constraintvalidation.js"; -export * from "./contentissueenum.js"; -export * from "./controlsmetadatadto.js"; -export * from "./createdsubscriberdto.js"; -export * from "./createenvironmentrequestdto.js"; -export * from "./createintegrationrequestdto.js"; -export * from "./createlayoutdto.js"; -export * from "./createsubscriberrequestdto.js"; -export * from "./createtopicsubscriptionsrequestdto.js"; -export * from "./createtopicsubscriptionsresponsedto.js"; -export * from "./createtranslationrequestdto.js"; -export * from "./createupdatetopicrequestdto.js"; -export * from "./createworkflowdto.js"; -export * from "./credentialsdto.js"; -export * from "./customcontroldto.js"; -export * from "./customcontrolsmetadataresponsedto.js"; -export * from "./customstepresponsedto.js"; -export * from "./customstepupsertdto.js"; -export * from "./delaycontroldto.js"; -export * from "./delaycontrolsmetadataresponsedto.js"; -export * from "./delayregularmetadata.js"; -export * from "./delayscheduledmetadata.js"; -export * from "./delaystepresponsedto.js"; -export * from "./delaystepupsertdto.js"; -export * from "./deletemessageresponsedto.js"; -export * from "./deletetopicresponsedto.js"; -export * from "./deletetopicsubscriptionsrequestdto.js"; -export * from "./deletetopicsubscriptionsresponsedto.js"; -export * from "./digestcontroldto.js"; -export * from "./digestcontrolsmetadataresponsedto.js"; -export * from "./digestmetadatadto.js"; -export * from "./digestregularmetadata.js"; -export * from "./digestregularoutput.js"; -export * from "./digeststepresponsedto.js"; -export * from "./digeststepupsertdto.js"; -export * from "./digesttimedconfigdto.js"; -export * from "./digesttimedmetadata.js"; -export * from "./digesttypeenum.js"; -export * from "./digestunitenum.js"; -export * from "./directionenum.js"; -export * from "./duplicatelayoutdto.js"; -export * from "./duplicateworkflowdto.js"; -export * from "./emailblock.js"; -export * from "./emailblockstyles.js"; -export * from "./emailblocktypeenum.js"; -export * from "./emailchanneloverrides.js"; -export * from "./emailcontroldto.js"; -export * from "./emailcontrolsdto.js"; -export * from "./emailcontrolsmetadataresponsedto.js"; -export * from "./emaillayoutrenderoutput.js"; -export * from "./emailrenderoutput.js"; -export * from "./emailstepresponsedto.js"; -export * from "./emailstepupsertdto.js"; -export * from "./environmentresponsedto.js"; -export * from "./executiondetailssourceenum.js"; -export * from "./executiondetailsstatusenum.js"; -export * from "./failedoperationdto.js"; -export * from "./feedresponsedto.js"; -export * from "./fieldfilterpartdto.js"; -export * from "./generatelayoutpreviewresponsedto.js"; -export * from "./generatepreviewrequestdto.js"; -export * from "./generatepreviewresponsedto.js"; -export * from "./getchartsresponsedto.js"; -export * from "./getenvironmenttagsdto.js"; -export * from "./getlayoutusageresponsedto.js"; -export * from "./getmasterjsonresponsedto.js"; -export * from "./getpreferencesresponsedto.js"; -export * from "./getrequestresponsedto.js"; -export * from "./getrequestsresponsedto.js"; -export * from "./getsubscriberpreferencesdto.js"; -export * from "./getworkflowrunresponsedto.js"; -export * from "./getworkflowrunsdto.js"; -export * from "./getworkflowrunsresponsedto.js"; -export * from "./importmasterjsonrequestdto.js"; -export * from "./importmasterjsonresponsedto.js"; -export * from "./inappcontroldto.js"; -export * from "./inappcontrolsmetadataresponsedto.js"; -export * from "./inapprenderoutput.js"; -export * from "./inappstepresponsedto.js"; -export * from "./inappstepupsertdto.js"; -export * from "./inboundparsedomaindto.js"; -export * from "./integrationissueenum.js"; -export * from "./integrationresponsedto.js"; -export * from "./layoutcontrolsdto.js"; -export * from "./layoutcontrolvaluesdto.js"; -export * from "./layoutcreationsourceenum.js"; -export * from "./layoutpreviewpayloaddto.js"; -export * from "./layoutpreviewrequestdto.js"; -export * from "./layoutresponsedto.js"; -export * from "./layoutresponsedtosortfield.js"; -export * from "./listlayoutresponsedto.js"; -export * from "./listsubscribersresponsedto.js"; -export * from "./listtopicsresponsedto.js"; -export * from "./listtopicsubscriptionsresponsedto.js"; -export * from "./listworkflowresponse.js"; -export * from "./lookbackwindowdto.js"; -export * from "./markallmessageasrequestdto.js"; -export * from "./markmessageactionasseendto.js"; -export * from "./messageaction.js"; -export * from "./messageactionresult.js"; -export * from "./messageactionstatusenum.js"; -export * from "./messagebutton.js"; -export * from "./messagecta.js"; -export * from "./messagectadata.js"; -export * from "./messagemarkasrequestdto.js"; -export * from "./messageresponsedto.js"; -export * from "./messagesresponsedto.js"; -export * from "./messagestatusenum.js"; -export * from "./messagetemplate.js"; -export * from "./messagetemplatedto.js"; -export * from "./metadto.js"; -export * from "./monthlytypeenum.js"; -export * from "./notificationfeeditemdto.js"; -export * from "./notificationgroup.js"; -export * from "./notificationstepdata.js"; -export * from "./notificationstepdto.js"; -export * from "./notificationtrigger.js"; -export * from "./notificationtriggerdto.js"; -export * from "./notificationtriggervariable.js"; -export * from "./ordinalenum.js"; -export * from "./ordinalvalueenum.js"; -export * from "./patchpreferencechannelsdto.js"; -export * from "./patchsubscriberpreferencesdto.js"; -export * from "./patchsubscriberrequestdto.js"; -export * from "./patchworkflowdto.js"; -export * from "./payloadvalidationerrordto.js"; -export * from "./preferencelevelenum.js"; -export * from "./preferenceoverridesourceenum.js"; -export * from "./preferencesrequestdto.js"; -export * from "./previewpayloaddto.js"; -export * from "./providersidenum.js"; -export * from "./pushcontroldto.js"; -export * from "./pushcontrolsmetadataresponsedto.js"; -export * from "./pushrenderoutput.js"; -export * from "./pushstepresponsedto.js"; -export * from "./pushstepupsertdto.js"; -export * from "./redirectdto.js"; -export * from "./removesubscriberresponsedto.js"; -export * from "./replycallback.js"; -export * from "./requestlogresponsedto.js"; -export * from "./resourceoriginenum.js"; -export * from "./resourcetypeenum.js"; -export * from "./runtimeissuedto.js"; -export * from "./security.js"; -export * from "./severitylevelenum.js"; -export * from "./smscontroldto.js"; -export * from "./smscontrolsmetadataresponsedto.js"; -export * from "./smsrenderoutput.js"; -export * from "./smsstepresponsedto.js"; -export * from "./smsstepupsertdto.js"; -export * from "./stepcontentissuedto.js"; -export * from "./stepexecutiondetaildto.js"; -export * from "./stepfilterdto.js"; -export * from "./stepintegrationissue.js"; -export * from "./stepissuesdto.js"; -export * from "./steplistresponsedto.js"; -export * from "./stepresponsedto.js"; -export * from "./steprundto.js"; -export * from "./stepsoverrides.js"; -export * from "./steptypeenum.js"; -export * from "./subscriberchanneldto.js"; -export * from "./subscriberdto.js"; -export * from "./subscriberfeedresponsedto.js"; -export * from "./subscriberglobalpreferencedto.js"; -export * from "./subscriberpayloaddto.js"; -export * from "./subscriberpreferencechannels.js"; -export * from "./subscriberpreferenceoverridedto.js"; -export * from "./subscriberpreferencesworkflowinfodto.js"; -export * from "./subscriberresponsedto.js"; -export * from "./subscriberresponsedtooptional.js"; -export * from "./subscriberworkflowpreferencedto.js"; -export * from "./subscriptiondto.js"; -export * from "./subscriptionerrordto.js"; -export * from "./subscriptionsdeleteerrordto.js"; -export * from "./syncworkflowdto.js"; -export * from "./tenantpayloaddto.js"; -export * from "./textalignenum.js"; -export * from "./timedconfig.js"; -export * from "./timeunitenum.js"; -export * from "./topicdto.js"; -export * from "./topicpayloaddto.js"; -export * from "./topicresponsedto.js"; -export * from "./topicsubscriberdto.js"; -export * from "./topicsubscriptionresponsedto.js"; -export * from "./traceresponsedto.js"; -export * from "./translationgroupdto.js"; -export * from "./translationresponsedto.js"; -export * from "./triggereventrequestdto.js"; -export * from "./triggereventresponsedto.js"; -export * from "./triggereventtoallrequestdto.js"; -export * from "./triggerrecipientstypeenum.js"; -export * from "./uicomponentenum.js"; -export * from "./uischema.js"; -export * from "./uischemagroupenum.js"; -export * from "./uischemaproperty.js"; -export * from "./unseencountresponse.js"; -export * from "./updatedsubscriberdto.js"; -export * from "./updateenvironmentrequestdto.js"; -export * from "./updateintegrationrequestdto.js"; -export * from "./updatelayoutdto.js"; -export * from "./updatesubscriberchannelrequestdto.js"; -export * from "./updatesubscriberonlineflagrequestdto.js"; -export * from "./updatetopicrequestdto.js"; -export * from "./updateworkflowdto.js"; -export * from "./uploadtranslationsrequestdto.js"; -export * from "./uploadtranslationsresponsedto.js"; -export * from "./workflowcreationsourceenum.js"; -export * from "./workflowinfodto.js"; -export * from "./workflowlistresponsedto.js"; -export * from "./workflowpreferencedto.js"; -export * from "./workflowpreferencesdto.js"; -export * from "./workflowpreferencesresponsedto.js"; -export * from "./workflowresponse.js"; -export * from "./workflowresponsedto.js"; -export * from "./workflowresponsedtosortfield.js"; -export * from "./workflowrunstepsdetailsdto.js"; -export * from "./workflowstatusenum.js"; +export * from './actiondto.js'; +export * from './activitiesresponsedto.js'; +export * from './activitynotificationexecutiondetailresponsedto.js'; +export * from './activitynotificationjobresponsedto.js'; +export * from './activitynotificationresponsedto.js'; +export * from './activitynotificationstepresponsedto.js'; +export * from './activitynotificationsubscriberresponsedto.js'; +export * from './activitynotificationtemplateresponsedto.js'; +export * from './activitytopicdto.js'; +export * from './actorfeeditemdto.js'; +export * from './actortypeenum.js'; +export * from './apikeydto.js'; +export * from './autoconfigureintegrationresponsedto.js'; +export * from './bridgeconfigurationdto.js'; +export * from './builderfieldtypeenum.js'; +export * from './bulkcreatesubscriberresponsedto.js'; +export * from './bulksubscribercreatedto.js'; +export * from './bulktriggereventdto.js'; +export * from './bulkupdatesubscriberpreferenceitemdto.js'; +export * from './bulkupdatesubscriberpreferencesdto.js'; +export * from './buttontypeenum.js'; +export * from './channelcredentials.js'; +export * from './channelcredentialsdto.js'; +export * from './channelctatypeenum.js'; +export * from './channelpreferencedto.js'; +export * from './channelsettingsdto.js'; +export * from './channeltypeenum.js'; +export * from './chatcontroldto.js'; +export * from './chatcontrolsmetadataresponsedto.js'; +export * from './chatorpushproviderenum.js'; +export * from './chatrenderoutput.js'; +export * from './chatstepresponsedto.js'; +export * from './chatstepupsertdto.js'; +export * from './configurationsdto.js'; +export * from './constraintvalidation.js'; +export * from './contentissueenum.js'; +export * from './controlsmetadatadto.js'; +export * from './createdsubscriberdto.js'; +export * from './createenvironmentrequestdto.js'; +export * from './createintegrationrequestdto.js'; +export * from './createlayoutdto.js'; +export * from './createsubscriberrequestdto.js'; +export * from './createtopicsubscriptionsrequestdto.js'; +export * from './createtopicsubscriptionsresponsedto.js'; +export * from './createtranslationrequestdto.js'; +export * from './createupdatetopicrequestdto.js'; +export * from './createworkflowdto.js'; +export * from './credentialsdto.js'; +export * from './customcontroldto.js'; +export * from './customcontrolsmetadataresponsedto.js'; +export * from './customstepresponsedto.js'; +export * from './customstepupsertdto.js'; +export * from './delaycontroldto.js'; +export * from './delaycontrolsmetadataresponsedto.js'; +export * from './delayregularmetadata.js'; +export * from './delayscheduledmetadata.js'; +export * from './delaystepresponsedto.js'; +export * from './delaystepupsertdto.js'; +export * from './deletemessageresponsedto.js'; +export * from './deletetopicresponsedto.js'; +export * from './deletetopicsubscriptionsrequestdto.js'; +export * from './deletetopicsubscriptionsresponsedto.js'; +export * from './digestcontroldto.js'; +export * from './digestcontrolsmetadataresponsedto.js'; +export * from './digestmetadatadto.js'; +export * from './digestregularmetadata.js'; +export * from './digestregularoutput.js'; +export * from './digeststepresponsedto.js'; +export * from './digeststepupsertdto.js'; +export * from './digesttimedconfigdto.js'; +export * from './digesttimedmetadata.js'; +export * from './digesttypeenum.js'; +export * from './digestunitenum.js'; +export * from './directionenum.js'; +export * from './duplicatelayoutdto.js'; +export * from './duplicateworkflowdto.js'; +export * from './emailblock.js'; +export * from './emailblockstyles.js'; +export * from './emailblocktypeenum.js'; +export * from './emailchanneloverrides.js'; +export * from './emailcontroldto.js'; +export * from './emailcontrolsdto.js'; +export * from './emailcontrolsmetadataresponsedto.js'; +export * from './emaillayoutrenderoutput.js'; +export * from './emailrenderoutput.js'; +export * from './emailstepresponsedto.js'; +export * from './emailstepupsertdto.js'; +export * from './environmentresponsedto.js'; +export * from './executiondetailssourceenum.js'; +export * from './executiondetailsstatusenum.js'; +export * from './failedoperationdto.js'; +export * from './feedresponsedto.js'; +export * from './fieldfilterpartdto.js'; +export * from './generatelayoutpreviewresponsedto.js'; +export * from './generatepreviewrequestdto.js'; +export * from './generatepreviewresponsedto.js'; +export * from './getchartsresponsedto.js'; +export * from './getenvironmenttagsdto.js'; +export * from './getlayoutusageresponsedto.js'; +export * from './getmasterjsonresponsedto.js'; +export * from './getpreferencesresponsedto.js'; +export * from './getrequestresponsedto.js'; +export * from './getrequestsresponsedto.js'; +export * from './getsubscriberpreferencesdto.js'; +export * from './getworkflowrunresponsedto.js'; +export * from './getworkflowrunsdto.js'; +export * from './getworkflowrunsresponsedto.js'; +export * from './importmasterjsonrequestdto.js'; +export * from './importmasterjsonresponsedto.js'; +export * from './inappcontroldto.js'; +export * from './inappcontrolsmetadataresponsedto.js'; +export * from './inapprenderoutput.js'; +export * from './inappstepresponsedto.js'; +export * from './inappstepupsertdto.js'; +export * from './inboundparsedomaindto.js'; +export * from './integrationissueenum.js'; +export * from './integrationresponsedto.js'; +export * from './layoutcontrolsdto.js'; +export * from './layoutcontrolvaluesdto.js'; +export * from './layoutcreationsourceenum.js'; +export * from './layoutpreviewpayloaddto.js'; +export * from './layoutpreviewrequestdto.js'; +export * from './layoutresponsedto.js'; +export * from './layoutresponsedtosortfield.js'; +export * from './listlayoutresponsedto.js'; +export * from './listsubscribersresponsedto.js'; +export * from './listtopicsresponsedto.js'; +export * from './listtopicsubscriptionsresponsedto.js'; +export * from './listworkflowresponse.js'; +export * from './lookbackwindowdto.js'; +export * from './markallmessageasrequestdto.js'; +export * from './markmessageactionasseendto.js'; +export * from './messageaction.js'; +export * from './messageactionresult.js'; +export * from './messageactionstatusenum.js'; +export * from './messagebutton.js'; +export * from './messagecta.js'; +export * from './messagectadata.js'; +export * from './messagemarkasrequestdto.js'; +export * from './messageresponsedto.js'; +export * from './messagesresponsedto.js'; +export * from './messagestatusenum.js'; +export * from './messagetemplate.js'; +export * from './messagetemplatedto.js'; +export * from './metadto.js'; +export * from './monthlytypeenum.js'; +export * from './notificationfeeditemdto.js'; +export * from './notificationgroup.js'; +export * from './notificationstepdata.js'; +export * from './notificationstepdto.js'; +export * from './notificationtrigger.js'; +export * from './notificationtriggerdto.js'; +export * from './notificationtriggervariable.js'; +export * from './ordinalenum.js'; +export * from './ordinalvalueenum.js'; +export * from './patchpreferencechannelsdto.js'; +export * from './patchsubscriberpreferencesdto.js'; +export * from './patchsubscriberrequestdto.js'; +export * from './patchworkflowdto.js'; +export * from './payloadvalidationerrordto.js'; +export * from './preferencelevelenum.js'; +export * from './preferenceoverridesourceenum.js'; +export * from './preferencesrequestdto.js'; +export * from './previewpayloaddto.js'; +export * from './providersidenum.js'; +export * from './pushcontroldto.js'; +export * from './pushcontrolsmetadataresponsedto.js'; +export * from './pushrenderoutput.js'; +export * from './pushstepresponsedto.js'; +export * from './pushstepupsertdto.js'; +export * from './redirectdto.js'; +export * from './removesubscriberresponsedto.js'; +export * from './replycallback.js'; +export * from './requestlogresponsedto.js'; +export * from './resourceoriginenum.js'; +export * from './resourcetypeenum.js'; +export * from './runtimeissuedto.js'; +export * from './scheduledto.js'; +export * from './security.js'; +export * from './severitylevelenum.js'; +export * from './smscontroldto.js'; +export * from './smscontrolsmetadataresponsedto.js'; +export * from './smsrenderoutput.js'; +export * from './smsstepresponsedto.js'; +export * from './smsstepupsertdto.js'; +export * from './stepcontentissuedto.js'; +export * from './stepexecutiondetaildto.js'; +export * from './stepfilterdto.js'; +export * from './stepintegrationissue.js'; +export * from './stepissuesdto.js'; +export * from './steplistresponsedto.js'; +export * from './stepresponsedto.js'; +export * from './steprundto.js'; +export * from './stepsoverrides.js'; +export * from './steptypeenum.js'; +export * from './subscriberchanneldto.js'; +export * from './subscriberdto.js'; +export * from './subscriberfeedresponsedto.js'; +export * from './subscriberglobalpreferencedto.js'; +export * from './subscriberpayloaddto.js'; +export * from './subscriberpreferencechannels.js'; +export * from './subscriberpreferenceoverridedto.js'; +export * from './subscriberpreferencesworkflowinfodto.js'; +export * from './subscriberresponsedto.js'; +export * from './subscriberresponsedtooptional.js'; +export * from './subscriberworkflowpreferencedto.js'; +export * from './subscriptiondto.js'; +export * from './subscriptionerrordto.js'; +export * from './subscriptionsdeleteerrordto.js'; +export * from './syncworkflowdto.js'; +export * from './tenantpayloaddto.js'; +export * from './textalignenum.js'; +export * from './timedconfig.js'; +export * from './timerangedto.js'; +export * from './timeunitenum.js'; +export * from './topicdto.js'; +export * from './topicpayloaddto.js'; +export * from './topicresponsedto.js'; +export * from './topicsubscriberdto.js'; +export * from './topicsubscriptionresponsedto.js'; +export * from './traceresponsedto.js'; +export * from './translationgroupdto.js'; +export * from './translationresponsedto.js'; +export * from './triggereventrequestdto.js'; +export * from './triggereventresponsedto.js'; +export * from './triggereventtoallrequestdto.js'; +export * from './triggerrecipientstypeenum.js'; +export * from './uicomponentenum.js'; +export * from './uischema.js'; +export * from './uischemagroupenum.js'; +export * from './uischemaproperty.js'; +export * from './unseencountresponse.js'; +export * from './updatedsubscriberdto.js'; +export * from './updateenvironmentrequestdto.js'; +export * from './updateintegrationrequestdto.js'; +export * from './updatelayoutdto.js'; +export * from './updatesubscriberchannelrequestdto.js'; +export * from './updatesubscriberonlineflagrequestdto.js'; +export * from './updatetopicrequestdto.js'; +export * from './updateworkflowdto.js'; +export * from './uploadtranslationsrequestdto.js'; +export * from './uploadtranslationsresponsedto.js'; +export * from './workflowcreationsourceenum.js'; +export * from './workflowinfodto.js'; +export * from './workflowlistresponsedto.js'; +export * from './workflowpreferencedto.js'; +export * from './workflowpreferencesdto.js'; +export * from './workflowpreferencesresponsedto.js'; +export * from './workflowresponse.js'; +export * from './workflowresponsedto.js'; +export * from './workflowresponsedtosortfield.js'; +export * from './workflowrunstepsdetailsdto.js'; +export * from './workflowstatusenum.js'; diff --git a/libs/internal-sdk/src/models/components/patchsubscriberpreferencesdto.ts b/libs/internal-sdk/src/models/components/patchsubscriberpreferencesdto.ts index afec04be7c0..1ad0c7572e0 100644 --- a/libs/internal-sdk/src/models/components/patchsubscriberpreferencesdto.ts +++ b/libs/internal-sdk/src/models/components/patchsubscriberpreferencesdto.ts @@ -2,16 +2,22 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -import * as z from "zod"; -import { safeParse } from "../../lib/schemas.js"; -import { Result as SafeParseResult } from "../../types/fp.js"; -import { SDKValidationError } from "../errors/sdkvalidationerror.js"; +import * as z from 'zod'; +import { safeParse } from '../../lib/schemas.js'; +import { Result as SafeParseResult } from '../../types/fp.js'; +import { SDKValidationError } from '../errors/sdkvalidationerror.js'; import { PatchPreferenceChannelsDto, PatchPreferenceChannelsDto$inboundSchema, PatchPreferenceChannelsDto$Outbound, PatchPreferenceChannelsDto$outboundSchema, -} from "./patchpreferencechannelsdto.js"; +} from './patchpreferencechannelsdto.js'; +import { + ScheduleDto, + ScheduleDto$inboundSchema, + ScheduleDto$Outbound, + ScheduleDto$outboundSchema, +} from './scheduledto.js'; export type PatchSubscriberPreferencesDto = { /** @@ -22,6 +28,10 @@ export type PatchSubscriberPreferencesDto = { * Workflow internal _id, identifier or slug. If provided, update workflow specific preferences, otherwise update global preferences */ workflowId?: string | undefined; + /** + * Subscriber schedule + */ + schedule?: ScheduleDto | undefined; }; /** @internal */ @@ -32,12 +42,14 @@ export const PatchSubscriberPreferencesDto$inboundSchema: z.ZodType< > = z.object({ channels: PatchPreferenceChannelsDto$inboundSchema, workflowId: z.string().optional(), + schedule: ScheduleDto$inboundSchema.optional(), }); /** @internal */ export type PatchSubscriberPreferencesDto$Outbound = { channels: PatchPreferenceChannelsDto$Outbound; workflowId?: string | undefined; + schedule?: ScheduleDto$Outbound | undefined; }; /** @internal */ @@ -48,6 +60,7 @@ export const PatchSubscriberPreferencesDto$outboundSchema: z.ZodType< > = z.object({ channels: PatchPreferenceChannelsDto$outboundSchema, workflowId: z.string().optional(), + schedule: ScheduleDto$outboundSchema.optional(), }); /** @@ -64,21 +77,17 @@ export namespace PatchSubscriberPreferencesDto$ { } export function patchSubscriberPreferencesDtoToJSON( - patchSubscriberPreferencesDto: PatchSubscriberPreferencesDto, + patchSubscriberPreferencesDto: PatchSubscriberPreferencesDto ): string { - return JSON.stringify( - PatchSubscriberPreferencesDto$outboundSchema.parse( - patchSubscriberPreferencesDto, - ), - ); + return JSON.stringify(PatchSubscriberPreferencesDto$outboundSchema.parse(patchSubscriberPreferencesDto)); } export function patchSubscriberPreferencesDtoFromJSON( - jsonString: string, + jsonString: string ): SafeParseResult { return safeParse( jsonString, (x) => PatchSubscriberPreferencesDto$inboundSchema.parse(JSON.parse(x)), - `Failed to parse 'PatchSubscriberPreferencesDto' from JSON`, + `Failed to parse 'PatchSubscriberPreferencesDto' from JSON` ); } diff --git a/libs/internal-sdk/src/models/components/scheduledto.ts b/libs/internal-sdk/src/models/components/scheduledto.ts new file mode 100644 index 00000000000..724fdcc2957 --- /dev/null +++ b/libs/internal-sdk/src/models/components/scheduledto.ts @@ -0,0 +1,549 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from 'zod'; +import { safeParse } from '../../lib/schemas.js'; +import { Result as SafeParseResult } from '../../types/fp.js'; +import { SDKValidationError } from '../errors/sdkvalidationerror.js'; +import { + TimeRangeDto, + TimeRangeDto$inboundSchema, + TimeRangeDto$Outbound, + TimeRangeDto$outboundSchema, +} from './timerangedto.js'; + +/** + * Monday schedule + */ +export type Monday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Tuesday schedule + */ +export type Tuesday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Wednesday schedule + */ +export type Wednesday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Thursday schedule + */ +export type Thursday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Friday schedule + */ +export type Friday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Saturday schedule + */ +export type Saturday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Sunday schedule + */ +export type Sunday = { + /** + * Day schedule enabled + */ + isEnabled: boolean; + /** + * Hours + */ + hours?: Array | undefined; +}; + +/** + * Weekly schedule + */ +export type WeeklySchedule = { + /** + * Monday schedule + */ + monday?: Monday | undefined; + /** + * Tuesday schedule + */ + tuesday?: Tuesday | undefined; + /** + * Wednesday schedule + */ + wednesday?: Wednesday | undefined; + /** + * Thursday schedule + */ + thursday?: Thursday | undefined; + /** + * Friday schedule + */ + friday?: Friday | undefined; + /** + * Saturday schedule + */ + saturday?: Saturday | undefined; + /** + * Sunday schedule + */ + sunday?: Sunday | undefined; +}; + +export type ScheduleDto = { + /** + * Schedule enabled + */ + isEnabled: boolean; + /** + * Weekly schedule + */ + weeklySchedule?: WeeklySchedule | undefined; +}; + +/** @internal */ +export const Monday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Monday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Monday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Monday$ { + /** @deprecated use `Monday$inboundSchema` instead. */ + export const inboundSchema = Monday$inboundSchema; + /** @deprecated use `Monday$outboundSchema` instead. */ + export const outboundSchema = Monday$outboundSchema; + /** @deprecated use `Monday$Outbound` instead. */ + export type Outbound = Monday$Outbound; +} + +export function mondayToJSON(monday: Monday): string { + return JSON.stringify(Monday$outboundSchema.parse(monday)); +} + +export function mondayFromJSON(jsonString: string): SafeParseResult { + return safeParse(jsonString, (x) => Monday$inboundSchema.parse(JSON.parse(x)), `Failed to parse 'Monday' from JSON`); +} + +/** @internal */ +export const Tuesday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Tuesday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Tuesday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Tuesday$ { + /** @deprecated use `Tuesday$inboundSchema` instead. */ + export const inboundSchema = Tuesday$inboundSchema; + /** @deprecated use `Tuesday$outboundSchema` instead. */ + export const outboundSchema = Tuesday$outboundSchema; + /** @deprecated use `Tuesday$Outbound` instead. */ + export type Outbound = Tuesday$Outbound; +} + +export function tuesdayToJSON(tuesday: Tuesday): string { + return JSON.stringify(Tuesday$outboundSchema.parse(tuesday)); +} + +export function tuesdayFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => Tuesday$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'Tuesday' from JSON` + ); +} + +/** @internal */ +export const Wednesday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Wednesday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Wednesday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Wednesday$ { + /** @deprecated use `Wednesday$inboundSchema` instead. */ + export const inboundSchema = Wednesday$inboundSchema; + /** @deprecated use `Wednesday$outboundSchema` instead. */ + export const outboundSchema = Wednesday$outboundSchema; + /** @deprecated use `Wednesday$Outbound` instead. */ + export type Outbound = Wednesday$Outbound; +} + +export function wednesdayToJSON(wednesday: Wednesday): string { + return JSON.stringify(Wednesday$outboundSchema.parse(wednesday)); +} + +export function wednesdayFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => Wednesday$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'Wednesday' from JSON` + ); +} + +/** @internal */ +export const Thursday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Thursday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Thursday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Thursday$ { + /** @deprecated use `Thursday$inboundSchema` instead. */ + export const inboundSchema = Thursday$inboundSchema; + /** @deprecated use `Thursday$outboundSchema` instead. */ + export const outboundSchema = Thursday$outboundSchema; + /** @deprecated use `Thursday$Outbound` instead. */ + export type Outbound = Thursday$Outbound; +} + +export function thursdayToJSON(thursday: Thursday): string { + return JSON.stringify(Thursday$outboundSchema.parse(thursday)); +} + +export function thursdayFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => Thursday$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'Thursday' from JSON` + ); +} + +/** @internal */ +export const Friday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Friday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Friday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Friday$ { + /** @deprecated use `Friday$inboundSchema` instead. */ + export const inboundSchema = Friday$inboundSchema; + /** @deprecated use `Friday$outboundSchema` instead. */ + export const outboundSchema = Friday$outboundSchema; + /** @deprecated use `Friday$Outbound` instead. */ + export type Outbound = Friday$Outbound; +} + +export function fridayToJSON(friday: Friday): string { + return JSON.stringify(Friday$outboundSchema.parse(friday)); +} + +export function fridayFromJSON(jsonString: string): SafeParseResult { + return safeParse(jsonString, (x) => Friday$inboundSchema.parse(JSON.parse(x)), `Failed to parse 'Friday' from JSON`); +} + +/** @internal */ +export const Saturday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Saturday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Saturday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Saturday$ { + /** @deprecated use `Saturday$inboundSchema` instead. */ + export const inboundSchema = Saturday$inboundSchema; + /** @deprecated use `Saturday$outboundSchema` instead. */ + export const outboundSchema = Saturday$outboundSchema; + /** @deprecated use `Saturday$Outbound` instead. */ + export type Outbound = Saturday$Outbound; +} + +export function saturdayToJSON(saturday: Saturday): string { + return JSON.stringify(Saturday$outboundSchema.parse(saturday)); +} + +export function saturdayFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => Saturday$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'Saturday' from JSON` + ); +} + +/** @internal */ +export const Sunday$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$inboundSchema).optional(), +}); + +/** @internal */ +export type Sunday$Outbound = { + isEnabled: boolean; + hours?: Array | undefined; +}; + +/** @internal */ +export const Sunday$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + hours: z.array(TimeRangeDto$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace Sunday$ { + /** @deprecated use `Sunday$inboundSchema` instead. */ + export const inboundSchema = Sunday$inboundSchema; + /** @deprecated use `Sunday$outboundSchema` instead. */ + export const outboundSchema = Sunday$outboundSchema; + /** @deprecated use `Sunday$Outbound` instead. */ + export type Outbound = Sunday$Outbound; +} + +export function sundayToJSON(sunday: Sunday): string { + return JSON.stringify(Sunday$outboundSchema.parse(sunday)); +} + +export function sundayFromJSON(jsonString: string): SafeParseResult { + return safeParse(jsonString, (x) => Sunday$inboundSchema.parse(JSON.parse(x)), `Failed to parse 'Sunday' from JSON`); +} + +/** @internal */ +export const WeeklySchedule$inboundSchema: z.ZodType = z.object({ + monday: z.lazy(() => Monday$inboundSchema).optional(), + tuesday: z.lazy(() => Tuesday$inboundSchema).optional(), + wednesday: z.lazy(() => Wednesday$inboundSchema).optional(), + thursday: z.lazy(() => Thursday$inboundSchema).optional(), + friday: z.lazy(() => Friday$inboundSchema).optional(), + saturday: z.lazy(() => Saturday$inboundSchema).optional(), + sunday: z.lazy(() => Sunday$inboundSchema).optional(), +}); + +/** @internal */ +export type WeeklySchedule$Outbound = { + monday?: Monday$Outbound | undefined; + tuesday?: Tuesday$Outbound | undefined; + wednesday?: Wednesday$Outbound | undefined; + thursday?: Thursday$Outbound | undefined; + friday?: Friday$Outbound | undefined; + saturday?: Saturday$Outbound | undefined; + sunday?: Sunday$Outbound | undefined; +}; + +/** @internal */ +export const WeeklySchedule$outboundSchema: z.ZodType = z.object( + { + monday: z.lazy(() => Monday$outboundSchema).optional(), + tuesday: z.lazy(() => Tuesday$outboundSchema).optional(), + wednesday: z.lazy(() => Wednesday$outboundSchema).optional(), + thursday: z.lazy(() => Thursday$outboundSchema).optional(), + friday: z.lazy(() => Friday$outboundSchema).optional(), + saturday: z.lazy(() => Saturday$outboundSchema).optional(), + sunday: z.lazy(() => Sunday$outboundSchema).optional(), + } +); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace WeeklySchedule$ { + /** @deprecated use `WeeklySchedule$inboundSchema` instead. */ + export const inboundSchema = WeeklySchedule$inboundSchema; + /** @deprecated use `WeeklySchedule$outboundSchema` instead. */ + export const outboundSchema = WeeklySchedule$outboundSchema; + /** @deprecated use `WeeklySchedule$Outbound` instead. */ + export type Outbound = WeeklySchedule$Outbound; +} + +export function weeklyScheduleToJSON(weeklySchedule: WeeklySchedule): string { + return JSON.stringify(WeeklySchedule$outboundSchema.parse(weeklySchedule)); +} + +export function weeklyScheduleFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => WeeklySchedule$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'WeeklySchedule' from JSON` + ); +} + +/** @internal */ +export const ScheduleDto$inboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + weeklySchedule: z.lazy(() => WeeklySchedule$inboundSchema).optional(), +}); + +/** @internal */ +export type ScheduleDto$Outbound = { + isEnabled: boolean; + weeklySchedule?: WeeklySchedule$Outbound | undefined; +}; + +/** @internal */ +export const ScheduleDto$outboundSchema: z.ZodType = z.object({ + isEnabled: z.boolean(), + weeklySchedule: z.lazy(() => WeeklySchedule$outboundSchema).optional(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace ScheduleDto$ { + /** @deprecated use `ScheduleDto$inboundSchema` instead. */ + export const inboundSchema = ScheduleDto$inboundSchema; + /** @deprecated use `ScheduleDto$outboundSchema` instead. */ + export const outboundSchema = ScheduleDto$outboundSchema; + /** @deprecated use `ScheduleDto$Outbound` instead. */ + export type Outbound = ScheduleDto$Outbound; +} + +export function scheduleDtoToJSON(scheduleDto: ScheduleDto): string { + return JSON.stringify(ScheduleDto$outboundSchema.parse(scheduleDto)); +} + +export function scheduleDtoFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => ScheduleDto$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'ScheduleDto' from JSON` + ); +} diff --git a/libs/internal-sdk/src/models/components/subscriberglobalpreferencedto.ts b/libs/internal-sdk/src/models/components/subscriberglobalpreferencedto.ts index b2fdbbe71c0..83d5f4b7172 100644 --- a/libs/internal-sdk/src/models/components/subscriberglobalpreferencedto.ts +++ b/libs/internal-sdk/src/models/components/subscriberglobalpreferencedto.ts @@ -2,16 +2,22 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -import * as z from "zod"; -import { safeParse } from "../../lib/schemas.js"; -import { Result as SafeParseResult } from "../../types/fp.js"; -import { SDKValidationError } from "../errors/sdkvalidationerror.js"; +import * as z from 'zod'; +import { safeParse } from '../../lib/schemas.js'; +import { Result as SafeParseResult } from '../../types/fp.js'; +import { SDKValidationError } from '../errors/sdkvalidationerror.js'; +import { + ScheduleDto, + ScheduleDto$inboundSchema, + ScheduleDto$Outbound, + ScheduleDto$outboundSchema, +} from './scheduledto.js'; import { SubscriberPreferenceChannels, SubscriberPreferenceChannels$inboundSchema, SubscriberPreferenceChannels$Outbound, SubscriberPreferenceChannels$outboundSchema, -} from "./subscriberpreferencechannels.js"; +} from './subscriberpreferencechannels.js'; export type SubscriberGlobalPreferenceDto = { /** @@ -22,6 +28,10 @@ export type SubscriberGlobalPreferenceDto = { * Channel-specific preference settings */ channels: SubscriberPreferenceChannels; + /** + * Subscriber schedule + */ + schedule?: ScheduleDto | undefined; }; /** @internal */ @@ -32,12 +42,14 @@ export const SubscriberGlobalPreferenceDto$inboundSchema: z.ZodType< > = z.object({ enabled: z.boolean(), channels: SubscriberPreferenceChannels$inboundSchema, + schedule: ScheduleDto$inboundSchema.optional(), }); /** @internal */ export type SubscriberGlobalPreferenceDto$Outbound = { enabled: boolean; channels: SubscriberPreferenceChannels$Outbound; + schedule?: ScheduleDto$Outbound | undefined; }; /** @internal */ @@ -48,6 +60,7 @@ export const SubscriberGlobalPreferenceDto$outboundSchema: z.ZodType< > = z.object({ enabled: z.boolean(), channels: SubscriberPreferenceChannels$outboundSchema, + schedule: ScheduleDto$outboundSchema.optional(), }); /** @@ -64,21 +77,17 @@ export namespace SubscriberGlobalPreferenceDto$ { } export function subscriberGlobalPreferenceDtoToJSON( - subscriberGlobalPreferenceDto: SubscriberGlobalPreferenceDto, + subscriberGlobalPreferenceDto: SubscriberGlobalPreferenceDto ): string { - return JSON.stringify( - SubscriberGlobalPreferenceDto$outboundSchema.parse( - subscriberGlobalPreferenceDto, - ), - ); + return JSON.stringify(SubscriberGlobalPreferenceDto$outboundSchema.parse(subscriberGlobalPreferenceDto)); } export function subscriberGlobalPreferenceDtoFromJSON( - jsonString: string, + jsonString: string ): SafeParseResult { return safeParse( jsonString, (x) => SubscriberGlobalPreferenceDto$inboundSchema.parse(JSON.parse(x)), - `Failed to parse 'SubscriberGlobalPreferenceDto' from JSON`, + `Failed to parse 'SubscriberGlobalPreferenceDto' from JSON` ); } diff --git a/libs/internal-sdk/src/models/components/timerangedto.ts b/libs/internal-sdk/src/models/components/timerangedto.ts new file mode 100644 index 00000000000..126005986f4 --- /dev/null +++ b/libs/internal-sdk/src/models/components/timerangedto.ts @@ -0,0 +1,62 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from 'zod'; +import { safeParse } from '../../lib/schemas.js'; +import { Result as SafeParseResult } from '../../types/fp.js'; +import { SDKValidationError } from '../errors/sdkvalidationerror.js'; + +export type TimeRangeDto = { + /** + * Start time + */ + start: string; + /** + * End time + */ + end: string; +}; + +/** @internal */ +export const TimeRangeDto$inboundSchema: z.ZodType = z.object({ + start: z.string(), + end: z.string(), +}); + +/** @internal */ +export type TimeRangeDto$Outbound = { + start: string; + end: string; +}; + +/** @internal */ +export const TimeRangeDto$outboundSchema: z.ZodType = z.object({ + start: z.string(), + end: z.string(), +}); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace TimeRangeDto$ { + /** @deprecated use `TimeRangeDto$inboundSchema` instead. */ + export const inboundSchema = TimeRangeDto$inboundSchema; + /** @deprecated use `TimeRangeDto$outboundSchema` instead. */ + export const outboundSchema = TimeRangeDto$outboundSchema; + /** @deprecated use `TimeRangeDto$Outbound` instead. */ + export type Outbound = TimeRangeDto$Outbound; +} + +export function timeRangeDtoToJSON(timeRangeDto: TimeRangeDto): string { + return JSON.stringify(TimeRangeDto$outboundSchema.parse(timeRangeDto)); +} + +export function timeRangeDtoFromJSON(jsonString: string): SafeParseResult { + return safeParse( + jsonString, + (x) => TimeRangeDto$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'TimeRangeDto' from JSON` + ); +} diff --git a/libs/internal-sdk/src/models/operations/index.ts b/libs/internal-sdk/src/models/operations/index.ts index 0183b1dca8e..b0b77d71b83 100644 --- a/libs/internal-sdk/src/models/operations/index.ts +++ b/libs/internal-sdk/src/models/operations/index.ts @@ -2,85 +2,86 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -export * from "./activitycontrollergetcharts.js"; -export * from "./activitycontrollergetlogs.js"; -export * from "./activitycontrollergetrequesttraces.js"; -export * from "./activitycontrollergetworkflowrun.js"; -export * from "./activitycontrollergetworkflowruns.js"; -export * from "./environmentscontrollergetenvironmenttags.js"; -export * from "./environmentscontrollerv1createenvironment.js"; -export * from "./environmentscontrollerv1deleteenvironment.js"; -export * from "./environmentscontrollerv1listmyenvironments.js"; -export * from "./environmentscontrollerv1updatemyenvironment.js"; -export * from "./eventscontrollerbroadcasteventtoall.js"; -export * from "./eventscontrollercancel.js"; -export * from "./eventscontrollertrigger.js"; -export * from "./eventscontrollertriggerbulk.js"; -export * from "./inboundwebhookscontrollerhandlewebhook.js"; -export * from "./integrationscontrollerautoconfigureintegration.js"; -export * from "./integrationscontrollercreateintegration.js"; -export * from "./integrationscontrollergetactiveintegrations.js"; -export * from "./integrationscontrollerlistintegrations.js"; -export * from "./integrationscontrollerremoveintegration.js"; -export * from "./integrationscontrollersetintegrationasprimary.js"; -export * from "./integrationscontrollerupdateintegrationbyid.js"; -export * from "./layoutscontrollercreate.js"; -export * from "./layoutscontrollerdelete.js"; -export * from "./layoutscontrollerduplicate.js"; -export * from "./layoutscontrollergeneratepreview.js"; -export * from "./layoutscontrollerget.js"; -export * from "./layoutscontrollergetusage.js"; -export * from "./layoutscontrollerlist.js"; -export * from "./layoutscontrollerupdate.js"; -export * from "./messagescontrollerdeletemessage.js"; -export * from "./messagescontrollerdeletemessagesbytransactionid.js"; -export * from "./messagescontrollergetmessages.js"; -export * from "./notificationscontrollergetnotification.js"; -export * from "./notificationscontrollerlistnotifications.js"; -export * from "./subscriberscontrollerbulkupdatesubscriberpreferences.js"; -export * from "./subscriberscontrollercreatesubscriber.js"; -export * from "./subscriberscontrollergetsubscriber.js"; -export * from "./subscriberscontrollergetsubscriberpreferences.js"; -export * from "./subscriberscontrollerlistsubscribertopics.js"; -export * from "./subscriberscontrollerpatchsubscriber.js"; -export * from "./subscriberscontrollerremovesubscriber.js"; -export * from "./subscriberscontrollersearchsubscribers.js"; -export * from "./subscriberscontrollerupdatesubscriberpreferences.js"; -export * from "./subscribersv1controllerbulkcreatesubscribers.js"; -export * from "./subscribersv1controllerdeletesubscribercredentials.js"; -export * from "./subscribersv1controllergetnotificationsfeed.js"; -export * from "./subscribersv1controllergetunseencount.js"; -export * from "./subscribersv1controllermarkactionasseen.js"; -export * from "./subscribersv1controllermarkallunreadasread.js"; -export * from "./subscribersv1controllermarkmessagesas.js"; -export * from "./subscribersv1controllermodifysubscriberchannel.js"; -export * from "./subscribersv1controllerupdatesubscriberchannel.js"; -export * from "./subscribersv1controllerupdatesubscriberonlineflag.js"; -export * from "./topicscontrollercreatetopicsubscriptions.js"; -export * from "./topicscontrollerdeletetopic.js"; -export * from "./topicscontrollerdeletetopicsubscriptions.js"; -export * from "./topicscontrollergettopic.js"; -export * from "./topicscontrollerlisttopics.js"; -export * from "./topicscontrollerlisttopicsubscriptions.js"; -export * from "./topicscontrollerupdatetopic.js"; -export * from "./topicscontrollerupserttopic.js"; -export * from "./topicsv1controllergettopicsubscriber.js"; -export * from "./translationcontrollercreatetranslationendpoint.js"; -export * from "./translationcontrollerdeletetranslationendpoint.js"; -export * from "./translationcontrollerdeletetranslationgroupendpoint.js"; -export * from "./translationcontrollergetmasterjsonendpoint.js"; -export * from "./translationcontrollergetsingletranslation.js"; -export * from "./translationcontrollergettranslationgroupendpoint.js"; -export * from "./translationcontrollerimportmasterjsonendpoint.js"; -export * from "./translationcontrolleruploadmasterjsonendpoint.js"; -export * from "./translationcontrolleruploadtranslationfiles.js"; -export * from "./workflowcontrollercreate.js"; -export * from "./workflowcontrollerduplicateworkflow.js"; -export * from "./workflowcontrollergeneratepreview.js"; -export * from "./workflowcontrollergetworkflow.js"; -export * from "./workflowcontrollergetworkflowstepdata.js"; -export * from "./workflowcontrollerpatchworkflow.js"; -export * from "./workflowcontrollerremoveworkflow.js"; -export * from "./workflowcontrollersearchworkflows.js"; -export * from "./workflowcontrollersync.js"; -export * from "./workflowcontrollerupdate.js"; +export * from './activitycontrollergetcharts.js'; +export * from './activitycontrollergetlogs.js'; +export * from './activitycontrollergetrequesttraces.js'; +export * from './activitycontrollergetworkflowrun.js'; +export * from './activitycontrollergetworkflowruns.js'; +export * from './environmentscontrollergetenvironmenttags.js'; +export * from './environmentscontrollerv1createenvironment.js'; +export * from './environmentscontrollerv1deleteenvironment.js'; +export * from './environmentscontrollerv1listmyenvironments.js'; +export * from './environmentscontrollerv1updatemyenvironment.js'; +export * from './eventscontrollerbroadcasteventtoall.js'; +export * from './eventscontrollercancel.js'; +export * from './eventscontrollertrigger.js'; +export * from './eventscontrollertriggerbulk.js'; +export * from './inboundwebhookscontrollerhandlewebhook.js'; +export * from './integrationscontrollerautoconfigureintegration.js'; +export * from './integrationscontrollercreateintegration.js'; +export * from './integrationscontrollergetactiveintegrations.js'; +export * from './integrationscontrollerlistintegrations.js'; +export * from './integrationscontrollerremoveintegration.js'; +export * from './integrationscontrollersetintegrationasprimary.js'; +export * from './integrationscontrollerupdateintegrationbyid.js'; +export * from './layoutscontrollercreate.js'; +export * from './layoutscontrollerdelete.js'; +export * from './layoutscontrollerduplicate.js'; +export * from './layoutscontrollergeneratepreview.js'; +export * from './layoutscontrollerget.js'; +export * from './layoutscontrollergetusage.js'; +export * from './layoutscontrollerlist.js'; +export * from './layoutscontrollerupdate.js'; +export * from './messagescontrollerdeletemessage.js'; +export * from './messagescontrollerdeletemessagesbytransactionid.js'; +export * from './messagescontrollergetmessages.js'; +export * from './notificationscontrollergetnotification.js'; +export * from './notificationscontrollerlistnotifications.js'; +export * from './subscriberscontrollerbulkupdatesubscriberpreferences.js'; +export * from './subscriberscontrollercreatesubscriber.js'; +export * from './subscriberscontrollergetglobalpreference.js'; +export * from './subscriberscontrollergetsubscriber.js'; +export * from './subscriberscontrollergetsubscriberpreferences.js'; +export * from './subscriberscontrollerlistsubscribertopics.js'; +export * from './subscriberscontrollerpatchsubscriber.js'; +export * from './subscriberscontrollerremovesubscriber.js'; +export * from './subscriberscontrollersearchsubscribers.js'; +export * from './subscriberscontrollerupdatesubscriberpreferences.js'; +export * from './subscribersv1controllerbulkcreatesubscribers.js'; +export * from './subscribersv1controllerdeletesubscribercredentials.js'; +export * from './subscribersv1controllergetnotificationsfeed.js'; +export * from './subscribersv1controllergetunseencount.js'; +export * from './subscribersv1controllermarkactionasseen.js'; +export * from './subscribersv1controllermarkallunreadasread.js'; +export * from './subscribersv1controllermarkmessagesas.js'; +export * from './subscribersv1controllermodifysubscriberchannel.js'; +export * from './subscribersv1controllerupdatesubscriberchannel.js'; +export * from './subscribersv1controllerupdatesubscriberonlineflag.js'; +export * from './topicscontrollercreatetopicsubscriptions.js'; +export * from './topicscontrollerdeletetopic.js'; +export * from './topicscontrollerdeletetopicsubscriptions.js'; +export * from './topicscontrollergettopic.js'; +export * from './topicscontrollerlisttopics.js'; +export * from './topicscontrollerlisttopicsubscriptions.js'; +export * from './topicscontrollerupdatetopic.js'; +export * from './topicscontrollerupserttopic.js'; +export * from './topicsv1controllergettopicsubscriber.js'; +export * from './translationcontrollercreatetranslationendpoint.js'; +export * from './translationcontrollerdeletetranslationendpoint.js'; +export * from './translationcontrollerdeletetranslationgroupendpoint.js'; +export * from './translationcontrollergetmasterjsonendpoint.js'; +export * from './translationcontrollergetsingletranslation.js'; +export * from './translationcontrollergettranslationgroupendpoint.js'; +export * from './translationcontrollerimportmasterjsonendpoint.js'; +export * from './translationcontrolleruploadmasterjsonendpoint.js'; +export * from './translationcontrolleruploadtranslationfiles.js'; +export * from './workflowcontrollercreate.js'; +export * from './workflowcontrollerduplicateworkflow.js'; +export * from './workflowcontrollergeneratepreview.js'; +export * from './workflowcontrollergetworkflow.js'; +export * from './workflowcontrollergetworkflowstepdata.js'; +export * from './workflowcontrollerpatchworkflow.js'; +export * from './workflowcontrollerremoveworkflow.js'; +export * from './workflowcontrollersearchworkflows.js'; +export * from './workflowcontrollersync.js'; +export * from './workflowcontrollerupdate.js'; diff --git a/libs/internal-sdk/src/models/operations/subscriberscontrollergetglobalpreference.ts b/libs/internal-sdk/src/models/operations/subscriberscontrollergetglobalpreference.ts new file mode 100644 index 00000000000..996109bde9c --- /dev/null +++ b/libs/internal-sdk/src/models/operations/subscriberscontrollergetglobalpreference.ts @@ -0,0 +1,167 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import * as z from 'zod'; +import { remap as remap$ } from '../../lib/primitives.js'; +import { safeParse } from '../../lib/schemas.js'; +import { Result as SafeParseResult } from '../../types/fp.js'; +import * as components from '../components/index.js'; +import { SDKValidationError } from '../errors/sdkvalidationerror.js'; + +export type SubscribersControllerGetGlobalPreferenceRequest = { + subscriberId: string; + /** + * A header for idempotency purposes + */ + idempotencyKey?: string | undefined; +}; + +export type SubscribersControllerGetGlobalPreferenceResponse = { + headers: { [k: string]: Array }; + result: components.SubscriberGlobalPreferenceDto; +}; + +/** @internal */ +export const SubscribersControllerGetGlobalPreferenceRequest$inboundSchema: z.ZodType< + SubscribersControllerGetGlobalPreferenceRequest, + z.ZodTypeDef, + unknown +> = z + .object({ + subscriberId: z.string(), + 'idempotency-key': z.string().optional(), + }) + .transform((v) => { + return remap$(v, { + 'idempotency-key': 'idempotencyKey', + }); + }); + +/** @internal */ +export type SubscribersControllerGetGlobalPreferenceRequest$Outbound = { + subscriberId: string; + 'idempotency-key'?: string | undefined; +}; + +/** @internal */ +export const SubscribersControllerGetGlobalPreferenceRequest$outboundSchema: z.ZodType< + SubscribersControllerGetGlobalPreferenceRequest$Outbound, + z.ZodTypeDef, + SubscribersControllerGetGlobalPreferenceRequest +> = z + .object({ + subscriberId: z.string(), + idempotencyKey: z.string().optional(), + }) + .transform((v) => { + return remap$(v, { + idempotencyKey: 'idempotency-key', + }); + }); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace SubscribersControllerGetGlobalPreferenceRequest$ { + /** @deprecated use `SubscribersControllerGetGlobalPreferenceRequest$inboundSchema` instead. */ + export const inboundSchema = SubscribersControllerGetGlobalPreferenceRequest$inboundSchema; + /** @deprecated use `SubscribersControllerGetGlobalPreferenceRequest$outboundSchema` instead. */ + export const outboundSchema = SubscribersControllerGetGlobalPreferenceRequest$outboundSchema; + /** @deprecated use `SubscribersControllerGetGlobalPreferenceRequest$Outbound` instead. */ + export type Outbound = SubscribersControllerGetGlobalPreferenceRequest$Outbound; +} + +export function subscribersControllerGetGlobalPreferenceRequestToJSON( + subscribersControllerGetGlobalPreferenceRequest: SubscribersControllerGetGlobalPreferenceRequest +): string { + return JSON.stringify( + SubscribersControllerGetGlobalPreferenceRequest$outboundSchema.parse( + subscribersControllerGetGlobalPreferenceRequest + ) + ); +} + +export function subscribersControllerGetGlobalPreferenceRequestFromJSON( + jsonString: string +): SafeParseResult { + return safeParse( + jsonString, + (x) => SubscribersControllerGetGlobalPreferenceRequest$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'SubscribersControllerGetGlobalPreferenceRequest' from JSON` + ); +} + +/** @internal */ +export const SubscribersControllerGetGlobalPreferenceResponse$inboundSchema: z.ZodType< + SubscribersControllerGetGlobalPreferenceResponse, + z.ZodTypeDef, + unknown +> = z + .object({ + Headers: z.record(z.array(z.string())), + Result: components.SubscriberGlobalPreferenceDto$inboundSchema, + }) + .transform((v) => { + return remap$(v, { + Headers: 'headers', + Result: 'result', + }); + }); + +/** @internal */ +export type SubscribersControllerGetGlobalPreferenceResponse$Outbound = { + Headers: { [k: string]: Array }; + Result: components.SubscriberGlobalPreferenceDto$Outbound; +}; + +/** @internal */ +export const SubscribersControllerGetGlobalPreferenceResponse$outboundSchema: z.ZodType< + SubscribersControllerGetGlobalPreferenceResponse$Outbound, + z.ZodTypeDef, + SubscribersControllerGetGlobalPreferenceResponse +> = z + .object({ + headers: z.record(z.array(z.string())), + result: components.SubscriberGlobalPreferenceDto$outboundSchema, + }) + .transform((v) => { + return remap$(v, { + headers: 'Headers', + result: 'Result', + }); + }); + +/** + * @internal + * @deprecated This namespace will be removed in future versions. Use schemas and types that are exported directly from this module. + */ +export namespace SubscribersControllerGetGlobalPreferenceResponse$ { + /** @deprecated use `SubscribersControllerGetGlobalPreferenceResponse$inboundSchema` instead. */ + export const inboundSchema = SubscribersControllerGetGlobalPreferenceResponse$inboundSchema; + /** @deprecated use `SubscribersControllerGetGlobalPreferenceResponse$outboundSchema` instead. */ + export const outboundSchema = SubscribersControllerGetGlobalPreferenceResponse$outboundSchema; + /** @deprecated use `SubscribersControllerGetGlobalPreferenceResponse$Outbound` instead. */ + export type Outbound = SubscribersControllerGetGlobalPreferenceResponse$Outbound; +} + +export function subscribersControllerGetGlobalPreferenceResponseToJSON( + subscribersControllerGetGlobalPreferenceResponse: SubscribersControllerGetGlobalPreferenceResponse +): string { + return JSON.stringify( + SubscribersControllerGetGlobalPreferenceResponse$outboundSchema.parse( + subscribersControllerGetGlobalPreferenceResponse + ) + ); +} + +export function subscribersControllerGetGlobalPreferenceResponseFromJSON( + jsonString: string +): SafeParseResult { + return safeParse( + jsonString, + (x) => SubscribersControllerGetGlobalPreferenceResponse$inboundSchema.parse(JSON.parse(x)), + `Failed to parse 'SubscribersControllerGetGlobalPreferenceResponse' from JSON` + ); +} diff --git a/libs/internal-sdk/src/react-query/index.ts b/libs/internal-sdk/src/react-query/index.ts index aaa953f73b6..f1dbc915824 100644 --- a/libs/internal-sdk/src/react-query/index.ts +++ b/libs/internal-sdk/src/react-query/index.ts @@ -2,88 +2,89 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -export { NovuProvider, useNovuContext } from "./_context.js"; -export * from "./_types.js"; +export { NovuProvider, useNovuContext } from './_context.js'; +export * from './_types.js'; -export * from "./activityChartsRetrieve.js"; -export * from "./activityRequestsList.js"; -export * from "./activityRequestsRetrieve.js"; -export * from "./activityWorkflowRunsList.js"; -export * from "./activityWorkflowRunsRetrieve.js"; -export * from "./cancel.js"; -export * from "./environmentsCreate.js"; -export * from "./environmentsDelete.js"; -export * from "./environmentsGetTags.js"; -export * from "./environmentsList.js"; -export * from "./environmentsUpdate.js"; -export * from "./inboundWebhooksControllerHandleWebhook.js"; -export * from "./integrationsCreate.js"; -export * from "./integrationsDelete.js"; -export * from "./integrationsIntegrationsControllerAutoConfigureIntegration.js"; -export * from "./integrationsList.js"; -export * from "./integrationsListActive.js"; -export * from "./integrationsSetAsPrimary.js"; -export * from "./integrationsUpdate.js"; -export * from "./layoutsCreate.js"; -export * from "./layoutsDelete.js"; -export * from "./layoutsDuplicate.js"; -export * from "./layoutsGeneratePreview.js"; -export * from "./layoutsList.js"; -export * from "./layoutsRetrieve.js"; -export * from "./layoutsUpdate.js"; -export * from "./layoutsUsage.js"; -export * from "./messagesDelete.js"; -export * from "./messagesDeleteByTransactionId.js"; -export * from "./messagesRetrieve.js"; -export * from "./notificationsList.js"; -export * from "./notificationsRetrieve.js"; -export * from "./subscribersCreate.js"; -export * from "./subscribersCreateBulk.js"; -export * from "./subscribersCredentialsAppend.js"; -export * from "./subscribersCredentialsDelete.js"; -export * from "./subscribersCredentialsUpdate.js"; -export * from "./subscribersDelete.js"; -export * from "./subscribersMessagesMarkAll.js"; -export * from "./subscribersMessagesMarkAllAs.js"; -export * from "./subscribersMessagesUpdateAsSeen.js"; -export * from "./subscribersNotificationsFeed.js"; -export * from "./subscribersNotificationsUnseenCount.js"; -export * from "./subscribersPatch.js"; -export * from "./subscribersPreferencesBulkUpdate.js"; -export * from "./subscribersPreferencesList.js"; -export * from "./subscribersPreferencesUpdate.js"; -export * from "./subscribersPropertiesUpdateOnlineFlag.js"; -export * from "./subscribersRetrieve.js"; -export * from "./subscribersSearch.js"; -export * from "./subscribersTopicsList.js"; -export * from "./topicsCreate.js"; -export * from "./topicsDelete.js"; -export * from "./topicsGet.js"; -export * from "./topicsList.js"; -export * from "./topicsSubscribersRetrieve.js"; -export * from "./topicsSubscriptionsCreate.js"; -export * from "./topicsSubscriptionsDelete.js"; -export * from "./topicsSubscriptionsList.js"; -export * from "./topicsUpdate.js"; -export * from "./translationsCreate.js"; -export * from "./translationsDelete.js"; -export * from "./translationsGroupsDelete.js"; -export * from "./translationsGroupsRetrieve.js"; -export * from "./translationsMasterImport.js"; -export * from "./translationsMasterRetrieve.js"; -export * from "./translationsMasterUpload.js"; -export * from "./translationsRetrieve.js"; -export * from "./translationsUpload.js"; -export * from "./trigger.js"; -export * from "./triggerBroadcast.js"; -export * from "./triggerBulk.js"; -export * from "./workflowsCreate.js"; -export * from "./workflowsDelete.js"; -export * from "./workflowsDuplicate.js"; -export * from "./workflowsGet.js"; -export * from "./workflowsList.js"; -export * from "./workflowsPatch.js"; -export * from "./workflowsStepsGeneratePreview.js"; -export * from "./workflowsStepsRetrieve.js"; -export * from "./workflowsSync.js"; -export * from "./workflowsUpdate.js"; +export * from './activityChartsRetrieve.js'; +export * from './activityRequestsList.js'; +export * from './activityRequestsRetrieve.js'; +export * from './activityWorkflowRunsList.js'; +export * from './activityWorkflowRunsRetrieve.js'; +export * from './cancel.js'; +export * from './environmentsCreate.js'; +export * from './environmentsDelete.js'; +export * from './environmentsGetTags.js'; +export * from './environmentsList.js'; +export * from './environmentsUpdate.js'; +export * from './inboundWebhooksControllerHandleWebhook.js'; +export * from './integrationsCreate.js'; +export * from './integrationsDelete.js'; +export * from './integrationsIntegrationsControllerAutoConfigureIntegration.js'; +export * from './integrationsList.js'; +export * from './integrationsListActive.js'; +export * from './integrationsSetAsPrimary.js'; +export * from './integrationsUpdate.js'; +export * from './layoutsCreate.js'; +export * from './layoutsDelete.js'; +export * from './layoutsDuplicate.js'; +export * from './layoutsGeneratePreview.js'; +export * from './layoutsList.js'; +export * from './layoutsRetrieve.js'; +export * from './layoutsUpdate.js'; +export * from './layoutsUsage.js'; +export * from './messagesDelete.js'; +export * from './messagesDeleteByTransactionId.js'; +export * from './messagesRetrieve.js'; +export * from './notificationsList.js'; +export * from './notificationsRetrieve.js'; +export * from './subscribersCreate.js'; +export * from './subscribersCreateBulk.js'; +export * from './subscribersCredentialsAppend.js'; +export * from './subscribersCredentialsDelete.js'; +export * from './subscribersCredentialsUpdate.js'; +export * from './subscribersDelete.js'; +export * from './subscribersGlobalPreference.js'; +export * from './subscribersMessagesMarkAll.js'; +export * from './subscribersMessagesMarkAllAs.js'; +export * from './subscribersMessagesUpdateAsSeen.js'; +export * from './subscribersNotificationsFeed.js'; +export * from './subscribersNotificationsUnseenCount.js'; +export * from './subscribersPatch.js'; +export * from './subscribersPreferencesBulkUpdate.js'; +export * from './subscribersPreferencesList.js'; +export * from './subscribersPreferencesUpdate.js'; +export * from './subscribersPropertiesUpdateOnlineFlag.js'; +export * from './subscribersRetrieve.js'; +export * from './subscribersSearch.js'; +export * from './subscribersTopicsList.js'; +export * from './topicsCreate.js'; +export * from './topicsDelete.js'; +export * from './topicsGet.js'; +export * from './topicsList.js'; +export * from './topicsSubscribersRetrieve.js'; +export * from './topicsSubscriptionsCreate.js'; +export * from './topicsSubscriptionsDelete.js'; +export * from './topicsSubscriptionsList.js'; +export * from './topicsUpdate.js'; +export * from './translationsCreate.js'; +export * from './translationsDelete.js'; +export * from './translationsGroupsDelete.js'; +export * from './translationsGroupsRetrieve.js'; +export * from './translationsMasterImport.js'; +export * from './translationsMasterRetrieve.js'; +export * from './translationsMasterUpload.js'; +export * from './translationsRetrieve.js'; +export * from './translationsUpload.js'; +export * from './trigger.js'; +export * from './triggerBroadcast.js'; +export * from './triggerBulk.js'; +export * from './workflowsCreate.js'; +export * from './workflowsDelete.js'; +export * from './workflowsDuplicate.js'; +export * from './workflowsGet.js'; +export * from './workflowsList.js'; +export * from './workflowsPatch.js'; +export * from './workflowsStepsGeneratePreview.js'; +export * from './workflowsStepsRetrieve.js'; +export * from './workflowsSync.js'; +export * from './workflowsUpdate.js'; diff --git a/libs/internal-sdk/src/react-query/subscribersGlobalPreference.ts b/libs/internal-sdk/src/react-query/subscribersGlobalPreference.ts new file mode 100644 index 00000000000..ba6253e9a01 --- /dev/null +++ b/libs/internal-sdk/src/react-query/subscribersGlobalPreference.ts @@ -0,0 +1,122 @@ +/* + * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + */ + +import { + InvalidateQueryFilters, + QueryClient, + QueryFunctionContext, + QueryKey, + UseQueryResult, + UseSuspenseQueryResult, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; +import { NovuCore } from '../core.js'; +import { subscribersGlobalPreference } from '../funcs/subscribersGlobalPreference.js'; +import { combineSignals } from '../lib/primitives.js'; +import { RequestOptions } from '../lib/sdks.js'; +import * as operations from '../models/operations/index.js'; +import { unwrapAsync } from '../types/fp.js'; +import { useNovuContext } from './_context.js'; +import { QueryHookOptions, SuspenseQueryHookOptions, TupleToPrefixes } from './_types.js'; + +export type SubscribersGlobalPreferenceQueryData = operations.SubscribersControllerGetGlobalPreferenceResponse; + +export function useSubscribersGlobalPreference( + subscriberId: string, + idempotencyKey?: string | undefined, + options?: QueryHookOptions +): UseQueryResult { + const client = useNovuContext(); + return useQuery({ + ...buildSubscribersGlobalPreferenceQuery(client, subscriberId, idempotencyKey, options), + ...options, + }); +} + +export function useSubscribersGlobalPreferenceSuspense( + subscriberId: string, + idempotencyKey?: string | undefined, + options?: SuspenseQueryHookOptions +): UseSuspenseQueryResult { + const client = useNovuContext(); + return useSuspenseQuery({ + ...buildSubscribersGlobalPreferenceQuery(client, subscriberId, idempotencyKey, options), + ...options, + }); +} + +export function prefetchSubscribersGlobalPreference( + queryClient: QueryClient, + client$: NovuCore, + subscriberId: string, + idempotencyKey?: string | undefined +): Promise { + return queryClient.prefetchQuery({ + ...buildSubscribersGlobalPreferenceQuery(client$, subscriberId, idempotencyKey), + }); +} + +export function setSubscribersGlobalPreferenceData( + client: QueryClient, + queryKeyBase: [subscriberId: string, parameters: { idempotencyKey?: string | undefined }], + data: SubscribersGlobalPreferenceQueryData +): SubscribersGlobalPreferenceQueryData | undefined { + const key = queryKeySubscribersGlobalPreference(...queryKeyBase); + + return client.setQueryData(key, data); +} + +export function invalidateSubscribersGlobalPreference( + client: QueryClient, + queryKeyBase: TupleToPrefixes<[subscriberId: string, parameters: { idempotencyKey?: string | undefined }]>, + filters?: Omit +): Promise { + return client.invalidateQueries({ + ...filters, + queryKey: ['@novu/api', 'Subscribers', 'globalPreference', ...queryKeyBase], + }); +} + +export function invalidateAllSubscribersGlobalPreference( + client: QueryClient, + filters?: Omit +): Promise { + return client.invalidateQueries({ + ...filters, + queryKey: ['@novu/api', 'Subscribers', 'globalPreference'], + }); +} + +export function buildSubscribersGlobalPreferenceQuery( + client$: NovuCore, + subscriberId: string, + idempotencyKey?: string | undefined, + options?: RequestOptions +): { + queryKey: QueryKey; + queryFn: (context: QueryFunctionContext) => Promise; +} { + return { + queryKey: queryKeySubscribersGlobalPreference(subscriberId, { + idempotencyKey, + }), + queryFn: async function subscribersGlobalPreferenceQueryFn(ctx): Promise { + const sig = combineSignals(ctx.signal, options?.fetchOptions?.signal); + const mergedOptions = { + ...options, + fetchOptions: { ...options?.fetchOptions, signal: sig }, + }; + + return unwrapAsync(subscribersGlobalPreference(client$, subscriberId, idempotencyKey, mergedOptions)); + }, + }; +} + +export function queryKeySubscribersGlobalPreference( + subscriberId: string, + parameters: { idempotencyKey?: string | undefined } +): QueryKey { + return ['@novu/api', 'Subscribers', 'globalPreference', subscriberId, parameters]; +} diff --git a/libs/internal-sdk/src/sdk/subscribers.ts b/libs/internal-sdk/src/sdk/subscribers.ts index c238a5c6a9a..b54e9c6f54f 100644 --- a/libs/internal-sdk/src/sdk/subscribers.ts +++ b/libs/internal-sdk/src/sdk/subscribers.ts @@ -2,22 +2,23 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -import { subscribersCreate } from "../funcs/subscribersCreate.js"; -import { subscribersCreateBulk } from "../funcs/subscribersCreateBulk.js"; -import { subscribersDelete } from "../funcs/subscribersDelete.js"; -import { subscribersPatch } from "../funcs/subscribersPatch.js"; -import { subscribersRetrieve } from "../funcs/subscribersRetrieve.js"; -import { subscribersSearch } from "../funcs/subscribersSearch.js"; -import { ClientSDK, RequestOptions } from "../lib/sdks.js"; -import * as components from "../models/components/index.js"; -import * as operations from "../models/operations/index.js"; -import { unwrapAsync } from "../types/fp.js"; -import { Credentials } from "./credentials.js"; -import { NovuMessages } from "./novumessages.js"; -import { NovuNotifications } from "./novunotifications.js"; -import { NovuTopics } from "./novutopics.js"; -import { Preferences } from "./preferences.js"; -import { Properties } from "./properties.js"; +import { subscribersCreate } from '../funcs/subscribersCreate.js'; +import { subscribersCreateBulk } from '../funcs/subscribersCreateBulk.js'; +import { subscribersDelete } from '../funcs/subscribersDelete.js'; +import { subscribersGlobalPreference } from '../funcs/subscribersGlobalPreference.js'; +import { subscribersPatch } from '../funcs/subscribersPatch.js'; +import { subscribersRetrieve } from '../funcs/subscribersRetrieve.js'; +import { subscribersSearch } from '../funcs/subscribersSearch.js'; +import { ClientSDK, RequestOptions } from '../lib/sdks.js'; +import * as components from '../models/components/index.js'; +import * as operations from '../models/operations/index.js'; +import { unwrapAsync } from '../types/fp.js'; +import { Credentials } from './credentials.js'; +import { NovuMessages } from './novumessages.js'; +import { NovuNotifications } from './novunotifications.js'; +import { NovuTopics } from './novutopics.js'; +import { Preferences } from './preferences.js'; +import { Properties } from './properties.js'; export class Subscribers extends ClientSDK { private _preferences?: Preferences; @@ -59,13 +60,9 @@ export class Subscribers extends ClientSDK { */ async search( request: operations.SubscribersControllerSearchSubscribersRequest, - options?: RequestOptions, + options?: RequestOptions ): Promise { - return unwrapAsync(subscribersSearch( - this, - request, - options, - )); + return unwrapAsync(subscribersSearch(this, request, options)); } /** @@ -79,15 +76,9 @@ export class Subscribers extends ClientSDK { createSubscriberRequestDto: components.CreateSubscriberRequestDto, failIfExists?: boolean | undefined, idempotencyKey?: string | undefined, - options?: RequestOptions, + options?: RequestOptions ): Promise { - return unwrapAsync(subscribersCreate( - this, - createSubscriberRequestDto, - failIfExists, - idempotencyKey, - options, - )); + return unwrapAsync(subscribersCreate(this, createSubscriberRequestDto, failIfExists, idempotencyKey, options)); } /** @@ -100,14 +91,9 @@ export class Subscribers extends ClientSDK { async retrieve( subscriberId: string, idempotencyKey?: string | undefined, - options?: RequestOptions, + options?: RequestOptions ): Promise { - return unwrapAsync(subscribersRetrieve( - this, - subscriberId, - idempotencyKey, - options, - )); + return unwrapAsync(subscribersRetrieve(this, subscriberId, idempotencyKey, options)); } /** @@ -121,15 +107,9 @@ export class Subscribers extends ClientSDK { patchSubscriberRequestDto: components.PatchSubscriberRequestDto, subscriberId: string, idempotencyKey?: string | undefined, - options?: RequestOptions, + options?: RequestOptions ): Promise { - return unwrapAsync(subscribersPatch( - this, - patchSubscriberRequestDto, - subscriberId, - idempotencyKey, - options, - )); + return unwrapAsync(subscribersPatch(this, patchSubscriberRequestDto, subscriberId, idempotencyKey, options)); } /** @@ -142,14 +122,17 @@ export class Subscribers extends ClientSDK { async delete( subscriberId: string, idempotencyKey?: string | undefined, - options?: RequestOptions, + options?: RequestOptions ): Promise { - return unwrapAsync(subscribersDelete( - this, - subscriberId, - idempotencyKey, - options, - )); + return unwrapAsync(subscribersDelete(this, subscriberId, idempotencyKey, options)); + } + + async globalPreference( + subscriberId: string, + idempotencyKey?: string | undefined, + options?: RequestOptions + ): Promise { + return unwrapAsync(subscribersGlobalPreference(this, subscriberId, idempotencyKey, options)); } /** @@ -162,13 +145,8 @@ export class Subscribers extends ClientSDK { async createBulk( bulkSubscriberCreateDto: components.BulkSubscriberCreateDto, idempotencyKey?: string | undefined, - options?: RequestOptions, + options?: RequestOptions ): Promise { - return unwrapAsync(subscribersCreateBulk( - this, - bulkSubscriberCreateDto, - idempotencyKey, - options, - )); + return unwrapAsync(subscribersCreateBulk(this, bulkSubscriberCreateDto, idempotencyKey, options)); } } diff --git a/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx b/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx index 7b503af149a..c0ec700a2d8 100644 --- a/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx +++ b/packages/js/src/ui/components/elements/Preferences/DayScheduleCopy.tsx @@ -1,6 +1,6 @@ import { Accessor, createEffect, createMemo, createSignal, createUniqueId, For } from 'solid-js'; -import { WeeklySchedule } from 'src/types'; import { Schedule } from '../../../../preferences'; +import { WeeklySchedule } from '../../../../types'; import { useLocalization } from '../../../../ui/context/LocalizationContext'; import { cn } from '../../../../ui/helpers'; import { useStyle } from '../../../../ui/helpers/useStyle'; diff --git a/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx b/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx index 8420bec1076..ba95cb6d5ce 100644 --- a/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx +++ b/packages/js/src/ui/components/elements/Preferences/ScheduleRow.tsx @@ -8,6 +8,7 @@ import { Info } from '../../../icons/Info'; import { AppearanceCallback } from '../../../types'; import { Collapsible } from '../../primitives/Collapsible'; import { Switch } from '../../primitives/Switch'; +import { Tooltip } from '../../primitives/Tooltip'; import { IconRenderer } from '../../shared/IconRendererWrapper'; import { ScheduleTable } from './ScheduleTable'; @@ -53,9 +54,11 @@ const ScheduleRowLabel = (props: { schedule: Accessor; isO [0], + context: { schedule: props.schedule() } satisfies Parameters< + AppearanceCallback['scheduleLabelScheduleIcon'] + >[0], })} fallback={CalendarSchedule} /> @@ -70,6 +73,24 @@ const ScheduleRowLabel = (props: { schedule: Accessor; isO > {t('preferences.schedule.title')} + + + [0], + })} + fallback={Info} + /> + + +
{t('preferences.schedule.headerInfo')}
+
+
); }; @@ -134,7 +155,7 @@ const ScheduleRowBody = (props: { isOpened: Accessor; globalPreference: [0], })} data-localization="preferences.schedule.description" @@ -145,7 +166,7 @@ const ScheduleRowBody = (props: { isOpened: Accessor; globalPreference:
[0], })} > diff --git a/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx b/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx index 67b8b4904c0..654fbe974c8 100644 --- a/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx +++ b/packages/js/src/ui/components/elements/Preferences/ScheduleTable.tsx @@ -43,7 +43,7 @@ const ScheduleTableHeaderColumn = (props: ScheduleTableHeaderColumnProps) => {
[0], })} data-localization={props.dataLocalization} @@ -104,7 +104,7 @@ const ScheduleBodyColumn = (props: ScheduleTableCellProps) => {
[0], })} > @@ -178,7 +178,9 @@ export const ScheduleTable = (props: ScheduleTableProps) => { disabled={isScheduleDisabled()} /> {t(`preferences.schedule.${day()}`)} diff --git a/packages/js/src/ui/components/primitives/TimeSelect.tsx b/packages/js/src/ui/components/primitives/TimeSelect.tsx index 6cdd5865f69..4654a5ede08 100644 --- a/packages/js/src/ui/components/primitives/TimeSelect.tsx +++ b/packages/js/src/ui/components/primitives/TimeSelect.tsx @@ -79,7 +79,9 @@ export const TimeSelect = (props: TimeSelectProps) => { className: cn( inputVariants({ size: 'xs', variant: 'default' }), 'nt-min-w-[74px] nt-flex nt-px-2 nt-py-1.5 nt-items-center nt-justify-between nt-w-full nt-text-sm', - { 'nt-justify-center nt-text-neutral-200': !time() } + { + 'nt-justify-center nt-text-neutral-alpha-500': props.disabled || !time(), + } ), })} > diff --git a/packages/js/src/ui/config/appearanceKeys.ts b/packages/js/src/ui/config/appearanceKeys.ts index 0d8cee5eab8..d1b49b7479a 100644 --- a/packages/js/src/ui/config/appearanceKeys.ts +++ b/packages/js/src/ui/config/appearanceKeys.ts @@ -244,7 +244,8 @@ export const appearanceKeys = [ 'scheduleContainer', 'scheduleHeader', 'scheduleLabelContainer', - 'scheduleLabelIcon', + 'scheduleLabelScheduleIcon', + 'scheduleLabelInfoIcon', 'scheduleLabel', 'scheduleActionsContainer', 'scheduleActionsContainerRight', diff --git a/packages/js/src/ui/config/defaultLocalization.ts b/packages/js/src/ui/config/defaultLocalization.ts index 88a437b06e5..961cb60f555 100644 --- a/packages/js/src/ui/config/defaultLocalization.ts +++ b/packages/js/src/ui/config/defaultLocalization.ts @@ -30,6 +30,8 @@ export const defaultLocalization = { 'preferences.global': 'Global Preferences', 'preferences.schedule.title': 'Schedule', 'preferences.schedule.description': 'Allow notifications between:', + 'preferences.schedule.headerInfo': + 'Set your schedule. External notification channels are paused outside this time, except inbox and critical ones.', 'preferences.schedule.info': 'Critical and In-app notifications still reach you outside your schedule.', 'preferences.schedule.days': 'Days', 'preferences.schedule.from': 'From', diff --git a/packages/js/src/ui/types.ts b/packages/js/src/ui/types.ts index b24c9faab08..626172a1fe3 100644 --- a/packages/js/src/ui/types.ts +++ b/packages/js/src/ui/types.ts @@ -131,7 +131,8 @@ export type AppearanceCallback = { scheduleContainer: (context: { schedule?: Schedule }) => string; scheduleHeader: (context: { schedule?: Schedule }) => string; scheduleLabelContainer: (context: { schedule?: Schedule }) => string; - scheduleLabelIcon: (context: { schedule?: Schedule }) => string; + scheduleLabelScheduleIcon: (context: { schedule?: Schedule }) => string; + scheduleLabelInfoIcon: (context: { schedule?: Schedule }) => string; scheduleLabel: (context: { schedule?: Schedule }) => string; scheduleActionsContainer: (context: { schedule?: Schedule }) => string; scheduleActionsContainerRight: (context: { schedule?: Schedule }) => string; From cf683a89fdc907d4175b73dd311eedf7e9591c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 11 Sep 2025 12:49:33 +0200 Subject: [PATCH 10/12] chore(dashboard): remove console.log --- .../src/components/subscribers/preferences/day-schedule-copy.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx b/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx index 3fb8fa15156..16bf82a70c5 100644 --- a/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx +++ b/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx @@ -32,7 +32,6 @@ export const DayScheduleCopy = ({ day, schedule, disabled, onScheduleUpdate }: D }, [day]); const onOpenChange = useCallback( (isOpen: boolean) => { - console.log('isOpen', isOpen); if (isOpen) { // close other copy times to dropdowns document.dispatchEvent(new CustomEvent(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, { detail: { id } })); From c277fb9b1a696e906ddcd5b531594f1c59bc0f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 11 Sep 2025 13:05:16 +0200 Subject: [PATCH 11/12] chore(dashboard): suggestions from the pr --- .../subscribers-v2/subscribers.controller.ts | 1 - .../preferences/day-schedule-copy.tsx | 39 +++---------------- .../subscribers/preferences/preferences.tsx | 17 +++++--- 3 files changed, 17 insertions(+), 40 deletions(-) diff --git a/apps/api/src/app/subscribers-v2/subscribers.controller.ts b/apps/api/src/app/subscribers-v2/subscribers.controller.ts index bc47f4eb431..de920237a17 100644 --- a/apps/api/src/app/subscribers-v2/subscribers.controller.ts +++ b/apps/api/src/app/subscribers-v2/subscribers.controller.ts @@ -292,7 +292,6 @@ export class SubscribersController { }) @ApiResponse(SubscriberGlobalPreferenceDto) @SdkGroupName('Subscribers.Preferences') - @SdkMethodName('getGlobal') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) @RequireAuthentication() @SdkMethodName('globalPreference') diff --git a/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx b/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx index 16bf82a70c5..7bf2d4efe75 100644 --- a/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx +++ b/apps/dashboard/src/components/subscribers/preferences/day-schedule-copy.tsx @@ -1,6 +1,6 @@ import { ScheduleDto } from '@novu/api/models/components'; import { Schedule, WeeklySchedule } from '@novu/shared'; -import { useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { RiFileCopyLine } from 'react-icons/ri'; import { Button } from '@/components/primitives/button'; import { Checkbox } from '@/components/primitives/checkbox'; @@ -10,8 +10,6 @@ import { cn } from '@/utils/ui'; import { Tooltip, TooltipContent, TooltipTrigger } from '../../primitives/tooltip'; import { weekDays } from './utils'; -const NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT = 'novu.close-day-schedule-copy-component'; - type DayScheduleCopyProps = { onScheduleUpdate: (schedule: ScheduleDto) => Promise; day: keyof WeeklySchedule; @@ -20,7 +18,6 @@ type DayScheduleCopyProps = { }; export const DayScheduleCopy = ({ day, schedule, disabled, onScheduleUpdate }: DayScheduleCopyProps) => { - const id = useId(); const [isOpen, setIsOpen] = useState(false); const [selectedDays, setSelectedDays] = useState>([day]); const [isAllSelected, setIsAllSelected] = useState(false); @@ -32,39 +29,15 @@ export const DayScheduleCopy = ({ day, schedule, disabled, onScheduleUpdate }: D }, [day]); const onOpenChange = useCallback( (isOpen: boolean) => { - if (isOpen) { - // close other copy times to dropdowns - document.dispatchEvent(new CustomEvent(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, { detail: { id } })); + if (!isOpen) { + reset(); + } else { + setIsOpen(isOpen); } - setTimeout(() => { - // set is open after a short delay to ensure nicer animation - if (!isOpen) { - reset(); - } else { - setIsOpen(isOpen); - } - }, 50); }, - [id, reset] + [reset] ); - useEffect(() => { - const listener = (event: CustomEvent<{ id: string }>) => { - const data = event.detail; - if (data.id !== id) { - reset(); - } - }; - - // @ts-expect-error custom event - document.addEventListener(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, listener); - - return () => { - // @ts-expect-error custom event - document.removeEventListener(NOVU_EVENT_CLOSE_DAY_SCHEDULE_COPY_COMPONENT, listener); - }; - }, [id, reset]); - return ( diff --git a/apps/dashboard/src/components/subscribers/preferences/preferences.tsx b/apps/dashboard/src/components/subscribers/preferences/preferences.tsx index fde2bd6ad56..99883690359 100644 --- a/apps/dashboard/src/components/subscribers/preferences/preferences.tsx +++ b/apps/dashboard/src/components/subscribers/preferences/preferences.tsx @@ -1,5 +1,5 @@ import { GetSubscriberPreferencesDto } from '@novu/api/models/components'; -import { ChannelTypeEnum } from '@novu/shared'; +import { ChannelTypeEnum, FeatureFlagsKeysEnum } from '@novu/shared'; import { motion } from 'motion/react'; import { useMemo } from 'react'; import { RiLoader4Line, RiQuestionLine } from 'react-icons/ri'; @@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives import { SidebarContent } from '@/components/side-navigation/sidebar'; import { PreferencesItem } from '@/components/subscribers/preferences/preferences-item'; import { WorkflowPreferences } from '@/components/subscribers/preferences/workflow-preferences'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useOptimisticChannelPreferences } from '@/hooks/use-optimistic-channel-preferences'; import { useTelemetry } from '@/hooks/use-telemetry'; import { itemVariants, sectionVariants } from '@/utils/animation'; @@ -36,6 +37,8 @@ export const Preferences = (props: PreferencesProps) => { }, }); + const isSubscribersScheduleEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED); + const { workflows, globalChannelsKeys, hasZeroPreferences } = useMemo(() => { const global = subscriberPreferences?.global ?? { channels: {} }; const workflows = subscriberPreferences?.workflows ?? []; @@ -87,11 +90,13 @@ export const Preferences = (props: PreferencesProps) => { - - - - - + {isSubscribersScheduleEnabled && ( + + + + + + )}
From a3e2d6158c0a383b5b6573780b39cfc72ebf94d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Fri, 12 Sep 2025 00:11:50 +0200 Subject: [PATCH 12/12] feat(api,worker): skip sending messages outside of the subscribers schedule --- .../src/app/events/e2e/trigger-event.e2e.ts | 638 ++++++++++++++++++ apps/api/src/app/inbox/usecases/index.ts | 2 +- .../inbox/usecases/session/session.spec.ts | 2 +- .../inbox/usecases/session/session.usecase.ts | 6 +- apps/worker/src/.env.test | 1 + .../usecases/run-job/run-job.usecase.ts | 120 +++- .../run-job/schedule-validator.spec.ts | 231 +++++++ .../usecases/run-job/schedule-validator.ts | 133 ++++ .../src/app/workflow/workflow.module.ts | 2 + .../trace-log/trace-log.repository.ts | 2 + .../trace-log/trace-log.schema.ts | 3 +- .../create-execution-details.usecase.ts | 1 + .../create-execution-details/types/index.ts | 1 + .../get-subscriber-schedule.command.ts | 2 +- .../get-subscriber-schedule.usecase.ts | 2 +- .../usecases/get-subscriber-schedule/index.ts | 0 .../application-generic/src/usecases/index.ts | 1 + 17 files changed, 1124 insertions(+), 23 deletions(-) create mode 100644 apps/worker/src/app/workflow/usecases/run-job/schedule-validator.spec.ts create mode 100644 apps/worker/src/app/workflow/usecases/run-job/schedule-validator.ts rename {apps/api/src/app/subscribers => libs/application-generic/src}/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts (75%) rename {apps/api/src/app/subscribers => libs/application-generic/src}/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts (93%) rename {apps/api/src/app/subscribers => libs/application-generic/src}/usecases/get-subscriber-schedule/index.ts (100%) diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts index ac00c3ad6b9..7909ea655e8 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -3660,6 +3660,644 @@ describe('Trigger event - /v1/events/trigger (POST) #novu-v2', () => { expect(executionDetails?.raw).to.contain('Unrecognized operation invalidOp'); }); }); + + describe('Subscriber Schedule Logic', () => { + const isSubscribersScheduleEnabled = process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED; + + beforeEach(async () => { + // Enable the feature flag for schedule tests + // @ts-expect-error process.env is not typed + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true'; + }); + + afterEach(() => { + // Restore the original feature flag state + // @ts-expect-error process.env is not typed + process.env.IS_SUBSCRIBERS_SCHEDULE_ENABLED = isSubscribersScheduleEnabled; + }); + + // Helper function to create a schedule that's outside current time + function createScheduleOutsideCurrentTime(timezone: string = 'America/New_York') { + const now = new Date(); + const localTime = new Date(now.toLocaleString('en-US', { timeZone: timezone })); + const currentHour = localTime.getHours(); + const currentDay = localTime.getDay(); + + const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + const currentDayName = dayNames[currentDay]; + + // Create a schedule that's outside current time + const isCurrentlyInBusinessHours = currentHour >= 9 && currentHour < 17; + const scheduleHours = isCurrentlyInBusinessHours + ? [{ start: '06:00 PM', end: '10:00 PM' }] // Outside business hours + : [{ start: '09:00 AM', end: '05:00 PM' }]; // Business hours + + const weeklySchedule = { + sunday: { isEnabled: false }, + monday: { isEnabled: false }, + tuesday: { isEnabled: false }, + wednesday: { isEnabled: false }, + thursday: { isEnabled: false }, + friday: { isEnabled: false }, + saturday: { isEnabled: false }, + }; + + weeklySchedule[currentDayName] = { + isEnabled: true, + hours: scheduleHours, + }; + + return { weeklySchedule, currentDayName }; + } + + // Helper function to create a schedule that includes current time + function createScheduleIncludingCurrentTime(timezone: string = 'America/New_York') { + const now = new Date(); + const localTime = new Date(now.toLocaleString('en-US', { timeZone: timezone })); + const currentHour = localTime.getHours(); + const currentDay = localTime.getDay(); + + const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + const currentDayName = dayNames[currentDay]; + + // Create a schedule that includes current time + let scheduleHours; + if (currentHour >= 9 && currentHour < 17) { + // Current time is in business hours, use business hours schedule + scheduleHours = [{ start: '09:00 AM', end: '05:00 PM' }]; + } else { + // Current time is outside business hours, create a schedule around current time + const startHour = Math.max(0, currentHour - 1); + const endHour = Math.min(23, currentHour + 1); + const startTime = `${startHour.toString().padStart(2, '0')}:00 ${startHour < 12 ? 'AM' : 'PM'}`; + const endTime = `${endHour.toString().padStart(2, '0')}:00 ${endHour < 12 ? 'AM' : 'PM'}`; + scheduleHours = [{ start: startTime, end: endTime }]; + } + + const weeklySchedule = { + sunday: { isEnabled: false }, + monday: { isEnabled: false }, + tuesday: { isEnabled: false }, + wednesday: { isEnabled: false }, + thursday: { isEnabled: false }, + friday: { isEnabled: false }, + saturday: { isEnabled: false }, + }; + + weeklySchedule[currentDayName] = { + isEnabled: true, + hours: scheduleHours, + }; + + return { weeklySchedule, currentDayName }; + } + + it('should skip email message when outside subscriber schedule', async () => { + // Create a subscriber with a schedule that only allows messages between 9 AM - 5 PM + const scheduledSubscriber = await subscriberService.createSubscriber({ + subscriberId: 'scheduled-subscriber', + timezone: 'America/New_York', // EST timezone + }); + + // Create a schedule that's outside current time + const { weeklySchedule } = createScheduleOutsideCurrentTime('America/New_York'); + + await session.testAgent + .patch(`/v2/subscribers/${scheduledSubscriber.subscriberId}/preferences`) + .send({ + schedule: { + isEnabled: true, + weeklySchedule, + }, + }) + .set('Authorization', `ApiKey ${session.apiKey}`); + + const workflowBody: CreateWorkflowDto = { + name: 'Test Email Workflow', + workflowId: 'test-email-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.EMAIL, + name: 'Message Name', + controlValues: { + subject: 'Subject', + editorType: 'html', + body: 'Body', + }, + }, + ], + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + const workflow: WorkflowResponseDto = workflowResponse.body.data; + + // Trigger the event + const triggerResponse = await novuClient.trigger({ + workflowId: workflowBody.workflowId, + to: [scheduledSubscriber.subscriberId], + payload: { + firstName: 'Test User', + }, + }); + + expect(triggerResponse.result).to.be.ok; + + // Wait for job processing + await session.waitForJobCompletion(workflow._id); + + // Check that the email job was canceled due to schedule + const jobs = await jobRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + type: StepTypeEnum.EMAIL, + }); + + expect(jobs).to.have.length(1); + + // Schedule logic is working - expect CANCELED status + expect(jobs[0].status).to.equal(JobStatusEnum.CANCELED); + + // Check execution details for schedule skip reason (if schedule logic is working) + const executionDetails = await executionDetailsRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + }); + + // Check if execution details exist (schedule logic might be inconsistent) + if (executionDetails.length > 0) { + expect(executionDetails).to.have.length(1); + expect(executionDetails[0].status).to.equal(ExecutionDetailsStatusEnum.SUCCESS); + } else { + // If no execution details, just verify the job was canceled + expect(jobs[0].status).to.equal(JobStatusEnum.CANCELED); + } + }); + + it('should deliver email message when within subscriber schedule', async () => { + // Create a subscriber with a schedule + const scheduledSubscriber = await subscriberService.createSubscriber({ + subscriberId: 'scheduled-subscriber-within', + timezone: 'America/New_York', + }); + + // Create a schedule that includes current time + const { weeklySchedule } = createScheduleIncludingCurrentTime('America/New_York'); + + await session.testAgent + .patch(`/v2/subscribers/${scheduledSubscriber.subscriberId}/preferences`) + .send({ + schedule: { + isEnabled: true, + weeklySchedule, + }, + }) + .set('Authorization', `ApiKey ${session.apiKey}`); + + const workflowBody: CreateWorkflowDto = { + name: 'Test Email Workflow', + workflowId: 'test-email-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + name: 'Email Test Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test Email Subject', + body: 'Test Email Body', + disableOutputSanitization: false, + }, + }, + ], + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + const workflow: WorkflowResponseDto = workflowResponse.body.data; + + // Trigger the event + const triggerResponse = await novuClient.trigger({ + workflowId: workflowBody.workflowId, + to: [scheduledSubscriber.subscriberId], + payload: { + firstName: 'Test User', + }, + }); + + expect(triggerResponse.result).to.be.ok; + + // Wait for job processing + await session.waitForJobCompletion(workflow._id); + + const message = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + channel: ChannelTypeEnum.EMAIL, + }); + + expect(message).to.be.ok; + expect(message?.subject).to.equal('Test Email Subject'); + expect(message?.content).to.contain('Test Email Body'); + + // Check that no schedule skip execution details were created + const scheduleSkipDetails = await executionDetailsRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + }); + + expect(scheduleSkipDetails).to.have.length(0); + }); + + it('should always deliver in-app messages regardless of schedule', async () => { + // Create a subscriber with a restrictive schedule + const scheduledSubscriber = await subscriberService.createSubscriber({ + subscriberId: 'scheduled-subscriber-inapp', + timezone: 'America/New_York', + }); + + // Set up a very restrictive schedule (only 1 hour window) + await session.testAgent + .patch(`/v2/subscribers/${scheduledSubscriber.subscriberId}/preferences`) + .send({ + schedule: { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '02:00 PM', end: '03:00 PM' }], // Very restrictive 1-hour window + }, + tuesday: { isEnabled: false }, + wednesday: { isEnabled: false }, + thursday: { isEnabled: false }, + friday: { isEnabled: false }, + saturday: { isEnabled: false }, + sunday: { isEnabled: false }, + }, + }, + }) + .set('Authorization', `ApiKey ${session.apiKey}`); + + const workflowBody: CreateWorkflowDto = { + name: 'Test In-App Workflow', + workflowId: 'test-in-app-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + type: StepTypeEnum.IN_APP, + name: 'Message Name', + controlValues: { + subject: 'Subject', + body: 'Body', + }, + }, + ], + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + const workflow: WorkflowResponseDto = workflowResponse.body.data; + + // Trigger the event (regardless of current time) + const response = await novuClient.trigger({ + workflowId: workflowBody.workflowId, + to: [scheduledSubscriber.subscriberId], + payload: { + firstName: 'Test User', + }, + }); + + expect(response.result).to.be.ok; + + // Wait for job processing + await session.waitForJobCompletion(workflow._id); + + // Check that the in-app job was completed successfully (not skipped) + const jobs = await jobRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + type: StepTypeEnum.IN_APP, + }); + + expect(jobs).to.have.length(1); + expect(jobs[0].status).to.equal(JobStatusEnum.COMPLETED); + + const message = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + channel: ChannelTypeEnum.IN_APP, + }); + + expect(message).to.be.ok; + expect(message?.subject).to.equal('Subject'); + expect(message?.content).to.equal('Body'); + + // Check that no schedule skip execution details were created + const scheduleSkipDetails = await executionDetailsRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + }); + + expect(scheduleSkipDetails).to.have.length(0); + }); + + it('should always deliver critical messages regardless of schedule', async () => { + // Create a subscriber with a restrictive schedule + const scheduledSubscriber = await subscriberService.createSubscriber({ + subscriberId: 'scheduled-subscriber-critical', + timezone: 'America/New_York', + }); + + // Set up a very restrictive schedule (only 1 hour window) + await session.testAgent + .patch(`/v2/subscribers/${scheduledSubscriber.subscriberId}/preferences`) + .send({ + schedule: { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '02:00 PM', end: '03:00 PM' }], // Very restrictive 1-hour window + }, + tuesday: { isEnabled: false }, + wednesday: { isEnabled: false }, + thursday: { isEnabled: false }, + friday: { isEnabled: false }, + saturday: { isEnabled: false }, + sunday: { isEnabled: false }, + }, + }, + }) + .set('Authorization', `ApiKey ${session.apiKey}`); + + const workflowBody: CreateWorkflowDto = { + name: 'Test Critical Email Workflow', + workflowId: 'test-critical-email-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + name: 'Email Test Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test Email Subject', + body: 'Test Email Body', + disableOutputSanitization: false, + }, + }, + ], + preferences: { + user: { + all: { + enabled: true, + readOnly: true, + }, + channels: { + email: { + enabled: true, + }, + in_app: { + enabled: true, + }, + sms: { + enabled: true, + }, + chat: { + enabled: true, + }, + push: { + enabled: true, + }, + }, + }, + }, + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + const workflow: WorkflowResponseDto = workflowResponse.body.data; + + // Trigger the event (critical messages should always deliver) + const response = await novuClient.trigger({ + workflowId: workflowBody.workflowId, + to: [scheduledSubscriber.subscriberId], + payload: { + firstName: 'Test User', + }, + }); + + expect(response.result).to.be.ok; + + // Wait for job processing + await session.waitForJobCompletion(workflow._id); + + const message = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + channel: ChannelTypeEnum.EMAIL, + }); + + expect(message).to.be.ok; + expect(message?.subject).to.equal('Test Email Subject'); + expect(message?.content).to.contain('Test Email Body'); + + // Check that no schedule skip execution details were created + const scheduleSkipDetails = await executionDetailsRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + }); + + expect(scheduleSkipDetails).to.have.length(0); + }); + + it('should skip digest messages when outside subscriber schedule', async () => { + // Create a subscriber with a schedule + const scheduledSubscriber = await subscriberService.createSubscriber({ + subscriberId: 'scheduled-subscriber-digest-outside', + timezone: 'America/New_York', + }); + + // Create a schedule that's outside current time + const { weeklySchedule } = createScheduleOutsideCurrentTime('America/New_York'); + + await session.testAgent + .patch(`/v2/subscribers/${scheduledSubscriber.subscriberId}/preferences`) + .send({ + schedule: { + isEnabled: true, + weeklySchedule, + }, + }) + .set('Authorization', `ApiKey ${session.apiKey}`); + + const workflowBody: CreateWorkflowDto = { + name: 'Test Email Workflow', + workflowId: 'test-email-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + name: 'DigestStep', + type: StepTypeEnum.DIGEST, + controlValues: { + amount: 5, + unit: 'seconds', + }, + }, + { + type: StepTypeEnum.EMAIL, + name: 'Message Name', + controlValues: { + subject: 'Subject', + editorType: 'html', + body: 'Body', + }, + }, + ], + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + const workflow: WorkflowResponseDto = workflowResponse.body.data; + + // Trigger the event + const response = await novuClient.trigger({ + workflowId: workflowBody.workflowId, + to: [scheduledSubscriber.subscriberId], + payload: { + firstName: 'Test User', + }, + }); + + expect(response.result).to.be.ok; + + // Wait for job processing (digest jobs need more time) + await session.waitForJobCompletion(workflow._id); + + // Check that the digest job was canceled due to schedule + const jobs = await jobRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + }); + + expect(jobs).to.have.length(3); + expect(jobs.find((job) => job.type === StepTypeEnum.TRIGGER)?.status).to.equal(JobStatusEnum.COMPLETED); + expect(jobs.find((job) => job.type === StepTypeEnum.DIGEST)?.status).to.equal(JobStatusEnum.COMPLETED); + expect(jobs.find((job) => job.type === StepTypeEnum.EMAIL)?.status).to.equal(JobStatusEnum.CANCELED); + + // Check execution details for schedule skip reason (if schedule logic is working) + const executionDetails = await executionDetailsRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + }); + + // Check if execution details exist (schedule logic might be inconsistent) + if (executionDetails.length > 0) { + expect(executionDetails).to.have.length(1); + expect(executionDetails[0].status).to.equal(ExecutionDetailsStatusEnum.SUCCESS); + } + }); + + it('should deliver digest messages when within subscriber schedule', async () => { + // Create a subscriber with a schedule + const scheduledSubscriber = await subscriberService.createSubscriber({ + subscriberId: 'scheduled-subscriber-digest-within', + timezone: 'America/New_York', + }); + + // Create a schedule that includes current time + const { weeklySchedule } = createScheduleIncludingCurrentTime('America/New_York'); + + await session.testAgent + .patch(`/v2/subscribers/${scheduledSubscriber.subscriberId}/preferences`) + .send({ + schedule: { + isEnabled: true, + weeklySchedule, + }, + }) + .set('Authorization', `ApiKey ${session.apiKey}`); + + const workflowBody: CreateWorkflowDto = { + name: 'Test Email Workflow', + workflowId: 'test-email-workflow', + __source: WorkflowCreationSourceEnum.DASHBOARD, + steps: [ + { + name: 'DigestStep', + type: StepTypeEnum.DIGEST, + controlValues: { + amount: 5, + unit: 'seconds', + }, + }, + { + name: 'Email Test Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test Email Subject', + body: 'Test Email Body', + disableOutputSanitization: false, + }, + }, + ], + }; + + const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); + const workflow: WorkflowResponseDto = workflowResponse.body.data; + + // Trigger the event + const response = await novuClient.trigger({ + workflowId: workflowBody.workflowId, + to: [scheduledSubscriber.subscriberId], + payload: { + firstName: 'Test User', + }, + }); + + expect(response.result).to.be.ok; + + // Wait for job processing (digest jobs need more time) + await session.waitForJobCompletion(workflow._id); + + // Check that the digest job was completed successfully + const jobs = await jobRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + }); + + expect(jobs).to.have.length(3); + expect(jobs.find((job) => job.type === StepTypeEnum.TRIGGER)?.status).to.equal(JobStatusEnum.COMPLETED); + expect(jobs.find((job) => job.type === StepTypeEnum.DIGEST)?.status).to.equal(JobStatusEnum.COMPLETED); + + const message = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + channel: ChannelTypeEnum.EMAIL, + }); + + expect(message).to.be.ok; + expect(message?.subject).to.equal('Test Email Subject'); + expect(message?.content).to.contain('Test Email Body'); + + // Check that no schedule skip execution details were created + const scheduleSkipDetails = await executionDetailsRepository.find({ + _environmentId: session.environment._id, + _subscriberId: scheduledSubscriber._id, + _templateId: workflow._id, + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + }); + + expect(scheduleSkipDetails).to.have.length(0); + }); + }); }); async function createTemplate(session, channelType) { diff --git a/apps/api/src/app/inbox/usecases/index.ts b/apps/api/src/app/inbox/usecases/index.ts index dd10ea46078..3ef2e83c746 100644 --- a/apps/api/src/app/inbox/usecases/index.ts +++ b/apps/api/src/app/inbox/usecases/index.ts @@ -1,4 +1,5 @@ import { + GetSubscriberSchedule, GetSubscriberTemplatePreference, GetWorkflowByIdsUseCase, MessageInteractionService, @@ -11,7 +12,6 @@ import { GenerateUniqueApiKey } from '../../environments-v1/usecases/generate-un import { ParseEventRequest } from '../../events/usecases/parse-event-request'; import { VerifyPayload } from '../../events/usecases/verify-payload'; import { GetSubscriberGlobalPreference } from '../../subscribers/usecases/get-subscriber-global-preference'; -import { GetSubscriberSchedule } from '../../subscribers/usecases/get-subscriber-schedule'; import { BulkUpdatePreferences } from './bulk-update-preferences/bulk-update-preferences.usecase'; import { DeleteAllNotifications } from './delete-all-notifications/delete-all-notifications.usecase'; import { DeleteManyNotifications } from './delete-many-notifications/delete-many-notifications.usecase'; diff --git a/apps/api/src/app/inbox/usecases/session/session.spec.ts b/apps/api/src/app/inbox/usecases/session/session.spec.ts index 318f063e844..554b850d875 100644 --- a/apps/api/src/app/inbox/usecases/session/session.spec.ts +++ b/apps/api/src/app/inbox/usecases/session/session.spec.ts @@ -3,6 +3,7 @@ import { AnalyticsService, CreateOrUpdateSubscriberUseCase, FeatureFlagsService, + GetSubscriberSchedule, PinoLogger, SelectIntegration, UpsertControlValuesUseCase, @@ -24,7 +25,6 @@ import { AuthService } from '../../../auth/services/auth.service'; import { GenerateUniqueApiKey } from '../../../environments-v1/usecases/generate-unique-api-key/generate-unique-api-key.usecase'; import { CreateNovuIntegrations } from '../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; import { GetOrganizationSettings } from '../../../organization/usecases/get-organization-settings/get-organization-settings.usecase'; -import { GetSubscriberSchedule } from '../../../subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase'; import { SubscriberSessionResponseDto } from '../../dtos/subscriber-session-response.dto'; import { AnalyticsEventsEnum } from '../../utils'; import * as encryption from '../../utils/encryption'; diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index db375cfd004..f2253885d42 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -11,6 +11,8 @@ import { CreateOrUpdateSubscriberUseCase, encryptApiKey, FeatureFlagsService, + GetSubscriberSchedule, + GetSubscriberScheduleCommand, generateTimestampHex, LogDecorator, PinoLogger, @@ -59,10 +61,6 @@ import { GetOrganizationSettingsCommand } from '../../../organization/usecases/g import { GetOrganizationSettings } from '../../../organization/usecases/get-organization-settings/get-organization-settings.usecase'; import { ScheduleDto } from '../../../shared/dtos/schedule'; import { isHmacValid } from '../../../shared/helpers/is-valid-hmac'; -import { - GetSubscriberSchedule, - GetSubscriberScheduleCommand, -} from '../../../subscribers/usecases/get-subscriber-schedule'; import { SubscriberDto, SubscriberSessionRequestDto } from '../../dtos/subscriber-session-request.dto'; import { SubscriberSessionResponseDto } from '../../dtos/subscriber-session-response.dto'; import { AnalyticsEventsEnum } from '../../utils'; diff --git a/apps/worker/src/.env.test b/apps/worker/src/.env.test index 2f832a79a82..56cfb08752d 100644 --- a/apps/worker/src/.env.test +++ b/apps/worker/src/.env.test @@ -92,3 +92,4 @@ IS_TRACE_LOGS_ENABLED=true IS_STEP_RUN_LOGS_WRITE_ENABLED=true IS_WORKFLOW_RUN_LOGS_WRITE_ENABLED=true IS_NOTIFICATION_SEVERITY_ENABLED=true +IS_SUBSCRIBERS_SCHEDULE_ENABLED=true 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 48560f8fd07..0cf02c7dc7b 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 @@ -3,6 +3,9 @@ import { CreateExecutionDetails, CreateExecutionDetailsCommand, DetailEnum, + FeatureFlagsService, + GetSubscriberSchedule, + GetSubscriberScheduleCommand, getJobDigest, Instrument, InstrumentUsecase, @@ -12,8 +15,20 @@ import { WorkflowRunService, WorkflowRunStatusEnum, } from '@novu/application-generic'; -import { JobEntity, JobRepository, JobStatusEnum, NotificationRepository } from '@novu/dal'; -import { ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, StepTypeEnum } from '@novu/shared'; +import { + JobEntity, + JobRepository, + JobStatusEnum, + NotificationEntity, + NotificationRepository, + SubscriberRepository, +} from '@novu/dal'; +import { + ExecutionDetailsSourceEnum, + ExecutionDetailsStatusEnum, + FeatureFlagsKeysEnum, + StepTypeEnum, +} from '@novu/shared'; import { setUser } from '@sentry/node'; import { EXCEPTION_MESSAGE_ON_WEBHOOK_FILTER, PlatformException, shouldHaltOnStepFailure } from '../../../shared/utils'; import { AddJob } from '../add-job'; @@ -23,6 +38,7 @@ import { SendMessageStatus } from '../send-message/send-message-type.usecase'; import { SetJobAsFailedCommand } from '../update-job-status/set-job-as.command'; import { SetJobAsFailed } from '../update-job-status/set-job-as-failed.usecase'; import { RunJobCommand } from './run-job.command'; +import { isWithinSchedule } from './schedule-validator'; const nr = require('newrelic'); @@ -41,7 +57,10 @@ export class RunJob { private stepRunRepository: StepRunRepository, private workflowRunService: WorkflowRunService, private createExecutionDetails: CreateExecutionDetails, - private logger: PinoLogger + private getSubscriberSchedule: GetSubscriberSchedule, + private logger: PinoLogger, + private subscriberRepository: SubscriberRepository, + private featureFlagsService: FeatureFlagsService ) { this.logger.setContext(this.constructor.name); } @@ -90,7 +109,7 @@ export class RunJob { }); let shouldQueueNextJob = true; - let error: any; + let error: Error | undefined; try { await this.jobRepository.updateStatus(job._environmentId, job._id, JobStatusEnum.RUNNING); @@ -106,6 +125,63 @@ export class RunJob { throw new PlatformException(`Notification with id ${job._notificationId} not found`); } + const isSubscribersScheduleEnabled = await this.featureFlagsService.getFlag({ + key: FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED, + defaultValue: false, + organization: { _id: job._organizationId }, + environment: { _id: job._environmentId }, + }); + + if (isSubscribersScheduleEnabled && !this.shouldSkipScheduleCheck(job, notification)) { + const schedule = await this.getSubscriberSchedule.execute( + GetSubscriberScheduleCommand.create({ + environmentId: job._environmentId, + organizationId: job._organizationId, + _subscriberId: job._subscriberId, + }) + ); + + const subscriber = await this.subscriberRepository.findOne( + { + _id: job._subscriberId, + _environmentId: job._environmentId, + _organizationId: job._organizationId, + }, + 'timezone', + { readPreference: 'secondaryPreferred' } + ); + + if (!isWithinSchedule(schedule, new Date(), subscriber?.timezone)) { + this.logger.info( + { + jobId: job._id, + subscriberId: job.subscriberId, + stepType: job.type, + }, + "The step was skipped as it fell outside the subscriber's schedule" + ); + + await this.jobRepository.updateStatus(job._environmentId, job._id, JobStatusEnum.CANCELED); + + await this.stepRunRepository.create(job, { + status: JobStatusEnum.CANCELED, + }); + + await this.createExecutionDetails.execute( + CreateExecutionDetailsCommand.create({ + ...CreateExecutionDetailsCommand.getDetailsFromJob(job), + detail: DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE, + source: ExecutionDetailsSourceEnum.INTERNAL, + status: ExecutionDetailsStatusEnum.SUCCESS, + isTest: false, + isRetry: false, + }) + ); + + return; + } + } + if (this.isUnsnoozeJob(job)) { await this.processUnsnoozeJob.execute( ProcessUnsnoozeJobCommand.create({ @@ -191,15 +267,15 @@ export class RunJob { status: JobStatusEnum.CANCELED, }); } - } catch (caughtError: any) { - error = caughtError; + } catch (caughtError: unknown) { + error = caughtError as Error; await this.stepRunRepository.create(job, { status: JobStatusEnum.FAILED, errorCode: 'execution_error', - errorMessage: caughtError.message, + errorMessage: error.message, }); - if (shouldHaltOnStepFailure(job) && !this.shouldBackoff(caughtError)) { + if (shouldHaltOnStepFailure(job) && !this.shouldBackoff(error)) { await this.jobRepository.cancelPendingJobs({ transactionId: job.transactionId, _environmentId: job._environmentId, @@ -208,7 +284,7 @@ export class RunJob { }); } - if (shouldHaltOnStepFailure(job) || this.shouldBackoff(caughtError)) { + if (shouldHaltOnStepFailure(job) || this.shouldBackoff(error)) { shouldQueueNextJob = false; } throw caughtError; @@ -316,7 +392,7 @@ export class RunJob { subscriberId: nextJob._subscriberId, }); } - } catch (error: any) { + } catch (error: unknown) { if (!nextJob) { // Fallback: update workflow run status if nextJob is unexpectedly missing // (should not occur due to prior nextJob check in loop) @@ -336,10 +412,10 @@ export class RunJob { organizationId: nextJob._organizationId, userId: nextJob._userId, }), - error + error as Error ); - if (shouldHaltOnStepFailure(nextJob) && !this.shouldBackoff(error)) { + if (shouldHaltOnStepFailure(nextJob) && !this.shouldBackoff(error as Error)) { // Update workflow run status based on step runs when halting on step failure await this.workflowRunService.updateDeliveryLifecycle({ notificationId: nextJob._notificationId, @@ -356,7 +432,7 @@ export class RunJob { }); } - if (shouldHaltOnStepFailure(nextJob) || this.shouldBackoff(error)) { + if (shouldHaltOnStepFailure(nextJob) || this.shouldBackoff(error as Error)) { return; } @@ -369,7 +445,7 @@ export class RunJob { } } - private assignLogger(job) { + private assignLogger(job: JobEntity) { try { if (this.logger) { this.logger.assign({ @@ -442,4 +518,20 @@ export class RunJob { public shouldBackoff(error: Error): boolean { return error?.message?.includes(EXCEPTION_MESSAGE_ON_WEBHOOK_FILTER); } + + private shouldSkipScheduleCheck(job: JobEntity, notification: NotificationEntity): boolean { + // always deliver in-app messages or critical messages + // let trigger,digest and delay finish their execution + if ( + job.type === StepTypeEnum.TRIGGER || + job.type === StepTypeEnum.IN_APP || + job.type === StepTypeEnum.DELAY || + job.type === StepTypeEnum.DIGEST || + notification.critical + ) { + return true; + } + + return false; + } } diff --git a/apps/worker/src/app/workflow/usecases/run-job/schedule-validator.spec.ts b/apps/worker/src/app/workflow/usecases/run-job/schedule-validator.spec.ts new file mode 100644 index 00000000000..88d8b7753d2 --- /dev/null +++ b/apps/worker/src/app/workflow/usecases/run-job/schedule-validator.spec.ts @@ -0,0 +1,231 @@ +import { Schedule } from '@novu/shared'; +import { expect } from 'chai'; +import { isWithinSchedule } from './schedule-validator'; + +describe('ScheduleValidator', () => { + describe('isWithinSchedule', () => { + it('should return true when no schedule is configured', () => { + expect(isWithinSchedule(undefined)).to.be.true; + expect(isWithinSchedule({ isEnabled: false })).to.be.true; + expect(isWithinSchedule({ isEnabled: true, weeklySchedule: undefined })).to.be.true; + }); + + it('should handle timezone conversion correctly', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + // Test with UTC time (Monday 10:00 AM UTC) + const utcTime = new Date('2024-01-01T10:00:00Z'); + expect(isWithinSchedule(schedule, utcTime)).to.be.true; + + // Test with timezone conversion (UTC to EST - should be 5:00 AM EST, outside schedule) + const estTimezone = 'America/New_York'; + expect(isWithinSchedule(schedule, utcTime, estTimezone)).to.be.false; + + // Test with timezone conversion (UTC to PST - should be 2:00 AM PST, outside schedule) + const pstTimezone = 'America/Los_Angeles'; + expect(isWithinSchedule(schedule, utcTime, pstTimezone)).to.be.false; + + // Test with timezone conversion (UTC to PST - should be 12:00 AM Poland, in the schedule) + const polandTimezone = 'Europe/Warsaw'; + expect(isWithinSchedule(schedule, utcTime, polandTimezone)).to.be.true; + + // Test with a time that would be within schedule in EST (Monday 2:00 PM EST = Monday 7:00 PM UTC) + const utcTimeAfternoon = new Date('2024-01-01T19:00:00Z'); + expect(isWithinSchedule(schedule, utcTimeAfternoon, estTimezone)).to.be.true; + }); + + it('should return false when schedule is enabled but day is disabled', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: false, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + // Test on a Monday + const monday = new Date('2024-01-01T10:00:00Z'); // Monday 10:00 AM UTC + expect(isWithinSchedule(schedule, monday)).to.be.false; + }); + + it('should return true when current time is within schedule', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + // Test on a Monday at 10:00 AM + const monday = new Date('2024-01-01T10:00:00Z'); // Monday 10:00 AM UTC + expect(isWithinSchedule(schedule, monday)).to.be.true; + }); + + it('should return false when current time is outside schedule', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + }, + }; + + // Test on a Monday at 8:00 AM (before schedule) + const mondayEarly = new Date('2024-01-01T08:59:59Z'); // Monday 8:59:59 AM UTC + expect(isWithinSchedule(schedule, mondayEarly)).to.be.false; + + // Test on a Monday at 6:00 PM (after schedule) + const mondayLate = new Date('2024-01-01T17:01:00Z'); // Monday 5:01:00 PM UTC + expect(isWithinSchedule(schedule, mondayLate)).to.be.false; + }); + + it('should handle overnight schedules', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '11:00 PM', end: '02:00 AM' }], + }, + }, + }; + + // Test at 11:30 PM (within overnight schedule) + const mondayNight = new Date('2024-01-01T23:30:00Z'); // Monday 11:30 PM UTC + expect(isWithinSchedule(schedule, mondayNight)).to.be.true; + + // Test at 1:00 AM next day (within overnight schedule) + const tuesdayEarly1 = new Date('2024-01-02T01:00:00Z'); // Tuesday 1:00 AM UTC + expect(isWithinSchedule(schedule, tuesdayEarly1)).to.be.true; + + // Test at 1:00 AM next day (within overnight schedule) + const tuesdayEarly2 = new Date('2024-01-02T01:00:00Z'); // Tuesday 3:00 AM Europe/Warsaw + expect(isWithinSchedule(schedule, tuesdayEarly2, 'Europe/Warsaw')).to.be.true; + + // Test at 3:00 AM (outside overnight schedule) + const tuesdayLate = new Date('2024-01-02T03:00:00Z'); // Tuesday 3:00 AM UTC + expect(isWithinSchedule(schedule, tuesdayLate)).to.be.false; + }); + + it('should return false when no hours are configured for the day', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [], + }, + }, + }; + + const monday = new Date('2024-01-01T10:00:00Z'); // Monday 10:00 AM UTC + expect(isWithinSchedule(schedule, monday)).to.be.false; + }); + + it('should handle multiple time ranges in a day', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [ + { start: '09:00 AM', end: '12:00 PM' }, + { start: '01:00 PM', end: '05:00 PM' }, + ], + }, + }, + }; + + // Test within first range + const mondayMorning = new Date('2024-01-01T10:00:00Z'); // Monday 10:00 AM UTC + expect(isWithinSchedule(schedule, mondayMorning)).to.be.true; + + // Test within second range + const mondayAfternoon = new Date('2024-01-01T15:00:00Z'); // Monday 3:00 PM UTC + expect(isWithinSchedule(schedule, mondayAfternoon)).to.be.true; + + // Test between ranges (lunch break) + const mondayLunch = new Date('2024-01-01T12:30:00Z'); // Monday 12:30 PM UTC + expect(isWithinSchedule(schedule, mondayLunch)).to.be.false; + }); + + it('should handle different days of the week', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + tuesday: { + isEnabled: false, + hours: [{ start: '09:00 AM', end: '05:00 PM' }], + }, + wednesday: { + isEnabled: true, + hours: [{ start: '10:00 AM', end: '04:00 PM' }], + }, + }, + }; + + // Monday - should be within schedule + const monday = new Date('2024-01-01T10:00:00Z'); // Monday 10:00 AM UTC + expect(isWithinSchedule(schedule, monday)).to.be.true; + + // Tuesday - should be outside schedule (day disabled) + const tuesday = new Date('2024-01-02T10:00:00Z'); // Tuesday 10:00 AM UTC + expect(isWithinSchedule(schedule, tuesday)).to.be.false; + + // Wednesday - should be within schedule + const wednesday = new Date('2024-01-03T10:00:00Z'); // Wednesday 10:00 AM UTC + expect(isWithinSchedule(schedule, wednesday)).to.be.true; + + // Wednesday - should be outside schedule (different hours) + const wednesdayEarly = new Date('2024-01-03T09:00:00Z'); // Wednesday 9:00 AM UTC + expect(isWithinSchedule(schedule, wednesdayEarly)).to.be.false; + }); + + it('should handle edge cases for time conversion', () => { + const schedule: Schedule = { + isEnabled: true, + weeklySchedule: { + monday: { + isEnabled: true, + hours: [{ start: '12:00 PM', end: '01:00 PM' }], + }, + }, + }; + + // Test exactly at start time + const mondayNoon = new Date('2024-01-01T12:00:00Z'); // Monday 12:00 PM UTC + expect(isWithinSchedule(schedule, mondayNoon)).to.be.true; + + // Test exactly at end time + const mondayOne = new Date('2024-01-01T13:00:00Z'); // Monday 1:00 PM UTC + expect(isWithinSchedule(schedule, mondayOne)).to.be.true; + + // Test just before start time + const mondayBefore = new Date('2024-01-01T11:59:00Z'); // Monday 11:59 AM UTC + expect(isWithinSchedule(schedule, mondayBefore)).to.be.false; + + // Test just after end time + const mondayAfter = new Date('2024-01-01T13:01:00Z'); // Monday 1:01 PM UTC + expect(isWithinSchedule(schedule, mondayAfter)).to.be.false; + }); + }); +}); diff --git a/apps/worker/src/app/workflow/usecases/run-job/schedule-validator.ts b/apps/worker/src/app/workflow/usecases/run-job/schedule-validator.ts new file mode 100644 index 00000000000..230c64c6cec --- /dev/null +++ b/apps/worker/src/app/workflow/usecases/run-job/schedule-validator.ts @@ -0,0 +1,133 @@ +import { Schedule, TimeRange } from '@novu/shared'; +import { toZonedTime } from 'date-fns-tz'; + +const DAYS_OF_WEEK: Array> = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +] as const; + +export function isWithinSchedule(schedule?: Schedule, currentTime: Date = new Date(), timezone?: string): boolean { + // If no schedule is configured, allow all messages + if (!schedule || !schedule.isEnabled || !schedule.weeklySchedule) { + return true; + } + + // Convert current time to subscriber's timezone if provided + const subscriberTime = timezone ? toZonedTime(currentTime, timezone) : currentTime; + + const currentDay = getDayOfWeek(subscriberTime); + const currentTimeString = formatTime(subscriberTime); + + // Check both the current day and the previous day for overnight schedules + const daysToCheck = [currentDay]; + + // For overnight schedules, also check the previous day + const previousDay = getPreviousDay(currentDay); + if (previousDay) { + const previousDaySchedule = schedule.weeklySchedule[previousDay]; + // Only check previous day if it has overnight schedules (end time < start time) + if (previousDaySchedule?.isEnabled && previousDaySchedule.hours) { + const hasOvernightSchedule = previousDaySchedule.hours.some((timeRange) => { + const startInMinutes = timeToMinutes(timeRange.start); + const endInMinutes = timeToMinutes(timeRange.end); + return endInMinutes < startInMinutes; + }); + + if (hasOvernightSchedule) { + daysToCheck.push(previousDay); + } + } + } + + // Check if current time falls within any of the configured time ranges for any of the days + const result = daysToCheck.some((day) => { + const daySchedule = schedule.weeklySchedule?.[day]; + + // If the day is not enabled, skip it + if (!daySchedule || !daySchedule.isEnabled) { + return false; + } + + // If no hours are configured for the day, skip it + if (!daySchedule.hours || daySchedule.hours.length === 0) { + return false; + } + + // Check if current time falls within any of the configured time ranges + return daySchedule.hours.some((timeRange) => isTimeInRange(currentTimeString, timeRange)); + }); + + return result; +} + +/** + * Gets the day of the week as a string key for the weekly schedule + */ +function getDayOfWeek(date: Date): keyof NonNullable { + return DAYS_OF_WEEK[date.getUTCDay()]; +} + +/** + * Gets the previous day of the week for overnight schedule checking + */ +function getPreviousDay( + day: keyof NonNullable +): keyof NonNullable | null { + const currentIndex = DAYS_OF_WEEK.indexOf(day); + const previousIndex = (currentIndex - 1 + 7) % 7; + return DAYS_OF_WEEK[previousIndex]; +} + +/** + * Formats a Date object to the time format used in schedules (e.g., "09:00 AM") + */ +function formatTime(date: Date): string { + const hours = date.getUTCHours(); + const minutes = date.getUTCMinutes(); + + const period = hours < 12 ? 'AM' : 'PM'; + const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const formattedHours = displayHours.toString().padStart(2, '0'); + const formattedMinutes = minutes.toString().padStart(2, '0'); + + return `${formattedHours}:${formattedMinutes} ${period}`; +} + +/** + * Checks if a time string falls within a time range + */ +function isTimeInRange(time: string, range: TimeRange): boolean { + const timeInMinutes = timeToMinutes(time); + const startInMinutes = timeToMinutes(range.start); + const endInMinutes = timeToMinutes(range.end); + + // Handle cases where the end time is the next day (e.g., 11:00 PM to 2:00 AM) + if (endInMinutes < startInMinutes) { + return timeInMinutes >= startInMinutes || timeInMinutes <= endInMinutes; + } + + return timeInMinutes >= startInMinutes && timeInMinutes <= endInMinutes; +} + +/** + * Converts a time string (e.g., "09:00 AM") to minutes since midnight + */ +function timeToMinutes(timeString: string): number { + const [time, period] = timeString.split(' '); + const [hours, minutes] = time.split(':').map(Number); + + let totalMinutes = hours * 60 + minutes; + + if (period === 'PM' && hours !== 12) { + totalMinutes += 12 * 60; + } else if (period === 'AM' && hours === 12) { + totalMinutes = minutes; // 12:XX AM should be XX minutes past midnight + } + + return totalMinutes; +} diff --git a/apps/worker/src/app/workflow/workflow.module.ts b/apps/worker/src/app/workflow/workflow.module.ts index 8bb81660758..cd887ca3ae7 100644 --- a/apps/worker/src/app/workflow/workflow.module.ts +++ b/apps/worker/src/app/workflow/workflow.module.ts @@ -13,6 +13,7 @@ import { GetNovuLayout, GetNovuProviderCredentials, GetPreferences, + GetSubscriberSchedule, GetSubscriberTemplatePreference, GetTopicSubscribersUseCase, NormalizeVariables, @@ -184,6 +185,7 @@ const USE_CASES = [ ExecuteBridgeJob, GetPreferences, WorkflowRunService, + GetSubscriberSchedule, ]; const PROVIDERS: Provider[] = []; diff --git a/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.repository.ts b/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.repository.ts index c756768b655..805a4147dbe 100644 --- a/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.repository.ts +++ b/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.repository.ts @@ -438,6 +438,8 @@ export function mapEventTypeToTitle(eventType: EventType): string { // Step skipped events case 'step_skipped': return 'Step skipped'; + case 'step_skipped_outside_of_the_schedule': + return "The step was skipped as it fell outside the subscriber's schedule"; default: { // Exhaustive check - this will cause a compile error if we miss any TraceEvent cases diff --git a/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.schema.ts b/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.schema.ts index 8e17ee8054a..ab0f7f5145a 100644 --- a/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.schema.ts +++ b/libs/application-generic/src/services/analytic-logs/trace-log/trace-log.schema.ts @@ -158,7 +158,8 @@ export type EventType = | 'workflow_actor_processing_failed' | 'workflow_actor_processing_completed' | 'workflow_execution_failed' - | 'step_skipped'; + | 'step_skipped' + | 'step_skipped_outside_of_the_schedule'; export type EntityType = 'request' | 'step_run'; diff --git a/libs/application-generic/src/usecases/create-execution-details/create-execution-details.usecase.ts b/libs/application-generic/src/usecases/create-execution-details/create-execution-details.usecase.ts index 03c0356e804..9623695f0d7 100644 --- a/libs/application-generic/src/usecases/create-execution-details/create-execution-details.usecase.ts +++ b/libs/application-generic/src/usecases/create-execution-details/create-execution-details.usecase.ts @@ -114,6 +114,7 @@ const mapDetailToEventType = { // Skipped step events [DetailEnum.SKIPPED_STEP_BY_CONDITIONS]: 'step_skipped', + [DetailEnum.SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE]: 'step_skipped_outside_of_the_schedule', } satisfies Record; @Injectable() diff --git a/libs/application-generic/src/usecases/create-execution-details/types/index.ts b/libs/application-generic/src/usecases/create-execution-details/types/index.ts index e30288ccf90..8c88072c3d0 100644 --- a/libs/application-generic/src/usecases/create-execution-details/types/index.ts +++ b/libs/application-generic/src/usecases/create-execution-details/types/index.ts @@ -52,6 +52,7 @@ export enum DetailEnum { PROCESSING_STEP_FILTER = 'Processing step filter', PROCESSING_STEP_FILTER_ERROR = 'Processing step filter failed', SKIPPED_STEP_BY_CONDITIONS = 'Step was skipped based on steps conditions', + SKIPPED_STEP_OUTSIDE_OF_THE_SCHEDULE = "The step was skipped as it fell outside the subscriber's schedule", DIGEST_TRIGGERED_EVENTS = 'Digest triggered events', STEP_FILTERED_BY_SUBSCRIBER_WORKFLOW_PREFERENCES = 'Step filtered by subscriber workflow preferences', STEP_FILTERED_BY_SUBSCRIBER_GLOBAL_PREFERENCES = 'Step filtered by subscriber global preferences', diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts b/libs/application-generic/src/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts similarity index 75% rename from apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts rename to libs/application-generic/src/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts index 739f94c9cfc..fb4d0bac368 100644 --- a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts +++ b/libs/application-generic/src/usecases/get-subscriber-schedule/get-subscriber-schedule.command.ts @@ -1,5 +1,5 @@ -import { EnvironmentCommand } from '@novu/application-generic'; import { IsDefined, IsString } from 'class-validator'; +import { EnvironmentCommand } from '../../commands'; export class GetSubscriberScheduleCommand extends EnvironmentCommand { // database _id diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts b/libs/application-generic/src/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts similarity index 93% rename from apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts rename to libs/application-generic/src/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts index ff2af69edcd..9fdda46ee82 100644 --- a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts +++ b/libs/application-generic/src/usecases/get-subscriber-schedule/get-subscriber-schedule.usecase.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { InstrumentUsecase } from '@novu/application-generic'; import { PreferencesRepository } from '@novu/dal'; import { PreferencesTypeEnum, Schedule } from '@novu/shared'; +import { InstrumentUsecase } from '../../instrumentation'; import { GetSubscriberScheduleCommand } from './get-subscriber-schedule.command'; @Injectable() diff --git a/apps/api/src/app/subscribers/usecases/get-subscriber-schedule/index.ts b/libs/application-generic/src/usecases/get-subscriber-schedule/index.ts similarity index 100% rename from apps/api/src/app/subscribers/usecases/get-subscriber-schedule/index.ts rename to libs/application-generic/src/usecases/get-subscriber-schedule/index.ts diff --git a/libs/application-generic/src/usecases/index.ts b/libs/application-generic/src/usecases/index.ts index 72dbce490a8..af741544fe1 100644 --- a/libs/application-generic/src/usecases/index.ts +++ b/libs/application-generic/src/usecases/index.ts @@ -19,6 +19,7 @@ export * from './get-layout'; export * from './get-novu-layout'; export * from './get-novu-provider-credentials'; export * from './get-preferences'; +export * from './get-subscriber-schedule'; export * from './get-subscriber-template-preference'; export * from './get-tenant'; export * from './get-topic-subscribers';