MEGAINFRA-WEBAPP/docs/data-model.md
DEls fecc885d3e docs(ask): agent actions
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>
2026-09-26 21:32:53 +02:00

506 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Data model
> Every PocketBase collection that backs the app, with fields, relations, and
> the site-attribution strategy each one uses.
Source of truth for types: `src/lib/pb-types.ts` (16 collection interfaces).
Source of truth for the live schema: `scripts/pb-bootstrap-schema.py`.
Production instance: `https://pb.mega.infra.apphub.co.za`.
This reflects the shape produced by the 2026-09-11 ingestion restructure
(`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`): the shared
`data_import_batches` pool is gone, replaced by typed document collections
(`timesheets`, `fuel_sheets`, `plant_hours_sheets`) and, for purchase orders,
queue fields carried directly on `purchase_orders` itself. `project_code`
denormalisation is gone everywhere except `sites`.
---
## 1. Entity-relationship diagram
```mermaid
erDiagram
SITES ||--o{ EMPLOYEES : "site (relation)"
SITES ||--o{ VEHICLES : "site (relation)"
SITES ||--o{ FIXED_COSTS : "site (relation)"
SITES ||--o{ INCOME_CLAIMS : "site (relation)"
SITES ||--o{ PURCHASE_ORDERS : "site (relation)"
SITES ||--o{ DELIVERY_NOTES : "site (relation)"
SITES ||--o{ FUEL_LOGS : "site (relation)"
SITES ||--o{ PLANT_HOURS_LOG : "site (relation)"
SITES ||--o{ TIMESHEET_ENTRIES : "site (relation, stamped at import)"
SITES ||--o{ TIMESHEETS : "site (relation, required)"
SITES ||--o{ FUEL_SHEETS : "site (relation, optional)"
SITES ||--o{ PLANT_HOURS_SHEETS : "site (relation, optional)"
EMPLOYEES ||--o{ TIMESHEET_ENTRIES : "employee (relation)"
VEHICLES ||--o{ FUEL_LOGS : "vehicle (relation)"
VEHICLES ||--o{ PLANT_HOURS_LOG : "vehicle (relation)"
TIMESHEETS ||--o{ TIMESHEET_ENTRIES : "timesheet (relation, optional)"
FUEL_SHEETS ||--o{ FUEL_LOGS : "fuel_sheet (relation, optional)"
PLANT_HOURS_SHEETS ||--o{ PLANT_HOURS_LOG : "plant_hours_sheet (relation, optional)"
PURCHASE_ORDERS ||--|{ PURCHASE_ORDER_ITEMS : "purchase_order (cascade)"
PURCHASE_ORDERS ||--o{ DELIVERY_NOTES : "purchase_order"
DELIVERY_NOTES ||--|{ DELIVERY_NOTE_ITEMS : "delivery_note (cascade)"
PURCHASE_ORDER_ITEMS ||--o{ DELIVERY_NOTE_ITEMS : "purchase_order_item"
```
`data_import_batches` and the deprecated `plant` alias (0 rows, 0 code
references) were deleted entirely as part of this restructure — neither
exists in production any more.
---
## 2. Site attribution strategy, per collection
This is the table to read first. Every non-joining collection now carries a
real `site` relation; `project_code` survives only on `sites` itself, as the
one canonical, human-readable label.
| Collection | `site` relation | Attribution source | Drift risk |
| --- | --- | --- | --- |
| `sites` | n/a | The site row itself; `project_code` is required + unique | None — the unique index is authoritative |
| `employees` | ✅ optional | The worker's current site assignment | Low — read directly, nothing denormalised to go stale |
| `vehicles` | ✅ optional | The vehicle's current site assignment | Low — same |
| `fixed_costs` | ✅ optional | Stamped by the entry form | Low |
| `income_claims` | ✅ optional | Stamped by the entry form | Low |
| `purchase_orders` | ✅ **required** | Resolved by `matchSite` at upload time | Low — required field, no null-site POs |
| `delivery_notes` | ✅ **required** | Stamped at upload time from the form | Low |
| `fuel_logs` | ✅ **required** | Resolved per-row during sheet import (site cell, or vehicle's current site as fallback) | Low — required field |
| `plant_hours_log` | ✅ **required** | Always set from the matched vehicle's site at import time | Low — required field |
| `timesheets` (document) | ✅ **required** | A wage sheet belongs to one site, stamped at upload | None |
| `timesheet_entries` | ✅ **required** | **Stamped from the employee's site at import time** — not joined at read time | None — this ends the historical drift the old `timesheets` collection had when a worker moved sites after the fact |
| `fuel_sheets` (document) | ✅ optional | A pump sheet can span sites; left empty and resolved per-row against `fuel_logs.site` | Low |
| `plant_hours_sheets` (document) | ✅ optional | Same reasoning as `fuel_sheets` | Low |
| `purchase_order_items` | ❌ | Inherited through `purchase_order → site` | None — never queried directly by site |
| `delivery_note_items` | ❌ | Inherited through `delivery_note → site` | None — same |
**Reading the table.** `timesheet_entries` (the renamed collection that used
to be called `timesheets`, and used to have no site of its own) is no longer
the worst drift risk in the system — it now carries a `site` relation that is
**stamped once, at import time**, from the employee's site as it was that
day. A worker moving sites later does not silently rewrite history the way
the old at-read-time join through `employees.site` did. The two join tables
(`purchase_order_items`, `delivery_note_items`) remain the only collections
with no site attribution of their own, by design — they're always reached
through their parent.
---
## 3. The `project_code` denormalisation — removed
Before this restructure, `project_code` (a free-text identifier like
`MEG-001`) was copied onto every child collection as a denormalised string
at insert time, alongside the `site` relation. That copy was never updated
when the parent site's `project_code` changed, which was the single biggest
drift risk in the schema (see the old §3 in git history for the full
writeup).
**Current state:** `project_code` now lives in exactly one place —
`sites.project_code`, required and unique
(`idx_sites_project_code`, enforced in `scripts/pb-bootstrap-schema.py`).
Every other collection that needs a human-readable site label resolves it by
expanding or joining the `site` relation at read time
(`src/lib/agent/load.ts`'s `loadSiteRefs()` is the canonical example). There
is no denormalised copy left anywhere to drift.
`scripts/pb-relax-rules.py` and the `PATCH /api/sites/[id]` route
(`src/app/api/sites/[id]/route.ts`) still refuse to let you change
`project_code` on an existing site — that rule now protects the single
source of truth rather than a chain of denormalised copies, but it is
exactly as important as it always was: renaming a site is a new site, not an
edit.
---
## 4. Collection reference
Each entry below lists every typed field, the relation direction, and any
notable constraints.
### 4.1 `sites`
The site is the unit of business. One row per construction contract.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `project_code` | text | ✅ | Unique (`idx_sites_project_code`). Locked after creation. |
| `name` | text | optional | Human label, e.g. "The Hills" |
| `duration` | text | optional | Free-text duration ("8 months", "52 weeks") — parsed by `parseDurationMonths` |
| `contract_value` | number | optional | Rand value, no currency field |
| `work_hours` | number | optional | Total project work hours |
| `hours_per_week` | number | optional | Standard workweek for the site |
| `wages_hours_per_day` | number | optional | Standard wage day length |
| `latitude`, `longitude` | number | optional | Site coordinates |
| `job_start_date` | date | optional | ISO date; the projection helper treats this as the authoritative start when present |
### 4.2 `employees`
| Field | Type | Notes |
| --- | --- | --- |
| `site` | relation → `sites` | The site the worker is currently assigned to |
| `name`, `employee_no`, `position` | text | `employee_no` is the cross-system payroll id; **uppercased** on import |
| `rate_per_hour` | number | Drives payroll burn |
| `hours_per_day_standard` | number | 9 by default for auto-created casuals |
| `worker_type` | select | `permanent` or `casual` |
### 4.3 `vehicles` (unified — includes plant)
| Field | Type | Notes |
| --- | --- | --- |
| `site` | relation → `sites` | Current assignment |
| `description` | text | e.g. "Toyota Hilux 2.4 GD-6" |
| `standard_work_hours` | text/number | Free-text on schema; numeric in practice. Used by the plant utilisation calc |
| `fuel_limits` | text | Free-form like `"8.5 L/h"`, `"6.5 km/L"` — parsed by `parseFuelLimit` (`src/lib/fuel-variance.ts`) |
| `qr_code_id` | text | The clean identifier the fuel importer matches against. NOT unique in schema — see [multi-site-assessment §5](./multi-site-assessment.md#5-vehicles-plant-and-employees-moving-between-sites) |
| `cost_per_hour` | number | Drives plant utilisation value |
| `unit_type` | select | `fuel` or `plant` (default `fuel`). Splits the bucket between Fleet and Plant pages |
| `attention_dismissed_at` | date | When the user dismissed the vehicle's "over fuel limit" alert from the fuel page |
| `last_latitude` | number | Latest GPS fix from a MEGA Clock Fleet scan. Decimal degrees. Null until first scan |
| `last_longitude` | number | Decimal degrees. Null until first scan |
| `last_scanned_at` | date | UTC midnight anchor of the most recent scan, same convention as `fuel_logs.date_logged`. Indexed via `idx_vehicles_last_scanned` |
| `last_location_accuracy_m` | number | `Position.coords.accuracy` from the kiosk phone, in metres. Diagnostic |
The `PLANT` collection (deprecated legacy alias) was deleted in this
restructure — it had 0 rows and 0 code references. All plant items live in
`vehicles` with `unit_type="plant"`.
### 4.4 `fixed_costs`
Monthly or one-off costs. `is_recurring` is the partition key.
| Field | Type | Notes |
| --- | --- | --- |
| `site` | relation → `sites` | |
| `date_logged` | date | Drives the monthly tile and trend charts |
| `description`, `notes` | text | |
| `cost` | number | Rand value |
| `is_recurring` | bool | `true` (or missing) → monthly bucket; `false` → one-off bucket. Partitioned by `partitionFixedCosts` (`src/lib/partition-fixed-costs.ts`) |
### 4.5 `income_claims`
| Field | Type | Notes |
| --- | --- | --- |
| `claim_number` | text | The contractor's IPN / claim ref |
| `site` | relation → `sites` | |
| `period_start`, `period_end` | date | The period the claim covers |
| `gross_amount` | number | Rand |
| `certified_by` | text | Signature line on the cert |
| `paid_on` | date | Empty until the claim is actually paid — drives the income series |
| `status` | select | `draft` / `certified` / `paid` |
| `is_verified` | bool | Verified by a human |
| `notes` | text | |
### 4.6 `fuel_prices`
Unchanged by this restructure. Global (not per-site) pump price history.
| Field | Type | Notes |
| --- | --- | --- |
| `effective_from` | date | Required, unique (`idx_fuel_prices_effective_from`) |
| `price_per_litre` | number | Required |
| `notes` | text | optional |
### 4.7 `purchase_orders`
Now carries the queue state `data_import_batches` used to carry — see
`docs/n8n/purchase-order-ingest-implementation.md` for the full async
pipeline. The row is created at upload time with a placeholder number and
`processing_status: pending`; there is no longer a separate batch row.
| Field | Type | Notes |
| --- | --- | --- |
| `purchase_order_number` | text | Unique (`idx_po_number`). Placeholder `PENDING-...` (`src/lib/po-placeholder.ts`) until OCR fills it in |
| `site` | relation → `sites`, **required** | Resolved by `matchSite` at upload time |
| `supplier` | text | |
| `required_by_date` | date | When the materials are needed |
| `ordered_by` | text | Who placed it |
| `items_count`, `total_value` | number | Denormalised roll-ups for table views |
| `purchase_order_file` | file | The original PDF/image; mime allowlist enforced by the upload route |
| `raw_ocr_data` | json | Full OCR payload + ingest metadata |
| `is_verified` | bool | Human-verified flag |
| `file_hash` | text | SHA-256 of the original file; drives the upload-time dedup window |
| `processing_status` | select | `pending` / `processing` / `processed` / `failed` / `dead` |
| `attempts` | number | Incremented on each claim |
| `last_error` | text | Last failure reason |
| `next_retry_at`, `last_attempt_at` | date | Backoff scheduling for the n8n cron |
| `date_uploaded` | date | |
Index: `idx_po_site_status` on `(site, processing_status)` — the shape the
n8n cron's "fetch pending" query and the dashboard's per-site pending count
both need.
### 4.8 `purchase_order_items`
Joins POs to line items. `cascadeDelete: true` on the parent relation.
| Field | Type | Notes |
| --- | --- | --- |
| `purchase_order` | relation → `purchase_orders` | Required parent |
| `material_code`, `cost_code` | text | |
| `description` | text | The free-text the OCR matches against in the DN line |
| `qty` | number | Ordered quantity |
| `unit`, `rate`, `disc_percent`, `vat_percent`, `amount` | | The amount field is the **computed** total, not stored separately |
`qty_outstanding` no longer exists as a stored field. Outstanding quantity
is computed at read time from `delivery_note_items` via
`src/lib/po-fulfilment.ts` (`computeOutstanding`) — there is nothing left to
drift out of sync with the actual delivery history.
### 4.9 `timesheets` (uploaded document)
New collection. One row per uploaded wage sheet — the file itself, plus
queue/outcome fields shared with `fuel_sheets` and `plant_hours_sheets`.
| Field | Type | Notes |
| --- | --- | --- |
| `site` | relation → `sites`, **required** | A wage sheet belongs to one site, unlike the fuel/plant sheets |
| `source_file` | file | The uploaded bytes |
| `content_type` | text | MIME of the original file |
| `file_hash` | text | SHA-256 |
| `date_uploaded`, `uploaded_by` | date/text | |
| `processing_status` | select | `pending` / `processing` / `processed` / `failed` / `dead` |
| `processed_at` | date | |
| `attempts`, `last_error` | number/text | |
| `rows_total`, `rows_created`, `rows_failed` | number | Set by `resolveDocumentOutcome()` (`src/lib/document-status.ts`) — never hardcoded to "processed" before rows are actually inserted |
| `row_errors` | json | Up to `MAX_ROW_ERRORS` (200) per-row failure reasons |
### 4.10 `timesheet_entries` (renamed from `timesheets`)
The actual time log: one row per (employee, date, hours). This is the
collection that used to be called `timesheets` before the restructure —
watch for stale references to the old name in anything not covered by this
rewrite.
| Field | Type | Notes |
| --- | --- | --- |
| `employee` | relation → `employees` | |
| `date_logged` | date | |
| `hours_worked` | number | Decimal |
| `site` | relation → `sites`, **required** | Stamped from the employee's site at import time — not joined at read time (see §2) |
| `timesheet` | relation → `timesheets`, optional | Empty means captured directly by QR scan, not from an uploaded sheet — this stays optional forever |
Indexes: `idx_ts_entries_site_date` on `(site, date_logged)`;
`idx_ts_entries_emp_date` on `(employee, date_logged)`.
### 4.11 `fuel_sheets` (uploaded document)
Same shape as `timesheets` (§4.9) except `site` is optional — a pump sheet
can span multiple sites, resolved per-row when the actual `fuel_logs` rows
are created.
Indexes: `idx_fuel_sheets_hash` on `file_hash`; `idx_fuel_sheets_status` on
`(processing_status, date_uploaded)`.
### 4.12 `fuel_logs`
| Field | Type | Notes |
| --- | --- | --- |
| `site` | relation → `sites`, **required** | Resolved per-row at import time |
| `vehicle` | relation → `vehicles` | Required by the schema but the importer tolerates a missing relation and falls back to QR |
| `date_logged` | date | ISO datetime; importer anchors at UTC midnight |
| `litres` | number | Required |
| `odo_reading` | number | Required — used for variance calculation |
| `latest_consumption` | number | Optional. Empty cells preserved as `undefined` rather than 0 (intentional) |
| `latitude`, `longitude` | number | Optional. Captured by the MEGA Clock Fleet scanner (`/api/clock/vehicle-scan`); the spreadsheet import path has no GPS context and leaves them null. Decimal degrees. Range validation lives in the route |
| `location_accuracy_m` | number | Optional. `Position.coords.accuracy` in metres at scan time. Diagnostic only |
| `fuel_sheet` | relation → `fuel_sheets`, optional | Which uploaded sheet produced this row, if any |
Indexes: `idx_fuel_logs_site_date` on `(site, date_logged)`;
`idx_fuel_logs_vehicle_date` on `(vehicle, date_logged)`.
### 4.13 `plant_hours_sheets` (uploaded document)
Identical shape to `fuel_sheets` (§4.11), same optional-site reasoning.
Indexes: `idx_plant_hours_sheets_hash` on `file_hash`;
`idx_plant_hours_sheets_status` on `(processing_status, date_uploaded)`.
### 4.14 `plant_hours_log`
The most disciplined collection. Every site-attribution field is required.
| Field | Type | Notes |
| --- | --- | --- |
| `date_logged`, `start_date`, `end_date` | date | |
| `vehicle_qr` | text | The QR text — part of the **composite unique index** below |
| `vehicle` | relation → `vehicles` | Required (minSelect=1) |
| `site` | relation → `sites` | Required (minSelect=1) |
| `start_hours`, `end_hours`, `hours` | number | |
| `hour_meter_budget`, `cost_per_hour`, `value` | number | `value = hours × cost_per_hour` |
| `over_budget`, `hours_mismatch` | bool | Computed at import time |
| `vehicle_unit_type_at_import` | select | Snapshot of the vehicle's `unit_type` at the moment of import — see the note in [multi-site-assessment §5](./multi-site-assessment.md#5-vehicles-plant-and-employees-moving-between-sites) about why this discipline should extend to `site` |
| `plant_hours_sheet` | relation → `plant_hours_sheets`, optional | Which uploaded sheet produced this row, if any |
**Unique index: `(vehicle_qr, start_date)`.** Prevents accidental
double-imports of the same week for the same vehicle. Surfaced as a friendly
`"duplicate: vehicle_qr=X start_date=Y"` error in the import response.
Additional index: `idx_ph_log_vehicle_start` on `(vehicle, start_date)`.
### 4.15 `delivery_notes`
| Field | Type | Notes |
| --- | --- | --- |
| `processing_status` | select | `pending` → `ocr-complete` → `auto-verified` / `needs-review` / `manual-verified` / `failed` |
| `delivery_note_number` | text | Optional; OCR may fill it in |
| `site` | relation → `sites`, **required** | Resolved at upload time |
| `purchase_order` | relation → `purchase_orders` | Filled by OCR (`exact → normalised → Levenshtein`) or by manual `assign-po` route |
| `date_received` | date | |
| `received_by` | text | The uploader's identity — currently always `"web-upload"` |
| `slip_image` | file | The photo/PDF, served via `/api/delivery-notes/:id/slip` to n8n |
| `raw_ocr_data` | json | Full OCR payload + match metadata |
| `is_verified` | bool | Final flip from `auto-verified` or `manual-verified` |
| `ocr_confidence` | number | 0..100 integer |
| `ocr_completed_at` | date | |
| `received_by_name` | text | Signature OCR'd from the slip |
| `file_hash` | text | SHA-256, used for upload-time dedup |
Indexes: `idx_dn_site_date` on `(site, date_received)`; `idx_dn_status` on
`processing_status`.
### 4.16 `delivery_note_items` (renamed from `received_items`)
Joins DNs to PO line items. `cascadeDelete: true` on the parent DN.
| Field | Type | Notes |
| --- | --- | --- |
| `delivery_note` | relation → `delivery_notes` | Required parent |
| `purchase_order_item` | relation → `purchase_order_items` | Optional — can be empty for items the OCR couldn't match, or for orphans where the PO item was deleted between fetch and create |
| `qty_received` | number | |
| `condition_notes` | text | Free-text audit trail (e.g. `"OCR 92% · desc: Cement 50kg · unit: bag"`) |
<a id="ask-agent-actions"></a>
### 4.17 `ask_threads`, `ask_messages`, `ask_log` (Ask agent actions)
Additive, created by `scripts/pb-add-ask-collections.py` (`--apply` is a
human step). All rules `null` — superuser only; the Next.js server is the
only client. See [modules.md §7.1](./modules.md#ask-agent-actions-modules)
for how these back threads, memory, actions and change requests.
**`ask_threads`** — one row per conversation.
| Field | Type | Notes |
| --- | --- | --- |
| `title` | text | `autoTitle()` of the first message, or renamed by the user |
| `owner` | relation → `users`, required | The picked actor (`x-ask-user`) |
| `archived` | bool | |
| `last_message_at` | date | Bumped on every `appendMessage`; a lost race here is harmless |
**`ask_messages`** — one row per message, attachment or generated image.
**Append-only**: created once, never rewritten, so concurrent tabs/users
can't overwrite each other.
| Field | Type | Notes |
| --- | --- | --- |
| `thread` | relation → `ask_threads`, required, **cascade delete** | |
| `owner` | relation → `users` | |
| `kind` | select | `user` / `assistant` / `attachment` / `image` |
| `text` | text (max 100 000) | The message; for an attachment, its summary; for an image, the prompt |
| `display` | text | The slash command as typed, when applicable |
| `charts` | json | `ChartSpec[]` the answer placed or left unplaced |
| `action_ids`, `attachment_ids`, `tools` | json | Arrays of ids / tool names |
| `file` | file (≤25 MB; xlsx/xls/csv/pdf/png/jpg/webp/txt) | Attachment or generated image |
| `parsed` | json (≤5 MB) | Attachment preview (capped 2 000 rows / 50 000 chars — `read_attachment` re-parses the stored file beyond that), or `{ pdf: {...} }` for a report turn |
| `parse_error` | text | |
**`ask_log`** — one row per thing Ask keeps track of, by `type`.
| Field | Type | Notes |
| --- | --- | --- |
| `type` | select | `change` (proposed data edit, for Undo) / `memory` (lasting fact) / `request` (app change request) |
| `owner` | relation → `users` | |
| `shared` | bool | Memories only — visible to everyone when `true` |
| `thread` | relation → `ask_threads` (optional, **no** cascade) | The log outlives a deleted thread |
| `status` | select | `change`: `pending`/`applied`/`rejected`/`failed`/`undone`. `memory`: `applied` (active) / `undone` (forgotten). `request`: `open`/`planned`/`done`/`declined` |
| `text` | text (max 2 000) | Change summary / memory text / request title |
| `detail` | text | Request detail |
| `site` | relation → `sites` (optional) | A memory about one site |
| `ops` | json | The planned `AgentOp[]`, `$ref`s intact |
| `before`, `after` | json | Snapshots for Undo. `before`: one entry per op. `after`: while `pending`, the proposed values per op; once `applied`/`failed`, `{ results, confirmed_by, undone_by? }` |
| `error` | text | Set on `applied_action` → `failed` |
| `applied_at`, `undone_at` | date | |
Indexes: `ask_threads(owner, archived, last_message_at)`,
`ask_messages(thread, created)`, `ask_log(type, status)`,
`ask_log(owner, type)`, `ask_log(thread)`.
---
## 5. Indexes and constraints summary
| Index | Where | Purpose |
| --- | --- | --- |
| `idx_sites_project_code` | `sites(project_code)` UNIQUE | Prevents duplicate site codes; enforced on the POST /api/sites route |
| `idx_po_number` | `purchase_orders(purchase_order_number)` UNIQUE | Prevents duplicate PO numbers — final backstop behind the app-level dedup in `process-batch` |
| `idx_po_site_status` | `purchase_orders(site, processing_status)` | Per-site pending-PO counts and the n8n cron's fetch-pending query |
| `idx_plant_hours_log_qr_start` | `plant_hours_log(vehicle_qr, start_date)` UNIQUE | Prevents accidental double-imports of the same week for the same vehicle |
| `idx_ph_log_vehicle_start` | `plant_hours_log(vehicle, start_date)` | Per-vehicle history queries |
| `idx_fuel_prices_effective_from` | `fuel_prices(effective_from)` UNIQUE | One price per effective date |
| `idx_dn_site_date` | `delivery_notes(site, date_received)` | Per-site DN listing |
| `idx_dn_status` | `delivery_notes(processing_status)` | Pending-verification dashboards |
| `idx_fuel_logs_site_date`, `idx_fuel_logs_vehicle_date` | `fuel_logs` | Per-site / per-vehicle fuel history |
| `idx_vehicles_last_scanned` | `vehicles(last_scanned_at)` | "Scanned in the last X hours" filters on the map page and the future attention rail |
| `idx_ts_entries_site_date`, `idx_ts_entries_emp_date` | `timesheet_entries` | Per-site / per-employee timesheet queries |
| `idx_timesheets_hash`, `idx_timesheets_status` | `timesheets` (document) | Upload dedup + pending-import dashboards |
| `idx_fuel_sheets_hash`, `idx_fuel_sheets_status` | `fuel_sheets` | Same, for fuel sheet uploads |
| `idx_plant_hours_sheets_hash`, `idx_plant_hours_sheets_status` | `plant_hours_sheets` | Same, for plant hours sheet uploads |
No collection outside the above has a server-enforced unique constraint. In
particular, `vehicles.qr_code_id` is **not** unique — see
[multi-site-assessment §5](./multi-site-assessment.md#5-vehicles-plant-and-employees-moving-between-sites).
---
## 6. PocketBase API rules
`scripts/pb-relax-rules.py` writes the ruleset used in production. For every
collection:
- `listRule`, `viewRule`, `createRule` → `@request.auth.id != ''` (any
authenticated user)
- `updateRule`, `deleteRule` → `null` (superuser only)
The collection list is the full set of 16 — there is no collection where
clients can update or delete records through the API. The
`/api/records/[collection]/[id]/verify` route hand-picks two
collections (`purchase_orders`, `delivery_notes`) for the `is_verified`
toggle, but it runs server-side as `pbAdmin()`, so it is not constrained by
these rules.
The only routes that respect the API rules at all are the (currently
unused) browser-side `pb` client and the n8n file-proxy route
`/api/delivery-notes/:id/slip`, which uses the admin token in its
`Authorization` header. The PB layer never blocks the Next.js server.
---
## 7. Schema drift
`pb-bootstrap-schema.py` is the canonical schema definition — a fresh
PocketBase must come up in the exact shape production is in. That script
had drifted ten-plus migrations behind production before the 2026-09-11
restructure's final task (Task 20) brought it back to truth: it was missing
`fuel_prices` and `income_claims` entirely, still described the deleted
`data_import_batches` and `plant` collections, still carried `project_code`
on eight collections that no longer have it, still had `qty_outstanding` on
`purchase_order_items`, and was missing every index added across the
restructure.
That drift is exactly the failure mode this section exists to name, so the
rule going forward is unchanged from before: **any time the schema changes,
the new field must be added to both `pb-bootstrap-schema.py`** (so a fresh
PB starts in the right shape) **and `src/lib/pb-types.ts`** (so the
TypeScript code agrees), in the same change that adds it to production.
Forgetting either side is a known foot-gun — it is what produced this
section's rewrite in the first place. `scripts/pb-relax-rules.py`'s
collection list is the third thing that must move in lockstep; it drifted
just as far (missing `fuel_prices`, `income_claims` and `plant_hours_log`
entirely, on top of the same renames/deletions).
One-off data-migration scripts under `scripts/migrations/*` are expected to
exist and are not a drift signal by themselves — they are how the schema
*got* to its current shape. The drift signal is `pb-bootstrap-schema.py` (or
`pb-relax-rules.py`) disagreeing with what a migration already did to
production.