feat(GRO-2155): route CRUD + optimization endpoint (Phase 2.1) (#175)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 35s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 35s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
This commit was merged in pull request #175.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user