MEGAINFRA-WEBAPP/docs/api-reference.md

800 lines
33 KiB
Markdown
Raw Normal View History

# 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`](./modules.md#7-ask-chat).
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:**
```json
{ "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:**
```ts
{
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:**
```ts
{
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`](./data-flows.md#3-plant-hours-import-synchronous) for the full pipeline.
**Response 200:**
```ts
{
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`](./data-flows.md#1-timesheet-import-synchronous) for the format auto-detection.
**Response 200:**
```ts
{
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`](./data-flows.md#2-fuel-log-import-synchronous) for the column allowlist and site-resolution strategy.
**Response 200:**
```ts
{
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`](./data-flows.md#4-purchase-order-upload-ingest-async-via-n8n) for the full pipeline and three dedup layers.
**Response 200:**
```ts
{
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:**
```ts
{
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`](./data-flows.md#5-delivery-note-upload-ocr-result-async-via-n8n) and `src/lib/ocr-result-tier.ts`.
**Response 200:**
```ts
{
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:**
```ts
{
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:**
```ts
{ 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:**
```ts
{ 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.
feat(plant): fleet map page with GPS-denormalised vehicles, clustering, and tile switcher 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.
2026-09-22 19:39:25 +00:00
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:**
```ts
{
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](./modules.md#ask-agent-actions-modules)). **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](./multi-site-assessment.md#7-the-chatagent-layer).
**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](./modules.md#ask-agent-actions-modules) for how these
fit together and [data-model.md §4.17](./data-model.md#ask-agent-actions)
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`](./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`](./operations.md#8-staged-delivery-note-ingestion). |
| `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`](./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`](./architecture.md#4-auth-model).