Adds /dashboard/plant/map: a Leaflet view of every vehicle scanned from the MEGA Clock kiosk, plotted at its last known position. Schema - vehicles.last_latitude / last_longitude / last_scanned_at / last_location_accuracy_m denormalised off fuel_logs so the map page renders every vehicle in a single query. Same best-effort pattern as attention_dismissed_at; updated by /api/clock/vehicle-scan after every successful scan. PB init defaults new number cols to 0 (not null) — filter uses 'last_latitude > 0 || last_latitude < 0' to mean 'real fix'. - Indexed via idx_vehicles_last_scanned. Map UI - Custom SVG markers per unit_type: car silhouette for fuel, tractor silhouette for plant, 24x24, drop-shadowed. - Clustering via react-leaflet-cluster over Leaflet.markercluster. Cluster icons colour-ramp with size (green->sky->amber->rose) and scale 32->54px. - Three-button base-layer switcher: Street (OSM), Dark (ESRI Dark Gray Canvas — CartoDB watermarks tiles without a key), Satellite (ESRI World Imagery). Last choice persists in localStorage. - Site pins: small purple map-marker divIcon, click flies the map to zoom level 15. - Empty-state is a floating chip on the map rather than a full-page card so the map is always visible. APIs - /api/clock/vehicle-scan: stores the GPS fix on the fuel_logs row AND denormalises onto the vehicle when present; missing-coord scan leaves last_* untouched. Sidebar: new Map entry under Plant group.
23 KiB
API reference
Every route under
src/app/api/, with method, path, auth requirements, request shape, response shape, and known error cases. Routes the user never sees (the chat tool layer, the MCP server) are deliberately excluded — those are documented inmodules.md.
Conventions:
- All routes return JSON unless explicitly noted.
?marks optional fields; the routes treat them as nullable.- HTTP status codes are first-class — they tell the caller whether a failure is retryable.
- The
runtime = "nodejs"annotation matters for upload routes — Next.js edge runtime has a hard body-size cap that would reject large files.
1. Health
GET /api/health
File: src/app/api/health/route.ts
Auth: none
Caching: dynamic = "force-dynamic" (no caching)
Use: Docker healthcheck in Dockerfile:86-87 and docker-compose.yml:18-22
Response 200:
{ "status": "ok", "uptime": 1234, "timestamp": "2026-09-06T10:00:00.000Z" }
2. Sites
POST /api/sites
File: src/app/api/sites/route.ts
Auth: server-only (PocketBase superuser)
Caching: none (no dynamic annotation but no caching either)
Creates a new site. project_code is required, unique, and 1-64 chars.
Request body:
{
project_code: string, // required, unique
name?: string,
duration?: string, // e.g. "8 months"
contract_value?: number,
work_hours?: number,
hours_per_week?: number,
wages_hours_per_day?: number,
job_start_date?: string // YYYY-MM-DD
}
Responses:
201 { site: SiteRecord }400 { error: "project_code is required" }/"Body must be JSON"/"project_code must be 64 characters or fewer"409 { error: "project_code \"X\" is already in use" }(matched viaidx_sites_project_code)500 { error: <PB error> }
PATCH /api/sites/[id]
File: src/app/api/sites/[id]/route.ts
Partial update. project_code is locked after creation. Numeric
fields are coerced via Number(). job_start_date: null clears the
date explicitly.
Request body: same shape as POST minus project_code (rejected with 400).
Responses:
200 { site: SiteRecord }400 { error: "project_code cannot be changed once a site exists" }500 { error: <PB error> }
POST /api/sites/[id]/fixed-costs
File: src/app/api/sites/[id]/fixed-costs/route.ts
Adds a fixed cost for one site. Required: description, cost > 0,
date_logged as YYYY-MM-DD, is_recurring as boolean. notes capped
at 500 chars.
Request body:
{
description: string, // required, ≤ 200 chars
cost: number, // required, > 0
date_logged: string, // required, YYYY-MM-DD
is_recurring: boolean, // required
notes?: string // ≤ 500 chars
}
Responses:
201 { fixed_cost: FixedCostRecord }400 { error: "validation", field, message }(any validation failure)404 { error: "site_not_found" }500 { error: "write_failed" }
3. Vehicles
POST /api/vehicles
File: src/app/api/vehicles/route.ts
Creates a vehicle. Required: project_code (≤ 64), description, site (relation id).
Optional: qr_code_id, fuel_limits, unit_type (fuel | plant),
standard_work_hours, cost_per_hour.
Responses:
201 { vehicle: VehicleRecord }400(missing field) /409(unique-constraint violation) /500
PATCH /api/vehicles/[id]
File: src/app/api/vehicles/[id]/route.ts
Updates description, project_code, unit_type. Empty payload → 400.
Responses: 200 { vehicle } / 400 / 500
DELETE /api/vehicles/[id]
Hard-deletes a vehicle. plant_hours_log.vehicle has cascadeDelete=False
(scripts/pb-bootstrap-schema.py:234), so a vehicle with logs cannot
be deleted — the route surfaces that as a 409 with a friendly message.
Responses:
200 { ok: true }409 { error: "Cannot delete this plant — it has plant hours logged against it. Clear or reassign those logs first." }500
PATCH /api/vehicles/[id]/attention-dismiss
File: src/app/api/vehicles/[id]/attention-dismiss/route.ts
Stamps attention_dismissed_at to "now" so the dismissed vehicle
disappears from the Overview's "Attention required" rail. Re-acknowledging
is allowed (overwrites with a new timestamp).
Responses: 200 { ok, attention_dismissed_at } / 500
4. Plant hours
POST /api/plant-hours/import
File: src/app/api/plant-hours/import/route.ts
Runtime: nodejs
Max file size: 10 MB
Accepts xlsx or csv via multipart/form-data with one file field. See
data-flows.md §3 for the full pipeline.
Response 200:
{
batchId: string,
totalRows: number,
imported: number,
skipped: number,
mismatched: number,
warnings: Array<{ row: number; reason: string }>,
errors: Array<{ row: number; reason: string }>,
}
errors[].reason includes "duplicate: vehicle_qr=X start_date=Y" for
the (vehicle_qr, start_date) unique-index rejections.
Errors:
400 { error: "No file provided" }/"Empty file"/"File too large"/"multipart/form-data required"/"File could not be parsed as Excel or CSV"/"Workbook has no sheets"/"File contains no data rows"400 { error: "Failed to create import batch", details: <PB field error> }
GET /api/plant-hours/exception-report/[batchId]
File: src/app/api/plant-hours/exception-report/[batchId]/route.ts
Auth: server-only
Reconstructs the per-row exception CSV for one batch by re-parsing the
batch's original_file and matching against the imported rows.
Response: text/csv (Content-Disposition: attachment; filename="<batchId>_plant_hours_exceptions.csv")
Errors: 400 { error: "invalid_batch_id" } / "no_sheet" / 500 { error: "source_file_unavailable" }
GET /api/plant-hours/template
File: src/app/api/plant-hours/template/route.ts
Auth: server-only
Returns the canonical Plant_Hours_Template_2.xlsx workbook, with the
sample row's QR / description / cost-per-hour pulled from the first
vehicle in PocketBase (so the template matches a real asset).
Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (attachment, Cache-Control: no-store)
DELETE /api/plant-hours/[id]
File: src/app/api/plant-hours/[id]/route.ts
Removes one plant_hours_log row. Used by the "Delete row" actions on
the Attention panel and the per-vehicle log table.
Responses: 200 { ok: true } / 500 { error: <PB error> }
5. Timesheets
POST /api/timesheets/import
File: src/app/api/timesheets/import/route.ts
Runtime: nodejs (multipart)
Accepts xlsx/xls/csv via multipart/form-data with optional form fields
siteId and createMissingEmployees ("true" to auto-create missing
casual employees tied to siteId).
See data-flows.md §1 for the format auto-detection.
Response 200:
{
batchId: string,
totalRows: number,
imported: number,
skipped: number,
errors: Array<{ row: number; reason: string }>,
detectedFormat: "casual-matrix" | "permanent-wide" | "long-format-csv" | "long-format-xlsx",
createdEmployees: Array<{ employeeNo: string; name: string }>,
}
Errors: 400 for missing file, parse failure, or zero rows extracted.
6. Fuel logs
POST /api/fuel-logs/import
File: src/app/api/fuel-logs/import/route.ts
Runtime: nodejs (multipart)
Accepts xlsx or csv via multipart/form-data with one file field.
See data-flows.md §2 for the column allowlist and site-resolution strategy.
Response 200:
{
batchId: string,
totalRows: number,
imported: number,
skipped: number,
errors: Array<{ row: number; reason: string }>,
}
errors[].reason includes PB field-level errors (flattened via
formatPbError) so the operator can see which field rejected the row.
Errors: 400 for missing file, parse failure, missing required columns, or zero rows.
7. Purchase orders
POST /api/purchase-orders/upload
File: src/app/api/purchase-orders/upload/route.ts
Runtime: nodejs
Auth: server-only
Accepts a PDF or image via multipart/form-data with one file field.
SHA-256 hashes the bytes; if a data_import_batches row with the same
hash and status=processed exists within the last 30 days, returns
200 { duplicate: true, existing_batch_id, existing_po_id } and
creates nothing.
Mime allowlist (ALLOWED_MIME in src/lib/purchase-order-schema.ts):
PDF + JPEG/PNG/WebP.
Response 200: { duplicate: true, existing_batch_id, existing_po_id, filename, status: "duplicate" }
Response 202: { batch_id, status: "queued", filename }
Errors: 400 for invalid form, missing file, empty file, too-large file, unsupported mime. 500 for hash or batch-create failure.
POST /api/purchase-orders/process-batch
File: src/app/api/purchase-orders/process-batch/route.ts
Runtime: nodejs
Auth: x-ingest-secret: ${N8N_INGEST_SECRET} (shared secret with n8n)
Body: { batch_id: string (15-char PB id), extracted: PurchaseOrderIngestPayload }
Side effects: creates the PO row, all PO items, moves the file from the batch to the PO, finalises the batch (status=processed).
See data-flows.md §4 for the full pipeline and three dedup layers.
Response 200:
{
ok: true,
id: string, // PO id
po_number: string, // normalised (uppercase, "PO" prefix stripped)
po_number_extracted: string, // what the AI actually read
duplicate?: true, // present only on the dedup branch
warnings?: string[], // e.g. "fuzzy match: ..." when matchSite fell back
}
Errors: All error responses include retryable: true (5xx-class) or
retryable: false (4xx-class) so n8n can decide. The cases:
400 unauthorized/invalid_json/bad_batch_id/missing_extracted/validation_failed404 batch_not_found409 batch_not_processing(race / duplicate callback)500withstepforlookup_duplicate/match_site/create_po/create_items/file_move(502) /batch_finalize
GET /api/purchase-orders/by-number/[poNumber]/items
File: src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts
Auth: server-only
Used by the purchasing detail page when the user is looking up items by PO number. Returns the PO + items.
Response 200: { po, items } (PO record + line items array, or null if no match)
PATCH /api/purchase-orders/[id]/supplier
File: src/app/api/purchase-orders/[id]/supplier/route.ts
Auth: server-only
Body: { supplier: string }
Responses: 200 { purchase_order } / 400 / 404 / 500
8. Delivery notes
POST /api/delivery-notes/upload
File: src/app/api/delivery-notes/upload/route.ts
Runtime: nodejs
Auth: server-only
Manual upload of a delivery note. Required form fields: file (image or PDF), site (site id). Optional: delivery_note_number.
The OCR pipeline picks the upload up via processing_status="pending".
Also creates a data_import_batches row (batch_type=delivery_notes)
with processed_record_id=<dn.id> so the OCR callback can find it
without a relation field on delivery_notes. A failure to create the
batch row is logged but does not fail the upload (the DN is already
persisted and the OCR pipeline can still pick it up).
Response 201:
{
id: string,
deliveryNoteNumber: string,
siteId: string,
projectCode: string,
processingStatus: "pending",
created: string,
}
Errors: 400 { error: "No file provided" } / "Missing site" / "Site not found" / 500
GET /api/delivery-notes/[id]/slip
File: src/app/api/delivery-notes/[id]/slip/route.ts
Runtime: nodejs
Auth: x-ingest-secret: ${N8N_INGEST_SECRET}
Proxies the slip file for n8n. Resolves dn.slip_image and streams
the bytes from PocketBase back to the caller, with
Content-Disposition: attachment; filename="<slip>" so the workflow's
binary metadata carries a sensible name.
Responses:
200withapplication/octet-stream(or the original mime) body400 { error: "invalid_dn_id" }401 { error: "unauthorized" }404 { error: "dn_not_found" }/"no_slip_image"500 { error: "pb_url_not_configured" }/502 { error: "slip_fetch_failed" }
POST /api/delivery-notes/[id]/ocr-result
File: src/app/api/delivery-notes/[id]/ocr-result/route.ts
Runtime: nodejs
Auth: x-ingest-secret: ${N8N_INGEST_SECRET}
Body: see header doc in the file — po_number, delivery_note_number, date_received, received_by_name, confidence (0..1), items[], raw_ocr
The three-tier decision (auto / needs-review / manual) is the load-bearing
business logic. See data-flows.md §5 and src/lib/ocr-result-tier.ts.
Response 200:
{
ok: true,
tier: "auto" | "needs-review" | "manual",
receivedItemsCreated: number,
receivedItems: Array<{ id: string; po_item_id: string; qty: number }>,
durationMs: number,
}
Errors: 400 invalid_dn_id / invalid_json / 401 unauthorized / 404 dn_not_found / 500
POST /api/delivery-notes/[id]/assign-po
File: src/app/api/delivery-notes/[id]/assign-po/route.ts
Auth: server-only
Body: { po: string } (PO id)
Manual fallback for OCR failures. Sets processing_status="manual-verified",
is_verified=true, links the PO, and stamps ocr_completed_at.
Responses: 200 { deliveryNote } / 400 / 404 / 500
9. Data import batches
GET /api/data-import-batches/[id]/status
File: src/app/api/data-import-batches/[id]/status/route.ts
Runtime: nodejs
Auth: server-only
Polled by the browser after a PO upload. For processed PO batches, also
resolves processed_record_id to the purchase_order_number so the UI
can show "MEGCPO1234" instead of a raw id.
Response 200:
{
id: string,
status: "pending" | "processing" | "processed" | "failed" | "dead",
attempts: number,
last_error: string | null,
processed_record_id: string | null,
processed_record_label: string | null, // PO number when batch_type=purchase_orders
processed_at: string | null,
}
Errors: 400 { error: "bad_id" } / 404 { error: "not_found" } / 500 { error: "lookup_failed" }
POST /api/data-import-batches/[id]/retry
File: src/app/api/data-import-batches/[id]/retry/route.ts
Runtime: nodejs
Auth: server-only
Resets a failed or dead batch back to pending with attempts=0.
Already-pending batches return 409.
Responses:
200 { id, status: "pending" }400 { error: "bad_id" }404 { error: "not_found" }409 { error: "not_retryable", current_status }500
10. Records
POST /api/records/[collection]/[id]/verify
File: src/app/api/records/[collection]/[id]/verify/route.ts
Auth: server-only
Body: { verified?: boolean } (default true)
Allowed collections: purchase_orders, delivery_notes
Generic verify toggle. The collection allowlist is hardcoded — everything else returns 400.
Responses: 200 { id, is_verified } / 400 { error: "Collection \"X\" is not verifiable" } / 500
11. MEGA Clock
The standalone QR timekeeping PWA at /clock. The PIN gate handles
auth — these routes trust the caller the same way every other
clock/PWA flow does.
POST /api/clock/scan
File: src/app/api/clock/scan/route.ts
Auth: server-only
Clock a worker in or out by scanning their employee_no badge. The
direction is implied by the worker's most recent clock_events row:
no row or out → in, in → out (which also writes/updates a
timesheet_entries row).
Body:
{ employee_no: string }
Responses:
200 { direction: "in", employeeName, at }200 { direction: "out", employeeName, sessionHours, dailyTotalHours }400 { error: "Body must be JSON" }/"employee_no is required"404 { error: "Unknown badge" }
POST /api/clock/vehicle-lookup
File: src/app/api/clock/vehicle-lookup/route.ts
Auth: server-only
Called by the Fleet tab the instant a vehicle QR is decoded. Looks up
the vehicle and the most-recently-inserted odo_reading for it (the
"previous reading" the popup shows).
Body:
{ qr_code_id: string }
Responses:
200 { vehicle: { id, description, qrCodeId, siteId, unitType, fuelLimits, costPerHour }, hasPrevious: boolean, previousReading: number | null }400 { error: "Body must be JSON" }/"qr_code_id is required"404 { error: "Unknown vehicle QR" }
POST /api/clock/vehicle-scan
File: src/app/api/clock/vehicle-scan/route.ts
Auth: server-only
Writes one fuel_logs row capturing the current odometer/hour-meter
reading for a vehicle whose QR was just scanned. Pairs with
/api/clock/vehicle-lookup, which the UI calls first to resolve the
QR and surface the previous reading.
The scan endpoint deliberately captures one number per scan (the dash reading) and stamps a zero-litre row. Capturing fuel alongside is left to the spreadsheet import — the Fleet tab is for the end-of-shift reading, not for the pump sheet.
GPS coordinates are optional: the MEGA Clock Fleet scanner sends them (kiosk phone), the spreadsheet import path doesn't have a GPS context and leaves them null. A half-fix (lat without lng, or vice versa) is rejected with 400 rather than written as a half-populated row.
When the scan includes a fix, the route also denormalises the
latest position onto the vehicles row (fields last_latitude,
last_longitude, last_scanned_at, last_location_accuracy_m) so
the map page can render every vehicle in a single query without
walking back through fuel_logs for each one. The denormalisation is
best-effort: the fuel_logs row is the source of truth, and a failed
vehicle PATCH is logged and ignored — the next scan catches up, so a
missed write is self-healing rather than a hard inconsistency. A scan
without GPS leaves the vehicle's last_* columns alone — a stale
but valid "last scanned at site X" is more useful than nulling it.
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 (diagnostic, optional)
}
Responses:
200 { vehicleId, odoReading, dateLogged, logId, hasFix }—hasFixis true when a lat/lng pair was persisted400 { error: "Body must be JSON" }/"qr_code_id is required"/"odo_reading is required"/"odo_reading must be greater than zero"/"latitude and longitude must be supplied together"/"latitude must be a finite number in [-90, 90]"/"longitude must be a finite number in [-180, 180]"/"location_accuracy_m must be a non-negative finite number"/{ error: "<pocketBase field-level error>" }404 { error: "Unknown vehicle QR" }
12. Chat (gated)
POST /api/chat
File: src/app/api/chat/route.ts
Caching: dynamic = "force-dynamic"
Auth: none — gated by ENABLE_CHAT=true. No user authentication.
Protocol: Server-Sent Events
Content-Type: text/event-stream; charset=utf-8. Events emitted:
meta—{ provider, model }(first)delta—{ text }(repeated, as the model streams)tool—{ name }(when a tool is called)done—{ text, iterations, stoppedOnLimit }(final)error—{ message }(on failure)
The chat MUST stay disabled in production until real authentication
exists — see the .env.local.example warning and
multi-site-assessment §7.
Other chat routes (also gated): /dashboard/chat page. The chat
consumes no other server route; every figure comes through the agent
tool layer (src/lib/agent/).
13. What is not under /api/
For completeness — these are out of scope for this reference but may be searched for:
- The MCP server (
src/mcp/server.ts) isnpm run mcp, stdio-only, never a route. Exposes the sametoolsarray the chat uses. - The chat tool layer (
src/lib/agent/) is server-internal; the chat route calls it via in-process imports, not over HTTP. - The Pure metrics helpers (
src/lib/metrics/) are server-internal functions, not routes.
14. Auth matrix
| Route | Auth | Notes |
|---|---|---|
GET /api/health |
none | Liveness |
POST /api/sites |
server-only | Superuser via pbAdmin() |
PATCH /api/sites/[id] |
server-only | |
POST /api/sites/[id]/fixed-costs |
server-only | |
POST /api/vehicles |
server-only | |
PATCH /api/vehicles/[id] |
server-only | |
DELETE /api/vehicles/[id] |
server-only | |
PATCH /api/vehicles/[id]/attention-dismiss |
server-only | |
POST /api/plant-hours/import |
server-only | Multipart, nodejs |
GET /api/plant-hours/exception-report/[batchId] |
server-only | |
GET /api/plant-hours/template |
server-only | |
DELETE /api/plant-hours/[id] |
server-only | |
POST /api/timesheets/import |
server-only | Multipart, nodejs |
POST /api/fuel-logs/import |
server-only | Multipart, nodejs |
POST /api/purchase-orders/upload |
server-only | Multipart, nodejs |
POST /api/purchase-orders/process-batch |
x-ingest-secret |
n8n callback |
GET /api/purchase-orders/by-number/[poNumber]/items |
server-only | |
PATCH /api/purchase-orders/[id]/supplier |
server-only | |
POST /api/delivery-notes/upload |
server-only | Multipart |
GET /api/delivery-notes/[id]/slip |
x-ingest-secret |
n8n file proxy |
POST /api/delivery-notes/[id]/ocr-result |
x-ingest-secret |
n8n callback |
POST /api/delivery-notes/[id]/assign-po |
server-only | Manual fallback |
GET /api/data-import-batches/[id]/status |
server-only | UI poll |
POST /api/data-import-batches/[id]/retry |
server-only | |
POST /api/records/[collection]/[id]/verify |
server-only | Allowlist: POs + DNs |
POST /api/chat |
gated by ENABLE_CHAT=true |
No user auth — the gate is the env flag, not identity |
POST /api/clock/scan |
server-only | Behind the /clock PIN gate |
POST /api/clock/vehicle-lookup |
server-only | Behind the /clock PIN gate |
POST /api/clock/vehicle-scan |
server-only | Behind the /clock PIN gate |
Routes with no protection at all: the chat route (when enabled) and
any route that runs purely server-side. The browser-facing routes
delegate their auth to the PIN gate, not to the route — see
architecture.md §4.