feat: appointment confirmation and cancellation (GH #98, GRO-153)

Add customer confirmation/cancellation flow for appointments:

- DB migration (0013): add confirmation_status, confirmed_at, cancelled_at,
  confirmation_token to appointments table with index on token column
- schema.ts + factories.ts + types: expose new columns and ConfirmationStatus type
- GET /api/book/confirm/:token — tokenized confirm via email link (redirects)
- GET /api/book/cancel/:token — tokenized cancel via email link, single-use token
- POST /api/appointments/:id/confirm — portal/staff confirm endpoint
- POST /api/appointments/:id/cancel — portal/staff cancel endpoint
- Reminder emails now include Confirm/Cancel CTA buttons with tokenized links
- Reminder service generates confirmation token if missing before sending
- Staff calendar shows confirmation status indicator on appointment cards
  and in the detail modal (confirmed ✓ / customer cancelled ✗)
- /booking/confirmed, /booking/cancelled, /booking/error redirect pages
- 23 new unit tests covering all new endpoints and edge cases

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Scrubs McBarkley
2026-03-24 16:02:58 +00:00
parent 75d0e4c3e6
commit d1ab91adfa
14 changed files with 736 additions and 3 deletions
+72
View File
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import {
and,
eq,
@@ -521,3 +522,74 @@ appointmentsRouter.delete("/:id", async (c) => {
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
// ─── POST /api/appointments/:id/confirm ───────────────────────────────────────
// Staff/portal: confirm a specific appointment by ID. Idempotent.
appointmentsRouter.post("/:id/confirm", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
if (!appt) return c.json({ error: "Not found" }, 404);
if (appt.confirmationStatus === "cancelled") {
return c.json({ error: "Cannot confirm a cancelled appointment" }, 409);
}
if (appt.confirmationStatus === "confirmed") {
return c.json(appt); // idempotent
}
const [updated] = await db
.update(appointments)
.set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() })
.where(eq(appointments.id, id))
.returning();
return c.json(updated);
});
// ─── POST /api/appointments/:id/cancel ───────────────────────────────────────
// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified.
appointmentsRouter.post("/:id/cancel", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
if (!appt) return c.json({ error: "Not found" }, 404);
if (appt.confirmationStatus === "cancelled") {
return c.json({ error: "Appointment is already cancelled" }, 409);
}
const [updated] = await db
.update(appointments)
.set({
confirmationStatus: "cancelled",
cancelledAt: new Date(),
confirmationToken: null,
updatedAt: new Date(),
})
.where(eq(appointments.id, id))
.returning();
return c.json(updated);
});
// ─── Token generation helper ──────────────────────────────────────────────────
export function generateConfirmationToken(): string {
return randomBytes(32).toString("hex");
}