import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, asc, eq, gte, lt, ne, getDb, appointments, businessSettings, clients, groomerRoutes, routeStops, } from "@groombook/db"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; import { optimizeRoute, resolveRouteGoogleApiKey, type RouteStopInput, } from "../services/routeOptimization.js"; export const routesRouter = new Hono(); const dailyQuerySchema = z.object({ staffId: z.string().uuid().optional(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), }); const optimizeBodySchema = z.object({ staffId: z.string().uuid().optional(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), }); /** * Resolves the target staffId for the request and enforces the groomer-own / * manager authorization rule. Groomers may only act on their own route; if a * groomer omits staffId it defaults to their own. Returns either the resolved * id or an error tuple the caller turns into a JSON response. */ function resolveTargetStaffId( staffRow: StaffRow | undefined, requestedStaffId: string | undefined ): { staffId: string } | { error: string; status: 400 | 403 } { const isGroomer = staffRow?.role === "groomer"; if (isGroomer) { if (requestedStaffId && requestedStaffId !== staffRow.id) { return { error: "Forbidden: groomers may only access their own route", status: 403, }; } return { staffId: staffRow.id }; } // Manager: staffId is required (no implicit self — managers plan others' days). if (!requestedStaffId) { return { error: "staffId is required", status: 400 }; } return { staffId: requestedStaffId }; } /** Day window [date 00:00:00Z, nextDay 00:00:00Z) for filtering appointments. */ function dayBounds(date: string): { start: Date; end: Date } { const start = new Date(`${date}T00:00:00.000Z`); const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); return { start, end }; } /** Loads a route's persisted stops, enriched with appointment + client detail. */ async function loadRouteStops(db: ReturnType, routeId: string) { return db .select({ id: routeStops.id, appointmentId: routeStops.appointmentId, stopOrder: routeStops.stopOrder, latitude: routeStops.latitude, longitude: routeStops.longitude, travelMinsFromPrev: routeStops.travelMinsFromPrev, travelDistanceKmFromPrev: routeStops.travelDistanceKmFromPrev, bufferMins: routeStops.bufferMins, appointmentStartTime: appointments.startTime, appointmentEndTime: appointments.endTime, appointmentStatus: appointments.status, clientId: clients.id, clientName: clients.name, clientAddress: clients.address, }) .from(routeStops) .innerJoin(appointments, eq(routeStops.appointmentId, appointments.id)) .innerJoin(clients, eq(appointments.clientId, clients.id)) .where(eq(routeStops.routeId, routeId)) .orderBy(asc(routeStops.stopOrder)); } /** * GET /api/routes/daily?staffId=&date= * Fetches (creating a draft if absent) the daily route for a groomer, with all * persisted stops. Auth: groomer (own) or manager. */ routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => { const db = getDb(); const { staffId: requestedStaffId, date } = c.req.valid("query"); const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId); if ("error" in resolved) { return c.json({ error: resolved.error }, resolved.status); } const staffId = resolved.staffId; let [route] = await db .select() .from(groomerRoutes) .where( and( eq(groomerRoutes.staffId, staffId), eq(groomerRoutes.routeDate, date) ) ); if (!route) { // Create a draft route so the day is addressable before optimization. [route] = await db .insert(groomerRoutes) .values({ staffId, routeDate: date, status: "draft" }) .returning(); } const stops = await loadRouteStops(db, route!.id); return c.json({ route, stops }); }); /** * POST /api/routes/optimize { staffId, date } * Generates or re-optimizes the daily route: pulls the day's geocoded * appointments, optimizes the visiting order (Google Directions when a key is * configured, else nearest-neighbor), and persists the ordered stops + totals. * Auth: groomer (own) or manager. */ routesRouter.post( "/optimize", zValidator("json", optimizeBodySchema), async (c) => { const db = getDb(); const { staffId: requestedStaffId, date } = c.req.valid("json"); const resolved = resolveTargetStaffId(c.get("staff"), requestedStaffId); if ("error" in resolved) { return c.json({ error: resolved.error }, resolved.status); } const staffId = resolved.staffId; const { start, end } = dayBounds(date); // Pull the day's non-cancelled appointments for this groomer, joined to the // client coordinates. Ordered by start time so the earliest booking anchors // the route. const dayAppointments = await db .select({ appointmentId: appointments.id, startTime: appointments.startTime, clientId: clients.id, clientName: clients.name, latitude: clients.latitude, longitude: clients.longitude, }) .from(appointments) .innerJoin(clients, eq(appointments.clientId, clients.id)) .where( and( eq(appointments.staffId, staffId), gte(appointments.startTime, start), lt(appointments.startTime, end), ne(appointments.status, "cancelled") ) ) .orderBy(asc(appointments.startTime)); const stopInputs: RouteStopInput[] = []; const skipped: Array<{ appointmentId: string; clientName: string; reason: string }> = []; for (const appt of dayAppointments) { if (appt.latitude == null || appt.longitude == null) { skipped.push({ appointmentId: appt.appointmentId, clientName: appt.clientName, reason: "client address is not geocoded", }); continue; } stopInputs.push({ appointmentId: appt.appointmentId, latitude: appt.latitude, longitude: appt.longitude, }); } const [settings] = await db.select().from(businessSettings).limit(1); const bufferMins = settings?.defaultTravelBufferMins ?? 15; const googleApiKey = await resolveRouteGoogleApiKey(db); const optimized = await optimizeRoute(stopInputs, { googleApiKey }); const warnings = [...optimized.warnings]; if (skipped.length > 0) { warnings.push( `${skipped.length} appointment(s) were skipped because the client address is not geocoded.` ); } const now = new Date(); const route = await db.transaction(async (tx) => { // Upsert the route row for (staffId, date). const [existing] = await tx .select() .from(groomerRoutes) .where( and( eq(groomerRoutes.staffId, staffId), eq(groomerRoutes.routeDate, date) ) ); const [routeRow] = existing ? await tx .update(groomerRoutes) .set({ status: "optimized", totalTravelMins: optimized.totalTravelMins, totalDistanceKm: optimized.totalDistanceKm.toFixed(2), optimizedAt: now, updatedAt: now, }) .where(eq(groomerRoutes.id, existing.id)) .returning() : await tx .insert(groomerRoutes) .values({ staffId, routeDate: date, status: "optimized", totalTravelMins: optimized.totalTravelMins, totalDistanceKm: optimized.totalDistanceKm.toFixed(2), optimizedAt: now, }) .returning(); // Replace stops: clear prior ordering, insert the freshly optimized one. await tx.delete(routeStops).where(eq(routeStops.routeId, routeRow!.id)); if (optimized.stops.length > 0) { await tx.insert(routeStops).values( optimized.stops.map((s, i) => ({ routeId: routeRow!.id, appointmentId: s.appointmentId, stopOrder: i + 1, latitude: s.latitude, longitude: s.longitude, travelMinsFromPrev: s.travelMinsFromPrev, travelDistanceKmFromPrev: s.travelDistanceKmFromPrev == null ? null : s.travelDistanceKmFromPrev.toFixed(2), bufferMins, })) ); } return routeRow!; }); const stops = await loadRouteStops(db, route.id); return c.json({ route, stops, provider: optimized.provider, chunked: optimized.chunked, subRouteCount: optimized.subRouteCount, skipped, warnings, }); } );