The query string skipped the PIN gate on any URL. The PIN is now the only way in. Docs updated; the login plan notes there is no fixed cutover date while the app is in on-site testing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
12 KiB
Markdown
233 lines
12 KiB
Markdown
# 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)
|