From a0f93fbb3fd475eb2568f6ad975e624a3d55bfa1 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Wed, 25 Mar 2026 01:40:56 +0000 Subject: [PATCH] feat: add waitlist cancellation hook and email notification (GRO-180) - Add buildWaitlistNotificationEmail() email template - Add notifyWaitlistForAppointment() service to find matching waitlist entries and send email notifications when appointments are cancelled - Wire up notifyWaitlist call in DELETE /api/appointments/:id handler - Fire-and-forget notification (non-blocking, logs errors) Co-Authored-By: Paperclip --- apps/api/src/index.ts | 3 +- apps/api/src/routes/appointments.ts | 25 +++++++++- apps/api/src/services/email.ts | 52 ++++++++++++++++++++ apps/api/src/services/waitlistNotify.ts | 63 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/waitlistNotify.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cddfd74..511facc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -61,9 +61,9 @@ app.get("/api/branding", async (c) => { // Portal routes — no staff auth required, uses impersonation session for client auth app.route("/api/portal", portalRouter); + // Public iCal calendar feed — token auth in URL, no auth middleware required app.route("/api/calendar", calendarRouter); - // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); @@ -116,7 +116,6 @@ api.route("/clients", clientsRouter); api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); -api.route("/portal", portalRouter); api.route("/waitlist", waitlistRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 8b82a88..d4ac28b 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -19,6 +19,7 @@ import { staff, } from "@groombook/db"; import { buildConfirmationEmail, sendEmail } from "../services/email.js"; +import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; export const appointmentsRouter = new Hono(); @@ -483,7 +484,7 @@ appointmentsRouter.delete("/:id", async (c) => { const id = c.req.param("id"); const cascade = c.req.query("cascade") ?? "this_only"; - if (cascade === "this_and_future" || cascade === "all") { + if (cascade === "this_and_future" || cascade === "all") { const [current] = await db .select() .from(appointments) @@ -510,16 +511,38 @@ appointmentsRouter.delete("/:id", async (c) => { .set({ status: "cancelled", updatedAt: new Date() }) .where(eq(appointments.id, id)); } + + const apptDate = current.startTime.toISOString().slice(0, 10); + const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); + notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => { + console.error("[appointments] Failed to notify waitlist:", err); + }); + return c.json({ ok: true }); } // Single cancel (default) + const [current] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + if (!current) return c.json({ error: "Not found" }, 404); + + const apptDate = current.startTime.toISOString().slice(0, 10); + const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); + const [row] = await db .update(appointments) .set({ status: "cancelled", updatedAt: new Date() }) .where(eq(appointments.id, id)) .returning(); if (!row) return c.json({ error: "Not found" }, 404); + + notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => { + console.error("[appointments] Failed to notify waitlist:", err); + }); + return c.json({ ok: true }); }); diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index adcac8d..4cd4be9 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -149,3 +149,55 @@ ${actionHtml}

— Groom Book

`, }; } + +interface WaitlistNotificationData { + clientName: string; + petName: string; + serviceName: string; + preferredDate: string; + preferredTime: string; +} + +export function buildWaitlistNotificationEmail( + to: string, + data: WaitlistNotificationData +): Mail.Options { + const apiUrl = process.env.API_URL ?? "http://localhost:3000"; + const bookUrl = `${apiUrl}/book`; + return { + to, + subject: `Appointment Cancelled — A slot has opened up for ${data.petName}`, + text: [ + `Hi ${data.clientName},`, + ``, + `Great news! An appointment slot has become available.`, + ``, + `We had a cancellation for:`, + ` Pet: ${data.petName}`, + ` Service: ${data.serviceName}`, + ` Date: ${data.preferredDate}`, + ` Time: ${data.preferredTime}`, + ``, + `If you're still interested, book now before this slot is taken!`, + ``, + `Book your appointment: ${bookUrl}`, + ``, + `— Groom Book`, + ].join("\n"), + html: ` +

Hi ${data.clientName},

+

Great news! An appointment slot has become available.

+

We had a cancellation for:

+ + + + + +
Pet${data.petName}
Service${data.serviceName}
Date${data.preferredDate}
Time${data.preferredTime}
+
+ Book This Slot +
+

If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.

+

— Groom Book

`, + }; +} diff --git a/apps/api/src/services/waitlistNotify.ts b/apps/api/src/services/waitlistNotify.ts new file mode 100644 index 0000000..2338515 --- /dev/null +++ b/apps/api/src/services/waitlistNotify.ts @@ -0,0 +1,63 @@ +import { and, eq, getDb, waitlistEntries, clients, pets, services } from "@groombook/db"; +import { buildWaitlistNotificationEmail, sendEmail } from "./email.js"; + +export async function notifyWaitlistForAppointment( + appointmentId: string, + appointmentDate: string, + appointmentTime: string, + serviceId: string +): Promise { + const db = getDb(); + + const matchingEntries = await db + .select() + .from(waitlistEntries) + .where( + and( + eq(waitlistEntries.preferredDate, appointmentDate), + eq(waitlistEntries.preferredTime, appointmentTime), + eq(waitlistEntries.serviceId, serviceId), + eq(waitlistEntries.status, "active") + ) + ); + + for (const entry of matchingEntries) { + const [client] = await db + .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) + .from(clients) + .where(eq(clients.id, entry.clientId)) + .limit(1); + + if (!client?.email || client.emailOptOut) continue; + + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, entry.petId)) + .limit(1); + + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, entry.serviceId)) + .limit(1); + + if (!pet || !service) continue; + + const email = buildWaitlistNotificationEmail(client.email, { + clientName: client.name, + petName: pet.name, + serviceName: service.name, + preferredDate: appointmentDate, + preferredTime: appointmentTime, + }); + + const sent = await sendEmail(email); + if (sent) { + await db + .update(waitlistEntries) + .set({ status: "notified", notifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(waitlistEntries.id, entry.id)); + } + } +}