fix(portal): prefix unused totalPending param with underscore

Silence @typescript-eslint/no-unused-vars for totalPending in
PaymentModal — it is accepted as a prop for API compatibility but the
modal computes selectedTotal from selected invoices instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Barkley Trimsworth
2026-03-29 19:56:14 +00:00
parent 9caa03723c
commit 38f435375f
89 changed files with 7064 additions and 916 deletions
+7 -7
View File
@@ -12,18 +12,17 @@
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.800.0",
"@aws-sdk/s3-request-presigner": "^3.800.0",
"@groombook/db": "workspace:*",
"@groombook/types": "workspace:*",
"@hono/node-server": "^1.13.7",
"@hono/zod-validator": "^0.4.3",
"@hono/zod-validator": "^0.7.6",
"better-auth": "^1.5.6",
"hono": "^4.6.17",
"jose": "^5.9.6",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"openid-client": "^6.1.7",
"zod": "^3.24.1",
"@aws-sdk/client-s3": "^3.800.0",
"@aws-sdk/s3-request-presigner": "^3.800.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.7",
@@ -35,5 +34,6 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^3.2.4"
}
},
"license": "AGPL-3.0-only"
}
@@ -15,7 +15,9 @@ import type { StaffRow } from "../middleware/rbac.js";
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { JwtPayload } from "../middleware/auth.js";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { buildStaff } from "@groombook/db/factories";
@@ -167,7 +166,7 @@ function createApp(
if (!staffRow) {
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
}
c.set("jwtPayload", { sub: staffRow.oidcSub } as JwtPayload);
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
c.set("staff", staffRow as unknown as StaffRow);
await next();
});
+2
View File
@@ -7,7 +7,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
+7 -3
View File
@@ -8,7 +8,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
const MANAGER: StaffRow = {
id: "staff-manager-id",
oidcSub: "oidc-manager-sub",
userId: "ba-user-manager",
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
@@ -21,6 +23,7 @@ const RECEPTIONIST: StaffRow = {
...MANAGER,
id: "staff-receptionist-id",
oidcSub: "oidc-receptionist-sub",
userId: "ba-user-receptionist",
role: "receptionist",
name: "Receptionist Rita",
email: "receptionist@example.com",
@@ -30,6 +33,7 @@ const GROOMER: StaffRow = {
...MANAGER,
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
userId: "ba-user-groomer",
role: "groomer",
name: "Groomer Gary",
email: "groomer@example.com",
@@ -89,7 +93,7 @@ function buildApp(
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" });
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
@@ -106,7 +110,7 @@ function buildWithStaff(
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
c.set("staff", staffRow);
await next();
});
@@ -165,7 +169,7 @@ describe("resolveStaffMiddleware", () => {
});
const res = await app.request("/test", {
headers: { "X-Dev-User-Id": GROOMER.oidcSub! },
headers: { "X-Dev-User-Id": GROOMER.id },
});
expect(res.status).toBe(200);
expect(capturedStaff!.role).toBe("groomer");
+31 -4
View File
@@ -2,6 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { auth } from "./lib/auth.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
import { servicesRouter } from "./routes/services.js";
@@ -18,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";
@@ -65,15 +67,37 @@ 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);
api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => auth.handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
// Manager-only: staff, admin settings, reports, invoices, impersonation
api.use("/staff/*", requireRole("manager"));
// 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"));
// 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"));
@@ -113,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);
+61
View File
@@ -0,0 +1,61 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb } from "@groombook/db";
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
}
export const auth = betterAuth({
database: drizzleAdapter(getDb(), {
provider: "pg",
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
plugins: [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: OIDC_CLIENT_ID ?? "",
clientSecret: OIDC_CLIENT_SECRET ?? "",
// When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT:
// - authorizationUrl: external (browser redirect, no server-side fetch)
// - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin)
// When not set, fall back to discoveryUrl for local dev.
...(OIDC_INTERNAL_BASE
? {
authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`,
tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`,
userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`,
}
: {
discoveryUrl: OIDC_ISSUER
? `${OIDC_ISSUER}/.well-known/openid-configuration`
: undefined,
}),
scopes: ["openid", "profile", "email"],
},
],
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
+29 -42
View File
@@ -1,34 +1,18 @@
import type { MiddlewareHandler } from "hono";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { auth } from "../lib/auth.js";
// Authentik OIDC configuration — loaded from env at startup
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE;
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks() {
if (!OIDC_ISSUER) throw new Error("OIDC_ISSUER is not set");
if (!jwks) {
jwks = createRemoteJWKSet(
new URL(`${OIDC_ISSUER}/application/o/groombook/jwks/`)
);
}
return jwks;
export interface AuthUser {
id: string;
email: string;
name: string;
}
export interface JwtPayload {
sub: string;
email?: string;
name?: string;
}
// Guard: refuse to start with AUTH_DISABLED in production (fixes #22).
// Guard: refuse to start with AUTH_DISABLED in production.
if (process.env.AUTH_DISABLED === "true") {
if (process.env.NODE_ENV === "production") {
console.error(
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
"Remove AUTH_DISABLED from your environment and configure OIDC_ISSUER."
"Remove AUTH_DISABLED from your environment and configure Better-Auth."
);
process.exit(1);
}
@@ -39,30 +23,33 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (process.env.AUTH_DISABLED === "true") {
const devUserId = c.req.header("X-Dev-User-Id");
const sub = devUserId ?? "dev-user";
c.set("jwtPayload", { sub } as JwtPayload);
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
}
const authorization = c.req.header("Authorization");
if (!authorization?.startsWith("Bearer ")) {
if (process.env.AUTH_DISABLED === "true") {
const devUserId = c.req.header("X-Dev-User-Id");
const sub = devUserId ?? "dev-user";
c.set("jwtPayload", { sub } as { sub: string });
await next();
return;
}
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const token = authorization.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(), {
issuer: OIDC_ISSUER,
audience: OIDC_AUDIENCE,
});
c.set("jwtPayload", payload as JwtPayload);
await next();
} catch {
return c.json({ error: "Invalid or expired token" }, 401);
}
// Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware
c.set("jwtPayload", {
sub: session.user.id,
email: session.user.email,
name: session.user.name,
});
await next();
};
+92 -11
View File
@@ -1,13 +1,12 @@
import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db";
import type { JwtPayload } from "./auth.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
export interface AppEnv {
Variables: {
jwtPayload: JwtPayload;
jwtPayload: { sub: string; email?: string; name?: string };
staff: StaffRow;
};
}
@@ -16,13 +15,19 @@ export interface AppEnv {
* Resolves the authenticated staff record from the DB and stores it in context.
* Must be applied after authMiddleware on all protected routes.
*
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (treated
* as oidcSub), or falls back to the first manager in the DB.
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth
* user ID), or falls back to the first manager in the DB.
*/
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
c,
next
) => {
// Better-Auth's own routes handle their own auth — skip staff resolution
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
}
const db = getDb();
if (process.env.AUTH_DISABLED === "true") {
@@ -37,38 +42,59 @@ 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;
}
// Treat X-Dev-User-Id as the oidcSub
// Treat X-Dev-User-Id as the Better-Auth user ID first
const [row] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, devUserId));
if (!row) {
.where(eq(staff.userId, devUserId));
if (row) {
c.set("staff", { ...row, isSuperUser: true });
await next();
return;
}
// Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login
// may send the primary key for staff records that predate the userId field)
const [fallbackRow] = await db
.select()
.from(staff)
.where(eq(staff.id, devUserId));
if (!fallbackRow) {
return c.json(
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
403
);
}
c.set("staff", row);
c.set("staff", { ...fallbackRow, isSuperUser: true });
await next();
return;
}
const jwt = c.get("jwtPayload");
const [row] = await db
.select()
.from(staff)
.where(eq(staff.userId, jwt.sub));
if (row) {
c.set("staff", row);
await next();
return;
}
// Fallback: staff records that predate the userId field may still have oidcSub
const [fallbackRow] = await db
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!row) {
if (!fallbackRow) {
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
}
c.set("staff", row);
c.set("staff", fallbackRow);
await next();
};
@@ -99,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();
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import {
and,
eq,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { randomBytes } from "node:crypto";
import {
and,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import {
and,
eq,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
+1
View File
@@ -20,6 +20,7 @@ devRouter.get("/users", async (c) => {
const staffList = await db
.select({
id: staff.id,
userId: staff.userId,
name: staff.name,
email: staff.email,
role: staff.role,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
export const groomingLogsRouter = new Hono();
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import {
and,
eq,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import {
and,
eq,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import {
+137 -57
View File
@@ -1,11 +1,135 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
import { z } from "zod/v3";
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,7 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
});
});
// ─── Client-facing waitlist routes ───────────────────────────────────────────
// ─── Client-facing waitlist routes ───────────────────────────────────────────
const createWaitlistEntrySchema = z.object({
petId: z.string().uuid(),
@@ -366,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.returning();
return c.json({ ok: true });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { eq, getDb, services } from "@groombook/db";
export const servicesRouter = new Hono();
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db";
export const settingsRouter = new Hono();
+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);
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { z } from "zod/v3";
import { randomBytes } from "node:crypto";
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";