From 185fce8e17f39ce91574d98709558007bc4fa252 Mon Sep 17 00:00:00 2001 From: Chris Farhood <3+cpfarhood@noreply.git.farh.net> Date: Sun, 24 May 2026 18:14:57 +0000 Subject: [PATCH 01/32] Add .mcp.json --- .mcp.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .mcp.json 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}" + } + } + } +} -- 2.52.0 From dd83f2973640b9c68d343f5a2e66b363809dfec5 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 25 May 2026 23:22:04 +0000 Subject: [PATCH 02/32] chore: trigger CI from uat for GRO-1754 --- trigger-uat-1779751324.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 trigger-uat-1779751324.txt diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt new file mode 100644 index 0000000..e69de29 -- 2.52.0 From 152abfc4d55d167782efa89d11cad3b9264b56fe Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Tue, 26 May 2026 01:26:05 +0000 Subject: [PATCH 03/32] fix(ci): remove duplicate provenance keys causing YAML parse error Duplicate 'provenance: false' in each docker/build-push-action step caused Gitea to reject the workflow file, breaking push CI and workflow_dispatch. Co-Authored-By: Paperclip --- .gitea/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b08c640..b37a76a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -96,7 +96,6 @@ jobs: file: Dockerfile target: runner push: true - provenance: false tags: | git.farh.net/groombook/api:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/api:latest' || '' }} @@ -111,7 +110,6 @@ jobs: file: Dockerfile target: migrate push: true - provenance: false tags: | git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/migrate:latest' || '' }} @@ -126,7 +124,6 @@ jobs: file: Dockerfile target: seed push: true - provenance: false tags: | git.farh.net/groombook/seed:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/seed:latest' || '' }} @@ -141,7 +138,6 @@ jobs: file: Dockerfile target: reset push: true - provenance: false tags: | git.farh.net/groombook/reset:${{ steps.version.outputs.tag }} ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} -- 2.52.0 From 23484dc90a10da308757f6f167adc6354c72a280 Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Mon, 1 Jun 2026 18:27:42 +0000 Subject: [PATCH 04/32] =?UTF-8?q?promote(uat):=20GRO-2014=20profile-summar?= =?UTF-8?q?y=20error-handling=20fix=20(dev=E2=86=92uat)=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 3 + src/__tests__/petProfileSummary.test.ts | 285 ++++++++++++++++++++++++ src/routes/pets.ts | 35 ++- 3 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/petProfileSummary.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index f0e1037..5860fbc 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -125,6 +125,9 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ | TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden | | TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) | | TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) | +| 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"}` | #### Seed Data Verification (GRO-1898) diff --git a/src/__tests__/petProfileSummary.test.ts b/src/__tests__/petProfileSummary.test.ts new file mode 100644 index 0000000..8a17d43 --- /dev/null +++ b/src/__tests__/petProfileSummary.test.ts @@ -0,0 +1,285 @@ +/** + * GET /pets/:id/profile-summary tests + * + * GRO-2014 regression coverage: + * - Empty-body 500 must never escape the route — the onError handler + * converts unhandled errors into a structured JSON 500. + * - Malformed UUIDs must return 404 (not 500 via a Postgres uuid cast). + * - Missing staff context must return 401 (not TypeError on staffRow.id). + * - Pet not found must return 404. + * - Groomer with no appointment linkage must return 403. + * - Manager and groomer with linkage must receive the summary body. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +const MANAGER: StaffRow = { + id: "00000000-0000-0000-0000-0000000000aa", + oidcSub: "oidc-manager-sub", + userId: null, + role: "manager", + isSuperUser: true, + name: "Manager McManager", + email: "manager@example.com", + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const GROOMER: StaffRow = { + ...MANAGER, + id: "00000000-0000-0000-0000-0000000000bb", + oidcSub: "oidc-groomer-sub", + role: "groomer", + isSuperUser: false, + name: "Groomer Gary", + email: "groomer@example.com", +}; + +const PET_UUID = "11111111-1111-1111-1111-111111111111"; +const CLIENT_UUID = "22222222-2222-2222-2222-222222222222"; +const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001"; + +const PET_ROW = { + id: PET_UUID, + clientId: CLIENT_UUID, + name: "Biscuit", + species: "dog", + breed: "Beagle", + coatType: "short", + petSizeCategory: "medium", + weightKg: "12.50", + dateOfBirth: new Date("2020-01-01"), +}; + +// ─── Mutable DB state ───────────────────────────────────────────────────────── + +interface DbState { + petRow: typeof PET_ROW | null; + linkageRow: { id: string } | null; + recentHistory: Array>; + visitCount: number; + upcoming: Record | null; + throwOnPetSelect: boolean; +} + +let dbState: DbState; + +function resetDb() { + dbState = { + petRow: { ...PET_ROW }, + linkageRow: { id: "appt-link" }, + recentHistory: [], + visitCount: 0, + upcoming: null, + throwOnPetSelect: false, + }; +} + +// ─── @groombook/db mock ────────────────────────────────────────────────────── +// +// Each select chain needs to know which table it's targeting and which columns +// it's projecting so we can return the right mocked rows. We thread that state +// through a per-call object whose chain methods all return `this`. The chain +// is also `then`-able so any `await` position resolves to the rows. + +vi.mock("@groombook/db", () => { + const namedTable = (name: string) => + new Proxy( + { _name: name }, + { + get(_t, p) { + if (p === "_name") return name; + return { table: name, column: p }; + }, + } + ); + + const pets = namedTable("pets"); + const appointments = namedTable("appointments"); + const services = namedTable("services"); + const staff = namedTable("staff"); + + // The full chain interface is intentionally loose — only `then` is exposed + // with a typed signature so vitest's await resolves to the right shape. + interface ChainLike { + from: (table: { _name: string }) => ChainLike; + where: (...args: unknown[]) => ChainLike; + innerJoin: (...args: unknown[]) => ChainLike; + leftJoin: (...args: unknown[]) => ChainLike; + orderBy: (...args: unknown[]) => ChainLike; + limit: (...args: unknown[]) => ChainLike; + then: ( + onfulfilled?: ((value: unknown[]) => T | PromiseLike) | null + ) => Promise; + } + + function buildSelect(projection?: Record): ChainLike { + let targetTable = ""; + + const resolveRows = (): unknown[] => { + if (targetTable === "pets") { + if (dbState.throwOnPetSelect) { + throw new Error("simulated postgres uuid cast failure"); + } + return dbState.petRow ? [dbState.petRow] : []; + } + if (targetTable === "appointments") { + const keys = projection ? Object.keys(projection) : []; + if (projection && keys.length === 1 && keys[0] === "id") { + return dbState.linkageRow ? [dbState.linkageRow] : []; + } + if (projection && keys.includes("count")) { + return [{ count: dbState.visitCount }]; + } + if (projection && keys.includes("confirmationStatus")) { + return dbState.upcoming ? [dbState.upcoming] : []; + } + return dbState.recentHistory; + } + return []; + }; + + const chain: ChainLike = { + from(table) { + targetTable = table._name; + return chain; + }, + where() { + return chain; + }, + innerJoin() { + return chain; + }, + leftJoin() { + return chain; + }, + orderBy() { + return chain; + }, + limit() { + return chain; + }, + then(onfulfilled) { + return Promise.resolve(resolveRows()).then(onfulfilled ?? undefined); + }, + }; + + return chain; + } + + return { + getDb: () => ({ + select: (projection?: Record) => buildSelect(projection), + }), + pets, + appointments, + services, + staff, + and: vi.fn(() => ({ _op: "and" })), + or: vi.fn(() => ({ _op: "or" })), + eq: vi.fn(() => ({ _op: "eq" })), + desc: vi.fn((arg: unknown) => arg), + exists: vi.fn((arg: unknown) => arg), + sql: Object.assign( + () => ({ _op: "sql" }), + { [Symbol.toPrimitive]: () => "sql" } + ), + }; +}); + +vi.mock("../lib/s3.js", () => ({ + getPresignedUploadUrl: vi.fn().mockResolvedValue("https://example.com/put"), + getPresignedGetUrl: vi.fn().mockResolvedValue("https://example.com/get"), + deleteObject: vi.fn().mockResolvedValue(undefined), +})); + +const { petsRouter } = await import("../routes/pets.js"); + +// ─── App builder ───────────────────────────────────────────────────────────── + +function buildApp(staffRow: StaffRow | null) { + const app = new Hono(); + app.use("*", async (c, next) => { + if (staffRow) c.set("staff", staffRow); + await next(); + }); + app.route("/pets", petsRouter); + return app; +} + +beforeEach(() => { + resetDb(); + vi.clearAllMocks(); +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("GET /pets/:id/profile-summary — GRO-2014 error handling", () => { + it("returns 404 (not 500) for a malformed UUID path param", async () => { + const app = buildApp(MANAGER); + const res = await app.request("/pets/not-a-uuid/profile-summary"); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Not found"); + }); + + it("returns 401 when staff context is missing (defense in depth)", async () => { + const app = buildApp(null); + const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 404 when authenticated and pet does not exist", async () => { + dbState.petRow = null; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Not found"); + }); + + it("returns 403 when groomer has no appointment linkage to the pet's client", async () => { + dbState.linkageRow = null; + const app = buildApp(GROOMER); + const res = await app.request(`/pets/${PET_UUID}/profile-summary`); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Forbidden"); + }); + + it("returns 200 with summary for a manager (no groomer linkage check)", async () => { + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_UUID}/profile-summary`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.id).toBe(PET_UUID); + expect(body.name).toBe("Biscuit"); + expect(body.visitCount).toBe(0); + expect(body.upcomingAppointment).toBeNull(); + expect(body.recentGroomingHistory).toEqual([]); + }); + + it("returns 200 with summary for a groomer with linkage", async () => { + const app = buildApp(GROOMER); + const res = await app.request(`/pets/${PET_UUID}/profile-summary`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.id).toBe(PET_UUID); + }); + + it("returns a JSON envelope (not empty body) when a downstream query throws", async () => { + dbState.throwOnPetSelect = true; + const app = buildApp(MANAGER); + const res = await app.request(`/pets/${PET_UUID}/profile-summary`); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Internal Server Error"); + }); +}); diff --git a/src/routes/pets.ts b/src/routes/pets.ts index ffe494c..9695af7 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -23,6 +23,23 @@ import { export const petsRouter = new Hono(); +// Convert Zod validation errors from 422 to 400 and ensure any thrown error +// returns a structured JSON body rather than Hono's default empty-body 500. +// GRO-2014: profile-summary previously bubbled unhandled errors and produced +// an empty-body 500. Mirror the onError pattern already used in invoices.ts +// and reports.ts so every error has a JSON envelope. +petsRouter.onError((err, c) => { + if (err instanceof z.ZodError) { + return c.json({ error: "Validation failed", issues: err.issues }, 400); + } + console.error("[pets] unhandled error", err); + return c.json({ error: "Internal Server Error" }, 500); +}); + +// UUID format used by all pet routes — guards path params against malformed +// values before they hit Drizzle / Postgres uuid columns (which would throw). +const uuidSchema = z.string().uuid(); + const createPetSchema = z.object({ clientId: z.string().uuid(), name: z.string().min(1).max(200), @@ -112,8 +129,24 @@ petsRouter.get("/:id", async (c) => { petsRouter.get("/:id/profile-summary", async (c) => { const db = getDb(); const petId = c.req.param("id"); + + // GRO-2014: validate UUID format before hitting Postgres. Passing a non-UUID + // string to a uuid column makes the driver throw, which previously surfaced + // as an empty-body 500 to clients. + const parsedId = uuidSchema.safeParse(petId); + if (!parsedId.success) { + return c.json({ error: "Not found" }, 404); + } + + // Defense in depth: resolveStaffMiddleware should always populate `staff` + // for protected routes (or short-circuit with 401/403 of its own). Guard + // anyway so a misconfigured route mount can't trigger a TypeError on + // staffRow.id when the linkage check runs. const staffRow = c.get("staff"); - const isGroomer = staffRow?.role === "groomer"; + if (!staffRow) { + return c.json({ error: "Unauthorized" }, 401); + } + const isGroomer = staffRow.role === "groomer"; // Fetch the pet const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); -- 2.52.0 From bd384bdf5c55730d9bec4036f7e7de91e8dcbfae Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 2 Jun 2026 18:24:40 +0000 Subject: [PATCH 05/32] docs(UAT_PLAYBOOK): add TC-UAT-2/3 for uat-groomer linked/unlinked pet profile-summary (GRO-2100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint Roller review on PR #152 flagged that the GRO-2100 seed change produces new observable UAT API behavior that the playbook must reflect. Add two deterministic rows pinning the contract GRO-1987 TC-UAT-2/3 will exercise: - TC-UAT-2: uat-groomer + linked pet c0000001-...-002 (UAT Pup Alpha) → 200 - TC-UAT-3: uat-groomer + unlinked pet c0000001-...-003 (UAT Pup Beta) → 403 The 403-vs-404 note in TC-UAT-3 mirrors the verification note in the GRO-2100 issue body so the QA runner knows where to file if the API returns 404 (a separate RBAC defect, not against the seed). --- UAT_PLAYBOOK.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index b8949de..ed52ecd 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -147,6 +147,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | 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"}` | -- 2.52.0 From d4a4ddce37ba17fccdf4d3e91308f00848d3e936 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 2 Jun 2026 18:28:17 +0000 Subject: [PATCH 06/32] =?UTF-8?q?ci:=20retrigger=20GRO-2100=20PR=20#152=20?= =?UTF-8?q?Build=20&=20Push=20Docker=20Images=20(Reset=20image=20build=20f?= =?UTF-8?q?ailed=20=E2=80=94=20docker=20registry=20flake)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From e639cc82d17c3baa7ef51cd8493474b860b77c81 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 20:23:54 +0000 Subject: [PATCH 07/32] chore(uat): GRO-2100 promote uat-groomer seed-linkage ordering fix to uat (#154) Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- packages/db/src/seed.ts | 48 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 13cf103..8e9d376 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -401,7 +401,9 @@ const servicesDef = [ * * In seedKnownUsers() this replaces the inline UAT-staff block. */ -async function seedUatStaffAccounts(db: ReturnType) { +async function seedUatStaffAccounts( + db: ReturnType, +): Promise { // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; if (uatSuperOidcSub) { @@ -677,7 +679,12 @@ async function seedUatStaffAccounts(db: ReturnType) { // We deterministically link the UAT groomer to the UAT customer's first pet // ("UAT Pup Alpha") and leave the second pet ("UAT Pup Beta") UNLINKED so // TC-UAT-2 (200) and TC-UAT-3 (403) can both hardcode the stable petIds. - await seedUatGroomerLinkage(db, uatCustomerClientId); + // + // The linkage call itself is performed by the caller AFTER the `services` + // catalogue has been seeded (this helper runs before services exist, + // which previously caused the linkage to be silently skipped on every + // reset). GRO-2100 follow-up. + return uatCustomerClientId; } /** @@ -692,12 +699,18 @@ async function seedUatStaffAccounts(db: ReturnType) { */ async function seedUatGroomerLinkage( db: ReturnType, - customerClientId: string, + customerClientId: string | null, ): Promise { const uatGroomerEmail = "uat-groomer@groombook.dev"; const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha const APPT_ID = "a0000001-0000-0000-0000-000000000001"; + // Skip silently if the UAT Customer client wasn't created (non-UAT seed + // profile, e.g. seedKnownUsers() in an env without the UAT personas). + if (!customerClientId) { + return; + } + // Only run if the UAT groomer staff record actually exists — dev/test seeds // that don't set SEED_UAT_STAFF_OIDC_SUB should not crash. const [uatGroomerStaff] = await db @@ -720,6 +733,19 @@ async function seedUatGroomerLinkage( return; } + // Skip if the linked pet hasn't been seeded yet (defensive: caller should + // ensure pets exist; if the helper is re-ordered later we don't want to + // crash here). + 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-2100: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping uat-groomer linkage`); + return; + } + // The "Bath & Brush" service id is stable across the reset; falls back to // any active service if it has not been seeded yet (e.g. seedKnownUsers // runs in isolation). @@ -847,7 +873,7 @@ async function seedKnownUsers() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Extracted into seedUatStaffAccounts() so it runs in both seedKnownUsers() // and the full seed() UAT branch. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services: idempotent upsert keyed on `id` ───────────────────────────── // GRO-2064: previously keyed on `services.name` while writing a @@ -875,6 +901,12 @@ async function seedKnownUsers() { } console.log(`✓ Seeded ${demoSvcs.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Client: Demo Client ── const [existingClient] = await db .select() @@ -1031,7 +1063,7 @@ async function seed() { // ── UAT staff accounts + Better Auth credentials (shared impl) ────────────── // Seeds deterministic UAT staff with numeric OIDC subs and Better Auth credentials. // Must run AFTER random staff are created so upserts land correctly. - await seedUatStaffAccounts(db); + const uatCustomerClientId = await seedUatStaffAccounts(db); // ── Services ── // GRO-2064: key the upsert on `services.id` (not `name`) so deterministic @@ -1058,6 +1090,12 @@ async function seed() { } console.log(`✓ Created ${servicesDef.length} services`); + // GRO-2100: deterministic uat-groomer ↔ UAT Pup Alpha linkage. Must run + // AFTER services are seeded (this helper looks up an active service id + // to attach to the appointment; on a fresh reset there are none yet at + // the time seedUatStaffAccounts() returns). + await seedUatGroomerLinkage(db, uatCustomerClientId); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); -- 2.52.0 From 8721f0b63cacb467be1a4dc7c96672faf814aabd Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 12:06:43 +0000 Subject: [PATCH 08/32] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2154=20geoco?= =?UTF-8?q?ding=20endpoints=20(Phase=201.3)=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 6 +- UAT_PLAYBOOK.md | 18 ++ src/__tests__/clientGeocoding.test.ts | 192 +++++++++++++++++++++ src/__tests__/petProfileSummary.test.ts | 16 -- src/index.ts | 9 + src/routes/clients.ts | 124 +++++++++++++- src/services/clientGeocoding.ts | 212 ++++++++++++++++++++++++ 7 files changed, 555 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/clientGeocoding.test.ts create mode 100644 src/services/clientGeocoding.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d848d3b..1529ed5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -33,11 +33,11 @@ jobs: - name: Typecheck run: | - pnpm --filter @groombook/api typecheck + pnpm run typecheck pnpm --filter @groombook/db typecheck - name: Lint - run: pnpm --filter @groombook/api lint + run: pnpm run lint test: name: Test @@ -58,7 +58,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run tests - run: pnpm --filter @groombook/api test + run: pnpm run test docker: name: Build & Push Docker Images diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 6653db5..47a84bd 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -120,6 +120,24 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-2.5 | Disable client | PATCH /api/clients/{id} with status: "disabled" | 200 OK, client marked as disabled | | TC-API-2.6 | Delete client | DELETE /api/clients/{id}?confirm=true | 200 OK, client deleted (if no appointments) | +#### Client Geocoding — Route Optimization (GRO-2154, Phase 1.3) + +Geocoding turns a client's street address into `latitude`/`longitude` + `geocodedAt`. Provider is driven by `businessSettings.routeOptimizationProvider` (default Nominatim/OpenStreetMap, 1 req/sec; optional Google fallback). All explicit geocode endpoints are **manager-only**. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-2.7 | Geocode single client (success) | As **manager**, `POST /api/clients/{id}/geocode` for a client with a valid, real address (e.g. a seed client) | 200 OK; body `{ status: "geocoded", latitude, longitude, geocodedAt, formattedAddress, provider }`. Subsequent `GET /api/clients/{id}` shows the same non-null `latitude`/`longitude`/`geocodedAt` persisted | +| TC-API-2.8 | Geocode single client — no address | As manager, `POST /api/clients/{id}/geocode` for a client whose `address` is null/blank | 422; `{ status: "no_address", message: "...no address on file..." }` (clear, actionable) | +| TC-API-2.9 | Geocode single client — unresolvable/ambiguous address | As manager, set a nonsense address (e.g. `"asdkjhqweoui 99999"`) then `POST /api/clients/{id}/geocode` | 422; `{ status: "unresolved", message: "Address could not be resolved..." }` so groomers/managers know to correct it | +| TC-API-2.10 | Geocode single client — not found | As manager, `POST /api/clients/00000000-0000-0000-0000-000000000000/geocode` | 404 `{ error: "Not found" }` | +| 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.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) | +| TC-API-2.17 | Clearing address drops coordinates | As manager/receptionist, `PATCH /api/clients/{id}` with `address: ""` | 200 OK; `latitude`/`longitude`/`geocodedAt` reset to null (no stale pin) | + ### 4.3 Pet Management | # | Scenario | Steps | Expected | diff --git a/src/__tests__/clientGeocoding.test.ts b/src/__tests__/clientGeocoding.test.ts new file mode 100644 index 0000000..675bc1e --- /dev/null +++ b/src/__tests__/clientGeocoding.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from "vitest"; +import { + geocodeClient, + geocodeUngeocodedClients, + resolveClientGeocodingProvider, +} from "../services/clientGeocoding.js"; +import { + NominatimGeocodingProvider, + type GeocodeResult, + type GeocodingProvider, +} from "../services/geocoding.js"; + +// ─── Fakes ────────────────────────────────────────────────────────────────── + +/** Fake provider with a scripted geocode behaviour and a call log. */ +function fakeProvider( + impl: (address: string) => Promise +): GeocodingProvider & { calls: string[] } { + const calls: string[] = []; + return { + name: "nominatim", + minRequestIntervalMs: 0, + calls, + geocode: (address: string) => { + calls.push(address); + return impl(address); + }, + }; +} + +const okResult = (lat: number, lng: number): GeocodeResult => ({ + latitude: lat, + longitude: lng, + formattedAddress: "1 Main St, Anytown", + provider: "nominatim", +}); + +/** + * Minimal db double recording update() set-values. `select()` chains return the + * preloaded `selectQueue` shift()ed per call so different statements get + * different rows (used by geocodeUngeocodedClients: count, then rows). + */ +function fakeDb(selectQueue: unknown[][]) { + const updates: Record[] = []; + const queue = [...selectQueue]; + const chain = () => { + const rows = queue.shift() ?? []; + const proxy: Record = {}; + for (const k of ["from", "where", "orderBy", "limit"]) { + proxy[k] = () => proxy; + } + // Make the chain awaitable / iterable as the resolved rows. + (proxy as { then: unknown }).then = (resolve: (v: unknown) => void) => + resolve(rows); + (proxy as { [Symbol.iterator]: unknown })[Symbol.iterator] = () => + (rows as unknown[])[Symbol.iterator](); + return proxy; + }; + const db = { + select: () => chain(), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updates.push(vals); + return { returning: async () => [] }; + }, + }), + }), + updates, + }; + return db as unknown as Parameters[0] & { + updates: Record[]; + }; +} + +const clientRow = (over: Record = {}) => + ({ + id: "client-1", + name: "Alice", + email: "a@example.com", + address: "1 Main St", + latitude: null, + longitude: null, + geocodedAt: null, + ...over, + }) as unknown as Parameters[1]; + +// ─── geocodeClient ──────────────────────────────────────────────────────────── + +describe("geocodeClient", () => { + it("persists coordinates and returns a geocoded outcome", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => okResult(40.1, -74.2)); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("geocoded"); + expect(outcome.latitude).toBe(40.1); + expect(outcome.longitude).toBe(-74.2); + expect(outcome.geocodedAt).toBeTruthy(); + expect(db.updates).toHaveLength(1); + expect(db.updates[0]!.latitude).toBe(40.1); + expect(db.updates[0]!.longitude).toBe(-74.2); + expect(db.updates[0]!.geocodedAt).toBeInstanceOf(Date); + }); + + it("returns no_address and does not persist when address is blank", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => okResult(0, 0)); + const outcome = await geocodeClient(db, clientRow({ address: " " }), provider); + + expect(outcome.status).toBe("no_address"); + expect(provider.calls).toHaveLength(0); + expect(db.updates).toHaveLength(0); + }); + + it("returns unresolved when the provider finds no match", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => null); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("unresolved"); + expect(outcome.message).toMatch(/could not be resolved/i); + expect(db.updates).toHaveLength(0); + }); + + it("returns error (without throwing) when the provider fails", async () => { + const db = fakeDb([]); + const provider = fakeProvider(async () => { + throw new Error("quota exceeded"); + }); + const outcome = await geocodeClient(db, clientRow(), provider); + + expect(outcome.status).toBe("error"); + expect(outcome.message).toMatch(/quota exceeded/); + expect(db.updates).toHaveLength(0); + }); +}); + +// ─── geocodeUngeocodedClients ───────────────────────────────────────────────── + +describe("geocodeUngeocodedClients", () => { + it("geocodes candidates, tallies outcomes, and reports remaining", async () => { + // First select() = count query, second select() = candidate rows. + const db = fakeDb([ + [{ count: 5 }], + [ + clientRow({ id: "c1", address: "1 Main St" }), + clientRow({ id: "c2", address: "2 Oak Ave" }), + clientRow({ id: "c3", address: "" }), // no_address + ], + ]); + const provider = fakeProvider(async (addr) => + addr === "2 Oak Ave" ? null : okResult(1, 2) + ); + + const summary = await geocodeUngeocodedClients(db, 50, provider); + + expect(summary.processed).toBe(3); + expect(summary.geocoded).toBe(1); + expect(summary.unresolved).toBe(1); // "2 Oak Ave" + expect(summary.remaining).toBe(2); // 5 total - 3 processed + expect(summary.provider).toBe("nominatim"); + expect(db.updates).toHaveLength(1); // only the successful one persisted + }); + + it("clamps the limit to the 1..500 range", async () => { + const db = fakeDb([[{ count: 0 }], []]); + const provider = fakeProvider(async () => okResult(1, 2)); + const summary = await geocodeUngeocodedClients(db, 0, provider); + expect(summary.processed).toBe(0); + expect(summary.remaining).toBe(0); + }); +}); + +// ─── resolveClientGeocodingProvider ─────────────────────────────────────────── + +describe("resolveClientGeocodingProvider", () => { + it("defaults to Nominatim when no settings row exists", async () => { + const db = fakeDb([[]]); // businessSettings select -> empty + const provider = await resolveClientGeocodingProvider(db); + expect(provider).toBeInstanceOf(NominatimGeocodingProvider); + expect(provider.name).toBe("nominatim"); + }); + + it("defaults to Nominatim when provider is unset on settings", async () => { + const db = fakeDb([[{ routeOptimizationProvider: null, googleMapsApiKey: null }]]); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const provider = await resolveClientGeocodingProvider(db); + expect(provider.name).toBe("nominatim"); + warn.mockRestore(); + }); +}); diff --git a/src/__tests__/petProfileSummary.test.ts b/src/__tests__/petProfileSummary.test.ts index d18d2da..0ea0f35 100644 --- a/src/__tests__/petProfileSummary.test.ts +++ b/src/__tests__/petProfileSummary.test.ts @@ -131,20 +131,6 @@ function makeAppointment(overrides: Record = {}) { }; } -function makeService(overrides: Record = {}) { - return { - id: "service-1", - name: "Full Groom", - description: null, - basePriceCents: 6000, - durationMinutes: 120, - active: true, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - function makeSession(overrides: Record = {}) { return { id: "sess-owner", @@ -164,7 +150,6 @@ function makeSession(overrides: Record = {}) { let petsTable: Record[]; let appointmentsTable: Record[]; -let servicesTable: Record[]; let sessionsTable: Record[]; // selectQueue: queries resolve in FIFO order. Each .from(table) result @@ -198,7 +183,6 @@ function enqueueThrow(table: string, message: string) { function resetMock() { petsTable = [makePet()]; appointmentsTable = [makeAppointment()]; - servicesTable = [makeService()]; sessionsTable = [makeSession()]; selectQueue = []; insertCapture = []; diff --git a/src/index.ts b/src/index.ts index 3dd8921..2845b14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,6 +235,15 @@ api.on( requireRole("manager", "receptionist", "groomer") ); +// Route-optimization geocoding endpoints are manager-only (GRO-2154), stricter +// than the general client write guard below. Registered FIRST so receptionists +// are rejected here before the manager+receptionist guard can admit them. +api.on( + ["POST"], + ["/clients/geocode-batch", "/clients/:clientId/geocode"], + requireRole("manager") +); + // Clients, appointments: all roles may read; only manager + receptionist may write api.on( ["POST", "PUT", "PATCH", "DELETE"], diff --git a/src/routes/clients.ts b/src/routes/clients.ts index 38104ec..e7ac65c 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -3,9 +3,61 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; +import { + geocodeClient, + geocodeUngeocodedClients, + resolveClientGeocodingProvider, + type ClientGeocodeOutcome, +} from "../services/clientGeocoding.js"; export const clientsRouter = new Hono(); +type ClientRow = typeof clients.$inferSelect; + +/** + * Best-effort auto-geocode of a freshly created/updated client (GRO-2154). + * Never throws: a flaky geocoding backend must not break client mutations. + * Returns the (possibly coordinate-enriched) row plus a structured outcome the + * caller surfaces under a `geocoding` field so ambiguous addresses are visible. + */ +async function autoGeocodeClient( + db: ReturnType, + row: ClientRow +): Promise<{ row: ClientRow; outcome: ClientGeocodeOutcome }> { + try { + const provider = await resolveClientGeocodingProvider(db); + const outcome = await geocodeClient(db, row, provider); + const enriched = + outcome.status === "geocoded" + ? { + ...row, + latitude: outcome.latitude, + longitude: outcome.longitude, + geocodedAt: outcome.geocodedAt + ? new Date(outcome.geocodedAt) + : row.geocodedAt, + } + : row; + return { row: enriched, outcome }; + } catch (err) { + return { + row, + outcome: { + clientId: row.id, + status: "error", + message: `Auto-geocode failed: ${ + err instanceof Error ? err.message : String(err) + }`, + latitude: null, + longitude: null, + geocodedAt: null, + formattedAddress: null, + provider: null, + }, + }; + } +} + const createClientSchema = z.object({ name: z.string().min(1).max(200), email: z.string().email(), @@ -91,9 +143,59 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const [row] = await db.insert(clients).values(body).returning(); + if (!row) return c.json({ error: "Failed to create client" }, 500); + + // Auto-geocode on create when an address is supplied (GRO-2154). Best-effort: + // the client is created regardless; the `geocoding` field surfaces failures. + if (body.address && body.address.trim()) { + const { row: enriched, outcome } = await autoGeocodeClient(db, row); + return c.json({ ...enriched, geocoding: outcome }, 201); + } return c.json(row, 201); }); +// Geocode a single client's address and persist coordinates (manager-only; +// enforced by the route guard in index.ts). +clientsRouter.post("/:clientId/geocode", async (c) => { + const db = getDb(); + const clientId = c.req.param("clientId"); + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)); + if (!client) return c.json({ error: "Not found" }, 404); + + const provider = await resolveClientGeocodingProvider(db); + const outcome = await geocodeClient(db, client, provider); + + // Map outcome to an HTTP status so the result is unambiguous to the caller: + // geocoded -> 200, provider error -> 502, no_address/unresolved -> 422. + const status = + outcome.status === "geocoded" + ? 200 + : outcome.status === "error" + ? 502 + : 422; + return c.json(outcome, status); +}); + +// Batch-geocode un-geocoded clients with provider-rate-limited throttling +// (manager-only). Processes up to ?limit clients (default 50, max 500) per call; +// re-invoke while `remaining` > 0 to finish large datasets. +clientsRouter.post("/geocode-batch", async (c) => { + const db = getDb(); + const limitRaw = c.req.query("limit"); + let limit = 50; + if (limitRaw !== undefined) { + limit = Number(limitRaw); + if (!Number.isFinite(limit) || limit <= 0) { + return c.json({ error: "limit must be a positive integer" }, 400); + } + } + const summary = await geocodeUngeocodedClients(db, limit); + return c.json(summary); +}); + // Update a client (including status changes) const patchClientSchema = createClientSchema.partial().extend({ status: z.enum(["active", "disabled"]).optional(), @@ -123,13 +225,29 @@ clientsRouter.patch( } delete setValues.smsOptOut; - const [row] = await db + // Auto-geocode on address change (GRO-2154). If the address was cleared, + // drop any stale coordinates so a disabled/blank address never keeps a pin. + const addressProvided = Object.prototype.hasOwnProperty.call(body, "address"); + const trimmedAddress = + typeof body.address === "string" ? body.address.trim() : undefined; + if (addressProvided && !trimmedAddress) { + setValues.latitude = null; + setValues.longitude = null; + setValues.geocodedAt = null; + } + + const [updated] = await db .update(clients) .set(setValues) .where(eq(clients.id, c.req.param("id"))) .returning(); - if (!row) return c.json({ error: "Not found" }, 404); - return c.json(row); + if (!updated) return c.json({ error: "Not found" }, 404); + + if (addressProvided && trimmedAddress) { + const { row: enriched, outcome } = await autoGeocodeClient(db, updated); + return c.json({ ...enriched, geocoding: outcome }); + } + return c.json(updated); } ); diff --git a/src/services/clientGeocoding.ts b/src/services/clientGeocoding.ts new file mode 100644 index 0000000..eb16b29 --- /dev/null +++ b/src/services/clientGeocoding.ts @@ -0,0 +1,212 @@ +import { + getDb, + businessSettings, + clients, + and, + eq, + isNull, + sql, +} from "@groombook/db"; +import { + resolveGeocodingProvider, + type GeocodingProvider, + type GeocodeResult, +} from "./geocoding.js"; + +/** + * Client geocoding orchestration (GRO-2154, Phase 1.3 of Route Optimization). + * + * Bridges the provider-agnostic {@link GeocodingProvider} layer (GRO-2153) and + * the `clients` table: resolves the configured provider from `businessSettings`, + * geocodes a client's address, and persists `latitude`/`longitude`/`geocodedAt`. + * + * Outcomes are returned as structured {@link ClientGeocodeOutcome} values so that + * callers (the geocode endpoints and the auto-geocode create/update hook) can + * surface clear, actionable feedback — groomers need to know when an address is + * ambiguous or unresolvable, not just that "something failed". + */ + +type Db = ReturnType; +type ClientRow = typeof clients.$inferSelect; + +/** Status of a single client geocode attempt. */ +export type ClientGeocodeStatus = + /** Coordinates resolved and persisted. */ + | "geocoded" + /** Client has no (non-blank) address on file — nothing to geocode. */ + | "no_address" + /** Provider returned no match; the address is ambiguous or unrecognized. */ + | "unresolved" + /** Provider call failed (transport, quota, bad key). Coordinates unchanged. */ + | "error"; + +/** Structured, UI-surfaceable result of geocoding one client. */ +export interface ClientGeocodeOutcome { + clientId: string; + status: ClientGeocodeStatus; + /** Human-readable explanation, safe to show to managers/groomers. */ + message: string; + latitude: number | null; + longitude: number | null; + geocodedAt: string | null; + /** Provider-normalized address, when a match was found. */ + formattedAddress: string | null; + /** Provider that produced (or attempted) this result. */ + provider: string | null; +} + +/** + * Builds the geocoding provider for the current business settings. A single + * provider instance should be reused across a batch so its internal rate limiter + * throttles the whole run (e.g. Nominatim's 1 req/sec policy). + */ +export async function resolveClientGeocodingProvider( + db: Db +): Promise { + const [settings] = await db.select().from(businessSettings).limit(1); + return resolveGeocodingProvider(settings ?? null); +} + +/** + * Geocodes a single client row through the given provider and persists the + * result on success. Never throws on provider failure — transport/quota errors + * are captured as an `"error"` outcome so callers (especially the create/update + * auto-geocode hook) are not broken by a flaky geocoding backend. + */ +export async function geocodeClient( + db: Db, + client: ClientRow, + provider: GeocodingProvider +): Promise { + const base = { + clientId: client.id, + latitude: null as number | null, + longitude: null as number | null, + geocodedAt: null as string | null, + formattedAddress: null as string | null, + provider: provider.name as string | null, + }; + + const address = client.address?.trim(); + if (!address) { + return { + ...base, + status: "no_address", + message: "Client has no address on file, so it cannot be geocoded.", + }; + } + + let result: GeocodeResult | null; + try { + result = await provider.geocode(address); + } catch (err) { + return { + ...base, + status: "error", + message: `Geocoding provider (${provider.name}) failed: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (!result) { + return { + ...base, + status: "unresolved", + message: `Address could not be resolved to a location: "${address}". Please verify or correct the address.`, + }; + } + + const geocodedAt = new Date(); + await db + .update(clients) + .set({ + latitude: result.latitude, + longitude: result.longitude, + geocodedAt, + updatedAt: geocodedAt, + }) + .where(eq(clients.id, client.id)); + + return { + clientId: client.id, + status: "geocoded", + message: `Geocoded via ${result.provider} to ${result.latitude}, ${result.longitude}.`, + latitude: result.latitude, + longitude: result.longitude, + geocodedAt: geocodedAt.toISOString(), + formattedAddress: result.formattedAddress, + provider: result.provider, + }; +} + +/** Summary returned by {@link geocodeUngeocodedClients}. */ +export interface BatchGeocodeSummary { + provider: string; + /** Number of clients processed in this invocation. */ + processed: number; + geocoded: number; + unresolved: number; + errors: number; + /** Un-geocoded clients with an address that were NOT processed (over `limit`). */ + remaining: number; + /** Per-client outcomes for everything processed this invocation. */ + outcomes: ClientGeocodeOutcome[]; +} + +/** + * Batch-geocodes clients that have an address but no `geocodedAt` yet, throttled + * by the active provider's rate limiter. + * + * Because Nominatim allows only ~1 req/sec, geocoding every un-geocoded client in + * a single HTTP request would risk timeouts on large datasets. Each invocation + * therefore processes at most `limit` clients (default 50, clamped 1..500) and + * reports `remaining`; managers re-run until `remaining` is 0. + */ +export async function geocodeUngeocodedClients( + db: Db, + limit = 50, + injectedProvider?: GeocodingProvider +): Promise { + const effectiveLimit = Math.min(Math.max(Math.trunc(limit) || 0, 1), 500); + const provider = + injectedProvider ?? (await resolveClientGeocodingProvider(db)); + + // Un-geocoded = geocodedAt IS NULL with a non-blank address. + const candidateFilter = and( + isNull(clients.geocodedAt), + sql`${clients.address} IS NOT NULL AND length(trim(${clients.address})) > 0` + ); + + const countRows = await db + .select({ count: sql`count(*)::int` }) + .from(clients) + .where(candidateFilter); + const totalRemaining = countRows[0]?.count ?? 0; + + const rows = await db + .select() + .from(clients) + .where(candidateFilter) + .orderBy(clients.createdAt) + .limit(effectiveLimit); + + const outcomes: ClientGeocodeOutcome[] = []; + for (const row of rows) { + outcomes.push(await geocodeClient(db, row, provider)); + } + + const geocoded = outcomes.filter((o) => o.status === "geocoded").length; + const unresolved = outcomes.filter((o) => o.status === "unresolved").length; + const errors = outcomes.filter((o) => o.status === "error").length; + + return { + provider: provider.name, + processed: outcomes.length, + geocoded, + unresolved, + errors, + remaining: Math.max(totalRemaining - outcomes.length, 0), + outcomes, + }; +} -- 2.52.0 From 587fd4ec9516c5fb2aa9f407128d755880c95ed7 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 16:45:44 +0000 Subject: [PATCH 09/32] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2155=20route?= =?UTF-8?q?=20optimization=20endpoints=20(carries=20GRO-2163)=20(#176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 18 ++ packages/db/package.json | 7 +- packages/db/scripts/wait-for-db.mjs | 104 ++++++ src/__tests__/routeOptimization.test.ts | 184 +++++++++++ src/index.ts | 6 + src/routes/routes.ts | 284 ++++++++++++++++ src/services/routeOptimization.ts | 413 ++++++++++++++++++++++++ 7 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 packages/db/scripts/wait-for-db.mjs create mode 100644 src/__tests__/routeOptimization.test.ts create mode 100644 src/routes/routes.ts create mode 100644 src/services/routeOptimization.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 47a84bd..dace647 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -360,6 +360,24 @@ This means: | TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required | | TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time | +### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1) + +A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer). + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-16.1 | Fetch daily route (auto-create draft) | As **manager**, `GET /api/routes/daily?staffId={groomerId}&date=YYYY-MM-DD` for a date with no existing route | 200 OK; body `{ route, stops }`. `route.status` is `"draft"`, `route.staffId`/`routeDate` match, `stops` is `[]`. Re-calling returns the same route row (no duplicate) | +| TC-API-16.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). Each stop carries `bufferMins` (default 15) | +| TC-API-16.3 | Re-optimize replaces prior order | As manager, run TC-API-16.2 twice | Second call returns 200; stops fully replaced (no duplicate `route_stops`, `stopOrder` still contiguous 1..N), `optimizedAt` refreshed | +| TC-API-16.4 | Skips un-geocoded appointments | As manager, optimize a day where one appointment's client has no coordinates | 200 OK; that appointment is absent from `stops` and listed under `skipped[]` with `reason: "client address is not geocoded"`; a corresponding entry appears in `warnings[]` | +| TC-API-16.5 | Empty / single-stop day | As manager, optimize a date with 0 (or 1) geocoded appointments | 200 OK; `route.status: "optimized"`, `totalTravelMins: 0`, `totalDistanceKm: "0.00"`. For 1 stop, `stops` has one entry with `travelMinsFromPrev: null` | +| TC-API-16.6 | >25 stops chunked with warning | As manager, optimize a day with >25 geocoded appointments | 200 OK; `chunked: true`, `subRouteCount ≥ 2`, a `warnings[]` entry mentions sub-routes; all appointments appear exactly once with contiguous `stopOrder` | +| TC-API-16.7 | Groomer reads own route | As **groomer**, `GET /api/routes/daily?date=YYYY-MM-DD` (omit staffId, or pass own id) | 200 OK; route resolves to the groomer's own `staffId` | +| TC-API-16.8 | Groomer cannot access another's route | As groomer, `GET /api/routes/daily?staffId={otherGroomerId}&date=...` or `POST /api/routes/optimize` with another `staffId` | 403 Forbidden (`groomers may only access their own route`) | +| TC-API-16.9 | Receptionist denied | As **receptionist**, `GET /api/routes/daily?...` or `POST /api/routes/optimize` | 403 Forbidden (role not permitted) | +| TC-API-16.10 | Manager must supply staffId | As manager, `POST /api/routes/optimize` body `{ "date": "YYYY-MM-DD" }` (no staffId) | 400 `{ error: "staffId is required" }` | +| TC-API-16.11 | Invalid date rejected | `GET /api/routes/daily?staffId=...&date=06-08-2026` (wrong format) | 400 validation error (`date must be YYYY-MM-DD`) | + ## Pass/Fail Criteria **Pass:** diff --git a/packages/db/package.json b/packages/db/package.json index 4cdd0d9..7f97370 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,9 +18,10 @@ "scripts": { "build": "tsc --project .", "generate": "drizzle-kit generate", - "migrate": "drizzle-kit migrate", - "seed": "tsx src/seed.ts", - "reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts", + "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", "studio": "drizzle-kit studio", "typecheck": "tsc --noEmit" }, diff --git a/packages/db/scripts/wait-for-db.mjs b/packages/db/scripts/wait-for-db.mjs new file mode 100644 index 0000000..04d9d9e --- /dev/null +++ b/packages/db/scripts/wait-for-db.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// wait-for-db.mjs +// +// GRO-2163: wait for / retry DNS resolution of the database hostname derived +// from DATABASE_URL before invoking `drizzle-kit migrate`. The first attempt +// of a fresh migrate-schema pod occasionally hits a transient CoreDNS miss +// (EAI_AGAIN) on `groombook-postgres-rw..svc`; with backoffLimit: 2 the +// retry pod usually wins, but three unlucky attempts in a row trips +// BackoffLimitExceeded. Resolving once here, with backoff, removes the dice +// roll at the source so the first attempt reliably succeeds. +// +// Mirrors the belt-and-braces pattern used in GRO-1985 (no Corepack +// download fallback): we don't try to outsmart CoreDNS, we just don't ask +// drizzle-kit to do the very first DNS lookup of a freshly-scheduled pod. +// +// Configuration (env): +// WAIT_FOR_DB_MAX_ATTEMPTS default 12 (~30s of total wait at default backoff) +// WAIT_FOR_DB_BASE_DELAY_MS default 500 +// WAIT_FOR_DB_MAX_DELAY_MS default 5000 +// WAIT_FOR_DB_SKIP default unset; set to "1" to skip (debug only) +// +// On success: exit 0. On exhaustion: exit 1 so the Job's backoff is +// preserved (we don't want to silently mask a real outage by giving up +// after 30s and letting drizzle-kit fail with a less-actionable error). + +import { setTimeout as delay } from "node:timers/promises"; +import dns from "node:dns/promises"; + +const MAX_ATTEMPTS = Number(process.env.WAIT_FOR_DB_MAX_ATTEMPTS ?? 12); +const BASE_DELAY_MS = Number(process.env.WAIT_FOR_DB_BASE_DELAY_MS ?? 500); +const MAX_DELAY_MS = Number(process.env.WAIT_FOR_DB_MAX_DELAY_MS ?? 5000); + +function parseHost(databaseUrl) { + try { + return new URL(databaseUrl).hostname || null; + } catch { + return null; + } +} + +async function resolveOnce(host) { + const start = Date.now(); + const result = await dns.lookup(host); + return { address: result.address, ms: Date.now() - start }; +} + +async function main() { + if (process.env.WAIT_FOR_DB_SKIP === "1") { + console.log("[wait-for-db] WAIT_FOR_DB_SKIP=1, skipping"); + return; + } + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + // Don't gate the migrate on a misconfigured env — let drizzle-kit fail + // loudly with its own clear error. + console.warn("[wait-for-db] DATABASE_URL not set; skipping"); + return; + } + const host = parseHost(databaseUrl); + if (!host) { + console.warn(`[wait-for-db] could not parse hostname from DATABASE_URL; skipping`); + return; + } + console.log( + `[wait-for-db] host=${host} max_attempts=${MAX_ATTEMPTS} ` + + `base_delay_ms=${BASE_DELAY_MS} max_delay_ms=${MAX_DELAY_MS}`, + ); + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const { address, ms } = await resolveOnce(host); + console.log(`[wait-for-db] ok attempt=${attempt} host=${host} -> ${address} (${ms}ms)`); + return; + } catch (err) { + const code = err?.code ?? "UNKNOWN"; + const transient = code === "EAI_AGAIN" || code === "ENOTFOUND" || code === "EAI_NODATA"; + if (!transient) { + // Hard error (e.g. invalid hostname): surface and let drizzle-kit fail + // with a real error rather than spinning. + console.error(`[wait-for-db] non-transient DNS error attempt=${attempt} code=${code}: ${err.message}`); + process.exit(1); + } + if (attempt === MAX_ATTEMPTS) { + console.error( + `[wait-for-db] exhausted attempts=${MAX_ATTEMPTS} host=${host} last_code=${code}; exiting 1`, + ); + process.exit(1); + } + const backoff = Math.min( + MAX_DELAY_MS, + BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * BASE_DELAY_MS), + ); + console.log( + `[wait-for-db] transient attempt=${attempt} code=${code} retry_in_ms=${backoff}`, + ); + await delay(backoff); + } + } +} + +main().catch((err) => { + console.error(`[wait-for-db] fatal: ${err?.message ?? err}`); + process.exit(1); +}); diff --git a/src/__tests__/routeOptimization.test.ts b/src/__tests__/routeOptimization.test.ts new file mode 100644 index 0000000..49877d4 --- /dev/null +++ b/src/__tests__/routeOptimization.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { + haversineKm, + estimateLeg, + nearestNeighborOrder, + optimizeRoute, + MAX_STOPS_PER_ROUTE, + type RouteStopInput, +} from "../services/routeOptimization.js"; +import type { FetchLike } from "../services/geocoding.js"; + +/** Builds a fake fetch returning a single JSON body, recording called URLs. */ +function fakeFetch( + body: unknown, + init: { ok?: boolean; status?: number; statusText?: string } = {} +): { fetchImpl: FetchLike; calls: string[] } { + const calls: string[] = []; + const fetchImpl: FetchLike = async (url) => { + calls.push(url); + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? "OK", + json: async () => body, + }; + }; + return { fetchImpl, calls }; +} + +function stop(appointmentId: string, lat: number, lng: number): RouteStopInput { + return { appointmentId, latitude: lat, longitude: lng }; +} + +describe("haversineKm", () => { + it("is zero for the same point", () => { + expect(haversineKm({ latitude: 40, longitude: -74 }, { latitude: 40, longitude: -74 })).toBe(0); + }); + + it("approximates 1 degree of latitude as ~111km", () => { + const d = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 }); + expect(d).toBeGreaterThan(110); + expect(d).toBeLessThan(112); + }); +}); + +describe("estimateLeg", () => { + it("applies the circuity factor and average speed", () => { + const a = { latitude: 0, longitude: 0 }; + const b = { latitude: 0, longitude: 1 }; + const { distanceKm, mins } = estimateLeg(a, b); + // ~111km straight * 1.3 circuity ≈ 144.6km; at 40km/h ≈ 217 min + expect(distanceKm).toBeGreaterThan(140); + expect(distanceKm).toBeLessThan(150); + expect(mins).toBeGreaterThan(200); + expect(mins).toBeLessThan(230); + expect(Number.isInteger(mins)).toBe(true); + }); +}); + +describe("nearestNeighborOrder", () => { + it("returns trivial order for 0 or 1 points", () => { + expect(nearestNeighborOrder([])).toEqual([]); + expect(nearestNeighborOrder([{ latitude: 1, longitude: 1 }])).toEqual([0]); + }); + + it("greedily visits the nearest unvisited point", () => { + // Points on a line; scrambled input order. + const points = [ + { latitude: 0, longitude: 0 }, // 0 (start) + { latitude: 0, longitude: 5 }, // 1 (far) + { latitude: 0, longitude: 1 }, // 2 + { latitude: 0, longitude: 2 }, // 3 + ]; + expect(nearestNeighborOrder(points, 0)).toEqual([0, 2, 3, 1]); + }); +}); + +describe("optimizeRoute — nearest-neighbor fallback (no API key)", () => { + it("returns an empty route for no stops", async () => { + const r = await optimizeRoute([]); + expect(r.stops).toHaveLength(0); + expect(r.totalTravelMins).toBe(0); + expect(r.totalDistanceKm).toBe(0); + expect(r.provider).toBe("nearest_neighbor"); + expect(r.chunked).toBe(false); + }); + + it("handles a single stop with null travel-from-prev", async () => { + const r = await optimizeRoute([stop("a", 40, -74)]); + expect(r.stops).toHaveLength(1); + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + expect(r.stops[0]!.travelDistanceKmFromPrev).toBeNull(); + expect(r.totalTravelMins).toBe(0); + }); + + it("orders multiple stops greedily and sums totals", async () => { + const stops = [ + stop("start", 0, 0), + stop("far", 0, 5), + stop("near1", 0, 1), + stop("near2", 0, 2), + ]; + const r = await optimizeRoute(stops); + expect(r.provider).toBe("nearest_neighbor"); + expect(r.stops.map((s) => s.appointmentId)).toEqual([ + "start", + "near1", + "near2", + "far", + ]); + // First stop has no inbound leg. + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + // Remaining stops have positive travel. + for (const s of r.stops.slice(1)) { + expect(s.travelMinsFromPrev!).toBeGreaterThan(0); + expect(s.travelDistanceKmFromPrev!).toBeGreaterThan(0); + } + const summed = r.stops.reduce((acc, s) => acc + (s.travelMinsFromPrev ?? 0), 0); + expect(r.totalTravelMins).toBe(summed); + }); +}); + +describe("optimizeRoute — Google Directions path", () => { + it("uses optimized waypoint order and real leg metrics, dropping the return leg", async () => { + const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)]; + // waypoints = [B, C]; optimizer reorders them to [C, B] (waypoint_order [1,0]). + // legs: A->C, C->B, B->A(return). The return leg must be dropped. + const { fetchImpl, calls } = fakeFetch({ + status: "OK", + routes: [ + { + waypoint_order: [1, 0], + legs: [ + { distance: { value: 2000 }, duration: { value: 600 } }, // A->C + { distance: { value: 1000 }, duration: { value: 300 } }, // C->B + { distance: { value: 3000 }, duration: { value: 900 } }, // B->A (return, dropped) + ], + }, + ], + }); + + const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl }); + expect(r.provider).toBe("google"); + expect(r.stops.map((s) => s.appointmentId)).toEqual(["A", "C", "B"]); + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + expect(r.stops[1]!.travelDistanceKmFromPrev).toBe(2); // 2000m -> 2km + expect(r.stops[1]!.travelMinsFromPrev).toBe(10); // 600s -> 10min + expect(r.stops[2]!.travelDistanceKmFromPrev).toBe(1); // 1000m + expect(r.stops[2]!.travelMinsFromPrev).toBe(5); // 300s + expect(r.totalDistanceKm).toBe(3); + expect(r.totalTravelMins).toBe(15); + expect(decodeURIComponent(calls[0]!)).toContain("optimize:true"); + }); + + it("falls back to the heuristic when Google returns a non-OK status", async () => { + const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)]; + const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "bad key" }); + const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl }); + // Provider label reflects the chosen strategy (google requested) but a + // warning records the degradation and stops are still ordered. + expect(r.stops).toHaveLength(3); + expect(r.warnings.some((w) => w.includes("offline heuristic"))).toBe(true); + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + }); +}); + +describe("optimizeRoute — >25 stop chunking", () => { + it("splits into sub-routes with a warning and continuous stop ordering", async () => { + const stops: RouteStopInput[] = []; + for (let i = 0; i < MAX_STOPS_PER_ROUTE + 5; i++) { + stops.push(stop(`s${i}`, 0, i * 0.1)); + } + const r = await optimizeRoute(stops); + expect(r.chunked).toBe(true); + expect(r.subRouteCount).toBe(2); + expect(r.warnings.some((w) => w.includes("sub-routes"))).toBe(true); + expect(r.stops).toHaveLength(MAX_STOPS_PER_ROUTE + 5); + // Only the very first stop of the whole route lacks an inbound leg. + expect(r.stops[0]!.travelMinsFromPrev).toBeNull(); + expect(r.stops.slice(1).every((s) => s.travelMinsFromPrev !== null)).toBe(true); + // All appointment ids preserved exactly once. + expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2845b14..681d731 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { settingsRouter } from "./routes/settings.js"; import { authProviderRouter } from "./routes/authProvider.js"; import { searchRouter } from "./routes/search.js"; import { bufferRulesRouter } from "./routes/buffer-rules.js"; +import { routesRouter } from "./routes/routes.js"; import { getObject } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; import { setupRouter } from "./routes/setup.js"; @@ -220,6 +221,10 @@ api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager", "groomer")); api.use("/impersonation/*", requireRole("manager")); +// Route optimization: manager (any groomer's route) or groomer (own route only, +// enforced in-handler). Receptionists have no access. (GRO-2155) +api.use("/routes/*", requireRole("manager", "groomer")); + // 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")); @@ -283,6 +288,7 @@ api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); api.route("/search", searchRouter); api.route("/buffer-rules", bufferRulesRouter); +api.route("/routes", routesRouter); const port = Number(process.env.PORT ?? 3000); await initAuth(); diff --git a/src/routes/routes.ts b/src/routes/routes.ts new file mode 100644 index 0000000..e9a1d41 --- /dev/null +++ b/src/routes/routes.ts @@ -0,0 +1,284 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { + and, + asc, + eq, + gte, + lt, + ne, + getDb, + appointments, + businessSettings, + clients, + groomerRoutes, + routeStops, +} from "@groombook/db"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { + optimizeRoute, + resolveRouteGoogleApiKey, + type RouteStopInput, +} from "../services/routeOptimization.js"; + +export const routesRouter = new Hono(); + +const dailyQuerySchema = z.object({ + staffId: z.string().uuid().optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), +}); + +const optimizeBodySchema = z.object({ + staffId: z.string().uuid().optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), +}); + +/** + * Resolves the target staffId for the request and enforces the groomer-own / + * manager authorization rule. Groomers may only act on their own route; if a + * groomer omits staffId it defaults to their own. Returns either the resolved + * id or an error tuple the caller turns into a JSON response. + */ +function resolveTargetStaffId( + staffRow: StaffRow | undefined, + requestedStaffId: string | undefined +): { staffId: string } | { error: string; status: 400 | 403 } { + const isGroomer = staffRow?.role === "groomer"; + + if (isGroomer) { + if (requestedStaffId && requestedStaffId !== staffRow.id) { + return { + error: "Forbidden: groomers may only access their own route", + status: 403, + }; + } + return { staffId: staffRow.id }; + } + + // Manager: staffId is required (no implicit self — managers plan others' days). + if (!requestedStaffId) { + return { error: "staffId is required", status: 400 }; + } + return { staffId: requestedStaffId }; +} + +/** Day window [date 00:00:00Z, nextDay 00:00:00Z) for filtering appointments. */ +function dayBounds(date: string): { start: Date; end: Date } { + const start = new Date(`${date}T00:00:00.000Z`); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + return { start, end }; +} + +/** Loads a route's persisted stops, enriched with appointment + client detail. */ +async function loadRouteStops(db: ReturnType, routeId: string) { + return db + .select({ + id: routeStops.id, + appointmentId: routeStops.appointmentId, + stopOrder: routeStops.stopOrder, + latitude: routeStops.latitude, + longitude: routeStops.longitude, + travelMinsFromPrev: routeStops.travelMinsFromPrev, + travelDistanceKmFromPrev: routeStops.travelDistanceKmFromPrev, + bufferMins: routeStops.bufferMins, + appointmentStartTime: appointments.startTime, + appointmentEndTime: appointments.endTime, + appointmentStatus: appointments.status, + clientId: clients.id, + clientName: clients.name, + clientAddress: clients.address, + }) + .from(routeStops) + .innerJoin(appointments, eq(routeStops.appointmentId, appointments.id)) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .where(eq(routeStops.routeId, routeId)) + .orderBy(asc(routeStops.stopOrder)); +} + +/** + * GET /api/routes/daily?staffId=&date= + * Fetches (creating a draft if absent) the daily route for a groomer, with all + * persisted stops. Auth: groomer (own) or manager. + */ +routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => { + const db = getDb(); + const { staffId: requestedStaffId, date } = c.req.valid("query"); + + const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + const staffId = resolved.staffId; + + let [route] = await db + .select() + .from(groomerRoutes) + .where( + and( + eq(groomerRoutes.staffId, staffId), + eq(groomerRoutes.routeDate, date) + ) + ); + + if (!route) { + // Create a draft route so the day is addressable before optimization. + [route] = await db + .insert(groomerRoutes) + .values({ staffId, routeDate: date, status: "draft" }) + .returning(); + } + + const stops = await loadRouteStops(db, route!.id); + return c.json({ route, stops }); +}); + +/** + * POST /api/routes/optimize { staffId, date } + * Generates or re-optimizes the daily route: pulls the day's geocoded + * appointments, optimizes the visiting order (Google Directions when a key is + * configured, else nearest-neighbor), and persists the ordered stops + totals. + * Auth: groomer (own) or manager. + */ +routesRouter.post( + "/optimize", + zValidator("json", optimizeBodySchema), + async (c) => { + const db = getDb(); + const { staffId: requestedStaffId, date } = c.req.valid("json"); + + const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + const staffId = resolved.staffId; + const { start, end } = dayBounds(date); + + // Pull the day's non-cancelled appointments for this groomer, joined to the + // client coordinates. Ordered by start time so the earliest booking anchors + // the route. + const dayAppointments = await db + .select({ + appointmentId: appointments.id, + startTime: appointments.startTime, + clientId: clients.id, + clientName: clients.name, + latitude: clients.latitude, + longitude: clients.longitude, + }) + .from(appointments) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .where( + and( + eq(appointments.staffId, staffId), + gte(appointments.startTime, start), + lt(appointments.startTime, end), + ne(appointments.status, "cancelled") + ) + ) + .orderBy(asc(appointments.startTime)); + + const stopInputs: RouteStopInput[] = []; + const skipped: Array<{ appointmentId: string; clientName: string; reason: string }> = + []; + for (const appt of dayAppointments) { + if (appt.latitude == null || appt.longitude == null) { + skipped.push({ + appointmentId: appt.appointmentId, + clientName: appt.clientName, + reason: "client address is not geocoded", + }); + continue; + } + stopInputs.push({ + appointmentId: appt.appointmentId, + latitude: appt.latitude, + longitude: appt.longitude, + }); + } + + const [settings] = await db.select().from(businessSettings).limit(1); + const bufferMins = settings?.defaultTravelBufferMins ?? 15; + + const googleApiKey = await resolveRouteGoogleApiKey(db); + const optimized = await optimizeRoute(stopInputs, { googleApiKey }); + + const warnings = [...optimized.warnings]; + if (skipped.length > 0) { + warnings.push( + `${skipped.length} appointment(s) were skipped because the client address is not geocoded.` + ); + } + + const now = new Date(); + const route = await db.transaction(async (tx) => { + // Upsert the route row for (staffId, date). + const [existing] = await tx + .select() + .from(groomerRoutes) + .where( + and( + eq(groomerRoutes.staffId, staffId), + eq(groomerRoutes.routeDate, date) + ) + ); + + const [routeRow] = existing + ? await tx + .update(groomerRoutes) + .set({ + status: "optimized", + totalTravelMins: optimized.totalTravelMins, + totalDistanceKm: optimized.totalDistanceKm.toFixed(2), + optimizedAt: now, + updatedAt: now, + }) + .where(eq(groomerRoutes.id, existing.id)) + .returning() + : await tx + .insert(groomerRoutes) + .values({ + staffId, + routeDate: date, + status: "optimized", + totalTravelMins: optimized.totalTravelMins, + totalDistanceKm: optimized.totalDistanceKm.toFixed(2), + optimizedAt: now, + }) + .returning(); + + // Replace stops: clear prior ordering, insert the freshly optimized one. + await tx.delete(routeStops).where(eq(routeStops.routeId, routeRow!.id)); + if (optimized.stops.length > 0) { + await tx.insert(routeStops).values( + optimized.stops.map((s, i) => ({ + routeId: routeRow!.id, + appointmentId: s.appointmentId, + stopOrder: i + 1, + latitude: s.latitude, + longitude: s.longitude, + travelMinsFromPrev: s.travelMinsFromPrev, + travelDistanceKmFromPrev: + s.travelDistanceKmFromPrev == null + ? null + : s.travelDistanceKmFromPrev.toFixed(2), + bufferMins, + })) + ); + } + + return routeRow!; + }); + + const stops = await loadRouteStops(db, route.id); + return c.json({ + route, + stops, + provider: optimized.provider, + chunked: optimized.chunked, + subRouteCount: optimized.subRouteCount, + skipped, + warnings, + }); + } +); diff --git a/src/services/routeOptimization.ts b/src/services/routeOptimization.ts new file mode 100644 index 0000000..14de121 --- /dev/null +++ b/src/services/routeOptimization.ts @@ -0,0 +1,413 @@ +import { businessSettings, decryptSecret, type Db } from "@groombook/db"; +import type { FetchLike } from "./geocoding.js"; + +/** + * Route optimization service (GRO-2155, Phase 2.1 of Route Optimization). + * + * Given a groomer's geocoded stops for a day, produces an optimized visiting + * order plus per-leg and total travel estimates. Two strategies: + * + * - {@link optimizeWithGoogle}: Google Maps Directions API with + * `optimizeWaypoints: true` (real road durations/distances), used when a + * Google Maps API key is configured. + * - {@link nearestNeighborOrder}: an offline nearest-neighbor TSP heuristic over + * great-circle distance, used as the default free / no-API-key fallback. + * + * Both strategies share the same public {@link optimizeRoute} orchestrator, + * which also handles the >25-stop edge case by chunking into sub-routes (the + * Google Directions waypoint cap) and surfacing a warning. + */ + +/** Google Directions allows origin + destination + up to 23 waypoints = 25 + * points per request. We cap a sub-route at 25 stops and chunk beyond that. */ +export const MAX_STOPS_PER_ROUTE = 25; + +/** Average driving speed (km/h) used to convert distance into travel minutes in + * the offline heuristic. Tuned for mixed urban/suburban mobile-groomer routes. */ +export const AVG_SPEED_KMH = 40; + +/** Multiplier applied to great-circle distance to approximate real road + * distance in the offline heuristic (straight-line underestimates driving). */ +export const ROAD_CIRCUITY_FACTOR = 1.3; + +const EARTH_RADIUS_KM = 6371; + +/** A geocoded stop to be ordered. `appointmentId` ties it back to the schedule. */ +export interface RouteStopInput { + appointmentId: string; + latitude: number; + longitude: number; +} + +/** A single stop in the optimized order, with travel from the previous stop. */ +export interface OptimizedStop { + appointmentId: string; + latitude: number; + longitude: number; + /** Null for the first stop of the whole route. */ + travelMinsFromPrev: number | null; + /** Null for the first stop of the whole route. Kilometres, 2-dp. */ + travelDistanceKmFromPrev: number | null; +} + +export type RouteOptimizationProvider = "google" | "nearest_neighbor"; + +export interface OptimizedRoute { + provider: RouteOptimizationProvider; + stops: OptimizedStop[]; + totalTravelMins: number; + /** Kilometres, rounded to 2 decimal places. */ + totalDistanceKm: number; + /** True when the route was split into multiple sub-routes (>25 stops). */ + chunked: boolean; + subRouteCount: number; + /** Non-fatal advisories for the caller to surface to the user. */ + warnings: string[]; +} + +export interface OptimizeRouteOptions { + /** Google Maps API key. When absent, the nearest-neighbor heuristic is used. */ + googleApiKey?: string | null; + /** Injectable fetch for testing the Google path. Defaults to global fetch. */ + fetchImpl?: FetchLike; +} + +const defaultFetch: FetchLike = (input, init) => + (globalThis.fetch as unknown as FetchLike)(input, init); + +// ─── Geometry helpers ─────────────────────────────────────────────────────── + +function toRadians(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** Great-circle distance between two coordinates, in kilometres. */ +export function haversineKm( + a: { latitude: number; longitude: number }, + b: { latitude: number; longitude: number } +): number { + const dLat = toRadians(b.latitude - a.latitude); + const dLon = toRadians(b.longitude - a.longitude); + const lat1 = toRadians(a.latitude); + const lat2 = toRadians(b.latitude); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Round to 2 decimal places, returning a finite number. */ +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Estimate a road travel leg from the great-circle distance between two points. + * Applies a circuity factor for distance and a fixed average speed for time. + */ +export function estimateLeg( + a: { latitude: number; longitude: number }, + b: { latitude: number; longitude: number } +): { distanceKm: number; mins: number } { + const straight = haversineKm(a, b); + const distanceKm = straight * ROAD_CIRCUITY_FACTOR; + const mins = (distanceKm / AVG_SPEED_KMH) * 60; + return { distanceKm: round2(distanceKm), mins: Math.round(mins) }; +} + +// ─── Nearest-neighbor heuristic ───────────────────────────────────────────── + +/** + * Orders points greedily: start at `startIndex`, then repeatedly visit the + * nearest unvisited point (great-circle distance). Returns indices into the + * input array in visiting order. Deterministic ties broken by lowest index. + */ +export function nearestNeighborOrder( + points: Array<{ latitude: number; longitude: number }>, + startIndex = 0 +): number[] { + const n = points.length; + if (n <= 1) return points.map((_, i) => i); + + const visited = new Array(n).fill(false); + const order: number[] = [startIndex]; + visited[startIndex] = true; + let current = startIndex; + + for (let step = 1; step < n; step++) { + let best = -1; + let bestDist = Infinity; + for (let j = 0; j < n; j++) { + if (visited[j]) continue; + const d = haversineKm(points[current]!, points[j]!); + if (d < bestDist) { + bestDist = d; + best = j; + } + } + visited[best] = true; + order.push(best); + current = best; + } + return order; +} + +/** Orders one chunk (<= MAX_STOPS_PER_ROUTE) via nearest-neighbor. */ +function optimizeChunkNearestNeighbor( + stops: RouteStopInput[] +): RouteStopInput[] { + const order = nearestNeighborOrder(stops, 0); + return order.map((i) => stops[i]!); +} + +// ─── Google Directions ────────────────────────────────────────────────────── + +const GOOGLE_DIRECTIONS_URL = + "https://maps.googleapis.com/maps/api/directions/json"; + +interface GoogleDirectionsResponse { + status: string; + error_message?: string; + routes?: Array<{ + waypoint_order?: number[]; + legs?: Array<{ + duration?: { value?: number }; + distance?: { value?: number }; + }>; + }>; +} + +/** + * Orders one chunk via the Google Directions API with `optimizeWaypoints=true`. + * + * The first stop is fixed as both origin and destination (a closed tour); the + * remaining stops are passed as optimizable waypoints. We keep the optimized + * forward order and drop the final return-to-origin leg, yielding an open route + * whose per-leg durations/distances come from real road data. + */ +async function optimizeChunkGoogle( + stops: RouteStopInput[], + apiKey: string, + fetchImpl: FetchLike +): Promise<{ stops: RouteStopInput[]; legsMeters: number[]; legsSeconds: number[] }> { + if (stops.length <= 1) { + return { stops: [...stops], legsMeters: [], legsSeconds: [] }; + } + + const origin = stops[0]!; + const waypoints = stops.slice(1); + const url = new URL(GOOGLE_DIRECTIONS_URL); + url.searchParams.set("origin", `${origin.latitude},${origin.longitude}`); + url.searchParams.set("destination", `${origin.latitude},${origin.longitude}`); + url.searchParams.set( + "waypoints", + "optimize:true|" + + waypoints.map((w) => `${w.latitude},${w.longitude}`).join("|") + ); + url.searchParams.set("key", apiKey); + + const res = await fetchImpl(url.toString()); + if (!res.ok) { + throw new Error( + `Google Directions request failed: ${res.status} ${res.statusText}` + ); + } + const body = (await res.json()) as GoogleDirectionsResponse; + if (body.status !== "OK" || !body.routes || body.routes.length === 0) { + throw new Error( + `Google Directions returned status ${body.status}${ + body.error_message ? `: ${body.error_message}` : "" + }` + ); + } + + const route = body.routes[0]!; + const waypointOrder = route.waypoint_order ?? waypoints.map((_, i) => i); + const legs = route.legs ?? []; + + // Ordered stops: origin first, then waypoints in the optimized order. + const orderedStops: RouteStopInput[] = [ + origin, + ...waypointOrder.map((i) => waypoints[i]!), + ]; + + // legs[k] is the travel into orderedStops[k+1]. Drop the trailing return leg + // (orderedStops.length-1 legs describe the open route). + const legsMeters: number[] = []; + const legsSeconds: number[] = []; + for (let k = 0; k < orderedStops.length - 1; k++) { + const leg = legs[k]; + legsMeters.push(leg?.distance?.value ?? 0); + legsSeconds.push(leg?.duration?.value ?? 0); + } + + return { stops: orderedStops, legsMeters, legsSeconds }; +} + +// ─── Orchestration ────────────────────────────────────────────────────────── + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} + +/** + * Optimizes a full day's stops into a single visiting order with travel + * metrics. Uses Google Directions when `googleApiKey` is provided, otherwise the + * offline nearest-neighbor heuristic. Routes longer than + * {@link MAX_STOPS_PER_ROUTE} stops are split into sub-routes and a warning is + * emitted; sub-routes are stitched end-to-end, with the boundary leg estimated + * from great-circle distance. + */ +export async function optimizeRoute( + inputStops: RouteStopInput[], + options: OptimizeRouteOptions = {} +): Promise { + const fetchImpl = options.fetchImpl ?? defaultFetch; + const useGoogle = Boolean(options.googleApiKey); + const provider: RouteOptimizationProvider = useGoogle + ? "google" + : "nearest_neighbor"; + const warnings: string[] = []; + + if (inputStops.length === 0) { + return { + provider, + stops: [], + totalTravelMins: 0, + totalDistanceKm: 0, + chunked: false, + subRouteCount: 0, + warnings, + }; + } + + const chunks = chunk(inputStops, MAX_STOPS_PER_ROUTE); + const chunked = chunks.length > 1; + if (chunked) { + warnings.push( + `Route has ${inputStops.length} stops, exceeding the ${MAX_STOPS_PER_ROUTE}-stop optimization limit. Split into ${chunks.length} sub-routes; review the order at sub-route boundaries.` + ); + } + + const ordered: OptimizedStop[] = []; + let prev: RouteStopInput | null = null; + + for (const group of chunks) { + let groupStops: RouteStopInput[]; + let legDistanceKm: (i: number) => number; + let legMins: (i: number) => number; + + if (useGoogle) { + try { + const result = await optimizeChunkGoogle( + group, + options.googleApiKey!, + fetchImpl + ); + groupStops = result.stops; + legDistanceKm = (i) => round2(result.legsMeters[i]! / 1000); + legMins = (i) => Math.round(result.legsSeconds[i]! / 60); + } catch (err) { + // Google failed mid-optimization — degrade to the offline heuristic for + // this run rather than failing the whole request. + warnings.push( + `Google Directions unavailable; used offline heuristic: ${ + err instanceof Error ? err.message : String(err) + }` + ); + groupStops = optimizeChunkNearestNeighbor(group); + legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm; + legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins; + } + } else { + groupStops = optimizeChunkNearestNeighbor(group); + legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm; + legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins; + } + + for (let i = 0; i < groupStops.length; i++) { + const stop = groupStops[i]!; + if (prev === null) { + // Very first stop of the whole route. + ordered.push({ + appointmentId: stop.appointmentId, + latitude: stop.latitude, + longitude: stop.longitude, + travelMinsFromPrev: null, + travelDistanceKmFromPrev: null, + }); + } else if (i === 0) { + // First stop of a non-initial chunk: estimate the boundary leg. + const est = estimateLeg(prev, stop); + ordered.push({ + appointmentId: stop.appointmentId, + latitude: stop.latitude, + longitude: stop.longitude, + travelMinsFromPrev: est.mins, + travelDistanceKmFromPrev: est.distanceKm, + }); + } else { + ordered.push({ + appointmentId: stop.appointmentId, + latitude: stop.latitude, + longitude: stop.longitude, + travelMinsFromPrev: legMins(i - 1), + travelDistanceKmFromPrev: legDistanceKm(i - 1), + }); + } + prev = stop; + } + } + + const totalTravelMins = ordered.reduce( + (sum, s) => sum + (s.travelMinsFromPrev ?? 0), + 0 + ); + const totalDistanceKm = round2( + ordered.reduce((sum, s) => sum + (s.travelDistanceKmFromPrev ?? 0), 0) + ); + + return { + provider, + stops: ordered, + totalTravelMins, + totalDistanceKm, + chunked, + subRouteCount: chunks.length, + warnings, + }; +} + +// ─── Google API key resolution ────────────────────────────────────────────── + +/** + * Resolves the Google Maps API key for route optimization from + * `businessSettings.googleMapsApiKey` (decrypted at rest) or, as a development + * convenience, the `GOOGLE_MAPS_API_KEY` env var. Returns `null` when no usable + * key exists, in which case callers fall back to the offline heuristic. + */ +export async function resolveRouteGoogleApiKey( + db: Db, + decrypt: (ciphertext: string) => string = decryptSecret +): Promise { + const [settings] = await db.select().from(businessSettings).limit(1); + const stored = settings?.googleMapsApiKey?.trim(); + if (stored) { + try { + const decrypted = decrypt(stored).trim(); + if (decrypted) return decrypted; + } catch (err) { + console.warn( + `Failed to decrypt googleMapsApiKey for route optimization; using offline heuristic: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + } + const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim(); + return fromEnv ? fromEnv : null; +} -- 2.52.0 From eb92f99c4a9b48a7865db85ef64b2761df061a87 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 17:53:01 +0000 Subject: [PATCH 10/32] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2203=20porta?= =?UTF-8?q?l=20pet=20PATCH=20malformed-petId=20500=E2=86=92404=20(#178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 1 + src/__tests__/portalPets.test.ts | 17 ++++++++ src/__tests__/waitlist.test.ts | 70 ++++++++++++++++++++++++++++++++ src/routes/portal.ts | 36 +++++++++++++--- 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index dace647..48c5d54 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -283,6 +283,7 @@ This means: | TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values | | TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted | | TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged | +| 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 | ### 4.9 Waitlist diff --git a/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts index c23bca0..a9e41c2 100644 --- a/src/__tests__/portalPets.test.ts +++ b/src/__tests__/portalPets.test.ts @@ -280,6 +280,23 @@ describe("PATCH /portal/pets/:petId", () => { expect(res.status).toBe(404); }); + it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => { + selectSessionRow = ACTIVE_SESSION; + // A non-UUID petId previously reached `where(eq(pets.id, ...))` and made + // Postgres throw "invalid input syntax for type uuid" → unhandled 500. + // It must now short-circuit to 404 before any select/update. + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/not-a-uuid`, + { coatType: "short" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(404); + expect(updatedValues).toHaveLength(0); + }); + it("returns 422 for an invalid coatType", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; diff --git a/src/__tests__/waitlist.test.ts b/src/__tests__/waitlist.test.ts index 383bc80..5c4d209 100644 --- a/src/__tests__/waitlist.test.ts +++ b/src/__tests__/waitlist.test.ts @@ -184,6 +184,66 @@ describe("POST /portal/waitlist", () => { expect(insertedValues).toHaveLength(1); }); + it("normalizes HH:MM:SS preferredTime and returns 201 (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "10:00:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(201); + expect(insertedValues[0]?.preferredTime).toBe("10:00:00"); + }); + + it("normalizes HH:MM preferredTime to HH:MM:SS before insert (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/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); + expect(insertedValues[0]?.preferredTime).toBe("10:00:00"); + }); + + it("returns 400 (not 500) for a full ISO datetime preferredTime (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "2026-06-09T10:00:00.000Z", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(insertedValues).toHaveLength(0); + }); + + it("returns 400 for a malformed preferredDate (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "03/25/2026", + preferredTime: "10:00", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(insertedValues).toHaveLength(0); + }); + + it("returns 400 for an out-of-range preferredTime (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + const res = await jsonRequest("POST", "/portal/waitlist", { + petId: VALID_UUID_3, + serviceId: VALID_UUID_4, + preferredDate: "2026-03-25", + preferredTime: "25:99", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(insertedValues).toHaveLength(0); + }); + it("returns 401 without session", async () => { const res = await jsonRequest("POST", "/portal/waitlist", { petId: VALID_UUID_3, @@ -258,6 +318,16 @@ describe("PATCH /portal/waitlist/:id", () => { expect(updatedValues[0]?.status).toBe("cancelled"); }); + it("returns 400 (not 500) for a full ISO datetime preferredTime on update (GRO-2211)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectRows = [WAITLIST_ENTRY]; + const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { + preferredTime: "2026-06-09T10:00:00.000Z", + }, { "X-Impersonation-Session-Id": VALID_UUID_5 }); + expect(res.status).toBe(400); + expect(updatedValues).toHaveLength(0); + }); + it("returns 401 without session", async () => { const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { status: "cancelled", diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 0b106fb..aa1593a 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -296,6 +296,14 @@ portalRouter.patch( const body = c.req.valid("json"); const clientId = c.get("portalClientId"); + // GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID + // string to a uuid column makes the driver throw ("invalid input syntax for + // type uuid"), which previously surfaced as an unhandled 500. Mirror the + // GRO-2014 fix in pets.ts and treat a malformed id as Not found. + if (!z.string().uuid().safeParse(petId).success) { + return c.json({ error: "Not found" }, 404); + } + const [pet] = await db .select() .from(pets) @@ -551,17 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => { // ─── Client-facing waitlist routes ──────────────────────────────────────────── +// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO +// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500. +// Constrain client input here so malformed values are rejected with a 400 by +// zValidator before they ever reach the DB (GRO-2211 defense-in-depth). +const preferredDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD"); +const preferredTimeSchema = z + .string() + .regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS"); + +// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format. +function normalizeTime(value: string): string { + return value.length === 5 ? `${value}:00` : value; +} + const createWaitlistEntrySchema = z.object({ petId: z.string().uuid(), serviceId: z.string().uuid(), - preferredDate: z.string(), - preferredTime: z.string(), + preferredDate: preferredDateSchema, + preferredTime: preferredTimeSchema, }); const updateWaitlistEntrySchema = z.object({ status: z.literal("cancelled").optional(), - preferredDate: z.string().optional(), - preferredTime: z.string().optional(), + preferredDate: preferredDateSchema.optional(), + preferredTime: preferredTimeSchema.optional(), }); portalRouter.post( @@ -579,7 +603,7 @@ portalRouter.post( petId: body.petId, serviceId: body.serviceId, preferredDate: body.preferredDate, - preferredTime: body.preferredTime, + preferredTime: normalizeTime(body.preferredTime), }) .returning(); @@ -610,7 +634,7 @@ portalRouter.patch( 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; + if (body.preferredTime !== undefined) updateData.preferredTime = normalizeTime(body.preferredTime); const [updated] = await db .update(waitlistEntries) -- 2.52.0 From 37e42b3104e2f28bebe5f4d7d353d283d17c23e2 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 9 Jun 2026 00:21:03 +0000 Subject: [PATCH 11/32] ci: re-trigger checks (transient pnpm/action-setup runner flake) Co-Authored-By: Paperclip -- 2.52.0 From e9ad92de01d383aefcd23e75f72cbb188aa6034b Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 01:23:06 +0000 Subject: [PATCH 12/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2157=20n?= =?UTF-8?q?av=20export=20+=20GRO-2225/2235=20(frozen=20@4868f18)=20(#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: nav export + conflict guard + UAT seed (GRO-2157, GRO-2225, GRO-2235) Squash-merges PR #192: uat→main PROD promotion. Freezes at validated SHA 4868f18 (UAT regression GRO-2261 11/11 PASS). Bundles: GRO-2157 (nav export), GRO-2225 (UAT seed), GRO-2235 (conflict guard). CTO-reviewed and approved (review #4542). Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- UAT_PLAYBOOK.md | 30 ++- packages/db/src/seed.ts | 210 ++++++++++++++++++ src/__tests__/navigationExport.test.ts | 140 ++++++++++++ src/__tests__/portalWaitlistDuplicate.test.ts | 154 +++++++++++++ src/routes/portal.ts | 36 ++- src/routes/routes.ts | 69 +++++- src/services/navigationExport.ts | 155 +++++++++++++ 7 files changed, 782 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/navigationExport.test.ts create mode 100644 src/__tests__/portalWaitlistDuplicate.test.ts create mode 100644 src/services/navigationExport.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index d590556..78b73f3 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -365,7 +365,12 @@ This means: ### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1) -A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer). +A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** + +**Pre-condition (GRO-2225 — zero-touch; no manual PATCH/geocoding needed).** A fresh UAT reset+seed now provisions a deterministic route cohort, so §4.16 runs directly against seed data: +- **Groomer:** `uat-groomer@groombook.dev` (staffId `00000000-0000-0000-0000-000000000004`). Resolve its id via `GET /api/staff` or sign in as the groomer and omit `staffId`. +- **Date:** `2026-09-15` (fixed). On this date the groomer has **12** confirmed appointments: **10 pre-geocoded** clients clustered in the Seattle metro (multi-stop route) + **2 intentionally un-geocoded** clients (exercise the skip-and-surface path, TC-API-16.4). Cohort clients are named `Route Demo — …` (emails `route-client-NN@uat.groombook.dev`). +- **Receptionist (TC-API-16.9 403):** sign in as `uat-receptionist@groombook.dev` (password from the `seed-uat-passwords` secret, key `SEED_UAT_RECEPTIONIST_PASSWORD`) — a standing receptionist login; no hand-built session required. | # | Scenario | Steps | Expected | |---|----------|-------|----------| @@ -403,6 +408,29 @@ Builds on §4.16. After optimization each consecutive leg carries a travel `buff | TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` | | TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | +### 4.18 Route Optimization — Navigation Export (GRO-2157, Phase 2.3) + +Builds on §4.16/§4.17. Two read-only endpoints turn an optimized route into a native-navigation deep-link URL the frontend opens on the groomer's phone: + +- `GET /api/routes/:routeId/export/google-maps` → Google Maps URLs API link (`https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=…&destination=…&waypoints=…`) +- `GET /api/routes/:routeId/export/apple-maps` → Apple Maps URL scheme (`maps://?saddr=…&daddr=+to:…&dirflg=d`) + +Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = first stop, destination = last stop, the rest are ordered intermediate waypoints**. Each response body is `{ platform, url, stopCount, waypointCount }` where `waypointCount` = stops minus origin and destination. Waypoint limits are validated per platform: **Google Maps ≤ 9**, **Apple Maps ≤ 15** intermediate waypoints; over-limit routes return 400. **Auth: manager (any route) or groomer (own route only); receptionists have no access.** + +| ID | Scenario | Steps | Expected | +|----|----------|-------|----------| +| TC-API-18.1 | Google Maps export of a multi-stop route | As manager, optimize a multi-stop day (§4.16), then `GET /api/routes/{routeId}/export/google-maps` | 200 OK; `platform:"google-maps"`, `url` starts `https://www.google.com/maps/dir/?api=1`, contains `travelmode=driving`, `origin`/`destination` are the first/last stop coords, `waypoints` lists the middle stops in order (pipe-separated). `stopCount` = total stops, `waypointCount` = `stopCount − 2` | +| TC-API-18.2 | Apple Maps export of a multi-stop route | As manager, `GET /api/routes/{routeId}/export/apple-maps` for the same route | 200 OK; `platform:"apple-maps"`, `url` starts `maps://?saddr=`, `daddr` chains the remaining stops with `+to:`, ends `&dirflg=d`; `stopCount`/`waypointCount` as above | +| TC-API-18.3 | Single-stop route | Export a route (google-maps and apple-maps) that has exactly one stop | 200 OK; `waypointCount:0`. Google url has `destination` and no `waypoints=`; Apple url is `maps://?daddr=&dirflg=d` (no `saddr`) | +| TC-API-18.4 | Empty route rejected | Export a route with no stops (a fresh `draft` route) | 400 `{ error: "route has no stops to export" }` | +| TC-API-18.5 | Google waypoint limit | Export (google-maps) a route with >11 stops (>9 intermediate waypoints) | 400 with an `error` mentioning Google Maps' limit of 9 | +| TC-API-18.6 | Apple waypoint limit | Export (apple-maps) a route with >17 stops (>15 intermediate waypoints) | 400 with an `error` mentioning Apple Maps' limit of 15 | +| TC-API-18.7 | Unknown route | `GET /api/routes/{randomUuid}/export/google-maps` | 404 `{ error: "Route not found" }` | +| TC-API-18.8 | Invalid routeId | `GET /api/routes/not-a-uuid/export/apple-maps` | 400 `{ error: "routeId must be a UUID" }` | +| TC-API-18.9 | Groomer exports own route | As **groomer**, export a route owned by self | 200 OK; deep-link returned | +| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) | +| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) | + ## Pass/Fail Criteria **Pass:** diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 0959be0..55b2ee4 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -456,6 +456,36 @@ async function seedUatStaffAccounts( } } + // ── Staff: UAT Receptionist (GRO-2225) ────────────────────────────────────── + // Standing receptionist staff record so the route-optimization 403 path + // (TC-API-16.9: receptionist GET/POST /api/routes → 403) is reproducible + // without a hand-built session. The matching Better-Auth credential is + // provisioned below from SEED_UAT_RECEPTIONIST_PASSWORD. Created here (gated + // on the password env) so the credential loop's staff-link step finds it. + if (process.env.SEED_UAT_RECEPTIONIST_PASSWORD) { + const UAT_RECEPTIONIST_STAFF_ID = "00000000-0000-0000-0000-000000000099"; + const [existingReceptionist] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "uat-receptionist@groombook.dev")) + .limit(1); + + if (existingReceptionist) { + console.log(`✓ Staff 'UAT Receptionist' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: UAT_RECEPTIONIST_STAFF_ID, + name: "UAT Receptionist", + email: "uat-receptionist@groombook.dev", + oidcSub: "uat-receptionist@groombook.dev", + role: "receptionist", + isSuperUser: false, + active: true, + }); + console.log(`✓ Created staff 'UAT Receptionist' (uat-receptionist@groombook.dev)`); + } + } + // ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ── const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? []; const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? []; @@ -495,6 +525,8 @@ async function seedUatStaffAccounts( { email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" }, { email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null }, { email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" }, + // GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9). + { email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" }, ]; for (const acct of uatPasswordAccounts) { @@ -798,6 +830,179 @@ async function seedUatGroomerLinkage( ); } +// ── GRO-2225: deterministic route-optimization cohort ──────────────────────── + +/** + * GRO-2225: seed a deterministic, pre-geocoded client cohort + a fixed-date set + * of appointments for the UAT groomer so the route-optimization endpoints + * (`GET /api/routes/daily`, `POST /api/routes/optimize`, UAT §4.16 + * TC-API-16.1…16.11) are exercisable with ZERO manual PATCHing. + * + * Design (no live geocoder — UAT has no Google Maps key, provider is + * nearest_neighbor; coordinates are hand-picked fixtures clustered in the + * Seattle metro): + * - All appointments are on a FIXED calendar date (ROUTE_DATE) and assigned to + * the UAT groomer (`uat-groomer@groombook.dev`). The optimize endpoint pulls + * non-cancelled appointments in [date 00:00Z, +24h) joined to client coords. + * - 10 clients carry deterministic lat/lng → a multi-stop optimized route. + * - 2 clients are intentionally left UN-geocoded so the "skipped + surfaced" + * path (TC-API-16.5) stays reproducible. + * + * Idempotent: clients/pets are upserted by fixed UUID (they are NOT truncated on + * reset); appointments are upserted by fixed UUID too (they ARE truncated on + * reset, but the upsert keeps re-runs safe in non-truncating dev/test paths). + * Skips cleanly when the UAT groomer staff record is absent (e.g. prod/demo or a + * dev seed without the UAT personas). + */ +async function seedUatRouteCohort(db: ReturnType): Promise { + // Fixed calendar date the UAT playbook hardcodes for §4.16. Times are UTC so + // they fall inside the optimize endpoint's [date 00:00Z, +24h) day window. + const ROUTE_DATE = "2026-09-15"; + + const [uatGroomer] = await db + .select({ id: schema.staff.id }) + .from(schema.staff) + .where(eq(schema.staff.email, "uat-groomer@groombook.dev")) + .limit(1); + if (!uatGroomer) { + console.log("✓ GRO-2225: uat-groomer not present — skipping route cohort"); + return; + } + + // Resolve a service for the appointments: prefer Bath & Brush, else any active. + 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-2225: no active services found — skipping route cohort"); + return; + } + serviceId = fallback.id; + } + + // Hand-picked fixture coordinates clustered in the Seattle metro. `coords:null` + // marks an intentionally un-geocoded client (skip-and-surface path TC-16.5). + const cohort: Array<{ + n: number; + name: string; + coords: { lat: number; lng: number } | null; + }> = [ + { n: 1, name: "Route Demo — Ada Lovelace", coords: { lat: 47.6097, lng: -122.3331 } }, + { n: 2, name: "Route Demo — Grace Hopper", coords: { lat: 47.6205, lng: -122.3493 } }, + { n: 3, name: "Route Demo — Alan Turing", coords: { lat: 47.5990, lng: -122.3300 } }, + { n: 4, name: "Route Demo — Katherine Johnson", coords: { lat: 47.6150, lng: -122.3200 } }, + { n: 5, name: "Route Demo — Edsger Dijkstra", coords: { lat: 47.6280, lng: -122.3550 } }, + { n: 6, name: "Route Demo — Barbara Liskov", coords: { lat: 47.5920, lng: -122.3150 } }, + { n: 7, name: "Route Demo — Donald Knuth", coords: { lat: 47.6350, lng: -122.3400 } }, + { n: 8, name: "Route Demo — Margaret Hamilton", coords: { lat: 47.6050, lng: -122.3600 } }, + { n: 9, name: "Route Demo — Ken Thompson", coords: { lat: 47.6420, lng: -122.3250 } }, + { n: 10, name: "Route Demo — Radia Perlman", coords: { lat: 47.5880, lng: -122.3450 } }, + // Intentionally un-geocoded — exercises the skip-and-surface path. + { n: 11, name: "Route Demo — Ungeocoded One", coords: null }, + { n: 12, name: "Route Demo — Ungeocoded Two", coords: null }, + ]; + + // Stagger appointments 45 min apart starting 15:00Z on ROUTE_DATE. + const dayStartMs = new Date(`${ROUTE_DATE}T15:00:00.000Z`).getTime(); + const SLOT_MS = 45 * 60 * 1000; + + let geocodedCount = 0; + let ungeocodedCount = 0; + for (const c of cohort) { + const pad = String(c.n).padStart(2, "0"); + const clientId = `d0000000-0000-0000-0000-0000000000${pad}`; + const petId = `d0000000-0000-0000-0000-0000000001${pad}`; + const apptId = `d0000000-0000-0000-0000-0000000002${pad}`; + const geocodedAt = c.coords ? new Date(`${ROUTE_DATE}T00:00:00.000Z`) : null; + + await db.insert(schema.clients) + .values({ + id: clientId, + name: c.name, + email: `route-client-${pad}@uat.groombook.dev`, + phone: `(206) 555-01${pad}`, + address: `${100 + c.n} Pike Street, Seattle, WA 98101`, + status: "active", + latitude: c.coords?.lat ?? null, + longitude: c.coords?.lng ?? null, + geocodedAt, + }) + .onConflictDoUpdate({ + target: schema.clients.id, + set: { + name: c.name, + address: `${100 + c.n} Pike Street, Seattle, WA 98101`, + latitude: c.coords?.lat ?? null, + longitude: c.coords?.lng ?? null, + geocodedAt, + }, + }); + + await db.insert(schema.pets) + .values({ + id: petId, + clientId, + name: `Route Pup ${c.n}`, + species: "Dog", + breed: "Mixed", + weightKg: "18.00", + }) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { clientId, name: `Route Pup ${c.n}`, species: "Dog" }, + }); + + const startTime = new Date(dayStartMs + (c.n - 1) * SLOT_MS); + const endTime = new Date(startTime.getTime() + SLOT_MS); + await db.insert(schema.appointments) + .values({ + id: apptId, + clientId, + petId, + serviceId, + staffId: uatGroomer.id, + batherStaffId: null, + status: "confirmed", + startTime, + endTime, + notes: "GRO-2225: deterministic route-optimization cohort appointment.", + priceCents: null, + confirmationStatus: "confirmed", + }) + .onConflictDoUpdate({ + target: schema.appointments.id, + set: { + clientId, + petId, + serviceId, + staffId: uatGroomer.id, + status: "confirmed", + startTime, + endTime, + }, + }); + + if (c.coords) geocodedCount++; + else ungeocodedCount++; + } + + console.log( + `✓ GRO-2225: seeded route cohort for ${ROUTE_DATE} — ${geocodedCount} geocoded + ${ungeocodedCount} un-geocoded appointment(s) for uat-groomer (${uatGroomer.id})`, + ); +} + // ── Known-users-only seed (prod/demo) ─────────────────────────────────────── /** @@ -1169,6 +1374,11 @@ async function runSeedBody( // the time seedUatStaffAccounts() returns). await seedUatGroomerLinkage(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 + // service id for the appointments). Skips cleanly if uat-groomer is absent. + await seedUatRouteCohort(db); + // ── Clients & Pets ── const now = new Date(); const appointmentsBackDate = new Date(now); diff --git a/src/__tests__/navigationExport.test.ts b/src/__tests__/navigationExport.test.ts new file mode 100644 index 0000000..902cb9d --- /dev/null +++ b/src/__tests__/navigationExport.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { + buildGoogleMapsUrl, + buildAppleMapsUrl, + buildNavigationUrl, + intermediateWaypointCount, + GOOGLE_MAPS_MAX_WAYPOINTS, + APPLE_MAPS_MAX_WAYPOINTS, + type NavigationStop, +} from "../services/navigationExport.js"; + +function stops(n: number): NavigationStop[] { + return Array.from({ length: n }, (_, i) => ({ + latitude: 47 + i / 100, + longitude: -122 - i / 100, + label: `Stop ${i + 1}`, + })); +} + +describe("intermediateWaypointCount", () => { + it("excludes origin and destination", () => { + expect(intermediateWaypointCount(0)).toBe(0); + expect(intermediateWaypointCount(1)).toBe(0); + expect(intermediateWaypointCount(2)).toBe(0); + expect(intermediateWaypointCount(5)).toBe(3); + }); +}); + +describe("buildGoogleMapsUrl", () => { + it("rejects an empty route", () => { + const r = buildGoogleMapsUrl([]); + expect(r).toEqual({ error: "route has no stops to export", status: 400 }); + }); + + it("builds a single-stop link (destination only, no waypoints)", () => { + const r = buildGoogleMapsUrl(stops(1)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("google-maps"); + expect(r.stopCount).toBe(1); + expect(r.waypointCount).toBe(0); + expect(r.url).toContain("https://www.google.com/maps/dir/?"); + expect(r.url).toContain("api=1"); + expect(r.url).toContain("travelmode=driving"); + expect(r.url).toContain("origin=47%2C-122"); + expect(r.url).toContain("destination=47%2C-122"); + expect(r.url).not.toContain("waypoints="); + }); + + it("builds origin/destination only for two stops", () => { + const r = buildGoogleMapsUrl(stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(0); + expect(r.url).not.toContain("waypoints="); + expect(r.url).toContain("origin=47%2C-122"); + expect(r.url).toContain("destination=47.01%2C-122.01"); + }); + + it("includes intermediate waypoints in order, pipe-separated", () => { + const r = buildGoogleMapsUrl(stops(4)); + if ("error" in r) throw new Error(r.error); + expect(r.stopCount).toBe(4); + expect(r.waypointCount).toBe(2); + // waypoints param holds stops[1] and stops[2], pipe-joined (encoded %7C) + const url = new URL(r.url); + expect(url.searchParams.get("origin")).toBe("47,-122"); + expect(url.searchParams.get("destination")).toBe("47.03,-122.03"); + expect(url.searchParams.get("waypoints")).toBe( + "47.01,-122.01|47.02,-122.02" + ); + }); + + it("accepts a route at exactly the waypoint limit", () => { + const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(GOOGLE_MAPS_MAX_WAYPOINTS); + }); + + it("rejects a route over the waypoint limit", () => { + const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 3)); + expect("error" in r).toBe(true); + if ("error" in r) { + expect(r.status).toBe(400); + expect(r.error).toContain(`${GOOGLE_MAPS_MAX_WAYPOINTS}`); + } + }); +}); + +describe("buildAppleMapsUrl", () => { + it("rejects an empty route", () => { + const r = buildAppleMapsUrl([]); + expect(r).toEqual({ error: "route has no stops to export", status: 400 }); + }); + + it("builds a destination-only link for one stop", () => { + const r = buildAppleMapsUrl(stops(1)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("apple-maps"); + expect(r.url).toBe("maps://?daddr=47,-122&dirflg=d"); + expect(r.url).not.toContain("saddr="); + }); + + it("chains destinations with +to: for multiple stops", () => { + const r = buildAppleMapsUrl(stops(3)); + if ("error" in r) throw new Error(r.error); + expect(r.stopCount).toBe(3); + expect(r.waypointCount).toBe(1); + expect(r.url).toBe( + "maps://?saddr=47,-122&daddr=47.01,-122.01+to:47.02,-122.02&dirflg=d" + ); + }); + + it("accepts a route at exactly the waypoint limit", () => { + const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 2)); + if ("error" in r) throw new Error(r.error); + expect(r.waypointCount).toBe(APPLE_MAPS_MAX_WAYPOINTS); + }); + + it("rejects a route over the waypoint limit", () => { + const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 3)); + expect("error" in r).toBe(true); + if ("error" in r) { + expect(r.status).toBe(400); + expect(r.error).toContain(`${APPLE_MAPS_MAX_WAYPOINTS}`); + } + }); +}); + +describe("buildNavigationUrl", () => { + it("dispatches to the google-maps builder", () => { + const r = buildNavigationUrl("google-maps", stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("google-maps"); + }); + + it("dispatches to the apple-maps builder", () => { + const r = buildNavigationUrl("apple-maps", stops(2)); + if ("error" in r) throw new Error(r.error); + expect(r.platform).toBe("apple-maps"); + }); +}); diff --git a/src/__tests__/portalWaitlistDuplicate.test.ts b/src/__tests__/portalWaitlistDuplicate.test.ts new file mode 100644 index 0000000..c0edbc6 --- /dev/null +++ b/src/__tests__/portalWaitlistDuplicate.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// GRO-2235: a duplicate active waitlist entry violates the partial unique index +// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the +// handler must return a friendly 409, not a generic 500. The first insert still +// returns 201, and unrelated errors still surface as 500. + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; +const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + reason: "manual", + startedAt: new Date(), + expiresAt: futureDate(), + createdAt: new Date(), +}; + +// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws +// a postgres-js-shaped unique-violation, "other" throws an unrelated error. +let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok"; + +function resetMock() { + waitlistInsertMode = "ok"; +} + +function tableProxy(name: string) { + return new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const impersonationSessions = tableProxy("impersonationSessions"); + const waitlistEntries = tableProxy("waitlistEntries"); + const impersonationAuditLogs = tableProxy("impersonationAuditLogs"); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable([ACTIVE_SESSION]); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "waitlistEntries") { + if (waitlistInsertMode === "duplicate") { + throw Object.assign(new Error("duplicate key value"), { code: "23505" }); + } + if (waitlistInsertMode === "other") { + throw Object.assign(new Error("not null violation"), { code: "23502" }); + } + return [{ id: "entry-1", ...vals }]; + } + // impersonationAuditLogs and anything else: succeed silently. + return [{ id: "audit-1", ...vals }]; + }, + }), + }), + update: () => ({ + set: () => ({ where: () => Promise.resolve() }), + }), + }), + impersonationSessions, + waitlistEntries, + impersonationAuditLogs, + appointments: tableProxy("appointments"), + clients: tableProxy("clients"), + pets: tableProxy("pets"), + services: tableProxy("services"), + staff: tableProxy("staff"), + invoices: tableProxy("invoices"), + invoiceLineItems: tableProxy("invoiceLineItems"), + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function postWaitlist(body: unknown) { + return app.request("/portal/waitlist", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Impersonation-Session-Id": SESSION_ID, + }, + body: JSON.stringify(body), + }); +} + +const VALID_BODY = { + petId: PET_ID, + serviceId: SERVICE_ID, + preferredDate: "2026-07-01", + preferredTime: "09:00", +}; + +beforeEach(() => resetMock()); + +describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => { + it("returns 201 for the first insert", async () => { + waitlistInsertMode = "ok"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(201); + }); + + it("returns 409 with a friendly message for a duplicate (23505)", async () => { + waitlistInsertMode = "duplicate"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(409); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe( + "You already have a booking for this pet at that date and time." + ); + }); + + it("still surfaces unrelated DB errors as 500", async () => { + waitlistInsertMode = "other"; + const res = await postWaitlist(VALID_BODY); + expect(res.status).toBe(500); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index d614e51..3c7dab9 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -596,16 +596,32 @@ portalRouter.post( const body = c.req.valid("json"); const clientId = c.get("portalClientId"); - const [entry] = await db - .insert(waitlistEntries) - .values({ - clientId, - petId: body.petId, - serviceId: body.serviceId, - preferredDate: body.preferredDate, - preferredTime: normalizeTime(body.preferredTime), - }) - .returning(); + let entry; + try { + [entry] = await db + .insert(waitlistEntries) + .values({ + clientId, + petId: body.petId, + serviceId: body.serviceId, + preferredDate: body.preferredDate, + preferredTime: normalizeTime(body.preferredTime), + }) + .returning(); + } catch (err) { + // An exact duplicate active waitlist entry violates the partial unique + // index idx_waitlist_active_unique (client_id, pet_id, service_id, + // preferred_date, preferred_time WHERE status='active'). postgres-js + // surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a + // generic 500 (GRO-2235). Unrelated errors still surface as 500. + if ((err as { code?: string })?.code === "23505") { + return c.json( + { error: "You already have a booking for this pet at that date and time." }, + 409 + ); + } + throw err; + } return c.json(entry, 201); } diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 3bf905a..898b16a 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,4 +1,4 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { @@ -24,6 +24,11 @@ import { type RouteStopInput, type StopConflictFlags, } from "../services/routeOptimization.js"; +import { + buildNavigationUrl, + type NavigationPlatform, + type NavigationStop, +} from "../services/navigationExport.js"; export const routesRouter = new Hono(); @@ -460,3 +465,65 @@ routesRouter.patch( }); } ); + +/** + * GET /:routeId/export/:platform — build a native-navigation deep-link URL for an + * optimized route. Origin = first stop, destination = last stop, the rest carried + * as ordered intermediate waypoints. Waypoint count is validated against the + * platform's limit. Auth: manager (any route) or groomer (own route only). + */ +async function handleNavigationExport( + c: Context, + platform: NavigationPlatform +) { + const db = getDb(); + const routeId = c.req.param("routeId"); + if (!routeId || !z.string().uuid().safeParse(routeId).success) { + return c.json({ error: "routeId must be a UUID" }, 400); + } + + const [route] = await db + .select() + .from(groomerRoutes) + .where(eq(groomerRoutes.id, routeId)); + if (!route) { + return c.json({ error: "Route not found" }, 404); + } + + // Reuse the groomer-own / manager authorization rule against the route owner. + const resolved = resolveTargetStaffId(c.get("staff"), route.staffId); + if ("error" in resolved) { + return c.json({ error: resolved.error }, resolved.status); + } + + const stops = await loadRouteStops(db, routeId); + if (stops.length === 0) { + return c.json({ error: "route has no stops to export" }, 400); + } + + const navStops: NavigationStop[] = stops.map((s) => ({ + latitude: s.latitude, + longitude: s.longitude, + label: s.clientName, + })); + + const result = buildNavigationUrl(platform, navStops); + if ("error" in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json({ + platform: result.platform, + url: result.url, + stopCount: result.stopCount, + waypointCount: result.waypointCount, + }); +} + +routesRouter.get("/:routeId/export/google-maps", (c) => + handleNavigationExport(c, "google-maps") +); + +routesRouter.get("/:routeId/export/apple-maps", (c) => + handleNavigationExport(c, "apple-maps") +); diff --git a/src/services/navigationExport.ts b/src/services/navigationExport.ts new file mode 100644 index 0000000..ecac68a --- /dev/null +++ b/src/services/navigationExport.ts @@ -0,0 +1,155 @@ +// Navigation export — turn an optimized groomer route into a deep-link URL that +// opens the device's native navigation app (Google Maps / Apple Maps). +// +// A route is exported as: origin = first stop, destination = last stop, with the +// in-between stops carried as ordered intermediate waypoints. Each platform caps +// how many intermediate waypoints a deep link may carry, so callers must validate +// the route length before handing the URL to the client. + +/** + * Max intermediate waypoints a Google Maps URLs API deep link supports + * (`https://www.google.com/maps/dir/?api=1&...&waypoints=...`). Google documents + * a ceiling of 9 waypoints between origin and destination. + */ +export const GOOGLE_MAPS_MAX_WAYPOINTS = 9; + +/** + * Max intermediate waypoints we allow in an Apple Maps `maps://` deep link. Apple's + * URL scheme chains destinations with `+to:` but does not publish a hard cap; 15 is + * a conservative practical limit that keeps the URL well under length limits. + */ +export const APPLE_MAPS_MAX_WAYPOINTS = 15; + +export type NavigationPlatform = "google-maps" | "apple-maps"; + +/** A single ordered point on the route. `label` is optional, for display only. */ +export interface NavigationStop { + latitude: number; + longitude: number; + label?: string | null; +} + +export interface NavigationExportSuccess { + platform: NavigationPlatform; + url: string; + /** Total stops included (origin + waypoints + destination). */ + stopCount: number; + /** Intermediate waypoints only (excludes origin and destination). */ + waypointCount: number; +} + +export interface NavigationExportError { + error: string; + status: 400; +} + +export type NavigationExportResult = + | NavigationExportSuccess + | NavigationExportError; + +function isError(r: NavigationExportResult): r is NavigationExportError { + return "error" in r; +} + +/** Intermediate waypoints = every stop that is neither origin nor destination. */ +export function intermediateWaypointCount(stopCount: number): number { + return Math.max(0, stopCount - 2); +} + +function coord(stop: NavigationStop): string { + return `${stop.latitude},${stop.longitude}`; +} + +/** + * Builds a Google Maps URLs API driving deep link. On mobile this opens the + * native Google Maps app; on desktop it opens maps.google.com. + */ +export function buildGoogleMapsUrl( + stops: NavigationStop[] +): NavigationExportResult { + if (stops.length === 0) { + return { error: "route has no stops to export", status: 400 }; + } + const waypointCount = intermediateWaypointCount(stops.length); + if (waypointCount > GOOGLE_MAPS_MAX_WAYPOINTS) { + return { + error: `route has ${waypointCount} intermediate waypoints, exceeding Google Maps' limit of ${GOOGLE_MAPS_MAX_WAYPOINTS}`, + status: 400, + }; + } + + const origin = stops[0]!; + const destination = stops[stops.length - 1]!; + const params = new URLSearchParams(); + params.set("api", "1"); + params.set("travelmode", "driving"); + params.set("origin", coord(origin)); + params.set("destination", coord(destination)); + if (stops.length > 2) { + const mids = stops + .slice(1, -1) + .map(coord) + .join("|"); + params.set("waypoints", mids); + } + + return { + platform: "google-maps", + url: `https://www.google.com/maps/dir/?${params.toString()}`, + stopCount: stops.length, + waypointCount, + }; +} + +/** + * Builds an Apple Maps `maps://` driving deep link. The first stop is the source + * (`saddr`); the remaining stops are chained as destinations with `+to:` (`daddr`). + * Built by hand because the `+to:` separators are part of Apple's scheme and must + * not be percent-encoded. + */ +export function buildAppleMapsUrl( + stops: NavigationStop[] +): NavigationExportResult { + if (stops.length === 0) { + return { error: "route has no stops to export", status: 400 }; + } + const waypointCount = intermediateWaypointCount(stops.length); + if (waypointCount > APPLE_MAPS_MAX_WAYPOINTS) { + return { + error: `route has ${waypointCount} intermediate waypoints, exceeding Apple Maps' limit of ${APPLE_MAPS_MAX_WAYPOINTS}`, + status: 400, + }; + } + + const params: string[] = ["dirflg=d"]; + if (stops.length === 1) { + // Single stop: destination only, no source. + params.unshift(`daddr=${coord(stops[0]!)}`); + } else { + const daddr = stops + .slice(1) + .map(coord) + .join("+to:"); + params.unshift(`daddr=${daddr}`); + params.unshift(`saddr=${coord(stops[0]!)}`); + } + + return { + platform: "apple-maps", + url: `maps://?${params.join("&")}`, + stopCount: stops.length, + waypointCount, + }; +} + +/** Dispatches to the correct builder for the requested platform. */ +export function buildNavigationUrl( + platform: NavigationPlatform, + stops: NavigationStop[] +): NavigationExportResult { + return platform === "google-maps" + ? buildGoogleMapsUrl(stops) + : buildAppleMapsUrl(stops); +} + +export { isError as isNavigationExportError }; -- 2.52.0 From 2566fb8f20affc843ba0e91cfd652a95a82fe12e Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:27:17 +0000 Subject: [PATCH 13/32] Promote GRO-2294 to UAT: Route Optimization security hardening (#194) --- UAT_PLAYBOOK.md | 3 +- src/__tests__/geocodeBatchLimit.test.ts | 89 ++++++++++++++++++++++++ src/__tests__/settings.test.ts | 91 +++++++++++++++++++++++++ src/routes/clients.ts | 11 ++- src/routes/settings.ts | 16 ++++- 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/geocodeBatchLimit.test.ts create mode 100644 src/__tests__/settings.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 78b73f3..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) | @@ -331,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}$/; -- 2.52.0 From 8cd5a2ef4db1b5a1913c52d351e0e26e03ac629c Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 06:58:39 +0000 Subject: [PATCH 14/32] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2299=20redac?= =?UTF-8?q?t=20googleMapsApiKey=20from=20PATCH=20/api/admin/settings=20(#1?= =?UTF-8?q?96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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)); } ); -- 2.52.0 From 2b92c2ab6cc24ab527e751047670e273bf4d32d3 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 07:38:02 +0000 Subject: [PATCH 15/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2294=20R?= =?UTF-8?q?oute=20Optimization=20security=20hardening=20(frozen=20@2566fb8?= =?UTF-8?q?)=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(security): GRO-2294 Route Optimization security hardening [squash] Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- UAT_PLAYBOOK.md | 3 +- src/__tests__/geocodeBatchLimit.test.ts | 89 ++++++++++++++++++++++++ src/__tests__/settings.test.ts | 91 +++++++++++++++++++++++++ src/routes/clients.ts | 11 ++- src/routes/settings.ts | 16 ++++- 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/geocodeBatchLimit.test.ts create mode 100644 src/__tests__/settings.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 78b73f3..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) | @@ -331,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}$/; -- 2.52.0 From 03f79a370135d4590ee9ab4846deea382fd14cdd Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 07:49:49 +0000 Subject: [PATCH 16/32] =?UTF-8?q?uat=20=E2=86=92=20main:=20GRO-2299=20reda?= =?UTF-8?q?ct=20googleMapsApiKey=20from=20PATCH=20/api/admin/settings=20(#?= =?UTF-8?q?198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GRO-2299: redact googleMapsApiKey from PATCH /api/admin/settings response Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- 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)); } ); -- 2.52.0 From c4385617c63d97933d6109aed7d1fb35ea5d1420 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 09:22:12 +0000 Subject: [PATCH 17/32] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2172=20exten?= =?UTF-8?q?ded=20pet=20fields=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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"))) -- 2.52.0 From 807ccb455fed2de98de7fe6e34ef2021b3925e69 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 09:56:34 +0000 Subject: [PATCH 18/32] =?UTF-8?q?dev=20=E2=86=92=20uat:=20GRO-2311=20seed?= =?UTF-8?q?=20portal=20StatusBadge=20appointments=20(#201)=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 55b2ee4..24d3dc3 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -830,6 +830,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 ──────────────────────── /** @@ -1111,6 +1273,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 @@ -1373,6 +1539,10 @@ 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 -- 2.52.0 From 4bbb0c9fc5cbdef04d043113d8a98bd0e21a0f21 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 9 Jun 2026 10:19:25 +0000 Subject: [PATCH 19/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2172=20p?= =?UTF-8?q?et=20extended-field=20schema=20fix=20(frozen=20@c4385617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote GRO-2172 from uat to main. Pins src/routes/pets.ts to its exact content at uat merge commit c4385617 (PR #200), adding the extended pet profile fields to createPetSchema/updatePetSchema and wiring medicalAlerts into POST/PATCH /pets: - temperamentScore: int 1–5 - temperamentFlags: string[] (≤20, each ≤100 chars) - medicalAlerts: {type,description,severity}[] (≤50) - preferredCuts: string[] (≤20, each ≤200 chars) - coatType already present on main; schema now references all 5 fields Based on main HEAD (03f79a37) so the PR diff is limited to src/routes/pets.ts. GRO-2311 (uat HEAD 807ccb45) is intentionally excluded. Co-Authored-By: Paperclip --- 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"))) -- 2.52.0 From 18640908eda17885af0236fac0f9b7e7b6ef637a Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 11:04:16 +0000 Subject: [PATCH 20/32] =?UTF-8?q?feat(GRO-2319):=20dev=E2=86=92uat=20?= =?UTF-8?q?=E2=80=94=20portal=20waitlist=20surfacing=20+=20seed=20(api)=20?= =?UTF-8?q?(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 24d3dc3..6ca4cc0 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -837,12 +837,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) @@ -990,6 +992,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) => { -- 2.52.0 From 31404befeee6719156cec3c237f56fda6285a565 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 9 Jun 2026 11:18:03 +0000 Subject: [PATCH 21/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2311=20s?= =?UTF-8?q?eed=20portal=20StatusBadge=20appointments=20(frozen=20@df5e768)?= =?UTF-8?q?=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uat→main (PROD): GRO-2311 seed portal StatusBadge appointments (squash) Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- 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 55b2ee4..24d3dc3 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -830,6 +830,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 ──────────────────────── /** @@ -1111,6 +1273,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 @@ -1373,6 +1539,10 @@ 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 -- 2.52.0 From 47e2021cf45bc7d494d1b32785437a69eb8e0a4f Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Wed, 10 Jun 2026 08:58:26 +0000 Subject: [PATCH 22/32] =?UTF-8?q?Promote=20uat=20=E2=86=92=20main=20(PROD)?= =?UTF-8?q?:=20GRO-2319=20portal=20waitlist=20surfacing=20+=20seed=20(#207?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- 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 24d3dc3..6ca4cc0 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -837,12 +837,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) @@ -990,6 +992,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) => { -- 2.52.0 From 58305d7a8961061411609eb2d7adf53228512ce4 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Thu, 11 Jun 2026 08:33:52 +0000 Subject: [PATCH 23/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2342=20p?= =?UTF-8?q?ortal=20waitlist=20service=20{id,=20name}=20(frozen=20@47e2021?= =?UTF-8?q?=20+=20cherry-pick=20c737bfe)=20(#211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge pull request 'GRO-2342: portal/appointments — symmetric service {id, name} on both card paths' (#211) from release/main-GRO-2342-api into main GRO-2342: GET /portal/appointments populates service: {id, name} on the synthetic waitlist card (was {id} only) and on the appointment card (consistent shape). TC-API-8.20 in UAT_PLAYBOOK.md. Approved CTO. Squashed from release/main-GRO-2342-api @ c737bfe. Refs: GRO-2342, GRO-2344, GRO-2345, GRO-2346, PR #211. Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- 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, }; }); -- 2.52.0 From 5363e1d5dc3a7f0bcfcf95336521d4fc53647f6d Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 11 Jun 2026 16:17:16 +0000 Subject: [PATCH 24/32] feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (web) The OOBE flow on the web portal calls this endpoint to create a fresh `clients` row bound to the Better Auth user's email when the SSO bridge returns 404. Returns 201 on success, 409 if a client with that email already exists (portal-selection case), 401/503 on auth issues, 400 on invalid body. The OOBE success path navigates the user back to `/` and lets the existing `session-from-auth` re-bridge; the new client is now resolvable by email, so the bridge mints a real portal session. Tests cover: 401 (no session), 400 (zod), 201 + persisted values (name trimmed, optional fields normalized to null), 409 (existing client or unique-constraint race), 503 (auth not configured). Paired with the web PR on `feature/2357-p2-sso-to-oobe-routing`. Co-Authored-By: Paperclip (cherry picked from commit cdeebec021022144241cd98b7cec9af7814cf0dd) --- src/__tests__/portalClientsFromAuth.test.ts | 201 ++++++++++++++++++++ src/routes/portal.ts | 108 +++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/__tests__/portalClientsFromAuth.test.ts diff --git a/src/__tests__/portalClientsFromAuth.test.ts b/src/__tests__/portalClientsFromAuth.test.ts new file mode 100644 index 0000000..dd2e899 --- /dev/null +++ b/src/__tests__/portalClientsFromAuth.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const NEW_USER_EMAIL = "new-sso-user@example.com"; +const NEW_USER_NAME = "New SSO User"; +const NEW_USER_ID = "11111111-2222-3333-4444-555555555555"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-new", + email: NEW_USER_EMAIL, + name: NEW_USER_NAME, + }, + session: { + id: "ba-session-new", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let existingClientRow: Record | null = null; +let insertedClientValues: Record | null = null; +let insertShouldThrow: { code?: string } | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(existingClientRow ? [existingClientRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => { + if (insertShouldThrow) { + const err = new Error("unique violation") as Error & { code?: string }; + err.code = insertShouldThrow.code; + throw err; + } + return { + returning: () => { + if (table._name === "clients") { + insertedClientValues = { id: NEW_USER_ID, ...vals }; + return [insertedClientValues]; + } + return []; + }, + }; + }, + }), + }), + clients, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/clients-from-auth (GRO-2359)", () => { + beforeEach(() => { + existingClientRow = null; + insertedClientValues = null; + insertShouldThrow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session is present", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test User" }), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when body fails zod validation (empty name)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + expect(res.status).toBe(400); + }); + + it("creates a new client row bound to the auth user's email and returns 201", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: " New SSO User ", + phone: "555-1234", + address: "1 Main St", + notes: "test note", + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + id: NEW_USER_ID, + name: "New SSO User", + email: NEW_USER_EMAIL, + }); + // Trim must be applied to the persisted values. + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).name).toBe("New SSO User"); + expect((insertedClientValues as Record).email).toBe(NEW_USER_EMAIL); + expect((insertedClientValues as Record).phone).toBe("555-1234"); + }); + + it("normalizes empty optional fields to null on insert", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test", phone: "", address: " " }), + }); + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).phone).toBeNull(); + expect((insertedClientValues as Record).address).toBeNull(); + }); + + it("returns 409 when a client row already exists for this email", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already exists/i); + expect(insertedClientValues).toBeNull(); + }); + + it("returns 409 on unique constraint race (23505)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + insertShouldThrow = { code: "23505" }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(503); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 487861d..17425a3 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -147,6 +147,114 @@ portalRouter.post("/session-from-auth", async (c) => { ); }); +// GRO-2359 — register a brand-new SSO user. The post-auth handler in the +// web portal redirects here when `session-from-auth` returns 404, so the +// OOBE can complete a customer record for the new user. Auth is via the +// Better Auth session (same shape as `session-from-auth`), so this is +// registered BEFORE the `validatePortalSession` middleware. +// +// Contract: +// POST /api/portal/clients-from-auth +// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null } +// 201: { id, name, email } +// 400: invalid body (zod failure) +// 401: no Better Auth session +// 409: a `clients` row already exists for this email (portal selection case) +// 500: insert failed +// +// We do NOT auto-link the user's auth account to the new client row; the +// existing `session-from-auth` endpoint re-resolves the row by email on the +// next call, so the OOBE's success path just navigates the user back to +// `/` and lets the bridge mint a portal session. +const createClientFromAuthSchema = z.object({ + name: z.string().min(1).max(200), + phone: z.string().max(50).nullish(), + address: z.string().max(500).nullish(), + notes: z.string().max(2000).nullish(), +}); + +portalRouter.post( + "/clients-from-auth", + zValidator("json", createClientFromAuthSchema), + async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = c.req.valid("json"); + const db = getDb(); + + // Pre-check: if a client already exists for this email, return 409 so + // the OOBE can render the "portal selection" message (the user needs + // to contact their groomer to link the new SSO identity to the + // pre-existing customer record). We don't return the existing row to + // avoid leaking PII about other accounts. + const [existing] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (existing) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + + let row; + try { + [row] = await db + .insert(clients) + .values({ + name: body.name.trim(), + email: session.user.email, + phone: body.phone?.trim() || null, + address: body.address?.trim() || null, + notes: body.notes?.trim() || null, + }) + .returning(); + } catch (err) { + // Concurrent insert from a parallel OOBE submit — treat as 409. + if ( + err instanceof Error && + "code" in err && + (err as { code?: string }).code === "23505" + ) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + throw err; + } + + if (!row) { + return c.json({ error: "Failed to create client" }, 500); + } + + return c.json( + { + id: row.id, + name: row.name, + email: row.email, + }, + 201, + ); + }, +); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit); -- 2.52.0 From bedeb05a67ef6f80613bbe8309c33e115c0152cb Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 12 Jun 2026 16:47:30 +0000 Subject: [PATCH 25/32] =?UTF-8?q?Promote=20uat=20=E2=86=92=20main=20(PROD)?= =?UTF-8?q?:=20GRO-2359=20OOBE=20portal-creation=20routing=20(api)=20(#214?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GRO-2359: add POST /api/portal/clients-from-auth for OOBE (#214) Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- src/__tests__/portalClientsFromAuth.test.ts | 201 ++++++++++++++++++++ src/routes/portal.ts | 108 +++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/__tests__/portalClientsFromAuth.test.ts diff --git a/src/__tests__/portalClientsFromAuth.test.ts b/src/__tests__/portalClientsFromAuth.test.ts new file mode 100644 index 0000000..dd2e899 --- /dev/null +++ b/src/__tests__/portalClientsFromAuth.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const NEW_USER_EMAIL = "new-sso-user@example.com"; +const NEW_USER_NAME = "New SSO User"; +const NEW_USER_ID = "11111111-2222-3333-4444-555555555555"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-new", + email: NEW_USER_EMAIL, + name: NEW_USER_NAME, + }, + session: { + id: "ba-session-new", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let existingClientRow: Record | null = null; +let insertedClientValues: Record | null = null; +let insertShouldThrow: { code?: string } | null = null; + +function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => makeChainable(target); + } + // @ts-expect-error proxy + return target[prop]; + }, + }); +} + +vi.mock("@groombook/db", () => { + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(existingClientRow ? [existingClientRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => { + if (insertShouldThrow) { + const err = new Error("unique violation") as Error & { code?: string }; + err.code = insertShouldThrow.code; + throw err; + } + return { + returning: () => { + if (table._name === "clients") { + insertedClientValues = { id: NEW_USER_ID, ...vals }; + return [insertedClientValues]; + } + return []; + }, + }; + }, + }), + }), + clients, + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +vi.mock("../lib/auth.js", () => ({ + getAuth: vi.fn(), +})); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +describe("POST /portal/clients-from-auth (GRO-2359)", () => { + beforeEach(() => { + existingClientRow = null; + insertedClientValues = null; + insertShouldThrow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session is present", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test User" }), + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when body fails zod validation (empty name)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "" }), + }); + expect(res.status).toBe(400); + }); + + it("creates a new client row bound to the auth user's email and returns 201", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: " New SSO User ", + phone: "555-1234", + address: "1 Main St", + notes: "test note", + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + id: NEW_USER_ID, + name: "New SSO User", + email: NEW_USER_EMAIL, + }); + // Trim must be applied to the persisted values. + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).name).toBe("New SSO User"); + expect((insertedClientValues as Record).email).toBe(NEW_USER_EMAIL); + expect((insertedClientValues as Record).phone).toBe("555-1234"); + }); + + it("normalizes empty optional fields to null on insert", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test", phone: "", address: " " }), + }); + expect(insertedClientValues).not.toBeNull(); + expect((insertedClientValues as Record).phone).toBeNull(); + expect((insertedClientValues as Record).address).toBeNull(); + }); + + it("returns 409 when a client row already exists for this email", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already exists/i); + expect(insertedClientValues).toBeNull(); + }); + + it("returns 409 on unique constraint race (23505)", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + insertShouldThrow = { code: "23505" }; + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(409); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/clients-from-auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test" }), + }); + expect(res.status).toBe(503); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 487861d..17425a3 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -147,6 +147,114 @@ portalRouter.post("/session-from-auth", async (c) => { ); }); +// GRO-2359 — register a brand-new SSO user. The post-auth handler in the +// web portal redirects here when `session-from-auth` returns 404, so the +// OOBE can complete a customer record for the new user. Auth is via the +// Better Auth session (same shape as `session-from-auth`), so this is +// registered BEFORE the `validatePortalSession` middleware. +// +// Contract: +// POST /api/portal/clients-from-auth +// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null } +// 201: { id, name, email } +// 400: invalid body (zod failure) +// 401: no Better Auth session +// 409: a `clients` row already exists for this email (portal selection case) +// 500: insert failed +// +// We do NOT auto-link the user's auth account to the new client row; the +// existing `session-from-auth` endpoint re-resolves the row by email on the +// next call, so the OOBE's success path just navigates the user back to +// `/` and lets the bridge mint a portal session. +const createClientFromAuthSchema = z.object({ + name: z.string().min(1).max(200), + phone: z.string().max(50).nullish(), + address: z.string().max(500).nullish(), + notes: z.string().max(2000).nullish(), +}); + +portalRouter.post( + "/clients-from-auth", + zValidator("json", createClientFromAuthSchema), + async (c) => { + let auth; + try { + auth = getAuth(); + } catch { + return c.json({ error: "Authentication not configured" }, 503); + } + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = c.req.valid("json"); + const db = getDb(); + + // Pre-check: if a client already exists for this email, return 409 so + // the OOBE can render the "portal selection" message (the user needs + // to contact their groomer to link the new SSO identity to the + // pre-existing customer record). We don't return the existing row to + // avoid leaking PII about other accounts. + const [existing] = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (existing) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + + let row; + try { + [row] = await db + .insert(clients) + .values({ + name: body.name.trim(), + email: session.user.email, + phone: body.phone?.trim() || null, + address: body.address?.trim() || null, + notes: body.notes?.trim() || null, + }) + .returning(); + } catch (err) { + // Concurrent insert from a parallel OOBE submit — treat as 409. + if ( + err instanceof Error && + "code" in err && + (err as { code?: string }).code === "23505" + ) { + return c.json( + { error: "A customer record with this email already exists" }, + 409, + ); + } + throw err; + } + + if (!row) { + return c.json({ error: "Failed to create client" }, 500); + } + + return c.json( + { + id: row.id, + name: row.name, + email: row.email, + }, + 201, + ); + }, +); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit); -- 2.52.0 From ed51a59c8087149e878d8092a6cb83635c5d5f54 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 12 Jun 2026 17:00:40 +0000 Subject: [PATCH 26/32] docs: add AGENTS.md and CONTRIBUTING.md (GRO-2381) (#215) Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- AGENTS.md | 54 ++++++++++++++++++++++ CONTRIBUTING.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c9ae143 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md + +This repository (`groombook/api`) is part of the GroomBook application stack. The +authoritative process, quality bar, and safety rules live in the shared +[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read +those first; this file is only a pointer. + +## Authoritative skills + +- **SDLC (branching, PRs, phases, handoffs):** + [`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md) +- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):** + [`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md) +- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):** + [`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md) + +For human contributors and humans reviewing agent work, see +[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR +flow and the `uat→main` merge-gate policy summary. + +## Non-negotiable operational rules + +These mirror the org skills; they are restated here so any agent landing in +this repo sees them without a cross-repo fetch. + +- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`. +- **Branch strategy:** `feature/` → `dev` → `uat` → `main`. Engineers + always target `dev` first. +- **No self-merge contract.** The engineer who opened a PR clicks merge only + after the named reviewer (CI / QA / UAT / Security / CTO per phase) + approves. Issue-thread QA / UAT / security approvals do **not** clear the + Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve** + click from a member of the `approvals_whitelist_username` does. On this + repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team). + Board-level accounts cannot give the Approve click by policy. +- **Always include `cc @cpfarhood`** at the bottom of every PR body for + board visibility (not as a reviewer). +- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit + plaintext. See the `safety` skill. +- **Production (`groombook` namespace) is Flux-managed.** Never + `kubectl apply` directly. Infrastructure changes go through PRs in + `groombook/infra`. + +## Local development + +See the repo's own README, package scripts, and CI workflow. The +authoritative pipeline (Gitea Actions, image build, deploy hooks) is the +shared `groombook/infra` overlay; do not reimplement it here. + +## When uncertain + +If a task conflicts with the org skills, **the org skills win**. Open an +issue in `groombook/org` to propose a change rather than encoding a local +exception. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..64a918a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,117 @@ +# Contributing to `groombook/api` + +Thanks for contributing. This document is the human-facing companion to +[`AGENTS.md`](./AGENTS.md) and the authoritative +[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills +govern; this file is a quick-reference for the human/agent PR flow in this +repo. + +## Branch strategy + +Three long-lived branches; one PR per promotion step. + +| Branch | Environment | Who merges | Prerequisites for merge | +|---------|-------------|-----------|-------------------------| +| `dev` | Dev | Engineer | CI passes | +| `uat` | UAT | Engineer | QA code review approval | +| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) | + +Engineers always target `dev` first. Feature branches: `/`. + +## Phase-by-phase PR flow + +### Phase 1 — Dev + +1. Branch from `dev`: `git checkout -b / origin/dev`. +2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI). +3. Open a PR against `dev`: + ```bash + tea pr create --base dev --title "..." --body "..." + ``` + Include `cc @cpfarhood` at the bottom of the body for board visibility. +4. CI must pass. CI green → engineer self-merges. +5. CI builds and deploys to Dev automatically. + +### Phase 2 — UAT promotion + +1. Open a PR from `dev` to `uat`. +2. CI must pass. +3. **QA (Lint Roller)** reviews and approves on the Gitea PR. +4. QA approved → engineer self-merges. +5. CI builds and deploys to UAT automatically. + +### Phase 3 — UAT regression + Security review + +1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every + feature, old and new, no exceptions. +2. **Security (Barkley Trimsworth)** reviews the changes. +3. Failures in either gate bounce back to Phase 1. + +### Phase 4 — Production promotion (`uat → main`) + +This is the gate the org PR +[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines. +The full rule is in +[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md) +and +[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md); +the summary is below. + +**The CTO Gitea Approve click is NOT the default gate.** Once the four +pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer +self-merges. + +**A CTO Gitea Approve click IS required** only for PRs in one of three +categories: + +1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware, + token issuance, password reset, MFA, new auth provider integrations. + Routine auth-gated UI (button styling, error messages, form layout) is + **not** in this category. +2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets, + GitOps overlays, CI/CD, `main` branch protection, production + routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in + `groombook/infra` require CTO Gitea Approve without exception. +3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO + sign-off request in the PR or issue thread. + +The engineer opens the `uat→main` PR, classifies it against the three +categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO +clicks Approve; once approved (and the four pre-gates are green), the +engineer merges. + +### Phase 5 — Production deployment + +A separate PR in `groombook/infra` bumps the overlay image tag for prod. +Handed to QA (Lint Roller) for review, then self-merged by the engineer. + +## The four pre-gates (uat→main) + +A `uat→main` PR is mergeable when **all four** are green: + +1. **QA code review** — done on the dev→uat promotion PR. +2. **UAT deploy** — the UAT image built from the uat tip is live in UAT. +3. **UAT regression** — Shedward's full-feature UAT pass is green (no + pre-existing defects, no new defects). +4. **Security review** — Barkley's security code review is green. + +Issue-thread QA / UAT / security approvals do **not** clear the Gitea +`required_approvals` gate. Only a Gitea **Approve** click from a member of +the `approvals_whitelist_username` for `main` clears it. In this repo that +whitelist is the engineer team (`gb_flea`, `gb_dogfather`). + +## Style, tests, and quality bar + +See +[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md) +for the engineering priority ordering, test requirements, no-hardcoded-values +rules, CalVer versioning policy, and the `git.farh.net` container registry +policy. + +## Safety + +See +[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md) +for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to +`groombook`, no self-merge, no direct `tofu` runs, board approval for +destructive actions, escalation protocol. -- 2.52.0 From 63d7aaa8c29a55583793f6580e70738ca15f752c Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 18 Jun 2026 01:30:43 +0000 Subject: [PATCH 27/32] =?UTF-8?q?chore:=20promote=20dev=20=E2=86=92=20uat?= =?UTF-8?q?=20(GRO-2425=20comma-split=20CORS=5FORIGIN)=20(#217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore: promote dev → uat (GRO-2425 comma-split CORS_ORIGIN) Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- UAT_PLAYBOOK.md | 2 ++ src/lib/auth.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 2a85e1d..66ef0d5 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -108,6 +108,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints | | TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false | | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | +| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | ### 4.2 Client Management diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ff1e125..b28153d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -118,7 +118,8 @@ export async function initAuth(): Promise { updateAge: 60 * 60 * 24, cookieCache: { enabled: false }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); return; } @@ -308,7 +309,8 @@ export async function initAuth(): Promise { maxAge: 5 * 60, // 5 minutes }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); })(); -- 2.52.0 From 1b6cd5825ac508330a77c2e786c20a9952d2ed58 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Thu, 18 Jun 2026 02:14:23 +0000 Subject: [PATCH 28/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2425=20c?= =?UTF-8?q?omma-split=20CORS=5FORIGIN=20(frozen=20@63d7aaa)=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: support comma-split CORS_ORIGIN for multiple trusted auth origins (GRO-2425) Co-authored-by: Flea Flicker Co-committed-by: Flea Flicker --- UAT_PLAYBOOK.md | 2 ++ src/lib/auth.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 2a85e1d..66ef0d5 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -108,6 +108,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints | | TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false | | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | +| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | ### 4.2 Client Management diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ff1e125..b28153d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -118,7 +118,8 @@ export async function initAuth(): Promise { updateAge: 60 * 60 * 24, cookieCache: { enabled: false }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); return; } @@ -308,7 +309,8 @@ export async function initAuth(): Promise { maxAge: 5 * 60, // 5 minutes }, }, - trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"], + trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173") + .split(",").map((s) => s.trim()).filter(Boolean), }); })(); -- 2.52.0 From 2d4edb6452cd4acde214f216a616a360308bee3a Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 26 Jun 2026 13:46:44 +0000 Subject: [PATCH 29/32] =?UTF-8?q?promote(GRO-2586):=20dev=20=E2=86=92=20ua?= =?UTF-8?q?t=20=E2=80=94=20CORS=20origin=20allowlist=20enforcement=20(#220?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promote(GRO-2586): dev → uat — CORS origin allowlist enforcement --- UAT_PLAYBOOK.md | 3 ++ src/__tests__/authCors.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/lib/auth-cors.ts | 22 +++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/authCors.test.ts create mode 100644 src/lib/auth-cors.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 66ef0d5..60acc87 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -110,6 +110,9 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | | TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | | TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response | +| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected | +| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin | ### 4.2 Client Management diff --git a/src/__tests__/authCors.test.ts b/src/__tests__/authCors.test.ts new file mode 100644 index 0000000..2603279 --- /dev/null +++ b/src/__tests__/authCors.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { enforceAuthCors } from "../lib/auth-cors.js"; + +const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"]; + +/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */ +function makeReflectedResponse(origin: string | null): Response { + return new Response('{"ok":true}', { + status: 200, + headers: { + "Content-Type": "application/json", + ...(origin + ? { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + : {}), + }, + }); +} + +describe("enforceAuthCors (GRO-2586)", () => { + it("passes trusted origin through with credentials", () => { + const origin = "https://uat.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + }); + + it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("strips ACAO when no Origin header (undefined)", () => { + const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("preserves non-CORS response headers and status from Better Auth", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.status).toBe(200); + }); + + it("second trusted origin is also allowed", () => { + const origin = "https://dev.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + }); + + it("empty string origin is treated as untrusted", () => { + const res = enforceAuthCors("", TRUSTED, makeReflectedResponse("")); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 681d731..6c0c930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js"; +import { enforceAuthCors } from "./lib/auth-cors.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; @@ -200,9 +201,10 @@ api.use("*", resolveStaffMiddleware); // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths const authRouter = new Hono(); -authRouter.all("/*", (c) => { +authRouter.all("/*", async (c) => { try { - return getAuth().handler(c.req.raw); + const res = await getAuth().handler(c.req.raw); + return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res); } catch { return c.json({ error: "Authentication not configured" }, 503); } diff --git a/src/lib/auth-cors.ts b/src/lib/auth-cors.ts new file mode 100644 index 0000000..bd68f38 --- /dev/null +++ b/src/lib/auth-cors.ts @@ -0,0 +1,22 @@ +/** + * Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth. + * Better Auth reflects the request Origin into Access-Control-Allow-Origin + * regardless of the trustedOrigins config, allowing credentialed cross-origin reads + * from arbitrary attacker origins. This wrapper strips CORS headers for any origin + * not in the allowlist. (GRO-2586) + */ +export function enforceAuthCors( + requestOrigin: string | undefined, + trustedOrigins: string[], + res: Response +): Response { + const headers = new Headers(res.headers); + if (requestOrigin && trustedOrigins.includes(requestOrigin)) { + headers.set("Access-Control-Allow-Origin", requestOrigin); + headers.set("Access-Control-Allow-Credentials", "true"); + } else { + headers.delete("Access-Control-Allow-Origin"); + headers.delete("Access-Control-Allow-Credentials"); + } + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} -- 2.52.0 From 98b1171f9864614c2909510e52cee4cccabeefd2 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 26 Jun 2026 14:23:34 +0000 Subject: [PATCH 30/32] =?UTF-8?q?uat=E2=86=92main=20(PROD):=20GRO-2586=20C?= =?UTF-8?q?ORS=20origin=20allowlist=20enforcement=20(frozen=20@2d4edb6)=20?= =?UTF-8?q?(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uat→main (PROD): GRO-2586 CORS origin allowlist enforcement (frozen @2d4edb6) --- UAT_PLAYBOOK.md | 3 ++ src/__tests__/authCors.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/lib/auth-cors.ts | 22 +++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/authCors.test.ts create mode 100644 src/lib/auth-cors.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 66ef0d5..60acc87 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -110,6 +110,9 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | | TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | | TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response | +| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected | +| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin | ### 4.2 Client Management diff --git a/src/__tests__/authCors.test.ts b/src/__tests__/authCors.test.ts new file mode 100644 index 0000000..2603279 --- /dev/null +++ b/src/__tests__/authCors.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { enforceAuthCors } from "../lib/auth-cors.js"; + +const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"]; + +/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */ +function makeReflectedResponse(origin: string | null): Response { + return new Response('{"ok":true}', { + status: 200, + headers: { + "Content-Type": "application/json", + ...(origin + ? { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + : {}), + }, + }); +} + +describe("enforceAuthCors (GRO-2586)", () => { + it("passes trusted origin through with credentials", () => { + const origin = "https://uat.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + }); + + it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("strips ACAO when no Origin header (undefined)", () => { + const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("preserves non-CORS response headers and status from Better Auth", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.status).toBe(200); + }); + + it("second trusted origin is also allowed", () => { + const origin = "https://dev.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + }); + + it("empty string origin is treated as untrusted", () => { + const res = enforceAuthCors("", TRUSTED, makeReflectedResponse("")); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 681d731..6c0c930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js"; +import { enforceAuthCors } from "./lib/auth-cors.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; @@ -200,9 +201,10 @@ api.use("*", resolveStaffMiddleware); // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths const authRouter = new Hono(); -authRouter.all("/*", (c) => { +authRouter.all("/*", async (c) => { try { - return getAuth().handler(c.req.raw); + const res = await getAuth().handler(c.req.raw); + return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res); } catch { return c.json({ error: "Authentication not configured" }, 503); } diff --git a/src/lib/auth-cors.ts b/src/lib/auth-cors.ts new file mode 100644 index 0000000..bd68f38 --- /dev/null +++ b/src/lib/auth-cors.ts @@ -0,0 +1,22 @@ +/** + * Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth. + * Better Auth reflects the request Origin into Access-Control-Allow-Origin + * regardless of the trustedOrigins config, allowing credentialed cross-origin reads + * from arbitrary attacker origins. This wrapper strips CORS headers for any origin + * not in the allowlist. (GRO-2586) + */ +export function enforceAuthCors( + requestOrigin: string | undefined, + trustedOrigins: string[], + res: Response +): Response { + const headers = new Headers(res.headers); + if (requestOrigin && trustedOrigins.includes(requestOrigin)) { + headers.set("Access-Control-Allow-Origin", requestOrigin); + headers.set("Access-Control-Allow-Credentials", "true"); + } else { + headers.delete("Access-Control-Allow-Origin"); + headers.delete("Access-Control-Allow-Credentials"); + } + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} -- 2.52.0 From 81a833e392f813f2b70653a172f3cef021dcbd07 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 5 Aug 2026 10:57:27 +0000 Subject: [PATCH 31/32] chore: remove CI-trigger litter and agent tooling artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove trigger-uat-1779751324.txt (empty CI-trigger file) and .mcp.json (agent tooling config with bearer token) — neither belongs in main. Co-Authored-By: Paperclip --- .mcp.json | 11 ----------- trigger-uat-1779751324.txt | 0 2 files changed, 11 deletions(-) delete mode 100644 .mcp.json delete mode 100644 trigger-uat-1779751324.txt diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 6efc1ca..0000000 --- a/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "gitea": { - "type": "http", - "url": "https://git-mcp.farh.net/mcp", - "headers": { - "Authorization": "Bearer ${GITEA_TOKEN}" - } - } - } -} diff --git a/trigger-uat-1779751324.txt b/trigger-uat-1779751324.txt deleted file mode 100644 index e69de29..0000000 -- 2.52.0 From 30d02dd7c7e9fdb0959013bbd84819d599b275a5 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Thu, 6 Aug 2026 08:55:53 +0000 Subject: [PATCH 32/32] fix(GRO-2672): use drizzle-kit migrate in reset.ts to bypass HWM bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drizzle-orm/postgres-js/migrator's migrate() has the known high-water-mark bug (GRO-1999/2033): on a fresh DB it applies migration 0000 first, setting the watermark to its `when` timestamp (1773771452946, 2026-03-17). Migrations 0001, 0003, 0010, 0011 all have stale 2025-era `when` values that fall below this watermark and are silently skipped. Migration 0003 (recurring_series) is the critical skip: it creates the `recurring_series` table and adds `series_id`/`series_index` columns to `appointments`. Since migrate() wraps all SQL in a single Postgres transaction, any downstream applied migration that depends on those missing objects causes a full transaction rollback — including migration 0000's staff and services tables. The result: every reset-demo-data run since the 552a4d9 deploy leaves the DB with zero tables, causing both reset-demo-data and seed-test-data jobs to fail on every subsequent run. Fix: replace migrate() from drizzle-orm with `pnpm exec drizzle-kit migrate` (hash-based, same as the K8s migrate Job). drizzle-kit applies every unhashed migration regardless of its `when` timestamp ordering, matching the K8s migrate Job's behaviour exactly. Co-Authored-By: Paperclip --- packages/db/src/reset.ts | 146 +-------------------------------------- 1 file changed, 1 insertion(+), 145 deletions(-) diff --git a/packages/db/src/reset.ts b/packages/db/src/reset.ts index fb88e20..9bb6d8f 100644 --- a/packages/db/src/reset.ts +++ b/packages/db/src/reset.ts @@ -1,145 +1 @@ -/** - * 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; - if (!url) { - console.error("DATABASE_URL is not set"); - 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.", - ); - process.exit(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 }); - - try { - await withSeedAdvisoryLock(client, async () => { - console.log("Dropping all application tables...\n"); - - // 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 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("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) => { - console.error("Reset failed:", err); - process.exit(1); -}); +LyoqCiAqIHJlc2V0LnRzIOKAlCBEcm9wIGFsbCBhcHBsaWNhdGlvbiB0YWJsZXMsIHJlLXJ1biBtaWdyYXRpb25zLCBhbmQgcmUtc2VlZC4KICoKICogSW50ZW5kZWQgZm9yIGxvY2FsIGRldmVsb3BtZW50IG9ubHkuIE5ldmVyIHJ1biBhZ2FpbnN0IHByb2R1Y3Rpb24uCiAqCiAqIFVzYWdlOgogKiAgIERBVEFCQVNFX1VSTD1wb3N0Z3JlczovLy4uLiBucHggdHN4IHBhY2thZ2VzL2RiL3NyYy9yZXNldC50cwogKgogKiBHUk8tMjEzOTogdGhlIGVudGlyZSBkcm9w4oaSbWlncmF0ZeKGknNlZWQgY2hhaW4gcnVucyBpbnNpZGUgYSBzaW5nbGUKICogUG9zdGdyZXMgYWR2aXNvcnkgbG9jayAoU0VFRF9BRFZJU09SWV9MT0NLX0tFWSkgc28gYSBjb25jdXJyZW50CiAqIGBzZWVkLnRzYCAoZS5nLiB0aGUgZGV2IGBzZWVkLXRlc3QtZGF0YS0qYCBKb2IgYmVpbmcgcmVjcmVhdGVkIGF0CiAqIHRoZSB0b3Agb2YgdGhlIGhvdXIpIGNhbm5vdCBpbnRlcmxlYXZlIGJldHdlZW4gYHJlc2V0LnRzYCAoRFJPUCkKICogYW5kIGBzZWVkLnRzYCAoVFJVTkNBVEUraW5zZXJ0KSBhbmQgY29sbGlkZSBvbiBgaW52b2ljZXNfcGtleWAuCiAqCiAqIFdoeSB0aGlzIG1hdHRlcnM6IGBzZWVkLnRzYCBkZXJpdmVzIGV2ZXJ5IHByaW1hcnkga2V5IGZyb20gYSBzaW5nbGUKICogc2hhcmVkIE11bGJlcnJ5MzIgUFJORyBzZWVkZWQgd2l0aCA0MiAoc2VlIGBjcmVhdGVQcm5nKDQyKWAgYW5kCiAqIGB1dWlkKClgIGluIHNlZWQudHMpLiBUd28gY29uY3VycmVudCBzYW1lLXByb2ZpbGUgc2VlZGVycyB0aGVyZWZvcmUKICogZW1pdCAqaWRlbnRpY2FsKiBpZHMgZm9yIHRoZSBzYW1lIGxvZ2ljYWwgcm93LCBhbmQgYW55IG1vbWVudAogKiBiZXR3ZWVuIGEgY29uY3VycmVudCBgc2VlZC50c2AgVFJVTkNBVEUgYW5kIElOU0VSVCBpcyBleGFjdGx5IHRoZQogKiB3aW5kb3cgaW4gd2hpY2ggdGhlIHNlY29uZCBzZWVkZXIncyBJTlNFUlQgY2FuIGhpdCBhIHBrZXkgYWxyZWFkeQogKiB0YWtlbiBieSB0aGUgZmlyc3QuIFByZS1HUk8tMjEyMyB0aGlzIHJhY2VkIHVuY29uZGl0aW9uYWxseTsKICogR1JPLTIxMjMgYWRkZWQgdGhlIGFkdmlzb3J5IGxvY2sgYXJvdW5kIGBydW5TZWVkQm9keWAgYnV0IGxlZnQKICogYHJlc2V0LnRzYCBhbmQgYGRyaXp6bGUta2l0IG1pZ3JhdGVgIG91dHNpZGUgdGhlIGxvY2suIFRoaXMgc2NyaXB0CiAqIG5vdyB3cmFwcyB0aGUgKndob2xlKiBjaGFpbiBpbiB0aGUgc2FtZSBsb2NrOiBgd2l0aFNlZWRBZHZpc29yeUxvY2tgCiAqIHBpbnMgdGhlIGxvY2sgdG8gb25lIHJlc2VydmVkIHNlc3Npb24gYW5kIHRoZSBEUk9QIOKGkiBtaWdyYXRlIOKGkiBzZWVkCiAqIHdvcmsgcnVucyBvbiB0aGUgcmVzdCBvZiB0aGUgcG9vbCwgc28gdGhlIGxvY2sgZ3VhcmFudGVlcyBtdXR1YWwKICogZXhjbHVzaW9uIGFnYWluc3QgYW55IGNvbmN1cnJlbnQgc2VlZGVyIGZvciB0aGUgZW50aXJlIGNoYWluLgogKgogKiBTZWU6IGdyb29tYm9vay9pbmZyYSBgYXBwcy9iYXNlL3Jlc2V0LWNyb25qb2IueWFtbGAgKENyb25Kb2IpIGFuZAogKiBgYXBwcy9iYXNlL3NlZWQtam9iLnlhbWxgIChvbmUtc2hvdCBKb2IpIOKAlCBib3RoIGludm9rZSB0aGUgc2FtZQogKiBgc2VlZC50c2AgY29kZSBwYXRoIG9uIHRoZSBzYW1lIGRhdGFiYXNlIGluIGBncm9vbWJvb2stZGV2YC4KICoKICogR1JPLTI2NzI6IG1pZ3JhdGUoKSBmcm9tIGRyaXp6bGUtb3JtL3Bvc3RncmVzLWpzL21pZ3JhdG9yIGhhcyBhCiAqIGhpZ2gtd2F0ZXItbWFyayBidWcgKEdSTy0xOTk5LzIwMzMpIHRoYXQgc2lsZW50bHkgc2tpcHMgbWlncmF0aW9ucwogKiB3aG9zZSBgd2hlbmAgdGltZXN0YW1wIGlzIOKJpCB0aGUgbW9zdCByZWNlbnRseSBhcHBsaWVkIG1pZ3JhdGlvbidzCiAqIGNyZWF0ZWRfYXQuIE1pZ3JhdGlvbnMgMDAwMSwgMDAwMywgMDAxMCwgMDAxMSBoYXZlIHN0YWxlIDIwMjUtZXJhCiAqIGB3aGVuYCB2YWx1ZXMgdGhhdCBmYWxsIGJlbG93IHRoZSB3YXRlcm1hcmsgc2V0IGJ5IG1pZ3JhdGlvbiAwMDAwCiAqIG9uIGEgZnJlc2ggREIsIGNhdXNpbmcgdGhlbSB0byBiZSBza2lwcGVkLiBTaW5jZSBtaWdyYXRlKCkgd3JhcHMKICogYWxsIFNRTCBpbiBhIHNpbmdsZSB0cmFuc2FjdGlvbiwgYW55IGRvd25zdHJlYW0gbWlncmF0aW9uIHRoYXQKICogZGVwZW5kcyBvbiBhIHNraXBwZWQgbWlncmF0aW9uJ3MgdGFibGVzL2NvbHVtbnMgY2F1c2VzIGEgZnVsbAogKiByb2xsYmFjayDigJQgaW5jbHVkaW5nIG1pZ3JhdGlvbiAwMDAwJ3Mgc3RhZmYgYW5kIHNlcnZpY2VzIHRhYmxlcy4KICoKICogRml4OiB1c2UgYGRyaXp6bGUta2l0IG1pZ3JhdGVgIChoYXNoLWJhc2VkLCBzYW1lIGFzIHRoZSBLOHMgbWlncmF0ZQogKiBKb2IpIGluc3RlYWQgb2YgdGhlIGRyaXp6bGUtb3JtIHJ1bnRpbWUgbWlncmF0b3IuIGRyaXp6bGUta2l0IGNoZWNrcwogKiBlYWNoIG1pZ3JhdGlvbidzIGhhc2ggYWdhaW5zdCB0aGUgbGVkZ2VyLCBzbyBpdCBhcHBsaWVzIGV2ZXJ5CiAqIHVuaGFzaGVkIG1pZ3JhdGlvbiByZWdhcmRsZXNzIG9mIGl0cyBgd2hlbmAgdGltZXN0YW1wLgogKi8KaW1wb3J0IHBvc3RncmVzIGZyb20gInBvc3RncmVzIjsKaW1wb3J0IHsgZHJpenpsZSB9IGZyb20gImRyaXp6bGUtb3JtL3Bvc3RncmVzLWpzIjsKaW1wb3J0IHsgZXhlY1N5bmMgfSBmcm9tICJub2RlOmNoaWxkX3Byb2Nlc3MiOwppbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSAibm9kZTp1cmwiOwppbXBvcnQgeyBkaXJuYW1lLCByZXNvbHZlIH0gZnJvbSAibm9kZTpwYXRoIjsKaW1wb3J0ICogYXMgc2NoZW1hIGZyb20gIi4vc2NoZW1hLmpzIjsKaW1wb3J0IHsKICBTRUVEX0FEVklTT1JZX0xPQ0tfS0VZLAogIHdpdGhTZWVkQWR2aXNvcnlMb2NrLAogIGdldFByb2ZpbGUsCiAgcnVuU2VlZEJvZHksCiAgcHJvZmlsZXMsCn0gZnJvbSAiLi9zZWVkLmpzIjsKCmNvbnN0IF9fZmlsZW5hbWUgPSBmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCk7CmNvbnN0IF9fZGlybmFtZSA9IGRpcm5hbWUoX19maWxlbmFtZSk7Cgphc3luYyBmdW5jdGlvbiByZXNldCgpIHsKICBjb25zdCB1cmwgPSBwcm9jZXNzLmVudi5EQVRBQkFTRV9VUkw7CiAgaWYgKCF1cmwpIHsKICAgIGNvbnNvbGUuZXJyb3IoIkRBVEFCQVNFX1VSTCBpcyBub3Qgc2V0Iik7CiAgICBwcm9jZXNzLmV4aXQoMSk7CiAgfQoKICBpZiAoCiAgICBwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gInByb2R1Y3Rpb24iICYmCiAgICBwcm9jZXNzLmVudi5BTExPV19SRVNFVCAhPT0gInRydWUiCiAgKSB7CiAgICBjb25zb2xlLmVycm9yKAogICAgICAiW0ZBVEFMXSBkYjpyZXNldCBtdXN0IG5vdCBiZSBydW4gaW4gcHJvZHVjdGlvbiB3aXRob3V0IEFMTE9XX1JFU0VUPXRydWUuIiwKICAgICk7CiAgICBwcm9jZXNzLmV4aXQoMSk7CiAgfQoKICAvLyBQb29sIHNpemluZyBpcyBsb2FkLWJlYXJpbmcgaGVyZS4gYHdpdGhTZWVkQWR2aXNvcnlMb2NrYCBkb2VzCiAgLy8gYHBvb2wucmVzZXJ2ZSgpYCB0byBwaW4gdGhlIGFkdmlzb3J5IGxvY2sgdG8gb25lIGRlZGljYXRlZCBzZXNzaW9uCiAgLy8gKGEgc2Vzc2lvbi1sZXZlbCBsb2NrIHJlbGVhc2VkIG9uIGEgKmRpZmZlcmVudCogcG9vbGVkIGNvbm5lY3Rpb24gaXMKICAvLyBhIG5vLW9wKSwgYW5kIHRoZSBEUk9QIC8gbWlncmF0ZSAvIHNlZWQgd29yayB0aGVuIHJ1bnMgb24gdGhlCiAgLy8gKnJlbWFpbmluZyogcG9vbGVkIGNvbm5lY3Rpb25zLiBUaGUgbG9jayBwcm92aWRlcyBtdXR1YWwgZXhjbHVzaW9uCiAgLy8gYWNyb3NzIHByb2Nlc3NlcyByZWdhcmRsZXNzIG9mIGhvdyBtYW55IGNvbm5lY3Rpb25zIHRoZSB3b3JrIHVzZXMg4oCUCiAgLy8gaXQgZG9lcyBOT1QgcmVxdWlyZSB0aGUgd29yayB0byBzaGFyZSB0aGUgbG9jaydzIHNlc3Npb24uCiAgLy8KICAvLyBUaGVyZWZvcmUgYG1heGAgbXVzdCBiZSDiiaUgMjogMSByZXNlcnZlZCBmb3IgdGhlIGxvY2sgKyDiiaUxIGZyZWUgZm9yCiAgLy8gdGhlIHdvcmsuIGBtYXg6IDFgIHdvdWxkIGxldCBgcmVzZXJ2ZSgpYCBjb25zdW1lIHRoZSBvbmx5IGNvbm5lY3Rpb24KICAvLyBhbmQgZXZlcnkgcXVlcnkgaW5zaWRlIHRoZSBjYWxsYmFjayB3b3VsZCBibG9jayBmb3JldmVyIHdhaXRpbmcgZm9yCiAgLy8gYSBjb25uZWN0aW9uIHRoYXQgbmV2ZXIgZnJlZXMgKGNvbm5lY3Rpb24tc3RhcnZhdGlvbiBkZWFkbG9jaykuIFdlCiAgLy8gdXNlIGBtYXg6IDZgIHRvIG1hdGNoIGBzZWVkKClgJ3MgaGVhZHJvb20gKDEgcmVzZXJ2ZWQgKyA1IHdvcmspLgogIGNvbnN0IGNsaWVudCA9IHBvc3RncmVzKHVybCwgeyBtYXg6IDYgfSk7CiAgY29uc3QgZGIgPSBkcml6emxlKGNsaWVudCwgeyBzY2hlbWEgfSk7CgogIHRyeSB7CiAgICBhd2FpdCB3aXRoU2VlZEFkdmlzb3J5TG9jayhjbGllbnQsIGFzeW5jICgpID0+IHsKICAgICAgY29uc29sZS5sb2coIkRyb3BwaW5nIGFsbCBhcHBsaWNhdGlvbiB0YWJsZXMuLi5cbiIpOwoKICAgICAgLy8gRHJvcCBkZXBlbmRlbmNpZXMgKHRhYmxlcykgZmlyc3QKICAgICAgYXdhaXQgY2xpZW50YAogICAgICAgIERPICQkIERFQ0xBUkUKICAgICAgICAgIHIgUkVDT1JEOwogICAgICAgIEJFR0lOCiAgICAgICAgICBGT1IgciBJTiAoCiAgICAgICAgICAgIFNFTEVDVCB0YWJsZW5hbWUgRlJPTSBwZ190YWJsZXMKICAgICAgICAgICAgV0hFUkUgc2NoZW1hbmFtZSA9ICdwdWJsaWMnCiAgICAgICAgICApIExPT1AKICAgICAgICAgICAgRVhFQ1VURSAnRFJPUCBUQUJMRSBJRiBFWElTVFMgcHVibGljLicgfHwgcXVvdGVfaWRlbnQoci50YWJsZW5hbWUpIHx8ICcgQ0FTQ0FERSc7CiAgICAgICAgICBFTkQgTE9PUDsKICAgICAgICBFTkQgJCQ7CiAgICAgIGA7CgogICAgICAvLyBEcm9wIGN1c3RvbSBlbnVtcwogICAgICBhd2FpdCBjbGllbnRgCiAgICAgICAgRE8gJCQgREVDTEFSRQogICAgICAgICAgciBSRUNPUkQ7CiAgICAgICAgQkVHSU4KICAgICAgICAgIEZPUiByIElOICgKICAgICAgICAgICAgU0VMRUNUIHR5cG5hbWUgRlJPTSBwZ190eXBlCiAgICAgICAgICAgIFdIRVJFIHR5cHR5cGUgPSAnZScgQU5EIHR5cG5hbWVzcGFjZSA9ICgKICAgICAgICAgICAgICBTRUxFQ1Qgb2lkIEZST00gcGdfbmFtZXNwYWNlIFdIRVJFIG5zcG5hbWUgPSAncHVibGljJwogICAgICAgICAgICApCiAgICAgICAgICApIExPT1AKICAgICAgICAgICAgRVhFQ1VURSAnRFJPUCBUWVBFIElGIEVYSVNUUyAnIHx8IHF1b3RlX2lkZW50KHIudHlwbmFtZSkgfHwgJyBDQVNDQURFJzsKICAgICAgICAgIEVORCBMT09QOwogICAgICAgIEVORCAkJDsKICAgICAgYDsKCiAgICAgIC8vIERyb3AgdGhlIGRyaXp6bGUgbWlncmF0aW9ucyB0cmFja2luZyB0YWJsZQogICAgICBhd2FpdCBjbGllbnRgRFJPUCBUQUJMRSBJRiBFWElTVFMgZHJpenpsZS5fX2RyaXp6bGVfbWlncmF0aW9ucyBDQVNDQURFYDsKICAgICAgYXdhaXQgY2xpZW50YERST1AgU0NIRU1BIElGIEVYSVNUUyBkcml6emxlIENBU0NBREVgOwoKICAgICAgY29uc29sZS5sb2coIuKckyBBbGwgdGFibGVzIGFuZCBlbnVtcyBkcm9wcGVkXG4iKTsKCiAgICAgIGNvbnNvbGUubG9nKCJSdW5uaW5nIG1pZ3JhdGlvbnMuLi4iKTsKICAgICAgLy8gR1JPLTI2NzI6IHVzZSBkcml6emxlLWtpdCAoaGFzaC1iYXNlZCkgaW5zdGVhZCBvZiBkcml6emxlLW9ybQogICAgICAvLyBtaWdyYXRlKCkgKGhpZ2gtd2F0ZXItbWFyaykgdG8gZ3VhcmFudGVlIGFsbCBtaWdyYXRpb25zIGFwcGx5IG9uCiAgICAgIC8vIGEgZnJlc2ggREIgcmVnYXJkbGVzcyBvZiB0aGVpciBgd2hlbmAgdGltZXN0YW1wIG9yZGVyaW5nLgogICAgICBleGVjU3luYygicG5wbSBleGVjIGRyaXp6bGUta2l0IG1pZ3JhdGUiLCB7CiAgICAgICAgc3RkaW86ICJpbmhlcml0IiwKICAgICAgICBlbnY6IHsgLi4ucHJvY2Vzcy5lbnYgfSwKICAgICAgICBjd2Q6IHJlc29sdmUoX19kaXJuYW1lLCAiLi4iKSwKICAgICAgfSk7CiAgICAgIGNvbnNvbGUubG9nKCLinJMgTWlncmF0aW9ucyBhcHBsaWVkXG4iKTsKCiAgICAgIGNvbnNvbGUubG9nKCJTZWVkaW5nIGRhdGFiYXNlLi4uIik7CiAgICAgIGNvbnN0IHByb2ZpbGUgPSBnZXRQcm9maWxlKCk7CiAgICAgIGNvbnN0IGNmZyA9IHByb2ZpbGVzW3Byb2ZpbGVdOwogICAgICBhd2FpdCBydW5TZWVkQm9keShjbGllbnQsIGRiLCBwcm9maWxlLCBjZmcpOwogICAgfSk7CgogICAgY29uc29sZS5sb2coCiAgICAgIGBcbuKckyBSZXNldCBjb21wbGV0ZSAoYWR2aXNvcnkgbG9jayBrZXk9MHgke1NFRURfQURWSVNPUllfTE9DS19LRVkudG9TdHJpbmcoMTYpfSlgLAogICAgKTsKICB9IGZpbmFsbHkgewogICAgYXdhaXQgY2xpZW50LmVuZCgpOwogIH0KfQoKcmVzZXQoKS5jYXRjaCgoZXJyKSA9PiB7CiAgY29uc29sZS5lcnJvcigiUmVzZXQgZmFpbGVkOiIsIGVycik7CiAgcHJvY2Vzcy5leGl0KDEpOwp9KTsK \ No newline at end of file -- 2.52.0