Updated the operations dashboard to include a rain outlook feature, providing users with a 7-day forecast based on site coordinates. Added latitude and longitude fields to the site form for better weather data integration. Enhanced the fuel log and site management functionalities to utilize dynamic fuel pricing based on historical data. Improved overall data handling and user experience across various dashboard components.
566 lines
19 KiB
Markdown
566 lines
19 KiB
Markdown
# 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/[batchId]`
|
|
|
|
**File:** `src/app/api/plant-hours/exception-report/[batchId]/route.ts`
|
|
**Auth:** server-only
|
|
|
|
Reconstructs the per-row exception CSV for one batch by re-parsing the
|
|
batch's `original_file` and matching against the imported rows.
|
|
|
|
**Response:** `text/csv` (Content-Disposition: `attachment; filename="<batchId>_plant_hours_exceptions.csv"`)
|
|
|
|
**Errors:** `400 { error: "invalid_batch_id" }` / `"no_sheet"` / `500 { error: "source_file_unavailable" }`
|
|
|
|
### `GET /api/plant-hours/template`
|
|
|
|
**File:** `src/app/api/plant-hours/template/route.ts`
|
|
**Auth:** server-only
|
|
|
|
Returns the canonical `Plant_Hours_Template_2.xlsx` workbook, with the
|
|
sample row's QR / description / cost-per-hour pulled from the first
|
|
vehicle in PocketBase (so the template matches a real asset).
|
|
|
|
**Response:** `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (attachment, `Cache-Control: no-store`)
|
|
|
|
### `DELETE /api/plant-hours/[id]`
|
|
|
|
**File:** `src/app/api/plant-hours/[id]/route.ts`
|
|
|
|
Removes one `plant_hours_log` row. Used by the "Delete row" actions on
|
|
the Attention panel and the per-vehicle log table.
|
|
|
|
**Responses:** `200 { ok: true }` / `500 { error: <PB error> }`
|
|
|
|
---
|
|
|
|
## 5. Timesheets
|
|
|
|
### `POST /api/timesheets/import`
|
|
|
|
**File:** `src/app/api/timesheets/import/route.ts`
|
|
**Runtime:** nodejs (multipart)
|
|
|
|
Accepts xlsx/xls/csv via `multipart/form-data` with optional form fields
|
|
`siteId` and `createMissingEmployees` (`"true"` to auto-create missing
|
|
casual employees tied to `siteId`).
|
|
|
|
See [`data-flows.md §1`](./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. Chat (gated)
|
|
|
|
### `POST /api/chat`
|
|
|
|
**File:** `src/app/api/chat/route.ts`
|
|
**Caching:** `dynamic = "force-dynamic"`
|
|
**Auth:** none — gated by `ENABLE_CHAT=true`. **No user authentication.**
|
|
**Protocol:** Server-Sent Events
|
|
|
|
`Content-Type: text/event-stream; charset=utf-8`. Events emitted:
|
|
- `meta` — `{ provider, model }` (first)
|
|
- `delta` — `{ text }` (repeated, as the model streams)
|
|
- `tool` — `{ name }` (when a tool is called)
|
|
- `done` — `{ text, iterations, stoppedOnLimit }` (final)
|
|
- `error` — `{ message }` (on failure)
|
|
|
|
The chat MUST stay disabled in production until real authentication
|
|
exists — see the `.env.local.example` warning and
|
|
[multi-site-assessment §7](./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/`).
|
|
|
|
---
|
|
|
|
## 12. What is **not** under `/api/`
|
|
|
|
For completeness — these are out of scope for this reference but may be
|
|
searched for:
|
|
|
|
- The **MCP server** (`src/mcp/server.ts`) is `npm run mcp`, stdio-only,
|
|
never a route. Exposes the same `tools` array the chat uses.
|
|
- The **chat tool layer** (`src/lib/agent/`) is server-internal; the
|
|
chat route calls it via in-process imports, not over HTTP.
|
|
- The **Pure metrics helpers** (`src/lib/metrics/`) are server-internal
|
|
functions, not routes.
|
|
|
|
---
|
|
|
|
## 13. Auth matrix
|
|
|
|
| Route | Auth | Notes |
|
|
| --- | --- | --- |
|
|
| `GET /api/health` | none | Liveness |
|
|
| `POST /api/sites` | server-only | Superuser via `pbAdmin()` |
|
|
| `PATCH /api/sites/[id]` | server-only | |
|
|
| `POST /api/sites/[id]/fixed-costs` | server-only | |
|
|
| `POST /api/vehicles` | server-only | |
|
|
| `PATCH /api/vehicles/[id]` | server-only | |
|
|
| `DELETE /api/vehicles/[id]` | server-only | |
|
|
| `PATCH /api/vehicles/[id]/attention-dismiss` | server-only | |
|
|
| `POST /api/plant-hours/import` | server-only | Multipart, nodejs |
|
|
| `GET /api/plant-hours/exception-report/[batchId]` | server-only | |
|
|
| `GET /api/plant-hours/template` | server-only | |
|
|
| `DELETE /api/plant-hours/[id]` | server-only | |
|
|
| `POST /api/timesheets/import` | server-only | Multipart, nodejs |
|
|
| `POST /api/fuel-logs/import` | server-only | Multipart, nodejs |
|
|
| `POST /api/purchase-orders/upload` | server-only | Multipart, nodejs |
|
|
| `POST /api/purchase-orders/process-batch` | `x-ingest-secret` | n8n callback |
|
|
| `GET /api/purchase-orders/by-number/[poNumber]/items` | server-only | |
|
|
| `PATCH /api/purchase-orders/[id]/supplier` | server-only | |
|
|
| `POST /api/delivery-notes/upload` | server-only | Multipart |
|
|
| `GET /api/delivery-notes/[id]/slip` | `x-ingest-secret` | n8n file proxy |
|
|
| `POST /api/delivery-notes/[id]/ocr-result` | `x-ingest-secret` | n8n callback |
|
|
| `POST /api/delivery-notes/[id]/assign-po` | server-only | Manual fallback |
|
|
| `GET /api/data-import-batches/[id]/status` | server-only | UI poll |
|
|
| `POST /api/data-import-batches/[id]/retry` | server-only | |
|
|
| `POST /api/records/[collection]/[id]/verify` | server-only | Allowlist: POs + DNs |
|
|
| `POST /api/chat` | gated by `ENABLE_CHAT=true` | **No user auth** — the gate is the env flag, not identity |
|
|
|
|
**Routes with no protection at all:** the chat route (when enabled) and
|
|
any route that runs purely server-side. The browser-facing routes
|
|
delegate their auth to the PIN gate, not to the route — see
|
|
[`architecture.md §4`](./architecture.md#4-auth-model).
|