Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c01e4acf0a | |||
| 10b78d810d | |||
| cdeebec021 | |||
| 1d6b906202 | |||
| 277f459237 | |||
| ef18ed7376 |
@@ -108,6 +108,8 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
||||||
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
||||||
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
||||||
|
| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
||||||
|
| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
@@ -288,6 +290,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.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.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.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
|
### 4.9 Waitlist
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ let selectAppointmentRow: Record<string, unknown> | null = null;
|
|||||||
let selectWaitlistRows: Record<string, unknown>[] = [];
|
let selectWaitlistRows: Record<string, unknown>[] = [];
|
||||||
let selectPetRows: Record<string, unknown>[] = [];
|
let selectPetRows: Record<string, unknown>[] = [];
|
||||||
let selectStaffRows: Record<string, unknown>[] = [];
|
let selectStaffRows: Record<string, unknown>[] = [];
|
||||||
|
let selectServiceRows: Record<string, unknown>[] = [];
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
@@ -50,6 +51,7 @@ function resetMock() {
|
|||||||
selectWaitlistRows = [];
|
selectWaitlistRows = [];
|
||||||
selectPetRows = [];
|
selectPetRows = [];
|
||||||
selectStaffRows = [];
|
selectStaffRows = [];
|
||||||
|
selectServiceRows = [];
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ vi.mock("@groombook/db", () => {
|
|||||||
const waitlistEntries = mkTable("waitlistEntries");
|
const waitlistEntries = mkTable("waitlistEntries");
|
||||||
const pets = mkTable("pets");
|
const pets = mkTable("pets");
|
||||||
const staff = mkTable("staff");
|
const staff = mkTable("staff");
|
||||||
|
const services = mkTable("services");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
@@ -103,6 +106,9 @@ vi.mock("@groombook/db", () => {
|
|||||||
if (table._name === "staff") {
|
if (table._name === "staff") {
|
||||||
return makeChainable(selectStaffRows);
|
return makeChainable(selectStaffRows);
|
||||||
}
|
}
|
||||||
|
if (table._name === "services") {
|
||||||
|
return makeChainable(selectServiceRows);
|
||||||
|
}
|
||||||
return makeChainable([]);
|
return makeChainable([]);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -126,6 +132,7 @@ vi.mock("@groombook/db", () => {
|
|||||||
waitlistEntries,
|
waitlistEntries,
|
||||||
pets,
|
pets,
|
||||||
staff,
|
staff,
|
||||||
|
services,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
inArray: 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", () => {
|
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||||
it("returns updated appointment with safe fields only", async () => {
|
it("returns updated appointment with safe fields only", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-2
@@ -118,7 +118,8 @@ export async function initAuth(): Promise<void> {
|
|||||||
updateAge: 60 * 60 * 24,
|
updateAge: 60 * 60 * 24,
|
||||||
cookieCache: { enabled: false },
|
cookieCache: { enabled: false },
|
||||||
},
|
},
|
||||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
||||||
|
.split(",").map((s) => s.trim()).filter(Boolean),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -308,7 +309,8 @@ export async function initAuth(): Promise<void> {
|
|||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60, // 5 minutes
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
||||||
|
.split(",").map((s) => s.trim()).filter(Boolean),
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
+125
-3
@@ -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
|
// Apply middleware to all portal routes
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
@@ -219,12 +327,22 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
...waitlistRows.map(w => w.petId),
|
...waitlistRows.map(w => w.petId),
|
||||||
];
|
];
|
||||||
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
|
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 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 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 petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
|
||||||
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
|
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 => ({
|
const appts = allAppts.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -235,13 +353,17 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
customerNotes: a.customerNotes,
|
customerNotes: a.customerNotes,
|
||||||
notes: a.notes,
|
notes: a.notes,
|
||||||
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
|
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,
|
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
|
// Derive a display `startTime` from the entry's preferred date/time so the
|
||||||
// portal can sort/classify the synthetic card (an invalid combination simply
|
// 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 waitlistAppts = waitlistRows.map(w => {
|
||||||
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
|
const parsed = new Date(`${w.preferredDate}T${w.preferredTime}`);
|
||||||
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
|
const startTime = Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
@@ -254,7 +376,7 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
customerNotes: null,
|
customerNotes: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
pet: { id: petMap[w.petId]?.id, name: petMap[w.petId]?.name, photo: petMap[w.petId]?.photoKey },
|
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,
|
staff: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user