567 lines
19 KiB
Markdown
567 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).
|