Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10b78d810d | |||
| cdeebec021 | |||
| 1d6b906202 | |||
| 277f459237 | |||
| ef18ed7376 |
@@ -287,6 +287,8 @@ 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. |
|
||||
| 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
|
||||
|
||||
|
||||
+46
-6
@@ -839,12 +839,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)
|
||||
@@ -992,6 +994,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,19 @@ 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 selectServiceRows: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
selectSessionRow = null;
|
||||
selectAppointmentRow = null;
|
||||
selectWaitlistRows = [];
|
||||
selectPetRows = [];
|
||||
selectStaffRows = [];
|
||||
selectServiceRows = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
@@ -72,6 +80,13 @@ 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");
|
||||
const services = mkTable("services");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
@@ -82,6 +97,18 @@ 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);
|
||||
}
|
||||
if (table._name === "services") {
|
||||
return makeChainable(selectServiceRows);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
@@ -102,8 +129,13 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
impersonationSessions,
|
||||
appointments,
|
||||
waitlistEntries,
|
||||
pets,
|
||||
staff,
|
||||
services,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -125,6 +157,104 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { getAuth } from "../lib/auth.js";
|
||||
|
||||
const NEW_USER_EMAIL = "new-sso-user@example.com";
|
||||
const NEW_USER_NAME = "New SSO User";
|
||||
const NEW_USER_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
const BETTER_AUTH_SESSION = {
|
||||
user: {
|
||||
id: "auth-user-new",
|
||||
email: NEW_USER_EMAIL,
|
||||
name: NEW_USER_NAME,
|
||||
},
|
||||
session: {
|
||||
id: "ba-session-new",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
};
|
||||
|
||||
let mockGetAuth: ReturnType<typeof vi.fn>;
|
||||
let mockGetSession: ReturnType<typeof vi.fn>;
|
||||
let existingClientRow: Record<string, unknown> | null = null;
|
||||
let insertedClientValues: Record<string, unknown> | null = null;
|
||||
let insertShouldThrow: { code?: string } | null = null;
|
||||
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
return new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => makeChainable(target);
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
const clients = new Proxy(
|
||||
{ _name: "clients" },
|
||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||
);
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "clients") {
|
||||
return makeChainable(existingClientRow ? [existingClientRow] : []);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
insert: (table: { _name: string }) => ({
|
||||
values: (vals: Record<string, unknown>) => {
|
||||
if (insertShouldThrow) {
|
||||
const err = new Error("unique violation") as Error & { code?: string };
|
||||
err.code = insertShouldThrow.code;
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
returning: () => {
|
||||
if (table._name === "clients") {
|
||||
insertedClientValues = { id: NEW_USER_ID, ...vals };
|
||||
return [insertedClientValues];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
clients,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/auth.js", () => ({
|
||||
getAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
describe("POST /portal/clients-from-auth (GRO-2359)", () => {
|
||||
beforeEach(() => {
|
||||
existingClientRow = null;
|
||||
insertedClientValues = null;
|
||||
insertShouldThrow = null;
|
||||
mockGetSession = vi.fn();
|
||||
mockGetAuth = vi.fn(() => ({
|
||||
api: {
|
||||
getSession: mockGetSession,
|
||||
},
|
||||
}));
|
||||
vi.mocked(getAuth).mockImplementation(mockGetAuth);
|
||||
});
|
||||
|
||||
it("returns 401 when no Better Auth session is present", async () => {
|
||||
mockGetSession.mockResolvedValue(null);
|
||||
const res = await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Test User" }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("returns 400 when body fails zod validation (empty name)", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
const res = await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("creates a new client row bound to the auth user's email and returns 201", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
const res = await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: " New SSO User ",
|
||||
phone: "555-1234",
|
||||
address: "1 Main St",
|
||||
notes: "test note",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({
|
||||
id: NEW_USER_ID,
|
||||
name: "New SSO User",
|
||||
email: NEW_USER_EMAIL,
|
||||
});
|
||||
// Trim must be applied to the persisted values.
|
||||
expect(insertedClientValues).not.toBeNull();
|
||||
expect((insertedClientValues as Record<string, unknown>).name).toBe("New SSO User");
|
||||
expect((insertedClientValues as Record<string, unknown>).email).toBe(NEW_USER_EMAIL);
|
||||
expect((insertedClientValues as Record<string, unknown>).phone).toBe("555-1234");
|
||||
});
|
||||
|
||||
it("normalizes empty optional fields to null on insert", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Test", phone: "", address: " " }),
|
||||
});
|
||||
expect(insertedClientValues).not.toBeNull();
|
||||
expect((insertedClientValues as Record<string, unknown>).phone).toBeNull();
|
||||
expect((insertedClientValues as Record<string, unknown>).address).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 409 when a client row already exists for this email", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL };
|
||||
const res = await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Test" }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/already exists/i);
|
||||
expect(insertedClientValues).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 409 on unique constraint race (23505)", async () => {
|
||||
mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION);
|
||||
insertShouldThrow = { code: "23505" };
|
||||
const res = await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Test" }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 503 when auth is not configured", async () => {
|
||||
mockGetAuth.mockImplementation(() => {
|
||||
throw new Error("Auth not initialized");
|
||||
});
|
||||
const res = await app.request("/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "Test" }),
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
+168
-4
@@ -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";
|
||||
@@ -147,6 +147,114 @@ portalRouter.post("/session-from-auth", async (c) => {
|
||||
);
|
||||
});
|
||||
|
||||
// GRO-2359 — register a brand-new SSO user. The post-auth handler in the
|
||||
// web portal redirects here when `session-from-auth` returns 404, so the
|
||||
// OOBE can complete a customer record for the new user. Auth is via the
|
||||
// Better Auth session (same shape as `session-from-auth`), so this is
|
||||
// registered BEFORE the `validatePortalSession` middleware.
|
||||
//
|
||||
// Contract:
|
||||
// POST /api/portal/clients-from-auth
|
||||
// Body: { name: string; phone?: string|null; address?: string|null; notes?: string|null }
|
||||
// 201: { id, name, email }
|
||||
// 400: invalid body (zod failure)
|
||||
// 401: no Better Auth session
|
||||
// 409: a `clients` row already exists for this email (portal selection case)
|
||||
// 500: insert failed
|
||||
//
|
||||
// We do NOT auto-link the user's auth account to the new client row; the
|
||||
// existing `session-from-auth` endpoint re-resolves the row by email on the
|
||||
// next call, so the OOBE's success path just navigates the user back to
|
||||
// `/` and lets the bridge mint a portal session.
|
||||
const createClientFromAuthSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
phone: z.string().max(50).nullish(),
|
||||
address: z.string().max(500).nullish(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/clients-from-auth",
|
||||
zValidator("json", createClientFromAuthSchema),
|
||||
async (c) => {
|
||||
let auth;
|
||||
try {
|
||||
auth = getAuth();
|
||||
} catch {
|
||||
return c.json({ error: "Authentication not configured" }, 503);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const body = c.req.valid("json");
|
||||
const db = getDb();
|
||||
|
||||
// Pre-check: if a client already exists for this email, return 409 so
|
||||
// the OOBE can render the "portal selection" message (the user needs
|
||||
// to contact their groomer to link the new SSO identity to the
|
||||
// pre-existing customer record). We don't return the existing row to
|
||||
// avoid leaking PII about other accounts.
|
||||
const [existing] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.email, session.user.email))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return c.json(
|
||||
{ error: "A customer record with this email already exists" },
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
let row;
|
||||
try {
|
||||
[row] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
name: body.name.trim(),
|
||||
email: session.user.email,
|
||||
phone: body.phone?.trim() || null,
|
||||
address: body.address?.trim() || null,
|
||||
notes: body.notes?.trim() || null,
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
// Concurrent insert from a parallel OOBE submit — treat as 409.
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
(err as { code?: string }).code === "23505"
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "A customer record with this email already exists" },
|
||||
409,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "Failed to create client" }, 500);
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
},
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Apply middleware to all portal routes
|
||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||
|
||||
@@ -195,14 +303,46 @@ 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);
|
||||
// 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,
|
||||
@@ -213,11 +353,35 @@ 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,
|
||||
}));
|
||||
|
||||
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). 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;
|
||||
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: w.serviceId ? { id: w.serviceId, name: serviceMap[w.serviceId]?.name } : null,
|
||||
staff: null,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ appointments: [...appts, ...waitlistAppts] });
|
||||
});
|
||||
|
||||
portalRouter.get("/pets", async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user