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) => {