From d1ab91adfae3266db4fc1354b6113016da273af8 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 16:02:58 +0000 Subject: [PATCH 1/2] feat: appointment confirmation and cancellation (GH #98, GRO-153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/__tests__/confirmation.test.ts | 339 ++++++++++++++++++ apps/api/src/routes/appointments.ts | 72 ++++ apps/api/src/routes/book.ts | 88 +++++ apps/api/src/services/email.ts | 28 +- apps/api/src/services/reminders.ts | 16 +- apps/web/src/App.tsx | 14 + apps/web/src/pages/Appointments.tsx | 11 + apps/web/src/pages/BookingCancelled.tsx | 49 +++ apps/web/src/pages/BookingConfirmed.tsx | 49 +++ apps/web/src/pages/BookingError.tsx | 49 +++ .../0013_appointment_confirmation.sql | 7 + packages/db/src/factories.ts | 4 + packages/db/src/schema.ts | 7 + packages/types/src/index.ts | 6 + 14 files changed, 736 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/__tests__/confirmation.test.ts create mode 100644 apps/web/src/pages/BookingCancelled.tsx create mode 100644 apps/web/src/pages/BookingConfirmed.tsx create mode 100644 apps/web/src/pages/BookingError.tsx create mode 100644 packages/db/migrations/0013_appointment_confirmation.sql diff --git a/apps/api/src/__tests__/confirmation.test.ts b/apps/api/src/__tests__/confirmation.test.ts new file mode 100644 index 0000000..091268f --- /dev/null +++ b/apps/api/src/__tests__/confirmation.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock appointment data ──────────────────────────────────────────────────── + +const FUTURE_TIME = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 1 week from now +const PAST_TIME = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago + +const BASE_APPT = { + id: "appt-uuid-1", + clientId: "client-uuid-1", + petId: "pet-uuid-1", + serviceId: "service-uuid-1", + staffId: "staff-uuid-1", + batherStaffId: null, + status: "scheduled" as const, + startTime: FUTURE_TIME, + endTime: new Date(FUTURE_TIME.getTime() + 3600_000), + notes: null, + priceCents: null, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "pending", + confirmedAt: null, + cancelledAt: null, + confirmationToken: "valid-token-abc123", + createdAt: new Date(), + updatedAt: new Date(), +}; + +// ─── Shared mock DB state ───────────────────────────────────────────────────── + +let mockAppt: typeof BASE_APPT | null = BASE_APPT; +let lastUpdate: Record = {}; + +function resetMock() { + mockAppt = { ...BASE_APPT }; + lastUpdate = {}; +} + +vi.mock("@groombook/db", () => { + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => (mockAppt ? [mockAppt] : []), + }), + }), + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + lastUpdate = { ...vals }; + if (mockAppt) { + mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT; + } + return { returning: () => (mockAppt ? [mockAppt] : []) }; + }, + }), + }), + }), + appointments, + eq: () => ({}), + }; +}); + +// ─── Book router (tokenized endpoints) ─────────────────────────────────────── + +async function makeBookApp() { + const { bookRouter } = await import("../routes/book.js"); + const app = new Hono(); + app.route("/api/book", bookRouter); + return app; +} + +// ─── Appointments router (portal endpoints) ──────────────────────────────── + +async function makeAppointmentsApp() { + const { appointmentsRouter } = await import("../routes/appointments.js"); + const app = new Hono(); + app.route("/api/appointments", appointmentsRouter); + return app; +} + +// ─── Tests: tokenized confirm endpoint ──────────────────────────────────────── + +describe("GET /api/book/confirm/:token", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeBookApp(); + }); + + it("redirects to /booking/confirmed on valid token and future appointment", async () => { + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/confirmed"); + }); + + it("sets confirmationStatus to confirmed", async () => { + await app.request("/api/book/confirm/valid-token-abc123"); + expect(lastUpdate.confirmationStatus).toBe("confirmed"); + expect(lastUpdate.confirmedAt).toBeInstanceOf(Date); + }); + + it("redirects to /booking/error when token not found", async () => { + mockAppt = null; + const res = await app.request("/api/book/confirm/bad-token"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/error when appointment is in the past", async () => { + mockAppt = { ...BASE_APPT, startTime: PAST_TIME }; + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/confirmed idempotently when already confirmed", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" }; + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/confirmed"); + }); + + it("redirects to /booking/error when appointment is already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/book/confirm/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); +}); + +// ─── Tests: tokenized cancel endpoint ──────────────────────────────────────── + +describe("GET /api/book/cancel/:token", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeBookApp(); + }); + + it("redirects to /booking/cancelled on valid token and future appointment", async () => { + const res = await app.request("/api/book/cancel/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/cancelled"); + }); + + it("sets confirmationStatus to cancelled and nullifies token (single-use)", async () => { + await app.request("/api/book/cancel/valid-token-abc123"); + expect(lastUpdate.confirmationStatus).toBe("cancelled"); + expect(lastUpdate.cancelledAt).toBeInstanceOf(Date); + expect(lastUpdate.confirmationToken).toBeNull(); + }); + + it("redirects to /booking/error when token not found", async () => { + mockAppt = null; + const res = await app.request("/api/book/cancel/bad-token"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/error when appointment is in the past", async () => { + mockAppt = { ...BASE_APPT, startTime: PAST_TIME }; + const res = await app.request("/api/book/cancel/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); + + it("redirects to /booking/error when already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/book/cancel/valid-token-abc123"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toContain("/booking/error"); + }); +}); + +// ─── Tests: portal confirm endpoint ────────────────────────────────────────── + +describe("POST /api/appointments/:id/confirm", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeAppointmentsApp(); + }); + + it("confirms a pending appointment", async () => { + const res = await app.request("/api/appointments/appt-uuid-1/confirm", { + method: "POST", + }); + expect(res.status).toBe(200); + expect(lastUpdate.confirmationStatus).toBe("confirmed"); + expect(lastUpdate.confirmedAt).toBeInstanceOf(Date); + }); + + it("returns 404 when appointment not found", async () => { + mockAppt = null; + const res = await app.request("/api/appointments/nonexistent/confirm", { + method: "POST", + }); + expect(res.status).toBe(404); + }); + + it("returns 409 when appointment is already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/appointments/appt-uuid-1/confirm", { + method: "POST", + }); + expect(res.status).toBe(409); + }); + + it("returns 200 idempotently when appointment is already confirmed", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" }; + const res = await app.request("/api/appointments/appt-uuid-1/confirm", { + method: "POST", + }); + expect(res.status).toBe(200); + }); +}); + +// ─── Tests: portal cancel endpoint ─────────────────────────────────────────── + +describe("POST /api/appointments/:id/cancel", () => { + let app: Hono; + + beforeEach(async () => { + vi.resetModules(); + resetMock(); + app = await makeAppointmentsApp(); + }); + + it("cancels a pending appointment and nullifies the token", async () => { + const res = await app.request("/api/appointments/appt-uuid-1/cancel", { + method: "POST", + }); + expect(res.status).toBe(200); + expect(lastUpdate.confirmationStatus).toBe("cancelled"); + expect(lastUpdate.cancelledAt).toBeInstanceOf(Date); + expect(lastUpdate.confirmationToken).toBeNull(); + }); + + it("returns 404 when appointment not found", async () => { + mockAppt = null; + const res = await app.request("/api/appointments/nonexistent/cancel", { + method: "POST", + }); + expect(res.status).toBe(404); + }); + + it("returns 409 when appointment is already customer-cancelled", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "cancelled" }; + const res = await app.request("/api/appointments/appt-uuid-1/cancel", { + method: "POST", + }); + expect(res.status).toBe(409); + }); + + it("can cancel a confirmed appointment", async () => { + mockAppt = { ...BASE_APPT, confirmationStatus: "confirmed" }; + const res = await app.request("/api/appointments/appt-uuid-1/cancel", { + method: "POST", + }); + expect(res.status).toBe(200); + expect(lastUpdate.confirmationStatus).toBe("cancelled"); + }); +}); + +// ─── Tests: token generation helper ────────────────────────────────────────── + +describe("generateConfirmationToken", () => { + it("generates a 64-character hex string", async () => { + const { generateConfirmationToken } = await import("../routes/appointments.js"); + const token = generateConfirmationToken(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + }); + + it("generates unique tokens on each call", async () => { + const { generateConfirmationToken } = await import("../routes/appointments.js"); + const t1 = generateConfirmationToken(); + const t2 = generateConfirmationToken(); + expect(t1).not.toBe(t2); + }); +}); + +// ─── Tests: reminder email with action links ────────────────────────────────── + +describe("buildReminderEmail with confirmation token", () => { + it("includes confirm and cancel links when token is provided", async () => { + const { buildReminderEmail } = await import("../services/email.js"); + const mail = buildReminderEmail( + "client@example.com", + { + clientName: "Jane", + petName: "Biscuit", + serviceName: "Full Groom", + groomerName: null, + startTime: new Date(), + }, + 24, + "abc123token" + ); + expect(mail.text).toContain("abc123token"); + expect(mail.html as string).toContain("abc123token"); + expect(mail.html as string).toContain("Confirm Appointment"); + expect(mail.html as string).toContain("Cancel Appointment"); + }); + + it("omits action links when no token is provided", async () => { + const { buildReminderEmail } = await import("../services/email.js"); + const mail = buildReminderEmail( + "client@example.com", + { + clientName: "Jane", + petName: "Biscuit", + serviceName: "Full Groom", + groomerName: null, + startTime: new Date(), + }, + 24, + null + ); + expect(mail.html as string).not.toContain("Confirm Appointment"); + expect(mail.html as string).not.toContain("Cancel Appointment"); + }); +}); diff --git a/apps/api/src/routes/appointments.ts b/apps/api/src/routes/appointments.ts index 30a7634..8b82a88 100644 --- a/apps/api/src/routes/appointments.ts +++ b/apps/api/src/routes/appointments.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; +import { randomBytes } from "node:crypto"; import { and, eq, @@ -521,3 +522,74 @@ appointmentsRouter.delete("/:id", async (c) => { if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); }); + +// ─── POST /api/appointments/:id/confirm ─────────────────────────────────────── +// Staff/portal: confirm a specific appointment by ID. Idempotent. + +appointmentsRouter.post("/:id/confirm", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) return c.json({ error: "Not found" }, 404); + + if (appt.confirmationStatus === "cancelled") { + return c.json({ error: "Cannot confirm a cancelled appointment" }, 409); + } + + if (appt.confirmationStatus === "confirmed") { + return c.json(appt); // idempotent + } + + const [updated] = await db + .update(appointments) + .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json(updated); +}); + +// ─── POST /api/appointments/:id/cancel ─────────────────────────────────────── +// Staff/portal: cancel confirmation for a specific appointment by ID. Single-use token nullified. + +appointmentsRouter.post("/:id/cancel", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) return c.json({ error: "Not found" }, 404); + + if (appt.confirmationStatus === "cancelled") { + return c.json({ error: "Appointment is already cancelled" }, 409); + } + + const [updated] = await db + .update(appointments) + .set({ + confirmationStatus: "cancelled", + cancelledAt: new Date(), + confirmationToken: null, + updatedAt: new Date(), + }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json(updated); +}); + +// ─── Token generation helper ────────────────────────────────────────────────── + +export function generateConfirmationToken(): string { + return randomBytes(32).toString("hex"); +} diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts index a60f008..3b12089 100644 --- a/apps/api/src/routes/book.ts +++ b/apps/api/src/routes/book.ts @@ -245,3 +245,91 @@ bookRouter.post( return c.json({ appointment, client, pet }, 201); } ); + +// ─── GET /api/book/confirm/:token ────────────────────────────────────────── +// Public: confirm appointment via tokenized email link. Redirects to success/error page. + +const BASE_URL = () => process.env.APP_URL ?? "http://localhost:5173"; + +bookRouter.get("/confirm/:token", async (c) => { + const token = c.req.param("token"); + const db = getDb(); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.confirmationToken, token)) + .limit(1); + + if (!appt) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + // Reject if appointment is in the past + if (appt.startTime < new Date()) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + // Idempotent confirm: if already confirmed, redirect to success + if (appt.confirmationStatus === "confirmed") { + return c.redirect(`${BASE_URL()}/booking/confirmed`); + } + + // Reject if already cancelled + if (appt.confirmationStatus === "cancelled") { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + await db + .update(appointments) + .set({ + confirmationStatus: "confirmed", + confirmedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(appointments.id, appt.id)); + + return c.redirect(`${BASE_URL()}/booking/confirmed`); +}); + +// ─── GET /api/book/cancel/:token ─────────────────────────────────────────── +// Public: cancel appointment via tokenized email link. Redirects to success/error page. + +bookRouter.get("/cancel/:token", async (c) => { + const token = c.req.param("token"); + const db = getDb(); + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.confirmationToken, token)) + .limit(1); + + if (!appt) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + // Reject if appointment is in the past + if (appt.startTime < new Date()) { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + // Reject if already cancelled (token was nullified — this path won't normally hit, + // but guard against edge cases where token lookup still works) + if (appt.confirmationStatus === "cancelled") { + return c.redirect(`${BASE_URL()}/booking/error`); + } + + // Single-use cancellation: nullify token after use + await db + .update(appointments) + .set({ + confirmationStatus: "cancelled", + cancelledAt: new Date(), + confirmationToken: null, + updatedAt: new Date(), + }) + .where(eq(appointments.id, appt.id)); + + return c.redirect(`${BASE_URL()}/booking/cancelled`); +}); diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index 1803e43..3b827c9 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -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 + ? ` +
+ Confirm Appointment + Cancel Appointment +
` + : ""; + 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( Service${data.serviceName} When${time}${groomer} +${actionHtml}

See you soon!

— Groom Book

`, }; diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 60bda20..3bbfae0 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -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 { serviceId: appointments.serviceId, staffId: appointments.staffId, status: appointments.status, + confirmationToken: appointments.confirmationToken, }) .from(appointments) .where( @@ -109,6 +111,17 @@ export async function runReminderCheck(): Promise { 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 { groomerName, startTime: appt.startTime, }, - window.hours + window.hours, + confirmationToken ) ); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f819165..cdf9d1f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,9 @@ import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; import { SettingsPage } from "./pages/Settings.js"; +import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; +import { BookingCancelledPage } from "./pages/BookingCancelled.js"; +import { BookingErrorPage } from "./pages/BookingError.js"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; @@ -151,6 +154,17 @@ export function App() { return ; } + // Public booking redirect pages — no auth or portal chrome needed + if (location.pathname === "/booking/confirmed") { + return ; + } + if (location.pathname === "/booking/cancelled") { + return ; + } + if (location.pathname === "/booking/error") { + return ; + } + return ( {location.pathname.startsWith("/admin") ? ( diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 455c582..505953c 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -431,6 +431,12 @@ export function AppointmentsPage() { {a.seriesId && (
↻ recurring
)} + {a.confirmationStatus === "confirmed" && ( +
✓ confirmed
+ )} + {a.confirmationStatus === "cancelled" && ( +
✗ cust. cancelled
+ )} ); })} @@ -695,6 +701,11 @@ function AppointmentDetail({ ["Start", new Date(appt.startTime).toLocaleString()], ["End", new Date(appt.endTime).toLocaleString()], ["Status", appt.status.replace("_", " ")], + ["Confirmation", appt.confirmationStatus === "confirmed" + ? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}` + : appt.confirmationStatus === "cancelled" + ? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}` + : "Pending"], ["Notes", appt.notes ?? "—"], ...(appt.seriesId ? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]] diff --git a/apps/web/src/pages/BookingCancelled.tsx b/apps/web/src/pages/BookingCancelled.tsx new file mode 100644 index 0000000..9b2ab4a --- /dev/null +++ b/apps/web/src/pages/BookingCancelled.tsx @@ -0,0 +1,49 @@ +export function BookingCancelledPage() { + return ( +
+
+
+

+ Appointment Cancelled +

+

+ Your appointment has been cancelled. If this was a mistake or you'd + like to rebook, please contact us. +

+ + Back to Portal + +
+
+ ); +} diff --git a/apps/web/src/pages/BookingConfirmed.tsx b/apps/web/src/pages/BookingConfirmed.tsx new file mode 100644 index 0000000..a56ba96 --- /dev/null +++ b/apps/web/src/pages/BookingConfirmed.tsx @@ -0,0 +1,49 @@ +export function BookingConfirmedPage() { + return ( +
+
+
+

+ Appointment Confirmed +

+

+ Thank you! Your appointment is confirmed. We look forward to seeing you + and your furry friend. +

+ + Back to Portal + +
+
+ ); +} diff --git a/apps/web/src/pages/BookingError.tsx b/apps/web/src/pages/BookingError.tsx new file mode 100644 index 0000000..62639d9 --- /dev/null +++ b/apps/web/src/pages/BookingError.tsx @@ -0,0 +1,49 @@ +export function BookingErrorPage() { + return ( +
+
+
⚠️
+

+ Link Invalid or Expired +

+

+ This confirmation link is invalid, has already been used, or your + appointment has already passed. Please contact us if you need help. +

+ + Back to Portal + +
+
+ ); +} diff --git a/packages/db/migrations/0013_appointment_confirmation.sql b/packages/db/migrations/0013_appointment_confirmation.sql new file mode 100644 index 0000000..347ebfd --- /dev/null +++ b/packages/db/migrations/0013_appointment_confirmation.sql @@ -0,0 +1,7 @@ +ALTER TABLE appointments + ADD COLUMN confirmation_status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN confirmed_at TIMESTAMPTZ, + ADD COLUMN cancelled_at TIMESTAMPTZ, + ADD COLUMN confirmation_token TEXT UNIQUE; + +CREATE INDEX idx_appointments_confirmation_token ON appointments (confirmation_token) WHERE confirmation_token IS NOT NULL; diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index d9cd58b..5cc6698 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -136,6 +136,10 @@ export function buildAppointment( endTime, notes: null, priceCents: null, + confirmationStatus: "pending", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), }; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b1456e4..35c2111 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -162,6 +162,13 @@ export const appointments = pgTable("appointments", { groupId: uuid("group_id").references(() => appointmentGroups.id, { onDelete: "set null", }), + // Customer confirmation/cancellation tracking + // Values: "pending" | "confirmed" | "cancelled" + confirmationStatus: text("confirmation_status").notNull().default("pending"), + confirmedAt: timestamp("confirmed_at"), + cancelledAt: timestamp("cancelled_at"), + // Token for tokenized email confirm/cancel links (no auth required) + confirmationToken: text("confirmation_token").unique(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 46190be..b794c93 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,6 +8,8 @@ export type AppointmentStatus = | "cancelled" | "no_show"; +export type ConfirmationStatus = "pending" | "confirmed" | "cancelled"; + export type ClientStatus = "active" | "disabled"; export interface Client { @@ -104,6 +106,10 @@ export interface Appointment { seriesId: string | null; seriesIndex: number | null; groupId: string | null; + confirmationStatus: ConfirmationStatus; + confirmedAt: string | null; + cancelledAt: string | null; + confirmationToken: string | null; createdAt: string; updatedAt: string; } -- 2.52.0 From f3923ddf54fedfe1360e9c3a9cde4a88aee88196 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 17:25:09 +0000 Subject: [PATCH 2/2] fix(email): remove unused baseUrl variable in buildReminderEmail Fixes ESLint @typescript-eslint/no-unused-vars error in CI. baseUrl was declared but never used; confirmUrl/cancelUrl correctly use apiUrl instead. Co-Authored-By: Paperclip --- apps/api/src/services/email.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/services/email.ts b/apps/api/src/services/email.ts index 3b827c9..adcac8d 100644 --- a/apps/api/src/services/email.ts +++ b/apps/api/src/services/email.ts @@ -99,7 +99,6 @@ export function buildReminderEmail( 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; -- 2.52.0