208 lines
7.6 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|