# 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)" SITES ||--o{ SITE_MODELS : "site (relation)" SITES ||--o{ SITE_PROGRESS_EVENTS : "site (relation)" SITE_MODELS ||--o{ SITE_PROGRESS_EVENTS : "model (relation)" SITES ||--o{ SITE_SURVEY_IMPORTS : "site (relation)" 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` | #### Worker status `employment_status` is an optional select (`active`, `inactive`). Blank status is also treated as active for compatibility with older imports. `inactive_from` is an optional date recording when the worker stopped working. The status takes effect immediately; future inactive dates are rejected by the app. The workforce Needs attention rows offer **Mark inactive** beside **Add rate**. Worker detail offers the same control and **Reactivate worker**. The worker list hides inactive workers by default and provides **Include inactive workers**. Inactive workers are excluded from current headcounts, missing-rate alerts, attendance alerts and new clock-ins. A pending same-day clock-out can still be completed. Recorded timesheets, rate history and historical payroll costs stay available. Reactivation clears `inactive_from`; it does not change rates or hours. The live migration initialized all 156 existing workers as active. No worker is automatically inactivated because they are absent from a wage list. Run `scripts/pb-add-worker-status.py` to add the schema fields and initialize blank statuses in another environment; it preserves existing inactive statuses. #### Employee rate history: `employee_rates` Created by `scripts/pb-add-employee-rates.py`. One row per employee per effective calendar day; the next effective date implicitly ends the previous period. There is no fixed limit on annual changes. | Field | Type | Notes | | --- | --- | --- | | `employee` | required relation → `employees` | Existing worker record; deletion does not cascade | | `effective_from` | required date | Inclusive business date, written at UTC midnight like `date_logged` | | `rate_per_hour` | required number | Positive hourly rate; PocketBase's required number rejects zero | | `notes` | text | Optional source or reason for change | | `created`, `updated` | autodate | Automatic record timestamps | Unique index on employee and the calendar-day portion of `effective_from` prevents duplicate changes even when timestamps differ within that day. A second employee/date index supports dated lookups. Read rules inherit employee visibility; create/update/delete are superuser-only. **Rollout status:** 169 September/October history records are imported; existing employee baseline rates are preserved. The workforce overview uses dated history and provides a missing-rate dialog. Other cost calculations still use `employees.rate_per_hour`; connect them to dated history before updating that legacy baseline. The imported payroll lists supply previous and new rates, with new rates effective 2026-10-01. Previous rates need an effective date covering their actual known period; do not invent rates for earlier years. Missing historical coverage must be flagged, never silently priced at the newest rate. Imports match employee numbers globally, preserve existing site assignments, and place only missing/unassigned workers at Mega Head Office. History lookups use the date worked, not the upload date or a site's reporting-month boundaries. ### 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 | | `import_key` | text | Unique non-empty site + SHA-256 claim; blocks concurrent repeat uploads | | `duplicate_of` | text | Canonical document ID for a verified historic duplicate; source file retained | | `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; preserved even on excluded duplicate rows | | `excluded_duplicate` | bool | Excluded from every application list read and calculation by the shared PocketBase client | | `duplicate_of` | text | Canonical entry ID for audit | | `import_key` | text | Unique non-empty employee/site/day claim for imported daily totals | | `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"`) | ### 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)`. --- ### `site_models`, `site_progress_events`, `site_survey_imports` (Site map) Created by `scripts/migrations/2026-10-14-site-map.py`. All API rules are `null`; the dashboard reads and writes them through `pbAdmin()` only. Spec: `docs/superpowers/specs/2026-10-14-site-map-phase1-design.md`. **`site_models`** — one row per model revision of a site; at most one `active` per site (enforced by `scripts/site-model/upload.py`, which archives the previous one). | Field | Type | Notes | | --- | --- | --- | | `site` | relation → sites, required | | | `label` | text, required | e.g. "Bushveld Joy Road — RD LO Rev 1" | | `status` | select `active` / `archived` | | | `files` | file (max 10, JSON, protected) | `bushveld`, `services`, `earthworks`, `schedules`, `surface` `.json` | | `sources` | json | source drawings the model was built from | | `stats` | json | headline figures | | `built_at` | date | | **`site_progress_events`** — append-only, one row per change. Current state = the latest event per (`site`, `key`). Keys: see `src/lib/site-map/keys.ts`. | Field | Type | Notes | | --- | --- | --- | | `site` | relation → sites, required | | | `model` | relation → site_models, required | model the change was made against | | `key` | text, required | e.g. `road:ch240-260:asphalt:asphalt-30mm-medium-graded`, `peg:BJ45` | | `value` | number | meaning depends on the key type | | `user`, `user_name` | text | who made the change | | `source` | select `manual` / `survey_import` / `admin_clear` | | | `created` | autodate | | Index: `(site, key, created)`. **`site_survey_imports`** — `site`, `model`, `file` (CSV, ≤ 1 MB, protected), `matched` (number), `unmatched` (json: point, nearest peg, distance), `user`, `user_name`, `created`. ## 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. ### Worker sick notes: `employee_sick_notes` A separate private collection stores a required relation to `employees`, the required `note_date` (the first day covered), optional `end_date` (the inclusive last day off), a required protected `photo` (JPEG, PNG or WebP, up to 10 MB), and an upload `created` timestamp. Multiple notes can be retained for the same date or overlapping ranges. No employee or payroll data is changed by uploading a note. Notes remain accessible on inactive worker detail pages. All collection rules are locked to the superuser; the app validates worker site access before uploading or streaming a photo. The photo endpoint checks that the note belongs to the worker and uses private, no-store responses. Run `scripts/pb-add-sick-notes.py` to create and verify the collection in another environment. Run `scripts/pb-add-sick-note-ranges.py` on existing installations to add end dates and backfill single-day notes without altering their photos, original dates or employee records. Missing end dates are treated as single-day notes. Date edits update the original note after origin, authentication, worker site and note ownership checks. Worker detail shows timesheet history one populated Monday–Sunday week at a time, starting with the latest recorded week, with Previous/Next and a week selector. Overall totals still include all recorded weeks. Sick notes appear above timesheet history, newest first, with ten notes per page. Each note has Open and Edit dates actions; the photo viewer also offers Edit dates. Uploads and edits accept a start date and last day off, both inclusive. ### Worker monthly attendance calendar The detail page replaces the eight-week sparkline with a Monday-first month calendar. Recorded hours are summed per date; normal weekdays are green, weekend or above-standard hours orange, sick-note dates blue and incomplete scans, short days or expected weekdays with no work recorded red ("Needs checking", never confirmed absence). Non-working weekends stay neutral, including weekends within sick-note ranges; their documents remain available in the day popup. Recorded weekend hours or clock scans retain their attendance status. Future weekdays covered by a sick note are blue; other future days, days before the worker's first recorded history and inactive days without hours stay neutral. Expected workdays default to Monday–Friday; no payroll or leave decision is automated. Saved missed-clock-out corrections are respected. Imported timesheets count without requiring clock scans. The day popup displays hours, scans, issues, all sick-note links covering that day and modelled rainfall. `/api/employees/[id]/calendar-weather?month=YYYY-MM` requires Workforce access and employee site access. Rain uses each day's recorded timesheet/scan site, not the worker's latest assignment. Multiple-site days, sites outside access, missing coordinates or missing weather readings remain unknown. The server uses Open-Meteo archive data and recent past forecast data, cached for an hour with a six-second upstream timeout. Only completed dates are included, and rainfall is displayed as estimated context, never a confirmed reason for a short shift. Weather failures do not prevent attendance from rendering. ### Timesheet matrix for HR The Timesheets screen opens a site/date-range preview and CSV or A4 landscape PDF exports through `/api/timesheets/matrix`. Dates are inclusive, up to 93 days; calendar weeks run Monday–Sunday and partial weeks total only selected dates. A shared model aggregates integer minutes and renders hours as HH:MM, including weekly, worker and site totals. Entries retain their recorded site, even after employee transfers. The preview pages workers; exports include all workers. Rows include the site's current active roster plus workers with recorded hours in the selected period. Inactive dates stay grey, while historical work remains included. No hours recorded is not confirmed absence. Today/future gaps stay neutral, non-working weekends grey, covered sick weekdays blue and recorded weekend hours orange. Multiple entries on one worker/site/date are summed and marked with an asterisk; CSV has a count of these days. No records are deduplicated or changed by reporting. Historical assignment start dates are not inferred. The API requires Workforce access and checks the selected site against permitted sites. “All sites” means only permitted sites. Private sick-note dates are read only for workers whose current site the user can access, matching worker-detail permissions. Exports contain dates/statuses, never medical images. All responses are private/no-store. CSV text neutralizes spreadsheet formula prefixes. PDF splits dates into sections of up to two weeks, repeats table headings/site labels on continuation pages, and retains the full-period total in each section. Timesheet duplicate protection: `src/lib/pb.ts` installs the shared `billableTimesheetRequest` hook on browser and admin clients. It adds `excluded_duplicate=false` to entry list queries while preserving existing site, worker and date filters. Individual record IDs remain readable for audit. External integrations reading PocketBase directly must apply this filter too. Imports SHA-256 the source bytes and use database unique keys to prevent concurrent repeat files and worker/site/day totals. A daily record already present (including direct clock records) produces a row conflict for review instead of adding hours. Failed imports with no entries release their file claim; partial imports retain it to avoid repeating successful rows. Review missing rows on the original import before retrying. `scripts/pb-deduplicate-timesheets.py` defaults to a read-only audit and private backup. `--apply` installs the schema, hashes historic source files, and flags only entries matched to an identical source file and canonical employee/site/date/hours record. It preserves original hours, entries and files. The October 7 audit excluded 427 repeated entries / 3,659.5 hours.