Compare commits

...

3 Commits

Author SHA1 Message Date
Flea Flicker 37e9634323 feat(GRO-2319): dev→uat — portal waitlist surfacing + seed (api) (#205)
CI / Test (pull_request) Successful in 29s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Build & Push Docker Images (pull_request) Successful in 38s
2026-06-10 08:29:57 +00:00
Flea Flicker 31404befee uat→main (PROD): GRO-2311 seed portal StatusBadge appointments (frozen @df5e768) (#206)
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 31s
uat→main (PROD): GRO-2311 seed portal StatusBadge appointments (squash)

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-09 11:18:03 +00:00
Scrubs McBarkley a9db0ca9ac uat→main (PROD): GRO-2172 pet extended-field schema fix (frozen @c4385617) (#203)
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Images (push) Successful in 1m7s
Phase-4 promotion. CTO-approved, CI 3/3 green. Single-file diff (src/routes/pets.ts +26/-2), frozen @c4385617. GRO-2311 excluded.
2026-06-09 10:52:37 +00:00
4 changed files with 329 additions and 3 deletions
+1
View File
@@ -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
+210
View File
@@ -830,6 +830,208 @@ async function seedUatGroomerLinkage(
);
}
// ── GRO-2311 / GRO-2313: portal customer StatusBadge coverage ────────────────
/**
* GRO-2311 / GRO-2313: give the UAT portal customer (`uat-customer@groombook.dev`)
* a deterministic spread of appointments so the customer-portal StatusBadge
* palette can be LIVE-observed (not just code-verified against the bundle).
*
* `appointment_status` enum is (`scheduled, confirmed, in_progress, completed,
* cancelled, no_show`) — the portal's <StatusBadge> 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)
* - cancelled → past startTime → Past tab (isUpcoming excludes cancelled)
* - no_show → past startTime → Past tab (raw `no_show` label until GRO-2319)
*
* The existing GRO-2100 `completed` appointment (a0000001-…-0001) is left
* untouched (AC #4), so Completed is also covered.
*
* Idempotent: each appointment uses a fixed UUID and is upserted with
* onConflictDoNothing, so the hourly reset-demo-data CronJob (which TRUNCATEs
* then re-seeds) and non-truncating dev re-seeds never dup-key
* (see GRO-2033 for the dup-key class).
*/
async function seedUatCustomerPortalAppointments(
db: ReturnType<typeof drizzle>,
customerClientId: string | null,
): Promise<void> {
const LINKED_PET_ID = "c0000001-0000-0000-0000-000000000002"; // UAT Pup Alpha
// Skip silently outside the UAT persona profile (e.g. a dev/test seed that
// never created the UAT Customer client).
if (!customerClientId) {
return;
}
// The customer's pet must exist (pets are NOT truncated on reset, so this is
// stable). Defensive: bail cleanly if the persona pet is absent.
const [linkedPet] = await db
.select({ id: schema.pets.id })
.from(schema.pets)
.where(eq(schema.pets.id, LINKED_PET_ID))
.limit(1);
if (!linkedPet) {
console.warn(`⚠ GRO-2311: UAT Pup Alpha (${LINKED_PET_ID}) not found — skipping portal appointment seed`);
return;
}
// Stable "Bath & Brush" service; fall back to any active service.
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn(`⚠ GRO-2311: no active services found — skipping portal appointment seed`);
return;
}
serviceId = fallback.id;
}
// Attach the UAT groomer when present (nicer "with <groomer>" card); else null
// ("First Available"). Either way these are the customer's own appointments —
// no new groomer↔pet linkage invariant is created (uses the already-linked
// Pup Alpha), so GRO-1987 TC-UAT-3 (403 on the UNLINKED Pup Beta) is unaffected.
const [uatGroomerStaff] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
.limit(1);
const staffId = uatGroomerStaff?.id ?? null;
// Anchor all times to local wall-clock so future/past holds regardless of the
// hourly reset cadence.
const at = (deltaDays: number, hour: number): Date => {
const d = new Date();
d.setDate(d.getDate() + deltaDays);
d.setHours(hour, 0, 0, 0);
return d;
};
const DURATION_MS = 45 * 60 * 1000;
const rows = [
{
id: "a0000001-0000-0000-0000-000000000002",
status: "confirmed" as const,
start: at(3, 10),
confirmationStatus: "confirmed",
confirmedAt: new Date(),
cancelledAt: null as Date | null,
notes: "GRO-2311: upcoming confirmed appointment for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000003",
status: "scheduled" as const,
start: at(5, 14),
confirmationStatus: "pending",
confirmedAt: null as Date | null,
cancelledAt: null as Date | null,
notes: "GRO-2311: upcoming scheduled appointment for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000004",
status: "cancelled" as const,
start: at(-3, 11),
confirmationStatus: "cancelled",
confirmedAt: null as Date | null,
cancelledAt: new Date(),
notes: "GRO-2311: cancelled appointment (Past tab) for portal StatusBadge coverage.",
},
{
id: "a0000001-0000-0000-0000-000000000005",
status: "no_show" as const,
start: at(-10, 9),
confirmationStatus: "confirmed",
confirmedAt: null as Date | null,
cancelledAt: null as Date | null,
notes: "GRO-2311: no_show appointment (Past tab) for portal StatusBadge coverage.",
},
];
await db
.insert(schema.appointments)
.values(
rows.map((r) => ({
id: r.id,
clientId: customerClientId,
petId: LINKED_PET_ID,
serviceId,
staffId,
batherStaffId: null,
status: r.status,
startTime: r.start,
endTime: new Date(r.start.getTime() + DURATION_MS),
notes: r.notes,
priceCents: null,
confirmationStatus: r.confirmationStatus,
confirmedAt: r.confirmedAt,
cancelledAt: r.cancelledAt,
})),
)
.onConflictDoNothing({ target: schema.appointments.id });
console.log(
`✓ GRO-2311: seeded ${rows.length} portal StatusBadge appointments (confirmed/scheduled/cancelled/no_show) for UAT customer`,
);
// GRO-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 ────────────────────────
/**
@@ -1111,6 +1313,10 @@ async function seedKnownUsers() {
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
// appointment statuses only). Runs after the groomer linkage so the customer
// client + Pup Alpha already exist.
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
// ── Client: Demo Client ──
const [existingClient] = await db
@@ -1373,6 +1579,10 @@ async function runSeedBody(
// to attach to the appointment; on a fresh reset there are none yet at
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2311 / GRO-2313: portal customer StatusBadge palette coverage (reachable
// appointment statuses only). Runs after the groomer linkage so the customer
// client + Pup Alpha already exist.
await seedUatCustomerPortalAppointments(db, uatCustomerClientId);
// GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments
// for the UAT groomer. Must run AFTER services are seeded (it looks up a
+73
View File
@@ -39,11 +39,17 @@ const APPOINTMENT = {
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let selectWaitlistRows: Record<string, unknown>[] = [];
let selectPetRows: Record<string, unknown>[] = [];
let selectStaffRows: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
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<string, string>
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;
+45 -3
View File
@@ -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) => {