fix(api): exempt OOBE setup from staff middleware and auto-create staff (GRO-485)

Exempt POST /api/setup from resolveStaffMiddleware so OOBE users (with no
pre-existing staff record) can complete the out-of-box experience without
getting blocked by the "no staff record found" 403 error.

Changes:
- rbac.ts: add /api/setup to path exemption alongside /api/auth/
- setup.ts POST /: add find-or-create logic that:
  - Looks up existing staff by userId from JWT
  - Auto-links legacy staff records by email if userId is null
  - Creates a new staff record if none exists (OOBE case)
  - Returns 400 if JWT has no email and no staff record found
- setup.test.ts: add regression tests for all scenarios

Fixes GRO-485 (OOBE regression introduced by GRO-480).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Flea Flicker
2026-04-05 19:37:23 +00:00
parent 6819bff2bf
commit fa18c41677
3 changed files with 360 additions and 31 deletions
+62 -12
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import { and, eq, getDb, isNull, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>();
@@ -44,20 +44,16 @@ const setupSchema = z.object({
businessName: z.string().min(1).max(200),
});
// POST /api/setup — authenticated, marks current staff as super user and sets business name
// POST /api/setup — authenticated (Better-Auth JWT), creates staff record if needed and sets business name
// This endpoint is exempt from resolveStaffMiddleware so that OOBE users (with no staff record yet) can complete setup
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
const currentStaff = c.get("staff");
const jwt = c.get("jwtPayload");
const currentStaff = c.get("staff"); // may be undefined during OOBE
// Use a transaction with row-level locking to prevent race conditions
const result = await db.transaction(async (tx) => {
// Lock the business_settings row for update to prevent concurrent setup
const [existingSettings] = await tx
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
// Lock super user rows to prevent concurrent claims
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
const [existingSuperUser] = await tx
@@ -71,6 +67,12 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
}
// Lock the business_settings row for update to prevent concurrent setup
const [existingSettings] = await tx
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
// Update or create business settings with the business name
if (existingSettings) {
await tx
@@ -81,18 +83,66 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
await tx.insert(businessSettings).values({ businessName: body.businessName });
}
// Mark the current staff as super user
// Find or create staff record for the authenticated user
let resolvedStaff = currentStaff;
if (!resolvedStaff) {
// Try to find by userId
const [byUserId] = await tx
.select()
.from(staff)
.where(eq(staff.userId, jwt.sub));
if (byUserId) {
resolvedStaff = byUserId;
}
}
if (!resolvedStaff && jwt.email) {
// Try auto-link by email: staff record exists with matching email but no userId
const [byEmail] = await tx
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
if (byEmail) {
await tx
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, byEmail.id));
resolvedStaff = { ...byEmail, userId: jwt.sub };
}
}
if (!resolvedStaff) {
// Brand new user during OOBE — create staff record
if (!jwt.email) {
return { error: "Cannot complete setup: authenticated user has no email claim", code: 400 };
}
const [newStaff] = await tx
.insert(staff)
.values({
name: jwt.name || jwt.email,
email: jwt.email,
userId: jwt.sub,
role: "manager",
isSuperUser: false, // will be set below
})
.returning();
resolvedStaff = newStaff;
}
// Mark as super user
const [updatedStaff] = await tx
.update(staff)
.set({ isSuperUser: true, updatedAt: new Date() })
.where(eq(staff.id, currentStaff.id))
.where(eq(staff.id, resolvedStaff.id))
.returning();
return { staff: updatedStaff };
});
if ("error" in result) {
return c.json({ error: result.error }, 409);
const status = (result as { code?: number }).code || 409;
return c.json({ error: result.error }, status as any);
}
return c.json({ ok: true, staff: result.staff }, 201);