285 lines
8.9 KiB
TypeScript
285 lines
8.9 KiB
TypeScript
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<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"),
|
|
});
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
}
|
|
);
|