Merge pull request 'feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE' (#212) from feature/2357-p2-portal-clients-from-auth into dev
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 41s

GRO-2359 (api): feat(GRO-2359): add POST /api/portal/clients-from-auth for OOBE (#212)
This commit was merged in pull request #212.
This commit is contained in:
2026-06-11 16:34:34 +00:00
2 changed files with 309 additions and 0 deletions
+201
View File
@@ -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);
});
});
+108
View File
@@ -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);