feat(tracker): breadcrumb poller — WFDASH's own vehicle position history

FleetAI has no position-history endpoint, so to answer "where was this
rep's vehicle at 10:20 last Tuesday" we have to record it ourselves.
An n8n Schedule Trigger calls POST /api/v1/tracker/poll every 5 minutes
during working hours; a nightly run derives stops and prunes.

  poll:     */5 6-18 * * 1-5     (the working-hours window lives in the
                                  cron, not in app settings — disabling
                                  the workflow is the off switch)
  nightly:  30 2 * * *

One getTokenBaseLiveData call covers the whole fleet per cycle, and only
genuinely new GPS fixes are written: dedupe keys on (vehicle_no,
fixed_at) against the newest fix already held. A parked tracker keeps
re-reporting the same GPSActualTime, so without this a 5-minute cycle
would store the same row a dozen times an hour.

Raw breadcrumbs are pruned at 35 days; derived rep_stops are kept
indefinitely, since a stop is small and is what reports actually ask
about.

Two bugs found by running this against the real stack, neither of which
the unit tests could have caught:

- FakeTrackerAdapter derived its fix time from Date.now(), so every poll
  looked like new movement. It could never reproduce a parked vehicle —
  the exact case dedupe exists for — and made the poller look correct
  while storing a duplicate every cycle. The fake now quantises its fix
  time to a bucket, so repeated polls inside one window return an
  identical fix.
- PocketBase's filter parser silently matches NOTHING against an
  ISO-8601 `T` separator. `fixed_at >= "2026-09-06T00:00:00.000Z"`
  returned 0 rows where the space-separated form returned 2, with no
  error either way — the rollup would have derived no stops and the
  prune deleted no rows, both reporting success, forever. Fixed with a
  `pbDate()` helper matching the existing convention in
  boss-dashboard-service.ts and order-delivery-note-link-service.ts.

Verified end to end on :4001 against the live PocketBase: three
consecutive polls returned stored=0/duplicates=1, and the nightly rollup
went from vehicles=0 to vehicles=1 once the date format was fixed. Test
rows were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-06 20:08:20 +02:00
parent b09a7e2e56
commit a551a71277
6 changed files with 958 additions and 1 deletions

View file

@ -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();
});
});

View file

@ -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 });
}
}

View file

@ -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", () => {

View file

@ -475,16 +475,36 @@ function extractRows(body: unknown): Record<string, unknown>[] {
*/
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<TrackerVehicle[]> {
@ -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,

View file

@ -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> = {}): 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<string, unknown> & { 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<string, Row[]> = {}) {
const store: Record<string, Row[]> = {
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<T>(opts?: { filter?: string; sort?: string }): Promise<T[]> {
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<T>(
filter: string,
opts?: { sort?: string }
): Promise<T> {
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<string, unknown>) {
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<typeof import("./pocketbase-admin").getAdminPocketBase>
> & { store: Record<string, Row[]> };
}
/* ============================================================================
* 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);
});
});

View file

@ -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<ReturnType<typeof getAdminPocketBase>>;
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<Map<string, string>> {
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<string, string>();
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<Map<string, number>> {
const latest = new Map<string, number>();
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<PollResult> {
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<string, string>
): Record<string, unknown> {
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<string, typeof rows>();
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 };
}