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>
33 KiB
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 inmodules.md.
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:
{ "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:
{
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 viaidx_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:
{
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 for the full pipeline.
Response 200:
{
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 for the format auto-detection.
Response 200:
{
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 for the column allowlist and site-resolution strategy.
Response 200:
{
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 for the full pipeline and three dedup layers.
Response 200:
{
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_failed404 batch_not_found409 batch_not_processing(race / duplicate callback)500withstepforlookup_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:
{
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:
200withapplication/octet-stream(or the original mime) body400 { 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 and src/lib/ocr-result-tier.ts.
Response 200:
{
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:
{
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:
{ 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:
{ 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:
{
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 }—hasFixis true when a lat/lng pair was persisted400 { 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). 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 createddelta—{ text }(repeated, as the model streams)tool—{ name }(when a tool is called)action— the fullPlannedActionpreview frompropose_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— aChartSpec(src/lib/reports/chart-spec.ts:{ id, kind, title, subtitle?, xKey, xLabel?, series[{ key, label, format }], stacked?, rows }), once per chart a report tool ormake_chartreturned. Sent instead of handing the chart to the model: the tool result the model sees haschartsreplaced bychartsAvailable: { id, title }[], and the model places a chart by writing[[chart:<id>]]on its own line.idmatches/^[a-z0-9-]{1,64}$/; specs with any other id are dropped.image—{ id, url }, once pergenerate_imagecall. Same split aschart: the model keepsimagesAvailable: [{ id }]and places it with[[image:<id>]].pdf—{ title, subtitle?, filename? }, once whenmake_pdf_reportfires (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.
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 for how these
fit together and data-model.md §4.17
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).
| 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. |
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. |
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) isnpm run mcp, stdio-only, never a route. Exposes the sametoolsarray 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.