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:
parent
b09a7e2e56
commit
a551a71277
6 changed files with 958 additions and 1 deletions
112
src/app/api/v1/tracker/poll/route.test.ts
Normal file
112
src/app/api/v1/tracker/poll/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
76
src/app/api/v1/tracker/poll/route.ts
Normal file
76
src/app/api/v1/tracker/poll/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
394
src/lib/server/vehicle-trail-service.test.ts
Normal file
394
src/lib/server/vehicle-trail-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
327
src/lib/server/vehicle-trail-service.ts
Normal file
327
src/lib/server/vehicle-trail-service.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue