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} |
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