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:
@@ -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>`,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user