530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
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<AppEnv>();
|
|
|
|
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<typeof getDb>, 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<ReturnType<typeof loadRouteStops>>[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<LoadedRouteStop & { conflict: StopConflictFlags }>;
|
|
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<string>();
|
|
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<AppEnv>,
|
|
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")
|
|
);
|