MEGAINFRA-WEBAPP/docs/architecture.md

234 lines
12 KiB
Markdown
Raw Permalink Normal View History

# Architecture
> MEGA INFRASTRUCTURE — a Next.js 16 operations dashboard for a South African
> civil-construction company, backed by PocketBase, with n8n handling AI-powered
> document ingestion.
This document is a map of how the system fits together: where data lives, who
reads it, who writes it, and what the trust boundaries are. For *what* the
collections look like, see [`data-model.md`](./data-model.md). For the
end-to-end ingestion pipelines, see [`data-flows.md`](./data-flows.md).
---
## 1. Runtime topology
```mermaid
flowchart LR
Browser["Browser (PWA)"]
Next["Next.js 16\nApp Router · React 19 · Tailwind v4"]
PB["PocketBase\n(shared via Traefik / TLS)"]
n8n["n8n workflows\n(PO ingest · DN OCR)"]
Anthropic["Anthropic / MiniMax LLM"]
MCP["Local MCP server\n(stdio · read-only)"]
Browser -- "HTTPS\n(cookie-less, PIN-only)" --> Next
Browser -- "Service worker\n(public/sw.js)" --> Browser
Next -- "server components\n& route handlers\nvia pbAdmin()" --> PB
Next -- "POST /api/chat\nSSE stream" --> Anthropic
Next -- "POST /api/purchase-orders/upload\nqueued batch row" --> PB
n8n -- "download file via /api/delivery-notes/:id/slip\nx-ingest-secret" --> Next
n8n -- "POST /api/delivery-notes/:id/ocr-result\nx-ingest-secret" --> Next
n8n -- "POST /api/purchase-orders/process-batch\nx-ingest-secret" --> Next
Anthropic -. "tools/calls" .-> Next
MCP -- "stdio\n(env-based admin creds)" --> Next
```
Three things matter about this picture:
1. **There is exactly one database.** PocketBase holds every collection
(sites, employees, vehicles, fuel logs, plant hours, POs, delivery notes,
income claims, fixed costs, data import batches). The Next.js app and the
MCP server both read/write it directly; there is no intermediate service.
2. **PocketBase itself does the file storage** for purchase-order PDFs and
delivery-note slip images. Routes stream those bytes in and out via the
PB admin client.
3. **The browser never talks to PocketBase directly in production.** Every PB
call from a server component or API route goes through `pbAdmin()` (see
§3). The browser-side `pb` client in `src/lib/pb.ts:36` exists for
completeness but no dashboard page uses it today.
---
## 2. The two PocketBase clients
`src/lib/pb.ts` is the single source of truth for all PocketBase access. It
exports two clients and you must use the right one for the job.
| Client | Where it runs | Auth | Used by |
| --- | --- | --- | --- |
| `pb` (`src/lib/pb.ts:36`) | Browser | Persisted JWT in `localStorage` (key `mega-pb-auth`) | Currently unused — kept for future client-side use |
| `pbAdmin()` (`src/lib/pb.ts:74`) | Server only | Superuser (email + password **or** preset token), cached in module scope | Every server component, every API route, the MCP server |
**Why this matters.** `pbAdmin()` authenticates as the PocketBase
**superuser**. That means the application's backend has read/write access to
every collection, irrespective of the per-collection API rules in
`scripts/pb-relax-rules.py`. The relaxed rules there are a defence-in-depth
measure that only constrains *direct* browser-to-PB access — which we do not
do. See [operations.md §3](./operations.md#3-pocketbase-api-rules) for the
exact rule set.
**Auto-cancellation off on `pbAdmin()`.** The SDK cancels in-flight requests
when a new request shares the same `(method, URL-path)` key. On the server,
the module-level client cache means every concurrent route handler shares
**one** PocketBase client, so the second concurrent request to the same
endpoint would abort the first and produce cascading `AbortError` 500s.
The code disables auto-cancel via a typed double-cast
(`src/lib/pb.ts:102`) — see the long comment at `src/lib/pb.ts:88-101` for
the full story.
The dashboard pages that fan out parallel reads also pass
`$autoCancel: false` explicitly on their own calls (e.g.
`src/app/dashboard/page.tsx:71` for the plant count, `src/app/dashboard/purchasing/page.tsx:22-25` for the needs-review DNS lookup). This is the
defensive belt-and-braces for the same problem.
---
## 3. Rendering model
Every dashboard page is a React server component (`export const dynamic = "force-dynamic"` is set on every page). Data fetching happens in
the page body before render; the only client work is the sidebar
(`src/components/layout/sidebar.tsx`, `"use client"`) and the few interactive
forms (chat, import buttons, inline edit).
| Surface | Rendering | Data fetching |
| --- | --- | --- |
| `/login` | Static + client hydration | None |
| `/dashboard` (Overview) | Server component | Parallel `getList` + `getFullList` via `Promise.all` in `loadOverview()` |
| `/dashboard/sites` | Server component | Seven parallel `getFullList` calls in `src/app/dashboard/sites/page.tsx:24-50` |
| `/dashboard/sites/[id]` | Server component | Per-site `getFullList` filters for fixed costs, POs, fuel logs, vehicles, income; **timesheets + employees fetched unfiltered** |
| `/dashboard/fuel` | Server component | `getFullList` of vehicles + fuel logs with `expand: "vehicle"` |
| `/dashboard/employees` | Server component | `getFullList` of employees (with `expand: "site"`), timesheets, sites |
| `/dashboard/plant` | Server component | `getFullList` of vehicles (`perPage: 5000`), fuel logs, sites |
| `/dashboard/purchasing` | Server component | `getFullList` of POs, PO items, DNs, sites, received items |
| `/dashboard/chat` | Server component (gated by `chatEnabled()`) | Provider config only — message stream happens client-side via SSE |
| `/api/**` | Route Handlers | One shot per request |
There is no client-side PocketBase fetching in any current page. The pattern
is: pull everything server-side, hand the view component a fully-shaped
`rows` / `summaries` array, render. This is the simplest pattern that
delivers the current data volumes (single-digit thousands of rows), and is
exactly what makes the multi-site assessment in
[`multi-site-assessment.md`](./multi-site-assessment.md) say "Tier 2 — this
stops scaling at ~20-30 sites" rather than "it broke last Tuesday".
---
## 4. Auth model
There is no real authentication. The "auth gate" is a shared numeric PIN
held in `sessionStorage` after a client-side string compare.
- PIN value comes from `NEXT_PUBLIC_APP_PIN` (default `2468`,
`src/hooks/use-auth.tsx:20`). It is exposed to the browser because the
lock screen validates it locally before showing the dashboard.
- Verification state is stored in `sessionStorage` under
`mega-auth-verified` (`src/hooks/use-auth.tsx:6`).
- `AuthGate` (`src/components/auth-gate.tsx`) redirects to `/login` whenever
the flag is missing, but does so client-side — every server component
below it still runs on the server.
- The `?demo=1` PIN bypass was removed on 2026-09-26 — the PIN is the
only way in.
- There is **no per-user identity anywhere in the application**. Every user
who knows the PIN sees every site's contract values, payroll rates, and
margins.
The PocketBase side, again, runs as superuser. PocketBase API rules
(`scripts/pb-relax-rules.py`) allow any authenticated user to list / view /
create on every collection, and reserve update / delete for the superuser.
Since the server is the superuser, *every* PB operation the app performs is
update-or-delete-eligible.
The "auth" header the chat API checks is `ENABLE_CHAT=true`, not a user
credential. See the `ENABLE_CHAT` note in [`operations.md §1`](./operations.md#1-environment-variables).
---
## 5. Build, deploy, and the `--webpack` requirement
| Layer | Tool | Notes |
| --- | --- | --- |
| App framework | Next.js 16 (App Router) | Modified build — see `AGENTS.md` and the patch in `scripts/patch-next-dev-blocker.cjs` |
| PWA | `@ducanh2912/next-pwa` | **Webpack-only**, so `next dev` and `next build` both pass `--webpack` (`package.json:6-7`) |
| Styling | Tailwind v4 (`@tailwindcss/postcss`) | `src/app/globals.css` is the only stylesheet |
| Charts | Recharts | Used for income-vs-expense, daily fuel cost, weekly hours |
| File parsing | SheetJS (`xlsx`) + PapaParse (`papaparse`) | All importers accept both XLSX and CSV through the same route |
| Test runner | Vitest | `npm run test:run` |
| Container | Multi-stage `Dockerfile` | `deps` → `builder` (Next standalone) → `runner` (non-root, port 3000) |
| Reverse proxy | Coolify / Traefik | `COOLIFY.md` has the full Coolify v4 setup |
| Healthcheck | `GET /api/health` | `src/app/api/health/route.ts` returns `{ status, uptime, timestamp }` |
The `npm run build` pipeline runs `npm run icons && npm run og-image && next
build --webpack` in that order (`package.json:7`). `scripts/build-icons.mjs`
regenerates the PWA icon set and `scripts/build-og-image.mjs` regenerates
`/og-image.png`; both must run before the Next build so the static files
exist when the PWA plugin captures the manifest.
The standalone server in the final image binds `${PORT:-3000}` on
`0.0.0.0` (`Dockerfile:64-65`) and runs as a non-root `nextjs` user. Static
assets are copied in from `.next/standalone` + `.next/static`; the
`output: "standalone"` mode does **not** bundle `.next/static` automatically
(`Dockerfile:77-80`).
---
## 6. Cross-cutting infrastructure
### 6.1 Site resolution
Free-text site lookup goes through one helper,
[`resolveSite`](./multi-site-assessment.md#2-site-resolution-by-name-string):
exact id → exact `project_code` → exact `name` → substring on either
(`src/lib/agent/resolve.ts:21-48`). Ambiguity is returned explicitly as a
`"ambiguous"` result, never silently picked. The same shape exists for
vehicles (`resolveVehicle`).
### 6.2 Shared metrics
Every page that computes financials imports from
[`@/lib/metrics`](./modules.md#shared-metrics-surface). The
[`Site` page](./modules.md#2-sites), the **Overview** page's monthly-fixed-cost
tile, the **chat** tool layer, and the **MCP** server all build on the same
pure functions (`computeSiteSpend`, `computeSiteProjection`,
`buildWeeklyIncomeVsExpenses`, etc.). The tests under
`src/lib/metrics/*.test.ts` pin every computation — a regression in the
number is a failed test, not a silent bug in the dashboard.
### 6.3 The batch state machine
Every data import creates a `data_import_batches` row. The state machine is
in `src/lib/batch-state.ts`:
```
pending → processing → processed
↘ failed → (retry) → pending
↘ dead
```
`nextBatchState({ lastResult: "fail", attempts })` advances `failed → dead`
after `MAX_ATTEMPTS` retries (`src/lib/batch-state.ts` and the call in
`src/app/api/delivery-notes/[id]/ocr-result/route.ts:486-489`). Timesheets,
fuel logs, and plant hours are born `processed` because their imports are
synchronous (no AI, no queue). Purchase orders are born `pending` and
processed by n8n. Delivery notes are born `pending` and processed by the
OCR webhook.
### 6.4 Where the numbers reconcile
`Site` page, **Sites** list page, Overview tile, and the chat's
`list_sites` tool all feed the same buckets into
`computeSiteSpend`. So "what's the burn on The Hills?" returns the same
rand value from any surface. The single most load-bearing property of this
codebase.
---
## 7. What this document deliberately does not cover
- **Collection shapes and field-level detail** — [`data-model.md`](./data-model.md)
- **Per-module behaviour and routes** — [`modules.md`](./modules.md)
- **Ingestion pipelines (timesheets, fuel, plant, PO, DN) with sequence diagrams** — [`data-flows.md`](./data-flows.md)
- **Every API route with method / auth / request / response / errors** — [`api-reference.md`](./api-reference.md)
- **Env vars, scripts, tests, feature flags** — [`operations.md`](./operations.md)
- **Whether this app actually holds up at 50 sites** — [`multi-site-assessment.md`](./multi-site-assessment.md)