feat(portal): replace mock data with real session-driven API calls (#152)

Closes GRO-205. Reviewed and approved by CTO (The Dogfather) and QA (Lint Roller). cc @cpfarhood
This commit was merged in pull request #152.
This commit is contained in:
groombook-engineer[bot]
2026-03-29 07:08:35 +00:00
committed by GitHub
parent 3834e45b66
commit 4746a63292
24 changed files with 4230 additions and 1048 deletions
+20 -3
View File
@@ -19,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { searchRouter } from "./routes/search.js";
import { calendarRouter } from "./routes/calendar.js";
import { getDb, businessSettings } from "@groombook/db";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -67,6 +68,17 @@ app.get("/api/branding", async (c) => {
// Public iCal calendar feed — token auth in URL, no auth middleware required
app.route("/api/calendar", calendarRouter);
// Public setup status — no auth required, must be registered before auth middleware
app.get("/api/setup/status", async (c) => {
const db = getDb();
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
return c.json({ needsSetup: !superUser });
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
@@ -82,8 +94,10 @@ api.route("/auth", authRouter);
// Manager-only: admin settings, reports, invoices, impersonation
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
api.use("/staff/*", requireRole("manager"));
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
api.use("/admin/*", requireRole("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
@@ -123,6 +137,9 @@ api.on(
);
// ──────────────────────────────────────────────────────────────────────────────
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
api.route("/setup", setupRouter);
api.route("/clients", clientsRouter);
api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
+58 -3
View File
@@ -42,7 +42,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
if (!manager) {
return c.json({ error: "Forbidden: no staff records found" }, 403);
}
c.set("staff", manager);
c.set("staff", { ...manager, isSuperUser: true });
await next();
return;
}
@@ -52,7 +52,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff)
.where(eq(staff.userId, devUserId));
if (row) {
c.set("staff", row);
c.set("staff", { ...row, isSuperUser: true });
await next();
return;
}
@@ -68,7 +68,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
403
);
}
c.set("staff", fallbackRow);
c.set("staff", { ...fallbackRow, isSuperUser: true });
await next();
return;
}
@@ -125,3 +125,58 @@ export function requireRole(
await next();
};
}
/**
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
* Use for routes where managers OR super-users should have access.
*
* @example
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
*/
export function requireRoleOrSuperUser(
...allowedRoles: StaffRole[]
): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
}
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
if (hasAllowedRole || staffRow.isSuperUser) {
await next();
return;
}
return c.json(
{
error: staffRow.isSuperUser
? `Forbidden: role '${staffRow.role}' is not permitted`
: "Forbidden: super user privileges required",
},
403
);
};
}
/**
* Middleware that enforces the staff member is a super user.
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
*
* @example
* api.use("/staff/*", requireRole("manager"));
* api.use("/staff/*", requireSuperUser());
*/
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
}
if (!staffRow.isSuperUser) {
return c.json(
{ error: "Forbidden: super user privileges required" },
403
);
}
await next();
};
}
+136 -155
View File
@@ -1,11 +1,135 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } 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 type { AppEnv } from "../middleware/rbac.js";
export const portalRouter = new Hono<AppEnv>();
// ─── Session helper ───────────────────────────────────────────────────────────
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
if (!sessionId) return null;
const db = getDb();
const [session] = await db
.select()
.from(impersonationSessions)
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
.limit(1);
if (!session || session.expiresAt <= new Date()) return null;
return session.clientId;
}
// ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404);
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
});
portalRouter.get("/services", async (c) => {
const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true));
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
});
portalRouter.get("/appointments", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
startTime: appointments.startTime,
endTime: appointments.endTime,
status: appointments.status,
confirmationStatus: appointments.confirmationStatus,
customerNotes: appointments.customerNotes,
notes: appointments.notes,
petId: appointments.petId,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
})
.from(appointments)
.where(eq(appointments.clientId, clientId))
.orderBy(appointments.startTime);
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
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)) : [];
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
startTime: a.startTime,
endTime: a.endTime,
status: a.status,
confirmationStatus: a.confirmationStatus,
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,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
});
portalRouter.get("/pets", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
});
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id);
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
const itemsByInvoice: Record<string, typeof lineItems> = {};
for (const li of lineItems) {
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
itemsByInvoice[li.invoiceId]!.push(li);
}
return c.json(clientInvoices.map(inv => ({
id: inv.id,
status: inv.status,
totalCents: inv.totalCents,
createdAt: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
})));
});
// ─── Appointment action routes ────────────────────────────────────────────────
const customerNotesSchema = z.object({
// .min(1) prevents empty strings — clearing notes is not a supported use case
customerNotes: z.string().min(1).max(500),
@@ -20,27 +144,11 @@ portalRouter.patch(
const body = c.req.valid("json");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const authClientId = session.clientId;
const [appt] = await db
.select()
.from(appointments)
@@ -51,7 +159,7 @@ portalRouter.patch(
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== authClientId) {
if (appt.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -84,22 +192,8 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
const id = c.req.param("id");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
@@ -113,7 +207,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
if (appt.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -152,22 +246,8 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
const id = c.req.param("id");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
@@ -181,7 +261,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
if (appt.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -212,106 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
});
});
// ─── Appointment reschedule ──────────────────────────────────────────────────
const rescheduleSchema = z.object({
startTime: z.string().datetime(),
});
portalRouter.post(
"/appointments/:id/reschedule",
zValidator("json", rescheduleSchema),
async (c) => {
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
if (!appt) {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
if (appt.startTime <= new Date()) {
return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422);
}
if (appt.status === "cancelled" || appt.status === "completed") {
return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422);
}
const newStart = new Date(body.startTime);
const durationMs = appt.endTime.getTime() - appt.startTime.getTime();
const newEnd = new Date(newStart.getTime() + durationMs);
const [existingConflict] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, appt.staffId!),
lt(appointments.startTime, newEnd),
gt(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id)
)
)
.limit(1);
if (existingConflict) {
return c.json({ error: "The selected time slot is no longer available" }, 409);
}
const [updated] = await db
.update(appointments)
.set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() })
.where(eq(appointments.id, id))
.returning();
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
status: updated.status,
updatedAt: updated.updatedAt,
});
}
);
// ─── Client-facing waitlist routes ───────────────────────────────────────────
// ─── Client-facing waitlist routes ────────────────────────────────────────────
const createWaitlistEntrySchema = z.object({
petId: z.string().uuid(),
@@ -465,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.returning();
return c.json({ ok: true });
});
});
+79
View File
@@ -0,0 +1,79 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, staff, businessSettings } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
setupRouter.get("/status", async (c) => {
const db = getDb();
// Check if any super user exists
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
return c.json({ needsSetup: !superUser });
});
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
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
const currentStaff = c.get("staff");
// 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
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.for("update")
.limit(1);
if (existingSuperUser) {
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
}
// Update or create business settings with the business name
if (existingSettings) {
await tx
.update(businessSettings)
.set({ businessName: body.businessName, updatedAt: new Date() })
.where(eq(businessSettings.id, existingSettings.id));
} else {
await tx.insert(businessSettings).values({ businessName: body.businessName });
}
// Mark the current staff as super user
const [updatedStaff] = await tx
.update(staff)
.set({ isSuperUser: true, updatedAt: new Date() })
.where(eq(staff.id, currentStaff.id))
.returning();
return { staff: updatedStaff };
});
if ("error" in result) {
return c.json({ error: result.error }, 409);
}
return c.json({ ok: true, staff: result.staff }, 201);
});