MEGAINFRA-WEBAPP/docs/api-reference.md
DEls 08ed89d9c8 feat(dashboard): enhance operations dashboard with rain outlook and coordinate support
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.
2026-09-06 19:59:52 +02:00

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).