diff --git a/.ci-trigger b/.ci-trigger index bc10555..1a34787 100644 --- a/.ci-trigger +++ b/.ci-trigger @@ -1 +1 @@ -GRO-1757+GRO-1764 CI trigger 2026-05-26 \ No newline at end of file +GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dfb785b..d848d3b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,7 +32,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck - run: pnpm --filter @groombook/api typecheck + run: | + pnpm --filter @groombook/api typecheck + pnpm --filter @groombook/db typecheck - name: Lint run: pnpm --filter @groombook/api lint @@ -116,6 +118,17 @@ jobs: cache-from: type=registry,ref=git.farh.net/groombook/cache:migrate cache-to: type=registry,ref=git.farh.net/groombook/cache:migrate,mode=max + - name: Smoke test migrate image (blackhole npmjs.org) + run: | + set -euo pipefail + IMAGE="git.farh.net/groombook/migrate:${{ steps.version.outputs.tag }}" + docker pull "$IMAGE" + docker run --rm \ + --add-host registry.npmjs.org:127.0.0.1 \ + --entrypoint="" \ + "$IMAGE" \ + pnpm --version + - name: Build and push Seed image uses: docker/build-push-action@v6 with: @@ -143,3 +156,32 @@ jobs: ${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }} cache-from: type=registry,ref=git.farh.net/groombook/cache:reset cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max + + - name: Smoke test seed image (blackhole npmjs.org) + run: | + set -euo pipefail + IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}" + docker pull "$IMAGE" + # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must + # not try to reach registry.npmjs.org on invocation. + docker run --rm \ + --add-host registry.npmjs.org:127.0.0.1 \ + --entrypoint="" \ + "$IMAGE" \ + sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version' + echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓" + + - name: Smoke test reset image (blackhole npmjs.org) + run: | + set -euo pipefail + IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}" + docker pull "$IMAGE" + # GRO-1985: pnpm must be a real binary, not a Corepack shim, and must + # not try to reach registry.npmjs.org on invocation. Validates the + # hard requirement from the issue: reset runs offline. + docker run --rm \ + --add-host registry.npmjs.org:127.0.0.1 \ + --entrypoint="" \ + "$IMAGE" \ + sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version' + echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓" diff --git a/Dockerfile b/Dockerfile index ac523cc..a77a7df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,14 @@ FROM node:22-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +# Install pnpm as a real binary via npm (not corepack shim) so runtime +# invocations of `pnpm` work without DNS access to registry.npmjs.org. +# The corepack shim delegates to corepack, which re-validates against +# npmjs.org on first use — that fails in air-gapped UAT seed/migrate/reset +# Jobs. GRO-1983 / GRO-1889 / GRO-1909 / GRO-1981 / GRO-1985. +RUN npm install -g pnpm@9.15.4 +# Belt-and-braces: disable Corepack's download fallback so that even if a +# Corepack shim is somehow invoked at runtime, it will not try to fetch +# pnpm from registry.npmjs.org. Belt for the real-binary trousers. GRO-1985. +ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0 WORKDIR /app # Install deps @@ -11,7 +20,6 @@ RUN pnpm install --frozen-lockfile # Build FROM deps AS builder -RUN mkdir -p /home/node/.cache/node/corepack COPY packages/ packages/ COPY src/ src/ COPY tsconfig.json ./ @@ -21,7 +29,9 @@ RUN pnpm --filter @groombook/types build && \ # Runtime FROM node:22-alpine AS runner -RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN npm install -g pnpm@9.15.4 +# Same defence-in-depth as base: no Corepack fallback. GRO-1985. +ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0 WORKDIR /app ENV NODE_ENV=production @@ -42,12 +52,18 @@ CMD ["node", "dist/index.js"] # Migrate stage — runs drizzle-kit migrate against the database FROM builder AS migrate +# pnpm needs a writable HOME for any config/state it writes. With +# readOnlyRootFilesystem: true and runAsUser: 1000, /home/node is read-only. +# The job pods mount a writable emptyDir at /tmp; point HOME there. GRO-1985. +ENV HOME=/tmp CMD ["pnpm", "--filter", "@groombook/db", "migrate"] # Seed stage — populates the database with test data FROM builder AS seed +ENV HOME=/tmp CMD ["pnpm", "--filter", "@groombook/db", "seed"] # Reset stage — drops all tables, re-runs migrations, and re-seeds FROM builder AS reset +ENV HOME=/tmp CMD ["pnpm", "--filter", "@groombook/db", "reset"] diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index d03aeea..f0e1037 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -19,6 +19,27 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet - OIDC authentication provider configured - Seed data present (clients, pets, services, staff) +### Source of truth for UAT passwords (GRO-2000) + +The `UAT_SUPER_PASSWORD` / `UAT_GROOMER_PASSWORD` / `UAT_TESTER_PASSWORD` / `UAT_CUSTOMER_PASSWORD` env vars the test orchestrator uses **must** be pulled from the live `seed-uat-passwords` Secret in the UAT cluster — never from a captured shell value, a previous run's `.env`, or a copy of the SealedSecret committed before the latest rotation. + +**Canonical recipe** (works from any host with `kubectl` + cluster credentials): + +```bash +SUPER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.super-password}' | base64 -d) +GROOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.groomer-password}' | base64 -d) +TESTER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.tester-password}' | base64 -d) +CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.customer-password}' | base64 -d) +``` + +**Why:** the Bitnami SealedSecret `apps/overlays/uat/ss-seed-uat-passwords.yaml` (in `groombook/infra`) is the single source of truth. The UAT `reset-demo-data` CronJob re-hashes these values into the `account` table on every run (idempotent — GRO-1977). A captured env var from a previous generation will not match the current hash, producing 401 `INVALID_EMAIL_OR_PASSWORD`. If the live login still 401s after pulling from the SealedSecret, the seed Job is stale — trigger `kubectl create job --from=cronjob/reset-demo-data -n groombook-uat manual-seed-$$` and retry. + +**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request. + ## Test Cases ### 4.0 Health Check @@ -41,6 +62,8 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-1.8 | Email+password — invalid password | POST /api/auth/sign-in/email with wrong password | 400 Bad Request, error returned | | TC-API-1.9 | Email+password — unknown user | POST /api/auth/sign-in/email with non-existent email | 400 Bad Request, error returned | | TC-API-1.10 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table | + +> **Note (GRO-1977):** Seed credential provisioning is idempotent — re-running the seed with updated `SEED_UAT_*_PASSWORD` env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug). | TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved | | TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true | | TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table | @@ -98,6 +121,26 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-3.13 | Reject too many temperamentFlags | POST /api/pets with 21 temperamentFlags | 400 Bad Request, max 20 flags enforced | | TC-API-3.14 | Reject too many preferredCuts | POST /api/pets with 21 preferredCuts | 400 Bad Request, max 20 cuts enforced | | TC-API-3.15 | Reject too many medicalAlerts | POST /api/pets with 51 medicalAlerts | 400 Bad Request, max 50 alerts enforced | +| TC-API-3.16 | Get pet profile summary | GET /api/pets/{id}/profile-summary | 200 OK, aggregated profile with grooming history, visit count, upcoming appointment | +| 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) | + +#### Seed Data Verification (GRO-1898) + +> As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-API-3.20 | GET /api/clients returns seed data | GET /api/clients | 200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) | +| TC-API-3.21 | GET /api/pets/{id} returns extended fields for seed pet | Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} | 200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null | +| TC-API-3.22 | Verify medicalAlerts shape | GET /api/pets/{id} for any pet with non-empty medicalAlerts | medicalAlerts is an array; each entry has type, description, severity | +| TC-API-3.23 | Verify UAT test pet Charlie has behavioral alert | GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) | medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" | +| TC-API-3.24 | Verify UAT test pet Delta has skin alert | GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) | medicalAlerts includes an entry with type: "skin" | +| TC-API-3.25 | Verify 30+ total pets in UAT DB | GET /api/pets then count total | 30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) | +| TC-API-3.26 | Verify 25-35% medicalAlerts distribution | GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts | Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) | +| TC-API-3.27 | Verify coat_type enum has all seed values | After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; coat_type includes all 8 values used by seed.ts `coatTypePool` | +| TC-API-3.28 | Verify pet_size_category enum has all seed values | After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large | UAT seed jobs (`reset-demo-data`, `seed-test-data`) complete 1/1 with no `enum_in` error; pet_size_category includes all 4 values used by seed.ts `petSizeCategoryPool` (regression for GRO-1999, mirrors TC-API-3.27) | ### 4.4 Appointment Scheduling @@ -159,6 +202,10 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet | TC-API-8.5 | Add waitlist entry | POST /api/portal/waitlist with pet and service | 201 Created, waitlist entry created | | TC-API-8.6 | View portal invoices | GET /api/portal/invoices | 200 OK, list of client's invoices returned | | TC-API-8.7 | Pay multiple invoices | POST /api/portal/invoices/pay-multiple with invoice IDs | 200 OK, payment intent created | +| TC-API-8.8 | SSO bridge — valid Better Auth session | POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) | 201 Created, `{sessionId, clientId, clientName}` returned | +| TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized | +| TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" | +| TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned | ### 4.9 Waitlist diff --git a/apps/api/src/__tests__/petProfileSummary.test.ts b/apps/api/src/__tests__/petProfileSummary.test.ts new file mode 100644 index 0000000..f7e5686 --- /dev/null +++ b/apps/api/src/__tests__/petProfileSummary.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { petsRouter } from "../routes/pets.js"; + +// ─── Mock staff fixtures ────────────────────────────────────────────────────── + +const MANAGER: StaffRow = { + id: "staff-manager-id", + 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 = { + id: "staff-groomer-id", + oidcSub: "oidc-groomer-sub", + userId: null, + role: "groomer", + isSuperUser: false, + name: "Groomer McGroome", + email: "groomer@example.com", + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +// ─── Mutable mock state ─────────────────────────────────────────────────────── + +const CLIENT_ID = "client-uuid-summary"; +const PET_ID = "pet-uuid-summary"; + +interface MockState { + pets: Record[]; + appointments: Record[]; + groomingLogs: Record[]; + staffMembers: Record[]; + services: Record[]; +} + +let mock: MockState; + +function resetMock() { + mock = { + pets: [{ + id: PET_ID, + clientId: CLIENT_ID, + name: "Biscuit", + species: "dog", + breed: "Golden Retriever", + weightKg: "30.00", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + customFields: {}, + photoKey: null, + photoUploadedAt: null, + image: null, + coatType: "double", + temperamentScore: 3, + temperamentFlags: ["gentle"], + medicalAlerts: [], + preferredCuts: ["puppy cut"], + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + }], + appointments: [ + { + id: "appt-completed-1", + clientId: CLIENT_ID, + petId: PET_ID, + serviceId: "service-1", + staffId: "staff-groomer-id", + batherStaffId: null, + status: "completed", + startTime: new Date("2024-06-01T09:00:00Z"), + endTime: new Date("2024-06-01T11:00:00Z"), + notes: null, + priceCents: 6000, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "confirmed", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2024-05-15"), + updatedAt: new Date("2024-05-15"), + }, + { + id: "appt-upcoming-1", + clientId: CLIENT_ID, + petId: PET_ID, + serviceId: "service-2", + staffId: "staff-groomer-id", + batherStaffId: null, + status: "confirmed", + startTime: new Date("2024-12-01T09:00:00Z"), + endTime: new Date("2024-12-01T11:00:00Z"), + notes: null, + priceCents: 6500, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "confirmed", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2024-11-01"), + updatedAt: new Date("2024-11-01"), + }, + ], + groomingLogs: [ + { + id: "log-1", + petId: PET_ID, + appointmentId: "appt-completed-1", + staffId: "staff-groomer-id", + cutStyle: "puppy cut", + productsUsed: "oatmeal shampoo", + notes: "Trimmed nails", + groomedAt: new Date("2024-06-01T10:00:00Z"), + createdAt: new Date("2024-06-01T10:00:00Z"), + }, + ], + staffMembers: [ + { + id: "staff-groomer-id", + name: "Groomer McGroome", + email: "groomer@example.com", + role: "groomer", + isSuperUser: false, + active: true, + oidcSub: "oidc-groomer-sub", + userId: null, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "staff-manager-id", + name: "Manager McManager", + email: "manager@example.com", + role: "manager", + isSuperUser: true, + active: true, + oidcSub: "oidc-manager-sub", + userId: null, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + services: [ + { id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() }, + { id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() }, + ], + }; +} + +vi.mock("../db/index.js", () => { + const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} }); + const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} }); + const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} }); + const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} }); + const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} }); + + // Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call + let selectedColumns: Record> = {}; + + function makeChainable(rows: unknown[]) { + const arr = rows as unknown[]; + return new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") { + return () => makeChainable(target); + } + if (prop === Symbol.iterator) { + return function* () { for (const v of target) yield v; }; + } + if (prop === Symbol.asyncIterator) { + return async function* () { for (const v of target) yield v; }; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + } + + // sql mock: returns an object with .as() so drizzle's select() can alias it + function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) { + const queryString = _strings[0]; + const asFn = (alias: string) => ({ + sql: { queryChunks: [_strings[0]] }, + fieldAlias: alias, + getSQL() { return this.sql; }, + }); + return { queryChunks: [queryString], as: asFn }; + } + + return { + getDb: () => ({ + select: (cols?: Record) => { + selectedColumns = {}; + if (cols) { + // Inspect cols to find sql-aliased expressions and their aliases + for (const [alias, expr] of Object.entries(cols)) { + if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record).as === "function") { + const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias); + // Detect count(*) queries + if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) { + // Store count query intent — we'll resolve it in from() + if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {}; + selectedColumns["appointments"][alias] = { _isCountQuery: true }; + } + } + } + } + return { + from: (table: unknown) => { + const name = (table as { _name?: string })._name; + const tableCols = selectedColumns[name] || {}; + // If this table has a count query, return computed count result + const countQueryEntry = Object.entries(tableCols).find(([, v]) => + typeof v === "object" && v !== null && "_isCountQuery" in v + ); + if (countQueryEntry) { + const [countAlias] = countQueryEntry; + const count = (name === "appointments" ? mock.appointments : []) + .filter((row: Record) => row.status === "completed").length; + return makeChainable([{ [countAlias]: count }]); + } + if (name === "pets") return makeChainable(mock.pets); + if (name === "appointments") return makeChainable(mock.appointments); + if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs); + if (name === "staff") return makeChainable(mock.staffMembers); + if (name === "services") return makeChainable(mock.services); + return makeChainable([]); + }, + }; + }, + insert: () => ({ values: () => ({ returning: () => [{}] }) }), + update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }), + delete: () => ({ where: () => ({ returning: () => [{}] }) }), + }), + pets, + appointments, + groomingVisitLogs, + staff, + services, + and: vi.fn((a: unknown, b: unknown) => [a, b]), + desc: vi.fn((c: unknown) => c), + eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), + exists: vi.fn(() => true), + gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })), + or: vi.fn((a: unknown, b: unknown) => [a, b]), + sql: sqlMock, + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeApp(staff: StaffRow = MANAGER) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("staff", staff); + await next(); + }); + return app.route("/pets", petsRouter); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("GET /:id/profile-summary", () => { + beforeEach(resetMock); + + it("returns 404 for non-existent pet", async () => { + const app = makeApp(); + mock.pets = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(404); + }); + + it("returns 403 for groomer with no pet linkage", async () => { + const app = makeApp(GROOMER); + // Groomer has no linkage to this pet's client — clear appointments + mock.appointments = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(403); + }); + + it("returns complete aggregated profile for manager", async () => { + const app = makeApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe(PET_ID); + expect(body.name).toBe("Biscuit"); + expect(body.species).toBe("dog"); + expect(body.recentGroomingHistory).toBeInstanceOf(Array); + expect(body.lastVisitDate).toBeTruthy(); + expect(body.visitCount).toBeGreaterThanOrEqual(0); + }); + + it("groomer with pet linkage returns 200", async () => { + const app = makeApp(GROOMER); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + }); + + it("recentGroomingHistory is limited to 10 entries", async () => { + const app = makeApp(MANAGER); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10); + }); + + it("returns null upcomingAppointment when none scheduled", async () => { + const app = makeApp(MANAGER); + mock.appointments = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.upcomingAppointment).toBeNull(); + }); +}); + +describe("GET /:id/profile-summary — visitCount", () => { + beforeEach(resetMock); + + it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => { + const app = makeApp(MANAGER); + // Add a second completed appointment + mock.appointments = [ + ...mock.appointments, + { + id: "appt-completed-2", + clientId: CLIENT_ID, + petId: PET_ID, + serviceId: "service-1", + staffId: "staff-groomer-id", + batherStaffId: null, + status: "completed", + startTime: new Date("2024-07-01T09:00:00Z"), + endTime: new Date("2024-07-01T11:00:00Z"), + notes: null, + priceCents: 6000, + seriesId: null, + seriesIndex: null, + groupId: null, + confirmationStatus: "confirmed", + confirmedAt: null, + cancelledAt: null, + confirmationToken: null, + customerNotes: null, + createdAt: new Date("2024-06-15"), + updatedAt: new Date("2024-06-15"), + }, + ]; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.visitCount).toBeGreaterThanOrEqual(2); + }); + + it("returns visitCount = 0 when no completed appointments", async () => { + const app = makeApp(MANAGER); + mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" })); + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.visitCount).toBe(0); + }); +}); + +describe("GET /:id/profile-summary — empty history", () => { + beforeEach(resetMock); + + it("returns empty history array when no grooming logs", async () => { + const app = makeApp(MANAGER); + mock.groomingLogs = []; + const res = await app.request(`/pets/${PET_ID}/profile-summary`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recentGroomingHistory).toEqual([]); + expect(body.lastVisitDate).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/api/src/__tests__/seed-uat-credentials.test.ts b/apps/api/src/__tests__/seed-uat-credentials.test.ts index 7f954ae..9bfccbf 100644 --- a/apps/api/src/__tests__/seed-uat-credentials.test.ts +++ b/apps/api/src/__tests__/seed-uat-credentials.test.ts @@ -67,6 +67,7 @@ let dbAccounts: AccountRow[] = []; let dbStaff: StaffRow[] = []; let insertedUsers: UserRow[] = []; let insertedAccounts: AccountRow[] = []; +let updatedAccounts: Array<{ id: string; password: string }> = []; let updatedStaff: Array<{ id: string; userId: string }> = []; const originalEnv = { ...process.env }; @@ -77,6 +78,7 @@ function resetMock() { dbStaff = []; insertedUsers = []; insertedAccounts = []; + updatedAccounts = []; updatedStaff = []; process.env = { ...originalEnv }; } @@ -173,7 +175,11 @@ async function seedUatCredentials( ); if (existingAccount) { - // skip — already has credential account + // Idempotent update: re-hash the current env password and update the stored hash. + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + existingAccount.password = passwordHash; + updatedAccounts.push({ id: existingAccount.id, password: passwordHash }); } else { // Use Better-Auth's hashPassword so test helper matches production seed.ts const { hashPassword } = await import("better-auth/crypto"); @@ -312,9 +318,9 @@ describe("seedUatCredentials — credential provisioning logic", () => { expect(updatedStaff).toHaveLength(0); }); - // ── AC-5: idempotent — skips when user already exists ─────────────────────── + // ── AC-5: idempotent — does not insert duplicate records ─────────────────── - it("AC-5: re-running does not duplicate user or account records (idempotent)", async () => { + it("AC-5: re-running does not insert duplicate user or account records", async () => { process.env.SEED_UAT_CUSTOMER_PASSWORD = TEST_PASSWORD; const preExistingUsers: UserRow[] = [ @@ -330,25 +336,96 @@ describe("seedUatCredentials — credential provisioning logic", () => { }, ]; - // First call — nothing inserted (user + account pre-exist) await seedUatCredentials([UAT_ACCOUNTS[2]!], { users: preExistingUsers, accounts: preExistingAccounts, staff: [], }); + // No inserts — user and account already exist expect(insertedUsers).toHaveLength(0); expect(insertedAccounts).toHaveLength(0); + }); + + // ── AC-5b: password rotation on re-seed ───────────────────────────────────── + + it("AC-5b: re-running with a new password updates the stored credential hash", async () => { + const OLD_PASSWORD = "old-password-abc"; + const NEW_PASSWORD = "new-password-xyz"; + process.env.SEED_UAT_CUSTOMER_PASSWORD = NEW_PASSWORD; + + const preExistingUsers: UserRow[] = [ + { id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true }, + ]; + const preExistingAccounts: AccountRow[] = [ + { + id: "pre-existing-acct", + accountId: "pre-existing-user", + providerId: "credential", + userId: "pre-existing-user", + password: await hashPassword(OLD_PASSWORD), + }, + ]; - // Second call — still nothing inserted await seedUatCredentials([UAT_ACCOUNTS[2]!], { users: preExistingUsers, accounts: preExistingAccounts, staff: [], }); + // No new records inserted expect(insertedUsers).toHaveLength(0); expect(insertedAccounts).toHaveLength(0); + // Password WAS updated to the new env value + expect(updatedAccounts).toHaveLength(1); + expect(updatedAccounts[0]!.id).toBe("pre-existing-acct"); + // New hash is valid Better-Auth format (salt:key, each hex) + const newHashParts = updatedAccounts[0]!.password.split(":"); + expect(Buffer.from(newHashParts[0]!, "hex")).toHaveLength(16); + expect(Buffer.from(newHashParts[1]!, "hex")).toHaveLength(64); + }); + + // ── AC-8: existing account password IS updated (not frozen at first-seed) ── + + it("AC-8: re-seeding with a changed password env var updates the stored hash", async () => { + const ORIGINAL_PASSWORD = "original-password"; + const ROTATED_PASSWORD = "rotated-password-456"; + + process.env.SEED_UAT_CUSTOMER_PASSWORD = ROTATED_PASSWORD; + + const preExistingUsers: UserRow[] = [ + { id: "pre-existing-user", email: "uat-customer@groombook.dev", name: "UAT Customer", emailVerified: true }, + ]; + // Account was created with the original password on first seed + const originalHash = await hashPassword(ORIGINAL_PASSWORD); + const preExistingAccounts: AccountRow[] = [ + { + id: "pre-existing-acct", + accountId: "pre-existing-user", + providerId: "credential", + userId: "pre-existing-user", + password: originalHash, + }, + ]; + + // Re-seed with the rotated password env var + await seedUatCredentials([UAT_ACCOUNTS[2]!], { + users: preExistingUsers, + accounts: preExistingAccounts, + staff: [], + }); + + // No new user or account created + expect(insertedUsers).toHaveLength(0); + expect(insertedAccounts).toHaveLength(0); + + // The pre-existing account's password WAS updated (not frozen at first-seed). + // hashPassword uses a random salt so we verify by format + that it is a new, + // different valid hash from the original. + const updatedAcct = preExistingAccounts[0]!; + expect(updatedAcct.password).toBeDefined(); + expect(updatedAcct.password).toMatch(/^[a-f0-9]{32}:[a-f0-9]{128}$/); + expect(updatedAcct.password).not.toBe(originalHash); // it actually changed }); // ── AC-6: missing env var skips with warning ──────────────────────────────── diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 566da17..5b48dd6 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -20,6 +20,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { eq, and, sql } from "drizzle-orm"; import * as schema from "./schema.js"; +import type { MedicalAlert, MedicalAlertSeverity } from "./schema.js"; // ── Seed profile configuration ───────────────────────────────────────────── @@ -252,6 +253,38 @@ const appointmentNotes = [ "Client running late, pushed start by 15min", ]; +const temperamentScores = [3, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9]; + +const temperamentFlags = [ + [], ["anxious"], ["friendly"], ["nippy"], ["anxious", "sensitive"], + ["friendly", "calm"], ["nippy", "territorial"], ["calm"], ["sensitive"], + ["friendly", "nippy"], ["anxious", "territorial"], +]; + +const medicalAlertsList = [ + [] as MedicalAlert[], + [] as MedicalAlert[], + [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], + [{ type: "ear", description: "Ear infection prone — dry ears thoroughly", severity: "medium" as MedicalAlertSeverity }], + [{ type: "mobility", description: "Hip dysplasia — handle with care", severity: "high" as MedicalAlertSeverity }], + [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], + [{ type: "medical", description: "Seizure history — avoid stress triggers", severity: "high" as MedicalAlertSeverity }], + [{ type: "skin", description: "Skin allergies — use hypoallergenic products only", severity: "medium" as MedicalAlertSeverity }], + [{ type: "behavioral", description: "Aggressive when nails trimmed — muzzle required", severity: "high" as MedicalAlertSeverity }], + [{ type: "cardiac", description: "Heart murmur — monitor during grooming", severity: "high" as MedicalAlertSeverity }], + [{ type: "dietary", description: "Diabetic — owner brings treats", severity: "medium" as MedicalAlertSeverity }], +]; + +const preferredCutsList = [ + [], ["Puppy Cut"], ["Teddy Bear Cut"], ["Breed Standard"], + ["Puppy Cut", "Sanitary Trim"], ["Full Groom"], ["Lion Cut"], + ["Kennel Cut", "Face & Feet Trim"], ["Teddy Bear Cut", "Sanitary Trim"], + ["Breed Standard", "Sanitary Trim"], ["Summer Shave"], + ["Puppy Cut", "Face & Feet Trim", "Sanitary Trim"], +]; + +const coatTypes: string[] = ["short", "medium", "long", "curly", "wire", "double", "silky"]; + const visitLogNotes = [ null, null, "Coat in great condition", @@ -561,7 +594,15 @@ async function seedKnownUsers() { .limit(1); if (existingAccount) { - console.log(`✓ Credential account for '${acct.email}' already exists — skipping`); + // Re-hash and update the password so that re-seeding rotates credentials + // when the env var changes (e.g. after a password rotation). Previously + // this branch skipped entirely, freezing the hash at first-seed. + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); + await db.update(schema.account) + .set({ password: passwordHash }) + .where(eq(schema.account.id, existingAccount.id)); + console.log(`✓ Updated credential account password for '${acct.email}'`); } else { // Use Better-Auth's own hashPassword to guarantee parameter/encoding match. // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random @@ -872,6 +913,11 @@ async function seed() { cutStyle: pick(cutStyles), shampooPreference: pick(shampoos), specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, + coatType: pick(coatTypes), + temperamentScore: pick(temperamentScores), + temperamentFlags: pick(temperamentFlags), + medicalAlerts: pick(medicalAlertsList), + preferredCuts: pick(preferredCutsList), customFields: {}, image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), }); @@ -907,6 +953,11 @@ async function seed() { cutStyle: pet.cutStyle, shampooPreference: pet.shampooPreference, specialCareNotes: pet.specialCareNotes, + coatType: pet.coatType, + temperamentScore: pet.temperamentScore, + temperamentFlags: pet.temperamentFlags, + medicalAlerts: pet.medicalAlerts, + preferredCuts: pet.preferredCuts, customFields: pet.customFields, image: pet.image, }, @@ -929,13 +980,18 @@ async function seed() { petId: string; petName: string; petBreed: string; + petCoatType: string; + petTemperamentScore: number; + petTemperamentFlags: string[]; + petMedicalAlerts: MedicalAlert[]; + petPreferredCuts: string[]; } const uatClients: UatClient[] = [ - { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" }, - { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" }, - { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" }, - { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog" }, - { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle" }, + { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever", petCoatType: "double", petTemperamentScore: 7, petTemperamentFlags: ["calm", "friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Breed Standard"] }, + { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever", petCoatType: "short", petTemperamentScore: 8, petTemperamentFlags: ["friendly"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Bath & Brush", "Sanitary Trim"] }, + { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle", petCoatType: "curly", petTemperamentScore: 9, petTemperamentFlags: ["calm"], petMedicalAlerts: [{ type: "behavioral", description: "Anxious — needs slow approach", severity: "low" as MedicalAlertSeverity }], petPreferredCuts: ["Teddy Bear Cut"] }, + { id: uuid(), name: "UAT Test Delta", email: "uat-delta@groombook.dev", phone: "(555) 100-0004", address: "400 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestRocky", petBreed: "French Bulldog", petCoatType: "short", petTemperamentScore: 6, petTemperamentFlags: ["nippy"], petMedicalAlerts: [{ type: "skin", description: "Sensitive skin — avoid harsh shampoos", severity: "medium" as MedicalAlertSeverity }], petPreferredCuts: ["Puppy Cut"] }, + { id: uuid(), name: "UAT Test Echo", email: "uat-echo@groombook.dev", phone: "(555) 100-0005", address: "500 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestDuke", petBreed: "Beagle", petCoatType: "short", petTemperamentScore: 7, petTemperamentFlags: ["friendly", "energetic"], petMedicalAlerts: [] as MedicalAlert[], petPreferredCuts: ["Full Groom", "Nail Trim"] }, ]; for (const uc of uatClients) { @@ -943,8 +999,26 @@ async function seed() { .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); await db.insert(schema.pets) - .values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) }) - .onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } }); + .values({ + id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, + weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), + coatType: uc.petCoatType, + temperamentScore: uc.petTemperamentScore, + temperamentFlags: uc.petTemperamentFlags, + medicalAlerts: uc.petMedicalAlerts, + preferredCuts: uc.petPreferredCuts, + image: pick(demoPetImages), + }) + .onConflictDoUpdate({ target: schema.pets.id, set: { + clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, + weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), + coatType: uc.petCoatType, + temperamentScore: uc.petTemperamentScore, + temperamentFlags: uc.petTemperamentFlags, + medicalAlerts: uc.petMedicalAlerts, + preferredCuts: uc.petPreferredCuts, + image: pick(demoPetImages), + } }); // Create one completed appointment for this client const apptId = uuid(); const svcIdx = 0; diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index dbc5418..f8b6440 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js"; +import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, @@ -283,3 +283,133 @@ petsRouter.get("/:petId/photo", async (c) => { const url = await getPresignedGetUrl(pet.photoKey); return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt }); }); + +// ─── Profile Summary ─────────────────────────────────────────────────────────── + +async function groomerLinkageCheck( + db: ReturnType, + clientId: string, + staffRow: NonNullable +): Promise { + const [linkage] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + return !!linkage; +} + +/** + * GET /:id/profile-summary + * Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment. + * Groomer RBAC: same visibility rules as GET /:id. + */ +petsRouter.get("/:id/profile-summary", async (c) => { + const db = getDb(); + const petId = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + const [row] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!row) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow); + if (!hasLinkage) return c.json({ error: "Forbidden" }, 403); + } + + // Recent grooming history: last 10, with staff name join + const historyRows = await db + .select({ + id: groomingVisitLogs.id, + petId: groomingVisitLogs.petId, + appointmentId: groomingVisitLogs.appointmentId, + staffId: groomingVisitLogs.staffId, + staffName: staff.name, + cutStyle: groomingVisitLogs.cutStyle, + productsUsed: groomingVisitLogs.productsUsed, + notes: groomingVisitLogs.notes, + groomedAt: groomingVisitLogs.groomedAt, + createdAt: groomingVisitLogs.createdAt, + }) + .from(groomingVisitLogs) + .leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId)) + .where(eq(groomingVisitLogs.petId, petId)) + .orderBy(desc(groomingVisitLogs.groomedAt)) + .limit(10); + + const recentGroomingHistory = historyRows.map((r) => ({ + id: r.id, + petId: r.petId, + appointmentId: r.appointmentId, + staffId: r.staffId, + staffName: r.staffName, + cutStyle: r.cutStyle, + productsUsed: r.productsUsed, + notes: r.notes, + groomedAt: r.groomedAt?.toISOString() ?? null, + createdAt: r.createdAt?.toISOString() ?? null, + })); + + const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null; + + // Completed appointment count for this pet + const [{ count: visitCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(appointments) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))); + + // Upcoming appointment: next scheduled or confirmed + const [nextAppt] = await db + .select({ + id: appointments.id, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .leftJoin(services, eq(services.id, appointments.serviceId)) + .leftJoin(staff, eq(staff.id, appointments.staffId)) + .where( + and( + eq(appointments.petId, petId), + or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")), + gte(appointments.startTime, new Date()) + ) + ) + .orderBy(appointments.startTime) + .limit(1); + + const upcomingAppointment = nextAppt + ? { + id: nextAppt.id, + serviceId: nextAppt.serviceId, + serviceName: nextAppt.serviceName, + staffId: nextAppt.staffId, + staffName: nextAppt.staffName, + startTime: nextAppt.startTime?.toISOString() ?? null, + endTime: nextAppt.endTime?.toISOString() ?? null, + status: nextAppt.status, + } + : null; + + return c.json({ + ...row, + recentGroomingHistory, + lastVisitDate, + visitCount, + upcomingAppointment, + }); +}); diff --git a/packages/db/migrations/0034_extend_pet_profile_columns.sql b/packages/db/migrations/0034_extend_pet_profile_columns.sql new file mode 100644 index 0000000..e931dc4 --- /dev/null +++ b/packages/db/migrations/0034_extend_pet_profile_columns.sql @@ -0,0 +1,8 @@ +-- Migration: 0034_extend_pet_profile_columns.sql +-- GRO-1850: Adds temperament_score, temperament_flags, medical_alerts, +-- and preferred_cuts columns to the pets table. + +ALTER TABLE "pets" ADD COLUMN "temperament_score" integer; +ALTER TABLE "pets" ADD COLUMN "temperament_flags" jsonb DEFAULT '[]'; +ALTER TABLE "pets" ADD COLUMN "medical_alerts" jsonb DEFAULT '[]'; +ALTER TABLE "pets" ADD COLUMN "preferred_cuts" jsonb DEFAULT '[]'; \ No newline at end of file diff --git a/packages/db/migrations/0035_add_missing_coat_type_values.sql b/packages/db/migrations/0035_add_missing_coat_type_values.sql new file mode 100644 index 0000000..3b7a2d3 --- /dev/null +++ b/packages/db/migrations/0035_add_missing_coat_type_values.sql @@ -0,0 +1,9 @@ +-- Migration: 0035_add_missing_coat_type_values.sql +-- Adds missing values to coat_type enum that seed.ts requires but which were +-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift). +-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless' +-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky' + +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky'; \ No newline at end of file diff --git a/packages/db/migrations/0035_add_short_to_coat_type_enum.sql b/packages/db/migrations/0035_add_short_to_coat_type_enum.sql new file mode 100644 index 0000000..26145a9 --- /dev/null +++ b/packages/db/migrations/0035_add_short_to_coat_type_enum.sql @@ -0,0 +1,14 @@ +-- Migration: 0035_add_short_to_coat_type_enum.sql +-- GRO-1953: Adds missing "short" value to the coat_type enum so that seed data +-- (which uses coatTypePool including "short") can be inserted without error. +-- +-- The seed file defines coatTypePool as: +-- ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"] +-- but migration 0031 created the enum without "short", causing: +-- PostgresError: invalid input value for enum coat_type: "short" + +BEGIN; + +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short'; + +COMMIT; \ No newline at end of file diff --git a/packages/db/migrations/0036_add_missing_coat_type_values.sql b/packages/db/migrations/0036_add_missing_coat_type_values.sql new file mode 100644 index 0000000..026c5ef --- /dev/null +++ b/packages/db/migrations/0036_add_missing_coat_type_values.sql @@ -0,0 +1,9 @@ +-- Migration: 0036_add_missing_coat_type_values.sql +-- Adds missing values to coat_type enum that seed.ts requires but which were +-- omitted from the 0031_buffer_rules.sql CREATE TYPE statement (migration drift). +-- 0031 created: 'smooth', 'double', 'wire', 'curly', 'long', 'hairless' +-- Missing (from schema.ts coatTypeEnum): 'short', 'medium', 'silky' + +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium'; +ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky'; \ No newline at end of file diff --git a/packages/db/migrations/0037_add_extra_large_to_pet_size_category.sql b/packages/db/migrations/0037_add_extra_large_to_pet_size_category.sql new file mode 100644 index 0000000..e7eac1a --- /dev/null +++ b/packages/db/migrations/0037_add_extra_large_to_pet_size_category.sql @@ -0,0 +1,19 @@ +-- Migration: 0037_add_extra_large_to_pet_size_category.sql +-- GRO-1979: Adds the 'extra_large' value to the pet_size_category enum. +-- +-- 0031_buffer_rules.sql created pet_size_category with values +-- ('small', 'medium', 'large', 'xlarge'), but seed.ts and the drizzle +-- schema (PetSizeCategory type) both use 'extra_large' — a mismatch that +-- caused the UAT seed job to fail with: +-- invalid input value for enum pet_size_category: "extra_large" +-- +-- 0035/0036 (GRO-1971) registered 'short'/'medium'/'silky' in coat_type. +-- This migration is the pet_size_category counterpart: register +-- 'extra_large' so seed.ts can write the value the schema declares. +-- +-- Postgres restriction: ALTER TYPE ADD VALUE cannot run inside a +-- transaction block. The drizzle migrate runner does not wrap +-- individual statements in an explicit transaction, so this applies +-- as a single auto-commit DDL. + +ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large'; diff --git a/packages/db/migrations/0038_register_extra_large_pet_size_category.sql b/packages/db/migrations/0038_register_extra_large_pet_size_category.sql new file mode 100644 index 0000000..e80fe7f --- /dev/null +++ b/packages/db/migrations/0038_register_extra_large_pet_size_category.sql @@ -0,0 +1,4 @@ +-- GRO-1999: 0037 was skipped on existing DBs due to a below-high-water-mark +-- journal timestamp. Re-register extra_large with a monotonic timestamp so +-- the existing UAT/persistent DBs apply it. Idempotent. +ALTER TYPE "pet_size_category" ADD VALUE IF NOT EXISTS 'extra_large'; diff --git a/packages/db/migrations/meta/0034_snapshot.json b/packages/db/migrations/meta/0034_snapshot.json new file mode 100644 index 0000000..66c1851 --- /dev/null +++ b/packages/db/migrations/meta/0034_snapshot.json @@ -0,0 +1,210 @@ +{ + "id": "0034_extend_pet_profile_columns", + "prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "species": { + "name": "species", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "breed": { + "name": "breed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "health_alerts": { + "name": "health_alerts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grooming_notes": { + "name": "grooming_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shampoo_preference": { + "name": "shampoo_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "special_care_notes": { + "name": "special_care_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coat_type": { + "name": "coat_type", + "type": "coat_type", + "primaryKey": false, + "notNull": false + }, + "pet_size_category": { + "name": "pet_size_category", + "type": "pet_size_category", + "primaryKey": false, + "notNull": false + }, + "temperament_score": { + "name": "temperament_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temperament_flags": { + "name": "temperament_flags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "medical_alerts": { + "name": "medical_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "preferred_cuts": { + "name": "preferred_cuts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "photo_key": { + "name": "photo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_uploaded_at": { + "name": "photo_uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pets_client_id_clients_id_fk": { + "name": "pets_client_id_clients_id_fk", + "tableFrom": "pets", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "coat_type": { + "name": "coat_type", + "values": [ + "short", + "medium", + "long", + "wire", + "double", + "hairless", + "curly" + ] + }, + "pet_size_category": { + "name": "pet_size_category", + "values": [ + "small", + "medium", + "large", + "extra_large" + ] + } + }, + "nativeEnums": {} +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index a364fe1..0645748 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -239,6 +239,34 @@ "when": 1779500000000, "tag": "0033_add_services_default_buffer_minutes", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1751140800000, + "tag": "0034_extend_pet_profile_columns", + "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1751480000000, + "tag": "0036_add_missing_coat_type_values", + "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1751500000000, + "tag": "0037_add_extra_large_to_pet_size_category", + "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1780000000000, + "tag": "0038_register_extra_large_pet_size_category", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 79164fa..cf65909 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -20,6 +20,7 @@ import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { eq, and, sql } from "drizzle-orm"; import * as schema from "./schema.js"; +import type { MedicalAlert } from "@groombook/types"; // ── Seed profile configuration ───────────────────────────────────────────── @@ -243,6 +244,59 @@ const groomingNotes = [ "Previous clipper burn — be gentle on belly", ]; +// ── Extended pet profile pools ───────────────────────────────────────────────── + +const temperamentFlagPool: string[] = [ + "friendly", + "anxious-with-strangers", + "good-with-kids", + "leash-reactive", + "vocal", + "high-energy", + "calm-on-table", + "treat-motivated", +]; + +const medicalAlertPool: MedicalAlert[] = [ + { id: "", type: "allergies", description: "Seasonal allergies — monitor skin", severity: "low" }, + { id: "", type: "allergies", description: "Chicken allergy — avoid poultry-based treats", severity: "high" }, + { id: "", type: "joint", description: "Hip dysplasia — handle with care", severity: "medium" }, + { id: "", type: "joint", description: "Arthritis — anti-inflammatory medication on file", severity: "medium" }, + { id: "", type: "dental", description: "Dental disease — extractions in history", severity: "medium" }, + { id: "", type: "dental", description: "Baby teeth retained — vet monitor", severity: "low" }, + { id: "", type: "heart", description: "Heart murmur grade II — avoid stress", severity: "high" }, + { id: "", type: "heart", description: "Murmur cleared by vet last year", severity: "low" }, + { id: "", type: "other", description: "Eye ulcer history — be careful around face", severity: "medium" }, + { id: "", type: "other", description: "Seizure history — avoid flashing lights", severity: "high" }, + { id: "", type: "other", description: "Luxating patella — short walks only", severity: "medium" }, + { id: "", type: "other", description: "Ear infections — dry thoroughly after bath", severity: "low" }, + { id: "", type: "behavioral", description: "Anxiety — calm environment preferred", severity: "low" }, + { id: "", type: "behavioral", description: "Fear-based aggression — approach with caution", severity: "high" }, + { id: "", type: "skin", description: "Contact dermatitis — avoid harsh chemicals", severity: "medium" }, + { id: "", type: "skin", description: "Hot spots — monitor and report any worsening", severity: "high" }, +]; + +const preferredCutPool: string[] = [ + "Puppy Cut", + "Teddy Bear Cut", + "Lion Cut", + "Breed Standard", + "Summer Shave", + "Kennel Cut", + "Lamb Cut", + "Continental Clip", + "Sporting Clip", + "Sanitary Trim", + "Face & Feet Trim", + "Full Groom", +]; + +type CoatType = (typeof schema.coatTypeEnum.enumValues)[number]; +type PetSizeCategory = (typeof schema.petSizeCategoryEnum.enumValues)[number]; + +const coatTypePool: CoatType[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]; +const petSizeCategoryPool: PetSizeCategory[] = ["small", "medium", "large", "extra_large"]; + const appointmentNotes = [ null, null, null, null, "Client requested extra brushing", @@ -335,78 +389,19 @@ const servicesDef = [ { id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 }, ]; -// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── +// ── UAT staff account seeding (shared between seed paths) ───────────────────── /** - * Seeds only the minimal known users for prod/demo environments. - * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. - * Idempotent: skips creation if records already exist. + * Seeds or upserts the deterministic UAT staff accounts with numeric OIDC subs + * from SEED_UAT_*_OIDC_SUB / SEED_UAT_GROOMER_OIDC_SUBS env vars. + * + * In the full seed path this must run AFTER random staff are created so the + * deterministic upserts land on the correct rows (groomers referenced by the + * UAT test-client appointment logic use groomers[0] etc.). + * + * In seedKnownUsers() this replaces the inline UAT-staff block. */ -async function seedKnownUsers() { - const url = process.env.DATABASE_URL; - if (!url) { - console.error("DATABASE_URL is not set"); - process.exit(1); - } - - const client = postgres(url, { max: 5 }); - const db = drizzle(client, { schema }); - - console.log("Seeding known users (prod/demo mode)...\n"); - - const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; - const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; - const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; - - // ── Staff: Demo Manager ── - const [existingStaff] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, "demo-manager@groombook.dev")) - .limit(1); - - if (existingStaff) { - console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: KNOWN_STAFF_ID, - name: "Demo Manager", - email: "demo-manager@groombook.dev", - oidcSub: "demo-manager-001", - role: "manager", - isSuperUser: true, - active: true, - }); - console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); - } - - // ── Staff: SEED_ADMIN_EMAIL admin ── - const adminEmail = process.env.SEED_ADMIN_EMAIL; - if (adminEmail) { - const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; - const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; - const [existingAdmin] = await db - .select() - .from(schema.staff) - .where(eq(schema.staff.email, adminEmail)) - .limit(1); - - if (existingAdmin) { - console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`); - } else { - await db.insert(schema.staff).values({ - id: ADMIN_STAFF_ID, - name: adminName, - email: adminEmail, - oidcSub: adminEmail, - role: "manager", - isSuperUser: true, - active: true, - }); - console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`); - } - } - +async function seedUatStaffAccounts(db: ReturnType) { // ── Staff: UAT Super User (oidcSub from SEED_UAT_SUPER_OIDC_SUB env var) ── const uatSuperOidcSub = process.env.SEED_UAT_SUPER_OIDC_SUB; if (uatSuperOidcSub) { @@ -574,6 +569,184 @@ async function seedKnownUsers() { } } + // ── Client: UAT Customer ───────────────────────────────────────────────────── + // Only uat-customer is a real end-user who needs a clients row. + // uat-groomer and uat-super are staff — they have staff records, not client records. + const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001"; + const [uatCustomerRow] = await db + .select() + .from(schema.clients) + .where(eq(schema.clients.email, "uat-customer@groombook.dev")) + .limit(1); + + let uatCustomerClientId: string; + if (uatCustomerRow) { + uatCustomerClientId = uatCustomerRow.id; + console.log(`✓ UAT Customer client record already exists — skipping`); + } else { + const [created] = await db + .insert(schema.clients) + .values({ + id: UAT_CUSTOMER_ID, + email: "uat-customer@groombook.dev", + name: "UAT Customer", + phone: "555-0102", + address: "1 UAT Lane, Test City, CA 90210", + }) + .returning(); + uatCustomerClientId = created!.id; + console.log(`✓ Created client 'UAT Customer' for SSO bridge`); + } + + // ── Pets: UAT Customer's dogs ──────────────────────────────────────────────── + const uatCustomerPets = [ + { id: "c0000001-0000-0000-0000-000000000002", name: "UAT Pup Alpha", species: "Dog", breed: "Beagle", weight: "12.00", dob: "2022-03-10", image: "/demo-pets/dog-beagle.png" }, + { id: "c0000001-0000-0000-0000-000000000003", name: "UAT Pup Beta", species: "Dog", breed: "Labrador", weight: "28.00", dob: "2021-07-22", image: "/demo-pets/dog-labrador.png" }, + ]; + for (const pet of uatCustomerPets) { + const [existing] = await db + .select() + .from(schema.pets) + .where(eq(schema.pets.id, pet.id)) + .limit(1); + + if (existing) { + // Upsert so extended fields are always populated on re-runs + await db.insert(schema.pets) + .values({ + id: pet.id, + clientId: uatCustomerClientId, + name: pet.name, + species: pet.species, + breed: pet.breed, + weightKg: pet.weight, + dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), + image: pet.image, + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: [], + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { + clientId: uatCustomerClientId, + name: pet.name, + species: pet.species, + breed: pet.breed, + weightKg: pet.weight, + dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), + image: pet.image, + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: [], + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }, + }); + console.log(`✓ Upserted UAT pet '${pet.name}' with extended fields`); + } else { + await db.insert(schema.pets).values({ + id: pet.id, + clientId: uatCustomerClientId, + name: pet.name, + species: pet.species, + breed: pet.breed, + weightKg: pet.weight, + dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), + image: pet.image, + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: [], + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }); + console.log(`✓ Created UAT pet '${pet.name}' with extended fields`); + } + } +} + +// ── Known-users-only seed (prod/demo) ─────────────────────────────────────── + +/** + * Seeds only the minimal known users for prod/demo environments. + * Creates: Demo Manager staff + Demo Client + Demo Dog + basic services. + * Idempotent: skips creation if records already exist. + */ +async function seedKnownUsers() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + const client = postgres(url, { max: 5 }); + const db = drizzle(client, { schema }); + + console.log("Seeding known users (prod/demo mode)...\n"); + + const KNOWN_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + const DEMO_CLIENT_ID = "00000000-0000-0000-0000-000000000002"; + const DEMO_PET_ID = "00000000-0000-0000-0000-000000000003"; + + // ── Staff: Demo Manager ── + const [existingStaff] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, "demo-manager@groombook.dev")) + .limit(1); + + if (existingStaff) { + console.log(`✓ Staff '${existingStaff.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: KNOWN_STAFF_ID, + name: "Demo Manager", + email: "demo-manager@groombook.dev", + oidcSub: "demo-manager-001", + role: "manager", + isSuperUser: true, + active: true, + }); + console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); + } + + // ── Staff: SEED_ADMIN_EMAIL admin ── + const adminEmail = process.env.SEED_ADMIN_EMAIL; + if (adminEmail) { + const adminName = process.env.SEED_ADMIN_NAME ?? "Admin"; + const ADMIN_STAFF_ID = "00000000-0000-0000-0000-000000000002"; + const [existingAdmin] = await db + .select() + .from(schema.staff) + .where(eq(schema.staff.email, adminEmail)) + .limit(1); + + if (existingAdmin) { + console.log(`✓ Staff admin '${existingAdmin.name}' already exists — skipping`); + } else { + await db.insert(schema.staff).values({ + id: ADMIN_STAFF_ID, + name: adminName, + email: adminEmail, + oidcSub: adminEmail, + role: "manager", + isSuperUser: true, + active: true, + }); + console.log(`✓ Created staff admin '${adminName}' (${adminEmail})`); + } + } + + // ── 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); + // ── Services: idempotent upsert using name as unique key ───────────────────── // UNIQUE constraint on services.name (migration 0020) must exist first. // Uses b0000001-... IDs to match main seed servicesDef for same-named services. @@ -740,30 +913,10 @@ async function seed() { console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); } - // ── 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) ?? []; - const groomerCount = Math.min(groomerEmails.length, groomerNames.length); - for (let i = 0; i < groomerCount; i++) { - const email = groomerEmails[i]!; - const name = groomerNames[i]!; - const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`; - await db.insert(schema.staff) - .values({ - id: staffId, - name, - email, - oidcSub: email, - role: "groomer", - isSuperUser: false, - active: true, - }) - .onConflictDoUpdate({ - target: schema.staff.email, - set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true }, - }); - console.log(`✓ Upserted groomer '${name}' (${email})`); - } + // ── 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); // ── Services ── // Upsert services using name as unique key. With deterministic IDs in @@ -853,6 +1006,19 @@ async function seed() { specialCareNotes: rand() < 0.1 ? "Vet clearance required before grooming" : null, customFields: {}, image: petIndex < 250 ? pick(puggleImages) : pick(demoPetImages), + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: (() => { + // ~30% of random-pool pets have alerts — lands squarely in the 25–35% AC band + if (rand() < 0.3) { + const count = rand() < 0.7 ? 1 : 2; + return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); + } + return []; + })(), + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), }); petRecords.push({ id: petId, clientId }); @@ -888,6 +1054,12 @@ async function seed() { specialCareNotes: pet.specialCareNotes, customFields: pet.customFields, image: pet.image, + temperamentScore: pet.temperamentScore, + temperamentFlags: pet.temperamentFlags, + medicalAlerts: pet.medicalAlerts, + preferredCuts: pet.preferredCuts, + coatType: pet.coatType, + petSizeCategory: pet.petSizeCategory, }, }); } @@ -922,8 +1094,72 @@ async function seed() { .values({ id: uc.id, name: uc.name, email: uc.email, phone: uc.phone, address: uc.address }) .onConflictDoUpdate({ target: schema.clients.id, set: { name: uc.name, email: uc.email, phone: uc.phone, address: uc.address } }); await db.insert(schema.pets) - .values({ id: uc.petId, clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) }) - .onConflictDoUpdate({ target: schema.pets.id, set: { clientId: uc.id, name: uc.petName, species: "Dog", breed: uc.petBreed, weightKg: "25.00", dateOfBirth: new Date("2021-03-15T00:00:00Z"), image: pick(demoPetImages) } }); + .values({ + id: uc.petId, + clientId: uc.id, + name: uc.petName, + species: "Dog", + breed: uc.petBreed, + weightKg: "25.00", + dateOfBirth: new Date("2021-03-15T00:00:00Z"), + image: pick(demoPetImages), + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: (() => { + // TestCooper always has a behavioral alert; TestRocky always has a skin alert. + // All other UAT test pets follow the 30% random distribution. + // Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift + // the overall distribution from the 25-35% target band. + if (uc.petName === "TestCooper") { + return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() })); + } + if (uc.petName === "TestRocky") { + return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() })); + } + if (rand() < 0.3) { + const count = rand() < 0.7 ? 1 : 2; + return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); + } + return []; + })(), + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }) + .onConflictDoUpdate({ + target: schema.pets.id, + set: { + clientId: uc.id, + name: uc.petName, + species: "Dog", + breed: uc.petBreed, + weightKg: "25.00", + dateOfBirth: new Date("2021-03-15T00:00:00Z"), + image: pick(demoPetImages), + temperamentScore: randInt(1, 5), + temperamentFlags: pickN(temperamentFlagPool, randInt(1, 3)), + medicalAlerts: (() => { + // TestCooper always has a behavioral alert; TestRocky always has a skin alert. + // All other UAT test pets follow the 30% random distribution. + // Deterministic alerts on 2 of 507 pets (~0.4%) do not meaningfully shift + // the overall distribution from the 25-35% target band. + if (uc.petName === "TestCooper") { + return pickN(medicalAlertPool.filter((a) => a.type === "behavioral"), 1).map((a) => ({ ...a, id: uuid() })); + } + if (uc.petName === "TestRocky") { + return pickN(medicalAlertPool.filter((a) => a.type === "skin"), 1).map((a) => ({ ...a, id: uuid() })); + } + if (rand() < 0.3) { + const count = rand() < 0.7 ? 1 : 2; + return pickN(medicalAlertPool, count).map((a) => ({ ...a, id: uuid() })); + } + return []; + })(), + preferredCuts: pickN(preferredCutPool, randInt(1, 2)), + coatType: pick(coatTypePool), + petSizeCategory: pick(petSizeCategoryPool), + }, + }); // Create one completed appointment for this client const apptId = uuid(); const svcIdx = 0; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d53138e..46ad5c6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -225,3 +225,34 @@ export interface MedicalAlert { } export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless"; + +export interface GroomingHistoryEntry { + id: string; + petId: string; + appointmentId: string | null; + staffId: string | null; + staffName: string | null; + cutStyle: string | null; + productsUsed: string | null; + notes: string | null; + groomedAt: string; + createdAt: string; +} + +export interface UpcomingAppointment { + id: string; + serviceId: string; + serviceName: string; + staffId: string | null; + staffName: string | null; + startTime: string; + endTime: string; + status: AppointmentStatus; +} + +export interface PetProfileSummary extends Pet { + recentGroomingHistory: GroomingHistoryEntry[]; + lastVisitDate: string | null; + visitCount: number; + upcomingAppointment: UpcomingAppointment | null; +} diff --git a/src/__tests__/portalSessionFromAuth.test.ts b/src/__tests__/portalSessionFromAuth.test.ts new file mode 100644 index 0000000..960e61e --- /dev/null +++ b/src/__tests__/portalSessionFromAuth.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { getAuth } from "../lib/auth.js"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const CLIENT_EMAIL = "alice@example.com"; +const CLIENT_NAME = "Alice Smith"; + +const UAT_CUSTOMER_ID = "c0000001-0000-0000-0000-000000000001"; +const UAT_CUSTOMER_EMAIL = "uat-customer@groombook.dev"; +const UAT_CUSTOMER_NAME = "UAT Customer"; + +const BETTER_AUTH_SESSION = { + user: { + id: "auth-user-001", + email: CLIENT_EMAIL, + name: CLIENT_NAME, + }, + session: { + id: "ba-session-001", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, +}; + +const MOCK_CLIENT = { + id: CLIENT_ID, + email: CLIENT_EMAIL, + name: CLIENT_NAME, +}; + +let mockGetAuth: ReturnType; +let mockGetSession: ReturnType; +let insertedSession: Record | null = null; +let mockClientRow: Record | null = null; +let mockStaffRow: Record | 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 impersonationSessions = new Proxy( + { _name: "impersonationSessions" }, + { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } + ); + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + const staff = new Proxy( + { _name: "staff" }, + { get: (t, p) => (p === "_name" ? "staff" : { table: "staff", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "clients") { + return makeChainable(mockClientRow ? [mockClientRow] : []); + } + if (table._name === "staff") { + return makeChainable(mockStaffRow ? [mockStaffRow] : []); + } + return makeChainable([]); + }, + }), + insert: (table: { _name: string }) => ({ + values: (vals: Record) => ({ + returning: () => { + if (table._name === "impersonationSessions") { + insertedSession = { id: "new-session-001", ...vals }; + return [insertedSession]; + } + return []; + }, + }), + }), + }), + impersonationSessions, + clients, + staff, + 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/session-from-auth", () => { + beforeEach(() => { + insertedSession = null; + mockClientRow = null; + mockStaffRow = null; + mockGetSession = vi.fn(); + mockGetAuth = vi.fn(() => ({ + api: { + getSession: mockGetSession, + }, + })); + vi.mocked(getAuth).mockImplementation(mockGetAuth); + }); + + it("returns 401 when no Better Auth session", async () => { + mockGetSession.mockResolvedValue(null); + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 404 when authenticated user has no client record", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = null; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("No client record found for this user"); + }); + + it("returns a portal session with sessionId, clientId, clientName when client is found", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = MOCK_CLIENT; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty("sessionId"); + expect(body).toHaveProperty("clientId", CLIENT_ID); + expect(body).toHaveProperty("clientName", CLIENT_NAME); + }); + + it("creates a portal session with reason sso-bridge", async () => { + mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); + mockClientRow = MOCK_CLIENT; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + expect(insertedSession).not.toBeNull(); + expect((insertedSession as Record).reason).toBe("sso-bridge"); + }); + + it("returns 201 for uat-customer SSO bridge with correct clientId and clientName", async () => { + const uatAuthSession = { + user: { + id: "auth-user-uat-customer", + email: UAT_CUSTOMER_EMAIL, + name: UAT_CUSTOMER_NAME, + }, + session: { + id: "ba-session-uat-customer", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }; + mockGetSession.mockResolvedValue(uatAuthSession); + mockClientRow = { id: UAT_CUSTOMER_ID, email: UAT_CUSTOMER_EMAIL, name: UAT_CUSTOMER_NAME }; + mockStaffRow = { id: "00000000-0000-0000-0000-000000000001" }; + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty("sessionId"); + expect(body.clientId).toBe(UAT_CUSTOMER_ID); + expect(body.clientName).toBe(UAT_CUSTOMER_NAME); + expect(insertedSession).not.toBeNull(); + expect((insertedSession as Record).reason).toBe("sso-bridge"); + }); + + it("returns 503 when auth is not configured", async () => { + mockGetAuth.mockImplementation(() => { + throw new Error("Auth not initialized"); + }); + const res = await app.request("/portal/session-from-auth", { + method: "POST", + }); + expect(res.status).toBe(503); + }); +}); \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index da2b2d1..ff1e125 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -172,7 +172,7 @@ export async function initAuth(): Promise { clientSecret: oidcClientSecret, issuerUrl: oidcIssuer, internalBaseUrl: process.env.OIDC_INTERNAL_BASE, - scopes: "openid profile email", + scopes: "openid profile email role", }; console.log("[auth] Using env var config (no DB config found)"); } diff --git a/src/middleware/rbac.ts b/src/middleware/rbac.ts index f8dbc14..de1fdec 100644 --- a/src/middleware/rbac.ts +++ b/src/middleware/rbac.ts @@ -22,7 +22,7 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( c, next ) => { - // Better-Auth's own routes handle their own auth — skip staff resolution + // Better-Auth\'s own routes handle their own auth — skip staff resolution // OOBE setup routes also handle their own auth — staff record is created during setup if (c.req.path.startsWith("/api/auth/") || c.req.path.startsWith("/api/setup")) { await next(); @@ -120,27 +120,27 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .where( and( eq(account.userId, jwt.sub), - sql`${account.providerId} IN ('authentik', 'google', 'github')` + sql`${account.providerId} IN (\'authentik\', \'google\', \'github\')` ) ) .limit(1); if (oidcAccount) { // Derive name: prefer jwt.name, fall back to email prefix, then "Unknown" - const emailPrefix = jwt.email.split("@")[0] ?? "Unknown"; + const emailPrefix = jwt.email ? jwt.email.split("@")[0] : "Unknown"; const name = jwt.name?.trim() || emailPrefix; const [newStaff] = await db .insert(staff) .values({ userId: jwt.sub, - email: jwt.email, + email: (jwt.email ?? "") as string, name, role: "groomer", isSuperUser: false, active: true, - }) - .returning(); + } as Parameters[0] extends { values: infer V } ? V : never) + .returning()!; if (!newStaff) { return c.json({ error: "Forbidden: auto-provision failed" }, 500); @@ -180,7 +180,7 @@ export function requireRole( if (!(allowedRoles as string[]).includes(staffRow.role)) { return c.json( { - error: `Forbidden: role '${staffRow.role}' is not permitted to access this resource`, + error: `Forbidden: role \'${staffRow.role}\' is not permitted to access this resource`, }, 403 ); @@ -213,7 +213,7 @@ export function requireRoleOrSuperUser( { error: hasAllowedRole ? "Forbidden: super user privileges required" - : `Forbidden: role '${staffRow.role}' is not permitted`, + : `Forbidden: role \'${staffRow.role}\' is not permitted`, }, 403 ); diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 039e8a5..ffe494c 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -1,7 +1,19 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db"; +import { + and, + desc, + eq, + exists, + getDb, + or, + pets, + appointments, + staff, + services, + sql, +} from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, @@ -97,6 +109,106 @@ petsRouter.get("/:id", async (c) => { return c.json(row); }); +petsRouter.get("/:id/profile-summary", async (c) => { + const db = getDb(); + const petId = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + // Fetch the pet + const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!pet) return c.json({ error: "Not found" }, 404); + + // Groomer RBAC: check appointment linkage to this pet's client + if (isGroomer) { + const [linkage] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, pet.clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!linkage) return c.json({ error: "Forbidden" }, 403); + } + + // Recent grooming history — last 10 completed appointments + const recentHistory = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + notes: appointments.notes, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))) + .orderBy(desc(appointments.startTime)) + .limit(10); + + // Visit count (completed appointments) + const [countRow] = await db + .select({ count: sql`count(*)::int` }) + .from(appointments) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))); + const visitCount = countRow?.count ?? 0; + + // Upcoming appointment (next scheduled or confirmed) + const [upcoming] = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + notes: appointments.notes, + confirmationStatus: appointments.confirmationStatus, + serviceName: services.name, + }) + .from(appointments) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .where( + and( + eq(appointments.petId, petId), + or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")) + ) + ) + .orderBy(appointments.startTime) + .limit(1); + + return c.json({ + id: pet.id, + name: pet.name, + species: pet.species, + breed: pet.breed, + coatType: pet.coatType, + petSizeCategory: pet.petSizeCategory, + weightKg: pet.weightKg, + dateOfBirth: pet.dateOfBirth, + recentGroomingHistory: recentHistory.map((h) => ({ + id: h.id, + startTime: h.startTime, + notes: h.notes, + serviceName: h.serviceName, + staffName: h.staffName, + })), + visitCount, + upcomingAppointment: upcoming + ? { + id: upcoming.id, + startTime: upcoming.startTime, + notes: upcoming.notes, + confirmationStatus: upcoming.confirmationStatus, + serviceName: upcoming.serviceName, + } + : null, + }); +}); + petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index a4c2b87..7b7b160 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -36,7 +36,7 @@ portalRouter.post( return c.json({ error: "Client not found" }, 404); } - const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001"; let staffId = DEMO_STAFF_ID; const [demoStaff] = await db @@ -71,6 +71,82 @@ portalRouter.post( } ); +// Bridge Better Auth session → portal session for real SSO customers (GRO-1866). +// Registered BEFORE the /* middleware so it is NOT subject to validatePortalSession. +import { getAuth } from "../lib/auth.js"; + +portalRouter.post("/session-from-auth", 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 db = getDb(); + const [client] = await db + .select() + .from(clients) + .where(eq(clients.email, session.user.email)) + .limit(1); + + if (!client) { + return c.json({ error: "No client record found for this user" }, 404); + } + + const DEMO_STAFF_ID = process.env.DEMO_STAFF_ID ?? "00000000-0000-0000-0000-000000000001"; + + let staffId = DEMO_STAFF_ID; + const [demoStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.id, DEMO_STAFF_ID)) + .limit(1); + + if (!demoStaff) { + const [firstStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.active, true)) + .limit(1); + if (!firstStaff) { + return c.json({ error: "No staff records found" }, 500); + } + staffId = firstStaff.id; + } + + const [portalSession] = await db + .insert(impersonationSessions) + .values({ + staffId, + clientId: client.id, + reason: "sso-bridge", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }) + .returning(); + + if (!portalSession) { + return c.json({ error: "Failed to create session" }, 500); + } + + return c.json( + { + sessionId: portalSession.id, + clientId: client.id, + clientName: client.name, + }, + 201 + ); +}); + // Apply middleware to all portal routes portalRouter.use("/*", validatePortalSession, portalAudit);