Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1039b96
feat(api-service): subscribers schedule api
LetItRock Sep 5, 2025
8cc0e4a
Merge branch 'next' into nv-6614-subscribers-schedule-api
LetItRock Sep 5, 2025
6b6a5ff
chore(api-service): remove unused import
LetItRock Sep 5, 2025
4faf036
chore(api-service): fix function name
LetItRock Sep 5, 2025
fc0191a
feat(js): schedule sub module
LetItRock Sep 5, 2025
d9b463c
Merge branch 'next' into nv-6614-subscribers-schedule-api
LetItRock Sep 8, 2025
592725e
chore(api-service): fixed failing unit tests
LetItRock Sep 8, 2025
f8f2a6d
Merge branch 'nv-6614-subscribers-schedule-api' into nv-6615-inbox-js…
LetItRock Sep 8, 2025
ae05b01
Merge branch 'next' into nv-6615-inbox-js-global-preference-schedule
LetItRock Sep 8, 2025
4a3cd91
feat(js): inbox subscribers schedule
LetItRock Sep 9, 2025
2ea98ee
Merge branch 'next' into nv-6615-inbox-js-global-preference-schedule
LetItRock Sep 9, 2025
fdd6624
Merge branch 'nv-6615-inbox-js-global-preference-schedule' into nv-66…
LetItRock Sep 9, 2025
50414a9
chore(root): satisfy cspell
LetItRock Sep 10, 2025
d49cb44
feat(react,js): default schedule and useSchedule hook
LetItRock Sep 10, 2025
ee09c0f
feat(dashboard): allow updating subscribers schedule
LetItRock Sep 11, 2025
1377585
Merge branch 'next' into nv-6616-inbox-schedule
LetItRock Sep 11, 2025
a19b983
Merge branch 'nv-6616-inbox-schedule' into nv-6616-react-use-schedule…
LetItRock Sep 11, 2025
90f7cc7
Merge branch 'nv-6616-react-use-schedule-and-default-schedule' into n…
LetItRock Sep 11, 2025
cf683a8
chore(dashboard): remove console.log
LetItRock Sep 11, 2025
02969f9
Merge branch 'next' into nv-6617-dashboard-subscribers-schedule
LetItRock Sep 11, 2025
c277fb9
chore(dashboard): suggestions from the pr
LetItRock Sep 11, 2025
a3e2d61
feat(api,worker): skip sending messages outside of the subscribers sc…
LetItRock Sep 11, 2025
81ea9d0
Merge branch 'next' into nv-6617-dashboard-subscribers-schedule
LetItRock Sep 11, 2025
7bbd110
Merge branch 'nv-6617-dashboard-subscribers-schedule' into nv-6618-sk…
LetItRock Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
638 changes: 638 additions & 0 deletions apps/api/src/app/events/e2e/trigger-event.e2e.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/api/src/app/inbox/usecases/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
GetSubscriberSchedule,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to the application-generic

GetSubscriberTemplatePreference,
GetWorkflowByIdsUseCase,
MessageInteractionService,
Expand All @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/inbox/usecases/session/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AnalyticsService,
CreateOrUpdateSubscriberUseCase,
FeatureFlagsService,
GetSubscriberSchedule,
PinoLogger,
SelectIntegration,
UpsertControlValuesUseCase,
Expand All @@ -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';
Expand Down
6 changes: 2 additions & 4 deletions apps/api/src/app/inbox/usecases/session/session.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
CreateOrUpdateSubscriberUseCase,
encryptApiKey,
FeatureFlagsService,
GetSubscriberSchedule,
GetSubscriberScheduleCommand,
generateTimestampHex,
LogDecorator,
PinoLogger,
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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:
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/subscribers-v2/subscribers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,9 @@ export class SubscribersController {
})
@ApiResponse(SubscriberGlobalPreferenceDto)
@SdkGroupName('Subscribers.Preferences')
@SdkMethodName('getGlobal')
@RequirePermissions(PermissionsEnum.SUBSCRIBER_READ)
@RequireAuthentication()
@SdkMethodName('globalPreference')
@ApiExcludeEndpoint()
async getGlobalPreference(
@UserSession() user: UserSessionData,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { ScheduleDto } from '@novu/api/models/components';
import { Schedule, WeeklySchedule } from '@novu/shared';
import { useCallback, 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';

type DayScheduleCopyProps = {
onScheduleUpdate: (schedule: ScheduleDto) => Promise<void>;
day: keyof WeeklySchedule;
schedule?: Schedule | undefined;
disabled?: boolean;
};

export const DayScheduleCopy = ({ day, schedule, disabled, onScheduleUpdate }: DayScheduleCopyProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [selectedDays, setSelectedDays] = useState<Array<keyof WeeklySchedule>>([day]);
const [isAllSelected, setIsAllSelected] = useState<boolean>(false);
const allWeekDaysSelected = useMemo(() => selectedDays.length === weekDays.length, [selectedDays]);
const reset = useCallback(() => {
setSelectedDays([day]);
setIsAllSelected(false);
setIsOpen(false);
}, [day]);
const onOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
reset();
} else {
setIsOpen(isOpen);
}
},
[reset]
);

return (
<Tooltip>
<TooltipTrigger disabled={disabled}>
<Popover modal open={isOpen} onOpenChange={onOpenChange}>
<PopoverTrigger disabled={disabled} className="w-full flex items-center justify-center">
<RiFileCopyLine
className={cn(
'text-foreground-alpha-600 size-3.5 group-hover:opacity-100 opacity-0 transition-opacity duration-200',
{
'group-hover:opacity-0': disabled,
}
)}
/>
</PopoverTrigger>
<PopoverContent
side="right"
sideOffset={0}
align="center"
className="rounded-md min-w-[220px] max-w-[220px] p-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<p className="text-sm text-neutral-600 mb-3 text-left">Copy times to:</p>
<span className="flex items-center gap-2 text-sm text-neutral-600 mb-2">
<Checkbox
checked={isAllSelected || allWeekDaysSelected}
onCheckedChange={(checked) => {
if (typeof checked !== 'boolean') return;
setIsAllSelected(checked);
setSelectedDays(checked ? weekDays : [day]);
}}
/>
Select all
</span>
{weekDays.map((weekDay) => (
<span key={weekDay} className="flex items-center gap-2 text-sm text-neutral-600 mb-2">
<Checkbox
checked={selectedDays.includes(weekDay) || weekDay === day}
onCheckedChange={(value) =>
setSelectedDays(value ? [...selectedDays, weekDay] : selectedDays.filter((d) => d !== weekDay))
}
disabled={weekDay === day}
/>
{capitalize(weekDay)}
</span>
))}
<div className="flex justify-end border-t border-neutral-alpha-100 pt-2">
<Button
onClick={async () => {
const currentDay = day;
const daysToCopy = selectedDays.filter((day) => day !== currentDay);
const dayToCopy = schedule?.weeklySchedule?.[currentDay];
if (dayToCopy) {
const updatedWeeklySchedule = {
...schedule?.weeklySchedule,
...daysToCopy.reduce((acc, day) => {
acc[day] = dayToCopy;
return acc;
}, {} as WeeklySchedule),
};
await onScheduleUpdate({
isEnabled: schedule?.isEnabled ?? false,
weeklySchedule: updatedWeeklySchedule,
});
}
reset();
}}
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent>Copy times to</TooltipContent>
</Tooltip>
);
};
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { GetSubscriberPreferencesDto, PatchPreferenceChannelsDto } from '@novu/api/models/components';
import { ChannelTypeEnum } from '@novu/shared';
import { GetSubscriberPreferencesDto } from '@novu/api/models/components';
import { ChannelTypeEnum, FeatureFlagsKeysEnum } 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 { 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';
import { TelemetryEvent } from '@/utils/telemetry';
import { PreferencesBlank } from './preferences-blank';
import { SubscribersSchedule } from './subscribers-schedule';

type PreferencesProps = {
subscriberPreferences: GetSubscriberPreferencesDto;
Expand All @@ -24,13 +26,19 @@ 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 isSubscribersScheduleEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED);

const { workflows, globalChannelsKeys, hasZeroPreferences } = useMemo(() => {
const global = subscriberPreferences?.global ?? { channels: {} };
const workflows = subscriberPreferences?.workflows ?? [];
Expand All @@ -41,13 +49,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 <PreferencesBlank />;
}
Expand All @@ -73,6 +74,7 @@ export const Preferences = (props: PreferencesProps) => {
</p>
</TooltipContent>
</Tooltip>
{isPending && <RiLoader4Line className="size-3 animate-spin text-neutral-400" />}
</div>

<SidebarContent size="md">
Expand All @@ -82,12 +84,20 @@ export const Preferences = (props: PreferencesProps) => {
channel={channel}
readOnly={readOnly}
enabled={enabled}
onChange={(checked: boolean) => handleChannelToggle({ [channel]: checked })}
onChange={(checked: boolean) => updateChannelPreferences({ [channel]: checked })}
/>
))}
</SidebarContent>
</motion.div>

{isSubscribersScheduleEnabled && (
<motion.div variants={itemVariants}>
<SidebarContent size="md">
<SubscribersSchedule globalPreference={subscriberPreferences.global} subscriberId={subscriberId} />
</SidebarContent>
</motion.div>
)}

<motion.div variants={itemVariants}>
<div className="flex items-center gap-2 bg-neutral-50 px-4 py-2">
<span className="text-2xs line-height uppercase text-neutral-400">Workflow Preferences</span>
Expand All @@ -102,14 +112,15 @@ export const Preferences = (props: PreferencesProps) => {
</p>
</TooltipContent>
</Tooltip>
{isPending && <RiLoader4Line className="size-3 animate-spin text-neutral-400" />}
</div>

<SidebarContent size="md">
{workflows.map((wf) => (
<WorkflowPreferences
key={wf.workflow.slug}
workflowPreferences={wf}
onToggle={handleChannelToggle}
onToggle={updateChannelPreferences}
readOnly={readOnly}
/>
))}
Expand Down
Loading
Loading