MEGAINFRA-WEBAPP/docs/api-reference.md
DEls 08ed89d9c8 feat(dashboard): enhance operations dashboard with rain outlook and coordinate support
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.
2026-09-06 19:59:52 +02:00

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 in modules.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 via idx_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_failed
  • 404 batch_not_found
  • 409 batch_not_processing (race / duplicate callback)
  • 500 with step for lookup_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:

  • 200 with application/octet-stream (or the original mime) body
  • 400 { 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) is npm run mcp, stdio-only, never a route. Exposes the same tools array 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.