MEGAINFRA-WEBAPP/docs/api-reference.md
DEls fecc885d3e docs(ask): agent actions
docs/modules.md §7.1: threads, memory, files, free queries/virtual
datasets, the propose/confirm/apply/undo gateway, both PDF export paths
(manual answer export and Ask-made reports via make_pdf_report),
images, and change requests — with the actor/x-ask-user model and the
three new PocketBase collections.

docs/data-model.md §4.17: field-by-field ask_threads/ask_messages/
ask_log reference, cross-linked from modules.md via explicit anchors.

docs/api-reference.md §12/§12b: /api/chat's new body shape and its
thread/action/chart/image/pdf SSE events, plus every new agent-actions
route (picker, threads, attachments, confirm-card actions, memories,
change requests) and their auth-matrix entries; the pb-files proxy
entry now notes its ask_messages admin-token path.

README.md: the intro, the modules table (adds Change requests), and
the API surface summary now describe an agent that acts, not just a
read-only chat.

Also marks Tasks 1–12 and 9b's implementation checkbox done in the plan
doc (docs/superpowers/plans/2026-09-26-ask-agent-actions.md) — the
remaining unchecked box is 9b's live check, skipped because
ask_threads/ask_messages/ask_log aren't applied on the target
PocketBase yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:32:53 +02:00

33 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/[sheetId]

File: src/app/api/plant-hours/exception-report/[sheetId]/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.

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 } — hasFix is true when a lat/lng pair was persisted
  • 400 { 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. Identification only, via x-ask-user (a users record id from the picker — see modules.md §7.1). No real user authentication yet. Protocol: Server-Sent Events

Body — new: { threadId?, message, display?, attachmentIds?, images? } with x-ask-user. No threadId creates one, titled from message, owned by the actor; a thread owned by someone else 404s. Body — legacy (kept working for one release, unpersisted): { history } with no threadId.

Content-Type: text/event-stream; charset=utf-8. Events emitted:

  • meta — { provider, model } (first)
  • thread — { id, title } — only when a new thread was just created
  • delta — { text } (repeated, as the model streams)
  • tool — { name } (when a tool is called)
  • action — the full PlannedAction preview from propose_changes ({ actionId, summary, preview[{ collection, kind, id?, before?, after }], problems, status: "pending" }). Sent instead of handing the plan to the model: the tool result the model sees is { actionId, status, problems } only.
  • chart — a ChartSpec (src/lib/reports/chart-spec.ts: { id, kind, title, subtitle?, xKey, xLabel?, series[{ key, label, format }], stacked?, rows }), once per chart a report tool or make_chart returned. Sent instead of handing the chart to the model: the tool result the model sees has charts replaced by chartsAvailable: { id, title }[], and the model places a chart by writing [[chart:<id>]] on its own line. id matches /^[a-z0-9-]{1,64}$/; specs with any other id are dropped.
  • image — { id, url }, once per generate_image call. Same split as chart: the model keeps imagesAvailable: [{ id }] and places it with [[image:<id>]].
  • pdf — { title, subtitle?, filename? }, once when make_pdf_report fires (the model calls it last, after building the report itself). The browser renders a report cover and a Download PDF button for that turn; the model's own tool result is just { ok: true }.
  • 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/).


12b. Ask agent actions

Detailed field-by-field contracts live in each route's own doc comment (same convention as §12a); see modules.md §7.1 for how these fit together and data-model.md §4.17 for the collections. All gated the same way as §12 (ENABLE_CHAT=true); all identify the actor via x-ask-user except where noted.

Method + path Purpose
GET /api/ask/users { users: { id, name }[] } — users records with a non-empty name, for the "Who's using Ask?" picker. No x-ask-user needed.
GET / POST /api/chat/threads List the actor's threads (newest first) / create an empty one.
GET / PATCH / DELETE /api/chat/threads/[id] A thread's rows / rename+archive / delete. 404 if not owned by the actor.
POST /api/chat/attachments Multipart file (+ optional threadId). Parses and stores as an ask_messages row (kind: "attachment"); returns { threadId, attachmentId, kind, summary, images? } (images only for a scanned PDF's rasterised pages).
GET /api/agent/actions/[id] An action's current state, reconstructed as { actionId, summary, preview, problems: [], status, error } — used to re-render a card on thread reopen or refresh.
POST /api/agent/actions/[id]/apply Confirm: applies a pending action. Same-origin only (Origin must match).
POST /api/agent/actions/[id]/reject Cancel: marks a pending action rejected without applying anything. Same-origin only.
POST /api/agent/actions/[id]/undo Reverses an applied action once. Same-origin only.
GET / POST /api/ask/memories List the actor's active memories + active shared ones / add one manually ({ text, site?, shared? }).
DELETE /api/ask/memories/[id] Forget one (kept as status: "undone", not deleted). 400 if someone else tries to forget a personal memory.
GET /api/ask/change-requests { requests: [...] }, optional ?status=. No x-ask-user needed — a plain dashboard route, not actor-scoped.
PATCH /api/ask/change-requests/[id] { status } — open/planned/done/declined. No x-ask-user needed.

12a. Routes added after the 2026-09-06 pass

These are summarised rather than documented field-by-field; each route file opens with a doc comment that is the authoritative contract.

Purchase orders (queue + manual edits)

Since the 2026-09-11 ingestion restructure the purchase_orders row is the queue item (see n8n/purchase-order-ingest-implementation.md).

Method + path Purpose
POST /api/purchase-orders/ingest n8n hands back an extracted PO (x-ingest-secret).
GET /api/purchase-orders/[id]/status Polled by the upload dialog; returns processed_record_id once processed.
POST /api/purchase-orders/[id]/retry Resets a failed/dead PO to pending for the next n8n tick. Replaces the batch retry for POs.
GET /api/purchase-orders/[id]/document The order's own file, proxied so no PB token reaches the browser.
GET /api/purchase-orders/[id]/pages/[n] One page of the order as PNG (for side-by-side DN checking).
POST /api/purchase-orders/[id]/items Add a line OCR missed. 409 once verified.
PATCH / DELETE /api/purchase-orders/[id]/items/[itemId] Edit / remove a line. PO must exist, be unverified, and own the item.

Staged delivery-note batches (dn-batches)

Method + path Purpose
POST /api/dn-batches/tick Cron (x-ingest-secret). Claims DN_TICK_BATCH_SIZE batches; runner chosen by DN_STAGED_INGESTION_ENABLED — see operations.md §8.
POST /api/dn-batches/[id]/rerun Re-run after a correction. Idempotent (upserts candidates).
POST /api/dn-batches/[id]/dismiss Mark a whole batch processed without promoting anything (e.g. notes captured by hand before the pipeline).
POST /api/dn-batches/[id]/assembly Settle how pages group into notes.
GET /api/dn-batches/[id]/pages/[n] One page as PNG, rendered on demand.
POST /api/dn-batches/[id]/pages/[n]/dismiss "This page is not a delivery document." Body { reason? }.
PATCH /api/dn-batches/[id]/notes/[noteId] Correct a candidate's fields or page range. Never touches delivery_notes.
POST /api/dn-batches/[id]/notes/[noteId]/resolve-entities Pick supplier / site / PO for a candidate.
POST /api/dn-batches/[id]/notes/[noteId]/resolve-item Link one slip line to a PO line.
POST /api/dn-batches/[id]/notes/[noteId]/post Promote the candidate into delivery_notes + received_items. The only step that writes financial rows.
POST /api/dn-batches/[id]/notes/[noteId]/dismiss Stand one candidate down (duplicates are per note, not per batch).
POST /api/dn-batches/[id]/notes/[noteId]/correct-receipt Edit received quantities after posting, with an audit row. Requires RECEIPT_CORRECTION_SECRET.

Suppliers, vehicles, files

Method + path Purpose
POST /api/suppliers Create. Name normalised (trim, collapse spaces, uppercase); duplicate → 409.
PATCH /api/suppliers/[id] Rename, same normalisation + uniqueness.
POST /api/vehicles/[id]/merge Body { intoId, confirm? }. Without confirm: true returns the plan (dry run). With it: moves the duplicate [id]'s fuel logs and plant-hours rows onto intoId, fills blank fields, deletes [id]. 409 on conflicts (different site/type, same plant-hours week). Batches of ≤ 50; safe to re-run if interrupted.
POST /api/vehicles/import-summary Sync the Buildsite "Vehicle Fuel Summary" CSV: which vehicles exist per site and whether each is fuel (km/L) or plant (L/h).
GET /api/pb-files/[collection]/[recordId]/[...filename] Same-origin proxy for PB files, so PDF slips render in an iframe without PB's sandbox CSP. Collections: delivery_notes/purchase_orders (public, forwarded as-is) and ask_messages (its rules are all null by design — this route mints a short-lived admin file token server-side rather than exposing that rule).

MEGA Clock additions

Method + path Purpose
GET /api/clock/employee-lookup?employee_no=X Called the instant a badge is decoded; returns the worker and the next direction (in / out) using the South African local day. no-store, NetworkOnly in the service worker.
POST /api/clock/events/[id]/correct Record the real leaving time for an unmatched earlier-day IN. Same-origin only, x-app-pin must equal APP_PIN (503 if unset). Body { actualOutLocal, reason }. See clock-daily-exceptions-rollout.md.

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/[sheetId] 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 x-ask-user identifies, doesn't authenticate — anyone with the PIN can pick any name
GET /api/ask/users · GET/POST /api/chat/threads · .../[id] · POST /api/chat/attachments · GET/POST /api/ask/memories · DELETE …/[id] gated by ENABLE_CHAT=true x-ask-user identifies the actor; threads/memories 404/400 if not owned
GET /api/agent/actions/[id] gated by ENABLE_CHAT=true x-ask-user only
POST /api/agent/actions/[id]/{apply,reject,undo} same-origin only + x-ask-user Confirm-card buttons; in the test phase anyone may confirm any pending action
GET/PATCH /api/ask/change-requests · .../[id] server-only Plain dashboard route, not actor-scoped
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
GET /api/clock/employee-lookup server-only Behind the /clock PIN gate; NetworkOnly in the service worker
POST /api/clock/events/[id]/correct same-origin + x-app-pin 503 when APP_PIN unset
POST /api/purchase-orders/ingest x-ingest-secret n8n
GET /api/purchase-orders/[id]/status · POST …/retry server-only Upload dialog poll / retry
GET /api/purchase-orders/[id]/document · GET …/pages/[n] server-only Source PDF / page PNG
POST /api/purchase-orders/[id]/items · PATCH/DELETE …/items/[itemId] server-only Blocked once the PO is verified
POST /api/dn-batches/tick x-ingest-secret Cron
POST /api/dn-batches/[id]/{rerun,dismiss,assembly} server-only
GET /api/dn-batches/[id]/pages/[n] · POST …/pages/[n]/dismiss server-only
PATCH /api/dn-batches/[id]/notes/[noteId] + POST …/{resolve-entities,resolve-item,post,dismiss} server-only
POST /api/dn-batches/[id]/notes/[noteId]/correct-receipt RECEIPT_CORRECTION_SECRET Edits a posted receipt, with audit
POST /api/suppliers · PATCH /api/suppliers/[id] server-only
POST /api/vehicles/import-summary server-only Buildsite CSV sync
POST /api/vehicles/[id]/merge server-only Dry run unless confirm: true; deletes the duplicate
GET /api/pb-files/[collection]/[recordId]/[...filename] server-only Same-origin PB file proxy; mints an admin file token for ask_messages

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.