From 277f45923738a379d2f9dd7cc8b38cf98344d3e1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Wed, 10 Jun 2026 09:11:08 +0000 Subject: [PATCH] fix(GRO-2342): portal waitlist card populates service {id, name} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmetic follow-up to GRO-2319 (Phase 4 review by CTO). The synthetic waitlist card on GET /portal/appointments returned service: {id} only, so the portal fell back to the literal 'Service' label. CMPO spec did not call for a service name on the waitlist card, but populating the real name is non-urgent and closes the cosmetic gap. - src/routes/portal.ts: include a services SELECT (in addition to pets and staff) covering both appointment and waitlist serviceIds. serviceMap feeds a service.name lookup. The synthetic waitlist card's service object is now {id, name} — same shape the appointments join returns — so the portal renders the real name. The appointments join also gains a name (consistent shape, no regression for the existing path). - src/__tests__/portal.test.ts: mock the services table and assert service: {id, name} on both the synthetic waitlist card and the appointment card. - UAT_PLAYBOOK.md: TC-API-8.20 covering the waitlist card service name (TC-API-8.19 retained verbatim for the original GRO-2319 surfacing contract). Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 1 + src/__tests__/portal.test.ts | 57 ++++++++++++++++++++++++++++++++++++ src/routes/portal.ts | 20 +++++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 71d00ff..2a85e1d 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -288,6 +288,7 @@ This means: | TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. | | TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) | | TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. | +| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "", name: ""}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. | ### 4.9 Waitlist diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 84f37ab..1ac8bce 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -42,6 +42,7 @@ let selectAppointmentRow: Record | null = null; let selectWaitlistRows: Record[] = []; let selectPetRows: Record[] = []; let selectStaffRows: Record[] = []; +let selectServiceRows: Record[] = []; let updatedValues: Record[] = []; function resetMock() { @@ -50,6 +51,7 @@ function resetMock() { selectWaitlistRows = []; selectPetRows = []; selectStaffRows = []; + selectServiceRows = []; updatedValues = []; } @@ -83,6 +85,7 @@ vi.mock("@groombook/db", () => { const waitlistEntries = mkTable("waitlistEntries"); const pets = mkTable("pets"); const staff = mkTable("staff"); + const services = mkTable("services"); return { getDb: () => ({ @@ -103,6 +106,9 @@ vi.mock("@groombook/db", () => { if (table._name === "staff") { return makeChainable(selectStaffRows); } + if (table._name === "services") { + return makeChainable(selectServiceRows); + } return makeChainable([]); }, }), @@ -126,6 +132,7 @@ vi.mock("@groombook/db", () => { waitlistEntries, pets, staff, + services, eq: vi.fn(), and: vi.fn(), inArray: vi.fn(), @@ -198,6 +205,56 @@ describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => { }); }); +// GRO-2342: GET /portal/appointments must populate the synthetic waitlist +// card's `service` object with the full service record (id + name) — same +// shape the appointments join returns — so the portal renders the real +// service name in place of the fallback "Service" label. +describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => { + it("returns service {id, name} on the synthetic waitlist card", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT }; + selectWaitlistRows = [ + { + id: "22222222-2222-2222-2222-222222222222", + petId: "pet-1", + serviceId: "svc-1", + preferredDate: "2099-01-01", + preferredTime: "13:00:00", + }, + ]; + selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }]; + selectServiceRows = [{ id: "svc-1", name: "Full Groom" }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const waitlistCard = body.appointments.find( + (a: { status: string }) => a.status === "waitlisted", + ); + expect(waitlistCard).toBeTruthy(); + expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" }); + }); + + it("returns service {id, name} on the appointment card (same shape)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" }; + selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }]; + + const res = await app.request("/portal/appointments", { + headers: { "X-Impersonation-Session-Id": SESSION_ID }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const apptCard = body.appointments.find( + (a: { status: string }) => a.status === "scheduled", + ); + expect(apptCard).toBeTruthy(); + expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" }); + }); +}); + describe("PATCH /portal/appointments/:id/notes", () => { it("returns updated appointment with safe fields only", async () => { selectSessionRow = ACTIVE_SESSION; diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 65c53a7..487861d 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -219,12 +219,22 @@ portalRouter.get("/appointments", async (c) => { ...waitlistRows.map(w => w.petId), ]; const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); + // GRO-2342: services must be looked up for both appointment and waitlist cards + // so the portal can render `service.name` in place of the fallback "Service" + // label (CMPO sign-off on the GRO-2319 waitlist card explicitly excluded the + // service name; this follow-up closes the cosmetic gap). + const serviceIds = [ + ...allAppts.map(a => a.serviceId).filter((id): id is string => id !== null), + ...waitlistRows.map(w => w.serviceId).filter((id): id is string => id !== null), + ]; const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; + const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); + const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s])); const appts = allAppts.map(a => ({ id: a.id, @@ -235,13 +245,17 @@ portalRouter.get("/appointments", async (c) => { customerNotes: a.customerNotes, notes: a.notes, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, - service: a.serviceId ? { id: a.serviceId } : null, + service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); // Derive a display `startTime` from the entry's preferred date/time so the // portal can sort/classify the synthetic card (an invalid combination simply - // yields a null startTime, which the portal tolerates). + // yields a null startTime, which the portal tolerates). GRO-2342: also + // populate the synthetic card's `service` object with the full service + // record (id + name) — same shape the appointments join returns — so the + // portal renders the real service name in place of the fallback "Service" + // label. const waitlistAppts = waitlistRows.map(w => { const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`); const startTime = Number.isNaN(parsed.getTime()) ? null : parsed; @@ -254,7 +268,7 @@ portalRouter.get("/appointments", async (c) => { customerNotes: null, notes: null, pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey }, - service: { id: w.serviceId }, + service: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null, staff: null, }; }); -- 2.52.0