feat: recurring appointments with cascading change propagation (#28)

* 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/🆔 new `cascadeMode` field
  (this_only | this_and_future | all) applies time-delta shifts and
  field updates across the series
- API DELETE /appointments/🆔 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 <noreply@paperclip.ing>

* 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 <noreply@paperclip.ing>

---------

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #28.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 20:37:33 +00:00
committed by GitHub
parent e524099214
commit e7cf185d8c
6 changed files with 462 additions and 57 deletions
+236 -31
View File
@@ -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<string, unknown> = { ...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<string, unknown> = {
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<string, unknown> = {
...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 });