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 <xodeveloper@gmail.com>
This commit is contained in:
Sid
2025-08-03 01:10:23 +05:30
committed by GitHub
parent ff5dbe74fd
commit ec0be1166f
8 changed files with 147 additions and 50 deletions

View File

@@ -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");
});
}
}

View File

@@ -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<typeof RemindersSchema>;

View File

@@ -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
}
});

View File

@@ -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
);

View File

@@ -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;

View File

@@ -12,14 +12,15 @@ export const useCreateReminder = (secretId: string) => {
const queryClient = useQueryClient();
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 }>(
`/api/v1/reminders/secrets/${secretId}`,
{
message,
repeatDays,
nextReminderDate,
recipients
recipients,
fromDate
}
);
return data.reminder;

View File

@@ -2,6 +2,7 @@ export type CreateReminderDTO = {
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: Date | null;
fromDate?: Date | null;
secretId: string;
recipients?: string[];
};

View File

@@ -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<typeof ReminderFormSchema>;
// 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 ? (
<Controller
control={control}
name="repeatDays"
render={({ field, fieldState }) => (
<div>
<div className="grid grid-cols-[1fr,auto] gap-x-2">
<Controller
control={control}
name="repeatDays"
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
isRequired
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)}
errorText={fieldState.error?.message || ""}
>
<Input
onChange={(el) => {
const value = parseInt(el.target.value, 10);
setValue("repeatDays", Number.isNaN(value) ? null : value);
<DatePicker
value={field.value || undefined}
className="w-full"
onChange={field.onChange}
dateFormat="P"
popUpProps={{
open: isDatePickerOpen,
onOpenChange: setIsDatePickerOpen
}}
type="number"
placeholder={DEFAULT_REPEAT_DAYS.toString()}
value={field.value || ""}
min={MIN_REPEAT_DAYS}
max={MAX_REPEAT_DAYS}
popUpContentProps={{}}
hideTime
hidden={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }}
/>
</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"}
</div>
</div>
)}
/>
)}
/>
</div>
) : (
<Controller
control={control}
@@ -385,7 +428,7 @@ export const CreateReminderForm = ({
}}
popUpContentProps={{}}
hideTime
hidden={{ before: new Date(Date.now() + 86400000) }}
hidden={{ before: new Date(Date.now() + ONE_DAY_IN_MILLIS) }}
/>
</FormControl>
</div>