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
+15 -1
View File
@@ -1,4 +1,5 @@
import cron from "node-cron";
import { randomBytes } from "node:crypto";
import {
and,
eq,
@@ -51,6 +52,7 @@ export async function runReminderCheck(): Promise<void> {
serviceId: appointments.serviceId,
staffId: appointments.staffId,
status: appointments.status,
confirmationToken: appointments.confirmationToken,
})
.from(appointments)
.where(
@@ -109,6 +111,17 @@ export async function runReminderCheck(): Promise<void> {
if (!pet || !service) continue;
// Ensure the appointment has a confirmation token before sending the reminder.
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
let confirmationToken = appt.confirmationToken;
if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex");
await db
.update(appointments)
.set({ confirmationToken, updatedAt: new Date() })
.where(eq(appointments.id, appt.id));
}
const sent = await sendEmail(
buildReminderEmail(
client.email,
@@ -119,7 +132,8 @@ export async function runReminderCheck(): Promise<void> {
groomerName,
startTime: appt.startTime,
},
window.hours
window.hours,
confirmationToken
)
);