From 3e2e19a31240c3ba4dfd0ed722401e7544ba5204 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 21:58:31 +0000 Subject: [PATCH 1/9] 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 3bc920f36b35debd30fa1899847c88a48e98f1e5 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 22:02:11 +0000 Subject: [PATCH 2/9] feat: add waitlist entries table and API routes (GRO-105) - Migration 0015: new waitlist_entries table with indexes - Schema update: add waitlistEntries table and waitlistStatusEnum - Staff API: GET /api/waitlist, GET /api/waitlist/:id - Portal API: POST /api/waitlist (via impersonation session), DELETE /api/waitlist/:id - Note: cancellation hook and email notification pending Co-Authored-By: Paperclip --- apps/api/src/index.ts | 2 + apps/api/src/routes/waitlist.ts | 198 +++++++++++++++++++++++ packages/db/migrations/0015_waitlist.sql | 18 +++ packages/db/src/schema.ts | 35 ++++ 4 files changed, 253 insertions(+) create mode 100644 apps/api/src/routes/waitlist.ts create mode 100644 packages/db/migrations/0015_waitlist.sql diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c940e0d..e53c365 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 { waitlistRouter } from "./routes/waitlist.js"; import { portalRouter } from "./routes/portal.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; @@ -109,6 +110,7 @@ api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); api.route("/portal", portalRouter); +api.route("/waitlist", waitlistRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts new file mode 100644 index 0000000..abb7697 --- /dev/null +++ b/apps/api/src/routes/waitlist.ts @@ -0,0 +1,198 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { + and, + eq, + getDb, + waitlistEntries, + clients, + pets, + services, + impersonationSessions, +} from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const waitlistRouter = new Hono(); + +const waitlistStatusEnum = z.enum(["active", "notified", "expired", "cancelled"]); + +const createWaitlistEntrySchema = z.object({ + petId: z.string().uuid(), + serviceId: z.string().uuid(), + preferredDate: z.string(), + preferredTime: z.string(), +}); + +const updateWaitlistEntrySchema = z.object({ + status: waitlistStatusEnum.optional(), + preferredDate: z.string().optional(), + preferredTime: z.string().optional(), +}); + +waitlistRouter.get("/", async (c) => { + const db = getDb(); + const date = c.req.query("date"); + + const conditions = []; + if (date) { + conditions.push(eq(waitlistEntries.preferredDate, date)); + } + + const rows = + conditions.length > 0 + ? await db + .select() + .from(waitlistEntries) + .where(and(...conditions)) + .orderBy(waitlistEntries.createdAt) + : await db + .select() + .from(waitlistEntries) + .orderBy(waitlistEntries.createdAt); + + const enriched = await Promise.all( + rows.map(async (entry) => { + const [client] = await db + .select({ name: clients.name, email: clients.email }) + .from(clients) + .where(eq(clients.id, entry.clientId)) + .limit(1); + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, entry.petId)) + .limit(1); + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, entry.serviceId)) + .limit(1); + return { + ...entry, + clientName: client?.name ?? null, + clientEmail: client?.email ?? null, + petName: pet?.name ?? null, + serviceName: service?.name ?? null, + }; + }) + ); + + return c.json(enriched); +}); + +waitlistRouter.get("/:id", async (c) => { + const db = getDb(); + const [row] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, c.req.param("id"))) + .limit(1); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json(row); +}); + +waitlistRouter.post( + "/", + zValidator("json", createWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + let clientId: string | null = null; + if (sessionId) { + 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()) { + clientId = session.clientId; + } + } + + if (!clientId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: body.preferredTime, + }) + .returning(); + + return c.json(entry, 201); + } +); + +waitlistRouter.patch( + "/:id", + zValidator("json", updateWaitlistEntrySchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const updateData: Record = { updatedAt: new Date() }; + if (body.status !== undefined) updateData.status = body.status; + if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; + if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; + + const [updated] = await db + .update(waitlistEntries) + .set(updateData) + .where(eq(waitlistEntries.id, id)) + .returning(); + + if (!updated) return c.json({ error: "Not found" }, 404); + return c.json(updated); + } +); + +waitlistRouter.delete("/:id", async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const sessionId = c.req.header("X-Impersonation-Session-Id"); + + if (sessionId) { + 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()) { + const [entry] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + if (entry && entry.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + } + } + + const [deleted] = await db + .delete(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .returning(); + + if (!deleted) return c.json({ error: "Not found" }, 404); + return c.json({ ok: true }); +}); diff --git a/packages/db/migrations/0015_waitlist.sql b/packages/db/migrations/0015_waitlist.sql new file mode 100644 index 0000000..fcbc6a7 --- /dev/null +++ b/packages/db/migrations/0015_waitlist.sql @@ -0,0 +1,18 @@ +CREATE TABLE waitlist_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE, + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + preferred_date DATE NOT NULL, + preferred_time TIME NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + notified_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_waitlist_client_id ON waitlist_entries (client_id); +CREATE INDEX idx_waitlist_preferred_date ON waitlist_entries (preferred_date); +CREATE INDEX idx_waitlist_status ON waitlist_entries (status) WHERE status = 'active'; +CREATE UNIQUE INDEX idx_waitlist_active_unique ON waitlist_entries (client_id, pet_id, service_id, preferred_date, preferred_time) WHERE status = 'active'; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b719b92..676602b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -312,3 +312,38 @@ export const groomingVisitLogs = pgTable("grooming_visit_logs", { groomedAt: timestamp("groomed_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(), }); + +export const waitlistStatusEnum = pgEnum("waitlist_status", [ + "active", + "notified", + "expired", + "cancelled", +]); + +export const waitlistEntries = pgTable( + "waitlist_entries", + { + id: uuid("id").primaryKey().defaultRandom(), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + petId: uuid("pet_id") + .notNull() + .references(() => pets.id, { onDelete: "cascade" }), + serviceId: uuid("service_id") + .notNull() + .references(() => services.id, { onDelete: "cascade" }), + preferredDate: text("preferred_date").notNull(), + preferredTime: text("preferred_time").notNull(), + status: waitlistStatusEnum("status").notNull().default("active"), + notifiedAt: timestamp("notified_at"), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + index("idx_waitlist_client_id").on(t.clientId), + index("idx_waitlist_preferred_date").on(t.preferredDate), + index("idx_waitlist_status").on(t.status), + ] +); -- 2.52.0 From c8884c6c30cb3da84acfa6f5ac425f368df27173 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 22:19:16 +0000 Subject: [PATCH 3/9] fix(waitlist): address QA review comments - auth fixes and pgEnum type - Add 401 when DELETE /waitlist/:id has no session (auth bypass fix) - Add auth to PATCH /waitlist/:id (was zero auth) - Add RBAC guard for /waitlist/* routes - Fix migration to use proper ENUM type instead of TEXT - Add unit tests for auth scenarios Co-Authored-By: Paperclip --- apps/api/src/__tests__/waitlist.test.ts | 276 +++++++++++++++++++++++ apps/api/src/index.ts | 7 +- apps/api/src/routes/waitlist.ts | 84 +++++-- packages/db/migrations/0015_waitlist.sql | 4 +- 4 files changed, 345 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/__tests__/waitlist.test.ts diff --git a/apps/api/src/__tests__/waitlist.test.ts b/apps/api/src/__tests__/waitlist.test.ts new file mode 100644 index 0000000..444b69d --- /dev/null +++ b/apps/api/src/__tests__/waitlist.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const VALID_UUID_1 = "550e8400-e29b-41d4-a716-446655440001"; +const VALID_UUID_2 = "550e8400-e29b-41d4-a716-446655440002"; +const VALID_UUID_3 = "550e8400-e29b-41d4-a716-446655440003"; +const VALID_UUID_4 = "550e8400-e29b-41d4-a716-446655440004"; +const VALID_UUID_5 = "550e8400-e29b-41d4-a716-446655440005"; + +const WAITLIST_ENTRY = { + id: VALID_UUID_1, + clientId: VALID_UUID_2, + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + status: "active", + notifiedAt: null, + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const ACTIVE_SESSION = { + id: VALID_UUID_5, + clientId: VALID_UUID_2, + status: "active" as const, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + createdAt: new Date(), +}; + +const EXPIRED_SESSION = { + id: "660e8400-e29b-41d4-a716-446655440006", + clientId: VALID_UUID_2, + status: "active" as const, + expiresAt: new Date(Date.now() - 60 * 60 * 1000), + createdAt: new Date(), +}; + +let selectRows: Record[] = []; +let selectSessionRow: Record | null = null; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; + +function resetMock() { + selectRows = []; + selectSessionRow = null; + insertedValues = []; + 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 waitlistEntries = new Proxy( + { _name: "waitlistEntries" }, + { get: (t, p) => (p === "_name" ? "waitlistEntries" : { table: "waitlistEntries", column: p }) } + ); + + const impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const pets = new Proxy( + { _name: "pets" }, + { get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) } + ); + + const services = new Proxy( + { _name: "services" }, + { get: (t, p) => (p === "_name" ? "services" : { table: "services", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "waitlistEntries") { + return makeChainable(selectRows); + } + return makeChainable([]); + }, + }), + insert: () => ({ + values: (vals: Record) => { + insertedValues.push(vals); + return { + returning: () => [{ ...WAITLIST_ENTRY, ...vals, id: "waitlist-uuid-new" }], + }; + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { + returning: () => + selectRows.length > 0 + ? [{ ...selectRows[0], ...vals }] + : [], + }; + }, + }), + }), + delete: () => ({ + where: () => { + return { + returning: () => + selectRows.length > 0 ? [selectRows[0]] : [], + }; + }, + }), + }), + waitlistEntries, + impersonationSessions, + clients, + pets, + services, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +const { waitlistRouter } = await import("../routes/waitlist.js"); + +const app = new Hono(); +app.route("/waitlist", waitlistRouter); + +function jsonRequest(method: string, path: string, body?: unknown, headers?: Record) { + return app.request(path, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetMock()); + +describe("POST /waitlist", () => { + it("creates entry with valid session", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.petId).toBe(VALID_UUID_3); + expect(insertedValues).toHaveLength(1); + }); + + it("returns 401 without session", async () => { + const res = await jsonRequest("POST", "/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }); + expect(res.status).toBe(401); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonRequest("POST", "/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": EXPIRED_SESSION.id }); + expect(res.status).toBe(401); + }); +}); + +describe("DELETE /waitlist/:id", () => { + it("deletes entry with valid session and correct owner", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await app.request(`/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); + + it("returns 401 without session", async () => { + const res = await app.request(`/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + }); + expect(res.status).toBe(401); + }); + + it("returns 403 with valid session but wrong owner", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" }; + selectRows = [WAITLIST_ENTRY]; + const res = await app.request(`/waitlist/${VALID_UUID_1}`, { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(403); + }); + + it("returns 404 when entry not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = []; + const res = await app.request("/waitlist/nonexistent", { + method: "DELETE", + headers: { "X-Impersonation-Session-Id": VALID_UUID_5 }, + }); + expect(res.status).toBe(404); + }); +}); + +describe("PATCH /waitlist/:id", () => { + it("updates entry with valid session and correct owner", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/waitlist/${VALID_UUID_1}`, { + status: "notified", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(200); + expect(updatedValues[0]?.status).toBe("notified"); + }); + + it("returns 401 without session", async () => { + const res = await jsonRequest("PATCH", `/waitlist/${VALID_UUID_1}`, { + status: "notified", + }); + expect(res.status).toBe(401); + }); + + it("returns 403 with valid session but wrong owner", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "other-client-uuid" }; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/waitlist/${VALID_UUID_1}`, { + status: "notified", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(403); + }); + + it("returns 404 when entry not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = []; + const res = await jsonRequest("PATCH", "/waitlist/nonexistent", { + status: "notified", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(404); + }); +}); \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e53c365..c745ee7 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"; @@ -58,6 +59,9 @@ 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); @@ -71,9 +75,10 @@ api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager")); api.use("/impersonation/*", requireRole("manager")); -// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs +// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist api.use("/appointment-groups/*", requireRole("manager", "receptionist")); api.use("/grooming-logs/*", requireRole("manager", "receptionist")); +api.use("/waitlist/*", requireRole("manager", "receptionist")); // Pet photo routes: all staff roles may upload/delete (groomers take photos during grooms) // These must be registered before the general pets write guard. Because Hono path params diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts index abb7697..4010d2d 100644 --- a/apps/api/src/routes/waitlist.ts +++ b/apps/api/src/routes/waitlist.ts @@ -143,6 +143,37 @@ waitlistRouter.patch( 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 [existing] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!existing) return c.json({ error: "Not found" }, 404); + if (existing.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } const updateData: Record = { updatedAt: new Date() }; if (body.status !== undefined) updateData.status = body.status; @@ -155,7 +186,6 @@ waitlistRouter.patch( .where(eq(waitlistEntries.id, id)) .returning(); - if (!updated) return c.json({ error: "Not found" }, 404); return c.json(updated); } ); @@ -165,34 +195,40 @@ waitlistRouter.delete("/:id", async (c) => { const id = c.req.param("id"); const sessionId = c.req.header("X-Impersonation-Session-Id"); - if (sessionId) { - 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()) { - const [entry] = await db - .select() - .from(waitlistEntries) - .where(eq(waitlistEntries.id, id)) - .limit(1); - if (entry && entry.clientId !== session.clientId) { - return c.json({ error: "Forbidden" }, 403); - } - } + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); } - const [deleted] = await db + 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 [entry] = await db + .select() + .from(waitlistEntries) + .where(eq(waitlistEntries.id, id)) + .limit(1); + + if (!entry) return c.json({ error: "Not found" }, 404); + if (entry.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + await db .delete(waitlistEntries) .where(eq(waitlistEntries.id, id)) .returning(); - if (!deleted) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); }); diff --git a/packages/db/migrations/0015_waitlist.sql b/packages/db/migrations/0015_waitlist.sql index fcbc6a7..d99ed8a 100644 --- a/packages/db/migrations/0015_waitlist.sql +++ b/packages/db/migrations/0015_waitlist.sql @@ -1,3 +1,5 @@ +CREATE TYPE waitlist_status AS ENUM ('active', 'notified', 'expired', 'cancelled'); + CREATE TABLE waitlist_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE, @@ -5,7 +7,7 @@ CREATE TABLE waitlist_entries ( service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, preferred_date DATE NOT NULL, preferred_time TIME NOT NULL, - status TEXT NOT NULL DEFAULT 'active', + status waitlist_status NOT NULL DEFAULT 'active', notified_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 2.52.0 From 5b1a8858f3bf65bf7b0c4dd0d01baa21180160de Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Tue, 24 Mar 2026 22:26:10 +0000 Subject: [PATCH 4/9] feat: implement iCal calendar feed (GRO-107) - Add icalToken column to staff table - Add public calendar feed endpoint GET /api/calendar/:staffId.ics - Add token management routes POST/DELETE /api/staff/:id/ical-token - Hand-build iCal output (RFC 5545) - Update staff factory with icalToken field Co-Authored-By: Paperclip --- apps/api/src/routes/calendar.ts | 135 +++++++++++++++++++++ apps/api/src/routes/staff.ts | 57 ++++++++- packages/db/migrations/0016_ical_token.sql | 1 + packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 2 + 5 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/calendar.ts create mode 100644 packages/db/migrations/0016_ical_token.sql diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts new file mode 100644 index 0000000..b9e985d --- /dev/null +++ b/apps/api/src/routes/calendar.ts @@ -0,0 +1,135 @@ +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 +): 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 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:${formatIcalDate(new Date())}`, + `DTSTART:${formatIcalDate(new Date(appt.startTime))}`, + `DTEND:${formatIcalDate(new Date(appt.endTime))}`, + `SUMMARY:${escapeIcalText(summary)}`, + `DESCRIPTION:${escapeIcalText(description)}`, + `STATUS:${status}`, + "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.json({ error: "Missing token parameter" }, 401); + } + + const [staffMember] = await db + .select() + .from(staff) + .where(eq(staff.id, staffId)) + .limit(1); + + if (!staffMember || staffMember.icalToken !== token) { + return c.json({ error: "Invalid token" }, 401); + } + + const now = new Date(); + const rows = await db + .select() + .from(appointments) + .where( + and( + eq(appointments.staffId, staffId), + gte(appointments.startTime, now) + ) + ) + .orderBy(appointments.startTime); + + const enriched = await Promise.all( + rows.map(async (appt) => { + const [client] = await db + .select({ name: clients.name }) + .from(clients) + .where(eq(clients.id, appt.clientId)) + .limit(1); + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, appt.petId)) + .limit(1); + const [service] = await db + .select({ name: services.name }) + .from(services) + .where(eq(services.id, appt.serviceId)) + .limit(1); + return { + ...appt, + clientName: client?.name ?? null, + petName: pet?.name ?? null, + serviceName: service?.name ?? null, + }; + }) + ); + + const ical = buildIcalFeed(enriched, staffMember.name); + return c.text(ical, 200, { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": `attachment; filename="${staffMember.name.replace(/\s+/g, "_")}_calendar.ics"`, + }); +}); + +export function generateIcalToken(): string { + return randomBytes(32).toString("hex"); +} 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/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 c5977ef573e9f6ee77700dc925872bb08a6f788b Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Wed, 25 Mar 2026 06:59:44 +0000 Subject: [PATCH 5/9] feat(gro-107): add CalendarSync UI component and calendar unit tests - Add CalendarSync component with generate/revoke/copy functionality - Add unit tests for generateIcalToken function - Fix StaffRow type in petPhotos.test.ts and rbac.test.ts to include icalToken The CalendarSync component can be added to a staff profile/settings page. Currently the Staff page (admin/staff) does not have a profile section for individual staff - integration will need a new route or profile section. Co-Authored-By: Paperclip --- 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/web/src/components/CalendarSync.tsx | 173 +++++++++++++++++++++++ opencode.json | 7 + 5 files changed, 198 insertions(+) create mode 100644 apps/api/src/__tests__/calendar.test.ts create mode 100644 apps/web/src/components/CalendarSync.tsx create mode 100644 opencode.json 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/web/src/components/CalendarSync.tsx b/apps/web/src/components/CalendarSync.tsx new file mode 100644 index 0000000..887d05e --- /dev/null +++ b/apps/web/src/components/CalendarSync.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from "react"; +import { Calendar, RefreshCw, Trash2, Copy, Check } from "lucide-react"; + +interface Props { + staffId: string; + staffName: string; +} + +export function CalendarSyncSection({ staffId, staffName }: 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); + + 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 (!confirm("Revoke your calendar feed link? Anyone with the current link will lose access.")) { + 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); + } + } + + 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 ? ( +
+
+ +
+ + +
+
+ +
+ + +
+ +

+ 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 + } +} -- 2.52.0 From f147dc3f891393535962d41c232ab691aac210ee Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Wed, 25 Mar 2026 11:04:08 +0000 Subject: [PATCH 6/9] fix: prefix unused staffName with underscore to satisfy ESLint Co-Authored-By: Paperclip --- apps/web/src/components/CalendarSync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CalendarSync.tsx b/apps/web/src/components/CalendarSync.tsx index 887d05e..795d1c2 100644 --- a/apps/web/src/components/CalendarSync.tsx +++ b/apps/web/src/components/CalendarSync.tsx @@ -6,7 +6,7 @@ interface Props { staffName: string; } -export function CalendarSyncSection({ staffId, staffName }: Props) { +export function CalendarSyncSection({ staffId }: Props) { const [token, setToken] = useState(null); const [loading, setLoading] = useState(false); const [actionLoading, setActionLoading] = useState<"generate" | "revoke" | null>(null); -- 2.52.0 From 8ca120d5219d33ce425324a5ddcdb8311dc1ea79 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 26 Mar 2026 04:07:35 +0000 Subject: [PATCH 7/9] fix(calendar): address CTO review - N+1, Content-Disposition, SEQUENCE, sensitive leak - Replace N+1 queries with single INNER JOIN across clients, pets, services - Change Content-Disposition from attachment to inline for calendar auto-sync - Add SEQUENCE:0 for confirmed, SEQUENCE:1 for cancelled events (RFC 5546) - Fix sensitive field leak: return only {id, customerNotes, updatedAt} - Add missing null-check guard after .returning() in portal.ts Co-Authored-By: Claude Opus 4.6 --- apps/api/src/routes/calendar.ts | 48 +++++++++++++-------------------- apps/api/src/routes/portal.ts | 13 +++++++-- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index b9e985d..62b569c 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -46,6 +46,7 @@ function buildIcalFeed( 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"}`; @@ -58,6 +59,7 @@ function buildIcalFeed( `SUMMARY:${escapeIcalText(summary)}`, `DESCRIPTION:${escapeIcalText(description)}`, `STATUS:${status}`, + `SEQUENCE:${sequence}`, "END:VEVENT" ); } @@ -87,8 +89,22 @@ calendarRouter.get("/:staffId.ics", async (c) => { const now = new Date(); const rows = await db - .select() + .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), @@ -97,36 +113,10 @@ calendarRouter.get("/:staffId.ics", async (c) => { ) .orderBy(appointments.startTime); - const enriched = await Promise.all( - rows.map(async (appt) => { - const [client] = await db - .select({ name: clients.name }) - .from(clients) - .where(eq(clients.id, appt.clientId)) - .limit(1); - const [pet] = await db - .select({ name: pets.name }) - .from(pets) - .where(eq(pets.id, appt.petId)) - .limit(1); - const [service] = await db - .select({ name: services.name }) - .from(services) - .where(eq(services.id, appt.serviceId)) - .limit(1); - return { - ...appt, - clientName: client?.name ?? null, - petName: pet?.name ?? null, - serviceName: service?.name ?? null, - }; - }) - ); - - const ical = buildIcalFeed(enriched, staffMember.name); + const ical = buildIcalFeed(rows, staffMember.name); return c.text(ical, 200, { "Content-Type": "text/calendar; charset=utf-8", - "Content-Disposition": `attachment; filename="${staffMember.name.replace(/\s+/g, "_")}_calendar.ics"`, + "Content-Disposition": `inline; filename="${staffMember.name.replace(/\s+/g, "_")}_calendar.ics"`, }); }); diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 792d62a..3d123d0 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( @@ -64,6 +65,14 @@ portalRouter.patch( .where(eq(appointments.id, id)) .returning(); - return c.json(updated); + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + customerNotes: updated.customerNotes, + updatedAt: updated.updatedAt, + }); } ); -- 2.52.0 From 1e84823656802c6649bf8d1e9cc4cb5bea648679 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 26 Mar 2026 04:10:40 +0000 Subject: [PATCH 8/9] fix(calendar): address CTO review follow-up items - Capture DTSTAMP once before loop instead of new Date() per event - Return plain text 401 for auth errors (calendar clients can't parse JSON) - Use encodeURIComponent for Content-Disposition filename Co-Authored-By: Paperclip --- apps/api/src/routes/calendar.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index 62b569c..a85568f 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -33,7 +33,8 @@ function buildIcalFeed( petName: string | null; serviceName: string | null; }>, - staffName: string + staffName: string, + dtstamp: string ): string { const lines: string[] = [ "BEGIN:VCALENDAR", @@ -53,7 +54,7 @@ function buildIcalFeed( lines.push( "BEGIN:VEVENT", `UID:${appt.id}@groombook`, - `DTSTAMP:${formatIcalDate(new Date())}`, + `DTSTAMP:${dtstamp}`, `DTSTART:${formatIcalDate(new Date(appt.startTime))}`, `DTEND:${formatIcalDate(new Date(appt.endTime))}`, `SUMMARY:${escapeIcalText(summary)}`, @@ -74,7 +75,7 @@ calendarRouter.get("/:staffId.ics", async (c) => { const token = c.req.query("token") as string; if (!token) { - return c.json({ error: "Missing token parameter" }, 401); + return c.text("Unauthorized", 401); } const [staffMember] = await db @@ -84,7 +85,7 @@ calendarRouter.get("/:staffId.ics", async (c) => { .limit(1); if (!staffMember || staffMember.icalToken !== token) { - return c.json({ error: "Invalid token" }, 401); + return c.text("Unauthorized", 401); } const now = new Date(); @@ -113,10 +114,10 @@ calendarRouter.get("/:staffId.ics", async (c) => { ) .orderBy(appointments.startTime); - const ical = buildIcalFeed(rows, staffMember.name); + 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="${staffMember.name.replace(/\s+/g, "_")}_calendar.ics"`, + "Content-Disposition": `inline; filename="${encodeURIComponent(staffMember.name)}_calendar.ics"`, }); }); -- 2.52.0 From 21b7eff326c8144d43f01da188b1180bc51c4eb6 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 26 Mar 2026 08:18:54 +0000 Subject: [PATCH 9/9] fix(CalendarSync): replace window.confirm() with inline confirmation and rename file - Replace blocking window.confirm() with showRevokeConfirm state + inline confirmation dialog in CalendarSyncSection - Rename CalendarSync.tsx to CalendarSyncSection.tsx to match export convention Co-Authored-By: Paperclip --- ...lendarSync.tsx => CalendarSyncSection.tsx} | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) rename apps/web/src/components/{CalendarSync.tsx => CalendarSyncSection.tsx} (66%) diff --git a/apps/web/src/components/CalendarSync.tsx b/apps/web/src/components/CalendarSyncSection.tsx similarity index 66% rename from apps/web/src/components/CalendarSync.tsx rename to apps/web/src/components/CalendarSyncSection.tsx index 795d1c2..abf66fa 100644 --- a/apps/web/src/components/CalendarSync.tsx +++ b/apps/web/src/components/CalendarSyncSection.tsx @@ -12,6 +12,7 @@ export function CalendarSyncSection({ staffId }: Props) { 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(); @@ -51,7 +52,8 @@ export function CalendarSyncSection({ staffId }: Props) { } async function revokeToken() { - if (!confirm("Revoke your calendar feed link? Anyone with the current link will lose access.")) { + if (!showRevokeConfirm) { + setShowRevokeConfirm(true); return; } setActionLoading("revoke"); @@ -67,6 +69,7 @@ export function CalendarSyncSection({ staffId }: Props) { setError(e instanceof Error ? e.message : "Failed to revoke token"); } finally { setActionLoading(null); + setShowRevokeConfirm(false); } } @@ -120,32 +123,59 @@ export function CalendarSyncSection({ staffId }: Props) { -
- - -
+ {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. -- 2.52.0