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 <noreply@paperclip.ing>
This commit is contained in:
Scrubs McBarkley
2026-03-25 01:40:56 +00:00
committed by Flea Flicker
parent 09cbf00157
commit a0f93fbb3f
4 changed files with 140 additions and 3 deletions
+1 -2
View File
@@ -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);
+24 -1
View File
@@ -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 });
});
+52
View File
@@ -149,3 +149,55 @@ ${actionHtml}
<p>— Groom Book</p>`,
};
}
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: `
<p>Hi ${data.clientName},</p>
<p>Great news! <strong>An appointment slot has become available</strong>.</p>
<p>We had a cancellation for:</p>
<table style="border-collapse:collapse;margin:1em 0">
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Pet</td><td>${data.petName}</td></tr>
<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">Date</td><td>${data.preferredDate}</td></tr>
<tr><td style="padding:4px 12px 4px 0;font-weight:600;color:#6b7280">Time</td><td>${data.preferredTime}</td></tr>
</table>
<div style="margin:1.5em 0">
<a href="${bookUrl}" style="display:inline-block;padding:12px 24px;background:#10b981;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;font-size:16px">Book This Slot</a>
</div>
<p>If you're no longer interested, you can ignore this email or remove yourself from the waitlist in your portal.</p>
<p>— Groom Book</p>`,
};
}
+63
View File
@@ -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<void> {
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));
}
}
}