fix(GRO-2342): portal waitlist card populates service {id, name}
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Images (pull_request) Successful in 1m15s

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 <noreply@paperclip.ing>
This commit is contained in:
Flea Flicker
2026-06-10 09:11:08 +00:00
parent ef18ed7376
commit 277f459237
3 changed files with 75 additions and 3 deletions
+1
View File
@@ -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: "<serviceId>", name: "<serviceName>"}` (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
+57
View File
@@ -42,6 +42,7 @@ let selectAppointmentRow: Record<string, unknown> | null = null;
let selectWaitlistRows: Record<string, unknown>[] = [];
let selectPetRows: Record<string, unknown>[] = [];
let selectStaffRows: Record<string, unknown>[] = [];
let selectServiceRows: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
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;
+17 -3
View File
@@ -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,
};
});