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
+88
View File
@@ -245,3 +245,91 @@ bookRouter.post(
return c.json({ appointment, client, pet }, 201);
}
);
// ─── GET /api/book/confirm/:token ──────────────────────────────────────────
// Public: confirm appointment via tokenized email link. Redirects to success/error page.
const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173";
bookRouter.get("/confirm/:token", async (c) => {
const token = c.req.param("token");
const db = getDb();
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Idempotent confirm: if already confirmed, redirect to success
if (appt.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`);
}
// Reject if already cancelled
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
await db
.update(appointments)
.set({
confirmationStatus: "confirmed",
confirmedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/confirmed`);
});
// ─── GET /api/book/cancel/:token ───────────────────────────────────────────
// Public: cancel appointment via tokenized email link. Redirects to success/error page.
bookRouter.get("/cancel/:token", async (c) => {
const token = c.req.param("token");
const db = getDb();
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.confirmationToken, token))
.limit(1);
if (!appt) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if already cancelled (token was nullified — this path won't normally hit,
// but guard against edge cases where token lookup still works)
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Single-use cancellation: nullify token after use
await db
.update(appointments)
.set({
confirmationStatus: "cancelled",
cancelledAt: new Date(),
confirmationToken: null,
updatedAt: new Date(),
})
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/cancelled`);
});