# Architecture — Queue North Website ``` Status: Current Owner: _null Last reviewed: 2026-08-18 Governs: docs/architecture/**, server/index.js, scripts/** — the module boundaries, the database schema and the API response shapes Review trigger: Any new module, any change to a module boundary or a data shape; any new table or column; any new external service the server calls; any change to how routes are prerendered ``` ## The shape of it One Express process serves everything. There is no separate web server, no reverse proxy inside the container, and no second runtime. ```text browser | v Cloudflare ── qn.isnull.dev | v Express (server/index.js, port 3001) ← the only process | +--> dist/ prerendered HTML + the React bundle, served static +--> /api/health liveness, and a real SELECT against SQLite +--> /api/leads POST → validate → SQLite → (fire and forget) Zoho +--> /api/support POST → validate → SQLite → (fire and forget) Zoho Cases | +--> db/queuenorth.db better-sqlite3, synchronous, single writer | +--> Google reCAPTCHA v3 verify, server-side, before any insert +--> Zoho CRM WebToLead form post, or REST/OAuth as a standby ``` ## Module boundaries **`src/` knows nothing about the database.** It talks to three JSON endpoints through `src/lib/api.js` and nothing else. There is no ORM in the client, no shared schema module, and no import that crosses from `src/` into `server/`. **`server/index.js` knows nothing about React.** It serves `dist/` as static files and falls through to `dist/index.html` for client routes. The one place this is not quite true is the privacy policy, below. **`src/data/*.js` is the content layer.** Services, industries and the privacy policy text live there as plain data, imported by both the client pages and `src/entry-server.jsx`. Adding a service is a data edit, not a component edit. **`scripts/prerender.js` is a build step, not a runtime.** It renders every route to static HTML at build time using `src/entry-server.jsx`. Nothing at request time renders React on the server. ### The three boundaries worth knowing about 1. **The privacy policy has two renderers, one source.** `src/data/privacyPolicy.js` is the single source of truth; `src/pages/PrivacyPolicy.jsx` renders it in the SPA and the prerender step emits a static copy. This exists because Meta's crawler does not execute JavaScript, and a policy it cannot read is a policy that does not count. **Do not add policy text to a component.** 2. **Zoho is an overlay, never a dependency.** Every handler writes SQLite first and then calls the forwarder without awaiting it. A Zoho outage, a bad token or a network timeout costs a CRM record and never a lead. `forwardLeadToZoho` dispatches to `forwardToZohoWebToLead` or `forwardToZoho` on `ZOHO_FORWARDING_MODE`; both carry a 10 s `AbortController`. 3. **reCAPTCHA is the one thing that runs *before* the insert.** It is the only check that can reject a submission outright, so it is deliberately the only external call on the blocking path — with a 5 s timeout, and it fails open when `RECAPTCHA_ENABLED` is false. ## Data shapes `server/index.js` owns the schema and applies it at startup via `initSchema()`. There is no external migration runner; the one migration that exists rebuilds `leads` to add the `UNIQUE` constraint and is idempotent. ### `leads` | Column | Type | Notes | | --- | --- | --- | | `id` | INTEGER PK AUTOINCREMENT | | | `company` | TEXT NOT NULL | max 200 after sanitisation | | `name` | TEXT NOT NULL | max 100. Split on the last space for Zoho's `First_Name` / `Last_Name` | | `email` | TEXT NOT NULL **UNIQUE** | max 254 (RFC 5321). A duplicate answers **409**, and the Zoho forward is still attempted — the local row existing does not mean the CRM record does | | `phone` | TEXT | | | `zip` | TEXT | maps to Zoho `Zip_Code` | | `message` | TEXT | | | `service_interest` | TEXT | normalised from empty to NULL. Maps to Zoho `Description`, not a custom field | | `created_at` | DATETIME | `CURRENT_TIMESTAMP` | ### `support_requests` | Column | Type | Notes | | --- | --- | --- | | `id` | INTEGER PK AUTOINCREMENT | | | `name`, `company`, `email` | TEXT NOT NULL | **no** UNIQUE — the same customer may raise many tickets | | `phone` | TEXT | | | `issue` | TEXT NOT NULL | minimum 10 characters, enforced client and server side | | `priority` | TEXT | defaults to `medium` | | `created_at` | DATETIME | `CURRENT_TIMESTAMP` | **The asymmetry between the two tables is deliberate.** A lead is a person you want once; a support request is an event that recurs. Adding `UNIQUE` to `support_requests.email` would silently drop a customer's second ticket. ### Every response shape the API produces | Status | Body | When | | --- | --- | --- | | 200 | the resource, or `{status, db, timestamp}` | success | | 400 | `{error: 'Validation failed', fields: {…}}` | Zod rejected it | | 403 | `{error}` | reCAPTCHA below `RECAPTCHA_MIN_SCORE` | | 404 | `{error: 'Not found'}` | unmatched `/api/*` only; other paths fall through to the SPA | | 409 | `{error}` | duplicate `leads.email` | | 413 | — | body over 1 MB | | 429 | `{error, message, retryAfter}` | rate limiter | | 500 | `{error}` | never a stack trace | | 503 | `{error, db: 'error'}` | health check could not reach SQLite | | 504 | `{error: 'Request timeout'}` | the 30 s request timeout fired | ## Documents here - **`GUARDS.md`** — how to write a check that actually checks. Read it before adding a structural test or a probe; every rule in it was learned from a guard that had been green over something broken. - **`zoho-setup.md`** — the CRM integration end to end: app setup, credentials, environment variables, and how to confirm a lead arrived. ## What ships in `scripts/` Ten scripts came from the template on 2026-08-18 and three were already here. The template's full catalogue is a **menu**, not an inventory — see [`../TOOLS.md`](../TOOLS.md). This table is what this project actually has, and each row says what it does *here*. | Path | What it is | | --- | --- | | `scripts/check-env.sh` | which of the 17 Zoho / reCAPTCHA / CORS / rate-limit variables are set and plausible, before the server reads them. Exit 2 means nothing was checked | | `scripts/secrets.sh` | credential shapes in a staged diff, and `--tracked` for a whole-tree audit. **`--built dist/` is the one that matters here**: `VITE_RECAPTCHA_SITE_KEY` is inlined into the bundle at build time, so the repository scan cannot see what users receive | | `scripts/verify.sh` | every check this project has, in one table. Honestly thin — there is no test suite, and it says so rather than printing a green row | | `scripts/doc-triggers.py` | which documents a pending change fires, read from the `Governs:` headers. Run it before committing, not after | | `scripts/forgejo-issue.py` | files and closes issues in the tracker convention, refusing malformed ones before they are filed | | `scripts/status.sh` | what is running on **nebula** as `qn-website-dev`, its version and its restart count. Read-only | | `scripts/healthcheck.sh` | a liveness tick against `qn.isnull.dev`, asserting HTTP 200 **and** `"status":"ok"` — a 503 with a JSON body is a real answer, not an outage | | `scripts/preflight.sh` | headers and TLS against the live origin. No `--auth` checks: there are no accounts | | `scripts/backup.sh` | a verified SQLite dump. Its ENGINE block was rewritten for better-sqlite3's online `.backup()` — see below | | `scripts/restore-check.sh` | restores the newest dump into a scratch file, runs `PRAGMA integrity_check`, counts tables, and **times it**. A backup nobody has restored is a guess | | `scripts/docker-push.sh` | builds and pushes `queue-north-website:dev` to the Forgejo registry. Predates the template | | `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template | | `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template | **Why `backup.sh` and `restore-check.sh` are not the template's originals.** Both ship as PostgreSQL tools. `backup.sh` is built to be adapted — everything engine-specific is in one ENGINE block — so that block now calls `better-sqlite3`'s `.backup()` inside the running container and verifies the result with `sqlite3` before renaming it into place. `restore-check.sh` had no such seam: it is `pg_restore` and `psql` end to end, so the SQLite version is a rewrite that keeps the argument and replaces the mechanism. ## What does not belong here - Product intent — that is `docs/planning/PROJECT_PLAN.md` - Engineering standards and the stack policy — that is `docs/planning/REQUIREMENTS.md` - What it should look and sound like — that is `docs/design/` - What happened while building it — that is `docs/history/` ## A note on drift Architecture docs go stale faster than any other kind, because code changes under them silently. This is exactly what the **Review trigger** line is for: name the change that should send somebody back here, and a reader can tell whether the trigger has fired. The specific instance to avoid in this repository: `BUILD_SUMMARY.md` carried a copy of the SQL schema that predated the `UNIQUE` constraint on `leads.email`. It was not carried forward on adoption. **`server/index.js` owns the schema; the tables above describe it and do not duplicate it.**