docs/modules.md §7.1: threads, memory, files, free queries/virtual datasets, the propose/confirm/apply/undo gateway, both PDF export paths (manual answer export and Ask-made reports via make_pdf_report), images, and change requests — with the actor/x-ask-user model and the three new PocketBase collections. docs/data-model.md §4.17: field-by-field ask_threads/ask_messages/ ask_log reference, cross-linked from modules.md via explicit anchors. docs/api-reference.md §12/§12b: /api/chat's new body shape and its thread/action/chart/image/pdf SSE events, plus every new agent-actions route (picker, threads, attachments, confirm-card actions, memories, change requests) and their auth-matrix entries; the pb-files proxy entry now notes its ask_messages admin-token path. README.md: the intro, the modules table (adds Change requests), and the API surface summary now describe an agent that acts, not just a read-only chat. Also marks Tasks 1–12 and 9b's implementation checkbox done in the plan doc (docs/superpowers/plans/2026-09-26-ask-agent-actions.md) — the remaining unchecked box is 9b's live check, skipped because ask_threads/ask_messages/ask_log aren't applied on the target PocketBase yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
799 lines
33 KiB
Markdown
799 lines
33 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/[sheetId]`
|
|
|
|
**File:** `src/app/api/plant-hours/exception-report/[sheetId]/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.
|
|
|
|
GPS coordinates are optional: the MEGA Clock Fleet scanner sends them
|
|
(kiosk phone), the spreadsheet import path doesn't have a GPS context
|
|
and leaves them null. A half-fix (lat without lng, or vice versa) is
|
|
rejected with 400 rather than written as a half-populated row.
|
|
|
|
When the scan includes a fix, the route also denormalises the
|
|
latest position onto the `vehicles` row (fields `last_latitude`,
|
|
`last_longitude`, `last_scanned_at`, `last_location_accuracy_m`) so
|
|
the map page can render every vehicle in a single query without
|
|
walking back through `fuel_logs` for each one. The denormalisation is
|
|
best-effort: the `fuel_logs` row is the source of truth, and a failed
|
|
vehicle PATCH is logged and ignored — the next scan catches up, so a
|
|
missed write is self-healing rather than a hard inconsistency. A scan
|
|
without GPS leaves the vehicle's `last_*` columns alone — a stale
|
|
but valid "last scanned at site X" is more useful than nulling it.
|
|
|
|
**Body:**
|
|
```ts
|
|
{
|
|
qr_code_id: string,
|
|
odo_reading: number,
|
|
latitude?: number, // decimal degrees, [-90, 90]
|
|
longitude?: number, // decimal degrees, [-180, 180]
|
|
location_accuracy_m?: number, // metres, ≥ 0 (diagnostic, optional)
|
|
}
|
|
```
|
|
|
|
**Responses:**
|
|
- `200 { vehicleId, odoReading, dateLogged, logId, hasFix }` — `hasFix` is true when a lat/lng pair was persisted
|
|
- `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` /
|
|
`"odo_reading is required"` / `"odo_reading must be greater than zero"` /
|
|
`"latitude and longitude must be supplied together"` /
|
|
`"latitude must be a finite number in [-90, 90]"` /
|
|
`"longitude must be a finite number in [-180, 180]"` /
|
|
`"location_accuracy_m must be a non-negative finite number"` /
|
|
`{ 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`. Identification only, via
|
|
`x-ask-user` (a `users` record id from the picker — see
|
|
[modules.md §7.1](./modules.md#ask-agent-actions-modules)). **No real user
|
|
authentication yet.**
|
|
**Protocol:** Server-Sent Events
|
|
|
|
**Body — new:** `{ threadId?, message, display?, attachmentIds?, images? }`
|
|
with `x-ask-user`. No `threadId` creates one, titled from `message`, owned
|
|
by the actor; a thread owned by someone else 404s.
|
|
**Body — legacy** (kept working for one release, unpersisted): `{ history }`
|
|
with no `threadId`.
|
|
|
|
`Content-Type: text/event-stream; charset=utf-8`. Events emitted:
|
|
- `meta` — `{ provider, model }` (first)
|
|
- `thread` — `{ id, title }` — only when a new thread was just created
|
|
- `delta` — `{ text }` (repeated, as the model streams)
|
|
- `tool` — `{ name }` (when a tool is called)
|
|
- `action` — the full `PlannedAction` preview from `propose_changes`
|
|
(`{ actionId, summary, preview[{ collection, kind, id?, before?, after }], problems, status: "pending" }`).
|
|
Sent **instead of** handing the plan to the model: the tool result the
|
|
model sees is `{ actionId, status, problems }` only.
|
|
- `chart` — a `ChartSpec` (`src/lib/reports/chart-spec.ts`:
|
|
`{ id, kind, title, subtitle?, xKey, xLabel?, series[{ key, label, format }], stacked?, rows }`),
|
|
once per chart a report tool or `make_chart` returned. Sent **instead
|
|
of** handing the chart to the model: the tool result the model sees has
|
|
`charts` replaced by `chartsAvailable: { id, title }[]`, and the model
|
|
places a chart by writing `[[chart:<id>]]` on its own line. `id` matches
|
|
`/^[a-z0-9-]{1,64}$/`; specs with any other id are dropped.
|
|
- `image` — `{ id, url }`, once per `generate_image` call. Same split as
|
|
`chart`: the model keeps `imagesAvailable: [{ id }]` and places it with
|
|
`[[image:<id>]]`.
|
|
- `pdf` — `{ title, subtitle?, filename? }`, once when `make_pdf_report`
|
|
fires (the model calls it last, after building the report itself). The
|
|
browser renders a report cover and a Download PDF button for that turn;
|
|
the model's own tool result is just `{ ok: true }`.
|
|
- `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/`).
|
|
|
|
---
|
|
|
|
## 12b. Ask agent actions
|
|
|
|
Detailed field-by-field contracts live in each route's own doc comment
|
|
(same convention as §12a); see
|
|
[modules.md §7.1](./modules.md#ask-agent-actions-modules) for how these
|
|
fit together and [data-model.md §4.17](./data-model.md#ask-agent-actions)
|
|
for the collections. All gated the same way as §12 (`ENABLE_CHAT=true`);
|
|
all identify the actor via `x-ask-user` except where noted.
|
|
|
|
| Method + path | Purpose |
|
|
| --- | --- |
|
|
| `GET /api/ask/users` | `{ users: { id, name }[] }` — `users` records with a non-empty `name`, for the "Who's using Ask?" picker. No `x-ask-user` needed. |
|
|
| `GET` / `POST /api/chat/threads` | List the actor's threads (newest first) / create an empty one. |
|
|
| `GET` / `PATCH` / `DELETE /api/chat/threads/[id]` | A thread's rows / rename+archive / delete. 404 if not owned by the actor. |
|
|
| `POST /api/chat/attachments` | Multipart `file` (+ optional `threadId`). Parses and stores as an `ask_messages` row (`kind: "attachment"`); returns `{ threadId, attachmentId, kind, summary, images? }` (`images` only for a scanned PDF's rasterised pages). |
|
|
| `GET /api/agent/actions/[id]` | An action's current state, reconstructed as `{ actionId, summary, preview, problems: [], status, error }` — used to re-render a card on thread reopen or refresh. |
|
|
| `POST /api/agent/actions/[id]/apply` | Confirm: applies a pending action. Same-origin only (`Origin` must match). |
|
|
| `POST /api/agent/actions/[id]/reject` | Cancel: marks a pending action rejected without applying anything. Same-origin only. |
|
|
| `POST /api/agent/actions/[id]/undo` | Reverses an applied action once. Same-origin only. |
|
|
| `GET` / `POST /api/ask/memories` | List the actor's active memories + active shared ones / add one manually (`{ text, site?, shared? }`). |
|
|
| `DELETE /api/ask/memories/[id]` | Forget one (kept as `status: "undone"`, not deleted). 400 if someone else tries to forget a personal memory. |
|
|
| `GET /api/ask/change-requests` | `{ requests: [...] }`, optional `?status=`. No `x-ask-user` needed — a plain dashboard route, not actor-scoped. |
|
|
| `PATCH /api/ask/change-requests/[id]` | `{ status }` — `open`/`planned`/`done`/`declined`. No `x-ask-user` needed. |
|
|
|
|
---
|
|
|
|
## 12a. Routes added after the 2026-09-06 pass
|
|
|
|
These are summarised rather than documented field-by-field; each route
|
|
file opens with a doc comment that is the authoritative contract.
|
|
|
|
### Purchase orders (queue + manual edits)
|
|
|
|
Since the 2026-09-11 ingestion restructure the `purchase_orders` row *is*
|
|
the queue item (see [`n8n/purchase-order-ingest-implementation.md`](./n8n/purchase-order-ingest-implementation.md)).
|
|
|
|
| Method + path | Purpose |
|
|
| --- | --- |
|
|
| `POST /api/purchase-orders/ingest` | n8n hands back an extracted PO (`x-ingest-secret`). |
|
|
| `GET /api/purchase-orders/[id]/status` | Polled by the upload dialog; returns `processed_record_id` once processed. |
|
|
| `POST /api/purchase-orders/[id]/retry` | Resets a failed/dead PO to pending for the next n8n tick. Replaces the batch retry for POs. |
|
|
| `GET /api/purchase-orders/[id]/document` | The order's own file, proxied so no PB token reaches the browser. |
|
|
| `GET /api/purchase-orders/[id]/pages/[n]` | One page of the order as PNG (for side-by-side DN checking). |
|
|
| `POST /api/purchase-orders/[id]/items` | Add a line OCR missed. 409 once verified. |
|
|
| `PATCH` / `DELETE /api/purchase-orders/[id]/items/[itemId]` | Edit / remove a line. PO must exist, be unverified, and own the item. |
|
|
|
|
### Staged delivery-note batches (`dn-batches`)
|
|
|
|
| Method + path | Purpose |
|
|
| --- | --- |
|
|
| `POST /api/dn-batches/tick` | Cron (`x-ingest-secret`). Claims `DN_TICK_BATCH_SIZE` batches; runner chosen by `DN_STAGED_INGESTION_ENABLED` — see [`operations.md §8`](./operations.md#8-staged-delivery-note-ingestion). |
|
|
| `POST /api/dn-batches/[id]/rerun` | Re-run after a correction. Idempotent (upserts candidates). |
|
|
| `POST /api/dn-batches/[id]/dismiss` | Mark a whole batch processed without promoting anything (e.g. notes captured by hand before the pipeline). |
|
|
| `POST /api/dn-batches/[id]/assembly` | Settle how pages group into notes. |
|
|
| `GET /api/dn-batches/[id]/pages/[n]` | One page as PNG, rendered on demand. |
|
|
| `POST /api/dn-batches/[id]/pages/[n]/dismiss` | "This page is not a delivery document." Body `{ reason? }`. |
|
|
| `PATCH /api/dn-batches/[id]/notes/[noteId]` | Correct a candidate's fields or page range. Never touches `delivery_notes`. |
|
|
| `POST /api/dn-batches/[id]/notes/[noteId]/resolve-entities` | Pick supplier / site / PO for a candidate. |
|
|
| `POST /api/dn-batches/[id]/notes/[noteId]/resolve-item` | Link one slip line to a PO line. |
|
|
| `POST /api/dn-batches/[id]/notes/[noteId]/post` | Promote the candidate into `delivery_notes` + `received_items`. The only step that writes financial rows. |
|
|
| `POST /api/dn-batches/[id]/notes/[noteId]/dismiss` | Stand one candidate down (duplicates are per note, not per batch). |
|
|
| `POST /api/dn-batches/[id]/notes/[noteId]/correct-receipt` | Edit received quantities after posting, with an audit row. Requires `RECEIPT_CORRECTION_SECRET`. |
|
|
|
|
### Suppliers, vehicles, files
|
|
|
|
| Method + path | Purpose |
|
|
| --- | --- |
|
|
| `POST /api/suppliers` | Create. Name normalised (trim, collapse spaces, uppercase); duplicate → 409. |
|
|
| `PATCH /api/suppliers/[id]` | Rename, same normalisation + uniqueness. |
|
|
| `POST /api/vehicles/[id]/merge` | Body `{ intoId, confirm? }`. Without `confirm: true` returns the plan (dry run). With it: moves the duplicate `[id]`'s fuel logs and plant-hours rows onto `intoId`, fills blank fields, deletes `[id]`. 409 on conflicts (different site/type, same plant-hours week). Batches of ≤ 50; safe to re-run if interrupted. |
|
|
| `POST /api/vehicles/import-summary` | Sync the Buildsite "Vehicle Fuel Summary" CSV: which vehicles exist per site and whether each is fuel (km/L) or plant (L/h). |
|
|
| `GET /api/pb-files/[collection]/[recordId]/[...filename]` | Same-origin proxy for PB files, so PDF slips render in an iframe without PB's sandbox CSP. Collections: `delivery_notes`/`purchase_orders` (public, forwarded as-is) and `ask_messages` (its rules are all null by design — this route mints a short-lived admin file token server-side rather than exposing that rule). |
|
|
|
|
### MEGA Clock additions
|
|
|
|
| Method + path | Purpose |
|
|
| --- | --- |
|
|
| `GET /api/clock/employee-lookup?employee_no=X` | Called the instant a badge is decoded; returns the worker and the next direction (`in` / `out`) using the South African local day. `no-store`, `NetworkOnly` in the service worker. |
|
|
| `POST /api/clock/events/[id]/correct` | Record the real leaving time for an unmatched earlier-day `IN`. Same-origin only, `x-app-pin` must equal `APP_PIN` (503 if unset). Body `{ actualOutLocal, reason }`. See [`clock-daily-exceptions-rollout.md`](./clock-daily-exceptions-rollout.md). |
|
|
|
|
---
|
|
|
|
## 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/[sheetId]` | 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` | `x-ask-user` **identifies**, doesn't authenticate — anyone with the PIN can pick any name |
|
|
| `GET /api/ask/users` · `GET/POST /api/chat/threads` · `.../[id]` · `POST /api/chat/attachments` · `GET/POST /api/ask/memories` · `DELETE …/[id]` | gated by `ENABLE_CHAT=true` | `x-ask-user` identifies the actor; threads/memories 404/400 if not owned |
|
|
| `GET /api/agent/actions/[id]` | gated by `ENABLE_CHAT=true` | `x-ask-user` only |
|
|
| `POST /api/agent/actions/[id]/{apply,reject,undo}` | same-origin only + `x-ask-user` | Confirm-card buttons; in the test phase anyone may confirm any pending action |
|
|
| `GET/PATCH /api/ask/change-requests` · `.../[id]` | server-only | Plain dashboard route, not actor-scoped |
|
|
| `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 |
|
|
| `GET /api/clock/employee-lookup` | server-only | Behind the /clock PIN gate; `NetworkOnly` in the service worker |
|
|
| `POST /api/clock/events/[id]/correct` | same-origin + `x-app-pin` | 503 when `APP_PIN` unset |
|
|
| `POST /api/purchase-orders/ingest` | `x-ingest-secret` | n8n |
|
|
| `GET /api/purchase-orders/[id]/status` · `POST …/retry` | server-only | Upload dialog poll / retry |
|
|
| `GET /api/purchase-orders/[id]/document` · `GET …/pages/[n]` | server-only | Source PDF / page PNG |
|
|
| `POST /api/purchase-orders/[id]/items` · `PATCH`/`DELETE …/items/[itemId]` | server-only | Blocked once the PO is verified |
|
|
| `POST /api/dn-batches/tick` | `x-ingest-secret` | Cron |
|
|
| `POST /api/dn-batches/[id]/{rerun,dismiss,assembly}` | server-only | |
|
|
| `GET /api/dn-batches/[id]/pages/[n]` · `POST …/pages/[n]/dismiss` | server-only | |
|
|
| `PATCH /api/dn-batches/[id]/notes/[noteId]` + `POST …/{resolve-entities,resolve-item,post,dismiss}` | server-only | |
|
|
| `POST /api/dn-batches/[id]/notes/[noteId]/correct-receipt` | `RECEIPT_CORRECTION_SECRET` | Edits a posted receipt, with audit |
|
|
| `POST /api/suppliers` · `PATCH /api/suppliers/[id]` | server-only | |
|
|
| `POST /api/vehicles/import-summary` | server-only | Buildsite CSV sync |
|
|
| `POST /api/vehicles/[id]/merge` | server-only | Dry run unless `confirm: true`; deletes the duplicate |
|
|
| `GET /api/pb-files/[collection]/[recordId]/[...filename]` | server-only | Same-origin PB file proxy; mints an admin file token for `ask_messages` |
|
|
|
|
**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).
|