From fe412933ead4e10d01b21a9ec0feed10abd845ef Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:17:42 +0000 Subject: [PATCH 1/7] GRO-2294: Route Optimization security hardening (geocode-batch limit cap + redact settings secret) (#193) --- .mcp.json | 11 +++ UAT_PLAYBOOK.md | 5 +- src/__tests__/geocodeBatchLimit.test.ts | 89 ++++++++++++++++++++++++ src/__tests__/settings.test.ts | 91 +++++++++++++++++++++++++ src/routes/clients.ts | 11 ++- src/routes/settings.ts | 16 ++++- trigger-uat-1779751324.txt | 0 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 .mcp.json create mode 100644 src/__tests__/geocodeBatchLimit.test.ts create mode 100644 src/__tests__/settings.test.ts create mode 100644 trigger-uat-1779751324.txt diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6efc1ca --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "gitea": { + "type": "http", + "url": "https://git-mcp.farh.net/mcp", + "headers": { + "Authorization": "Bearer ${GITEA_TOKEN}" + } + } + } +} diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index cf0a541..48082de 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -133,6 +133,7 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | TC-API-2.11 | Geocode endpoint is manager-only | As **groomer** or **receptionist**, `POST /api/clients/{id}/geocode` | 403 Forbidden (role not permitted) | | TC-API-2.12 | Batch geocode un-geocoded clients | As manager, `POST /api/clients/geocode-batch?limit=10` on a DB with un-geocoded clients | 200 OK; body `{ provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }`. `processed` ≤ 10; `remaining` reflects un-geocoded clients beyond this batch. Re-run while `remaining > 0` to finish (throttled to provider rate limit) | | TC-API-2.13 | Batch geocode — invalid limit | As manager, `POST /api/clients/geocode-batch?limit=0` (or non-numeric) | 400 `{ error: "limit must be a positive integer" }` | +| TC-API-2.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500** — `processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit | | TC-API-2.14 | Batch geocode — manager-only | As groomer/receptionist, `POST /api/clients/geocode-batch` | 403 Forbidden | | TC-API-2.15 | Auto-geocode on create | As manager/receptionist, `POST /api/clients` with a valid `address` | 201 Created; response includes a `geocoding` object (`status: "geocoded"` for a resolvable address) and the persisted client carries `latitude`/`longitude`/`geocodedAt`. Creating without an address succeeds with no `geocoding` field | | TC-API-2.16 | Auto-geocode on address update | As manager/receptionist, `PATCH /api/clients/{id}` changing `address` to a new valid value | 200 OK; response includes a `geocoding` object and refreshed coordinates. Patching unrelated fields (e.g. `name`) does NOT re-geocode (no `geocoding` field) | @@ -165,6 +166,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode | TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) | | TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) | | TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) | +| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) | +| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note | | TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) | | TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) | | TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` | @@ -329,7 +332,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| -| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned | +| TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | | TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | diff --git a/src/__tests__/geocodeBatchLimit.test.ts b/src/__tests__/geocodeBatchLimit.test.ts new file mode 100644 index 0000000..8731c02 --- /dev/null +++ b/src/__tests__/geocodeBatchLimit.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the +// documented maximum (500) before invoking the geocoding service. We mock the +// service to capture the exact limit the route forwards. + +const geocodeUngeocodedClients = vi.fn(async () => ({ + totalRemaining: 0, + processed: 0, + geocoded: 0, + failed: 0, + remaining: 0, +})); + +vi.mock("../services/clientGeocoding.js", () => ({ + geocodeUngeocodedClients, + geocodeClient: vi.fn(), + resolveClientGeocodingProvider: vi.fn(), +})); + +vi.mock("@groombook/db", () => { + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + return { + getDb: () => ({}), + clients: tableProxy("clients"), + appointments: tableProxy("appointments"), + and: vi.fn(), + eq: vi.fn(), + or: vi.fn(), + exists: vi.fn(), + }; +}); + +const { clientsRouter } = await import("../routes/clients.js"); + +const app = new Hono(); +app.route("/clients", clientsRouter); + +function postBatch(query: string) { + return app.request(`/clients/geocode-batch${query}`, { method: "POST" }); +} + +describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => { + beforeEach(() => { + geocodeUngeocodedClients.mockClear(); + }); + + it("defaults to 50 when no ?limit is supplied", async () => { + const res = await postBatch(""); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50); + }); + + it("passes through a value within the cap", async () => { + const res = await postBatch("?limit=120"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120); + }); + + it("clamps an over-cap value to 500", async () => { + const res = await postBatch("?limit=100000"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500); + }); + + it("floors a fractional value before clamping", async () => { + const res = await postBatch("?limit=49.9"); + expect(res.status).toBe(200); + expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49); + }); + + it("rejects a non-positive limit with 400", async () => { + const res = await postBatch("?limit=0"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); + + it("rejects a non-numeric limit with 400", async () => { + const res = await postBatch("?limit=abc"); + expect(res.status).toBe(400); + expect(geocodeUngeocodedClients).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts new file mode 100644 index 0000000..c878999 --- /dev/null +++ b/src/__tests__/settings.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// GRO-2294: GET /api/admin/settings must not return the encrypted +// googleMapsApiKey ciphertext, on either the existing-row or auto-create branch. + +let selectRows: Record[] = []; +let insertReturning: Record[] = []; + +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 passthrough + return target[prop]; + }, + }); + return chain; +} + +vi.mock("@groombook/db", () => { + const businessSettings = new Proxy( + { _name: "business_settings" }, + { get: (_t, p) => (p === "_name" ? "business_settings" : { column: p }) } + ); + return { + getDb: () => ({ + select: () => ({ from: () => makeChainable(selectRows) }), + insert: () => ({ + values: () => ({ returning: () => insertReturning }), + }), + }), + businessSettings, + eq: vi.fn(), + }; +}); + +vi.mock("../lib/s3.js", () => ({ + getPresignedUploadUrl: vi.fn(), + deleteObject: vi.fn(), + putObject: vi.fn(), + getObject: vi.fn(), +})); + +const { settingsRouter } = await import("../routes/settings.js"); + +const app = new Hono(); +app.route("/settings", settingsRouter); + +const FULL_ROW = { + id: "settings-uuid-1", + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + routeOptimizationProvider: "google", + googleMapsApiKey: "ENCRYPTED::super-secret-ciphertext", + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + }); + + it("omits googleMapsApiKey from an existing settings row", async () => { + selectRows = [{ ...FULL_ROW }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret fields are still returned. + expect(body.businessName).toBe("GroomBook"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey from the auto-create branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await app.request("/settings", { method: "GET" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/clients.ts b/src/routes/clients.ts index e7ac65c..328ed31 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -12,6 +12,12 @@ import { export const clientsRouter = new Hono(); +// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how +// long one synchronous request stays open and the per-request external API cost +// when routeOptimizationProvider = "google". +const GEOCODE_BATCH_DEFAULT_LIMIT = 50; +const GEOCODE_BATCH_MAX_LIMIT = 500; + type ClientRow = typeof clients.$inferSelect; /** @@ -185,12 +191,15 @@ clientsRouter.post("/:clientId/geocode", async (c) => { clientsRouter.post("/geocode-batch", async (c) => { const db = getDb(); const limitRaw = c.req.query("limit"); - let limit = 50; + let limit = GEOCODE_BATCH_DEFAULT_LIMIT; if (limitRaw !== undefined) { limit = Number(limitRaw); if (!Number.isFinite(limit) || limit <= 0) { return c.json({ error: "limit must be a positive integer" }, 400); } + // Clamp to the documented maximum to bound synchronous request duration + // and (for the Google provider) per-request external API cost. + limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT); } const summary = await geocodeUngeocodedClients(db, limit); return c.json(summary); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 3b931db..8529135 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -7,6 +7,17 @@ import { requireSuperUser } from "../middleware/rbac.js"; export const settingsRouter = new Hono(); +type BusinessSettingsRow = typeof businessSettings.$inferSelect; + +// Strip the encrypted googleMapsApiKey ciphertext from settings responses +// (GRO-2294, defense-in-depth). The secret is never needed client-side; it is +// only written via the dedicated provider-config endpoint. +function redactSettings(row: BusinessSettingsRow) { + const rest: Partial = { ...row }; + delete rest.googleMapsApiKey; + return rest; +} + // GET /api/admin/settings — return current business settings settingsRouter.get("/", async (c) => { const db = getDb(); @@ -14,9 +25,10 @@ settingsRouter.get("/", async (c) => { if (!row) { // Auto-create default settings if none exist const [created] = await db.insert(businessSettings).values({}).returning(); - return c.json(created); + if (!created) throw new Error("Failed to create default settings"); + return c.json(redactSettings(created)); } - return c.json(row); + return c.json(redactSettings(row)); }); const hexColorRegex = /^#[0-9a-fA-F]{6}$/; diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt new file mode 100644 index 0000000..e69de29 From b4b48f7b50d8ef9f5129024b2b65bb51d2ead802 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:52:48 +0000 Subject: [PATCH 2/7] fix(GRO-2299): redact googleMapsApiKey from PATCH /api/admin/settings response (#195) --- UAT_PLAYBOOK.md | 2 +- src/__tests__/settings.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/routes/settings.ts | 3 +- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 48082de..ecccc77 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -333,7 +333,7 @@ This means: | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-API-13.1 | Get business settings | GET /api/admin/settings | 200 OK, business settings returned. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present | -| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated | +| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned | | TC-API-13.3 | Upload logo | POST /api/admin/settings/logo/upload with file | 200 OK, logo uploaded and stored | | TC-API-13.4 | View logo | GET /api/admin/settings/logo | 200 OK, logo image returned | | TC-API-13.5 | Delete logo | DELETE /api/admin/settings/logo | 200 OK, logo removed | diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts index c878999..5cdccca 100644 --- a/src/__tests__/settings.test.ts +++ b/src/__tests__/settings.test.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; let selectRows: Record[] = []; let insertReturning: Record[] = []; +let updateReturning: Record[] = []; function makeChainable(data: unknown[]): unknown { const arr = [...data]; @@ -33,6 +34,9 @@ vi.mock("@groombook/db", () => { insert: () => ({ values: () => ({ returning: () => insertReturning }), }), + update: () => ({ + set: () => ({ where: () => ({ returning: () => updateReturning }) }), + }), }), businessSettings, eq: vi.fn(), @@ -51,6 +55,17 @@ const { settingsRouter } = await import("../routes/settings.js"); const app = new Hono(); app.route("/settings", settingsRouter); +// PATCH /settings is guarded by requireSuperUser(), which reads the staff record +// from context. Inject a super-user staff row so the handler runs. +const patchApp = new Hono<{ + Variables: { staff: { id: string; isSuperUser: boolean } }; +}>(); +patchApp.use("*", async (c, next) => { + c.set("staff", { id: "staff-1", isSuperUser: true }); + await next(); +}); +patchApp.route("/settings", settingsRouter); + const FULL_ROW = { id: "settings-uuid-1", businessName: "GroomBook", @@ -89,3 +104,42 @@ describe("GET /settings — googleMapsApiKey redaction (GRO-2294)", () => { expect(body.id).toBe("settings-uuid-new"); }); }); + +describe("PATCH /settings — googleMapsApiKey redaction (GRO-2299)", () => { + beforeEach(() => { + selectRows = []; + insertReturning = []; + updateReturning = []; + }); + + function patchRequest(body: Record) { + return patchApp.request("/settings", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("omits googleMapsApiKey from the PATCH response", async () => { + selectRows = [{ ...FULL_ROW }]; + updateReturning = [{ ...FULL_ROW, businessName: "Updated Name" }]; + const res = await patchRequest({ businessName: "Updated Name" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + // Non-secret updated fields are still returned. + expect(body.businessName).toBe("Updated Name"); + expect(body.routeOptimizationProvider).toBe("google"); + }); + + it("omits googleMapsApiKey on the auto-create-then-update branch", async () => { + selectRows = []; + insertReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + updateReturning = [{ ...FULL_ROW, id: "settings-uuid-new" }]; + const res = await patchRequest({ primaryColor: "#123456" }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).not.toHaveProperty("googleMapsApiKey"); + expect(body.id).toBe("settings-uuid-new"); + }); +}); diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8529135..bcb4476 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -65,7 +65,8 @@ settingsRouter.patch( .where(eq(businessSettings.id, settingsId)) .returning(); - return c.json(updated); + if (!updated) throw new Error("Failed to update settings"); + return c.json(redactSettings(updated)); } ); From 1e0747324d5c1c74033d0a1323d6f472573c0bc2 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 08:44:58 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix(GRO-2139):=20serialize=20reset=E2=86=92?= =?UTF-8?q?migrate=E2=86=92seed=20under=20the=20seed=20advisory=20lock=20(?= =?UTF-8?q?#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize the entire db:reset chain (DROP → migrate → seed) inside one withSeedAdvisoryLock callback so a concurrent same-PRNG seeder cannot interleave and collide on invoices_pkey. Pool sized max:6 (1 reserved for the lock + work headroom) to avoid the connection-starvation deadlock the CTO caught. Verified with three end-to-end live db:reset runs against a throwaway Postgres. cc @cpfarhood --- packages/db/package.json | 2 +- packages/db/src/reset.ts | 153 +++++++++++++++++++++++++++++---------- packages/db/src/seed.ts | 14 ++-- 3 files changed, 123 insertions(+), 46 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index 7f97370..cb3811f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,7 +21,7 @@ "wait-for-db": "node ./scripts/wait-for-db.mjs", "migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate", "seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts", - "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", + "reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index 41c3ce8..fb88e20 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -1,13 +1,52 @@ /** - * reset.ts — Drop all application tables and re-run migrations + seed. + * reset.ts — Drop all application tables, re-run migrations, and re-seed. * * Intended for local development only. Never run against production. * * Usage: * DATABASE_URL=postgres://... npx tsx packages/db/src/reset.ts + * + * GRO-2139: the entire drop→migrate→seed chain runs inside a single + * Postgres advisory lock (SEED_ADVISORY_LOCK_KEY) so a concurrent + * `seed.ts` (e.g. the dev `seed-test-data-*` Job being recreated at + * the top of the hour) cannot interleave between `reset.ts` (DROP) + * and `seed.ts` (TRUNCATE+insert) and collide on `invoices_pkey`. + * + * Why this matters: `seed.ts` derives every primary key from a single + * shared Mulberry32 PRNG seeded with 42 (see `createPrng(42)` and + * `uuid()` in seed.ts). Two concurrent same-profile seeders therefore + * emit *identical* ids for the same logical row, and any moment + * between a concurrent `seed.ts` TRUNCATE and INSERT is exactly the + * window in which the second seeder's INSERT can hit a pkey already + * taken by the first. Pre-GRO-2123 this raced unconditionally; + * GRO-2123 added the advisory lock around `runSeedBody` but left + * `reset.ts` and `drizzle-kit migrate` outside the lock. This script + * now wraps the *whole* chain in the same lock: `withSeedAdvisoryLock` + * pins the lock to one reserved session and the DROP → migrate → seed + * work runs on the rest of the pool, so the lock guarantees mutual + * exclusion against any concurrent seeder for the entire chain. + * + * See: groombook/infra `apps/base/reset-cronjob.yaml` (CronJob) and + * `apps/base/seed-job.yaml` (one-shot Job) — both invoke the same + * `seed.ts` code path on the same database in `groombook-dev`. */ - import postgres from "postgres"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import * as schema from "./schema.js"; +import { + SEED_ADVISORY_LOCK_KEY, + withSeedAdvisoryLock, + getProfile, + runSeedBody, + profiles, +} from "./seed.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const MIGRATIONS_FOLDER = resolve(__dirname, "../migrations"); async function reset() { const url = process.env.DATABASE_URL; @@ -16,52 +55,88 @@ async function reset() { process.exit(1); } - if (process.env.NODE_ENV === "production" && process.env.ALLOW_RESET !== "true") { - console.error("[FATAL] db:reset must not be run in production without ALLOW_RESET=true."); + if ( + process.env.NODE_ENV === "production" && + process.env.ALLOW_RESET !== "true" + ) { + console.error( + "[FATAL] db:reset must not be run in production without ALLOW_RESET=true.", + ); process.exit(1); } - const client = postgres(url, { max: 1 }); + // Pool sizing is load-bearing here. `withSeedAdvisoryLock` does + // `pool.reserve()` to pin the advisory lock to one dedicated session + // (a session-level lock released on a *different* pooled connection is + // a no-op), and the DROP / migrate / seed work then runs on the + // *remaining* pooled connections. The lock provides mutual exclusion + // across processes regardless of how many connections the work uses — + // it does NOT require the work to share the lock's session. + // + // Therefore `max` must be ≥ 2: 1 reserved for the lock + ≥1 free for + // the work. `max: 1` would let `reserve()` consume the only connection + // and every query inside the callback would block forever waiting for + // a connection that never frees (connection-starvation deadlock). We + // use `max: 6` to match `seed()`'s headroom (1 reserved + 5 work). + const client = postgres(url, { max: 6 }); + const db = drizzle(client, { schema }); - console.log("Dropping all application tables...\n"); + try { + await withSeedAdvisoryLock(client, async () => { + console.log("Dropping all application tables...\n"); - // Drop in dependency order (children before parents) - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT tablename FROM pg_tables - WHERE schemaname = 'public' - ) LOOP - EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; - END LOOP; - END $$; - `; + // Drop dependencies (tables) first + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ) LOOP + EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + `; - // Drop custom enums - await client` - DO $$ DECLARE - r RECORD; - BEGIN - FOR r IN ( - SELECT typname FROM pg_type - WHERE typtype = 'e' AND typnamespace = ( - SELECT oid FROM pg_namespace WHERE nspname = 'public' - ) - ) LOOP - EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; - END LOOP; - END $$; - `; + // Drop custom enums + await client` + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN ( + SELECT typname FROM pg_type + WHERE typtype = 'e' AND typnamespace = ( + SELECT oid FROM pg_namespace WHERE nspname = 'public' + ) + ) LOOP + EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE'; + END LOOP; + END $$; + `; - // Drop the drizzle migrations tracking table - await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; - await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; + // Drop the drizzle migrations tracking table + await client`DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE`; + await client`DROP SCHEMA IF EXISTS drizzle CASCADE`; - console.log("✓ All tables and enums dropped\n"); + console.log("✓ All tables and enums dropped\n"); - await client.end(); + console.log("Running migrations..."); + await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + console.log("✓ Migrations applied\n"); + + console.log("Seeding database..."); + const profile = getProfile(); + const cfg = profiles[profile]; + await runSeedBody(client, db, profile, cfg); + }); + + console.log( + `\n✓ Reset complete (advisory lock key=0x${SEED_ADVISORY_LOCK_KEY.toString(16)})`, + ); + } finally { + await client.end(); + } } reset().catch((err) => { diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 55b2ee4..b519c04 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -24,9 +24,9 @@ import type { MedicalAlert } from "@groombook/types"; // ── Seed profile configuration ───────────────────────────────────────────── -type SeedProfile = "dev" | "uat" | "demo"; +export type SeedProfile = "dev" | "uat" | "demo"; -interface ProfileConfig { +export interface ProfileConfig { staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; clientCount: number; appointmentsBackDays: number; @@ -35,7 +35,7 @@ interface ProfileConfig { includeUatClients: boolean; } -const profiles: Record = { +export const profiles: Record = { dev: { staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, clientCount: 100, @@ -70,6 +70,8 @@ function getProfile(): SeedProfile { return "uat"; } +export { getProfile }; + // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── /** @@ -1194,7 +1196,7 @@ async function seedKnownUsers() { // from runbooks without ambiguity and binds to the single-argument // `pg_advisory_lock(int)` form, which postgres-js serializes as a plain // number (no bigint type plumbing required). -const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable +export const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, stable /** * Reserve a dedicated connection from `pool`, take the seed advisory lock @@ -1207,7 +1209,7 @@ const SEED_ADVISORY_LOCK_KEY = 0x47524f4f; // "GROO" in ASCII — arbitrary, sta * for the lock and release it from the same reserved connection. The * seed work itself still runs on the pooled connections. */ -async function withSeedAdvisoryLock( +export async function withSeedAdvisoryLock( pool: ReturnType, fn: () => Promise, ): Promise { @@ -1265,7 +1267,7 @@ async function seed() { await client.end(); } -async function runSeedBody( +export async function runSeedBody( client: ReturnType, db: ReturnType, profile: SeedProfile, From 2853ce73a59e8a9513aa41bcfbc544f6d8daae2f Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 08:56:22 +0000 Subject: [PATCH 4/7] GRO-2172: add missing extended pet fields to create/update schemas (#199) --- src/routes/pets.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 5c4aaec..229a047 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -57,6 +57,23 @@ const createPetSchema = z.object({ customFields: z.record(z.string(), z.string()).optional(), petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(), coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(), + // Extended pet profile fields (api/#39, GRO-1178). + // GRO-2172: these were missing from the schema, causing POST/PATCH to + // silently drop them even though migrations 0034/0036 and seed data + // populate them. GRO-1472 was the original UAT regression. + temperamentScore: z.number().int().min(1).max(5).optional(), + temperamentFlags: z.array(z.string().max(100)).max(20).optional(), + medicalAlerts: z + .array( + z.object({ + type: z.string().max(100), + description: z.string().max(1000), + severity: z.enum(["low", "medium", "high"]), + }) + ) + .max(50) + .optional(), + preferredCuts: z.array(z.string().max(200)).max(20).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); @@ -333,7 +350,8 @@ petsRouter.get("/:id/profile-summary", async (c) => { petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } = + c.req.valid("json"); const [row] = await db .insert(pets) .values({ @@ -341,6 +359,10 @@ petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, customFields: customFields ?? {}, + // GRO-2172: medicalAlerts shape from the API request is + // { type, description, severity } — the @groombook/types MedicalAlert + // has an optional server-generated `id`, so cast for the jsonb column. + medicalAlerts: medicalAlerts as never, }) .returning(); return c.json(row, 201); @@ -351,7 +373,8 @@ petsRouter.patch( zValidator("json", updatePetSchema), async (c) => { const db = getDb(); - const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); + const { weightKg, dateOfBirth, customFields, medicalAlerts, ...rest } = + c.req.valid("json"); const [row] = await db .update(pets) .set({ @@ -359,6 +382,7 @@ petsRouter.patch( weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, ...(customFields !== undefined ? { customFields } : {}), + medicalAlerts: medicalAlerts as never, updatedAt: new Date(), }) .where(eq(pets.id, c.req.param("id"))) From d61607f4c5f24e3b5524196d87bc60f4e985634b Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 09:53:04 +0000 Subject: [PATCH 5/7] feat(seed): seed upcoming appointments across statuses for UAT portal customer (GRO-2311) (#201) --- packages/db/src/seed.ts | 170 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index b519c04..f6bdf4c 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -832,6 +832,168 @@ async function seedUatGroomerLinkage( ); } +// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ──────────────── + +/** + * GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`) + * a deterministic spread of appointments so the customer-portal StatusBadge + * palette can be LIVE-observed (not just code-verified against the bundle). + * + * Scope is the subset of badge states reachable from the `appointment_status` + * enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) — + * the portal's renders `appointment.status` verbatim. `pending` + * and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the + * styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are + * tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313. + * + * - confirmed → future startTime → renders as an Upcoming card (Confirmed badge) + * - scheduled → future startTime → renders as an Upcoming card (Scheduled badge) + * - cancelled → past startTime → Past tab (isUpcoming excludes cancelled) + * - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319) + * + * The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left + * untouched (AC #4), so Completed is also covered. + * + * Idempotent: each appointment uses a fixed UUID and is upserted with + * onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs + * then re-seeds) and non-truncating dev re-seeds never dup-key + * (see GRO-2033 for the dup-key class). + */ +async function seedUatCustomerPortalAppointments( + db: ReturnType, + customerClientId: string | null, +): Promise { + const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha + + // Skip silently outside the UAT persona profile (e.g. a dev/test seed that + // never created the UAT Customer client). + if (!customerClientId) { + return; + } + + // The customer's pet must exist (pets are NOT truncated on reset, so this is + // stable). Defensive: bail cleanly if the persona pet is absent. + const [linkedPet] = await db + .select({ id: schema.pets.id }) + .from(schema.pets) + .where(eq(schema.pets.id, LINKED_PET_ID)) + .limit(1); + if (!linkedPet) { + console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`); + return; + } + + // Stable "Bath & Brush" service; fall back to any active service. + const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001"; + const [bathService] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.id, BATH_AND_BRUSH_ID)) + .limit(1); + + let serviceId: string; + if (bathService) { + serviceId = bathService.id; + } else { + const [fallback] = await db + .select({ id: schema.services.id }) + .from(schema.services) + .where(eq(schema.services.active, true)) + .limit(1); + if (!fallback) { + console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`); + return; + } + serviceId = fallback.id; + } + + // Attach the UAT groomer when present (nicer "with " card); else null + // ("First Available"). Either way these are the customer's own appointments — + // no new groomer↔pet linkage invariant is created (uses the already-linked + // Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected. + const [uatGroomerStaff] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + const staffId = uatGroomerStaff?.id ?? null; + + // Anchor all times to local wall-clock so future/past holds regardless of the + // hourly reset cadence. + const at = (deltaDays: number, hour: number): Date => { + const d = new Date(); + d.setDate(d.getDate() + deltaDays); + d.setHours(hour, 0, 0, 0); + return d; + }; + const DURATION_MS = 45 * 60 * 1000; + + const rows = [ + { + id: "a0000001-0000-0000-0000-000000000002", + status: "confirmed" as const, + start: at(3, 10), + confirmationStatus: "confirmed", + confirmedAt: new Date(), + cancelledAt: null as Date | null, + notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000003", + status: "scheduled" as const, + start: at(5, 14), + confirmationStatus: "pending", + confirmedAt: null as Date | null, + cancelledAt: null as Date | null, + notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000004", + status: "cancelled" as const, + start: at(-3, 11), + confirmationStatus: "cancelled", + confirmedAt: null as Date | null, + cancelledAt: new Date(), + notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.", + }, + { + id: "a0000001-0000-0000-0000-000000000005", + status: "no_show" as const, + start: at(-10, 9), + confirmationStatus: "confirmed", + confirmedAt: null as Date | null, + cancelledAt: null as Date | null, + notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.", + }, + ]; + + await db + .insert(schema.appointments) + .values( + rows.map((r) => ({ + id: r.id, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + staffId, + batherStaffId: null, + status: r.status, + startTime: r.start, + endTime: new Date(r.start.getTime() + DURATION_MS), + notes: r.notes, + priceCents: null, + confirmationStatus: r.confirmationStatus, + confirmedAt: r.confirmedAt, + cancelledAt: r.cancelledAt, + })), + ) + .onConflictDoNothing({ target: schema.appointments.id }); + + console.log( + `✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`, + ); +} + // ── GRO-2225: deterministic route-optimization cohort ──────────────────────── /** @@ -1113,6 +1275,10 @@ async function seedKnownUsers() { // to attach to the appointment; on a fresh reset there are none yet at // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable + // appointment statuses only). Runs after the groomer linkage so the customer + // client + Pup Alpha already exist. + await seedUatCustomerPortalAppointments(db, uatCustomerClientId); // ── Client: Demo Client ── const [existingClient] = await db @@ -1375,6 +1541,10 @@ export async function runSeedBody( // to attach to the appointment; on a fresh reset there are none yet at // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(db, uatCustomerClientId); + // GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable + // appointment statuses only). Runs after the groomer linkage so the customer + // client + Pup Alpha already exist. + await seedUatCustomerPortalAppointments(db, uatCustomerClientId); // GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments // for the UAT groomer. Must run AFTER services are seeded (it looks up a From ef18ed737693f2d1eb1bd7973aca4cc7e9bd399e Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 10:41:08 +0000 Subject: [PATCH 6/7] feat(GRO-2319): surface active waitlist entries on portal appointments + seed (#204) --- UAT_PLAYBOOK.md | 1 + packages/db/src/seed.ts | 52 ++++++++++++++++++++++--- src/__tests__/portal.test.ts | 73 ++++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 48 ++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index ecccc77..71d00ff 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -287,6 +287,7 @@ This means: | TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted | | TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | | TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | +| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. | ### 4.9 Waitlist diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index f6bdf4c..02dc913 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -839,12 +839,14 @@ async function seedUatGroomerLinkage( * a deterministic spread of appointments so the customer-portal StatusBadge * palette can be LIVE-observed (not just code-verified against the bundle). * - * Scope is the subset of badge states reachable from the `appointment_status` - * enum (`scheduled, confirmed, in_progress, completed, cancelled, no_show`) — - * the portal's renders `appointment.status` verbatim. `pending` - * and `waitlisted` are NOT valid appointment statuses and cannot be seeded; the - * styled `no_show`→`no-show` badge fix and any pending/waitlisted derivation are - * tracked separately in GRO-2319 (web). CTO-approved Option A on GRO-2313. + * `appointment_status` enum is (`scheduled, confirmed, in_progress, completed, + * cancelled, no_show`) — the portal's renders `appointment.status` + * verbatim. `pending` and `waitlisted` are NOT valid appointment statuses, so + * GRO-2319 derives them in the portal: `pending` from an upcoming appointment's + * `confirmationStatus` (the `scheduled` row below carries `pending`), and + * `waitlisted` from an ACTIVE `waitlist_entries` row (seeded at the end of this + * function) which `GET /api/portal/appointments` surfaces as a synthetic card. + * The `no_show`→`no-show` badge-key fix is the web side of GRO-2319. * * - confirmed → future startTime → renders as an Upcoming card (Confirmed badge) * - scheduled → future startTime → renders as an Upcoming card (Scheduled badge) @@ -992,6 +994,44 @@ async function seedUatCustomerPortalAppointments( console.log( `✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`, ); + + // GRO-2319 item 2: seed one ACTIVE waitlist entry so the portal's `waitlisted` + // card (surfaced by GET /api/portal/appointments) is live-observable. Unlike + // appointments, `waitlist_entries` is NOT truncated on the hourly reset, so we + // upsert by fixed id and REFRESH the preferred date to a future-relative value + // each reset — otherwise the date would go stale and the card would drop out of + // the Upcoming list. (The seeded `scheduled` appointment above already carries + // `confirmationStatus: "pending"`, which drives the live Pending badge.) + const WAITLIST_ENTRY_ID = "e0000001-0000-0000-0000-000000000001"; + const pad2 = (n: number): string => String(n).padStart(2, "0"); + const wlStart = at(7, 13); // 7 days out, 1pm — comfortably "upcoming" + const wlPreferredDate = `${wlStart.getFullYear()}-${pad2(wlStart.getMonth() + 1)}-${pad2(wlStart.getDate())}`; + const wlPreferredTime = `${pad2(wlStart.getHours())}:00:00`; + + await db + .insert(schema.waitlistEntries) + .values({ + id: WAITLIST_ENTRY_ID, + clientId: customerClientId, + petId: LINKED_PET_ID, + serviceId, + preferredDate: wlPreferredDate, + preferredTime: wlPreferredTime, + status: "active", + }) + .onConflictDoUpdate({ + target: schema.waitlistEntries.id, + set: { + preferredDate: wlPreferredDate, + preferredTime: wlPreferredTime, + status: "active", + updatedAt: new Date(), + }, + }); + + console.log( + `✓ GRO-2319: seeded 1 active waitlist entry (${wlPreferredDate} ${wlPreferredTime}) for UAT customer portal Waitlisted card`, + ); } // ── GRO-2225: deterministic route-optimization cohort ──────────────────────── diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 73f05ff..84f37ab 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -39,11 +39,17 @@ const APPOINTMENT = { let selectSessionRow: Record | null = null; let selectAppointmentRow: Record | null = null; +let selectWaitlistRows: Record[] = []; +let selectPetRows: Record[] = []; +let selectStaffRows: Record[] = []; let updatedValues: Record[] = []; function resetMock() { selectSessionRow = null; selectAppointmentRow = null; + selectWaitlistRows = []; + selectPetRows = []; + selectStaffRows = []; updatedValues = []; } @@ -72,6 +78,12 @@ vi.mock("@groombook/db", () => { { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } ); + const mkTable = (name: string) => + new Proxy({ _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }); + const waitlistEntries = mkTable("waitlistEntries"); + const pets = mkTable("pets"); + const staff = mkTable("staff"); + return { getDb: () => ({ select: () => ({ @@ -82,6 +94,15 @@ vi.mock("@groombook/db", () => { if (table._name === "appointments") { return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []); } + if (table._name === "waitlistEntries") { + return makeChainable(selectWaitlistRows); + } + if (table._name === "pets") { + return makeChainable(selectPetRows); + } + if (table._name === "staff") { + return makeChainable(selectStaffRows); + } return makeChainable([]); }, }), @@ -102,8 +123,12 @@ vi.mock("@groombook/db", () => { }), impersonationSessions, appointments, + waitlistEntries, + pets, + staff, eq: vi.fn(), and: vi.fn(), + inArray: vi.fn(), }; }); @@ -125,6 +150,54 @@ function jsonPatch(path: string, body: unknown, headers?: Record beforeEach(() => resetMock()); +// GRO-2319 item 2: the portal Upcoming list renders active waitlist entries as +// synthetic `waitlisted` cards, so GET /portal/appointments must surface them. +describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => { + it("returns active waitlist entries as synthetic waitlisted cards", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = [ + { + id: "11111111-1111-1111-1111-111111111111", + petId: "pet-1", + serviceId: "svc-1", + preferredDate: "2099-01-01", + preferredTime: "13:00:00", + }, + ]; + selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const waitlistCard = body.appointments.find( + (a: { status: string }) => a.status === "waitlisted", + ); + expect(waitlistCard).toBeTruthy(); + expect(waitlistCard.id).toBe("waitlist:11111111-1111-1111-1111-111111111111"); + expect(waitlistCard.pet.name).toBe("Rex"); + expect(waitlistCard.confirmationStatus).toBeNull(); + // startTime is derived from preferredDate + preferredTime so the card sorts + // and classifies as Upcoming. + expect(waitlistCard.startTime).toBeTruthy(); + }); + + it("omits the waitlist section when the client has no active entries", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = []; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.appointments.some((a: { status: string }) => a.status === "waitlisted")).toBe(false); + }); +}); + describe("PATCH /portal/appointments/:id/notes", () => { it("returns updated appointment with safe fields only", async () => { selectSessionRow = ACTIVE_SESSION; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 3c7dab9..65c53a7 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { eq, inArray } from "@groombook/db"; +import { and, eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js"; import { portalAudit } from "../middleware/portalAudit.js"; @@ -195,7 +195,29 @@ portalRouter.get("/appointments", async (c) => { .where(eq(appointments.clientId, clientId)) .orderBy(appointments.startTime); - const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); + // GRO-2319: surface the client's ACTIVE waitlist entries alongside their + // appointments so the portal can render them as `waitlisted` cards in the + // Upcoming list. The `appointment_status` enum cannot represent `waitlisted`, + // so these are synthetic entries (status hard-set to `waitlisted`, id prefixed + // `waitlist:`) derived from `waitlist_entries`. + const waitlistRows = await db + .select({ + id: waitlistEntries.id, + petId: waitlistEntries.petId, + serviceId: waitlistEntries.serviceId, + preferredDate: waitlistEntries.preferredDate, + preferredTime: waitlistEntries.preferredTime, + }) + .from(waitlistEntries) + .where( + and(eq(waitlistEntries.clientId, clientId), eq(waitlistEntries.status, "active")), + ); + + // Pet lookups must cover both appointment and waitlist pets. + const petIds = [ + ...allAppts.map(a => a.petId).filter((id): id is string => id !== null), + ...waitlistRows.map(w => w.petId), + ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; @@ -217,7 +239,27 @@ portalRouter.get("/appointments", async (c) => { staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); - return c.json({ appointments: appts }); + // Derive a display `startTime` from the entry's preferred date/time so the + // portal can sort/classify the synthetic card (an invalid combination simply + // yields a null startTime, which the portal tolerates). + const waitlistAppts = waitlistRows.map(w => { + const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); + const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; + return { + id: `waitlist:${w.id}`, + startTime, + endTime: null, + status: "waitlisted" as const, + confirmationStatus: null, + customerNotes: null, + notes: null, + pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, + service: { id: w.serviceId }, + staff: null, + }; + }); + + return c.json({ appointments: [...appts, ...waitlistAppts] }); }); portalRouter.get("/pets", async (c) => { From 277f45923738a379d2f9dd7cc8b38cf98344d3e1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 10 Jun 2026 09:11:08 +0000 Subject: [PATCH 7/7] fix(GRO-2342): portal waitlist card populates service {id, name} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic follow-up to GRO-2319 (Phase 4 review by CTO). The synthetic waitlist card on GET /portal/appointments returned service: {id} only, so the portal fell back to the literal 'Service' label. CMPO spec did not call for a service name on the waitlist card, but populating the real name is non-urgent and closes the cosmetic gap. - src/routes/portal.ts: include a services SELECT (in addition to pets and staff) covering both appointment and waitlist serviceIds. serviceMap feeds a service.name lookup. The synthetic waitlist card's service object is now {id, name} — same shape the appointments join returns — so the portal renders the real name. The appointments join also gains a name (consistent shape, no regression for the existing path). - src/__tests__/portal.test.ts: mock the services table and assert service: {id, name} on both the synthetic waitlist card and the appointment card. - UAT_PLAYBOOK.md: TC-API-8.20 covering the waitlist card service name (TC-API-8.19 retained verbatim for the original GRO-2319 surfacing contract). Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 1 + src/__tests__/portal.test.ts | 57 ++++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 20 +++++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 71d00ff..2a85e1d 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -288,6 +288,7 @@ This means: | TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | | TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | | TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. | +| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "", name: ""}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. | ### 4.9 Waitlist diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 84f37ab..1ac8bce 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -42,6 +42,7 @@ let selectAppointmentRow: Record | null = null; let selectWaitlistRows: Record[] = []; let selectPetRows: Record[] = []; let selectStaffRows: Record[] = []; +let selectServiceRows: Record[] = []; let updatedValues: Record[] = []; function resetMock() { @@ -50,6 +51,7 @@ function resetMock() { selectWaitlistRows = []; selectPetRows = []; selectStaffRows = []; + selectServiceRows = []; updatedValues = []; } @@ -83,6 +85,7 @@ vi.mock("@groombook/db", () => { const waitlistEntries = mkTable("waitlistEntries"); const pets = mkTable("pets"); const staff = mkTable("staff"); + const services = mkTable("services"); return { getDb: () => ({ @@ -103,6 +106,9 @@ vi.mock("@groombook/db", () => { if (table._name === "staff") { return makeChainable(selectStaffRows); } + if (table._name === "services") { + return makeChainable(selectServiceRows); + } return makeChainable([]); }, }), @@ -126,6 +132,7 @@ vi.mock("@groombook/db", () => { waitlistEntries, pets, staff, + services, eq: vi.fn(), and: vi.fn(), inArray: vi.fn(), @@ -198,6 +205,56 @@ describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => { }); }); +// GRO-2342: GET /portal/appointments must populate the synthetic waitlist +// card's `service` object with the full service record (id + name) — same +// shape the appointments join returns — so the portal renders the real +// service name in place of the fallback "Service" label. +describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => { + it("returns service {id, name} on the synthetic waitlist card", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = [ + { + id: "22222222-2222-2222-2222-222222222222", + petId: "pet-1", + serviceId: "svc-1", + preferredDate: "2099-01-01", + preferredTime: "13:00:00", + }, + ]; + selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }]; + selectServiceRows = [{ id: "svc-1", name: "Full Groom" }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const waitlistCard = body.appointments.find( + (a: { status: string }) => a.status === "waitlisted", + ); + expect(waitlistCard).toBeTruthy(); + expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" }); + }); + + it("returns service {id, name} on the appointment card (same shape)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" }; + selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const apptCard = body.appointments.find( + (a: { status: string }) => a.status === "scheduled", + ); + expect(apptCard).toBeTruthy(); + expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" }); + }); +}); + describe("PATCH /portal/appointments/:id/notes", () => { it("returns updated appointment with safe fields only", async () => { selectSessionRow = ACTIVE_SESSION; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 65c53a7..487861d 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -219,12 +219,22 @@ portalRouter.get("/appointments", async (c) => { ...waitlistRows.map(w => w.petId), ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); + // GRO-2342: services must be looked up for both appointment and waitlist cards + // so the portal can render `service.name` in place of the fallback "Service" + // label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the + // service name; this follow-up closes the cosmetic gap). + const serviceIds = [ + ...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null), + ...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null), + ]; const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; + const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); + const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s])); const appts = allAppts.map(a => ({ id: a.id, @@ -235,13 +245,17 @@ portalRouter.get("/appointments", async (c) => { customerNotes: a.customerNotes, notes: a.notes, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, - service: a.serviceId ? { id: a.serviceId } : null, + service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); // Derive a display `startTime` from the entry's preferred date/time so the // portal can sort/classify the synthetic card (an invalid combination simply - // yields a null startTime, which the portal tolerates). + // yields a null startTime, which the portal tolerates). GRO-2342: also + // populate the synthetic card's `service` object with the full service + // record (id + name) — same shape the appointments join returns — so the + // portal renders the real service name in place of the fallback "Service" + // label. const waitlistAppts = waitlistRows.map(w => { const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; @@ -254,7 +268,7 @@ portalRouter.get("/appointments", async (c) => { customerNotes: null, notes: null, pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, - service: { id: w.serviceId }, + service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null, staff: null, }; });