feat: Secret reminder from date filter (#4289)

* feat: add fromDate in reminders

* feat: update reminder form

* fix: lint

* chore: generate schema

* fix: reminder logic

* fix: update ui

* fix: pr change

---------

Co-authored-by: sidwebworks <[email protected]>
This commit is contained in:
Sid
2025-08-03 01:10:23 +05:30
committed by GitHub
co-authored by sidwebworks
parent ff5dbe74fd
commit ec0be1166f
8 changed files with 147 additions and 50 deletions
@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.Reminder, "fromDate")) {
await knex.schema.alterTable(TableName.Reminder, (t) => {
t.dropColumn("fromDate");
});
}
}
+2 -1
View File
@@ -14,7 +14,8 @@ export const RemindersSchema = z.object({
repeatDays: z.number().nullable().optional(), repeatDays: z.number().nullable().optional(),
nextReminderDate: z.date(), nextReminderDate: z.date(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
fromDate: z.date().nullable().optional()
}); });
export type TReminders = z.infer<typeof RemindersSchema>; export type TReminders = z.infer<typeof RemindersSchema>;
@@ -22,6 +22,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) =
message: z.string().trim().max(1024).optional(), message: z.string().trim().max(1024).optional(),
repeatDays: z.number().min(1).nullable().optional(), repeatDays: z.number().min(1).nullable().optional(),
nextReminderDate: z.string().datetime().nullable().optional(), nextReminderDate: z.string().datetime().nullable().optional(),
fromDate: z.string().datetime().nullable().optional(),
recipients: z.string().array().optional() recipients: z.string().array().optional()
}) })
.refine((data) => { .refine((data) => {
@@ -45,6 +46,7 @@ export const registerSecretReminderRouter = async (server: FastifyZodProvider) =
message: req.body.message, message: req.body.message,
repeatDays: req.body.repeatDays, repeatDays: req.body.repeatDays,
nextReminderDate: req.body.nextReminderDate, nextReminderDate: req.body.nextReminderDate,
fromDate: req.body.fromDate,
recipients: req.body.recipients recipients: req.body.recipients
} }
}); });
@@ -79,25 +79,33 @@ export const reminderServiceFactory = ({
repeatDays, repeatDays,
nextReminderDate: nextReminderDateInput, nextReminderDate: nextReminderDateInput,
recipients, recipients,
projectId projectId,
fromDate: fromDateInput
}: { }: {
secretId?: string; secretId?: string;
message?: string | null; message?: string | null;
repeatDays?: number | null; repeatDays?: number | null;
nextReminderDate?: string | null; nextReminderDate?: string | null;
recipients?: string[] | null; recipients?: string[] | null;
fromDate?: string | null;
projectId: string; projectId: string;
}) => { }) => {
if (!secretId) { if (!secretId) {
throw new BadRequestError({ message: "secretId is required" }); throw new BadRequestError({ message: "secretId is required" });
} }
let nextReminderDate; let nextReminderDate;
let fromDate;
if (nextReminderDateInput) { if (nextReminderDateInput) {
nextReminderDate = new Date(nextReminderDateInput); nextReminderDate = new Date(nextReminderDateInput);
} }
if (repeatDays && repeatDays > 0) { if (repeatDays) {
nextReminderDate = $addDays(repeatDays); if (fromDateInput) {
fromDate = new Date(fromDateInput);
nextReminderDate = fromDate;
} else {
nextReminderDate = $addDays(repeatDays);
}
} }
if (!nextReminderDate) { if (!nextReminderDate) {
@@ -112,7 +120,8 @@ export const reminderServiceFactory = ({
await reminderDAL.updateById(existingReminder.id, { await reminderDAL.updateById(existingReminder.id, {
message, message,
repeatDays, repeatDays,
nextReminderDate nextReminderDate,
fromDate
}); });
reminderId = existingReminder.id; reminderId = existingReminder.id;
} else { } else {
@@ -121,7 +130,8 @@ export const reminderServiceFactory = ({
secretId, secretId,
message, message,
repeatDays, repeatDays,
nextReminderDate nextReminderDate,
fromDate
}); });
reminderId = newReminder.id; reminderId = newReminder.id;
} }
@@ -280,14 +290,28 @@ export const reminderServiceFactory = ({
} }
const processedReminders = remindersData.map( const processedReminders = remindersData.map(
({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => { ({
secretId,
message,
repeatDays,
nextReminderDate: nextReminderDateInput,
recipients,
projectId,
fromDate: fromDateInput
}) => {
let nextReminderDate; let nextReminderDate;
let fromDate;
if (nextReminderDateInput) { if (nextReminderDateInput) {
nextReminderDate = new Date(nextReminderDateInput); nextReminderDate = new Date(nextReminderDateInput);
} }
if (repeatDays && repeatDays > 0 && !nextReminderDate) { if (repeatDays && !nextReminderDate) {
nextReminderDate = $addDays(repeatDays); if (fromDateInput) {
fromDate = new Date(fromDateInput);
nextReminderDate = fromDate;
} else {
nextReminderDate = $addDays(repeatDays);
}
} }
if (!nextReminderDate) { if (!nextReminderDate) {
@@ -302,17 +326,19 @@ export const reminderServiceFactory = ({
repeatDays, repeatDays,
nextReminderDate, nextReminderDate,
recipients: recipients ? [...new Set(recipients)] : [], recipients: recipients ? [...new Set(recipients)] : [],
projectId projectId,
fromDate
}; };
} }
); );
const newReminders = await reminderDAL.insertMany( const newReminders = await reminderDAL.insertMany(
processedReminders.map(({ secretId, message, repeatDays, nextReminderDate }) => ({ processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, fromDate }) => ({
secretId, secretId,
message, message,
repeatDays, repeatDays,
nextReminderDate nextReminderDate,
fromDate
})), })),
tx tx
); );
@@ -8,6 +8,7 @@ export type TReminder = {
message?: string | null; message?: string | null;
repeatDays?: number | null; repeatDays?: number | null;
nextReminderDate: Date; nextReminderDate: Date;
fromDate?: Date | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; };
@@ -21,6 +22,7 @@ export type TCreateReminderDTO = {
secretId?: string; secretId?: string;
message?: string | null; message?: string | null;
repeatDays?: number | null; repeatDays?: number | null;
fromDate?: string | null;
nextReminderDate?: string | null; nextReminderDate?: string | null;
recipients?: string[] | null; recipients?: string[] | null;
}; };
@@ -31,6 +33,7 @@ export type TBatchCreateReminderDTO = {
message?: string | null; message?: string | null;
repeatDays?: number | null; repeatDays?: number | null;
nextReminderDate?: string | Date | null; nextReminderDate?: string | Date | null;
fromDate?: Date | null;
recipients?: string[] | null; recipients?: string[] | null;
projectId?: string; projectId?: string;
}[]; }[];
@@ -95,6 +98,7 @@ export interface TReminderServiceFactory {
nextReminderDate?: string | null; nextReminderDate?: string | null;
recipients?: string[] | null; recipients?: string[] | null;
projectId: string; projectId: string;
fromDate?: string | null;
}) => Promise<{ }) => Promise<{
id: string; id: string;
created: boolean; created: boolean;
+3 -2
View File
@@ -12,14 +12,15 @@ export const useCreateReminder = (secretId: string) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Reminder, object, CreateReminderDTO>({ return useMutation<Reminder, object, CreateReminderDTO>({
mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => { mutationFn: async ({ message, repeatDays, nextReminderDate, recipients, fromDate }) => {
const { data } = await apiRequest.post<{ reminder: Reminder }>( const { data } = await apiRequest.post<{ reminder: Reminder }>(
`/api/v1/reminders/secrets/${secretId}`, `/api/v1/reminders/secrets/${secretId}`,
{ {
message, message,
repeatDays, repeatDays,
nextReminderDate, nextReminderDate,
recipients recipients,
fromDate
} }
); );
return data.reminder; return data.reminder;
@@ -2,6 +2,7 @@ export type CreateReminderDTO = {
message?: string | null; message?: string | null;
repeatDays?: number | null; repeatDays?: number | null;
nextReminderDate?: Date | null; nextReminderDate?: Date | null;
fromDate?: Date | null;
secretId: string; secretId: string;
recipients?: string[]; recipients?: string[];
}; };
@@ -4,6 +4,7 @@ import { faClock, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
@@ -33,6 +34,7 @@ const MIN_REPEAT_DAYS = 1;
const MAX_REPEAT_DAYS = 365; const MAX_REPEAT_DAYS = 365;
const DEFAULT_REPEAT_DAYS = 30; const DEFAULT_REPEAT_DAYS = 30;
const DEFAULT_TEXTAREA_ROWS = 8; const DEFAULT_TEXTAREA_ROWS = 8;
const ONE_DAY_IN_MILLIS = 86400000;
// Enums // Enums
enum ReminderType { enum ReminderType {
@@ -79,6 +81,11 @@ const ReminderFormSchema = z.object({
.refine((data) => data > new Date(), { message: "Reminder date must be in the future" }) .refine((data) => data > new Date(), { message: "Reminder date must be in the future" })
.nullable() .nullable()
.optional(), .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"]) reminderType: z.enum(["Recurring", "One Time"])
}); });
@@ -86,7 +93,7 @@ export type TReminderFormSchema = z.infer<typeof ReminderFormSchema>;
// Custom hook for form state management // Custom hook for form state management
const useReminderForm = (reminderData?: Reminder) => { const useReminderForm = (reminderData?: Reminder) => {
const { repeatDays, message, nextReminderDate } = reminderData || {}; const { repeatDays, message, nextReminderDate, fromDate } = reminderData || {};
const isEditMode = Boolean(reminderData); const isEditMode = Boolean(reminderData);
@@ -96,9 +103,10 @@ const useReminderForm = (reminderData?: Reminder) => {
message: message || "", message: message || "",
nextReminderDate: nextReminderDate || null, nextReminderDate: nextReminderDate || null,
reminderType: repeatDays ? ReminderType.Recurring : ReminderType.OneTime, reminderType: repeatDays ? ReminderType.Recurring : ReminderType.OneTime,
recipients: [] recipients: [],
fromDate
}), }),
[repeatDays, message, nextReminderDate] [repeatDays, message, nextReminderDate, fromDate]
); );
return { return {
@@ -153,7 +161,8 @@ export const CreateReminderForm = ({
message: reminderData?.message || "", message: reminderData?.message || "",
nextReminderDate: reminderData?.nextReminderDate || null, nextReminderDate: reminderData?.nextReminderDate || null,
reminderType: reminderData?.repeatDays ? ReminderType.Recurring : ReminderType.OneTime, reminderType: reminderData?.repeatDays ? ReminderType.Recurring : ReminderType.OneTime,
recipients: [] recipients: [],
fromDate: reminderData?.fromDate
}, },
resolver: zodResolver(ReminderFormSchema) resolver: zodResolver(ReminderFormSchema)
}); });
@@ -170,6 +179,7 @@ export const CreateReminderForm = ({
// Watch form values // Watch form values
const reminderType = watch("reminderType"); const reminderType = watch("reminderType");
const fromDate = watch("fromDate");
// Invalidate queries helper // Invalidate queries helper
const invalidateQueries = () => { const invalidateQueries = () => {
@@ -195,7 +205,8 @@ export const CreateReminderForm = ({
message: data.message, message: data.message,
recipients: data.recipients?.map((r) => r.value) || [], recipients: data.recipients?.map((r) => r.value) || [],
secretId, secretId,
nextReminderDate: data.nextReminderDate nextReminderDate: data.nextReminderDate,
fromDate: data.fromDate
}); });
invalidateQueries(); invalidateQueries();
@@ -243,6 +254,7 @@ export const CreateReminderForm = ({
if (newType === ReminderType.Recurring) { if (newType === ReminderType.Recurring) {
setValue("repeatDays", DEFAULT_REPEAT_DAYS); setValue("repeatDays", DEFAULT_REPEAT_DAYS);
setValue("nextReminderDate", null); setValue("nextReminderDate", null);
setValue("fromDate", null);
} else if (newType === ReminderType.OneTime) { } else if (newType === ReminderType.OneTime) {
const tomorrow = new Date(); const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
@@ -259,6 +271,7 @@ export const CreateReminderForm = ({
const { const {
repeatDays: repeatDaysInitial, repeatDays: repeatDaysInitial,
fromDate: fromDateInitial,
message, message,
recipients, recipients,
nextReminderDate: nextReminderDateInitial nextReminderDate: nextReminderDateInitial
@@ -266,6 +279,7 @@ export const CreateReminderForm = ({
if (repeatDaysInitial) { if (repeatDaysInitial) {
setValue("repeatDays", repeatDaysInitial); setValue("repeatDays", repeatDaysInitial);
setValue("fromDate", fromDateInitial);
setValue("reminderType", ReminderType.Recurring); setValue("reminderType", ReminderType.Recurring);
} else { } else {
setValue("reminderType", ReminderType.OneTime); setValue("reminderType", ReminderType.OneTime);
@@ -323,44 +337,73 @@ export const CreateReminderForm = ({
{/* Conditional Fields Based on Reminder Type */} {/* Conditional Fields Based on Reminder Type */}
{reminderType === ReminderType.Recurring ? ( {reminderType === ReminderType.Recurring ? (
<Controller <div className="grid grid-cols-[1fr,auto] gap-x-2">
control={control} <Controller
name="repeatDays" control={control}
render={({ field, fieldState }) => ( name="repeatDays"
<div> render={({ field, fieldState }) => (
<div>
<FormControl
isRequired
className="mb-0"
label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""}
>
<Input
onChange={(el) => {
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}
/>
</FormControl>
{/* Interval description */}
<div
className={twMerge(
"ml-1 mt-2 text-xs",
field.value ? "opacity-60" : "opacity-0"
)}
>
A reminder will be sent every{" "}
{field.value && field.value > 1 ? `${field.value} days` : "day"}
{fromDate ? ` starting from ${format(fromDate, "MM/dd/yy")}` : ""}
</div>
</div>
)}
/>
<Controller
control={control}
name="fromDate"
render={({ field, fieldState }) => (
<FormControl <FormControl
isRequired
className="mb-0" className="mb-0"
label="Reminder Interval (in days)" label="Start Date"
tooltipText="When enabled, this date will be used as the start date for the first reminder"
isError={Boolean(fieldState.error)} isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""} errorText={fieldState.error?.message || ""}
> >
<Input <DatePicker
onChange={(el) => { value={field.value || undefined}
const value = parseInt(el.target.value, 10); className="w-full"
setValue("repeatDays", Number.isNaN(value) ? null : value); onChange={field.onChange}
dateFormat="P"
popUpProps={{
open: isDatePickerOpen,
onOpenChange: setIsDatePickerOpen
}} }}
type="number" popUpContentProps={{}}
placeholder={DEFAULT_REPEAT_DAYS.toString()} hideTime
value={field.value || ""} hidden={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }}
min={MIN_REPEAT_DAYS}
max={MAX_REPEAT_DAYS}
/> />
</FormControl> </FormControl>
)}
{/* Interval description */} />
<div </div>
className={twMerge(
"ml-1 mt-2 text-xs",
field.value ? "opacity-60" : "opacity-0"
)}
>
A reminder will be sent every{" "}
{field.value && field.value > 1 ? `${field.value} days` : "day"}
</div>
</div>
)}
/>
) : ( ) : (
<Controller <Controller
control={control} control={control}
@@ -385,7 +428,7 @@ export const CreateReminderForm = ({
}} }}
popUpContentProps={{}} popUpContentProps={{}}
hideTime hideTime
hidden={{ before: new Date(Date.now() + 86400000) }} hidden={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }}
/> />
</FormControl> </FormControl>
</div> </div>