MEGAINFRA-WEBAPP/docs/api-reference.md
DEls 3077223d38 feat(clock): add MEGA Clock API endpoints and update documentation
Introduced three new server-only API endpoints for the MEGA Clock PWA:
- `POST /api/clock/scan` for clocking workers in and out via badge scanning.
- `POST /api/clock/vehicle-lookup` for looking up vehicle details using QR codes.
- `POST /api/clock/vehicle-scan` for logging odometer readings from scanned vehicles.

Updated the README and API reference documentation to include these new endpoints and their functionalities. Replaced the placeholder for fleet scanning in the ClockView component with the new FleetScanner component.
2026-09-22 12:42:39 +02:00

644 lines
21 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. 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.
**Body:**
```ts
{ qr_code_id: string, odo_reading: number }
```
**Responses:**
- `200 { vehicleId, odoReading, dateLogged, logId }`
- `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` /
`"odo_reading is required"` / `"odo_reading must be greater than zero"`
/ `{ error: "<pocketBase field-level error>" }`
- `404 { error: "Unknown vehicle QR" }`
---
## 12. Chat (gated)
### `POST /api/chat`
**File:** `src/app/api/chat/route.ts`
**Caching:** `dynamic = "force-dynamic"`
**Auth:** none — gated by `ENABLE_CHAT=true`. **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/`).
---
## 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/[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 |
| `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 |
**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).