merge: resolve conflicts between feat/impersonation-backend and main

Keep both backend impersonation (schema, routes, types) and main's
additions (settings, branding, dev login, full customer portal UI).

Portal frontend files retain main's versions (complete UI with sidebar,
sections, mock impersonation). Wiring frontend to real impersonation
backend API remains as follow-up work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Groom Book CTO
2026-03-20 02:17:02 +00:00
50 changed files with 4677 additions and 503 deletions
+21
View File
@@ -13,7 +13,10 @@ import { reportsRouter } from "./routes/reports.js";
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { getDb, businessSettings } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { devRouter } from "./routes/dev.js";
import { startReminderScheduler } from "./services/reminders.js";
const app = new Hono();
@@ -34,6 +37,23 @@ app.get("/health", (c) => c.json({ status: "ok" }));
// Public booking routes — no auth required, must be registered before auth middleware
app.route("/api/book", bookRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter);
// Public branding endpoint — no auth required, returns business name/colors/logo
app.get("/api/branding", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null };
return c.json({
businessName: settings.businessName,
primaryColor: settings.primaryColor,
accentColor: settings.accentColor,
logoBase64: settings.logoBase64,
logoMimeType: settings.logoMimeType,
});
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
@@ -48,6 +68,7 @@ api.route("/reports", reportsRouter);
api.route("/appointment-groups", appointmentGroupsRouter);
api.route("/grooming-logs", groomingLogsRouter);
api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+3 -1
View File
@@ -40,7 +40,9 @@ if (process.env.AUTH_DISABLED === "true") {
export const authMiddleware: MiddlewareHandler = async (c, next) => {
if (process.env.AUTH_DISABLED === "true") {
c.set("jwtPayload", { sub: "dev-user" } as JwtPayload);
const devUserId = c.req.header("X-Dev-User-Id");
const sub = devUserId ?? "dev-user";
c.set("jwtPayload", { sub } as JwtPayload);
await next();
return;
}
+2 -2
View File
@@ -160,11 +160,11 @@ bookRouter.post(
);
}
// Find or create client by email
// Find or create client by email (skip disabled clients)
let [client] = await db
.select()
.from(clients)
.where(eq(clients.email, body.clientEmail));
.where(and(eq(clients.email, body.clientEmail), eq(clients.status, "active")));
if (!client) {
const inserted = await db
+33 -7
View File
@@ -13,12 +13,15 @@ const createClientSchema = z.object({
notes: z.string().max(2000).optional(),
});
const updateClientSchema = createClientSchema.partial();
// List all clients
// List clients — defaults to active only, ?includeDisabled=true shows all
clientsRouter.get("/", async (c) => {
const db = getDb();
const rows = await db.select().from(clients).orderBy(clients.name);
const includeDisabled = c.req.query("includeDisabled") === "true";
const query = includeDisabled
? db.select().from(clients).orderBy(clients.name)
: db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name);
const rows = await query;
return c.json(rows);
});
@@ -41,16 +44,31 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
return c.json(row, 201);
});
// Update a client
// Update a client (including status changes)
const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(),
});
clientsRouter.patch(
"/:id",
zValidator("json", updateClientSchema),
zValidator("json", patchClientSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const now = new Date();
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
// When disabling, set disabledAt; when re-enabling, clear it
if (body.status === "disabled") {
setValues.disabledAt = now;
} else if (body.status === "active") {
setValues.disabledAt = null;
}
const [row] = await db
.update(clients)
.set({ ...body, updatedAt: new Date() })
.set(setValues)
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
@@ -58,8 +76,16 @@ clientsRouter.patch(
}
);
// Delete a client
// Delete a client — requires ?confirm=true query param
clientsRouter.delete("/:id", async (c) => {
const confirm = c.req.query("confirm");
if (confirm !== "true") {
return c.json(
{ error: "Permanent deletion requires ?confirm=true. Consider disabling the client instead." },
400
);
}
const db = getDb();
const [row] = await db
.delete(clients)
+45
View File
@@ -0,0 +1,45 @@
import { Hono } from "hono";
import { getDb, staff, clients, eq, sql } from "@groombook/db";
const devRouter = new Hono();
// GET /api/dev/config — tells the frontend whether auth is disabled
devRouter.get("/config", (c) => {
return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" });
});
// GET /api/dev/users — list staff and clients for the login selector
// Only available when AUTH_DISABLED=true
devRouter.get("/users", async (c) => {
if (process.env.AUTH_DISABLED !== "true") {
return c.json({ error: "Not available when auth is enabled" }, 403);
}
const db = getDb();
const staffList = await db
.select({
id: staff.id,
name: staff.name,
email: staff.email,
role: staff.role,
})
.from(staff)
.where(eq(staff.active, true))
.orderBy(staff.name);
const clientList = await db
.select({
id: clients.id,
name: clients.name,
email: clients.email,
petCount: sql<number>`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"),
})
.from(clients)
.orderBy(clients.name)
.limit(20);
return c.json({ staff: staffList, clients: clientList });
});
export { devRouter };
+7 -1
View File
@@ -16,6 +16,11 @@ import {
export const reportsRouter = new Hono();
reportsRouter.onError((err, c) => {
console.error("[reports] unhandled error:", err);
return c.json({ error: "Internal server error", message: err.message }, 500);
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseDate(value: string | undefined, fallback: Date): Date {
@@ -279,6 +284,7 @@ reportsRouter.get("/clients", async (c) => {
// Clients with no appointment in last 90 days (churn risk)
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
const churnRisk = await db
.select({
@@ -290,7 +296,7 @@ reportsRouter.get("/clients", async (c) => {
.leftJoin(appointments, eq(appointments.clientId, clients.id))
.groupBy(clients.id, clients.name)
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgo} OR MAX(${appointments.startTime}) IS NULL`
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
+60
View File
@@ -0,0 +1,60 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { eq, getDb, businessSettings } from "@groombook/db";
export const settingsRouter = new Hono();
// GET /api/admin/settings — return current business settings
settingsRouter.get("/", async (c) => {
const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1);
if (!row) {
// Auto-create default settings if none exist
const [created] = await db.insert(businessSettings).values({}).returning();
return c.json(created);
}
return c.json(row);
});
const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
const updateSettingsSchema = z.object({
businessName: z.string().min(1).max(200).optional(),
primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(),
accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(),
logoBase64: z.string().max(700_000).nullable().optional(), // ~512KB base64
logoMimeType: z
.enum(["image/png", "image/svg+xml", "image/jpeg", "image/webp"])
.nullable()
.optional(),
});
// PATCH /api/admin/settings — update business settings
settingsRouter.patch(
"/",
zValidator("json", updateSettingsSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
// Get or create the settings row
const rows = await db.select().from(businessSettings).limit(1);
let settingsId: string;
if (rows[0]) {
settingsId = rows[0].id;
} else {
const [inserted] = await db.insert(businessSettings).values({}).returning();
if (!inserted) throw new Error("Failed to create default settings");
settingsId = inserted.id;
}
const [updated] = await db
.update(businessSettings)
.set({ ...body, updatedAt: new Date() })
.where(eq(businessSettings.id, settingsId))
.returning();
return c.json(updated);
}
);