Merge pull request 'Promote dev → uat: GRO-2155/2156/2203/2211/2163 + GRO-2234 (cumulative batch)' (#182) from flea/dev-to-uat-gro-2156 into uat
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Images (push) Successful in 1m24s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Images (pull_request) Successful in 1m11s
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Images (push) Successful in 1m24s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Images (pull_request) Successful in 1m11s
This commit was merged in pull request #182.
This commit is contained in:
+25
-1
@@ -284,6 +284,8 @@ This means:
|
|||||||
| 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.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.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 |
|
| 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 |
|
||||||
|
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
||||||
|
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
||||||
|
|
||||||
### 4.9 Waitlist
|
### 4.9 Waitlist
|
||||||
|
|
||||||
@@ -368,7 +370,7 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes
|
|||||||
| # | Scenario | Steps | Expected |
|
| # | 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.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). Each stop carries `bufferMins` (default 15) |
|
| 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.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.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.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` |
|
||||||
@@ -379,6 +381,28 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes
|
|||||||
| 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.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`) |
|
| 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/Fail Criteria
|
||||||
|
|
||||||
**Pass:**
|
**Pass:**
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import {
|
||||||
|
validatePortalSession,
|
||||||
|
PORTAL_SESSION_IDLE_TTL_MS,
|
||||||
|
PORTAL_SESSION_MAX_LIFETIME_MS,
|
||||||
|
type PortalEnv,
|
||||||
|
} from "../middleware/portalSession.js";
|
||||||
|
|
||||||
|
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
|
||||||
|
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
|
||||||
|
// Mutable test state driven per-case.
|
||||||
|
let selectSessionRow: Record<string, unknown> | null = null;
|
||||||
|
let sessionUpdates: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
function resetMock() {
|
||||||
|
selectSessionRow = null;
|
||||||
|
sessionUpdates = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@groombook/db", () => {
|
||||||
|
function makeChainable(data: unknown[]): unknown {
|
||||||
|
const arr = [...data];
|
||||||
|
const chain = new Proxy(arr, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||||
|
return () => chain;
|
||||||
|
}
|
||||||
|
// @ts-expect-error proxy index
|
||||||
|
return target[prop];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impersonationSessions = new Proxy(
|
||||||
|
{ _name: "impersonationSessions" },
|
||||||
|
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getDb: () => ({
|
||||||
|
select: () => ({
|
||||||
|
from: (table: { _name: string }) => {
|
||||||
|
if (table._name === "impersonationSessions") {
|
||||||
|
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
|
||||||
|
}
|
||||||
|
return makeChainable([]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
update: () => ({
|
||||||
|
set: (vals: Record<string, unknown>) => {
|
||||||
|
sessionUpdates.push(vals);
|
||||||
|
return { where: () => Promise.resolve(undefined) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
impersonationSessions,
|
||||||
|
eq: vi.fn(),
|
||||||
|
and: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<PortalEnv>();
|
||||||
|
app.use("/portal/*", validatePortalSession);
|
||||||
|
app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") }));
|
||||||
|
|
||||||
|
function ping(headers?: Record<string, string>) {
|
||||||
|
return app.request("/portal/ping", { method: "GET", headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => resetMock());
|
||||||
|
|
||||||
|
describe("validatePortalSession — sliding expiration (GRO-2234)", () => {
|
||||||
|
it("extends an sso-bridge session's expiresAt on each authenticated request", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Session minted ~28 min ago, originally a 30-min idle window: it is still
|
||||||
|
// valid (2 min left) but a slow wizard would otherwise let it lapse.
|
||||||
|
selectSessionRow = {
|
||||||
|
id: SESSION_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
status: "active",
|
||||||
|
reason: "sso-bridge",
|
||||||
|
startedAt: new Date(now - 28 * 60 * 1000),
|
||||||
|
expiresAt: new Date(now + 2 * 60 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(sessionUpdates).toHaveLength(1);
|
||||||
|
const newExpiry = sessionUpdates[0]!.expiresAt as Date;
|
||||||
|
// Slid forward to ~now + 30 min (well past the original 2-min-left window).
|
||||||
|
expect(newExpiry.getTime()).toBeGreaterThan(now + PORTAL_SESSION_IDLE_TTL_MS - 5_000);
|
||||||
|
expect(newExpiry.getTime()).toBeLessThanOrEqual(now + PORTAL_SESSION_IDLE_TTL_MS + 5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a slow-wizard customer authorized past the original mint TTL", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Original mint window has fully elapsed in wall-clock terms, but the session
|
||||||
|
// was slid forward on the previous request, so it is still valid now.
|
||||||
|
selectSessionRow = {
|
||||||
|
id: SESSION_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
status: "active",
|
||||||
|
reason: "sso-bridge",
|
||||||
|
startedAt: new Date(now - 35 * 60 * 1000),
|
||||||
|
expiresAt: new Date(now + 10 * 60 * 1000), // previously slid
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.clientId).toBe(CLIENT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never extends beyond startedAt + MAX_LIFETIME (bounded)", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Session started right at the absolute cap boundary minus a hair.
|
||||||
|
const startedAt = now - (PORTAL_SESSION_MAX_LIFETIME_MS - 5 * 60 * 1000);
|
||||||
|
selectSessionRow = {
|
||||||
|
id: SESSION_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
status: "active",
|
||||||
|
reason: "sso-bridge",
|
||||||
|
startedAt: new Date(startedAt),
|
||||||
|
expiresAt: new Date(now + 60 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sessionUpdates).toHaveLength(1);
|
||||||
|
const newExpiry = (sessionUpdates[0]!.expiresAt as Date).getTime();
|
||||||
|
// Capped at startedAt + MAX_LIFETIME, NOT now + IDLE_TTL.
|
||||||
|
expect(newExpiry).toBeLessThanOrEqual(startedAt + PORTAL_SESSION_MAX_LIFETIME_MS + 1_000);
|
||||||
|
expect(newExpiry).toBeGreaterThan(now); // still extends at least a little
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT slide a staff-initiated impersonation session (no regression)", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
selectSessionRow = {
|
||||||
|
id: SESSION_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
status: "active",
|
||||||
|
reason: "manager reviewing booking", // staff-console reason, free text
|
||||||
|
startedAt: new Date(now - 5 * 60 * 1000),
|
||||||
|
expiresAt: new Date(now + 20 * 60 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sessionUpdates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects an already-expired session (no resurrection)", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
selectSessionRow = {
|
||||||
|
id: SESSION_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
status: "active",
|
||||||
|
reason: "sso-bridge",
|
||||||
|
startedAt: new Date(now - 40 * 60 * 1000),
|
||||||
|
expiresAt: new Date(now - 60 * 1000), // already lapsed
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(sessionUpdates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the write when the extension is below the slide threshold", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Already slid this minute: expiresAt is essentially now + IDLE_TTL already.
|
||||||
|
selectSessionRow = {
|
||||||
|
id: SESSION_ID,
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
status: "active",
|
||||||
|
reason: "sso-bridge",
|
||||||
|
startedAt: new Date(now - 2 * 60 * 1000),
|
||||||
|
expiresAt: new Date(now + PORTAL_SESSION_IDLE_TTL_MS - 2_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ping({ "X-Impersonation-Session-Id": SESSION_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sessionUpdates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
estimateLeg,
|
estimateLeg,
|
||||||
nearestNeighborOrder,
|
nearestNeighborOrder,
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
|
detectScheduleConflicts,
|
||||||
|
recomputeLegsForOrder,
|
||||||
MAX_STOPS_PER_ROUTE,
|
MAX_STOPS_PER_ROUTE,
|
||||||
type RouteStopInput,
|
type RouteStopInput,
|
||||||
} from "../services/routeOptimization.js";
|
} from "../services/routeOptimization.js";
|
||||||
@@ -182,3 +184,152 @@ describe("optimizeRoute — >25 stop chunking", () => {
|
|||||||
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,32 @@ export interface PortalEnv {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idle lifetime of an SSO-bridge portal impersonation session. Each authenticated
|
||||||
|
* portal request slides `expiresAt` forward to `now + IDLE_TTL`, so an actively-used
|
||||||
|
* session (e.g. a customer working through the multi-step Book New wizard) never
|
||||||
|
* lapses mid-flow. Matches the staff-console impersonation idle window
|
||||||
|
* (SESSION_TIMEOUT_MINUTES in routes/impersonation.ts). (GRO-2234)
|
||||||
|
*/
|
||||||
|
export const PORTAL_SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute cap on a single SSO-bridge portal session's lifetime, measured from
|
||||||
|
* `startedAt`. Sliding can never extend a session beyond this bound, keeping the
|
||||||
|
* impersonation model bounded regardless of how long a customer keeps the tab
|
||||||
|
* active. Deliberately tighter than the previous static 24h mint. (GRO-2234)
|
||||||
|
*/
|
||||||
|
export const PORTAL_SESSION_MAX_LIFETIME_MS = 8 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum extension before we issue a sliding-expiration write. Avoids a DB write
|
||||||
|
* on every rapid successive request — at most one slide per minute per session.
|
||||||
|
*/
|
||||||
|
const PORTAL_SESSION_SLIDE_THRESHOLD_MS = 60 * 1000;
|
||||||
|
|
||||||
|
/** Reason marker for sessions minted by the Better Auth -> portal bridge. */
|
||||||
|
const SSO_BRIDGE_REASON = "sso-bridge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
||||||
* Must be applied to all portal routes.
|
* Must be applied to all portal routes.
|
||||||
@@ -16,6 +42,12 @@ export interface PortalEnv {
|
|||||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
||||||
* Returns 401 if session is invalid/missing/expired.
|
* Returns 401 if session is invalid/missing/expired.
|
||||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
||||||
|
*
|
||||||
|
* Sliding expiration (GRO-2234): for SSO-bridge sessions, each successful request
|
||||||
|
* extends `expiresAt` to `now + PORTAL_SESSION_IDLE_TTL_MS`, bounded by
|
||||||
|
* `startedAt + PORTAL_SESSION_MAX_LIFETIME_MS`. Staff-initiated impersonation
|
||||||
|
* sessions (any other `reason`) are left untouched, preserving their existing
|
||||||
|
* console-enforced timeout behavior.
|
||||||
*/
|
*/
|
||||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
@@ -24,16 +56,29 @@ export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
const [session] = await db
|
const [session] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(impersonationSessions)
|
.from(impersonationSessions)
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
if (!session || session.expiresAt <= now) {
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sliding expiration for SSO-bridge portal sessions only (GRO-2234).
|
||||||
|
if (session.reason === SSO_BRIDGE_REASON) {
|
||||||
|
const maxExpiry = session.startedAt.getTime() + PORTAL_SESSION_MAX_LIFETIME_MS;
|
||||||
|
const slidExpiry = Math.min(now.getTime() + PORTAL_SESSION_IDLE_TTL_MS, maxExpiry);
|
||||||
|
if (slidExpiry - session.expiresAt.getTime() >= PORTAL_SESSION_SLIDE_THRESHOLD_MS) {
|
||||||
|
await db
|
||||||
|
.update(impersonationSessions)
|
||||||
|
.set({ expiresAt: new Date(slidExpiry) })
|
||||||
|
.where(eq(impersonationSessions.id, session.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.set("portalClientId", session.clientId);
|
c.set("portalClientId", session.clientId);
|
||||||
c.set("portalSessionId", session.id);
|
c.set("portalSessionId", session.id);
|
||||||
await next();
|
await next();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator";
|
|||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, inArray } from "@groombook/db";
|
import { eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
import { validatePortalSession, PORTAL_SESSION_IDLE_TTL_MS } from "../middleware/portalSession.js";
|
||||||
import { portalAudit } from "../middleware/portalAudit.js";
|
import { portalAudit } from "../middleware/portalAudit.js";
|
||||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
import type { PortalEnv } from "../middleware/portalSession.js";
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => {
|
|||||||
staffId,
|
staffId,
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
reason: "sso-bridge",
|
reason: "sso-bridge",
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
+181
-3
@@ -19,7 +19,10 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|||||||
import {
|
import {
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
resolveRouteGoogleApiKey,
|
resolveRouteGoogleApiKey,
|
||||||
|
detectScheduleConflicts,
|
||||||
|
recomputeLegsForOrder,
|
||||||
type RouteStopInput,
|
type RouteStopInput,
|
||||||
|
type StopConflictFlags,
|
||||||
} from "../services/routeOptimization.js";
|
} from "../services/routeOptimization.js";
|
||||||
|
|
||||||
export const routesRouter = new Hono<AppEnv>();
|
export const routesRouter = new Hono<AppEnv>();
|
||||||
@@ -34,6 +37,11 @@ const optimizeBodySchema = z.object({
|
|||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
|
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 /
|
* 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
|
* manager authorization rule. Groomers may only act on their own route; if a
|
||||||
@@ -96,6 +104,31 @@ async function loadRouteStops(db: ReturnType<typeof getDb>, routeId: string) {
|
|||||||
.orderBy(asc(routeStops.stopOrder));
|
.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=
|
* GET /api/routes/daily?staffId=&date=
|
||||||
* Fetches (creating a draft if absent) the daily route for a groomer, with all
|
* Fetches (creating a draft if absent) the daily route for a groomer, with all
|
||||||
@@ -130,7 +163,13 @@ routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stops = await loadRouteStops(db, route!.id);
|
const stops = await loadRouteStops(db, route!.id);
|
||||||
return c.json({ route, stops });
|
const annotated = annotateConflicts(stops);
|
||||||
|
return c.json({
|
||||||
|
route,
|
||||||
|
stops: annotated.stops,
|
||||||
|
hasConflicts: annotated.hasConflicts,
|
||||||
|
conflictCount: annotated.conflictCount,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,7 +301,9 @@ routesRouter.post(
|
|||||||
s.travelDistanceKmFromPrev == null
|
s.travelDistanceKmFromPrev == null
|
||||||
? null
|
? null
|
||||||
: s.travelDistanceKmFromPrev.toFixed(2),
|
: s.travelDistanceKmFromPrev.toFixed(2),
|
||||||
bufferMins,
|
// Buffer applies between consecutive stops; the first stop has no
|
||||||
|
// predecessor, so it carries no travel buffer.
|
||||||
|
bufferMins: i === 0 ? 0 : bufferMins,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -271,9 +312,12 @@ routesRouter.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stops = await loadRouteStops(db, route.id);
|
const stops = await loadRouteStops(db, route.id);
|
||||||
|
const annotated = annotateConflicts(stops);
|
||||||
return c.json({
|
return c.json({
|
||||||
route,
|
route,
|
||||||
stops,
|
stops: annotated.stops,
|
||||||
|
hasConflicts: annotated.hasConflicts,
|
||||||
|
conflictCount: annotated.conflictCount,
|
||||||
provider: optimized.provider,
|
provider: optimized.provider,
|
||||||
chunked: optimized.chunked,
|
chunked: optimized.chunked,
|
||||||
subRouteCount: optimized.subRouteCount,
|
subRouteCount: optimized.subRouteCount,
|
||||||
@@ -282,3 +326,137 @@ routesRouter.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -411,3 +411,103 @@ export async function resolveRouteGoogleApiKey(
|
|||||||
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
|
const fromEnv = process.env.GOOGLE_MAPS_API_KEY?.trim();
|
||||||
return fromEnv ? fromEnv : null;
|
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