Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6120b96c7c | |||
| ca62fb8ef6 | |||
| eb92f99c4a | |||
| 29c42e3130 | |||
| b842237425 | |||
| 587fd4ec95 | |||
| d0c0b1b646 | |||
| 8cf72d926d | |||
| 8721f0b63c | |||
| 027e012a58 | |||
| b3db206588 | |||
| 6538406db2 | |||
| e2eacbc9fe | |||
| e639cc82d1 | |||
| f2931d7be2 | |||
| d4a4ddce37 | |||
| bd384bdf5c | |||
| 411c42b2c4 | |||
| bf97849324 | |||
| 7181d41b24 | |||
| 4e9c4c5e08 | |||
| 16c959434b | |||
| 23484dc90a | |||
| 6a81a52a50 | |||
| 5a4b9a98bd | |||
| f7f88156e1 | |||
| 8af5a49d14 |
@@ -165,6 +165,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
||||
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
|
||||
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
|
||||
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
|
||||
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
|
||||
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
|
||||
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
|
||||
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
|
||||
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
|
||||
@@ -281,6 +283,7 @@ This means:
|
||||
| TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values |
|
||||
| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted |
|
||||
| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged |
|
||||
| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
|
||||
|
||||
### 4.9 Waitlist
|
||||
|
||||
@@ -358,6 +361,46 @@ This means:
|
||||
| TC-API-15.6 | Reject missing required fields | POST /api/admin/buffer-rules with service only | 400 Bad Request, species and sizeCategory required |
|
||||
| TC-API-15.7 | Booking uses buffer | Book appointment for pet with sizeCategory; verify duration reflects buffer | 201 Created, appointment duration includes buffer time |
|
||||
|
||||
### 4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1)
|
||||
|
||||
A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes`, with ordered `route_stops`. `POST /api/routes/optimize` pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions `optimizeWaypoints` when a key is configured in `businessSettings.googleMapsApiKey`, else an offline nearest-neighbor heuristic), and persists `stopOrder`, `travelMinsFromPrev`, `travelDistanceKmFromPrev` plus route `totalTravelMins`/`totalDistanceKm`/`optimizedAt`. **Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access.** Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer).
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-API-16.1 | Fetch daily route (auto-create draft) | As **manager**, `GET /api/routes/daily?staffId={groomerId}&date=YYYY-MM-DD` for a date with no existing route | 200 OK; body `{ route, stops }`. `route.status` is `"draft"`, `route.staffId`/`routeDate` match, `stops` is `[]`. Re-calling returns the same route row (no duplicate) |
|
||||
| TC-API-16.2 | Optimize a multi-stop day | As manager, with ≥2 geocoded appointments for the groomer on the date, `POST /api/routes/optimize` body `{ "staffId": "{groomerId}", "date": "YYYY-MM-DD" }` | 200 OK; `route.status: "optimized"`, `optimizedAt` set, `totalTravelMins`/`totalDistanceKm` populated. `stops` ordered by `stopOrder` (1..N); first stop has `travelMinsFromPrev: null`, the rest positive. `provider` is `"nearest_neighbor"` (no Google key in UAT). The first stop carries `bufferMins: 0` (no predecessor); every later stop carries `bufferMins` = `businessSettings.defaultTravelBufferMins` (default 15). Response also includes `hasConflicts` / `conflictCount` and each stop a `conflict` object (GRO-2156, see §4.17) |
|
||||
| TC-API-16.3 | Re-optimize replaces prior order | As manager, run TC-API-16.2 twice | Second call returns 200; stops fully replaced (no duplicate `route_stops`, `stopOrder` still contiguous 1..N), `optimizedAt` refreshed |
|
||||
| TC-API-16.4 | Skips un-geocoded appointments | As manager, optimize a day where one appointment's client has no coordinates | 200 OK; that appointment is absent from `stops` and listed under `skipped[]` with `reason: "client address is not geocoded"`; a corresponding entry appears in `warnings[]` |
|
||||
| TC-API-16.5 | Empty / single-stop day | As manager, optimize a date with 0 (or 1) geocoded appointments | 200 OK; `route.status: "optimized"`, `totalTravelMins: 0`, `totalDistanceKm: "0.00"`. For 1 stop, `stops` has one entry with `travelMinsFromPrev: null` |
|
||||
| TC-API-16.6 | >25 stops chunked with warning | As manager, optimize a day with >25 geocoded appointments | 200 OK; `chunked: true`, `subRouteCount ≥ 2`, a `warnings[]` entry mentions sub-routes; all appointments appear exactly once with contiguous `stopOrder` |
|
||||
| TC-API-16.7 | Groomer reads own route | As **groomer**, `GET /api/routes/daily?date=YYYY-MM-DD` (omit staffId, or pass own id) | 200 OK; route resolves to the groomer's own `staffId` |
|
||||
| TC-API-16.8 | Groomer cannot access another's route | As groomer, `GET /api/routes/daily?staffId={otherGroomerId}&date=...` or `POST /api/routes/optimize` with another `staffId` | 403 Forbidden (`groomers may only access their own route`) |
|
||||
| TC-API-16.9 | Receptionist denied | As **receptionist**, `GET /api/routes/daily?...` or `POST /api/routes/optimize` | 403 Forbidden (role not permitted) |
|
||||
| TC-API-16.10 | Manager must supply staffId | As manager, `POST /api/routes/optimize` body `{ "date": "YYYY-MM-DD" }` (no staffId) | 400 `{ error: "staffId is required" }` |
|
||||
| TC-API-16.11 | Invalid date rejected | `GET /api/routes/daily?staffId=...&date=06-08-2026` (wrong format) | 400 validation error (`date must be YYYY-MM-DD`) |
|
||||
|
||||
### 4.17 Route Optimization — Travel Buffer + Reorder (GRO-2156, Phase 2.2)
|
||||
|
||||
Builds on §4.16. After optimization each consecutive leg carries a travel `bufferMins` (= `businessSettings.defaultTravelBufferMins`, default 15; the first stop is `0`). The API derives a per-stop **`conflict`** object at read time on `GET /api/routes/daily`, `POST /api/routes/optimize`, and `PATCH /api/routes/:routeId/reorder`:
|
||||
|
||||
- `conflict.scheduleGapMins` — minutes between the previous appointment's `endTime` and this appointment's `startTime` (null for the first stop)
|
||||
- `conflict.requiredGapMins` — `travelMinsFromPrev + bufferMins` (null for the first stop)
|
||||
- `conflict.shortfallMins` — `requiredGapMins − scheduleGapMins` (positive ⇒ tight)
|
||||
- `conflict.hasConflict` — true when `shortfallMins > 0` ("tight schedule"); appointments are **never auto-moved**, only flagged
|
||||
|
||||
`PATCH /api/routes/:routeId/reorder` accepts `{ "stopOrder": ["<routeStopId>", …] }` (every current stop id, exactly once, first-to-last), persists the new `stopOrder`, re-estimates each leg's travel offline for the new adjacency, re-applies buffers, recomputes route totals, and returns the route with refreshed conflict flags. **Auth: manager (any route) or groomer (own route only).**
|
||||
|
||||
| ID | Scenario | Steps | Expected |
|
||||
|----|----------|-------|----------|
|
||||
| TC-API-17.1 | Conflict flags on optimize | As manager, optimize a day with ≥2 geocoded appointments whose times are close together | 200 OK; top-level `hasConflicts` (bool) + `conflictCount` (int). First stop `conflict.hasConflict:false` with null gap fields. A later stop whose `scheduleGapMins < travelMinsFromPrev + bufferMins` has `conflict.hasConflict:true` and positive `shortfallMins` |
|
||||
| TC-API-17.2 | No false conflict on a roomy schedule | Optimize a day where appointment gaps comfortably exceed travel + buffer | 200 OK; `hasConflicts:false`, `conflictCount:0`, every `conflict.shortfallMins ≤ 0` |
|
||||
| TC-API-17.3 | Reorder persists new order | As manager, take an optimized route, `PATCH /api/routes/{routeId}/reorder` with the stop ids in a new order | 200 OK; `stops` returned in the requested order with contiguous `stopOrder` 1..N; first stop `travelMinsFromPrev:null`/`bufferMins:0`, others recomputed; `route.totalTravelMins`/`totalDistanceKm` updated |
|
||||
| TC-API-17.4 | Reorder re-flags conflicts | Reorder so a far-apart pair becomes adjacent | 200 OK; `conflict` flags recomputed for the new adjacency (`hasConflicts`/`conflictCount` reflect the new order) |
|
||||
| TC-API-17.5 | Reorder validation — wrong stop set | `PATCH …/reorder` with a missing, extra, duplicate, or unknown stop id | 400 with an explanatory `error` (e.g. "must list every stop exactly once", "unknown stop id", "duplicate stop id") |
|
||||
| TC-API-17.6 | Reorder unknown route | `PATCH /api/routes/{randomUuid}/reorder` with any body | 404 `{ error: "Route not found" }` |
|
||||
| TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` |
|
||||
| TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
||||
|
||||
## Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
|
||||
@@ -280,6 +280,23 @@ describe("PATCH /portal/pets/:petId", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
// A non-UUID petId previously reached `where(eq(pets.id, ...))` and made
|
||||
// Postgres throw "invalid input syntax for type uuid" → unhandled 500.
|
||||
// It must now short-circuit to 404 before any select/update.
|
||||
selectPetRow = PET;
|
||||
|
||||
const res = await jsonPatch(
|
||||
`/portal/pets/not-a-uuid`,
|
||||
{ coatType: "short" },
|
||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(updatedValues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 422 for an invalid coatType", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectPetRow = PET;
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
haversineKm,
|
||||
estimateLeg,
|
||||
nearestNeighborOrder,
|
||||
optimizeRoute,
|
||||
detectScheduleConflicts,
|
||||
recomputeLegsForOrder,
|
||||
MAX_STOPS_PER_ROUTE,
|
||||
type RouteStopInput,
|
||||
} from "../services/routeOptimization.js";
|
||||
import type { FetchLike } from "../services/geocoding.js";
|
||||
|
||||
/** Builds a fake fetch returning a single JSON body, recording called URLs. */
|
||||
function fakeFetch(
|
||||
body: unknown,
|
||||
init: { ok?: boolean; status?: number; statusText?: string } = {}
|
||||
): { fetchImpl: FetchLike; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
const fetchImpl: FetchLike = async (url) => {
|
||||
calls.push(url);
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
statusText: init.statusText ?? "OK",
|
||||
json: async () => body,
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
function stop(appointmentId: string, lat: number, lng: number): RouteStopInput {
|
||||
return { appointmentId, latitude: lat, longitude: lng };
|
||||
}
|
||||
|
||||
describe("haversineKm", () => {
|
||||
it("is zero for the same point", () => {
|
||||
expect(haversineKm({ latitude: 40, longitude: -74 }, { latitude: 40, longitude: -74 })).toBe(0);
|
||||
});
|
||||
|
||||
it("approximates 1 degree of latitude as ~111km", () => {
|
||||
const d = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 });
|
||||
expect(d).toBeGreaterThan(110);
|
||||
expect(d).toBeLessThan(112);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateLeg", () => {
|
||||
it("applies the circuity factor and average speed", () => {
|
||||
const a = { latitude: 0, longitude: 0 };
|
||||
const b = { latitude: 0, longitude: 1 };
|
||||
const { distanceKm, mins } = estimateLeg(a, b);
|
||||
// ~111km straight * 1.3 circuity ≈ 144.6km; at 40km/h ≈ 217 min
|
||||
expect(distanceKm).toBeGreaterThan(140);
|
||||
expect(distanceKm).toBeLessThan(150);
|
||||
expect(mins).toBeGreaterThan(200);
|
||||
expect(mins).toBeLessThan(230);
|
||||
expect(Number.isInteger(mins)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearestNeighborOrder", () => {
|
||||
it("returns trivial order for 0 or 1 points", () => {
|
||||
expect(nearestNeighborOrder([])).toEqual([]);
|
||||
expect(nearestNeighborOrder([{ latitude: 1, longitude: 1 }])).toEqual([0]);
|
||||
});
|
||||
|
||||
it("greedily visits the nearest unvisited point", () => {
|
||||
// Points on a line; scrambled input order.
|
||||
const points = [
|
||||
{ latitude: 0, longitude: 0 }, // 0 (start)
|
||||
{ latitude: 0, longitude: 5 }, // 1 (far)
|
||||
{ latitude: 0, longitude: 1 }, // 2
|
||||
{ latitude: 0, longitude: 2 }, // 3
|
||||
];
|
||||
expect(nearestNeighborOrder(points, 0)).toEqual([0, 2, 3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimizeRoute — nearest-neighbor fallback (no API key)", () => {
|
||||
it("returns an empty route for no stops", async () => {
|
||||
const r = await optimizeRoute([]);
|
||||
expect(r.stops).toHaveLength(0);
|
||||
expect(r.totalTravelMins).toBe(0);
|
||||
expect(r.totalDistanceKm).toBe(0);
|
||||
expect(r.provider).toBe("nearest_neighbor");
|
||||
expect(r.chunked).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a single stop with null travel-from-prev", async () => {
|
||||
const r = await optimizeRoute([stop("a", 40, -74)]);
|
||||
expect(r.stops).toHaveLength(1);
|
||||
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
||||
expect(r.stops[0]!.travelDistanceKmFromPrev).toBeNull();
|
||||
expect(r.totalTravelMins).toBe(0);
|
||||
});
|
||||
|
||||
it("orders multiple stops greedily and sums totals", async () => {
|
||||
const stops = [
|
||||
stop("start", 0, 0),
|
||||
stop("far", 0, 5),
|
||||
stop("near1", 0, 1),
|
||||
stop("near2", 0, 2),
|
||||
];
|
||||
const r = await optimizeRoute(stops);
|
||||
expect(r.provider).toBe("nearest_neighbor");
|
||||
expect(r.stops.map((s) => s.appointmentId)).toEqual([
|
||||
"start",
|
||||
"near1",
|
||||
"near2",
|
||||
"far",
|
||||
]);
|
||||
// First stop has no inbound leg.
|
||||
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
||||
// Remaining stops have positive travel.
|
||||
for (const s of r.stops.slice(1)) {
|
||||
expect(s.travelMinsFromPrev!).toBeGreaterThan(0);
|
||||
expect(s.travelDistanceKmFromPrev!).toBeGreaterThan(0);
|
||||
}
|
||||
const summed = r.stops.reduce((acc, s) => acc + (s.travelMinsFromPrev ?? 0), 0);
|
||||
expect(r.totalTravelMins).toBe(summed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimizeRoute — Google Directions path", () => {
|
||||
it("uses optimized waypoint order and real leg metrics, dropping the return leg", async () => {
|
||||
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
|
||||
// waypoints = [B, C]; optimizer reorders them to [C, B] (waypoint_order [1,0]).
|
||||
// legs: A->C, C->B, B->A(return). The return leg must be dropped.
|
||||
const { fetchImpl, calls } = fakeFetch({
|
||||
status: "OK",
|
||||
routes: [
|
||||
{
|
||||
waypoint_order: [1, 0],
|
||||
legs: [
|
||||
{ distance: { value: 2000 }, duration: { value: 600 } }, // A->C
|
||||
{ distance: { value: 1000 }, duration: { value: 300 } }, // C->B
|
||||
{ distance: { value: 3000 }, duration: { value: 900 } }, // B->A (return, dropped)
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
|
||||
expect(r.provider).toBe("google");
|
||||
expect(r.stops.map((s) => s.appointmentId)).toEqual(["A", "C", "B"]);
|
||||
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
||||
expect(r.stops[1]!.travelDistanceKmFromPrev).toBe(2); // 2000m -> 2km
|
||||
expect(r.stops[1]!.travelMinsFromPrev).toBe(10); // 600s -> 10min
|
||||
expect(r.stops[2]!.travelDistanceKmFromPrev).toBe(1); // 1000m
|
||||
expect(r.stops[2]!.travelMinsFromPrev).toBe(5); // 300s
|
||||
expect(r.totalDistanceKm).toBe(3);
|
||||
expect(r.totalTravelMins).toBe(15);
|
||||
expect(decodeURIComponent(calls[0]!)).toContain("optimize:true");
|
||||
});
|
||||
|
||||
it("falls back to the heuristic when Google returns a non-OK status", async () => {
|
||||
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
|
||||
const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "bad key" });
|
||||
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
|
||||
// Provider label reflects the chosen strategy (google requested) but a
|
||||
// warning records the degradation and stops are still ordered.
|
||||
expect(r.stops).toHaveLength(3);
|
||||
expect(r.warnings.some((w) => w.includes("offline heuristic"))).toBe(true);
|
||||
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimizeRoute — >25 stop chunking", () => {
|
||||
it("splits into sub-routes with a warning and continuous stop ordering", async () => {
|
||||
const stops: RouteStopInput[] = [];
|
||||
for (let i = 0; i < MAX_STOPS_PER_ROUTE + 5; i++) {
|
||||
stops.push(stop(`s${i}`, 0, i * 0.1));
|
||||
}
|
||||
const r = await optimizeRoute(stops);
|
||||
expect(r.chunked).toBe(true);
|
||||
expect(r.subRouteCount).toBe(2);
|
||||
expect(r.warnings.some((w) => w.includes("sub-routes"))).toBe(true);
|
||||
expect(r.stops).toHaveLength(MAX_STOPS_PER_ROUTE + 5);
|
||||
// Only the very first stop of the whole route lacks an inbound leg.
|
||||
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
||||
expect(r.stops.slice(1).every((s) => s.travelMinsFromPrev !== null)).toBe(true);
|
||||
// All appointment ids preserved exactly once.
|
||||
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectScheduleConflicts", () => {
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
it("returns no conflict and null gaps for an empty or single-stop route", () => {
|
||||
expect(detectScheduleConflicts([])).toEqual([]);
|
||||
const one = detectScheduleConflicts([
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
||||
travelMinsFromPrev: null,
|
||||
bufferMins: 15,
|
||||
},
|
||||
]);
|
||||
expect(one).toEqual([
|
||||
{
|
||||
hasConflict: false,
|
||||
scheduleGapMins: null,
|
||||
requiredGapMins: null,
|
||||
shortfallMins: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags a tight schedule when gap < travel + buffer", () => {
|
||||
// Stop 1 ends 10:00, stop 2 starts 10:20 → 20min gap. Travel 15 + buffer 15
|
||||
// = 30 required → shortfall 10 → conflict.
|
||||
const flags = detectScheduleConflicts([
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
||||
travelMinsFromPrev: null,
|
||||
bufferMins: 0,
|
||||
},
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T10:20:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T11:00:00Z"),
|
||||
travelMinsFromPrev: 15,
|
||||
bufferMins: 15,
|
||||
},
|
||||
]);
|
||||
expect(flags[0]!.hasConflict).toBe(false);
|
||||
expect(flags[1]).toEqual({
|
||||
hasConflict: true,
|
||||
scheduleGapMins: 20,
|
||||
requiredGapMins: 30,
|
||||
shortfallMins: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flag when the gap comfortably covers travel + buffer", () => {
|
||||
// 90min gap, 15 travel + 15 buffer = 30 required → 60 slack → no conflict.
|
||||
const flags = detectScheduleConflicts([
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
||||
travelMinsFromPrev: null,
|
||||
bufferMins: 0,
|
||||
},
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T11:30:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T12:00:00Z"),
|
||||
travelMinsFromPrev: 15,
|
||||
bufferMins: 15,
|
||||
},
|
||||
]);
|
||||
expect(flags[1]).toEqual({
|
||||
hasConflict: false,
|
||||
scheduleGapMins: 90,
|
||||
requiredGapMins: 30,
|
||||
shortfallMins: -60,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a null travelMinsFromPrev as zero travel", () => {
|
||||
const flags = detectScheduleConflicts([
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
||||
travelMinsFromPrev: null,
|
||||
bufferMins: 0,
|
||||
},
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T10:05:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T11:00:00Z"),
|
||||
travelMinsFromPrev: null,
|
||||
bufferMins: 15,
|
||||
},
|
||||
]);
|
||||
// 5min gap vs 0 travel + 15 buffer = 15 required → conflict, shortfall 10.
|
||||
expect(flags[1]!.hasConflict).toBe(true);
|
||||
expect(flags[1]!.requiredGapMins).toBe(15);
|
||||
expect(flags[1]!.shortfallMins).toBe(10);
|
||||
});
|
||||
|
||||
it("flags overlapping appointments (negative gap) as conflicts", () => {
|
||||
const flags = detectScheduleConflicts([
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
||||
travelMinsFromPrev: null,
|
||||
bufferMins: 0,
|
||||
},
|
||||
{
|
||||
appointmentStartTime: at("2026-06-08T09:30:00Z"),
|
||||
appointmentEndTime: at("2026-06-08T10:30:00Z"),
|
||||
travelMinsFromPrev: 10,
|
||||
bufferMins: 15,
|
||||
},
|
||||
]);
|
||||
expect(flags[1]!.scheduleGapMins).toBe(-30);
|
||||
expect(flags[1]!.hasConflict).toBe(true);
|
||||
expect(flags[1]!.shortfallMins).toBe(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeLegsForOrder", () => {
|
||||
it("returns null travel for an empty or single-point order", () => {
|
||||
expect(recomputeLegsForOrder([])).toEqual([]);
|
||||
expect(recomputeLegsForOrder([{ latitude: 40, longitude: -74 }])).toEqual([
|
||||
{ travelMinsFromPrev: null, travelDistanceKmFromPrev: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("estimates each leg for the fixed given order without reordering", () => {
|
||||
const pts = [
|
||||
{ latitude: 0, longitude: 0 },
|
||||
{ latitude: 0, longitude: 1 },
|
||||
{ latitude: 0, longitude: 2 },
|
||||
];
|
||||
const legs = recomputeLegsForOrder(pts);
|
||||
expect(legs).toHaveLength(3);
|
||||
expect(legs[0]).toEqual({
|
||||
travelMinsFromPrev: null,
|
||||
travelDistanceKmFromPrev: null,
|
||||
});
|
||||
// Each leg equals estimateLeg between adjacent points (no optimization).
|
||||
const e01 = estimateLeg(pts[0]!, pts[1]!);
|
||||
const e12 = estimateLeg(pts[1]!, pts[2]!);
|
||||
expect(legs[1]).toEqual({
|
||||
travelMinsFromPrev: e01.mins,
|
||||
travelDistanceKmFromPrev: e01.distanceKm,
|
||||
});
|
||||
expect(legs[2]).toEqual({
|
||||
travelMinsFromPrev: e12.mins,
|
||||
travelDistanceKmFromPrev: e12.distanceKm,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,66 @@ describe("POST /portal/waitlist", () => {
|
||||
expect(insertedValues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("normalizes HH:MM:SS preferredTime and returns 201 (GRO-2211)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00:00",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
|
||||
});
|
||||
|
||||
it("normalizes HH:MM preferredTime to HH:MM:SS before insert (GRO-2211)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "10:00",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
|
||||
});
|
||||
|
||||
it("returns 400 (not 500) for a full ISO datetime preferredTime (GRO-2211)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "2026-06-09T10:00:00.000Z",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(insertedValues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for a malformed preferredDate (GRO-2211)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "03/25/2026",
|
||||
preferredTime: "10:00",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(insertedValues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 for an out-of-range preferredTime (GRO-2211)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
serviceId: VALID_UUID_4,
|
||||
preferredDate: "2026-03-25",
|
||||
preferredTime: "25:99",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(insertedValues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||
petId: VALID_UUID_3,
|
||||
@@ -258,6 +318,16 @@ describe("PATCH /portal/waitlist/:id", () => {
|
||||
expect(updatedValues[0]?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("returns 400 (not 500) for a full ISO datetime preferredTime on update (GRO-2211)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectRows = [WAITLIST_ENTRY];
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
preferredTime: "2026-06-09T10:00:00.000Z",
|
||||
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(updatedValues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 401 without session", async () => {
|
||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||
status: "cancelled",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { settingsRouter } from "./routes/settings.js";
|
||||
import { authProviderRouter } from "./routes/authProvider.js";
|
||||
import { searchRouter } from "./routes/search.js";
|
||||
import { bufferRulesRouter } from "./routes/buffer-rules.js";
|
||||
import { routesRouter } from "./routes/routes.js";
|
||||
import { getObject } from "./lib/s3.js";
|
||||
import { calendarRouter } from "./routes/calendar.js";
|
||||
import { setupRouter } from "./routes/setup.js";
|
||||
@@ -220,6 +221,10 @@ api.use("/reports/*", requireRole("manager"));
|
||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||
api.use("/impersonation/*", requireRole("manager"));
|
||||
|
||||
// Route optimization: manager (any groomer's route) or groomer (own route only,
|
||||
// enforced in-handler). Receptionists have no access. (GRO-2155)
|
||||
api.use("/routes/*", requireRole("manager", "groomer"));
|
||||
|
||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||
api.use("/appointment-groups/*", requireRole("manager", "receptionist"));
|
||||
api.use("/grooming-logs/*", requireRole("manager", "receptionist"));
|
||||
@@ -283,6 +288,7 @@ api.route("/admin/auth-provider", authProviderRouter);
|
||||
api.route("/admin/seed", adminSeedRouter);
|
||||
api.route("/search", searchRouter);
|
||||
api.route("/buffer-rules", bufferRulesRouter);
|
||||
api.route("/routes", routesRouter);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await initAuth();
|
||||
|
||||
+30
-6
@@ -296,6 +296,14 @@ portalRouter.patch(
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
// GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID
|
||||
// string to a uuid column makes the driver throw ("invalid input syntax for
|
||||
// type uuid"), which previously surfaced as an unhandled 500. Mirror the
|
||||
// GRO-2014 fix in pets.ts and treat a malformed id as Not found.
|
||||
if (!z.string().uuid().safeParse(petId).success) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
const [pet] = await db
|
||||
.select()
|
||||
.from(pets)
|
||||
@@ -551,17 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
|
||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||
|
||||
// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO
|
||||
// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500.
|
||||
// Constrain client input here so malformed values are rejected with a 400 by
|
||||
// zValidator before they ever reach the DB (GRO-2211 defense-in-depth).
|
||||
const preferredDateSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD");
|
||||
const preferredTimeSchema = z
|
||||
.string()
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS");
|
||||
|
||||
// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format.
|
||||
function normalizeTime(value: string): string {
|
||||
return value.length === 5 ? `${value}:00` : value;
|
||||
}
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
preferredDate: z.string(),
|
||||
preferredTime: z.string(),
|
||||
preferredDate: preferredDateSchema,
|
||||
preferredTime: preferredTimeSchema,
|
||||
});
|
||||
|
||||
const updateWaitlistEntrySchema = z.object({
|
||||
status: z.literal("cancelled").optional(),
|
||||
preferredDate: z.string().optional(),
|
||||
preferredTime: z.string().optional(),
|
||||
preferredDate: preferredDateSchema.optional(),
|
||||
preferredTime: preferredTimeSchema.optional(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
@@ -579,7 +603,7 @@ portalRouter.post(
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: body.preferredTime,
|
||||
preferredTime: normalizeTime(body.preferredTime),
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -610,7 +634,7 @@ portalRouter.patch(
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
||||
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
|
||||
if (body.preferredTime !== undefined) updateData.preferredTime = normalizeTime(body.preferredTime);
|
||||
|
||||
const [updated] = await db
|
||||
.update(waitlistEntries)
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
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,
|
||||
detectScheduleConflicts,
|
||||
recomputeLegsForOrder,
|
||||
type RouteStopInput,
|
||||
type StopConflictFlags,
|
||||
} 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"),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,513 @@
|
||||
import { businessSettings, decryptSecret, type Db } from "@groombook/db";
|
||||
import type { FetchLike } from "./geocoding.js";
|
||||
|
||||
/**
|
||||
* Route optimization service (GRO-2155, Phase 2.1 of Route Optimization).
|
||||
*
|
||||
* Given a groomer's geocoded stops for a day, produces an optimized visiting
|
||||
* order plus per-leg and total travel estimates. Two strategies:
|
||||
*
|
||||
* - {@link optimizeWithGoogle}: Google Maps Directions API with
|
||||
* `optimizeWaypoints: true` (real road durations/distances), used when a
|
||||
* Google Maps API key is configured.
|
||||
* - {@link nearestNeighborOrder}: an offline nearest-neighbor TSP heuristic over
|
||||
* great-circle distance, used as the default free / no-API-key fallback.
|
||||
*
|
||||
* Both strategies share the same public {@link optimizeRoute} orchestrator,
|
||||
* which also handles the >25-stop edge case by chunking into sub-routes (the
|
||||
* Google Directions waypoint cap) and surfacing a warning.
|
||||
*/
|
||||
|
||||
/** Google Directions allows origin + destination + up to 23 waypoints = 25
|
||||
* points per request. We cap a sub-route at 25 stops and chunk beyond that. */
|
||||
export const MAX_STOPS_PER_ROUTE = 25;
|
||||
|
||||
/** Average driving speed (km/h) used to convert distance into travel minutes in
|
||||
* the offline heuristic. Tuned for mixed urban/suburban mobile-groomer routes. */
|
||||
export const AVG_SPEED_KMH = 40;
|
||||
|
||||
/** Multiplier applied to great-circle distance to approximate real road
|
||||
* distance in the offline heuristic (straight-line underestimates driving). */
|
||||
export const ROAD_CIRCUITY_FACTOR = 1.3;
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
/** A geocoded stop to be ordered. `appointmentId` ties it back to the schedule. */
|
||||
export interface RouteStopInput {
|
||||
appointmentId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
/** A single stop in the optimized order, with travel from the previous stop. */
|
||||
export interface OptimizedStop {
|
||||
appointmentId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** Null for the first stop of the whole route. */
|
||||
travelMinsFromPrev: number | null;
|
||||
/** Null for the first stop of the whole route. Kilometres, 2-dp. */
|
||||
travelDistanceKmFromPrev: number | null;
|
||||
}
|
||||
|
||||
export type RouteOptimizationProvider = "google" | "nearest_neighbor";
|
||||
|
||||
export interface OptimizedRoute {
|
||||
provider: RouteOptimizationProvider;
|
||||
stops: OptimizedStop[];
|
||||
totalTravelMins: number;
|
||||
/** Kilometres, rounded to 2 decimal places. */
|
||||
totalDistanceKm: number;
|
||||
/** True when the route was split into multiple sub-routes (>25 stops). */
|
||||
chunked: boolean;
|
||||
subRouteCount: number;
|
||||
/** Non-fatal advisories for the caller to surface to the user. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface OptimizeRouteOptions {
|
||||
/** Google Maps API key. When absent, the nearest-neighbor heuristic is used. */
|
||||
googleApiKey?: string | null;
|
||||
/** Injectable fetch for testing the Google path. Defaults to global fetch. */
|
||||
fetchImpl?: FetchLike;
|
||||
}
|
||||
|
||||
const defaultFetch: FetchLike = (input, init) =>
|
||||
(globalThis.fetch as unknown as FetchLike)(input, init);
|
||||
|
||||
// ─── Geometry helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function toRadians(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** Great-circle distance between two coordinates, in kilometres. */
|
||||
export function haversineKm(
|
||||
a: { latitude: number; longitude: number },
|
||||
b: { latitude: number; longitude: number }
|
||||
): number {
|
||||
const dLat = toRadians(b.latitude - a.latitude);
|
||||
const dLon = toRadians(b.longitude - a.longitude);
|
||||
const lat1 = toRadians(a.latitude);
|
||||
const lat2 = toRadians(b.latitude);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/** Round to 2 decimal places, returning a finite number. */
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate a road travel leg from the great-circle distance between two points.
|
||||
* Applies a circuity factor for distance and a fixed average speed for time.
|
||||
*/
|
||||
export function estimateLeg(
|
||||
a: { latitude: number; longitude: number },
|
||||
b: { latitude: number; longitude: number }
|
||||
): { distanceKm: number; mins: number } {
|
||||
const straight = haversineKm(a, b);
|
||||
const distanceKm = straight * ROAD_CIRCUITY_FACTOR;
|
||||
const mins = (distanceKm / AVG_SPEED_KMH) * 60;
|
||||
return { distanceKm: round2(distanceKm), mins: Math.round(mins) };
|
||||
}
|
||||
|
||||
// ─── Nearest-neighbor heuristic ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Orders points greedily: start at `startIndex`, then repeatedly visit the
|
||||
* nearest unvisited point (great-circle distance). Returns indices into the
|
||||
* input array in visiting order. Deterministic ties broken by lowest index.
|
||||
*/
|
||||
export function nearestNeighborOrder(
|
||||
points: Array<{ latitude: number; longitude: number }>,
|
||||
startIndex = 0
|
||||
): number[] {
|
||||
const n = points.length;
|
||||
if (n <= 1) return points.map((_, i) => i);
|
||||
|
||||
const visited = new Array<boolean>(n).fill(false);
|
||||
const order: number[] = [startIndex];
|
||||
visited[startIndex] = true;
|
||||
let current = startIndex;
|
||||
|
||||
for (let step = 1; step < n; step++) {
|
||||
let best = -1;
|
||||
let bestDist = Infinity;
|
||||
for (let j = 0; j < n; j++) {
|
||||
if (visited[j]) continue;
|
||||
const d = haversineKm(points[current]!, points[j]!);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = j;
|
||||
}
|
||||
}
|
||||
visited[best] = true;
|
||||
order.push(best);
|
||||
current = best;
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/** Orders one chunk (<= MAX_STOPS_PER_ROUTE) via nearest-neighbor. */
|
||||
function optimizeChunkNearestNeighbor(
|
||||
stops: RouteStopInput[]
|
||||
): RouteStopInput[] {
|
||||
const order = nearestNeighborOrder(stops, 0);
|
||||
return order.map((i) => stops[i]!);
|
||||
}
|
||||
|
||||
// ─── Google Directions ──────────────────────────────────────────────────────
|
||||
|
||||
const GOOGLE_DIRECTIONS_URL =
|
||||
"https://maps.googleapis.com/maps/api/directions/json";
|
||||
|
||||
interface GoogleDirectionsResponse {
|
||||
status: string;
|
||||
error_message?: string;
|
||||
routes?: Array<{
|
||||
waypoint_order?: number[];
|
||||
legs?: Array<{
|
||||
duration?: { value?: number };
|
||||
distance?: { value?: number };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders one chunk via the Google Directions API with `optimizeWaypoints=true`.
|
||||
*
|
||||
* The first stop is fixed as both origin and destination (a closed tour); the
|
||||
* remaining stops are passed as optimizable waypoints. We keep the optimized
|
||||
* forward order and drop the final return-to-origin leg, yielding an open route
|
||||
* whose per-leg durations/distances come from real road data.
|
||||
*/
|
||||
async function optimizeChunkGoogle(
|
||||
stops: RouteStopInput[],
|
||||
apiKey: string,
|
||||
fetchImpl: FetchLike
|
||||
): Promise<{ stops: RouteStopInput[]; legsMeters: number[]; legsSeconds: number[] }> {
|
||||
if (stops.length <= 1) {
|
||||
return { stops: [...stops], legsMeters: [], legsSeconds: [] };
|
||||
}
|
||||
|
||||
const origin = stops[0]!;
|
||||
const waypoints = stops.slice(1);
|
||||
const url = new URL(GOOGLE_DIRECTIONS_URL);
|
||||
url.searchParams.set("origin", `${origin.latitude},${origin.longitude}`);
|
||||
url.searchParams.set("destination", `${origin.latitude},${origin.longitude}`);
|
||||
url.searchParams.set(
|
||||
"waypoints",
|
||||
"optimize:true|" +
|
||||
waypoints.map((w) => `${w.latitude},${w.longitude}`).join("|")
|
||||
);
|
||||
url.searchParams.set("key", apiKey);
|
||||
|
||||
const res = await fetchImpl(url.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Google Directions request failed: ${res.status} ${res.statusText}`
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as GoogleDirectionsResponse;
|
||||
if (body.status !== "OK" || !body.routes || body.routes.length === 0) {
|
||||
throw new Error(
|
||||
`Google Directions returned status ${body.status}${
|
||||
body.error_message ? `: ${body.error_message}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const route = body.routes[0]!;
|
||||
const waypointOrder = route.waypoint_order ?? waypoints.map((_, i) => i);
|
||||
const legs = route.legs ?? [];
|
||||
|
||||
// Ordered stops: origin first, then waypoints in the optimized order.
|
||||
const orderedStops: RouteStopInput[] = [
|
||||
origin,
|
||||
...waypointOrder.map((i) => waypoints[i]!),
|
||||
];
|
||||
|
||||
// legs[k] is the travel into orderedStops[k+1]. Drop the trailing return leg
|
||||
// (orderedStops.length-1 legs describe the open route).
|
||||
const legsMeters: number[] = [];
|
||||
const legsSeconds: number[] = [];
|
||||
for (let k = 0; k < orderedStops.length - 1; k++) {
|
||||
const leg = legs[k];
|
||||
legsMeters.push(leg?.distance?.value ?? 0);
|
||||
legsSeconds.push(leg?.duration?.value ?? 0);
|
||||
}
|
||||
|
||||
return { stops: orderedStops, legsMeters, legsSeconds };
|
||||
}
|
||||
|
||||
// ─── Orchestration ──────────────────────────────────────────────────────────
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimizes a full day's stops into a single visiting order with travel
|
||||
* metrics. Uses Google Directions when `googleApiKey` is provided, otherwise the
|
||||
* offline nearest-neighbor heuristic. Routes longer than
|
||||
* {@link MAX_STOPS_PER_ROUTE} stops are split into sub-routes and a warning is
|
||||
* emitted; sub-routes are stitched end-to-end, with the boundary leg estimated
|
||||
* from great-circle distance.
|
||||
*/
|
||||
export async function optimizeRoute(
|
||||
inputStops: RouteStopInput[],
|
||||
options: OptimizeRouteOptions = {}
|
||||
): Promise<OptimizedRoute> {
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const useGoogle = Boolean(options.googleApiKey);
|
||||
const provider: RouteOptimizationProvider = useGoogle
|
||||
? "google"
|
||||
: "nearest_neighbor";
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (inputStops.length === 0) {
|
||||
return {
|
||||
provider,
|
||||
stops: [],
|
||||
totalTravelMins: 0,
|
||||
totalDistanceKm: 0,
|
||||
chunked: false,
|
||||
subRouteCount: 0,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const chunks = chunk(inputStops, MAX_STOPS_PER_ROUTE);
|
||||
const chunked = chunks.length > 1;
|
||||
if (chunked) {
|
||||
warnings.push(
|
||||
`Route has ${inputStops.length} stops, exceeding the ${MAX_STOPS_PER_ROUTE}-stop optimization limit. Split into ${chunks.length} sub-routes; review the order at sub-route boundaries.`
|
||||
);
|
||||
}
|
||||
|
||||
const ordered: OptimizedStop[] = [];
|
||||
let prev: RouteStopInput | null = null;
|
||||
|
||||
for (const group of chunks) {
|
||||
let groupStops: RouteStopInput[];
|
||||
let legDistanceKm: (i: number) => number;
|
||||
let legMins: (i: number) => number;
|
||||
|
||||
if (useGoogle) {
|
||||
try {
|
||||
const result = await optimizeChunkGoogle(
|
||||
group,
|
||||
options.googleApiKey!,
|
||||
fetchImpl
|
||||
);
|
||||
groupStops = result.stops;
|
||||
legDistanceKm = (i) => round2(result.legsMeters[i]! / 1000);
|
||||
legMins = (i) => Math.round(result.legsSeconds[i]! / 60);
|
||||
} catch (err) {
|
||||
// Google failed mid-optimization — degrade to the offline heuristic for
|
||||
// this run rather than failing the whole request.
|
||||
warnings.push(
|
||||
`Google Directions unavailable; used offline heuristic: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
groupStops = optimizeChunkNearestNeighbor(group);
|
||||
legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm;
|
||||
legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins;
|
||||
}
|
||||
} else {
|
||||
groupStops = optimizeChunkNearestNeighbor(group);
|
||||
legDistanceKm = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).distanceKm;
|
||||
legMins = (i) => estimateLeg(groupStops[i]!, groupStops[i + 1]!).mins;
|
||||
}
|
||||
|
||||
for (let i = 0; i < groupStops.length; i++) {
|
||||
const stop = groupStops[i]!;
|
||||
if (prev === null) {
|
||||
// Very first stop of the whole route.
|
||||
ordered.push({
|
||||
appointmentId: stop.appointmentId,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
travelMinsFromPrev: null,
|
||||
travelDistanceKmFromPrev: null,
|
||||
});
|
||||
} else if (i === 0) {
|
||||
// First stop of a non-initial chunk: estimate the boundary leg.
|
||||
const est = estimateLeg(prev, stop);
|
||||
ordered.push({
|
||||
appointmentId: stop.appointmentId,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
travelMinsFromPrev: est.mins,
|
||||
travelDistanceKmFromPrev: est.distanceKm,
|
||||
});
|
||||
} else {
|
||||
ordered.push({
|
||||
appointmentId: stop.appointmentId,
|
||||
latitude: stop.latitude,
|
||||
longitude: stop.longitude,
|
||||
travelMinsFromPrev: legMins(i - 1),
|
||||
travelDistanceKmFromPrev: legDistanceKm(i - 1),
|
||||
});
|
||||
}
|
||||
prev = stop;
|
||||
}
|
||||
}
|
||||
|
||||
const totalTravelMins = ordered.reduce(
|
||||
(sum, s) => sum + (s.travelMinsFromPrev ?? 0),
|
||||
0
|
||||
);
|
||||
const totalDistanceKm = round2(
|
||||
ordered.reduce((sum, s) => sum + (s.travelDistanceKmFromPrev ?? 0), 0)
|
||||
);
|
||||
|
||||
return {
|
||||
provider,
|
||||
stops: ordered,
|
||||
totalTravelMins,
|
||||
totalDistanceKm,
|
||||
chunked,
|
||||
subRouteCount: chunks.length,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Google API key resolution ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves the Google Maps API key for route optimization from
|
||||
* `businessSettings.googleMapsApiKey` (decrypted at rest) or, as a development
|
||||
* convenience, the `GOOGLE_MAPS_API_KEY` env var. Returns `null` when no usable
|
||||
* key exists, in which case callers fall back to the offline heuristic.
|
||||
*/
|
||||
export async function resolveRouteGoogleApiKey(
|
||||
db: Db,
|
||||
decrypt: (ciphertext: string) => string = decryptSecret
|
||||
): Promise<string | null> {
|
||||
const [settings] = await db.select().from(businessSettings).limit(1);
|
||||
const stored = settings?.googleMapsApiKey?.trim();
|
||||
if (stored) {
|
||||
try {
|
||||
const decrypted = decrypt(stored).trim();
|
||||
if (decrypted) return decrypted;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to decrypt googleMapsApiKey for route optimization; using offline heuristic: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
|
||||
return fromEnv ? fromEnv : null;
|
||||
}
|
||||
|
||||
// ─── Travel buffer & schedule-conflict logic (GRO-2156, Phase 2.2) ───────────
|
||||
|
||||
/** A single stop's timing inputs for schedule-conflict detection. */
|
||||
export interface ScheduleStopTiming {
|
||||
/** Scheduled appointment start. */
|
||||
appointmentStartTime: Date;
|
||||
/** Scheduled appointment end. */
|
||||
appointmentEndTime: Date;
|
||||
/** Travel minutes into this stop from the previous one (null for first). */
|
||||
travelMinsFromPrev: number | null;
|
||||
/** Configured buffer minutes before this stop. */
|
||||
bufferMins: number;
|
||||
}
|
||||
|
||||
/** Conflict annotation for one stop, surfaced for the frontend to display. */
|
||||
export interface StopConflictFlags {
|
||||
/** True when the schedule gap is too tight for travel + buffer. */
|
||||
hasConflict: boolean;
|
||||
/** Minutes between the previous appointment's end and this one's start.
|
||||
* Null for the first stop (no predecessor). */
|
||||
scheduleGapMins: number | null;
|
||||
/** travelMinsFromPrev + bufferMins. Null for the first stop. */
|
||||
requiredGapMins: number | null;
|
||||
/** requiredGapMins − scheduleGapMins; positive when the schedule is tight.
|
||||
* Null for the first stop. */
|
||||
shortfallMins: number | null;
|
||||
}
|
||||
|
||||
const MS_PER_MIN = 60_000;
|
||||
|
||||
/**
|
||||
* Detects "tight schedule" conflicts between consecutive stops, in visiting
|
||||
* order. A conflict exists when the real gap between the previous appointment's
|
||||
* end and this appointment's start is smaller than the time needed to travel
|
||||
* plus the configured buffer (`travelMinsFromPrev + bufferMins`).
|
||||
*
|
||||
* This only *flags* conflicts — appointments are never moved. The first stop
|
||||
* has no predecessor and is therefore always conflict-free.
|
||||
*/
|
||||
export function detectScheduleConflicts(
|
||||
stops: ScheduleStopTiming[]
|
||||
): StopConflictFlags[] {
|
||||
return stops.map((s, i) => {
|
||||
if (i === 0) {
|
||||
return {
|
||||
hasConflict: false,
|
||||
scheduleGapMins: null,
|
||||
requiredGapMins: null,
|
||||
shortfallMins: null,
|
||||
};
|
||||
}
|
||||
const prev = stops[i - 1]!;
|
||||
const scheduleGapMins = Math.round(
|
||||
(s.appointmentStartTime.getTime() - prev.appointmentEndTime.getTime()) /
|
||||
MS_PER_MIN
|
||||
);
|
||||
const requiredGapMins = (s.travelMinsFromPrev ?? 0) + s.bufferMins;
|
||||
const shortfallMins = requiredGapMins - scheduleGapMins;
|
||||
return {
|
||||
hasConflict: shortfallMins > 0,
|
||||
scheduleGapMins,
|
||||
requiredGapMins,
|
||||
shortfallMins,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A coordinate used when recomputing legs for a fixed (manually chosen) order. */
|
||||
export interface OrderedPoint {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
/** Recomputed per-leg travel for a fixed stop order. */
|
||||
export interface RecomputedLeg {
|
||||
/** Null for the first stop. */
|
||||
travelMinsFromPrev: number | null;
|
||||
/** Null for the first stop. Kilometres, 2-dp. */
|
||||
travelDistanceKmFromPrev: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes per-leg travel estimates for a *fixed* visiting order (e.g. after a
|
||||
* manual reorder). Unlike {@link optimizeRoute} this does not reorder anything —
|
||||
* it walks the given order and estimates each leg offline via {@link estimateLeg}
|
||||
* so a manual drag does not consume Google Directions quota.
|
||||
*/
|
||||
export function recomputeLegsForOrder(points: OrderedPoint[]): RecomputedLeg[] {
|
||||
return points.map((p, i) => {
|
||||
if (i === 0) {
|
||||
return { travelMinsFromPrev: null, travelDistanceKmFromPrev: null };
|
||||
}
|
||||
const est = estimateLeg(points[i - 1]!, p);
|
||||
return {
|
||||
travelMinsFromPrev: est.mins,
|
||||
travelDistanceKmFromPrev: est.distanceKm,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user