diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 48c5d54..d590556 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -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.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.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 @@ -368,7 +370,7 @@ A groomer's daily route is one row per `(staffId, routeDate)` in `groomer_routes | # | 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). 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.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` | @@ -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.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": ["", …] }` (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:** diff --git a/src/__tests__/portalSessionSliding.test.ts b/src/__tests__/portalSessionSliding.test.ts new file mode 100644 index 0000000..4125548 --- /dev/null +++ b/src/__tests__/portalSessionSliding.test.ts @@ -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 | null = null; +let sessionUpdates: Record[] = []; + +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) => { + sessionUpdates.push(vals); + return { where: () => Promise.resolve(undefined) }; + }, + }), + }), + impersonationSessions, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +const app = new Hono(); +app.use("/portal/*", validatePortalSession); +app.get("/portal/ping", (c) => c.json({ ok: true, clientId: c.get("portalClientId") })); + +function ping(headers?: Record) { + 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); + }); +}); diff --git a/src/__tests__/routeOptimization.test.ts b/src/__tests__/routeOptimization.test.ts index 49877d4..706924a 100644 --- a/src/__tests__/routeOptimization.test.ts +++ b/src/__tests__/routeOptimization.test.ts @@ -4,6 +4,8 @@ import { estimateLeg, nearestNeighborOrder, optimizeRoute, + detectScheduleConflicts, + recomputeLegsForOrder, MAX_STOPS_PER_ROUTE, type RouteStopInput, } 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); }); }); + +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, + }); + }); +}); diff --git a/src/middleware/portalSession.ts b/src/middleware/portalSession.ts index 6dfdb03..055c3fe 100644 --- a/src/middleware/portalSession.ts +++ b/src/middleware/portalSession.ts @@ -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. * 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(). * Returns 401 if session is invalid/missing/expired. * 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 = async (c, next) => { const sessionId = c.req.header("X-Impersonation-Session-Id"); @@ -24,16 +56,29 @@ export const validatePortalSession: MiddlewareHandler = async (c, nex } const db = getDb(); + const now = new Date(); const [session] = await db .select() .from(impersonationSessions) .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) .limit(1); - if (!session || session.expiresAt <= new Date()) { + if (!session || session.expiresAt <= now) { 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("portalSessionId", session.id); await next(); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index aa1593a..d614e51 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, inArray } 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 type { PortalEnv } from "../middleware/portalSession.js"; @@ -129,7 +129,7 @@ portalRouter.post("/session-from-auth", async (c) => { staffId, clientId: client.id, reason: "sso-bridge", - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + PORTAL_SESSION_IDLE_TTL_MS), }) .returning(); diff --git a/src/routes/routes.ts b/src/routes/routes.ts index e9a1d41..3bf905a 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -19,7 +19,10 @@ 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(); @@ -34,6 +37,11 @@ const optimizeBodySchema = z.object({ 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 @@ -96,6 +104,31 @@ async function loadRouteStops(db: ReturnType, routeId: string) { .orderBy(asc(routeStops.stopOrder)); } +type LoadedRouteStop = Awaited>[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; + 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 @@ -130,7 +163,13 @@ routesRouter.get("/daily", zValidator("query", dailyQuerySchema), async (c) => { } 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 ? null : 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 annotated = annotateConflicts(stops); return c.json({ route, - stops, + stops: annotated.stops, + hasConflicts: annotated.hasConflicts, + conflictCount: annotated.conflictCount, provider: optimized.provider, chunked: optimized.chunked, 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(); + 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, + }); + } +); diff --git a/src/services/routeOptimization.ts b/src/services/routeOptimization.ts index 14de121..53ffe90 100644 --- a/src/services/routeOptimization.ts +++ b/src/services/routeOptimization.ts @@ -411,3 +411,103 @@ export async function resolveRouteGoogleApiKey( 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, + }; + }); +}