import { Hono, type Context } 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, detectScheduleConflicts, recomputeLegsForOrder, type RouteStopInput, type StopConflictFlags, } from "../services/routeOptimization.js"; import { buildNavigationUrl, type NavigationPlatform, type NavigationStop, } from "../services/navigationExport.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"), }); const reorderBodySchema = z.object({ // New visiting order expressed as routeStops.id values, first-to-last. stopOrder: z.array(z.string().uuid()).min(1), }); /** * 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)); } type LoadedRouteStop = Awaited>[number]; /** * Annotates persisted stops with "tight schedule" conflict flags for the * frontend. Conflicts are derived at read time from the live appointment times, * persisted travel estimates and buffers — never auto-resolved by moving stops. */ function annotateConflicts(stops: LoadedRouteStop[]): { stops: Array; hasConflicts: boolean; conflictCount: number; } { const flags = detectScheduleConflicts( stops.map((s) => ({ appointmentStartTime: s.appointmentStartTime, appointmentEndTime: s.appointmentEndTime, travelMinsFromPrev: s.travelMinsFromPrev, bufferMins: s.bufferMins, })) ); const annotated = stops.map((s, i) => ({ ...s, conflict: flags[i]! })); const conflictCount = flags.filter((f) => f.hasConflict).length; return { stops: annotated, hasConflicts: conflictCount > 0, conflictCount }; } /** * 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); const annotated = annotateConflicts(stops); return c.json({ route, stops: annotated.stops, hasConflicts: annotated.hasConflicts, conflictCount: annotated.conflictCount, }); }); /** * 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), // Buffer applies between consecutive stops; the first stop has no // predecessor, so it carries no travel buffer. bufferMins: i === 0 ? 0 : bufferMins, })) ); } return routeRow!; }); const stops = await loadRouteStops(db, route.id); const annotated = annotateConflicts(stops); return c.json({ route, stops: annotated.stops, hasConflicts: annotated.hasConflicts, conflictCount: annotated.conflictCount, provider: optimized.provider, chunked: optimized.chunked, subRouteCount: optimized.subRouteCount, skipped, warnings, }); } ); /** * PATCH /api/routes/:routeId/reorder { stopOrder: string[] } * Persists a manual stop order (array of routeStops.id, first-to-last), then * re-runs the buffer logic: each leg's travel is re-estimated for the new * adjacency, the default travel buffer is re-applied between consecutive stops, * route totals are recomputed, and tight-schedule conflicts are re-flagged. * Appointments are never moved. Auth: groomer (own route) or manager. */ routesRouter.patch( "/:routeId/reorder", zValidator("json", reorderBodySchema), async (c) => { const db = getDb(); const routeId = c.req.param("routeId"); if (!z.string().uuid().safeParse(routeId).success) { return c.json({ error: "routeId must be a UUID" }, 400); } const { stopOrder: newOrderIds } = c.req.valid("json"); const [route] = await db .select() .from(groomerRoutes) .where(eq(groomerRoutes.id, routeId)); if (!route) { return c.json({ error: "Route not found" }, 404); } // Reuse the groomer-own / manager authorization rule against the route owner. const resolved = resolveTargetStaffId(c.get("staff"), route.staffId); if ("error" in resolved) { return c.json({ error: resolved.error }, resolved.status); } const existing = await db .select({ id: routeStops.id, latitude: routeStops.latitude, longitude: routeStops.longitude, }) .from(routeStops) .where(eq(routeStops.routeId, routeId)); // The new order must be an exact permutation of the route's current stops. const existingIds = new Set(existing.map((s) => s.id)); if (newOrderIds.length !== existing.length) { return c.json( { error: `stopOrder must list every stop exactly once (expected ${existing.length}, got ${newOrderIds.length})`, }, 400 ); } const seen = new Set(); for (const id of newOrderIds) { if (!existingIds.has(id)) { return c.json({ error: `unknown stop id: ${id}` }, 400); } if (seen.has(id)) { return c.json({ error: `duplicate stop id: ${id}` }, 400); } seen.add(id); } const [settings] = await db.select().from(businessSettings).limit(1); const bufferMins = settings?.defaultTravelBufferMins ?? 15; const byId = new Map(existing.map((s) => [s.id, s])); const legs = recomputeLegsForOrder( newOrderIds.map((id) => { const s = byId.get(id)!; return { latitude: s.latitude, longitude: s.longitude }; }) ); const totalTravelMins = legs.reduce( (sum, l) => sum + (l.travelMinsFromPrev ?? 0), 0 ); const totalDistanceKm = Math.round( legs.reduce((sum, l) => sum + (l.travelDistanceKmFromPrev ?? 0), 0) * 100 ) / 100; const now = new Date(); await db.transaction(async (tx) => { // Two-pass update: park stopOrder in a non-colliding negative range first // so the unique(routeId, stopOrder) constraint never trips mid-reorder. for (let i = 0; i < newOrderIds.length; i++) { await tx .update(routeStops) .set({ stopOrder: -(i + 1), updatedAt: now }) .where(eq(routeStops.id, newOrderIds[i]!)); } for (let i = 0; i < newOrderIds.length; i++) { const leg = legs[i]!; await tx .update(routeStops) .set({ stopOrder: i + 1, travelMinsFromPrev: leg.travelMinsFromPrev, travelDistanceKmFromPrev: leg.travelDistanceKmFromPrev == null ? null : leg.travelDistanceKmFromPrev.toFixed(2), bufferMins: i === 0 ? 0 : bufferMins, updatedAt: now, }) .where(eq(routeStops.id, newOrderIds[i]!)); } await tx .update(groomerRoutes) .set({ totalTravelMins, totalDistanceKm: totalDistanceKm.toFixed(2), updatedAt: now, }) .where(eq(groomerRoutes.id, routeId)); }); const [updatedRoute] = await db .select() .from(groomerRoutes) .where(eq(groomerRoutes.id, routeId)); const stops = await loadRouteStops(db, routeId); const annotated = annotateConflicts(stops); return c.json({ route: updatedRoute, stops: annotated.stops, hasConflicts: annotated.hasConflicts, conflictCount: annotated.conflictCount, }); } ); /** * GET /:routeId/export/:platform — build a native-navigation deep-link URL for an * optimized route. Origin = first stop, destination = last stop, the rest carried * as ordered intermediate waypoints. Waypoint count is validated against the * platform's limit. Auth: manager (any route) or groomer (own route only). */ async function handleNavigationExport( c: Context, platform: NavigationPlatform ) { const db = getDb(); const routeId = c.req.param("routeId"); if (!routeId || !z.string().uuid().safeParse(routeId).success) { return c.json({ error: "routeId must be a UUID" }, 400); } const [route] = await db .select() .from(groomerRoutes) .where(eq(groomerRoutes.id, routeId)); if (!route) { return c.json({ error: "Route not found" }, 404); } // Reuse the groomer-own / manager authorization rule against the route owner. const resolved = resolveTargetStaffId(c.get("staff"), route.staffId); if ("error" in resolved) { return c.json({ error: resolved.error }, resolved.status); } const stops = await loadRouteStops(db, routeId); if (stops.length === 0) { return c.json({ error: "route has no stops to export" }, 400); } const navStops: NavigationStop[] = stops.map((s) => ({ latitude: s.latitude, longitude: s.longitude, label: s.clientName, })); const result = buildNavigationUrl(platform, navStops); if ("error" in result) { return c.json({ error: result.error }, result.status); } return c.json({ platform: result.platform, url: result.url, stopCount: result.stopCount, waypointCount: result.waypointCount, }); } routesRouter.get("/:routeId/export/google-maps", (c) => handleNavigationExport(c, "google-maps") ); routesRouter.get("/:routeId/export/apple-maps", (c) => handleNavigationExport(c, "apple-maps") );