From 44a6db3542d0bf6bd9444d035760f0ab5ee6adc0 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 21:58:31 +0000 Subject: [PATCH 1/6] feat: add customer-facing appointment notes (GRO-106) - Migration 0014: add customer_notes column to appointments - Schema update: add customerNotes field to appointments table - Factory update: include customerNotes in buildAppointment - Portal route: PATCH /api/portal/appointments/:id/notes - Ownership validation via impersonation session - Future-only validation (no edits after start) - 500 character limit - Register portal router in index.ts Co-Authored-By: Paperclip --- apps/api/src/index.ts | 2 + apps/api/src/routes/portal.ts | 69 +++++++++++++++++++ .../db/migrations/0014_customer_notes.sql | 3 + packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 2 + 5 files changed, 77 insertions(+) create mode 100644 apps/api/src/routes/portal.ts create mode 100644 packages/db/migrations/0014_customer_notes.sql diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 79a6cd6..c940e0d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; +import { portalRouter } from "./routes/portal.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; import { bookRouter } from "./routes/book.js"; @@ -107,6 +108,7 @@ api.route("/clients", clientsRouter); api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); +api.route("/portal", portalRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts new file mode 100644 index 0000000..792d62a --- /dev/null +++ b/apps/api/src/routes/portal.ts @@ -0,0 +1,69 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { and, eq, getDb, appointments, impersonationSessions } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const portalRouter = new Hono(); + +const customerNotesSchema = z.object({ + customerNotes: z.string().max(500), +}); + +portalRouter.patch( + "/appointments/:id/notes", + zValidator("json", customerNotesSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + 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 authClientId = session.clientId; + + 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 !== authClientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot edit notes for past or in-progress appointments" }, 422); + } + + const [updated] = await db + .update(appointments) + .set({ customerNotes: body.customerNotes, updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + return c.json(updated); + } +); diff --git a/packages/db/migrations/0014_customer_notes.sql b/packages/db/migrations/0014_customer_notes.sql new file mode 100644 index 0000000..9599808 --- /dev/null +++ b/packages/db/migrations/0014_customer_notes.sql @@ -0,0 +1,3 @@ +ALTER TABLE appointments ADD COLUMN customer_notes TEXT; + +CREATE INDEX idx_appointments_customer_notes ON appointments (client_id) WHERE customer_notes IS NOT NULL; diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index 5cc6698..7e4d735 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -140,6 +140,7 @@ export function buildAppointment( confirmedAt: null, cancelledAt: null, confirmationToken: null, + customerNotes: 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 35c2111..b719b92 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -169,6 +169,8 @@ export const appointments = pgTable("appointments", { cancelledAt: timestamp("cancelled_at"), // Token for tokenized email confirm/cancel links (no auth required) confirmationToken: text("confirmation_token").unique(), + // Customer-provided note visible to groomer (500 char max, editable until appointment starts) + customerNotes: text("customer_notes"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); -- 2.52.0 From 541e83b937e8719b61081e24086c15bc6647e5a0 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 22:37:24 +0000 Subject: [PATCH 2/6] Fix confirmationToken leak and add unit tests for portal notes endpoint - Return only id, customerNotes, updatedAt instead of full appointment row - Add comprehensive unit tests covering auth, ownership, time-gating, and validation - Fix: confirmationToken no longer returned to portal session Co-Authored-By: Paperclip --- apps/api/src/__tests__/portal.test.ts | 257 ++++++++++++++++++++++++++ apps/api/src/routes/portal.ts | 6 +- 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/__tests__/portal.test.ts diff --git a/apps/api/src/__tests__/portal.test.ts b/apps/api/src/__tests__/portal.test.ts new file mode 100644 index 0000000..3fed8ee --- /dev/null +++ b/apps/api/src/__tests__/portal.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); +const pastDate = () => new Date(Date.now() - 5 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: futureDate(), + createdAt: new Date(), +}; + +const EXPIRED_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: pastDate(), + createdAt: new Date(), +}; + +const ENDED_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "ended" as const, + expiresAt: futureDate(), + createdAt: new Date(), +}; + +const APPOINTMENT = { + id: APPOINTMENT_ID, + clientId: CLIENT_ID, + startTime: futureDate(), + endTime: futureDate(), + customerNotes: null, + confirmationToken: "secret-token-leak-test", +}; + +let selectSessionRow: Record | null = null; +let selectAppointmentRow: Record | null = null; +let updatedValues: Record[] = []; + +function resetMock() { + selectSessionRow = null; + selectAppointmentRow = null; + updatedValues = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const appointments = new Proxy( + { _name: "appointments" }, + { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "appointments") { + return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + if (selectAppointmentRow) { + const updated = { ...selectAppointmentRow, ...vals }; + updatedValues.push(vals); + return [updated]; + } + return []; + }, + }), + }), + }), + }), + impersonationSessions, + appointments, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function jsonPatch(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => resetMock()); + +describe("PATCH /portal/appointments/:id/notes", () => { + it("returns updated appointment with safe fields only", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Please be gentle with Fido" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("id"); + expect(body).toHaveProperty("customerNotes", "Please be gentle with Fido"); + expect(body).toHaveProperty("updatedAt"); + expect(body).not.toHaveProperty("confirmationToken"); + expect(body).not.toHaveProperty("clientId"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 with ended session", async () => { + selectSessionRow = null; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 403 when appointment belongs to different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectAppointmentRow = { ...APPOINTMENT }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe("Forbidden"); + }); + + it("returns 422 for past appointment", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/past|in-progress|cannot edit/i); + }); + + it("returns 422 when appointment is in progress", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, startTime: new Date(Date.now() - 2 * 60 * 1000) }; + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: "Test note" }, + { "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 jsonPatch( + `/portal/appointments/nonexistent-id/notes`, + { customerNotes: "Test note" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); + + it("accepts notes at exactly 500 characters", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + const longNote = "a".repeat(500); + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: longNote }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.customerNotes).toBe(longNote); + }); + + it("rejects notes exceeding 500 characters", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + const longNote = "a".repeat(501); + const res = await jsonPatch( + `/portal/appointments/${APPOINTMENT_ID}/notes`, + { customerNotes: longNote }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 792d62a..cd0f6ec 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -64,6 +64,10 @@ portalRouter.patch( .where(eq(appointments.id, id)) .returning(); - return c.json(updated); + return c.json({ + id: updated.id, + customerNotes: updated.customerNotes, + updatedAt: updated.updatedAt, + }); } ); -- 2.52.0 From e15523681030caefed6f3274610af20a988e2e90 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Wed, 25 Mar 2026 01:32:50 +0000 Subject: [PATCH 3/6] feat: add customer notes UI to portal and staff views (GRO-178) - Add customerNotes field to Appointment type - Add read-only customer notes display in staff appointment detail modal - Add customer notes textarea with save, char counter (500 max), and disabled state - Wire up PATCH /api/portal/appointments/:id/notes in portal UI - Update mockData with customerNotes field Co-Authored-By: Paperclip --- apps/web/src/pages/Appointments.tsx | 1 + apps/web/src/portal/mockData.ts | 12 +++ apps/web/src/portal/sections/Appointments.tsx | 78 ++++++++++++++++++- packages/types/src/index.ts | 1 + 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 505953c..4d64b1b 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -707,6 +707,7 @@ function AppointmentDetail({ ? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}` : "Pending"], ["Notes", appt.notes ?? "—"], + ...(appt.customerNotes ? [["Customer Notes", appt.customerNotes] as [string, string]] : []), ...(appt.seriesId ? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]] : []), diff --git a/apps/web/src/portal/mockData.ts b/apps/web/src/portal/mockData.ts index ed52a0e..190b41a 100644 --- a/apps/web/src/portal/mockData.ts +++ b/apps/web/src/portal/mockData.ts @@ -42,6 +42,7 @@ export interface Appointment { price: number; status: "confirmed" | "pending" | "waitlisted" | "completed" | "cancelled"; notes: string; + customerNotes: string; reportCardId?: string; } @@ -177,18 +178,21 @@ export const UPCOMING_APPOINTMENTS: Appointment[] = [ 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", + 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", + 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: "", + customerNotes: "", }, ]; @@ -198,48 +202,56 @@ export const PAST_APPOINTMENTS: Appointment[] = [ 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", + 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", + 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: "", + 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: "", + 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", + 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: "", + 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: "", + 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: "", + customerNotes: "", }, ]; diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index fdb9281..2b96952 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; -import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat } from "lucide-react"; +import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Search, Repeat, Loader2 } from "lucide-react"; import { UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, PETS, SERVICES, GROOMERS } from "../mockData.js"; import type { Appointment, Pet, Service, Groomer } from "../mockData.js"; +const MAX_CUSTOMER_NOTES = 500; + interface Props { readOnly: boolean; } @@ -11,6 +13,12 @@ function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); } +function isUpcoming(appt: Appointment): boolean { + const now = new Date(); + const apptDate = new Date(`${appt.date}T${appt.time}`); + return apptDate > now && appt.status !== "cancelled" && appt.status !== "completed"; +} + const STATUS_COLORS: Record = { confirmed: "bg-green-100 text-green-700", pending: "bg-amber-100 text-amber-700", @@ -138,8 +146,11 @@ function AppointmentCard({ {appt.notes && (

{appt.notes}

)} + {isUpcoming(appt) && !readOnly && ( + + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && ( -
+
@@ -161,6 +172,69 @@ function AppointmentCard({ ); } +function CustomerNotesSection({ appointment: appt }: { appointment: Appointment }) { + const [notes, setNotes] = useState(appt.customerNotes || ""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + const isDisabled = appt.status === "completed" || appt.status === "cancelled"; + + async function handleSave() { + setSaving(true); + setError(null); + setSaved(false); + try { + const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customerNotes: notes }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to save" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + MAX_CUSTOMER_NOTES ? "text-red-500" : "text-stone-400"}`}> + {notes.length}/{MAX_CUSTOMER_NOTES} + +
+