From d0b4baf5aaf26944fc3fa7176be51867079dd1ef Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:24:21 +0000 Subject: [PATCH] feat: customer-facing appointment notes (GRO-106) (#109) * 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 * 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 * 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 * fix: address QA review feedback - null check and portal route auth - Add null check after db.update().returning() in portal notes endpoint - Move portal router registration before auth middleware so clients can access it - Remove unused ENDED_SESSION variable from test file Co-Authored-By: Paperclip * fix(portal): address QA review - isUpcoming time parsing and session header - Fixed parseTimeTo24Hour to handle 12-hour AM/PM format correctly - Added X-Impersonation-Session-Id header to CustomerNotesSection fetch - Added comprehensive tests for CustomerNotesSection and time parsing - Fixed TypeScript strict null checks for parseTimeTo24Hour Fixes QA review issues: - isUpcoming() now correctly parses 12-hour time format - CustomerNotesSection sends session ID header for auth - Added unit tests for new UI component Co-Authored-By: Paperclip * fix: thread sessionId as prop instead of sessionStorage CustomerNotesSection was reading sessionStorage for the impersonation session ID, but CustomerPortal stores it in React state. Pass sessionId as a prop through AppointmentsSection and AppointmentCard instead. Also update tests to pass sessionId prop and add test for null sessionId case. Co-Authored-By: Paperclip --------- Co-authored-by: Scrubs McBarkley Co-authored-by: Paperclip Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com> --- apps/api/src/__tests__/portal.test.ts | 249 ++++++++++++++++++ apps/api/src/index.ts | 4 + apps/api/src/routes/portal.ts | 77 ++++++ apps/web/src/__tests__/Appointments.test.tsx | 194 ++++++++++++++ apps/web/src/pages/Appointments.tsx | 1 + apps/web/src/portal/CustomerPortal.tsx | 2 +- apps/web/src/portal/mockData.ts | 12 + apps/web/src/portal/sections/Appointments.tsx | 106 +++++++- .../db/migrations/0014_customer_notes.sql | 3 + packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 2 + packages/types/src/index.ts | 1 + 12 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/__tests__/portal.test.ts create mode 100644 apps/api/src/routes/portal.ts create mode 100644 apps/web/src/__tests__/Appointments.test.tsx create mode 100644 packages/db/migrations/0014_customer_notes.sql diff --git a/apps/api/src/__tests__/portal.test.ts b/apps/api/src/__tests__/portal.test.ts new file mode 100644 index 0000000..907d879 --- /dev/null +++ b/apps/api/src/__tests__/portal.test.ts @@ -0,0 +1,249 @@ +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 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/index.ts b/apps/api/src/index.ts index 79a6cd6..8de8e51 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"; @@ -56,6 +57,9 @@ app.get("/api/branding", async (c) => { }); }); +// Portal routes — no staff auth required, uses impersonation session for client auth +app.route("/api/portal", portalRouter); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts new file mode 100644 index 0000000..5c2b2f1 --- /dev/null +++ b/apps/api/src/routes/portal.ts @@ -0,0 +1,77 @@ +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(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + customerNotes: updated.customerNotes, + updatedAt: updated.updatedAt, + }); + } +); diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx new file mode 100644 index 0000000..ade71a7 --- /dev/null +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { Appointment } from "../portal/mockData.js"; +import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection } from "../portal/sections/Appointments.js"; + +const UPCOMING_APPT: Appointment = { + id: "appt-1", + petId: "pet-1", + petName: "Buddy", + groomerId: "groomer-1", + groomerName: "Sarah", + services: ["Bath & Brush"], + addOns: [], + date: "2027-01-01", + time: "10:00 AM", + duration: 60, + price: 50, + status: "confirmed", + notes: "", + customerNotes: "", +}; + +const PAST_APPT: Appointment = { + ...UPCOMING_APPT, + id: "appt-2", + date: "2025-01-01", + time: "10:00 AM", + status: "completed", +}; + +describe("parseTimeTo24Hour", () => { + it("converts AM times correctly", () => { + expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00"); + expect(parseTimeTo24Hour("10:00 AM")).toBe("10:00:00"); + expect(parseTimeTo24Hour("12:00 AM")).toBe("00:00:00"); + }); + + it("converts PM times correctly", () => { + expect(parseTimeTo24Hour("1:00 PM")).toBe("13:00:00"); + expect(parseTimeTo24Hour("2:00 PM")).toBe("14:00:00"); + expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00"); + expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00"); + }); +}); + +describe("isUpcoming", () => { + it("returns true for future confirmed appointments", () => { + expect(isUpcoming(UPCOMING_APPT)).toBe(true); + }); + + it("returns false for past appointments", () => { + expect(isUpcoming(PAST_APPT)).toBe(false); + }); + + it("returns false for cancelled appointments", () => { + expect(isUpcoming({ ...UPCOMING_APPT, status: "cancelled" })).toBe(false); + }); + + it("returns false for completed appointments", () => { + expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false); + }); +}); + +describe("CustomerNotesSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it("renders textarea with existing notes", () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue("Test note"); + }); + + it("renders Save Notes button", () => { + render(); + expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument(); + }); + + it("sends X-Impersonation-Session-Id header when session exists", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/portal/appointments/appt-1/notes", + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Impersonation-Session-Id": "test-session-id", + }), + }) + ); + }); + }); + + it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/portal/appointments/appt-1/notes", + expect.objectContaining({ + headers: expect.not.objectContaining({ + "X-Impersonation-Session-Id": expect.anything(), + }), + }) + ); + }); + }); + + it("shows error message when save fails", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Unauthorized" }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument(); + }); + }); + + it("shows success message when save succeeds", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(screen.getByText(/Saved!/i)).toBeInTheDocument(); + }); + }); + + it("disables button when notes unchanged", () => { + render(); + expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled(); + }); + + it("enforces 500 character limit", () => { + render(); + const textarea = screen.getByRole("textbox"); + const longText = "a".repeat(600); + fireEvent.change(textarea, { target: { value: longText } }); + expect(textarea).toHaveValue("a".repeat(500)); + }); + + it("displays character count", () => { + render(); + expect(screen.getByText(/0\/500/)).toBeInTheDocument(); + }); + + it("shows exceeded character count in red when limit exceeded", () => { + render(); + const textarea = screen.getByRole("textbox"); + // Type characters one by one to exceed limit + const longText = "a".repeat(501); + fireEvent.change(textarea, { target: { value: longText } }); + // The textarea value is truncated to 500, so counter shows 500/500 + // The class check would need to verify text-red-500 appears + // Since the onChange truncates, we test that limit is enforced + expect(textarea).toHaveValue("a".repeat(500)); + }); + + it("does not render save button for completed appointments", () => { + render(); + expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); + }); + + it("does not render save button for cancelled appointments", () => { + render(); + expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); + }); +}); \ No newline at end of file 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/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index b74e297..65a17e3 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -114,7 +114,7 @@ export function CustomerPortal() { case "dashboard": return ; case "appointments": - return ; + return ; case "pets": return ; case "reports": 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..bd38475 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -1,16 +1,38 @@ 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; + sessionId?: string | null; } -function formatDate(dateStr: string): string { +export function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); } +export function parseTimeTo24Hour(time: string): string { + const parts = time.split(" "); + const hoursMinutes = parts[0] ?? ""; + const period = parts[1] ?? ""; + const [hoursStr, minutesStr] = hoursMinutes.split(":"); + const hours = parseInt(hoursStr ?? "0", 10); + const minutes = parseInt(minutesStr ?? "0", 10); + let hours24 = hours; + if (period === "PM" && hours !== 12) hours24 += 12; + if (period === "AM" && hours === 12) hours24 = 0; + return `${hours24.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`; +} + +export function isUpcoming(appt: Appointment): boolean { + const now = new Date(); + const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(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", @@ -19,7 +41,7 @@ const STATUS_COLORS: Record = { cancelled: "bg-red-100 text-red-600", }; -export function AppointmentsSection({ readOnly }: Props) { +export function AppointmentsSection({ readOnly, sessionId }: Props) { const [showBooking, setShowBooking] = useState(false); const [expandedId, setExpandedId] = useState(null); const [tab, setTab] = useState<"upcoming" | "past">("upcoming"); @@ -61,6 +83,7 @@ export function AppointmentsSection({ readOnly }: Props) { expanded={expandedId === appt.id} onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} + sessionId={sessionId} /> ))} {UPCOMING_APPOINTMENTS.length === 0 && ( @@ -78,6 +101,7 @@ export function AppointmentsSection({ readOnly }: Props) { expanded={expandedId === appt.id} onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} + sessionId={sessionId} /> ))} @@ -94,9 +118,9 @@ export function AppointmentsSection({ readOnly }: Props) { } function AppointmentCard({ - appointment: appt, expanded, onToggle, readOnly, + appointment: appt, expanded, onToggle, readOnly, sessionId, }: { - appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; + appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; }) { return (
@@ -138,8 +162,11 @@ function AppointmentCard({ {appt.notes && (

{appt.notes}

)} + {isUpcoming(appt) && !readOnly && ( + + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && ( -
+
@@ -161,6 +188,73 @@ function AppointmentCard({ ); } +export function CustomerNotesSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { + 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 headers: Record = { "Content-Type": "application/json" }; + if (sessionId) { + headers["X-Impersonation-Session-Id"] = sessionId; + } + const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { + method: "PATCH", + headers, + 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} + +
+