Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e9634323 | |||
| 31404befee | |||
| a9db0ca9ac | |||
| 4bbb0c9fc5 | |||
| 03f79a3701 | |||
| 2b92c2ab6c | |||
| e9ad92de01 | |||
| bfe1a29c08 | |||
| 1ad43ce701 | |||
| 6e2e46daf8 | |||
| fc072d51f4 | |||
| c92fb2539d | |||
| 2a6242d3de | |||
| 766728865e | |||
| 403634eb96 | |||
| 152abfc4d5 | |||
| c8bbb12edb | |||
| ba95088653 | |||
| dd83f29736 | |||
| 185fce8e17 | |||
| 081379c189 | |||
| e01c12a316 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"type": "http",
|
||||
"url": "https://git-mcp.farh.net/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+46
-6
@@ -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 <StatusBadge> 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 <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)
|
||||
@@ -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 ────────────────────────
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user