import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; import { geocodeClient, geocodeUngeocodedClients, resolveClientGeocodingProvider, type ClientGeocodeOutcome, } from "../services/clientGeocoding.js"; export const clientsRouter = new Hono(); type ClientRow = typeof clients.$inferSelect; /** * Best-effort auto-geocode of a freshly created/updated client (GRO-2154). * Never throws: a flaky geocoding backend must not break client mutations. * Returns the (possibly coordinate-enriched) row plus a structured outcome the * caller surfaces under a `geocoding` field so ambiguous addresses are visible. */ async function autoGeocodeClient( db: ReturnType, row: ClientRow ): Promise<{ row: ClientRow; outcome: ClientGeocodeOutcome }> { try { const provider = await resolveClientGeocodingProvider(db); const outcome = await geocodeClient(db, row, provider); const enriched = outcome.status === "geocoded" ? { ...row, latitude: outcome.latitude, longitude: outcome.longitude, geocodedAt: outcome.geocodedAt ? new Date(outcome.geocodedAt) : row.geocodedAt, } : row; return { row: enriched, outcome }; } catch (err) { return { row, outcome: { clientId: row.id, status: "error", message: `Auto-geocode failed: ${ err instanceof Error ? err.message : String(err) }`, latitude: null, longitude: null, geocodedAt: null, formattedAddress: null, provider: null, }, }; } } const createClientSchema = z.object({ name: z.string().min(1).max(200), email: z.string().email(), phone: z.string().max(50).optional(), address: z.string().max(500).optional(), notes: z.string().max(2000).optional(), smsOptIn: z.boolean().optional(), smsConsentText: z.string().max(1000).optional(), }); // List clients — defaults to active only, ?includeDisabled=true shows all. // Groomers see only clients with ≥1 appointment assigned to them. clientsRouter.get("/", async (c) => { const db = getDb(); const includeDisabled = c.req.query("includeDisabled") === "true"; const staffRow = c.get("staff"); const isGroomer = staffRow?.role === "groomer"; // Groomer: subquery for clients with an appointment for this groomer const groomerApptFilter = isGroomer ? exists( db .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.clientId, clients.id), or( eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id) ) ) ) ) : undefined; const conditions = []; if (!includeDisabled) conditions.push(eq(clients.status, "active")); if (groomerApptFilter) conditions.push(groomerApptFilter); const rows = await db .select() .from(clients) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(clients.name); return c.json(rows); }); // Get a single client clientsRouter.get("/:id", async (c) => { const db = getDb(); const clientId = c.req.param("id"); const staffRow = c.get("staff"); const isGroomer = staffRow?.role === "groomer"; const [row] = await db .select() .from(clients) .where(eq(clients.id, clientId)); if (!row) return c.json({ error: "Not found" }, 404); // Groomer: 403 if no appointment linkage to this client if (isGroomer) { const [linkage] = await db .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.clientId, clientId), or( eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id) ) ) ) .limit(1); if (!linkage) return c.json({ error: "Forbidden" }, 403); } return c.json(row); }); // Create a client clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const [row] = await db.insert(clients).values(body).returning(); if (!row) return c.json({ error: "Failed to create client" }, 500); // Auto-geocode on create when an address is supplied (GRO-2154). Best-effort: // the client is created regardless; the `geocoding` field surfaces failures. if (body.address && body.address.trim()) { const { row: enriched, outcome } = await autoGeocodeClient(db, row); return c.json({ ...enriched, geocoding: outcome }, 201); } return c.json(row, 201); }); // Geocode a single client's address and persist coordinates (manager-only; // enforced by the route guard in index.ts). clientsRouter.post("/:clientId/geocode", async (c) => { const db = getDb(); const clientId = c.req.param("clientId"); const [client] = await db .select() .from(clients) .where(eq(clients.id, clientId)); if (!client) return c.json({ error: "Not found" }, 404); const provider = await resolveClientGeocodingProvider(db); const outcome = await geocodeClient(db, client, provider); // Map outcome to an HTTP status so the result is unambiguous to the caller: // geocoded -> 200, provider error -> 502, no_address/unresolved -> 422. const status = outcome.status === "geocoded" ? 200 : outcome.status === "error" ? 502 : 422; return c.json(outcome, status); }); // Batch-geocode un-geocoded clients with provider-rate-limited throttling // (manager-only). Processes up to ?limit clients (default 50, max 500) per call; // re-invoke while `remaining` > 0 to finish large datasets. clientsRouter.post("/geocode-batch", async (c) => { const db = getDb(); const limitRaw = c.req.query("limit"); let limit = 50; if (limitRaw !== undefined) { limit = Number(limitRaw); if (!Number.isFinite(limit) || limit <= 0) { return c.json({ error: "limit must be a positive integer" }, 400); } } const summary = await geocodeUngeocodedClients(db, limit); return c.json(summary); }); // Update a client (including status changes) const patchClientSchema = createClientSchema.partial().extend({ status: z.enum(["active", "disabled"]).optional(), smsOptOut: z.boolean().optional(), }); clientsRouter.patch( "/:id", zValidator("json", patchClientSchema), async (c) => { const db = getDb(); const body = c.req.valid("json"); const now = new Date(); const setValues: Record = { ...body, updatedAt: now }; if (body.status === "disabled") { setValues.disabledAt = now; } else if (body.status === "active") { setValues.disabledAt = null; } if (body.smsOptOut === true) { setValues.smsOptIn = false; setValues.smsOptOutDate = now; delete setValues.smsOptOut; } delete setValues.smsOptOut; // Auto-geocode on address change (GRO-2154). If the address was cleared, // drop any stale coordinates so a disabled/blank address never keeps a pin. const addressProvided = Object.prototype.hasOwnProperty.call(body, "address"); const trimmedAddress = typeof body.address === "string" ? body.address.trim() : undefined; if (addressProvided && !trimmedAddress) { setValues.latitude = null; setValues.longitude = null; setValues.geocodedAt = null; } const [updated] = await db .update(clients) .set(setValues) .where(eq(clients.id, c.req.param("id"))) .returning(); if (!updated) return c.json({ error: "Not found" }, 404); if (addressProvided && trimmedAddress) { const { row: enriched, outcome } = await autoGeocodeClient(db, updated); return c.json({ ...enriched, geocoding: outcome }); } return c.json(updated); } ); // 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 clientId = c.req.param("id"); const [existingAppt] = await db .select({ id: appointments.id }) .from(appointments) .where(eq(appointments.clientId, clientId)) .limit(1); if (existingAppt) { return c.json( { error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." }, 409 ); } const [row] = await db .delete(clients) .where(eq(clients.id, clientId)) .returning(); if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); });