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:
committed by
Flea Flicker
parent
09cbf00157
commit
a0f93fbb3f
@@ -61,9 +61,9 @@ app.get("/api/branding", async (c) => {
|
|||||||
|
|
||||||
// Portal routes — no staff auth required, uses impersonation session for client auth
|
// Portal routes — no staff auth required, uses impersonation session for client auth
|
||||||
app.route("/api/portal", portalRouter);
|
app.route("/api/portal", portalRouter);
|
||||||
|
|
||||||
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
// Public iCal calendar feed — token auth in URL, no auth middleware required
|
||||||
app.route("/api/calendar", calendarRouter);
|
app.route("/api/calendar", calendarRouter);
|
||||||
|
|
||||||
// Protected API routes
|
// Protected API routes
|
||||||
const api = app.basePath("/api");
|
const api = app.basePath("/api");
|
||||||
api.use("*", authMiddleware);
|
api.use("*", authMiddleware);
|
||||||
@@ -116,7 +116,6 @@ api.route("/clients", clientsRouter);
|
|||||||
api.route("/pets", petsRouter);
|
api.route("/pets", petsRouter);
|
||||||
api.route("/services", servicesRouter);
|
api.route("/services", servicesRouter);
|
||||||
api.route("/appointments", appointmentsRouter);
|
api.route("/appointments", appointmentsRouter);
|
||||||
api.route("/portal", portalRouter);
|
|
||||||
api.route("/waitlist", waitlistRouter);
|
api.route("/waitlist", waitlistRouter);
|
||||||
api.route("/staff", staffRouter);
|
api.route("/staff", staffRouter);
|
||||||
api.route("/invoices", invoicesRouter);
|
api.route("/invoices", invoicesRouter);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono();
|
export const appointmentsRouter = new Hono();
|
||||||
|
|
||||||
@@ -483,7 +484,7 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const cascade = c.req.query("cascade") ?? "this_only";
|
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
|
const [current] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
@@ -510,16 +511,38 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
.where(eq(appointments.id, id));
|
.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 });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single cancel (default)
|
// 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
|
const [row] = await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
.where(eq(appointments.id, id))
|
.where(eq(appointments.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -149,3 +149,55 @@ ${actionHtml}
|
|||||||
<p>— Groom Book</p>`,
|
<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>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user