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>
12 KiB
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. For the
end-to-end ingestion pipelines, see data-flows.md.
1. Runtime topology
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:
- 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.
- 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.
- 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-sidepbclient insrc/lib/pb.ts:36exists 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 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 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(default2468,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
sessionStorageundermega-auth-verified(src/hooks/use-auth.tsx:6). AuthGate(src/components/auth-gate.tsx) redirects to/loginwhenever the flag is missing, but does so client-side — every server component below it still runs on the server.- The
?demo=1PIN 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.
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:
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. The
Site page, 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 - Per-module behaviour and routes —
modules.md - Ingestion pipelines (timesheets, fuel, plant, PO, DN) with sequence diagrams —
data-flows.md - Every API route with method / auth / request / response / errors —
api-reference.md - Env vars, scripts, tests, feature flags —
operations.md - Whether this app actually holds up at 50 sites —
multi-site-assessment.md