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>`,
};
+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
)
);