diff --git a/src/app/api/v1/tracker/poll/route.test.ts b/src/app/api/v1/tracker/poll/route.test.ts new file mode 100644 index 0000000..d875d1a --- /dev/null +++ b/src/app/api/v1/tracker/poll/route.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const assertAgentAuth = vi.fn(); +const pollFleet = vi.fn(); +const rollUpStops = vi.fn(); +const pruneOldPositions = vi.fn(); + +vi.mock("@/lib/server/agent-auth", () => ({ + assertAgentAuth: (req: NextRequest) => assertAgentAuth(req), +})); + +vi.mock("@/lib/server/vehicle-trail-service", () => ({ + pollFleet: () => pollFleet(), + rollUpStops: (date: string) => rollUpStops(date), + pruneOldPositions: (days: number) => pruneOldPositions(days), + RETENTION_DAYS: 35, +})); + +import { POST } from "./route"; + +function request(body?: unknown): NextRequest { + return new NextRequest("http://localhost:4001/api/v1/tracker/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +describe("POST /api/v1/tracker/poll", () => { + beforeEach(() => { + vi.clearAllMocks(); + assertAgentAuth.mockImplementation(() => {}); + pollFleet.mockResolvedValue({ fetched: 3, stored: 2, duplicates: 1, unusable: 0 }); + rollUpStops.mockResolvedValue({ vehicles: 2, stops: 5 }); + pruneOldPositions.mockResolvedValue({ deleted: 120 }); + }); + + it("runs a poll cycle and reports what it stored", async () => { + const res = await POST(request({})); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + mode: "poll", + fetched: 3, + stored: 2, + duplicates: 1, + unusable: 0, + }); + }); + + it("treats an empty body as a poll — n8n's Schedule Trigger sends none", async () => { + const res = await POST(request()); + + expect(res.status).toBe(200); + expect(pollFleet).toHaveBeenCalledOnce(); + }); + + it("rejects an unauthenticated caller before doing any work", async () => { + assertAgentAuth.mockImplementation(() => { + const error = new Error("Unauthorized") as Error & { status?: number }; + error.status = 401; + throw error; + }); + + const res = await POST(request({})); + + expect(res.status).toBe(401); + expect(pollFleet).not.toHaveBeenCalled(); + }); + + it("rolls up yesterday and prunes in nightly mode", async () => { + const res = await POST(request({ mode: "nightly" })); + const json = await res.json(); + + const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + expect(rollUpStops).toHaveBeenCalledWith(yesterday); + expect(pruneOldPositions).toHaveBeenCalledWith(35); + expect(json).toMatchObject({ + mode: "nightly", + date: yesterday, + rollup: { vehicles: 2, stops: 5 }, + pruned: { deleted: 120 }, + }); + expect(pollFleet).not.toHaveBeenCalled(); + }); + + it("accepts an explicit date so a day can be backfilled", async () => { + await POST(request({ mode: "nightly", date: "2026-09-01" })); + expect(rollUpStops).toHaveBeenCalledWith("2026-09-01"); + }); + + it("surfaces a tracker failure as a 500 with the reason", async () => { + pollFleet.mockRejectedValue(new Error("FleetAI getTokenBaseLiveData: Invalid parameter")); + + const res = await POST(request({})); + + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Invalid parameter/); + }); + + it("does not prune when the rollup fails", async () => { + // Pruning after a failed rollup would delete breadcrumbs whose stops + // were never derived — the one irreversible thing this route does. + rollUpStops.mockRejectedValue(new Error("PB unreachable")); + + const res = await POST(request({ mode: "nightly" })); + + expect(res.status).toBe(500); + expect(pruneOldPositions).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/v1/tracker/poll/route.ts b/src/app/api/v1/tracker/poll/route.ts new file mode 100644 index 0000000..6ccb945 --- /dev/null +++ b/src/app/api/v1/tracker/poll/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { + pollFleet, + pruneOldPositions, + rollUpStops, + RETENTION_DAYS, +} from "@/lib/server/vehicle-trail-service"; + +/** + * POST /api/v1/tracker/poll + * + * The breadcrumb poller's entry point. Driven by an n8n Schedule + * Trigger — machine-called, so it sits behind `assertAgentAuth` + * (Bearer `AGENT_API_KEY`), the same pattern as the existing cron + * workflows. + * + * Two modes: + * + * {} → one poll cycle. Cron: every 5 min, + * working hours only. The window lives + * in the cron expression rather than in + * app settings, so disabling the n8n + * workflow is the off switch: + * `*​/5 6-18 * * 1-5` + * + * { "mode": "nightly" } → roll yesterday's trail into + * `rep_stops`, then prune breadcrumbs + * past the retention window. Cron: + * `30 2 * * *` + * + * `date` overrides which day the nightly rollup processes, for + * backfills. Rollup is idempotent — re-running a day replaces its + * stops rather than duplicating them. + * + * Polling is deliberately cheap: one FleetAI call covers the whole + * fleet, and only genuinely new GPS fixes are written. A cycle where + * every vehicle is parked stores nothing, which is the expected steady + * state overnight and over lunch. + */ +export async function POST(req: NextRequest) { + try { + assertAgentAuth(req); + + // An empty body is the common case (n8n's Schedule Trigger sends + // nothing), so a parse failure means "no options", not an error. + let body: { mode?: string; date?: string; retentionDays?: number } = {}; + try { + const parsed = await req.json(); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + body = parsed; + } + } catch { + /* no body */ + } + + if (body.mode === "nightly") { + const day = + body.date ?? + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + const rollup = await rollUpStops(day); + const pruned = await pruneOldPositions(body.retentionDays ?? RETENTION_DAYS); + + return NextResponse.json({ mode: "nightly", date: day, rollup, pruned }); + } + + const result = await pollFleet(); + return NextResponse.json({ mode: "poll", ...result }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Tracker poll failed"; + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/lib/server/tracker-service.test.ts b/src/lib/server/tracker-service.test.ts index 311f41a..6b91810 100644 --- a/src/lib/server/tracker-service.test.ts +++ b/src/lib/server/tracker-service.test.ts @@ -412,6 +412,29 @@ describe("FakeTrackerAdapter", () => { const position = await adapter.getCurrentPosition("FAKE-001-GP"); expect(position?.fixAgeSeconds).toBe(3600); }); + + it("reports the same fix time across polls, like a parked tracker", async () => { + // The poller's dedupe keys on (vehicle, fixedAt). A fake that minted + // a fresh timestamp per call would make every cycle look like new + // movement and store a duplicate row — which is exactly what + // happened against the real database before this was quantised. + const adapter = new FakeTrackerAdapter(); + + const first = await adapter.getFleetPositions(); + const second = await adapter.getFleetPositions(); + + expect(first[0].fixedAt).toBe(second[0].fixedAt); + }); + + it("advances the fix time once per bucket", async () => { + const adapter = new FakeTrackerAdapter({ fixBucketMs: 1000 }); + + const before = (await adapter.getFleetPositions())[0].fixedAt; + await new Promise((resolve) => setTimeout(resolve, 1100)); + const after = (await adapter.getFleetPositions())[0].fixedAt; + + expect(after).not.toBe(before); + }); }); describe("readFleetAiConfig", () => { diff --git a/src/lib/server/tracker-service.ts b/src/lib/server/tracker-service.ts index 2c33f8f..245f383 100644 --- a/src/lib/server/tracker-service.ts +++ b/src/lib/server/tracker-service.ts @@ -475,16 +475,36 @@ function extractRows(body: unknown): Record[] { */ export const FAKE_VEHICLE_POSITION = { lat: -26.348597, lon: 27.375136 } as const; +/** + * How coarsely the fake quantises its simulated GPS fix time. + * + * This is load-bearing, not cosmetic. A real tracker on a parked + * vehicle keeps reporting the **same** `GPSActualTime` until it moves, + * which is precisely the case the poller's dedupe exists to handle. A + * fake that derived its fix time from `Date.now()` on every call would + * emit a brand-new fix on every poll and could never reproduce that — + * it would make the poller look correct while storing a duplicate row + * every cycle. (It did exactly that, until an end-to-end run caught it.) + * + * Quantising to the poll interval means repeated polls inside one + * window see an identical fix, and the fix advances once per window — + * behaving like a parked vehicle that occasionally re-reports. + */ +export const FAKE_FIX_BUCKET_MS = 5 * 60 * 1000; + export class FakeTrackerAdapter implements TrackerAdapter { private readonly vehicles: TrackerVehicle[]; private readonly position: { lat: number; lon: number }; private readonly fixAgeSecondsValue: number; + private readonly bucketMs: number; constructor( options: { vehicles?: TrackerVehicle[]; position?: { lat: number; lon: number }; fixAgeSeconds?: number; + /** Quantisation window for the simulated fix time. */ + fixBucketMs?: number; } = {} ) { this.vehicles = options.vehicles ?? [ @@ -492,6 +512,7 @@ export class FakeTrackerAdapter implements TrackerAdapter { ]; this.position = options.position ?? { ...FAKE_VEHICLE_POSITION }; this.fixAgeSecondsValue = options.fixAgeSeconds ?? 30; + this.bucketMs = options.fixBucketMs ?? FAKE_FIX_BUCKET_MS; } async listVehicles(): Promise { @@ -513,7 +534,11 @@ export class FakeTrackerAdapter implements TrackerAdapter { } private simulate(vehicle: TrackerVehicle): VehiclePosition { - const fixedAt = new Date(Date.now() - this.fixAgeSecondsValue * 1000).toISOString(); + // Quantised, not `Date.now()` — see FAKE_FIX_BUCKET_MS. + const anchor = Date.now() - this.fixAgeSecondsValue * 1000; + const fixedAt = new Date( + this.bucketMs > 0 ? Math.floor(anchor / this.bucketMs) * this.bucketMs : anchor + ).toISOString(); return { vehicleNo: vehicle.vehicleNo, diff --git a/src/lib/server/vehicle-trail-service.test.ts b/src/lib/server/vehicle-trail-service.test.ts new file mode 100644 index 0000000..517c308 --- /dev/null +++ b/src/lib/server/vehicle-trail-service.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi } from "vitest"; +import { + pollFleet, + pruneOldPositions, + rollUpStops, + normaliseVehicleNo, + loadVehicleRepMap, + pbDate, + RETENTION_DAYS, +} from "./vehicle-trail-service"; +import type { TrackerAdapter, VehiclePosition } from "./tracker-service"; + +/* ============================================================================ + * Fixtures + a minimal PocketBase stand-in + * ========================================================================== */ + +function position(over: Partial = {}): VehiclePosition { + return { + vehicleNo: "ABC123GP", + imei: "111", + lat: -26.348597, + lon: 27.375136, + address: null, + speedKmh: 0, + status: "STOP", + ignition: false, + odometerKm: null, + fixedAt: "2026-09-05T07:00:00.000Z", + fixAgeSeconds: 30, + source: "fleetai", + raw: {}, + ...over, + }; +} + +function adapterReturning(positions: VehiclePosition[]): TrackerAdapter { + return { + listVehicles: vi.fn(async () => []), + getCurrentPosition: vi.fn(async () => null), + getFleetPositions: vi.fn(async () => positions), + }; +} + +type Row = Record & { id: string }; + +/** + * Just enough PocketBase to exercise this service: per-collection row + * stores, `create`/`delete`, and the two read shapes the service uses + * (`getFullList` and a sorted `getFirstListItem`). Filters are evaluated + * only for the specific predicates the service emits, which keeps the + * fake honest without reimplementing PB's filter language. + */ +function fakePb(seed: Record = {}) { + const store: Record = { + users: [], + rep_vehicle_positions: [], + rep_stops: [], + ...seed, + }; + let nextId = 1; + + function matches(row: Row, filter?: string): boolean { + if (!filter) return true; + return filter.split("&&").every((clause) => { + const c = clause.trim(); + + const eq = c.match(/^(\w+)\s*=\s*"(.*)"$/); + if (eq) return String(row[eq[1]] ?? "") === eq[2]; + + const cmp = c.match(/^(\w+)\s*(>=|<)\s*"(.*)"$/); + if (cmp) { + const value = new Date(String(row[cmp[1]] ?? "")).getTime(); + const bound = new Date(cmp[3]).getTime(); + return cmp[2] === ">=" ? value >= bound : value < bound; + } + + throw new Error(`fakePb: unsupported filter clause ${c}`); + }); + } + + const api = { + store, + collection(name: string) { + const rows = () => (store[name] ??= []); + return { + async getFullList(opts?: { filter?: string; sort?: string }): Promise { + let out = rows().filter((r) => matches(r, opts?.filter)); + if (opts?.sort === "fixed_at") { + out = [...out].sort( + (a, b) => + new Date(String(a.fixed_at)).getTime() - + new Date(String(b.fixed_at)).getTime() + ); + } + return out as unknown as T[]; + }, + async getFirstListItem( + filter: string, + opts?: { sort?: string } + ): Promise { + let out = rows().filter((r) => matches(r, filter)); + if (opts?.sort === "-fixed_at") { + out = [...out].sort( + (a, b) => + new Date(String(b.fixed_at)).getTime() - + new Date(String(a.fixed_at)).getTime() + ); + } + if (out.length === 0) { + const err = new Error("not found") as Error & { status?: number }; + err.status = 404; + throw err; + } + return out[0] as unknown as T; + }, + async create(data: Record) { + const row = { ...data, id: `rec-${nextId++}` } as Row; + rows().push(row); + return row; + }, + async delete(id: string) { + store[name] = rows().filter((r) => r.id !== id); + return true; + }, + }; + }, + }; + + return api as unknown as Awaited< + ReturnType + > & { store: Record }; +} + +/* ============================================================================ + * Tests + * ========================================================================== */ + +describe("pbDate", () => { + it("uses a space separator, not the ISO T", () => { + // Verified against the live instance: PocketBase's filter parser + // silently matches NOTHING for `fixed_at >= "…T00:00:00.000Z"` and + // matches correctly for the space-separated form. No error either + // way, so the rollup and prune would quietly no-op forever. + expect(pbDate(new Date("2026-09-06T00:00:00.000Z"))).toBe( + "2026-09-06 00:00:00.000Z" + ); + expect(pbDate(new Date("2026-09-06T00:00:00.000Z"))).not.toContain("T"); + }); +}); + +describe("normaliseVehicleNo", () => { + it("ignores spacing and case so an operator can type it either way", () => { + expect(normaliseVehicleNo("ABC 123 GP")).toBe("ABC123GP"); + expect(normaliseVehicleNo("abc123gp")).toBe("ABC123GP"); + }); + + it("returns an empty string for nothing", () => { + expect(normaliseVehicleNo(undefined)).toBe(""); + expect(normaliseVehicleNo("")).toBe(""); + }); +}); + +describe("loadVehicleRepMap", () => { + it("maps registrations to reps and skips users with no vehicle", async () => { + const pb = fakePb({ + users: [ + { id: "u-tiaan", tracker_vehicle_id: "ABC 123 GP" }, + { id: "u-chris", tracker_vehicle_id: "xyz789gp" }, + { id: "u-office", tracker_vehicle_id: "" }, + { id: "u-none" }, + ] as Row[], + }); + + const map = await loadVehicleRepMap(pb); + + expect(map.get("ABC123GP")).toBe("u-tiaan"); + expect(map.get("XYZ789GP")).toBe("u-chris"); + expect(map.size).toBe(2); + }); +}); + +describe("pollFleet", () => { + it("stores a new fix and attaches the rep who drives that vehicle", async () => { + const pb = fakePb({ users: [{ id: "u-tiaan", tracker_vehicle_id: "ABC123GP" }] as Row[] }); + + const result = await pollFleet({ pb, adapter: adapterReturning([position()]) }); + + expect(result).toMatchObject({ fetched: 1, stored: 1, duplicates: 0 }); + expect(pb.store.rep_vehicle_positions[0]).toMatchObject({ + rep: "u-tiaan", + vehicle_no: "ABC123GP", + coordinates: { lat: -26.348597, lon: 27.375136 }, + fixed_at: "2026-09-05T07:00:00.000Z", + ignition: false, + }); + }); + + it("skips a fix it already holds — the whole point of the dedupe", async () => { + // A parked tracker stops emitting new fixes, so consecutive polls + // return an identical position. Without this, a 5-minute cycle + // would write the same row a dozen times an hour. + const pb = fakePb({ users: [] }); + const adapter = adapterReturning([position()]); + + const first = await pollFleet({ pb, adapter }); + const second = await pollFleet({ pb, adapter }); + + expect(first.stored).toBe(1); + expect(second).toMatchObject({ fetched: 1, stored: 0, duplicates: 1 }); + expect(pb.store.rep_vehicle_positions).toHaveLength(1); + }); + + it("stores a genuinely newer fix for the same vehicle", async () => { + const pb = fakePb({ users: [] }); + + await pollFleet({ pb, adapter: adapterReturning([position()]) }); + const second = await pollFleet({ + pb, + adapter: adapterReturning([position({ fixedAt: "2026-09-05T07:05:00.000Z" })]), + }); + + expect(second.stored).toBe(1); + expect(pb.store.rep_vehicle_positions).toHaveLength(2); + }); + + it("does not treat an older fix as new", async () => { + // Clock skew or a retry can hand us a stale fix after a fresh one. + const pb = fakePb({ users: [] }); + + await pollFleet({ pb, adapter: adapterReturning([position({ fixedAt: "2026-09-05T07:05:00.000Z" })]) }); + const second = await pollFleet({ pb, adapter: adapterReturning([position()]) }); + + expect(second).toMatchObject({ stored: 0, duplicates: 1 }); + }); + + it("stores an unmapped vehicle with a blank rep rather than dropping it", async () => { + // The trail should already exist on the day someone finally fills in + // that rep's tracker_vehicle_id. + const pb = fakePb({ users: [] }); + + await pollFleet({ pb, adapter: adapterReturning([position()]) }); + + expect(pb.store.rep_vehicle_positions[0].rep).toBe(""); + }); + + it("drops a fix with no usable timestamp instead of guessing one", async () => { + const pb = fakePb({ users: [] }); + + const result = await pollFleet({ + pb, + adapter: adapterReturning([ + position({ fixedAt: null }), + position({ vehicleNo: "XYZ789GP", fixedAt: "not a date" }), + ]), + }); + + expect(result).toMatchObject({ fetched: 2, stored: 0, unusable: 2 }); + expect(pb.store.rep_vehicle_positions).toHaveLength(0); + }); + + it("deduplicates within a single response", async () => { + const pb = fakePb({ users: [] }); + + const result = await pollFleet({ + pb, + adapter: adapterReturning([position(), position()]), + }); + + expect(result).toMatchObject({ stored: 1, duplicates: 1 }); + }); + + it("handles an empty fleet without touching PocketBase", async () => { + const pb = fakePb({ users: [] }); + const result = await pollFleet({ pb, adapter: adapterReturning([]) }); + + expect(result).toEqual({ fetched: 0, stored: 0, duplicates: 0, unusable: 0 }); + }); + + it("keeps vehicles independent of each other", async () => { + const pb = fakePb({ users: [] }); + const adapter = adapterReturning([ + position(), + position({ vehicleNo: "XYZ789GP", fixedAt: "2026-09-05T06:00:00.000Z" }), + ]); + + const result = await pollFleet({ pb, adapter }); + + expect(result.stored).toBe(2); + }); +}); + +describe("rollUpStops", () => { + /** Builds a stationary trail: n fixes, `stepMinutes` apart. */ + function parkedTrail(count: number, stepMinutes: number): Row[] { + return Array.from({ length: count }, (_, i) => ({ + id: `p-${i}`, + rep: "u-tiaan", + vehicle_no: "ABC123GP", + coordinates: { lat: -26.4, lon: 27.4 }, + fixed_at: new Date(Date.UTC(2026, 8, 5, 7, 0, 0) + i * stepMinutes * 60_000).toISOString(), + speed_kmh: 0, + ignition: false, + })); + } + + it("writes a stop for a vehicle parked at one place", async () => { + const pb = fakePb({ rep_vehicle_positions: parkedTrail(4, 5) }); + + const result = await rollUpStops("2026-09-05", { pb }); + + expect(result).toEqual({ vehicles: 1, stops: 1 }); + expect(pb.store.rep_stops[0]).toMatchObject({ + rep: "u-tiaan", + vehicle_no: "ABC123GP", + duration_minutes: 15, + fix_count: 4, + ignition_off: true, + }); + }); + + it("is idempotent — a re-run replaces the day's stops rather than doubling them", async () => { + const pb = fakePb({ rep_vehicle_positions: parkedTrail(4, 5) }); + + await rollUpStops("2026-09-05", { pb }); + await rollUpStops("2026-09-05", { pb }); + + expect(pb.store.rep_stops).toHaveLength(1); + }); + + it("ignores positions outside the requested day", async () => { + const pb = fakePb({ + rep_vehicle_positions: [ + ...parkedTrail(4, 5), + { + id: "other-day", + rep: "u-tiaan", + vehicle_no: "ABC123GP", + coordinates: { lat: -26.4, lon: 27.4 }, + fixed_at: "2026-09-04T09:00:00.000Z", + ignition: false, + }, + ] as Row[], + }); + + const result = await rollUpStops("2026-09-05", { pb }); + expect(result.stops).toBe(1); + expect(pb.store.rep_stops[0].fix_count).toBe(4); + }); + + it("rejects an unparseable date rather than scanning an empty range", async () => { + const pb = fakePb(); + await expect(rollUpStops("not-a-date", { pb })).rejects.toThrow(/invalid date/); + }); + + it("returns zero stops for a day with no trail", async () => { + const pb = fakePb(); + expect(await rollUpStops("2026-09-05", { pb })).toEqual({ vehicles: 0, stops: 0 }); + }); +}); + +describe("pruneOldPositions", () => { + it("deletes only breadcrumbs past the retention window", async () => { + const now = new Date("2026-09-05T12:00:00.000Z"); + const pb = fakePb({ + rep_vehicle_positions: [ + { id: "old", vehicle_no: "A", fixed_at: "2026-07-01T00:00:00.000Z" }, + { id: "fresh", vehicle_no: "A", fixed_at: "2026-09-04T00:00:00.000Z" }, + ] as Row[], + }); + + const result = await pruneOldPositions(RETENTION_DAYS, { pb, now }); + + expect(result).toEqual({ deleted: 1 }); + expect(pb.store.rep_vehicle_positions.map((r) => r.id)).toEqual(["fresh"]); + }); + + it("leaves derived stops alone — they are the durable record", async () => { + const now = new Date("2026-09-05T12:00:00.000Z"); + const pb = fakePb({ + rep_vehicle_positions: [ + { id: "old", vehicle_no: "A", fixed_at: "2026-07-01T00:00:00.000Z" }, + ] as Row[], + rep_stops: [{ id: "s1", vehicle_no: "A", started_at: "2026-07-01T08:00:00.000Z" }] as Row[], + }); + + await pruneOldPositions(RETENTION_DAYS, { pb, now }); + + expect(pb.store.rep_stops).toHaveLength(1); + }); + + it("defaults to a 35-day window", () => { + expect(RETENTION_DAYS).toBe(35); + }); +}); diff --git a/src/lib/server/vehicle-trail-service.ts b/src/lib/server/vehicle-trail-service.ts new file mode 100644 index 0000000..ff01350 --- /dev/null +++ b/src/lib/server/vehicle-trail-service.ts @@ -0,0 +1,327 @@ +/** + * Vehicle breadcrumb trail — WFDASH's own position history. + * + * Netstar FleetAI has **no position-history endpoint** (see + * `lib/server/tracker-service.ts`), so if we want to answer "where was + * this rep's vehicle at 10:20 last Tuesday" we have to record it + * ourselves. An n8n Schedule Trigger hits + * `POST /api/v1/tracker/poll` every 5 minutes during working hours and + * this service does the work. + * + * The trail is **not** on the check-in critical path. A rep checks in + * with their phone's GPS, which works with no signal and points at the + * person rather than the bakkie. The trail exists to corroborate those + * check-ins after the fact, and to answer time-spent-per-site. + * + * Retention: raw positions are pruned at `RETENTION_DAYS`; the stops + * derived from them (`rep_stops`) are kept indefinitely, because a stop + * is small and is what every report actually asks about. + */ + +import { getAdminPocketBase } from "./pocketbase-admin"; +import { getTrackerAdapter, type TrackerAdapter, type VehiclePosition } from "./tracker-service"; +import { detectStops, type Breadcrumb } from "@/lib/visit-stops"; + +const POSITIONS_COLLECTION = "rep_vehicle_positions"; +const STOPS_COLLECTION = "rep_stops"; +const USERS_COLLECTION = "users"; + +/** + * Days of raw breadcrumbs to keep. 35 covers a full "this month vs last + * month" comparison plus a buffer. At ~5 vehicles this is ~21k rows — + * the existing `driver_locations` table already holds 54k. + */ +export const RETENTION_DAYS = 35; + +type AdminPocketBase = Awaited>; + +export interface PollResult { + /** Positions FleetAI returned. */ + fetched: number; + /** New rows written — i.e. genuinely new GPS fixes. */ + stored: number; + /** Positions skipped because we already hold that fix. */ + duplicates: number; + /** Positions skipped because they carried no usable fix time. */ + unusable: number; +} + +/** Escapes a value for embedding in a PB filter string literal. */ +function esc(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Formats a date for a PocketBase **filter** expression. + * + * PB stores dates as `2026-09-06 18:03:18.489Z` and its filter parser + * will not accept the ISO-8601 `T` separator — a comparison against + * `"2026-09-06T00:00:00.000Z"` matches **nothing**, silently, with no + * error. That failure mode is invisible: the rollup would derive no + * stops and the prune would delete no rows, both reporting success, + * forever. + * + * Same convention as `boss-dashboard-service.ts` and + * `order-delivery-note-link-service.ts`. + */ +export function pbDate(value: Date): string { + return value.toISOString().replace("T", " "); +} + +/** + * Maps vehicle registration → PB user id, from `users.tracker_vehicle_id`. + * + * Registration rather than IMEI because that's what an operator can + * read off the bakkie and type into the admin. Matching is + * case-insensitive and ignores spaces: "ABC 123 GP" and "abc123gp" are + * the same vehicle to a human, and will be entered both ways. + */ +export async function loadVehicleRepMap( + pbArg?: AdminPocketBase +): Promise> { + const pb = pbArg ?? (await getAdminPocketBase()); + + const users = await pb.collection(USERS_COLLECTION).getFullList<{ + id: string; + tracker_vehicle_id?: string; + }>({ fields: "id,tracker_vehicle_id" }); + + const map = new Map(); + for (const user of users) { + const key = normaliseVehicleNo(user.tracker_vehicle_id); + if (key) map.set(key, user.id); + } + return map; +} + +export function normaliseVehicleNo(raw: unknown): string { + return String(raw ?? "") + .replace(/\s+/g, "") + .toUpperCase(); +} + +/** + * The newest fix time we already hold for each vehicle. + * + * Deduping against the newest-per-vehicle rather than querying for each + * incoming fix keeps this to one small query per vehicle, and it is the + * right check: the trail only ever grows forwards, so a fix at or + * before the newest one we hold is something we've already seen. That + * matters more than it sounds — a parked tracker stops emitting new + * fixes, so without this a 5-minute poll would write the same position + * a dozen times an hour. + */ +async function loadLatestFixTimes( + pb: AdminPocketBase, + vehicleNos: string[] +): Promise> { + const latest = new Map(); + + await Promise.all( + vehicleNos.map(async (vehicleNo) => { + try { + const row = await pb + .collection(POSITIONS_COLLECTION) + .getFirstListItem<{ fixed_at: string }>(`vehicle_no = "${esc(vehicleNo)}"`, { + sort: "-fixed_at", + fields: "fixed_at", + }); + const at = new Date(row.fixed_at).getTime(); + if (!Number.isNaN(at)) latest.set(vehicleNo, at); + } catch { + // 404 — first time we've seen this vehicle. Nothing to dedupe against. + } + }) + ); + + return latest; +} + +/** + * One poll cycle: fetch every vehicle's current position in a single + * FleetAI call and append the fixes we don't already have. + * + * Polls the whole company rather than only mapped vehicles. Storing an + * unmapped vehicle's trail costs almost nothing and means the history is + * already there on the day someone fills in that rep's + * `tracker_vehicle_id` — `rep` is nullable on the row for exactly this. + */ +export async function pollFleet(options?: { + pb?: AdminPocketBase; + adapter?: TrackerAdapter; +}): Promise { + const pb = options?.pb ?? (await getAdminPocketBase()); + const adapter = options?.adapter ?? getTrackerAdapter(); + + const positions = await adapter.getFleetPositions(); + const result: PollResult = { + fetched: positions.length, + stored: 0, + duplicates: 0, + unusable: 0, + }; + if (positions.length === 0) return result; + + const repMap = await loadVehicleRepMap(pb); + const latest = await loadLatestFixTimes( + pb, + [...new Set(positions.map((p) => p.vehicleNo))] + ); + + for (const position of positions) { + // `fixed_at` is required on the collection and is half the dedupe + // key. A fix we can't time is one we can never order or deduplicate, + // so it's dropped rather than stored with a guessed timestamp. + if (!position.fixedAt) { + result.unusable++; + continue; + } + + const at = new Date(position.fixedAt).getTime(); + if (Number.isNaN(at)) { + result.unusable++; + continue; + } + + const seen = latest.get(position.vehicleNo); + if (seen !== undefined && at <= seen) { + result.duplicates++; + continue; + } + + await pb.collection(POSITIONS_COLLECTION).create(toPositionRow(position, repMap)); + // Track within the batch too, in case FleetAI ever returns the same + // vehicle twice in one response. + latest.set(position.vehicleNo, at); + result.stored++; + } + + return result; +} + +function toPositionRow( + position: VehiclePosition, + repMap: Map +): Record { + return { + rep: repMap.get(normaliseVehicleNo(position.vehicleNo)) ?? "", + vehicle_no: position.vehicleNo, + coordinates: { lat: position.lat, lon: position.lon }, + fixed_at: position.fixedAt, + speed_kmh: position.speedKmh ?? 0, + // PocketBase bools can't be null, so an unwired ignition sensor + // (FleetAI's `--`) lands as false — the same value as "engine off". + // `detectStops` treats false as corroborating a stop, so a fleet + // without ignition wiring will over-report `ignition_off`. Harmless + // today (it's only a confidence signal on reports that don't exist + // yet); revisit as a 3-state field if the probe shows IGN is `--`. + ignition: position.ignition ?? false, + }; +} + +/** + * Derives `rep_stops` from a day's trail. + * + * Idempotent: existing stops for that vehicle/day are deleted first, so + * a re-run after a backfill or a bug fix replaces them rather than + * doubling them up. + */ +export async function rollUpStops( + dayIso: string, + options?: { pb?: AdminPocketBase } +): Promise<{ vehicles: number; stops: number }> { + const pb = options?.pb ?? (await getAdminPocketBase()); + + const dayStart = new Date(`${dayIso.slice(0, 10)}T00:00:00.000Z`); + if (Number.isNaN(dayStart.getTime())) { + throw new Error(`rollUpStops: invalid date "${dayIso}"`); + } + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + const rows = await pb.collection(POSITIONS_COLLECTION).getFullList<{ + id: string; + rep?: string; + vehicle_no: string; + coordinates?: { lat: number; lon: number }; + fixed_at: string; + speed_kmh?: number; + ignition?: boolean; + }>({ + filter: `fixed_at >= "${pbDate(dayStart)}" && fixed_at < "${pbDate(dayEnd)}"`, + sort: "fixed_at", + }); + + const byVehicle = new Map(); + for (const row of rows) { + const list = byVehicle.get(row.vehicle_no) ?? []; + list.push(row); + byVehicle.set(row.vehicle_no, list); + } + + let stopCount = 0; + + for (const [vehicleNo, trail] of byVehicle) { + const existing = await pb.collection(STOPS_COLLECTION).getFullList<{ id: string }>({ + filter: + `vehicle_no = "${esc(vehicleNo)}" && ` + + `started_at >= "${pbDate(dayStart)}" && started_at < "${pbDate(dayEnd)}"`, + fields: "id", + }); + for (const row of existing) { + await pb.collection(STOPS_COLLECTION).delete(row.id); + } + + const crumbs: Breadcrumb[] = trail + .filter((row) => row.coordinates) + .map((row) => ({ + fixedAt: row.fixed_at, + lat: row.coordinates!.lat, + lon: row.coordinates!.lon, + speedKmh: row.speed_kmh ?? null, + ignition: row.ignition ?? null, + })); + + const rep = trail.find((row) => row.rep)?.rep ?? ""; + + for (const stop of detectStops(crumbs)) { + await pb.collection(STOPS_COLLECTION).create({ + rep, + vehicle_no: vehicleNo, + started_at: stop.startedAt, + ended_at: stop.endedAt, + duration_minutes: stop.durationMinutes, + coordinates: { lat: stop.lat, lon: stop.lon }, + fix_count: stop.fixCount, + ignition_off: stop.ignitionOff, + longest_gap_minutes: stop.longestGapMinutes, + }); + stopCount++; + } + } + + return { vehicles: byVehicle.size, stops: stopCount }; +} + +/** + * Deletes breadcrumbs older than `retentionDays`. Stops are untouched — + * they're the durable record. + */ +export async function pruneOldPositions( + retentionDays = RETENTION_DAYS, + options?: { pb?: AdminPocketBase; now?: Date } +): Promise<{ deleted: number }> { + const pb = options?.pb ?? (await getAdminPocketBase()); + const now = options?.now ?? new Date(); + const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000); + + const stale = await pb.collection(POSITIONS_COLLECTION).getFullList<{ id: string }>({ + filter: `fixed_at < "${pbDate(cutoff)}"`, + fields: "id", + }); + + for (const row of stale) { + await pb.collection(POSITIONS_COLLECTION).delete(row.id); + } + + return { deleted: stale.length }; +}