Updated the operations dashboard to include a rain outlook feature, providing users with a 7-day forecast based on site coordinates. Added latitude and longitude fields to the site form for better weather data integration. Enhanced the fuel log and site management functionalities to utilize dynamic fuel pricing based on historical data. Improved overall data handling and user experience across various dashboard components.
19 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. 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/).
12. 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.
13. 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 |
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.