MEGAINFRA-WEBAPP/src/app/api/clock/vehicle-scan/route.ts

208 lines
7.6 KiB
TypeScript

import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import type { VehicleRecord } from "@/lib/pb-types";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
function escapePb(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Format a PocketBase ClientResponseError the same way the rest of the
* codebase does (see src/app/api/fuel-logs/import/route.ts):
* the top-level message is generic ("Failed to create record."), and
* the field-level detail lives under `.data`. Flatten both into one
* string so the operator can see exactly which field is unhappy.
*/
function formatPbError(err: unknown): string {
const e = err as {
message?: string;
data?: Record<string, { message?: string; code?: string }>;
};
const head = e?.message ?? "Insert failed";
if (e?.data && typeof e.data === "object") {
const parts = Object.entries(e.data)
.map(([field, info]) => `${field}: ${info?.message ?? "invalid"}`)
.join("; ");
if (parts) return `${head} (${parts})`;
}
return head;
}
/**
* POST /api/clock/vehicle-scan
*
* Writes one fuel_logs row for the vehicle whose QR was just scanned.
* Pairs with /api/clock/vehicle-lookup, which the UI calls first to
* resolve the QR and show the previous reading.
*
* The Fleet scanner is intentionally narrow: it captures *one* number
* per scan — the end-of-shift hour-meter / odometer — and stamps a
* zero-litre fuel_logs row against it. Capturing fuel alongside is
* left to the spreadsheet import, which is the only place the pump
* sheet's litres column lives anyway.
*
* Why fuel_logs (the same collection the fuel import writes to):
* - The collection is already unified across fuel and plant units —
* see `VehicleRecord.unit_type` and the plant-view metrics.
* - `odo_reading` is the hour-meter for plant vehicles and the
* odometer for fuel vehicles; the column is one field because the
* audit trail cares about "the number on the dash", not its unit.
*
* Auth: server-only (pbAdmin), same posture as every other write in
* this directory.
*
* Body:
* {
* qr_code_id: string,
* odo_reading: number,
* latitude?: number, // decimal degrees, [-90, 90]
* longitude?: number, // decimal degrees, [-180, 180]
* location_accuracy_m?: number, // metres, ≥ 0
* }
* lat/lng must be supplied together (a half-fix is rejected with 400
* rather than written as a half-populated row). accuracy may be
* omitted on its own — it's diagnostic metadata.
*
* Response (200):
* { vehicleId, odoReading, dateLogged, logId, hasFix }
* `hasFix` is true when a lat/lng pair was persisted, false when the
* caller didn't supply one (desktop, denied permission, no signal).
*/
export async function POST(request: Request) {
const user = await requireApiUser("clock");
if (user instanceof Response) return user;
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
}
const qrRaw = typeof body.qr_code_id === "string" ? body.qr_code_id : "";
const qrCodeId = qrRaw.trim();
if (!qrCodeId) {
return NextResponse.json({ error: "qr_code_id is required" }, { status: 400 });
}
const odoReading = typeof body.odo_reading === "number" ? body.odo_reading : NaN;
if (!Number.isFinite(odoReading)) {
return NextResponse.json({ error: "odo_reading is required" }, { status: 400 });
}
if (odoReading <= 0) {
return NextResponse.json(
{ error: "odo_reading must be greater than zero" },
{ status: 400 },
);
}
// GPS fix is optional. The Fleet scanner on the kiosk phone sends it;
// any other caller is free to omit it. We validate the geometry
// (lat ∈ [-90, 90], lng ∈ [-180, 180], accuracy ≥ 0) but stay
// permissive on the rest — partial fixes (lat without lng, or
// accuracy without coords) are a sign of a buggy client, so we 400
// on those rather than writing a half-populated row.
const lat = typeof body.latitude === "number" ? body.latitude : null;
const lng = typeof body.longitude === "number" ? body.longitude : null;
const accuracy =
typeof body.location_accuracy_m === "number" ? body.location_accuracy_m : null;
if ((lat === null) !== (lng === null)) {
return NextResponse.json(
{ error: "latitude and longitude must be supplied together" },
{ status: 400 },
);
}
if (lat !== null && (!Number.isFinite(lat) || lat < -90 || lat > 90)) {
return NextResponse.json(
{ error: "latitude must be a finite number in [-90, 90]" },
{ status: 400 },
);
}
if (lng !== null && (!Number.isFinite(lng) || lng < -180 || lng > 180)) {
return NextResponse.json(
{ error: "longitude must be a finite number in [-180, 180]" },
{ status: 400 },
);
}
if (accuracy !== null && (!Number.isFinite(accuracy) || accuracy < 0)) {
return NextResponse.json(
{ error: "location_accuracy_m must be a non-negative finite number" },
{ status: 400 },
);
}
const pb = await pbAdmin();
let vehicle: VehicleRecord;
try {
vehicle = await pb
.collection("vehicles")
.getFirstListItem<VehicleRecord>(`qr_code_id="${escapePb(qrCodeId)}"`);
} catch {
return NextResponse.json({ error: "Unknown vehicle QR" }, { status: 404 });
}
const denied = requireSiteAccess(user, vehicle.site);
if (denied) return denied;
// PB's date field validator wants a full ISO datetime; the rest of
// the codebase anchors date-only fields at UTC midnight (see
// src/app/api/clock/scan/route.ts `dateLoggedFor`).
const dateLogged = new Date(new Date().toISOString().slice(0, 10)).toISOString();
try {
const created = await pb.collection("fuel_logs").create({
site: vehicle.site,
vehicle: vehicle.id,
date_logged: dateLogged,
litres: 0,
odo_reading: odoReading,
// PB coerces `null` to a missing field on write for optional
// number columns, so passing the validated values verbatim
// keeps desktop-imported rows null while scanned rows get the
// fix. `undefined` would do the same thing; null is explicit.
latitude: lat,
longitude: lng,
location_accuracy_m: accuracy,
});
// Denormalise the latest fix onto the vehicle so the map page
// can render every vehicle in one query, without walking back
// through `fuel_logs` for each one. Best-effort: the `fuel_logs`
// row is already written, which is the source of truth. If this
// PATCH fails (network blip, validation surprise) we log and
// carry on — the next scan catches up, and the row never blocks
// on cosmetic bookkeeping. We only denormalise when a fix exists;
// a scan without GPS leaves the vehicle's last_* alone (a stale
// but valid "last scanned at site X" is more useful than nulling
// it to nothing).
if (lat !== null && lng !== null) {
try {
await pb.collection("vehicles").update(vehicle.id, {
last_latitude: lat,
last_longitude: lng,
last_scanned_at: dateLogged,
last_location_accuracy_m: accuracy,
});
} catch (err) {
console.error(
"[clock/vehicle-scan] vehicle last-location PATCH failed",
{ vehicleId: vehicle.id, logId: created.id, err },
);
}
}
return NextResponse.json({
vehicleId: vehicle.id,
odoReading,
dateLogged,
logId: created.id,
hasFix: lat !== null,
});
} catch (err) {
return NextResponse.json(
{ error: formatPbError(err) },
{ status: 400 },
);
}
}