# 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: }` ### `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: }` ### `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: }` ### `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="_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: }` --- ## 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=` 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=""` 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. 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: "" }` - `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:]]` 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:]]`. - `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).