diff --git a/apps/webapp/app/components/asset-reminder/actions-dropdown.tsx b/apps/webapp/app/components/asset-reminder/actions-dropdown.tsx index 13fc54d819..20f9bb2da9 100644 --- a/apps/webapp/app/components/asset-reminder/actions-dropdown.tsx +++ b/apps/webapp/app/components/asset-reminder/actions-dropdown.tsx @@ -10,6 +10,10 @@ import { DropdownMenuTrigger, } from "~/components/shared/dropdown"; import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; +import { + isRecurringReminder, + repeatValueFromRecurrence, +} from "~/modules/asset-reminder/recurrence"; import DeleteReminder from "./delete-reminder"; import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog"; import When from "../when/when"; @@ -25,7 +29,13 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const now = new Date(); - const isPending = now < new Date(reminder.alertDateTime); + /** + * One-shot reminders are immutable once sent. Recurring reminders stay + * editable even when the stored date briefly sits in the past (fire-to- + * fetch window) or the series ended — editing re-arms the series. + */ + const isPending = + now < new Date(reminder.alertDateTime) || isRecurringReminder(reminder); return ( tm.id), + repeat: repeatValueFromRecurrence(reminder), + endsAt: reminder.recurrenceEndsAt, + recurrenceTimezone: reminder.recurrenceTimezone, }} open={isEditDialogOpen} onClose={() => { diff --git a/apps/webapp/app/components/asset-reminder/reminder-recurrence-fields.tsx b/apps/webapp/app/components/asset-reminder/reminder-recurrence-fields.tsx new file mode 100644 index 0000000000..74ea170a38 --- /dev/null +++ b/apps/webapp/app/components/asset-reminder/reminder-recurrence-fields.tsx @@ -0,0 +1,172 @@ +/** + * Recurrence controls for the set/edit reminder dialog: the Repeat select + * (locked with an upgrade nudge when the workspace tier lacks recurrence) + * and the optional "Ends on" date input. + * + * Extracted from SetOrEditReminderDialog to keep the dialog component lean; + * all form state (zorm field names, error fallbacks) is threaded in as props. + * + * @see {@link file://./set-or-edit-reminder-dialog.tsx} + */ +import Input from "~/components/forms/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/forms/select"; +import { Button } from "~/components/shared/button"; +import { + REMINDER_REPEAT_PRESETS, + type ReminderRepeatValue, +} from "~/modules/asset-reminder/recurrence"; + +/** Props for {@link ReminderRecurrenceFields}. */ +type ReminderRecurrenceFieldsProps = { + /** Whether the workspace tier includes recurring reminders. */ + canUseRecurringReminders: boolean; + /** Form-submission disabled state (useDisabled). */ + disabled: boolean; + /** Controlled Repeat selection. */ + repeat: ReminderRepeatValue; + onRepeatChange: (value: ReminderRepeatValue) => void; + /** The stored cadence, round-tripped via hidden inputs when locked. */ + initialRepeat: ReminderRepeatValue; + /** Stored end date (yyyy-MM-dd in the series' own timezone), if any. */ + endsAtDefault?: string; + /** zorm field names. */ + repeatFieldName: string; + endsAtFieldName: string; + /** Combined client/server error message for the endsAt field, if any. */ + endsAtError?: string; +}; + +/** The shared option list (Never + the repeating presets) for both select states. */ +function RepeatOptions() { + return ( + + Never + {Object.entries(REMINDER_REPEAT_PRESETS).map(([value, preset]) => ( + + {preset.label} + + ))} + + ); +} + +/** + * Renders the Repeat select and the optional "Ends on" date input for the + * set/edit reminder dialog. When the workspace tier lacks recurring + * reminders, the controls render locked with an upgrade nudge and the + * stored cadence round-trips via hidden inputs. + * + * @param props - See {@link ReminderRecurrenceFieldsProps}. + * @returns The recurrence form-fields fragment. + */ +export default function ReminderRecurrenceFields({ + canUseRecurringReminders, + disabled, + repeat, + onRepeatChange, + initialRepeat, + endsAtDefault, + repeatFieldName, + endsAtFieldName, + endsAtError, +}: ReminderRecurrenceFieldsProps) { + return ( + <> +
+ + {canUseRecurringReminders ? ( + + ) : ( + <> + {/* why hidden inputs: a disabled control submits nothing — + carry the STORED cadence through so plain edits by + downgraded workspaces neither throw nor strip it */} + + {endsAtDefault ? ( + + ) : null} + + + )} + {canUseRecurringReminders ? ( +

+ Automatically send this reminder again on a schedule. +

+ ) : ( + <> +

+ Recurring reminders are a premium feature.{" "} + {" "} + to send reminders on a schedule. +

+ {/* why: the Ends-on input doesn't render in the locked state, + but its stored value still round-trips through hidden inputs + and can fail validation (e.g. moving the reminder date past + the series' end date) — surface that error here or the + submit blocks with no visible cause. role="alert" because + there is no associated rendered input for screen readers. */} + {endsAtError ? ( +

+ {endsAtError} (this reminder's end date is fixed on your current + plan) +

+ ) : null} + + )} +
+ + {canUseRecurringReminders && repeat !== "never" ? ( +
+ +

+ Leave empty to repeat until the reminder is deleted. +

+
+ ) : null} + + ); +} diff --git a/apps/webapp/app/components/asset-reminder/reminders-table.tsx b/apps/webapp/app/components/asset-reminder/reminders-table.tsx index b1547b54d2..43f1a60773 100644 --- a/apps/webapp/app/components/asset-reminder/reminders-table.tsx +++ b/apps/webapp/app/components/asset-reminder/reminders-table.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; import type { Prisma } from "@prisma/client"; +import { RepeatIcon } from "lucide-react"; import { useParams } from "react-router"; import colors from "tailwindcss/colors"; import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields"; +import { describeRecurrence } from "~/modules/asset-reminder/recurrence"; import { List } from "../list"; import ReminderTeamMembers from "./reminder-team-members"; import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog"; @@ -110,8 +112,15 @@ function ListContent({ extraProps: { isAssetReminderPage: boolean }; }) { const now = new Date(); + /** + * For an ACTIVE recurring reminder, alertDateTime is always the next + * occurrence (the worker advances it in place), so "Pending" stays + * correct; once a series ends the date stays in the past and the badge + * flips to "Reminder sent" — same rule as one-shots. + */ const status = now < new Date(item.alertDateTime) ? "Pending" : "Reminder sent"; + const recurrenceLabel = describeRecurrence(item); return ( <> @@ -131,6 +140,12 @@ function ListContent({ + {recurrenceLabel ? ( + + + ) : null} = {}) { + return { + name: "Service the generator", + message: "Change oil and filters", + alertDateTime: FUTURE, + teamMembers: ["tm-1"], + ...overrides, + }; +} + +describe("setReminderSchema", () => { + it("defaults repeat to never when the field is absent (disabled/locked select)", () => { + const parsed = setReminderSchema.parse(basePayload()); + expect(parsed.repeat).toBe("never"); + expect(parsed.endsAt).toBeUndefined(); + }); + + it('treats an empty "Ends on" input ("") as no end date', () => { + const parsed = setReminderSchema.parse( + basePayload({ repeat: "monthly", endsAt: "" }) + ); + expect(parsed.repeat).toBe("monthly"); + expect(parsed.endsAt).toBeUndefined(); + }); + + it("accepts a recurring payload with an end date after the reminder date", () => { + const parsed = setReminderSchema.parse( + basePayload({ repeat: "quarterly", endsAt: "2099-12-31" }) + ); + expect(parsed.endsAt).toBeInstanceOf(Date); + }); + + it("accepts a SAME-DAY end date (interpreted as end-of-day server-side)", () => { + expect(() => + setReminderSchema.parse( + basePayload({ repeat: "weekly", endsAt: "2099-07-15" }) + ) + ).not.toThrow(); + }); + + it("rejects an end date on an earlier CALENDAR DAY than the reminder (client)", () => { + const result = setReminderSchema.safeParse( + basePayload({ repeat: "monthly", endsAt: "2099-07-01" }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(["endsAt"]); + } + }); + + it("accepts a same-day end date with a LATE-evening reminder time (calendar compare, not instant compare)", () => { + // The old instant-based check (endsAt UTC-midnight + 24h vs local + // datetime) rejected this shape for users west of UTC. + expect(() => + setReminderSchema.parse( + basePayload({ + repeat: "weekly", + alertDateTime: "2099-07-15T23:30", + endsAt: "2099-07-15", + }) + ) + ).not.toThrow(); + }); + + it("rejects a past alertDateTime at PARSE time on the CLIENT schema", () => { + const result = setReminderSchema.safeParse( + basePayload({ alertDateTime: "2020-01-01T09:00" }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(["alertDateTime"]); + } + }); + + it("runs NO date refinements on the SERVER schemas (zone-resolved checks happen in resolveReminderPayloadDates)", () => { + // Server-side, z.coerce.date() reads raw strings in the process zone, so + // both the future check and the endsAt ordering check would misfire for + // users in other zones. Both run on the resolved instants instead. + expect( + setReminderServerSchema.safeParse( + basePayload({ alertDateTime: "2020-01-01T09:00" }) + ).success + ).toBe(true); + expect( + setReminderServerSchema.safeParse( + basePayload({ repeat: "monthly", endsAt: "2099-07-01" }) + ).success + ).toBe(true); + }); + + it("ignores endsAt ordering when repeat is never", () => { + expect(() => + setReminderSchema.parse(basePayload({ endsAt: "2000-01-01" })) + ).not.toThrow(); + }); +}); + +describe("editReminderServerSchema", () => { + it("requires the reminder id", () => { + const parsed = editReminderServerSchema.parse( + basePayload({ id: "reminder-1", repeat: "yearly" }) + ); + expect(parsed.id).toBe("reminder-1"); + expect(parsed.repeat).toBe("yearly"); + + expect( + editReminderServerSchema.safeParse(basePayload({ repeat: "yearly" })) + .success + ).toBe(false); + }); +}); diff --git a/apps/webapp/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx b/apps/webapp/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx index df1ada421d..3bbc1431f1 100644 --- a/apps/webapp/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx +++ b/apps/webapp/app/components/asset-reminder/set-or-edit-reminder-dialog.tsx @@ -1,5 +1,6 @@ -import { useEffect } from "react"; -import { Form, useNavigation, useLocation, useActionData } from "react-router"; +import { useEffect, useState } from "react"; +import { DateTime } from "luxon"; +import { Form, useLoaderData, useLocation, useActionData } from "react-router"; import { useZorm } from "react-zorm"; import { z } from "zod"; import Input from "~/components/forms/input"; @@ -7,43 +8,144 @@ import { Button } from "~/components/shared/button"; import { Separator } from "~/components/shared/separator"; import { useSearchParams } from "~/hooks/search-params"; import { useAutoFocus } from "~/hooks/use-auto-focus"; +import { useDisabled } from "~/hooks/use-disabled"; +import { + REMINDER_REPEAT_VALUES, + resolveRecurrenceZone, + type ReminderRepeatValue, +} from "~/modules/asset-reminder/recurrence"; import { dateForDateTimeInputValue } from "~/utils/date-fns"; -import { isFormProcessing } from "~/utils/form"; import { getValidationErrors } from "~/utils/http"; import type { DataOrErrorResponse } from "~/utils/http.server"; +import ReminderRecurrenceFields from "./reminder-recurrence-fields"; import TeamMembersSelector from "./team-members-selector"; import { Dialog, DialogPortal } from "../layout/dialog"; -export const setReminderSchema = z.object({ +const baseReminderSchema = z.object({ name: z.string().min(1, "Please enter name."), message: z.string().min(1, "Please enter message."), - alertDateTime: z.coerce - .date() - .min(new Date(), "Please select a date in the future"), + alertDateTime: z.coerce.date(), teamMembers: z .array(z.string()) .min(1, "Please select at least one team member"), + repeat: z.enum(REMINDER_REPEAT_VALUES).default("never"), + // why preprocess: an empty optional date input submits "" through the + // multipart form, and z.coerce.date() would turn "" into Invalid Date + endsAt: z.preprocess( + (value) => (value === "" || value == null ? undefined : value), + z.coerce.date().optional() + ), redirectTo: z.string().optional(), }); +/** + * CLIENT-ONLY endsAt ordering check, comparing CALENDAR DAYS rather than + * instants. The two inputs coerce differently: a type="date" string parses + * at UTC midnight while a datetime-local string parses in the browser's + * local zone — comparing the raw instants wrongly rejected valid same-day + * evening reminders for users west of UTC. The endsAt calendar day is the + * UTC date of the coerced value; the reminder's calendar day is its LOCAL + * date. The authoritative server check runs on the zone-resolved values in + * resolveReminderPayloadDates. + */ +function clientEndsAtOrderingRefinement( + data: z.infer, + ctx: z.RefinementCtx +) { + if (data.repeat === "never" || !data.endsAt) return; + + const endsDay = data.endsAt.toISOString().slice(0, 10); + const alert = data.alertDateTime; + const alertDay = `${alert.getFullYear()}-${String( + alert.getMonth() + 1 + ).padStart(2, "0")}-${String(alert.getDate()).padStart(2, "0")}`; + + if (endsDay < alertDay) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endsAt"], + message: "End date must be on or after the reminder date", + }); + } +} + +/** + * CLIENT-ONLY future check. In the browser, z.coerce.date() parses the + * datetime-local string in the user's own zone, so comparing against + * Date.now() is correct. On the SERVER the same coercion runs in the + * process zone (UTC in prod) and would wrongly reject valid future times + * for users west of UTC — the authoritative server check happens in + * resolveReminderPayloadDates against the client-hint-resolved instant. + * (Evaluated at parse time; the previous `.min(new Date())` snapshotted + * boot time.) + */ +function clientFutureRefinement( + data: z.infer, + ctx: z.RefinementCtx +) { + if (data.alertDateTime.getTime() <= Date.now()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["alertDateTime"], + message: "Please select a date in the future", + }); + } +} + +/** Client-side schema (zorm): calendar-day ordering + zone-correct future check. */ +export const setReminderSchema = baseReminderSchema + .superRefine(clientEndsAtOrderingRefinement) + .superRefine(clientFutureRefinement); + +/** + * Server-side parse schemas: NO date refinements — server-side coercion runs + * in the process zone, not the user's, so both the future check and the + * endsAt ordering check are enforced by resolveReminderPayloadDates on the + * client-hint-resolved instants instead. + */ +export const setReminderServerSchema = baseReminderSchema; + +/** Edit adds the reminder id; consumed by resolveRemindersActions. */ +export const editReminderServerSchema = baseReminderSchema.extend({ + id: z.string(), +}); + type SetOrEditReminderDialogProps = { open: boolean; onClose: () => void; - reminder?: z.infer & { id: string }; + reminder?: Omit, "repeat" | "endsAt"> & { + id: string; + repeat?: ReminderRepeatValue; + endsAt?: Date | string | null; + recurrenceTimezone?: string | null; + }; action?: string; }; +/** + * Reads the tier capability exposed by every route that renders this dialog + * (asset detail, global reminders index, asset reminders tab). Fails CLOSED + * (locked UI) if a future surface forgets to expose it — the server asserts + * independently either way. + */ +function useCanUseRecurringReminders(): boolean { + const data = useLoaderData() as + | { canUseRecurringReminders?: boolean } + | undefined; + return data?.canUseRecurringReminders ?? false; +} + export default function SetOrEditReminderDialog({ open, onClose, reminder, action, }: SetOrEditReminderDialogProps) { - const navigation = useNavigation(); - const disabled = isFormProcessing(navigation.state); + const disabled = useDisabled(); const pathname = useLocation().pathname; const [searchParams, setSearchParams] = useSearchParams(); + const canUseRecurringReminders = useCanUseRecurringReminders(); const redirectTo = `${pathname}${ searchParams.size > 0 @@ -60,6 +162,27 @@ export default function SetOrEditReminderDialog({ ); const isEdit = !!reminder; + const initialRepeat: ReminderRepeatValue = reminder?.repeat ?? "never"; + const [repeat, setRepeat] = useState(initialRepeat); + + /** + * Reset the Repeat selection whenever the dialog (re)opens — useState only + * seeds once, so without this a changed-then-cancelled cadence would leak + * into the next open and could be submitted unintentionally. Uses the + * adjust-state-during-render pattern (not an effect) so the reset applies + * in the same render pass with no stale flash. + */ + // why: react-doctor flags prop-seeded useState (no-derived-useState), but + // this is the React-docs "adjust state during render" pattern — `repeat` + // is user-editable state that must RESET on open, not derived state that + // could be computed inline. Accepted residual per CLAUDE.md. + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setRepeat(initialRepeat); + } + } /** Ref for the first field so we can focus it on open without autoFocus. */ const nameInputRef = useAutoFocus({ when: open }); @@ -78,6 +201,16 @@ export default function SetOrEditReminderDialog({ [onClose, searchParams, setSearchParams] ); + // why: recurrenceEndsAt is stored as end-of-day in the reminder's own + // timezone. Render the calendar date back in THAT zone (not UTC via + // toISOString) so the date shown matches what was picked and does not drift + // +1 day per save for west-of-UTC workspaces. + const endsAtDefault = reminder?.endsAt + ? DateTime.fromJSDate(new Date(reminder.endsAt)) + .setZone(resolveRecurrenceZone(reminder.recurrenceTimezone ?? null)) + .toFormat("yyyy-MM-dd") + : undefined; + return ( -
+
+ +
diff --git a/apps/webapp/app/components/assets/asset-reminder-cards.tsx b/apps/webapp/app/components/assets/asset-reminder-cards.tsx index cd27b80e45..d0b042c079 100644 --- a/apps/webapp/app/components/assets/asset-reminder-cards.tsx +++ b/apps/webapp/app/components/assets/asset-reminder-cards.tsx @@ -1,5 +1,7 @@ import type { CSSProperties } from "react"; +import { RepeatIcon } from "lucide-react"; import { useLoaderData } from "react-router"; +import { describeRecurrence } from "~/modules/asset-reminder/recurrence"; import { type loader } from "~/routes/_layout+/assets.$assetId.overview"; import { tw } from "~/utils/tw"; import ReminderTeamMembers from "../asset-reminder/reminder-team-members"; @@ -40,6 +42,7 @@ export function AssetReminderCards({ const remainingTeamMembers = reminder.teamMembers.length - slicedTeamMembers.length; const isAlreadySent = new Date() > new Date(reminder.alertDateTime); + const recurrenceLabel = describeRecurrence(reminder); return (
@@ -53,6 +56,12 @@ export function AssetReminderCards({

+ {recurrenceLabel ? ( +

+

+ ) : null}

{reminder.message.substring(0, 1000)} diff --git a/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx b/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx index 3ff039d7ce..42765d01f6 100644 --- a/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx +++ b/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx @@ -9,6 +9,7 @@ import { PopoverPortal, PopoverContent, } from "@radix-ui/react-popover"; +import { RepeatIcon } from "lucide-react"; import { Link, useLoaderData } from "react-router"; import { EventCardContent } from "~/components/calendar/event-card"; import LineBreakText from "~/components/layout/line-break-text"; @@ -46,6 +47,7 @@ import type { ColumnLabelKey, BarcodeField, } from "~/modules/asset-index-settings/helpers"; +import { describeRecurrence } from "~/modules/asset-reminder/recurrence"; import { formatCustodyList } from "~/modules/custody/utils"; import { type AssetIndexLoaderData } from "~/routes/_layout+/assets._index"; import { formatAssetValueWithBreakdown } from "~/utils/asset-value"; @@ -771,18 +773,31 @@ function UpcomingReminderColumn({ return No upcoming reminder; } + const recurrenceLabel = describeRecurrence(upcomingReminder); + return (

{upcomingReminder.name}

{upcomingReminder.message.substring(0, 1000)}

+ {recurrenceLabel ? ( +

Repeats: {recurrenceLabel}

+ ) : null} diff --git a/apps/webapp/app/components/home/upcoming-reminders.tsx b/apps/webapp/app/components/home/upcoming-reminders.tsx index 375c4d2e32..b5ff189235 100644 --- a/apps/webapp/app/components/home/upcoming-reminders.tsx +++ b/apps/webapp/app/components/home/upcoming-reminders.tsx @@ -1,4 +1,6 @@ +import { RepeatIcon } from "lucide-react"; import { useLoaderData } from "react-router"; +import { isRecurringReminder } from "~/modules/asset-reminder/recurrence"; import type { loader } from "~/routes/_layout+/home"; import { ClickableTr } from "../dashboard/clickable-tr"; import { DashboardEmptyState } from "../dashboard/empty-state"; @@ -57,6 +59,12 @@ export default function UpcomingReminders() { options={{ month: "short", day: "numeric" }} includeTime /> + {isRecurringReminder(reminder) ? ( + + ) : null}
diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 3f79196ef3..301b80c486 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -9,6 +9,7 @@ import { ServerRouter } from "react-router"; import type { AppLoadContext, EntryContext } from "react-router"; import { registerEmailWorkers } from "./emails/email.worker.server"; import { registerAddonTrialWorkers } from "./modules/addon-trial/worker.server"; +import { reconcileRecurringReminders } from "./modules/asset-reminder/chain.server"; import { regierAssetWorkers } from "./modules/asset-reminder/worker.server"; import { registerAuditWorkers } from "./modules/audit/worker.server"; import { registerBookingWorkers } from "./modules/booking/worker.server"; @@ -81,6 +82,31 @@ schedulerService }), ]) ) + .then(() => + /** + * Boot-time safety net for recurring reminders (no cron by design): + * re-arms series whose self-rescheduling chain died. Runs after worker + * registration so a re-armed overdue job can be picked up immediately. + */ + reconcileRecurringReminders() + .then(({ scanned, rearmed }) => { + if (scanned > 0) { + console.log( + `Recurring reminders reconciled: ${rearmed}/${scanned} chains re-armed` + ); + } + }) + .catch((cause) => { + Logger.error( + new ShelfError({ + cause, + message: + "Something went wrong while reconciling recurring reminders.", + label: "Scheduler", + }) + ); + }) + ) .finally(() => { // eslint-disable-next-line no-console console.log("Scheduler and workers registration completed"); diff --git a/apps/webapp/app/modules/asset-reminder/chain.server.test.ts b/apps/webapp/app/modules/asset-reminder/chain.server.test.ts new file mode 100644 index 0000000000..3026bde55a --- /dev/null +++ b/apps/webapp/app/modules/asset-reminder/chain.server.test.ts @@ -0,0 +1,284 @@ +// @vitest-environment node +import { ReminderRecurrenceUnit } from "@prisma/client"; +import { db } from "~/database/db.server"; +import { + advanceRecurringReminder, + reconcileRecurringReminders, + RECONCILE_GRACE_MS, +} from "./chain.server"; +import { scheduleAssetReminder } from "./scheduler.server"; + +// why: testing chain logic without a real database +vitest.mock("~/database/db.server", () => ({ + db: { + assetReminder: { + updateMany: vitest.fn().mockResolvedValue({ count: 1 }), + findMany: vitest.fn().mockResolvedValue([]), + findUnique: vitest.fn().mockResolvedValue(null), + }, + organization: { + findUnique: vitest.fn().mockResolvedValue({ userId: "owner-1" }), + }, + }, +})); + +// why: preventing real pg-boss scheduling; recurringReminderJobOptions stays +// real so option shapes are asserted end-to-end +vitest.mock("./scheduler.server", async (importOriginal) => { + const original = (await importOriginal()) as object; + return { + ...original, + scheduleAssetReminder: vitest.fn().mockResolvedValue(undefined), + }; +}); + +// why: tier resolution hits the database; the capability flag is the input +// under test +vitest.mock("../tier/service.server", () => ({ + getUserTierLimit: vitest.fn().mockResolvedValue({ + canUseRecurringReminders: true, + }), +})); + +// why: subscription helpers read premium config from env at import time +vitest.mock("~/utils/subscription.server", () => ({ + canUseRecurringReminders: vitest.fn( + (tierLimit: { canUseRecurringReminders: boolean } | null) => + tierLimit?.canUseRecurringReminders ?? false + ), +})); + +// why: spying on logging without side effects +vitest.mock("~/utils/logger", () => ({ + Logger: { warn: vitest.fn(), info: vitest.fn(), error: vitest.fn() }, +})); + +const { getUserTierLimit } = await import("../tier/service.server"); + +const NOW = new Date("2026-07-02T12:00:00.000Z"); + +function buildReminder(overrides: Record = {}) { + return { + id: "reminder-1", + organizationId: "org-1", + alertDateTime: new Date("2026-07-02T11:59:00.000Z"), + recurrenceUnit: ReminderRecurrenceUnit.MONTH, + recurrenceInterval: 1, + recurrenceTimezone: "UTC", + recurrenceEndsAt: null, + ...overrides, + }; +} + +beforeEach(() => { + vitest.clearAllMocks(); + (db.assetReminder.updateMany as any).mockResolvedValue({ count: 1 }); + (db.assetReminder.findUnique as any).mockResolvedValue(null); + (db.organization.findUnique as any).mockResolvedValue({ userId: "owner-1" }); + (getUserTierLimit as any).mockResolvedValue({ + canUseRecurringReminders: true, + }); +}); + +describe("advanceRecurringReminder", () => { + it("is a no-op for one-shot reminders", async () => { + const result = await advanceRecurringReminder({ + reminder: buildReminder({ + recurrenceUnit: null, + recurrenceInterval: null, + }) as any, + now: NOW, + }); + + expect(result).toEqual({ + next: null, + advanced: false, + paused: false, + ended: false, + }); + expect(db.assetReminder.updateMany).not.toHaveBeenCalled(); + expect(scheduleAssetReminder).not.toHaveBeenCalled(); + }); + + it("advances the row via CAS and schedules the next job with retry + singleton options", async () => { + const result = await advanceRecurringReminder({ + reminder: buildReminder() as any, + now: NOW, + }); + + const expectedNext = new Date("2026-08-02T11:59:00.000Z"); + expect(result.advanced).toBe(true); + expect(result.next?.toISOString()).toBe(expectedNext.toISOString()); + + // CAS: the update is guarded on the OLD alertDateTime + expect(db.assetReminder.updateMany).toHaveBeenCalledWith({ + where: { + id: "reminder-1", + organizationId: "org-1", + alertDateTime: new Date("2026-07-02T11:59:00.000Z"), + }, + data: { alertDateTime: expectedNext }, + }); + + expect(scheduleAssetReminder).toHaveBeenCalledWith({ + data: { reminderId: "reminder-1", eventType: "REMINDER" }, + when: expectedNext, + options: { + retryLimit: 3, + retryBackoff: true, + singletonKey: `asset-reminder-reminder-1-${expectedNext.toISOString()}`, + }, + }); + }); + + it("stops without scheduling when a concurrent actor wins the CAS", async () => { + (db.assetReminder.updateMany as any).mockResolvedValue({ count: 0 }); + + const result = await advanceRecurringReminder({ + reminder: buildReminder() as any, + now: NOW, + }); + + expect(result.advanced).toBe(false); + expect(scheduleAssetReminder).not.toHaveBeenCalled(); + }); + + it("ends the series when the next occurrence would exceed endsAt", async () => { + const result = await advanceRecurringReminder({ + reminder: buildReminder({ + recurrenceEndsAt: new Date("2026-07-15T00:00:00.000Z"), + }) as any, + now: NOW, + }); + + expect(result).toEqual({ + next: null, + advanced: false, + paused: false, + ended: true, + }); + expect(db.assetReminder.updateMany).not.toHaveBeenCalled(); + expect(scheduleAssetReminder).not.toHaveBeenCalled(); + }); + + it("pauses (no reschedule) when the org tier lost the capability", async () => { + (getUserTierLimit as any).mockResolvedValue({ + canUseRecurringReminders: false, + }); + + const result = await advanceRecurringReminder({ + reminder: buildReminder() as any, + now: NOW, + }); + + expect(result.paused).toBe(true); + expect(result.advanced).toBe(false); + expect(db.assetReminder.updateMany).not.toHaveBeenCalled(); + expect(scheduleAssetReminder).not.toHaveBeenCalled(); + }); + + it("fails OPEN when the tier lookup errors (never kills a series on a transient)", async () => { + (getUserTierLimit as any).mockRejectedValue(new Error("db down")); + + const result = await advanceRecurringReminder({ + reminder: buildReminder() as any, + now: NOW, + }); + + expect(result.advanced).toBe(true); + expect(scheduleAssetReminder).toHaveBeenCalled(); + }); + + it("propagates scheduling failures (caller must retry/reconcile)", async () => { + (scheduleAssetReminder as any).mockRejectedValue(new Error("pg-boss down")); + + await expect( + advanceRecurringReminder({ reminder: buildReminder() as any, now: NOW }) + ).rejects.toThrow("pg-boss down"); + }); +}); + +describe("reconcileRecurringReminders", () => { + it("only claims chains dead for longer than the grace window", async () => { + await reconcileRecurringReminders({ now: NOW }); + + expect(db.assetReminder.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + recurrenceUnit: { not: null }, + alertDateTime: { + lt: new Date(NOW.getTime() - RECONCILE_GRACE_MS), + }, + }), + }) + ); + }); + + it("re-arms dead chains and isolates per-row failures", async () => { + const deadRow = buildReminder({ + id: "dead-1", + alertDateTime: new Date("2026-07-02T09:00:00.000Z"), + }); + const badRow = buildReminder({ + id: "bad-1", + alertDateTime: new Date("2026-07-02T08:00:00.000Z"), + }); + (db.assetReminder.findMany as any).mockResolvedValue([badRow, deadRow]); + // why: first row's schedule blows up — the loop must continue to row 2 + (scheduleAssetReminder as any) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(undefined); + + const { scanned, rearmed } = await reconcileRecurringReminders({ + now: NOW, + }); + + expect(scanned).toBe(2); + expect(rearmed).toBe(1); + expect(scheduleAssetReminder).toHaveBeenCalledTimes(2); + }); + + it("re-arms the row's current occurrence when the CAS committed but scheduling failed", async () => { + const row = buildReminder({ + id: "orphan-1", + alertDateTime: new Date("2026-07-02T09:00:00.000Z"), + }); + (db.assetReminder.findMany as any).mockResolvedValue([row]); + // why: advance CAS commits, then the schedule for the NEXT occurrence + // throws; the refetched row now points at that future occurrence + (scheduleAssetReminder as any) + .mockRejectedValueOnce(new Error("pg-boss hiccup")) + .mockResolvedValueOnce(undefined); + (db.assetReminder.findUnique as any).mockResolvedValue({ + alertDateTime: new Date("2026-08-02T09:00:00.000Z"), + }); + + const { rearmed } = await reconcileRecurringReminders({ now: NOW }); + + expect(rearmed).toBe(1); + expect(scheduleAssetReminder).toHaveBeenCalledTimes(2); + expect((scheduleAssetReminder as any).mock.calls[1][0]).toMatchObject({ + when: new Date("2026-08-02T09:00:00.000Z"), + options: expect.objectContaining({ retryLimit: 3 }), + }); + }); + + it("skips ended-but-not-yet-expired series quietly", async () => { + // Monthly series: last occurrence fired, endsAt is still future but the + // NEXT occurrence would exceed it -> getNextOccurrence returns null + const endedRow = buildReminder({ + id: "ended-1", + alertDateTime: new Date("2026-07-01T09:00:00.000Z"), + recurrenceEndsAt: new Date("2026-07-20T00:00:00.000Z"), + }); + (db.assetReminder.findMany as any).mockResolvedValue([endedRow]); + + const { scanned, rearmed } = await reconcileRecurringReminders({ + now: NOW, + }); + + expect(scanned).toBe(1); + expect(rearmed).toBe(0); + expect(scheduleAssetReminder).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/webapp/app/modules/asset-reminder/chain.server.ts b/apps/webapp/app/modules/asset-reminder/chain.server.ts new file mode 100644 index 0000000000..e380650844 --- /dev/null +++ b/apps/webapp/app/modules/asset-reminder/chain.server.ts @@ -0,0 +1,286 @@ +/** + * Recurring-reminder chain: the advance step shared by the worker and boot + * reconciliation. + * + * Design (no cron — pg-boss runs with noScheduling: true): + * - A recurring reminder is ONE AssetReminder row whose alertDateTime is + * advanced in place to the next occurrence each time it fires + * (advance-before-notify, see worker.server.ts). + * - Concurrency safety without locks: the row update is a compare-and-swap on + * the old alertDateTime (updateMany where { id, alertDateTime }); a racing + * actor (worker vs boot reconciliation vs a second bluegreen machine) loses + * the CAS and stops. Scheduling itself is deduped by a pg-boss singletonKey + * keyed on (reminderId, occurrence), so even a double-schedule of the same + * next occurrence collapses to one job. + * - Chains can still die (handler crash after retries, jobs archived while + * workers are down for >14 days past fire time). reconcileRecurringReminders + * runs at every boot/deploy and re-arms provably-dead chains. + * + * @see {@link file://./worker.server.ts} + * @see {@link file://./recurrence.ts} + */ +import type { AssetReminder } from "@prisma/client"; +import { db } from "~/database/db.server"; +import { ShelfError } from "~/utils/error"; +import { Logger } from "~/utils/logger"; +import { canUseRecurringReminders } from "~/utils/subscription.server"; +import { getNextOccurrence, isRecurringReminder } from "./recurrence"; +import { + ASSETS_EVENT_TYPE_MAP, + recurringReminderJobOptions, + scheduleAssetReminder, +} from "./scheduler.server"; +import { getUserTierLimit } from "../tier/service.server"; + +const label = "Asset Scheduler"; + +/** + * Grace period before boot reconciliation considers a chain dead. Must be + * comfortably larger than the pg-boss poll interval (5 min) plus the retry + * backoff horizon, or reconciliation would hijack healthy in-flight fires and + * the worker's stale-job guard would then swallow the due notification. + */ +export const RECONCILE_GRACE_MS = 60 * 60 * 1000; // 1 hour + +/** + * Tolerance on the "did this occurrence actually come due" check. Covers + * clock skew between the app server (evaluating the guard) and Postgres + * (releasing the job). + */ +export const ADVANCE_CLOCK_EPSILON_MS = 60 * 1000; // 1 minute + +type AdvanceResult = { + /** The next occurrence now scheduled, when the advance happened. */ + next: Date | null; + /** False when the series ended, the CAS lost, or the tier paused it. */ + advanced: boolean; + /** True when the org's tier no longer includes recurrence (series pauses). */ + paused: boolean; + /** + * True when the series ended naturally (the next occurrence would exceed + * recurrenceEndsAt) — lets the worker call out the final fire explicitly. + */ + ended: boolean; +}; + +/** + * Checks whether the reminder's organization (owner tier) still includes + * recurring reminders. Fails OPEN on lookup errors so a transient DB issue + * never kills a paying customer's series; an explicit `false` tier flag is + * the only thing that pauses it. + */ +async function orgCanUseRecurringReminders( + organizationId: AssetReminder["organizationId"] +): Promise { + try { + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { userId: true }, + }); + if (!organization) return true; + + const tierLimit = await getUserTierLimit(organization.userId); + return canUseRecurringReminders(tierLimit); + } catch (cause) { + Logger.error( + new ShelfError({ + cause, + message: + "Failed to resolve tier for recurring reminder advance. Failing open.", + additionalData: { organizationId }, + label, + shouldBeCaptured: false, + }) + ); + return true; + } +} + +/** + * Advances a recurring reminder to its next occurrence and schedules the + * next pg-boss job. + * + * Ordering note: the CAS row-update commits BEFORE sendAfter. If scheduling + * then fails, the row points at a next occurrence with no queued job. On the + * pg-boss retry the worker sees the already-advanced (future) alertDateTime + * and re-arms that occurrence via its orphan-recovery branch; boot + * reconciliation is the terminal safety net if the retries are exhausted. + * pg-boss writes through its own pool, so sendAfter can never be part of a + * Prisma transaction. + * + * @throws When the CAS succeeded but scheduling failed — callers must treat + * this as critical (retry / reconcile), not swallow it. + */ +export async function advanceRecurringReminder({ + reminder, + now = new Date(), +}: { + reminder: Pick< + AssetReminder, + | "id" + | "alertDateTime" + | "organizationId" + | "recurrenceUnit" + | "recurrenceInterval" + | "recurrenceTimezone" + | "recurrenceEndsAt" + >; + now?: Date; +}): Promise { + if (!isRecurringReminder(reminder)) { + return { next: null, advanced: false, paused: false, ended: false }; + } + + const next = getNextOccurrence({ + base: reminder.alertDateTime, + unit: reminder.recurrenceUnit!, + interval: reminder.recurrenceInterval!, + timezone: reminder.recurrenceTimezone, + endsAt: reminder.recurrenceEndsAt, + now, + }); + + /** Series ended (next occurrence would exceed recurrenceEndsAt). */ + if (!next) { + return { next: null, advanced: false, paused: false, ended: true }; + } + + if (!(await orgCanUseRecurringReminders(reminder.organizationId))) { + return { next: null, advanced: false, paused: true, ended: false }; + } + + /** + * Compare-and-swap: only the actor that still sees the fired occurrence + * gets to advance. A concurrent worker/reconcile/edit that already moved + * alertDateTime makes this a no-op. + */ + const { count } = await db.assetReminder.updateMany({ + where: { + id: reminder.id, + organizationId: reminder.organizationId, + alertDateTime: reminder.alertDateTime, + }, + data: { alertDateTime: next }, + }); + + if (count === 0) { + return { next: null, advanced: false, paused: false, ended: false }; + } + + await scheduleAssetReminder({ + data: { + reminderId: reminder.id, + eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, + }, + when: next, + options: recurringReminderJobOptions(reminder.id, next), + }); + + return { next, advanced: true, paused: false, ended: false }; +} + +/** + * Boot-time reconciliation: re-arms recurring series whose chain died + * (worker crash after retries, jobs archived during long downtime, crash + * between sendAfter and the reference write). + * + * Runs once per boot from entry.server.tsx — Shelf deploys frequently, so + * this is the no-cron sweep. Only claims PROVABLY dead chains: the stored + * occurrence must be more than RECONCILE_GRACE_MS in the past, far beyond + * the poll + retry horizon of a healthy in-flight fire. + * + * Each row is fault-isolated: one bad row (e.g. a timezone the zone database + * dropped) must never abort resurrection for the remaining tenants. + */ +export async function reconcileRecurringReminders({ + now = new Date(), +}: { now?: Date } = {}): Promise<{ scanned: number; rearmed: number }> { + const deadBefore = new Date(now.getTime() - RECONCILE_GRACE_MS); + + const candidates = await db.assetReminder.findMany({ + where: { + recurrenceUnit: { not: null }, + alertDateTime: { lt: deadBefore }, + OR: [{ recurrenceEndsAt: null }, { recurrenceEndsAt: { gt: now } }], + }, + select: { + id: true, + alertDateTime: true, + organizationId: true, + recurrenceUnit: true, + recurrenceInterval: true, + recurrenceTimezone: true, + recurrenceEndsAt: true, + }, + }); + + let rearmed = 0; + + for (const reminder of candidates) { + try { + const { advanced } = await advanceRecurringReminder({ reminder, now }); + if (advanced) { + rearmed += 1; + Logger.info( + `Re-armed dead recurring reminder chain ${reminder.id} (org ${reminder.organizationId}).` + ); + } + // advanced === false covers: series actually ended (next beyond + // endsAt — quietly skipped every boot until endsAt passes), tier + // paused, or a concurrent actor won the CAS. All are no-ops here. + } catch (cause) { + // why: per-row isolation — reconciliation is the only recovery + // mechanism in a no-cron design, so one bad row must not abort the rest + Logger.error( + new ShelfError({ + cause, + message: "Failed to reconcile recurring reminder. Continuing.", + additionalData: { reminderId: reminder.id }, + label, + }) + ); + + /** + * The CAS may have committed before scheduling failed, leaving the row + * pointing at a future occurrence with no queued job — a state this + * sweep would no longer match. Best-effort re-arm of the row's CURRENT + * occurrence (singletonKey makes it idempotent); if this also fails, + * the worker's orphan-recovery branch or the sweep after that + * occurrence goes stale (grace period) recovers the series. + */ + try { + const current = await db.assetReminder.findUnique({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: system boot sweep, id comes from the org-scoped candidate query above + where: { id: reminder.id }, + select: { alertDateTime: true }, + }); + if (current && current.alertDateTime.getTime() > now.getTime()) { + await scheduleAssetReminder({ + data: { + reminderId: reminder.id, + eventType: ASSETS_EVENT_TYPE_MAP.REMINDER, + }, + when: current.alertDateTime, + options: recurringReminderJobOptions( + reminder.id, + current.alertDateTime + ), + }); + rearmed += 1; + } + } catch (rearmCause) { + Logger.error( + new ShelfError({ + cause: rearmCause, + message: + "Orphan re-arm after failed reconciliation also failed. The series recovers at the next sweep.", + additionalData: { reminderId: reminder.id }, + label, + }) + ); + } + } + } + + return { scanned: candidates.length, rearmed }; +} diff --git a/apps/webapp/app/modules/asset-reminder/emails.tsx b/apps/webapp/app/modules/asset-reminder/emails.tsx index ec601946e7..7ffc37b26f 100644 --- a/apps/webapp/app/modules/asset-reminder/emails.tsx +++ b/apps/webapp/app/modules/asset-reminder/emails.tsx @@ -15,6 +15,7 @@ import { LogoForEmail } from "~/emails/logo"; import { styles } from "~/emails/styles"; import { SERVER_URL } from "~/utils/env"; import { resolveUserDisplayName } from "~/utils/user"; +import { describeRecurrence, formatOccurrenceInZone } from "./recurrence"; type AssetAlertEmailProps = { user: Pick; @@ -23,8 +24,29 @@ type AssetAlertEmailProps = { workspaceName: string; isOwner?: boolean; customEmailFooter?: string | null; + /** Next occurrence of a recurring series, when one was scheduled. */ + nextOccurrence?: Date | null; }; +/** + * "Repeats every 3 months. Next reminder: 15 Oct 2026, 09:00 (Europe/Berlin)." + * Rendered in the timezone the series was configured in (the worker has no + * request context, so the recipient's own zone is unknown) — the zone label + * keeps it unambiguous for recipients elsewhere. + */ +function recurrenceLine( + reminder: AssetReminder, + nextOccurrence?: Date | null +): string | null { + const cadence = describeRecurrence(reminder); + if (!cadence || !nextOccurrence) return null; + + return `${cadence} reminder. Next reminder: ${formatOccurrenceInZone( + nextOccurrence, + reminder.recurrenceTimezone + )}.`; +} + export function assetAlertEmailText({ user, asset, @@ -32,6 +54,7 @@ export function assetAlertEmailText({ workspaceName, isOwner, customEmailFooter, + nextOccurrence, }: AssetAlertEmailProps) { const userName = resolveUserDisplayName(user); @@ -40,6 +63,8 @@ export function assetAlertEmailText({ : `This email was sent to ${user.email} because it is part of the Shelf workspace ${workspaceName}. If you think you weren't supposed to have received this email please contact the owner of the workspace.`; + const recurrence = recurrenceLine(reminder, nextOccurrence); + return `Asset reminder notice Hi ${userName}, your asset reminder date has been reached. Please @@ -51,7 +76,7 @@ ${asset.id} Reminder - ${reminder.name} ${reminder.message} - +${recurrence ? `\n${recurrence}\n` : ""} ${SERVER_URL}/assets/${asset.id} ${note} @@ -79,11 +104,14 @@ function AssetAlertEmailTemplate({ workspaceName, isOwner, customEmailFooter, + nextOccurrence, }: AssetAlertEmailProps) { const userName = resolveUserDisplayName(user); const isEmailExpired = isAssetImageExpired(asset.mainImageExpiration); + const recurrence = recurrenceLine(reminder, nextOccurrence); + return ( @@ -166,6 +194,14 @@ function AssetAlertEmailTemplate({ {reminder.message} + {recurrence ? ( + + {recurrence} + + ) : null} +