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
+11
View File
@@ -431,6 +431,12 @@ export function AppointmentsPage() {
{a.seriesId && (
<div style={{ opacity: 0.85, fontSize: 10 }}> recurring</div>
)}
{a.confirmationStatus === "confirmed" && (
<div style={{ opacity: 0.95, fontSize: 10 }}> confirmed</div>
)}
{a.confirmationStatus === "cancelled" && (
<div style={{ opacity: 0.95, fontSize: 10, textDecoration: "line-through" }}> cust. cancelled</div>
)}
</div>
);
})}
@@ -695,6 +701,11 @@ function AppointmentDetail({
["Start", new Date(appt.startTime).toLocaleString()],
["End", new Date(appt.endTime).toLocaleString()],
["Status", appt.status.replace("_", " ")],
["Confirmation", appt.confirmationStatus === "confirmed"
? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}`
: appt.confirmationStatus === "cancelled"
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
: "Pending"],
["Notes", appt.notes ?? "—"],
...(appt.seriesId
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]