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.
644 lines
21 KiB
Markdown
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).
|