diff --git a/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts b/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts new file mode 100644 index 000000000..427e7fb5a --- /dev/null +++ b/backend/src/db/migrations/20250730162101_add-start-from-reminder.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Reminder, "fromDate"))) { + await knex.schema.alterTable(TableName.Reminder, (t) => { + t.timestamp("fromDate", { useTz: true }).nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Reminder, "fromDate")) { + await knex.schema.alterTable(TableName.Reminder, (t) => { + t.dropColumn("fromDate"); + }); + } +} diff --git a/backend/src/db/schemas/reminders.ts b/backend/src/db/schemas/reminders.ts index 6656ad077..f9d5d8b42 100644 --- a/backend/src/db/schemas/reminders.ts +++ b/backend/src/db/schemas/reminders.ts @@ -14,7 +14,8 @@ export const RemindersSchema = z.object({ repeatDays: z.number().nullable().optional(), nextReminderDate: z.date(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + fromDate: z.date().nullable().optional() }); export type TReminders = z.infer; diff --git a/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts index 4aa68197f..046607da1 100644 --- a/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts +++ b/backend/src/server/routes/v1/reminder-routers/secret-reminder-router.ts @@ -22,6 +22,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) = message: z.string().trim().max(1024).optional(), repeatDays: z.number().min(1).nullable().optional(), nextReminderDate: z.string().datetime().nullable().optional(), + fromDate: z.string().datetime().nullable().optional(), recipients: z.string().array().optional() }) .refine((data) => { @@ -45,6 +46,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) = message: req.body.message, repeatDays: req.body.repeatDays, nextReminderDate: req.body.nextReminderDate, + fromDate: req.body.fromDate, recipients: req.body.recipients } }); diff --git a/backend/src/services/reminder/reminder-service.ts b/backend/src/services/reminder/reminder-service.ts index a03e9cddd..3c4276b56 100644 --- a/backend/src/services/reminder/reminder-service.ts +++ b/backend/src/services/reminder/reminder-service.ts @@ -79,25 +79,33 @@ export const reminderServiceFactory = ({ repeatDays, nextReminderDate: nextReminderDateInput, recipients, - projectId + projectId, + fromDate: fromDateInput }: { secretId?: string; message?: string | null; repeatDays?: number | null; nextReminderDate?: string | null; recipients?: string[] | null; + fromDate?: string | null; projectId: string; }) => { if (!secretId) { throw new BadRequestError({ message: "secretId is required" }); } let nextReminderDate; + let fromDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); } - if (repeatDays && repeatDays > 0) { - nextReminderDate = $addDays(repeatDays); + if (repeatDays) { + if (fromDateInput) { + fromDate = new Date(fromDateInput); + nextReminderDate = fromDate; + } else { + nextReminderDate = $addDays(repeatDays); + } } if (!nextReminderDate) { @@ -112,7 +120,8 @@ export const reminderServiceFactory = ({ await reminderDAL.updateById(existingReminder.id, { message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate }); reminderId = existingReminder.id; } else { @@ -121,7 +130,8 @@ export const reminderServiceFactory = ({ secretId, message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate }); reminderId = newReminder.id; } @@ -280,14 +290,28 @@ export const reminderServiceFactory = ({ } const processedReminders = remindersData.map( - ({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => { + ({ + secretId, + message, + repeatDays, + nextReminderDate: nextReminderDateInput, + recipients, + projectId, + fromDate: fromDateInput + }) => { let nextReminderDate; + let fromDate; if (nextReminderDateInput) { nextReminderDate = new Date(nextReminderDateInput); } - if (repeatDays && repeatDays > 0 && !nextReminderDate) { - nextReminderDate = $addDays(repeatDays); + if (repeatDays && !nextReminderDate) { + if (fromDateInput) { + fromDate = new Date(fromDateInput); + nextReminderDate = fromDate; + } else { + nextReminderDate = $addDays(repeatDays); + } } if (!nextReminderDate) { @@ -302,17 +326,19 @@ export const reminderServiceFactory = ({ repeatDays, nextReminderDate, recipients: recipients ? [...new Set(recipients)] : [], - projectId + projectId, + fromDate }; } ); const newReminders = await reminderDAL.insertMany( - processedReminders.map(({ secretId, message, repeatDays, nextReminderDate }) => ({ + processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, fromDate }) => ({ secretId, message, repeatDays, - nextReminderDate + nextReminderDate, + fromDate })), tx ); diff --git a/backend/src/services/reminder/reminder-types.ts b/backend/src/services/reminder/reminder-types.ts index 1f6a53ac7..54726ab66 100644 --- a/backend/src/services/reminder/reminder-types.ts +++ b/backend/src/services/reminder/reminder-types.ts @@ -8,6 +8,7 @@ export type TReminder = { message?: string | null; repeatDays?: number | null; nextReminderDate: Date; + fromDate?: Date | null; createdAt: Date; updatedAt: Date; }; @@ -21,6 +22,7 @@ export type TCreateReminderDTO = { secretId?: string; message?: string | null; repeatDays?: number | null; + fromDate?: string | null; nextReminderDate?: string | null; recipients?: string[] | null; }; @@ -31,6 +33,7 @@ export type TBatchCreateReminderDTO = { message?: string | null; repeatDays?: number | null; nextReminderDate?: string | Date | null; + fromDate?: Date | null; recipients?: string[] | null; projectId?: string; }[]; @@ -95,6 +98,7 @@ export interface TReminderServiceFactory { nextReminderDate?: string | null; recipients?: string[] | null; projectId: string; + fromDate?: string | null; }) => Promise<{ id: string; created: boolean; diff --git a/frontend/src/hooks/api/reminders/queries.tsx b/frontend/src/hooks/api/reminders/queries.tsx index 68c00913d..557bc24ea 100644 --- a/frontend/src/hooks/api/reminders/queries.tsx +++ b/frontend/src/hooks/api/reminders/queries.tsx @@ -12,14 +12,15 @@ export const useCreateReminder = (secretId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { + mutationFn: async ({ message, repeatDays, nextReminderDate, recipients, fromDate }) => { const { data } = await apiRequest.post<{ reminder: Reminder }>( `/api/v1/reminders/secrets/${secretId}`, { message, repeatDays, nextReminderDate, - recipients + recipients, + fromDate } ); return data.reminder; diff --git a/frontend/src/hooks/api/reminders/types.ts b/frontend/src/hooks/api/reminders/types.ts index 245805aea..cbe96aeaf 100644 --- a/frontend/src/hooks/api/reminders/types.ts +++ b/frontend/src/hooks/api/reminders/types.ts @@ -2,6 +2,7 @@ export type CreateReminderDTO = { message?: string | null; repeatDays?: number | null; nextReminderDate?: Date | null; + fromDate?: Date | null; secretId: string; recipients?: string[]; }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx index 3b9db7bed..ea23e3de7 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx @@ -4,6 +4,7 @@ import { faClock, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; @@ -33,6 +34,7 @@ const MIN_REPEAT_DAYS = 1; const MAX_REPEAT_DAYS = 365; const DEFAULT_REPEAT_DAYS = 30; const DEFAULT_TEXTAREA_ROWS = 8; +const ONE_DAY_IN_MILLIS = 86400000; // Enums enum ReminderType { @@ -79,6 +81,11 @@ const ReminderFormSchema = z.object({ .refine((data) => data > new Date(), { message: "Reminder date must be in the future" }) .nullable() .optional(), + fromDate: z.coerce + .date() + .refine((data) => data > new Date(), { message: "From date must be in the future" }) + .nullable() + .optional(), reminderType: z.enum(["Recurring", "One Time"]) }); @@ -86,7 +93,7 @@ export type TReminderFormSchema = z.infer; // Custom hook for form state management const useReminderForm = (reminderData?: Reminder) => { - const { repeatDays, message, nextReminderDate } = reminderData || {}; + const { repeatDays, message, nextReminderDate, fromDate } = reminderData || {}; const isEditMode = Boolean(reminderData); @@ -96,9 +103,10 @@ const useReminderForm = (reminderData?: Reminder) => { message: message || "", nextReminderDate: nextReminderDate || null, reminderType: repeatDays ? ReminderType.Recurring : ReminderType.OneTime, - recipients: [] + recipients: [], + fromDate }), - [repeatDays, message, nextReminderDate] + [repeatDays, message, nextReminderDate, fromDate] ); return { @@ -153,7 +161,8 @@ export const CreateReminderForm = ({ message: reminderData?.message || "", nextReminderDate: reminderData?.nextReminderDate || null, reminderType: reminderData?.repeatDays ? ReminderType.Recurring : ReminderType.OneTime, - recipients: [] + recipients: [], + fromDate: reminderData?.fromDate }, resolver: zodResolver(ReminderFormSchema) }); @@ -170,6 +179,7 @@ export const CreateReminderForm = ({ // Watch form values const reminderType = watch("reminderType"); + const fromDate = watch("fromDate"); // Invalidate queries helper const invalidateQueries = () => { @@ -195,7 +205,8 @@ export const CreateReminderForm = ({ message: data.message, recipients: data.recipients?.map((r) => r.value) || [], secretId, - nextReminderDate: data.nextReminderDate + nextReminderDate: data.nextReminderDate, + fromDate: data.fromDate }); invalidateQueries(); @@ -243,6 +254,7 @@ export const CreateReminderForm = ({ if (newType === ReminderType.Recurring) { setValue("repeatDays", DEFAULT_REPEAT_DAYS); setValue("nextReminderDate", null); + setValue("fromDate", null); } else if (newType === ReminderType.OneTime) { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); @@ -259,6 +271,7 @@ export const CreateReminderForm = ({ const { repeatDays: repeatDaysInitial, + fromDate: fromDateInitial, message, recipients, nextReminderDate: nextReminderDateInitial @@ -266,6 +279,7 @@ export const CreateReminderForm = ({ if (repeatDaysInitial) { setValue("repeatDays", repeatDaysInitial); + setValue("fromDate", fromDateInitial); setValue("reminderType", ReminderType.Recurring); } else { setValue("reminderType", ReminderType.OneTime); @@ -323,44 +337,73 @@ export const CreateReminderForm = ({ {/* Conditional Fields Based on Reminder Type */} {reminderType === ReminderType.Recurring ? ( - ( -
+
+ ( +
+ + { + const value = parseInt(el.target.value, 10); + setValue("repeatDays", Number.isNaN(value) ? null : value); + }} + type="number" + placeholder={DEFAULT_REPEAT_DAYS.toString()} + value={field.value || ""} + min={MIN_REPEAT_DAYS} + max={MAX_REPEAT_DAYS} + /> + + {/* Interval description */} +
+ A reminder will be sent every{" "} + {field.value && field.value > 1 ? `${field.value} days` : "day"} + {fromDate ? ` starting from ${format(fromDate, "MM/dd/yy")}` : ""} +
+
+ )} + /> + ( - { - const value = parseInt(el.target.value, 10); - setValue("repeatDays", Number.isNaN(value) ? null : value); + - - {/* Interval description */} -
- A reminder will be sent every{" "} - {field.value && field.value > 1 ? `${field.value} days` : "day"} -
-
- )} - /> + )} + /> +
) : (