MEGAINFRA-WEBAPP/docs/api-reference.md
DEls 3077223d38 feat(clock): add MEGA Clock API endpoints and update documentation
Introduced three new server-only API endpoints for the MEGA Clock PWA:
- `POST /api/clock/scan` for clocking workers in and out via badge scanning.
- `POST /api/clock/vehicle-lookup` for looking up vehicle details using QR codes.
- `POST /api/clock/vehicle-scan` for logging odometer readings from scanned vehicles.

Updated the README and API reference documentation to include these new endpoints and their functionalities. Replaced the placeholder for fleet scanning in the ClockView component with the new FleetScanner component.
2026-09-22 12:42:39 +02:00

21 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. 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.

Body:

{ qr_code_id: string, odo_reading: number }

Responses:

  • 200 { vehicleId, odoReading, dateLogged, logId }
  • 400 { error: "Body must be JSON" } / "qr_code_id is required" / "odo_reading is required" / "odo_reading must be greater than zero" / { 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) 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.

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.