From 6539eb455421db748ee9c46892476d5911641d13 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:37:06 +0000 Subject: [PATCH 1/6] feat: iCal calendar feed (GRO-107) feat: iCal calendar feed (GRO-107) Closes GRO-107 --- apps/api/src/__tests__/calendar.test.ts | 16 ++ apps/api/src/__tests__/petPhotos.test.ts | 1 + apps/api/src/__tests__/rbac.test.ts | 1 + apps/api/src/index.ts | 3 + apps/api/src/routes/calendar.ts | 126 +++++++++++ apps/api/src/routes/portal.ts | 3 +- apps/api/src/routes/staff.ts | 57 ++++- .../src/components/CalendarSyncSection.tsx | 203 ++++++++++++++++++ opencode.json | 7 + packages/db/migrations/0016_ical_token.sql | 1 + packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 2 + 12 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/__tests__/calendar.test.ts create mode 100644 apps/api/src/routes/calendar.ts create mode 100644 apps/web/src/components/CalendarSyncSection.tsx create mode 100644 opencode.json create mode 100644 packages/db/migrations/0016_ical_token.sql diff --git a/apps/api/src/__tests__/calendar.test.ts b/apps/api/src/__tests__/calendar.test.ts new file mode 100644 index 0000000..7287d88 --- /dev/null +++ b/apps/api/src/__tests__/calendar.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { generateIcalToken } from "../routes/calendar.js"; + +describe("generateIcalToken", () => { + it("generates a 64-character hex token", () => { + const token = generateIcalToken(); + expect(token).toHaveLength(64); + expect(token).toMatch(/^[a-f0-9]+$/); + }); + + it("generates unique tokens", () => { + const token1 = generateIcalToken(); + const token2 = generateIcalToken(); + expect(token1).not.toBe(token2); + }); +}); \ No newline at end of file diff --git a/apps/api/src/__tests__/petPhotos.test.ts b/apps/api/src/__tests__/petPhotos.test.ts index 84a930c..b4d2d6b 100644 --- a/apps/api/src/__tests__/petPhotos.test.ts +++ b/apps/api/src/__tests__/petPhotos.test.ts @@ -11,6 +11,7 @@ const MANAGER: StaffRow = { name: "Manager McManager", email: "manager@example.com", active: true, + icalToken: null, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index c27db51..b052507 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -12,6 +12,7 @@ const MANAGER: StaffRow = { name: "Manager McManager", email: "manager@example.com", active: true, + icalToken: null, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 54c18ea..f20f277 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -17,6 +17,7 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; +import { calendarRouter } from "./routes/calendar.js"; import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; @@ -62,6 +63,8 @@ app.get("/api/branding", async (c) => { }); }); +// 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); diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts new file mode 100644 index 0000000..a85568f --- /dev/null +++ b/apps/api/src/routes/calendar.ts @@ -0,0 +1,126 @@ +import { Hono } from "hono"; +import { randomBytes } from "node:crypto"; +import { + and, + eq, + gte, + getDb, + appointments, + clients, + pets, + services, + staff, +} from "@groombook/db"; + +export const calendarRouter = new Hono(); + +function formatIcalDate(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); +} + +function escapeIcalText(text: string | null): string { + if (!text) return ""; + return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); +} + +function buildIcalFeed( + appointments: Array<{ + id: string; + startTime: Date; + endTime: Date; + status: string; + clientName: string | null; + petName: string | null; + serviceName: string | null; + }>, + staffName: string, + dtstamp: string +): string { + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//GroomBook//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${escapeIcalText(staffName)} - GroomBook`, + ]; + + for (const appt of appointments) { + const status = appt.status === "cancelled" ? "CANCELLED" : "CONFIRMED"; + const sequence = appt.status === "cancelled" ? "1" : "0"; + const summary = `${appt.petName ?? "Pet"} - ${appt.serviceName ?? "Appointment"}`; + const description = `Client: ${appt.clientName ?? "Unknown"}\nPet: ${appt.petName ?? "Unknown"}\nService: ${appt.serviceName ?? "Unknown"}`; + + lines.push( + "BEGIN:VEVENT", + `UID:${appt.id}@groombook`, + `DTSTAMP:${dtstamp}`, + `DTSTART:${formatIcalDate(new Date(appt.startTime))}`, + `DTEND:${formatIcalDate(new Date(appt.endTime))}`, + `SUMMARY:${escapeIcalText(summary)}`, + `DESCRIPTION:${escapeIcalText(description)}`, + `STATUS:${status}`, + `SEQUENCE:${sequence}`, + "END:VEVENT" + ); + } + + lines.push("END:VCALENDAR"); + return lines.join("\r\n"); +} + +calendarRouter.get("/:staffId.ics", async (c) => { + const db = getDb(); + const staffId = c.req.param("staffId") as string; + const token = c.req.query("token") as string; + + if (!token) { + return c.text("Unauthorized", 401); + } + + const [staffMember] = await db + .select() + .from(staff) + .where(eq(staff.id, staffId)) + .limit(1); + + if (!staffMember || staffMember.icalToken !== token) { + return c.text("Unauthorized", 401); + } + + const now = new Date(); + const rows = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + clientId: appointments.clientId, + petId: appointments.petId, + serviceId: appointments.serviceId, + clientName: clients.name, + petName: pets.name, + serviceName: services.name, + }) + .from(appointments) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .innerJoin(pets, eq(appointments.petId, pets.id)) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .where( + and( + eq(appointments.staffId, staffId), + gte(appointments.startTime, now) + ) + ) + .orderBy(appointments.startTime); + + const ical = buildIcalFeed(rows, staffMember.name, formatIcalDate(new Date())); + return c.text(ical, 200, { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`, + }); +}); + +export function generateIcalToken(): string { + return randomBytes(32).toString("hex"); +} diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index b7c9fa4..f67a891 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -7,7 +7,8 @@ import type { AppEnv } from "../middleware/rbac.js"; export const portalRouter = new Hono(); const customerNotesSchema = z.object({ - customerNotes: z.string().max(500), + // .min(1) prevents empty strings — clearing notes is not a supported use case + customerNotes: z.string().min(1).max(500), }); portalRouter.patch( diff --git a/apps/api/src/routes/staff.ts b/apps/api/src/routes/staff.ts index 60e31dc..0aa6b70 100644 --- a/apps/api/src/routes/staff.ts +++ b/apps/api/src/routes/staff.ts @@ -1,9 +1,11 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; +import { randomBytes } from "node:crypto"; import { and, eq, getDb, ne, staff, appointments } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; -export const staffRouter = new Hono(); +export const staffRouter = new Hono(); const createStaffSchema = z.object({ name: z.string().min(1).max(200), @@ -86,3 +88,56 @@ staffRouter.delete("/:id", async (c) => { if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); }); + +staffRouter.post("/:id/ical-token", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + + if (staffRow.role !== "manager" && staffRow.id !== id) { + return c.json({ error: "Forbidden" }, 403); + } + + const [member] = await db + .select() + .from(staff) + .where(eq(staff.id, id)) + .limit(1); + + if (!member) return c.json({ error: "Not found" }, 404); + + const token = randomBytes(32).toString("hex"); + const [updated] = await db + .update(staff) + .set({ icalToken: token, updatedAt: new Date() }) + .where(eq(staff.id, id)) + .returning(); + + if (!updated) return c.json({ error: "Not found" }, 404); + return c.json({ icalToken: updated.icalToken }); +}); + +staffRouter.delete("/:id/ical-token", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const staffRow = c.get("staff"); + + if (staffRow.role !== "manager" && staffRow.id !== id) { + return c.json({ error: "Forbidden" }, 403); + } + + const [member] = await db + .select() + .from(staff) + .where(eq(staff.id, id)) + .limit(1); + + if (!member) return c.json({ error: "Not found" }, 404); + + await db + .update(staff) + .set({ icalToken: null, updatedAt: new Date() }) + .where(eq(staff.id, id)); + + return c.json({ ok: true }); +}); diff --git a/apps/web/src/components/CalendarSyncSection.tsx b/apps/web/src/components/CalendarSyncSection.tsx new file mode 100644 index 0000000..abf66fa --- /dev/null +++ b/apps/web/src/components/CalendarSyncSection.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from "react"; +import { Calendar, RefreshCw, Trash2, Copy, Check } from "lucide-react"; + +interface Props { + staffId: string; + staffName: string; +} + +export function CalendarSyncSection({ staffId }: Props) { + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState<"generate" | "revoke" | null>(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [showRevokeConfirm, setShowRevokeConfirm] = useState(false); + + useEffect(() => { + fetchToken(); + }, [staffId]); + + async function fetchToken() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/staff/${staffId}`); + if (!res.ok) throw new Error("Failed to fetch staff data"); + const data = await res.json(); + setToken(data.icalToken || null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoading(false); + } + } + + async function generateToken() { + setActionLoading("generate"); + setError(null); + try { + const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "POST" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to generate token"); + } + const data = await res.json(); + setToken(data.icalToken); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to generate token"); + } finally { + setActionLoading(null); + } + } + + async function revokeToken() { + if (!showRevokeConfirm) { + setShowRevokeConfirm(true); + return; + } + setActionLoading("revoke"); + setError(null); + try { + const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "DELETE" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to revoke token"); + } + setToken(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to revoke token"); + } finally { + setActionLoading(null); + setShowRevokeConfirm(false); + } + } + + async function copyFeedUrl() { + if (!token) return; + const url = `${window.location.origin}/api/calendar/${staffId}.ics?token=${token}`; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + const feedUrl = token ? `/api/calendar/${staffId}.ics?token=${token}` : null; + + return ( +
+
+ +

Calendar Sync

+
+ +

+ Generate a calendar feed link to share your upcoming appointments with any calendar app that supports iCal (Apple Calendar, Google Calendar, Outlook). +

+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading...
+ ) : token ? ( +
+
+ +
+ + +
+
+ + {showRevokeConfirm ? ( +
+

+ Revoke your calendar feed link? Anyone with the current link will lose access. +

+ + +
+ ) : ( +
+ + +
+ )} + +

+ Regenerating will create a new URL and invalidate the old one. +

+
+ ) : ( +
+

You don't have a calendar feed set up yet.

+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2539f7d --- /dev/null +++ b/opencode.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": "allow", + "experimental": { + "snapshots": false + } +} diff --git a/packages/db/migrations/0016_ical_token.sql b/packages/db/migrations/0016_ical_token.sql new file mode 100644 index 0000000..2b0bf79 --- /dev/null +++ b/packages/db/migrations/0016_ical_token.sql @@ -0,0 +1 @@ +ALTER TABLE staff ADD COLUMN ical_token TEXT UNIQUE; diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 7e4d735..b2327e3 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -52,6 +52,7 @@ export function buildStaff(overrides: Partial = {}): StaffRow { oidcSub: `oidc-${id}`, role: "groomer", active: true, + icalToken: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), ...overrides, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 676602b..ddcddc7 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -106,6 +106,8 @@ export const staff = pgTable("staff", { oidcSub: text("oidc_sub").unique(), role: staffRoleEnum("role").notNull().default("groomer"), active: boolean("active").notNull().default(true), + // Token for iCal calendar feed subscription (no auth required) + icalToken: text("ical_token").unique(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -- 2.52.0 From d742b48741ff4f15d3f98cfcbe2381cd1bc4bf57 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 26 Mar 2026 22:09:26 +0000 Subject: [PATCH 2/6] Implement confirm/cancel in customer portal (GRO-50) Backend: - Add POST /api/portal/appointments/:id/confirm endpoint - Validates impersonation session auth and ownership - Rejects past/in-progress, non-pending, or already-cancelled/completed - Sets confirmationStatus="confirmed", confirmedAt, updatedAt - Add POST /api/portal/appointments/:id/cancel endpoint - Same auth/ownership pattern - Rejects past/in-progress or already-cancelled/completed - Sets status="cancelled", confirmationStatus="cancelled", cancelledAt, updatedAt Frontend (Appointments.tsx): - Add confirmationStatus field to Appointment type and mock data - Add ConfirmationSection component: shows status badge + confirm button - Add CancelAppointmentButton: wires to cancel API with loading/error state - Wire existing Cancel button to CancelAppointmentButton - Show confirmation status badge in expanded view for upcoming appointments Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 127 ++++++++++++++++++ apps/web/src/portal/mockData.ts | 23 ++-- apps/web/src/portal/sections/Appointments.tsx | 119 +++++++++++++++- 3 files changed, 255 insertions(+), 14 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index b7c9fa4..4d6284f 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -76,6 +76,133 @@ portalRouter.patch( } ); +// ─── Appointment confirm/cancel ────────────────────────────────────────────── + +portalRouter.post("/appointments/:id/confirm", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + 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.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot confirm a past or in-progress appointment" }, 422); + } + + if (appt.confirmationStatus !== "pending") { + return c.json({ error: "Appointment is not pending confirmation" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Cannot confirm a cancelled or completed appointment" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ confirmationStatus: "confirmed", confirmedAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json({ + id: updated.id, + confirmationStatus: updated.confirmationStatus, + confirmedAt: updated.confirmedAt, + updatedAt: updated.updatedAt, + }); +}); + +portalRouter.post("/appointments/:id/cancel", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + 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.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot cancel a past or in-progress appointment" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Appointment is already cancelled or completed" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ status: "cancelled", confirmationStatus: "cancelled", cancelledAt: new Date(), updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json({ + id: updated.id, + status: updated.status, + confirmationStatus: updated.confirmationStatus, + cancelledAt: updated.cancelledAt, + updatedAt: updated.updatedAt, + }); +}); + // ─── Client-facing waitlist routes ─────────────────────────────────────────── const createWaitlistEntrySchema = z.object({ diff --git a/apps/web/src/portal/mockData.ts b/apps/web/src/portal/mockData.ts index 190b41a..330cfe8 100644 --- a/apps/web/src/portal/mockData.ts +++ b/apps/web/src/portal/mockData.ts @@ -41,6 +41,7 @@ export interface Appointment { duration: number; price: number; status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled"; + confirmationStatus: "pending" | "confirmed" | "cancelled"; notes: string; customerNotes: string; reportCardId?: string; @@ -177,21 +178,21 @@ export const UPCOMING_APPOINTMENTS: Appointment[] = [ id: "a1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["De-shedding Treatment"], date: "2026-03-21", time: "10:00 AM", duration: 120, price: 145, - status: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed", + status: "confirmed", confirmationStatus: "confirmed", notes: "Spring shed is heavy — extra undercoat work needed", customerNotes: "", }, { id: "a2", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan", services: ["Full Groom"], addOns: ["Teeth Brushing"], date: "2026-03-25", time: "2:00 PM", duration: 100, price: 90, - status: "confirmed", notes: "First visit with Morgan — patient with anxious pets", + status: "confirmed", confirmationStatus: "confirmed", notes: "First visit with Morgan — patient with anxious pets", customerNotes: "", }, { id: "a3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: [], date: "2026-04-18", time: "11:00 AM", duration: 45, price: 55, - status: "pending", notes: "", + status: "pending", confirmationStatus: "pending", notes: "", customerNotes: "", }, ]; @@ -201,56 +202,56 @@ export const PAST_APPOINTMENTS: Appointment[] = [ id: "pa1", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["De-shedding Treatment", "Blueberry Facial"], date: "2026-02-15", time: "10:00 AM", duration: 130, price: 160, - status: "completed", notes: "", reportCardId: "rc1", + status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc1", customerNotes: "", }, { id: "pa2", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex", services: ["Full Groom"], addOns: ["Teeth Brushing"], date: "2026-02-20", time: "1:00 PM", duration: 100, price: 88, - status: "completed", notes: "", reportCardId: "rc2", + status: "completed", confirmationStatus: "confirmed", notes: "", reportCardId: "rc2", customerNotes: "", }, { id: "pa3", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: [], date: "2026-01-18", time: "9:00 AM", duration: 45, price: 55, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa4", petId: "p2", petName: "Mochi", groomerId: "g2", groomerName: "Alex", services: ["Puppy's First Groom"], addOns: [], date: "2026-01-10", time: "3:00 PM", duration: 60, price: 62, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa5", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Full Groom"], addOns: ["Nail Grinding"], date: "2025-12-20", time: "10:00 AM", duration: 105, price: 132, - status: "completed", notes: "Holiday groom", + status: "completed", confirmationStatus: "confirmed", notes: "Holiday groom", customerNotes: "", }, { id: "pa6", petId: "p1", petName: "Biscuit", groomerId: "g2", groomerName: "Alex", services: ["Full Groom"], addOns: [], date: "2025-11-15", time: "11:00 AM", duration: 90, price: 110, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa7", petId: "p2", petName: "Mochi", groomerId: "g3", groomerName: "Morgan", services: ["Bath & Brush"], addOns: [], date: "2025-11-08", time: "2:00 PM", duration: 45, price: 48, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, { id: "pa8", petId: "p1", petName: "Biscuit", groomerId: "g1", groomerName: "Jamie", services: ["Bath & Brush"], addOns: ["De-shedding Treatment"], date: "2025-10-12", time: "10:00 AM", duration: 75, price: 85, - status: "completed", notes: "", + status: "completed", confirmationStatus: "confirmed", notes: "", customerNotes: "", }, ]; diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index bd38475..ba27732 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -41,6 +41,12 @@ const STATUS_COLORS: Record = { cancelled: "bg-red-100 text-red-600", }; +const CONFIRMATION_STATUS_COLORS: Record = { + confirmed: "bg-green-100 text-green-700", + pending: "bg-amber-100 text-amber-700", + cancelled: "bg-red-100 text-red-600", +}; + export function AppointmentsSection({ readOnly, sessionId }: Props) { const [showBooking, setShowBooking] = useState(false); const [expandedId, setExpandedId] = useState(null); @@ -165,14 +171,15 @@ function AppointmentCard({ {isUpcoming(appt) && !readOnly && ( )} + {isUpcoming(appt) && ( + {}} /> + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
- +
)} {appt.reportCardId && ( @@ -188,6 +195,112 @@ function AppointmentCard({ ); } +export function ConfirmationSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { + const [confirming, setConfirming] = useState(false); + const [confirmError, setConfirmError] = useState(null); + const [confirmSuccess, setConfirmSuccess] = useState(false); + + async function handleConfirm() { + if (!window.confirm("Confirm this appointment?")) return; + setConfirming(true); + setConfirmError(null); + try { + const headers: Record = {}; + if (sessionId) { + headers["X-Impersonation-Session-Id"] = sessionId; + } + const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { + method: "POST", + headers, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to confirm" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + setConfirmSuccess(true); + setTimeout(() => setConfirmSuccess(false), 2000); + } catch (e) { + setConfirmError(e instanceof Error ? e.message : "Failed to confirm"); + } finally { + setConfirming(false); + } + } + + const statusLabel = appt.confirmationStatus === "confirmed" + ? "✓ Confirmed" + : appt.confirmationStatus === "pending" + ? "Pending confirmation" + : "Cancelled"; + + return ( +
+
+
+ + {statusLabel} + +
+ {!confirmSuccess && appt.confirmationStatus === "pending" && ( + + )} + {confirmSuccess && ( + Confirmed! + )} +
+ {confirmError &&

{confirmError}

} +
+ ); +} + +function CancelAppointmentButton({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(null); + + async function handleCancel() { + if (!window.confirm("Cancel this appointment? This cannot be undone.")) return; + setCancelling(true); + setCancelError(null); + try { + const headers: Record = {}; + if (sessionId) { + headers["X-Impersonation-Session-Id"] = sessionId; + } + const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { + method: "POST", + headers, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to cancel" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + window.location.reload(); + } catch (e) { + setCancelError(e instanceof Error ? e.message : "Failed to cancel"); + setCancelling(false); + } + } + + return ( + <> + + {cancelError &&

{cancelError}

} + + ); +} + export function CustomerNotesSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { const [notes, setNotes] = useState(appt.customerNotes || ""); const [saving, setSaving] = useState(false); -- 2.52.0 From 7f6b2fd4857796b264933ed3ef9903eb9a838de0 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 27 Mar 2026 02:21:19 +0000 Subject: [PATCH 3/6] fix(gro-50): add portal confirm/cancel tests and fix ConfirmationSection state - Add test coverage for POST /portal/appointments/:id/confirm endpoint - Add test coverage for POST /portal/appointments/:id/cancel endpoint - Fix ConfirmationSection not updating local status after successful confirm - Remove unused onCancel prop from ConfirmationSection call site - Fix Appointments.test.tsx missing confirmationStatus field Co-Authored-By: Claude Opus 4.6 --- apps/api/src/__tests__/portal.test.ts | 174 ++++++++++++++++++ apps/web/src/__tests__/Appointments.test.tsx | 1 + apps/web/src/portal/sections/Appointments.tsx | 14 +- 3 files changed, 184 insertions(+), 5 deletions(-) diff --git a/apps/api/src/__tests__/portal.test.ts b/apps/api/src/__tests__/portal.test.ts index 907d879..73f05ff 100644 --- a/apps/api/src/__tests__/portal.test.ts +++ b/apps/api/src/__tests__/portal.test.ts @@ -31,6 +31,10 @@ const APPOINTMENT = { endTime: futureDate(), customerNotes: null, confirmationToken: "secret-token-leak-test", + status: "scheduled" as const, + confirmationStatus: "pending" as const, + confirmedAt: null, + cancelledAt: null, }; let selectSessionRow: Record | null = null; @@ -246,4 +250,174 @@ describe("PATCH /portal/appointments/:id/notes", () => { ); expect(res.status).toBe(400); }); +}); + +// ─── POST /portal/appointments/:id/confirm ──────────────────────────────────── + +function jsonPost(path: string, headers?: Record) { + return app.request(path, { + method: "POST", + headers, + }); +} + +describe("POST /portal/appointments/:id/confirm", () => { + it("confirms a pending appointment and returns updated status", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.confirmationStatus).toBe("confirmed"); + expect(body).toHaveProperty("confirmedAt"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/confirm`); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when appointment belongs to a different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + }); + + it("returns 422 when appointment is in the past", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when appointment is not pending confirmation", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "confirmed" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when cancelling an already-cancelled appointment", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 404 when appointment not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = null; + const res = await jsonPost( + `/portal/appointments/nonexistent-id/confirm`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); +}); + +// ─── POST /portal/appointments/:id/cancel ───────────────────────────────────── + +describe("POST /portal/appointments/:id/cancel", () => { + it("cancels a pending appointment and returns updated status", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("cancelled"); + expect(body.confirmationStatus).toBe("cancelled"); + expect(body).toHaveProperty("cancelledAt"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/cancel`); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when appointment belongs to a different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + }); + + it("returns 422 when appointment is in the past", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when appointment is already cancelled", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 422 when appointment is already completed", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, status: "completed" }; + const res = await jsonPost( + `/portal/appointments/${APPOINTMENT_ID}/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + }); + + it("returns 404 when appointment not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = null; + const res = await jsonPost( + `/portal/appointments/nonexistent-id/cancel`, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); }); \ No newline at end of file diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx index ade71a7..e2365ab 100644 --- a/apps/web/src/__tests__/Appointments.test.tsx +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -18,6 +18,7 @@ const UPCOMING_APPT: Appointment = { status: "confirmed", notes: "", customerNotes: "", + confirmationStatus: "pending", }; const PAST_APPT: Appointment = { diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index ba27732..04c2bc1 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -172,7 +172,7 @@ function AppointmentCard({ )} {isUpcoming(appt) && ( - {}} /> + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
@@ -199,6 +199,8 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm const [confirming, setConfirming] = useState(false); const [confirmError, setConfirmError] = useState(null); const [confirmSuccess, setConfirmSuccess] = useState(false); + // Local state mirrors confirmationStatus so the badge updates immediately after confirm + const [localStatus, setLocalStatus] = useState(appt.confirmationStatus); async function handleConfirm() { if (!window.confirm("Confirm this appointment?")) return; @@ -217,6 +219,7 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm const err = await res.json().catch(() => ({ error: "Failed to confirm" })); throw new Error(err.error || `HTTP ${res.status}`); } + setLocalStatus("confirmed"); setConfirmSuccess(true); setTimeout(() => setConfirmSuccess(false), 2000); } catch (e) { @@ -226,9 +229,10 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm } } - const statusLabel = appt.confirmationStatus === "confirmed" + const currentStatus = localStatus ?? appt.confirmationStatus; + const statusLabel = currentStatus === "confirmed" ? "✓ Confirmed" - : appt.confirmationStatus === "pending" + : currentStatus === "pending" ? "Pending confirmation" : "Cancelled"; @@ -236,11 +240,11 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm
- + {statusLabel}
- {!confirmSuccess && appt.confirmationStatus === "pending" && ( + {!confirmSuccess && currentStatus === "pending" && (