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
@@ -0,0 +1,10 @@
-- Add recurring_series table to store recurrence patterns
CREATE TABLE "recurring_series" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"frequency_weeks" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
-- Extend appointments with series tracking
ALTER TABLE "appointments" ADD COLUMN "series_id" uuid REFERENCES "recurring_series"("id") ON DELETE SET NULL;
ALTER TABLE "appointments" ADD COLUMN "series_index" integer;
@@ -22,6 +22,13 @@
"when": 1773777600000,
"tag": "0002_invoices",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1742169600000,
"tag": "0003_recurring_series",
"breakpoints": true
}
]
}
+12
View File
@@ -92,6 +92,13 @@ export const staff = pgTable("staff", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const recurringSeries = pgTable("recurring_series", {
id: uuid("id").primaryKey().defaultRandom(),
// How many weeks between each appointment in the series
frequencyWeeks: integer("frequency_weeks").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const appointments = pgTable("appointments", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
@@ -112,6 +119,11 @@ export const appointments = pgTable("appointments", {
notes: text("notes"),
// Override price at time of booking (null = use service base price)
priceCents: integer("price_cents"),
// Recurring series support
seriesId: uuid("series_id").references(() => recurringSeries.id, {
onDelete: "set null",
}),
seriesIndex: integer("series_index"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
+8
View File
@@ -54,6 +54,12 @@ export interface Staff {
updatedAt: string;
}
export interface RecurringSeries {
id: string;
frequencyWeeks: number;
createdAt: string;
}
export interface Appointment {
id: string;
clientId: string;
@@ -65,6 +71,8 @@ export interface Appointment {
endTime: string;
notes: string | null;
priceCents: number | null;
seriesId: string | null;
seriesIndex: number | null;
createdAt: string;
updatedAt: string;
}