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:
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user