WFDASH-WEBAPP/scripts/fleetai-probe.mjs
Dave 6fd11f4af0 feat(rep-visits): FleetAI tracker adapter, nearby-customer matching, stop detection
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.

Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md

- lib/visit-matching.ts   rankNearbyCustomers: radius match around a
                          point, rep's own customers first
- lib/visit-stops.ts      detectStops: breadcrumb trail -> stops.
                          Anchor-based clustering rather than a running
                          centroid, which would let a slow crawl chain
                          into one bogus stationary stop
- lib/tracker-parsing.ts  Netstar FleetAI response decoding
- lib/server/tracker-service.ts
                          FleetAiAdapter + FakeTrackerAdapter behind a
                          TrackerAdapter seam. The fake is auto-selected
                          when no credentials are configured, so the rest
                          of the feature builds and tests without vendor
                          access.
- lib/geo.ts              isRealGeoPoint, lifting the Null-Island guard
                          that was inlined identically in customers/[id]
                          and companies/[id]

Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.

FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.

102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:22:28 +02:00

170 lines
6 KiB
JavaScript

/**
* FleetAI probe — run this BEFORE building anything on top of the tracker.
*
* node scripts/fleetai-probe.mjs
*
* Reads FLEETAI_* from .env. Read-only: authenticates, lists vehicles,
* pulls one fleet-wide position snapshot, prints what came back.
*
* It exists to settle three assumptions that the whole rep check-in
* design rests on and that no amount of reading the PDF can answer:
*
* 1. Does each rep have a dedicated vehicle, under a registration we
* can map to a WFDASH user? If reps share a bakkie,
* `users.tracker_vehicle_id` is the wrong model.
* 2. Does the response carry `GPSActualTime` (the real GPS fix) or
* only `Datetime` / `timestamp` (when the platform received it)?
* That decides how honest the check-in staleness warning can be.
* 3. Is FLEETAI_COMPANY_NAME spelled exactly as the platform has it?
* A mismatch returns "No Company Found For Given User", which
* otherwise reads as "no vehicles today".
*
* Throwaway by design — delete it once the answers are known.
*/
import { readFileSync } from "node:fs";
function loadEnv() {
let raw = "";
try {
raw = readFileSync(".env", "utf8");
} catch {
console.warn("No .env found — falling back to process.env only.\n");
}
const fromFile = Object.fromEntries(
raw
.split("\n")
.filter((line) => line.includes("=") && !line.trim().startsWith("#"))
.map((line) => {
const i = line.indexOf("=");
return [line.slice(0, i).trim(), line.slice(i + 1).trim()];
})
);
return { ...fromFile, ...process.env };
}
const env = loadEnv();
const BASE = (env.FLEETAI_BASE_URL || "https://fleetai.netstar.co.za").replace(/\/$/, "");
const USERNAME = env.FLEETAI_USERNAME;
const PASSWORD = env.FLEETAI_PASSWORD;
const COMPANY = env.FLEETAI_COMPANY_NAME;
const PROJECT_ID = env.FLEETAI_PROJECT_ID || "37";
if (!USERNAME || !PASSWORD) {
console.error(
"Set FLEETAI_USERNAME and FLEETAI_PASSWORD in .env first.\n" +
"The account must be Company or Company Sub-Account level —\n" +
"FleetAI rejects Admin and Reseller logins outright."
);
process.exit(1);
}
/**
* FleetAI returns HTTP 200 for errors too, so every response body has to
* be inspected rather than trusting the status.
*/
async function post(endpoint, payload, headers = {}) {
const url = `${BASE}/webservice?token=${endpoint}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(payload),
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
console.error(`\n✗ ${endpoint} returned non-JSON (HTTP ${res.status}):`);
console.error(text.slice(0, 400));
process.exit(1);
}
const failed =
String(body?.RESULT ?? body?.result ?? "").toUpperCase() === "FAIL" ||
body?.root?.error;
if (failed) {
console.error(`\n✗ ${endpoint} failed:`);
console.error(JSON.stringify(body, null, 2).slice(0, 800));
process.exit(1);
}
return body;
}
console.log(`FleetAI probe → ${BASE}\n`);
/* 1. Authenticate ---------------------------------------------------- */
const auth = await post("generateAccessToken", {
username: USERNAME,
password: PASSWORD,
});
const token = auth?.token;
if (!token) {
console.error("✗ No token in the response:");
console.error(JSON.stringify(auth, null, 2).slice(0, 800));
console.error(
"\nMost likely cause: the account is Admin or Reseller level.\n" +
"FleetAI only accepts Company or Company Sub-Account logins."
);
process.exit(1);
}
console.log("✓ Authenticated (token is 30-minute; refresh at 25)\n");
/* 2. Vehicle registry ------------------------------------------------ */
const registry = await post("viewVehicle", { project_id: PROJECT_ID }, { "auth-code": token });
const vehicles = Array.isArray(registry?.data) ? registry.data : [];
console.log(`── QUESTION 1: which vehicles exist? (${vehicles.length} found)`);
if (vehicles.length === 0) {
console.log(" (none — check project_id, currently " + PROJECT_ID + ")");
}
for (const v of vehicles) {
console.log(
` ${String(v.plate_no ?? "?").padEnd(14)} imei=${String(v.imei_no ?? "?").padEnd(18)} ${v.device_name ?? ""}`
);
}
console.log(
"\n → Can each of Chris / Tiaan / George / Calvin be matched to exactly\n" +
" one registration above? If they share vehicles, tell me — the\n" +
" rep→vehicle model changes.\n"
);
/* 3. Fleet-wide live snapshot ---------------------------------------- */
if (!COMPANY) {
console.log("── QUESTION 3: FLEETAI_COMPANY_NAME is not set, skipping the fleet poll.");
console.log(" The poller needs it. Set it and re-run to verify the spelling.\n");
process.exit(0);
}
const live = await post(
`getTokenBaseLiveData&ProjectId=${encodeURIComponent(PROJECT_ID)}`,
{ company_names: COMPANY, format: "json" },
{ "auth-code": token }
);
const rows = live?.root?.VehicleData ?? live?.data ?? [];
const list = Array.isArray(rows) ? rows : [rows];
console.log(`── QUESTION 3: company name "${COMPANY}" accepted ✓ (${list.length} rows)\n`);
console.log("── QUESTION 2: which timestamp fields come back?\n");
const first = list[0];
if (!first) {
console.log(" (no rows — vehicles may all be INACTIVE)");
} else {
for (const key of ["Vehicle_No", "Status", "Speed", "Latitude", "Longitude", "IGN"]) {
console.log(` ${key.padEnd(16)} ${JSON.stringify(first[key])}`);
}
console.log("");
for (const key of ["GPSActualTime", "Datetime", "timestamp"]) {
const present = key in first ? JSON.stringify(first[key]) : "— ABSENT —";
console.log(` ${key.padEnd(16)} ${present}`);
}
console.log(
"\n → GPSActualTime present means the staleness warning reflects the\n" +
" vehicle. Only Datetime means it reflects the network instead.\n" +
" → Timestamps should read as UTC+2 (SAST). If they look like UTC,\n" +
" set FLEETAI_TZ_OFFSET_MINUTES=0.\n"
);
console.log("── Full first row, for reference:\n");
console.log(JSON.stringify(first, null, 2));
}