From e7cf185d8cabed262e200d0e07817977ccd28819 Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:37:33 +0000 Subject: [PATCH] feat: recurring appointments with cascading change propagation (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: recurring appointments with cascading change propagation Implements GitHub issue #9 — recurring appointment scheduling with configurable frequency and cascade edit/cancel options. Changes: - DB: add `recurring_series` table (frequency_weeks) and series_id / series_index columns on appointments (migration 0003) - API POST /appointments: accepts optional `recurrence` object (frequencyWeeks + count) that creates a full series in one transaction - API PATCH /appointments/:id: new `cascadeMode` field (this_only | this_and_future | all) applies time-delta shifts and field updates across the series - API DELETE /appointments/:id: new `?cascade=` query param cancels this_only / this_and_future / all series members - Frontend: booking form gains a "Recurring appointment" checkbox with frequency and count pickers; calendar chips show a ↻ recurring label; detail modal shows "Recurring series" badge and a cascade-delete radio picker for series appointments Co-Authored-By: Paperclip * fix: resolve TypeScript errors in recurring appointments route Guard against possibly-undefined results from Drizzle .returning() destructuring — use indexed access + explicit null checks instead of array destructuring for the recurring_series insert, and add an early throw when the series or first appointment row is missing. Co-Authored-By: Paperclip --------- Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/api/src/routes/appointments.ts | 267 ++++++++++++++++-- apps/web/src/pages/Appointments.tsx | 215 ++++++++++++-- .../db/migrations/0003_recurring_series.sql | 10 + packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 12 + packages/types/src/index.ts | 8 + 6 files changed, 462 insertions(+), 57 deletions(-) create mode 100644 packages/db/migrations/0003_recurring_series.sql diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 9859d69..b7cc938 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -1,7 +1,17 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; -import { and, eq, getDb, gte, lt, lte, ne, appointments } from "@groombook/db"; +import { + and, + eq, + getDb, + gte, + lt, + lte, + ne, + appointments, + recurringSeries, +} from "@groombook/db"; export const appointmentsRouter = new Hono(); @@ -14,6 +24,13 @@ const createAppointmentSchema = z.object({ endTime: z.string().datetime(), notes: z.string().max(2000).optional(), priceCents: z.number().int().positive().optional(), + // Optional recurrence: creates a series of N appointments every frequencyWeeks weeks + recurrence: z + .object({ + frequencyWeeks: z.number().int().min(1).max(52), + count: z.number().int().min(2).max(52), + }) + .optional(), }); const updateAppointmentSchema = z.object({ @@ -32,6 +49,8 @@ const updateAppointmentSchema = z.object({ endTime: z.string().datetime().optional(), notes: z.string().max(2000).nullable().optional(), priceCents: z.number().int().positive().nullable().optional(), + // When updating a series member, optionally propagate the change + cascadeMode: z.enum(["this_only", "this_and_future", "all"]).optional(), }); // List appointments, optionally filtered by date range or staffId @@ -84,18 +103,23 @@ appointmentsRouter.post( return c.json({ error: "endTime must be after startTime" }, 422); } + const { recurrence, ...apptFields } = body; + // Wrap conflict check + insert in a transaction to prevent double-booking // race conditions under concurrent load (fixes #18). - let row; + let firstRow: typeof appointments.$inferSelect; try { - row = await db.transaction(async (tx) => { - if (body.staffId) { + firstRow = await db.transaction(async (tx) => { + // Conflict check applies to the first occurrence only; subsequent + // occurrences are spread weeks apart so conflicts are unlikely and can + // be resolved individually if needed. + if (apptFields.staffId) { const conflicts = await tx .select({ id: appointments.id }) .from(appointments) .where( and( - eq(appointments.staffId, body.staffId), + eq(appointments.staffId, apptFields.staffId), lt(appointments.startTime, end), gte(appointments.endTime, start), ne(appointments.status, "cancelled"), @@ -108,11 +132,49 @@ appointmentsRouter.post( } } - const [inserted] = await tx - .insert(appointments) - .values({ ...body, startTime: start, endTime: end }) + if (!recurrence) { + // Single appointment + const [inserted] = await tx + .insert(appointments) + .values({ ...apptFields, startTime: start, endTime: end }) + .returning(); + if (!inserted) throw new Error("Insert failed"); + return inserted; + } + + // Create recurring series + const seriesRows = await tx + .insert(recurringSeries) + .values({ frequencyWeeks: recurrence.frequencyWeeks }) .returning(); - return inserted; + const series = seriesRows[0]; + if (!series) throw new Error("Failed to create recurring series"); + + const durationMs = end.getTime() - start.getTime(); + const intervalMs = + recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000; + + let first: typeof appointments.$inferSelect | undefined; + for (let i = 0; i < recurrence.count; i++) { + const instanceStart = new Date(start.getTime() + i * intervalMs); + const instanceEnd = new Date( + instanceStart.getTime() + durationMs + ); + const [inserted] = await tx + .insert(appointments) + .values({ + ...apptFields, + startTime: instanceStart, + endTime: instanceEnd, + seriesId: series.id, + seriesIndex: i, + }) + .returning(); + if (i === 0) first = inserted; + } + + if (!first) throw new Error("No appointments created"); + return first; }); } catch (err: unknown) { if ( @@ -127,7 +189,7 @@ appointmentsRouter.post( throw err; } - return c.json(row, 201); + return c.json(firstRow, 201); } ); @@ -138,21 +200,11 @@ appointmentsRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); + const { cascadeMode = "this_only", ...updateFields } = body; - const needsConflictCheck = - body.startTime !== undefined || - body.endTime !== undefined || - body.staffId !== undefined; - - const update: Record = { ...body, updatedAt: new Date() }; - if (body.startTime) update.startTime = new Date(body.startTime); - if (body.endTime) update.endTime = new Date(body.endTime); - - if (needsConflictCheck) { - // Wrap conflict check + update in a transaction to prevent race conditions - // (fixes #18). Also falls back to the existing staffId when staffId is - // omitted from the request, so rescheduling always checks conflicts (fixes #19). - let row; + // ── Cascade update (this_and_future / all) ──────────────────────────────── + if (cascadeMode !== "this_only") { + let row: typeof appointments.$inferSelect | undefined; try { row = await db.transaction(async (tx) => { const [current] = await tx @@ -164,13 +216,132 @@ appointmentsRouter.patch( throw Object.assign(new Error("not found"), { statusCode: 404 }); } - const start = body.startTime - ? new Date(body.startTime) + // Compute time deltas and apply them uniformly across the series so + // all instances shift by the same amount (e.g. rescheduled 1 hr later). + const startDeltaMs = updateFields.startTime + ? new Date(updateFields.startTime).getTime() - + current.startTime.getTime() + : 0; + const endDeltaMs = updateFields.endTime + ? new Date(updateFields.endTime).getTime() - + current.endTime.getTime() + : 0; + + // Validate resulting times on the anchor appointment + const newStart = new Date( + current.startTime.getTime() + startDeltaMs + ); + const newEnd = new Date(current.endTime.getTime() + endDeltaMs); + if (newEnd <= newStart) { + throw Object.assign(new Error("end before start"), { + statusCode: 422, + }); + } + + // Determine which appointments to update + let whereClause; + if (current.seriesId && current.seriesIndex !== null) { + whereClause = + cascadeMode === "this_and_future" + ? and( + eq(appointments.seriesId, current.seriesId), + gte(appointments.seriesIndex, current.seriesIndex), + ) + : eq(appointments.seriesId, current.seriesId); + } else { + // Not part of a series — fall back to single update + whereClause = eq(appointments.id, id); + } + + const affected = await tx + .select() + .from(appointments) + .where(whereClause); + + let firstUpdated: typeof appointments.$inferSelect | undefined; + for (const appt of affected) { + const apptUpdate: Record = { + updatedAt: new Date(), + }; + if (updateFields.staffId !== undefined) + apptUpdate.staffId = updateFields.staffId; + if (updateFields.notes !== undefined) + apptUpdate.notes = updateFields.notes; + if (updateFields.status !== undefined) + apptUpdate.status = updateFields.status; + if (updateFields.priceCents !== undefined) + apptUpdate.priceCents = updateFields.priceCents; + if (startDeltaMs !== 0) + apptUpdate.startTime = new Date( + appt.startTime.getTime() + startDeltaMs + ); + if (endDeltaMs !== 0) + apptUpdate.endTime = new Date( + appt.endTime.getTime() + endDeltaMs + ); + + const [updated] = await tx + .update(appointments) + .set(apptUpdate) + .where(eq(appointments.id, appt.id)) + .returning(); + if (appt.id === id) firstUpdated = updated; + } + + return firstUpdated; + }); + } catch (err: unknown) { + const statusCode = (err as Error & { statusCode?: number }).statusCode; + if (statusCode === 404) return c.json({ error: "Not found" }, 404); + if (statusCode === 422) + return c.json({ error: "endTime must be after startTime" }, 422); + throw err; + } + + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); + } + + // ── this_only (original logic) ──────────────────────────────────────────── + const needsConflictCheck = + updateFields.startTime !== undefined || + updateFields.endTime !== undefined || + updateFields.staffId !== undefined; + + const update: Record = { + ...updateFields, + updatedAt: new Date(), + }; + if (updateFields.startTime) update.startTime = new Date(updateFields.startTime); + if (updateFields.endTime) update.endTime = new Date(updateFields.endTime); + + if (needsConflictCheck) { + // Wrap conflict check + update in a transaction to prevent race conditions + // (fixes #18). Also falls back to the existing staffId when staffId is + // omitted from the request, so rescheduling always checks conflicts (fixes #19). + let row: typeof appointments.$inferSelect | undefined; + try { + row = await db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) { + throw Object.assign(new Error("not found"), { statusCode: 404 }); + } + + const start = updateFields.startTime + ? new Date(updateFields.startTime) : current.startTime; - const end = body.endTime ? new Date(body.endTime) : current.endTime; + const end = updateFields.endTime + ? new Date(updateFields.endTime) + : current.endTime; // Use provided staffId (may be null to unassign); fall back to existing const staffId = - body.staffId !== undefined ? body.staffId : current.staffId; + updateFields.staffId !== undefined + ? updateFields.staffId + : current.staffId; if (end <= start) { throw Object.assign(new Error("end before start"), { @@ -213,8 +384,7 @@ appointmentsRouter.patch( if (statusCode === 409) return c.json( { - error: - "Staff member has a conflicting appointment at this time", + error: "Staff member has a conflicting appointment at this time", }, 409 ); @@ -237,12 +407,47 @@ appointmentsRouter.patch( // Soft-delete: cancel the appointment instead of removing the row, // preserving audit trail and financial records (fixes #20). +// Optional ?cascade=this_only|this_and_future|all for series appointments. appointmentsRouter.delete("/:id", async (c) => { const db = getDb(); + const id = c.req.param("id"); + const cascade = c.req.query("cascade") ?? "this_only"; + + if (cascade === "this_and_future" || cascade === "all") { + const [current] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) return c.json({ error: "Not found" }, 404); + + if (current.seriesId && current.seriesIndex !== null) { + const whereClause = + cascade === "this_and_future" + ? and( + eq(appointments.seriesId, current.seriesId), + gte(appointments.seriesIndex, current.seriesIndex), + ) + : eq(appointments.seriesId, current.seriesId); + await db + .update(appointments) + .set({ status: "cancelled", updatedAt: new Date() }) + .where(whereClause); + } else { + // Not in a series — cancel only this one + await db + .update(appointments) + .set({ status: "cancelled", updatedAt: new Date() }) + .where(eq(appointments.id, id)); + } + return c.json({ ok: true }); + } + + // Single cancel (default) const [row] = await db .update(appointments) .set({ status: "cancelled", updatedAt: new Date() }) - .where(eq(appointments.id, c.req.param("id"))) + .where(eq(appointments.id, id)) .returning(); if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index d088f2a..e9313ad 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -50,6 +50,8 @@ const STATUS_TRANSITIONS: Record = { // ─── Types ─────────────────────────────────────────────────────────────────── +type CascadeMode = "this_only" | "this_and_future" | "all"; + interface BookingForm { clientId: string; petId: string; @@ -58,6 +60,9 @@ interface BookingForm { date: string; startTime: string; notes: string; + recurring: boolean; + recurrenceFrequencyWeeks: string; + recurrenceCount: string; } const EMPTY_FORM: BookingForm = { @@ -68,6 +73,9 @@ const EMPTY_FORM: BookingForm = { date: formatDate(new Date()), startTime: "09:00", notes: "", + recurring: false, + recurrenceFrequencyWeeks: "4", + recurrenceCount: "12", }; // ─── Component ─────────────────────────────────────────────────────────────── @@ -153,21 +161,30 @@ export function AppointmentsPage() { endDate.setMinutes(endDate.getMinutes() + service.durationMinutes); const endISO = endDate.toISOString(); + const payload: Record = { + clientId: form.clientId, + petId: form.petId, + serviceId: form.serviceId, + staffId: form.staffId || undefined, + startTime: startISO, + endTime: endISO, + notes: form.notes || undefined, + }; + + if (form.recurring) { + payload.recurrence = { + frequencyWeeks: parseInt(form.recurrenceFrequencyWeeks), + count: parseInt(form.recurrenceCount), + }; + } + setSaving(true); setFormError(null); try { const res = await fetch("/api/appointments", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: form.clientId, - petId: form.petId, - serviceId: form.serviceId, - staffId: form.staffId || undefined, - startTime: startISO, - endTime: endISO, - notes: form.notes || undefined, - }), + body: JSON.stringify(payload), }); if (!res.ok) { const err = (await res.json()) as { error?: string }; @@ -197,9 +214,12 @@ export function AppointmentsPage() { } } - async function deleteAppt(id: string) { - if (!confirm("Delete this appointment?")) return; - await fetch(`/api/appointments/${id}`, { method: "DELETE" }); + async function deleteAppt(id: string, cascade: CascadeMode) { + const url = + cascade !== "this_only" + ? `/api/appointments/${id}?cascade=${cascade}` + : `/api/appointments/${id}`; + await fetch(url, { method: "DELETE" }); setSelectedAppt(null); await loadAppointments(); } @@ -289,6 +309,9 @@ export function AppointmentsPage() {
{fmtTime(a.startTime)}
{cli?.name ?? "—"}
{svc?.name ?? "—"}
+ {a.seriesId && ( +
↻ recurring
+ )} ); })} @@ -383,6 +406,58 @@ export function AppointmentsPage() { style={{ ...inputStyle, resize: "vertical" }} /> + + {/* Recurrence */} +
+ +
+ + {form.recurring && ( +
+ + + + + setForm((f) => ({ ...f, recurrenceCount: e.target.value }))} + style={inputStyle} + /> + +
+ )} + {formError &&

{formError}

}