2b92c2ab6c
feat(security): GRO-2294 Route Optimization security hardening [squash] Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
296 lines
9.3 KiB
TypeScript
296 lines
9.3 KiB
TypeScript
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<AppEnv>();
|
|
|
|
// Batch-geocode bounds (GRO-2294): default 50, hard cap 500. The cap bounds how
|
|
// long one synchronous request stays open and the per-request external API cost
|
|
// when routeOptimizationProvider = "google".
|
|
const GEOCODE_BATCH_DEFAULT_LIMIT = 50;
|
|
const GEOCODE_BATCH_MAX_LIMIT = 500;
|
|
|
|
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<typeof getDb>,
|
|
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 = GEOCODE_BATCH_DEFAULT_LIMIT;
|
|
if (limitRaw !== undefined) {
|
|
limit = Number(limitRaw);
|
|
if (!Number.isFinite(limit) || limit <= 0) {
|
|
return c.json({ error: "limit must be a positive integer" }, 400);
|
|
}
|
|
// Clamp to the documented maximum to bound synchronous request duration
|
|
// and (for the Google provider) per-request external API cost.
|
|
limit = Math.min(Math.floor(limit), GEOCODE_BATCH_MAX_LIMIT);
|
|
}
|
|
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<string, unknown> = { ...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 });
|
|
});
|