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
+26 -2
View File
@@ -93,11 +93,34 @@ export function buildConfirmationEmail(
export function buildReminderEmail(
to: string,
data: AppointmentEmailData,
hoursAhead: number
hoursAhead: number,
confirmationToken?: string | null
): Mail.Options {
const time = formatDateTime(data.startTime);
const groomer = data.groomerName ? ` with ${data.groomerName}` : "";
const when = hoursAhead >= 24 ? `tomorrow` : `in ${hoursAhead} hours`;
const baseUrl = process.env.APP_URL ?? "http://localhost:5173";
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = confirmationToken ? `${apiUrl}/api/book/confirm/${confirmationToken}` : null;
const cancelUrl = confirmationToken ? `${apiUrl}/api/book/cancel/${confirmationToken}` : null;
const actionText = confirmationToken
? [
``,
`Confirm your appointment: ${confirmUrl}`,
`Cancel your appointment: ${cancelUrl}`,
].join("\n")
: "";
const actionHtml = confirmationToken
? `
<div style="margin:1.5em 0">
<a href="${confirmUrl}" style="display:inline-block;padding:10px 20px;background:#10b981;color:#fff;text-decoration:none;border-radius:4px;font-weight:600;margin-right:12px">Confirm Appointment</a>
<a href="${cancelUrl}" style="display:inline-block;padding:10px 20px;background:#fff;color:#ef4444;text-decoration:none;border-radius:4px;font-weight:600;border:1px solid #ef4444">Cancel Appointment</a>
</div>`
: "";
return {
to,
subject: `Reminder: ${data.petName}'s appointment is ${when}`,
@@ -109,7 +132,7 @@ export function buildReminderEmail(
` Pet: ${data.petName}`,
` Service: ${data.serviceName}`,
` When: ${time}${groomer}`,
``,
actionText,
`See you soon!`,
``,
`— Groom Book`,
@@ -122,6 +145,7 @@ export function buildReminderEmail(
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Service</td><td>${data.serviceName}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">When</td><td>${time}${groomer}</td></tr>
</table>
${actionHtml}
<p>See you soon!</p>
<p>— Groom Book</p>`,
};