Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .source
5 changes: 0 additions & 5 deletions apps/api/src/app/events/e2e/trigger-event.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3998,18 +3998,13 @@ describe('Trigger event - /v1/events/trigger (POST) #novu-v2', () => {
});

describe('Subscriber Schedule Logic', () => {
const isSubscribersScheduleEnabled = (process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED;
const isContextPreferencesEnabled = (process.env as Record<string, string>).IS_CONTEXT_PREFERENCES_ENABLED;

beforeEach(async () => {
// Enable the feature flags for schedule tests
(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true';
(process.env as Record<string, string>).IS_CONTEXT_PREFERENCES_ENABLED = 'true';
});

afterEach(() => {
// Restore the original feature flag states
(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = isSubscribersScheduleEnabled;
(process.env as Record<string, string>).IS_CONTEXT_PREFERENCES_ENABLED = isContextPreferencesEnabled;
});

Expand Down
42 changes: 0 additions & 42 deletions apps/api/src/app/inbox/e2e/session.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const mockSubscriberId = '12345';
describe('Session - /inbox/session (POST) #novu-v2', async () => {
let session: UserSession;
let subscriberRepository: SubscriberRepository;
const isSubscribersScheduleEnabled = (process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED;

before(async () => {
subscriberRepository = new SubscriberRepository();
Expand All @@ -27,11 +26,6 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
});
(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true';
});

afterEach(() => {
(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = isSubscribersScheduleEnabled;
});

const initializeSession = async ({
Expand Down Expand Up @@ -1133,42 +1127,6 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => {
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

(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'false';

await setIntegrationConfig({
_environmentId: session.environment._id,
_organizationId: session.environment._organizationId,
hmac: false,
});

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

(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true';
});

it('should return context-specific schedule when multiple contexts exist', async () => {
(process.env as any).IS_CONTEXT_PREFERENCES_ENABLED = 'true';

Expand Down
6 changes: 0 additions & 6 deletions apps/api/src/app/inbox/e2e/update-preferences.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,10 @@ import { expect } from 'chai';

describe('Update global preferences - /inbox/preferences (PATCH) #novu-v2', () => {
let session: UserSession;
const isSubscribersScheduleEnabled = (process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED;

beforeEach(async () => {
session = new UserSession();
await session.initialize();
(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'true';
});

afterEach(() => {
(process.env as Record<string, string>).IS_SUBSCRIBERS_SCHEDULE_ENABLED = isSubscribersScheduleEnabled;
});

it('should throw error when made unauthorized call', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,16 @@ export class MarkNotificationAs {
snoozedUntil: command.snoozedUntil,
});

return mapToDto(
(await this.messageRepository.findOneForInbox({
_environmentId: command.environmentId,
_id: command.notificationId,
})) as MessageEntity
);
const updatedMessage = await this.messageRepository.findOneForInbox({
_environmentId: command.environmentId,
_subscriberId: subscriber._id,
_id: command.notificationId,
contextKeys: command.contextKeys,
});
if (!updatedMessage) {
throw new NotFoundException(`Notification with id: ${command.notificationId} could not be found after update.`);
}

return mapToDto(updatedMessage);
}
}
11 changes: 0 additions & 11 deletions apps/api/src/app/inbox/usecases/session/session.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,17 +300,6 @@ export class Session {
subscriber: SubscriberEntity;
contextKeys: string[];
}): Promise<Schedule | undefined> {
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,
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/app/widgets/widgets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ export class WidgetsController {
const messageIds = this.toArray(body.messageId);
if (!messageIds) throw new BadRequestException('messageId is required');

const invalidIds = messageIds.filter((id) => !BaseRepository.isInternalId(id));
if (invalidIds.length > 0) {
throw new BadRequestException(`Invalid messageId format: ${invalidIds.join(', ')}`);
}

return await this.markMessageAsUsecase.execute(
MarkMessageAsCommand.create({
organizationId: subscriberSession._organizationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export const Preferences = (props: PreferencesProps) => {
},
});

const isSubscribersScheduleEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED);
const isContextPreferencesEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED);

const { workflows, globalChannelsKeys, hasZeroPreferences } = useMemo(() => {
Expand Down Expand Up @@ -109,24 +108,20 @@ export const Preferences = (props: PreferencesProps) => {
</SidebarContent>
</motion.div>

{isSubscribersScheduleEnabled && (
<>
<motion.div variants={itemVariants}>
<SidebarContent size="md" className="pb-0">
<div className="w-full border-t border-neutral-100" />
</SidebarContent>
</motion.div>
<motion.div variants={itemVariants}>
<SidebarContent size="md">
<SubscribersSchedule
globalPreference={subscriberPreferences.global}
subscriberId={subscriberId}
contextKeys={contextKeys}
/>
</SidebarContent>
</motion.div>
</>
)}
<motion.div variants={itemVariants}>
<SidebarContent size="md" className="pb-0">
<div className="w-full border-t border-neutral-100" />
</SidebarContent>
</motion.div>
<motion.div variants={itemVariants}>
<SidebarContent size="md">
<SubscribersSchedule
globalPreference={subscriberPreferences.global}
subscriberId={subscriberId}
contextKeys={contextKeys}
/>
</SidebarContent>
</motion.div>

<motion.div variants={itemVariants}>
<div className="flex items-center gap-2 bg-neutral-50 px-4 py-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const CustomStepControls = (props: CustomStepControlsProps) => {
const [isRestoreDefaultModalOpen, setIsRestoreDefaultModalOpen] = useState(false);
const { step, workflow, update } = useWorkflow();
const { saveForm } = useSaveForm();
const { control } = useFormContext();
const { control, reset } = useFormContext();
const watchedValues = useWatch({ control });

const dataSchemaDefaults = buildDefaultValuesOfDataSchema(step?.controls.dataSchema ?? {});
Expand Down Expand Up @@ -117,6 +117,7 @@ export const CustomStepControls = (props: CustomStepControlsProps) => {
if (!workflow || !step) return;

update(updateStepInWorkflow(workflow, step.stepId, { controlValues: null }));
reset(dataSchemaDefaults, { keepErrors: true });
setIsRestoreDefaultModalOpen(false);
setIsOverridden(false);
}}
Expand Down Expand Up @@ -171,7 +172,12 @@ export const CustomStepControls = (props: CustomStepControlsProps) => {
!isOverridden && 'opacity-60 pointer-events-none'
)}
>
<JsonForm schema={(dataSchema as RJSFSchema) || {}} formData={watchedValues} disabled={!isOverridden} />
<JsonForm
key={String(isOverridden)}
schema={(dataSchema as RJSFSchema) || {}}
formData={isOverridden ? watchedValues : dataSchemaDefaults}
disabled={!isOverridden}
/>
</div>
</AccordionContent>
</AccordionItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export function TextWidget(props: WidgetProps) {
render={({ field, fieldState }) => {
let stringValue = '';

if (typeof field.value === 'string') {
if (disabled) {
stringValue = typeof rjsfValue === 'string' ? rjsfValue : '';
} else if (typeof field.value === 'string') {
stringValue = field.value;
} else if (typeof rjsfValue === 'string') {
stringValue = rjsfValue;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { FeatureFlagsKeysEnum, UiComponentEnum, UiSchemaGroupEnum } from '@novu/shared';
import { UiComponentEnum, UiSchemaGroupEnum } from '@novu/shared';
import { Separator } from '@/components/primitives/separator';
import { SidebarContent } from '@/components/side-navigation/sidebar';
import { getComponentByType } from '@/components/workflow-editor/steps/component-utils';
import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
import { useFeatureFlag } from '@/hooks/use-feature-flag';

const typeKey = 'type';
const amountKey = 'amount';
Expand All @@ -13,7 +12,6 @@ const dynamicKeyKey = 'dynamicKey';
const extendToScheduleKey = 'extendToSchedule';

export const DelayControlValues = () => {
const isSubscribersScheduleEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED);
const { workflow, step } = useWorkflow();
const { uiSchema } = step?.controls ?? {};

Expand All @@ -40,14 +38,10 @@ export const DelayControlValues = () => {
type?.component || amount?.component || unit?.component || cron?.component || dynamicKey?.component,
})}
</SidebarContent>
{isSubscribersScheduleEnabled && (
<>
<Separator />
<SidebarContent>
{getComponentByType({ component: extendToSchedule?.component ?? UiComponentEnum.EXTEND_TO_SCHEDULE })}
</SidebarContent>
</>
)}
<Separator />
<SidebarContent>
{getComponentByType({ component: extendToSchedule?.component ?? UiComponentEnum.EXTEND_TO_SCHEDULE })}
</SidebarContent>
<Separator />
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { FeatureFlagsKeysEnum, UiComponentEnum, UiSchemaGroupEnum } from '@novu/shared';
import { UiComponentEnum, UiSchemaGroupEnum } from '@novu/shared';
import { Separator } from '@/components/primitives/separator';
import { SidebarContent } from '@/components/side-navigation/sidebar';
import { getComponentByType } from '@/components/workflow-editor/steps/component-utils';
import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
import { useFeatureFlag } from '@/hooks/use-feature-flag';

const extendToScheduleKey = 'extendToSchedule';

export const DigestControlValues = () => {
const isSubscribersScheduleEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_SUBSCRIBERS_SCHEDULE_ENABLED);
const { step } = useWorkflow();
const { uiSchema } = step?.controls ?? {};

Expand Down Expand Up @@ -43,14 +41,10 @@ export const DigestControlValues = () => {
component: amount.component || unit.component || cron.component,
})}
</SidebarContent>
{isSubscribersScheduleEnabled && (
<>
<Separator />
<SidebarContent>
{getComponentByType({ component: extendToSchedule?.component ?? UiComponentEnum.EXTEND_TO_SCHEDULE })}
</SidebarContent>
</>
)}
<Separator />
<SidebarContent>
{getComponentByType({ component: extendToSchedule?.component ?? UiComponentEnum.EXTEND_TO_SCHEDULE })}
</SidebarContent>
<Separator />
</>
)}
Expand Down
10 changes: 8 additions & 2 deletions apps/dashboard/src/hooks/use-form-autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,15 @@ export function useFormAutosave<U extends Record<string, unknown>, T extends Fie
}

const values = { ...previousData, ...data };
// form.reset(values, { keepErrors: true });
lastSavedDataRef.current = serializedData;
save(values, { onSuccess: options?.onSuccess });
save(values, {
onSuccess: () => {
// Reset dirty state after successful save so that polling hooks (e.g. useStepResolverPolling)
// are not permanently blocked. keepValues: true avoids regenerating useFieldArray field IDs.
formRef.current.reset(values, { keepErrors: true, keepValues: true });
options?.onSuccess?.();
},
});
},
[formRef, savePropsRef]
);
Expand Down
11 changes: 8 additions & 3 deletions apps/dashboard/src/pages/edit-step-template-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ export function EditStepTemplateV2Page() {
// on every render where `values` has a new reference — which regenerates all
// useFieldArray field IDs and causes visible row flicker on every save round-trip.
const hasInitializedRef = useRef(false);
const prevHashRef = useRef(step?.stepResolverHash);
useEffect(() => {
if (hasInitializedRef.current || !step) return;
hasInitializedRef.current = true;
form.reset(getControlsDefaultValues(step), { keepErrors: true });
if (!step) return;
const hashChanged = step.stepResolverHash !== prevHashRef.current;
prevHashRef.current = step.stepResolverHash;
if (!hasInitializedRef.current || hashChanged) {
hasInitializedRef.current = true;
form.reset(getControlsDefaultValues(step), { keepErrors: true });
}
}, [form, step]);

const { onBlur, saveForm, saveFormDebounced } = useFormAutosave({
Expand Down
1 change: 0 additions & 1 deletion apps/worker/src/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,5 @@ CLICK_HOUSE_DATABASE=test_logs
IS_TRACE_LOGS_ENABLED=true
IS_STEP_RUN_LOGS_WRITE_ENABLED=true
IS_WORKFLOW_RUN_LOGS_WRITE_ENABLED=true
IS_SUBSCRIBERS_SCHEDULE_ENABLED=true
IS_PUSH_UNREAD_COUNT_ENABLED=true
IS_CONTEXT_PREFERENCES_ENABLED=true
Loading
Loading