# 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
  |
  +── queuenorth.com ──► nginx-proxy-manager (thor/exodus)  ┐   PRODUCTION
  |                                                          │
  +── qn.isnull.dev ───► Cloudflare ─────────────────────────┤
                                                             v
Express (server/index.js, port 3001) on nebula   ← the only process, one instance
  |
  +--> 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. Carries this project's own shapes, and one **tightened** pattern — see below |
| `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 `queuenorth.com`, asserting HTTP 200 **and** `"status":"ok"` **and** `"db":"ok"` — a 503 with a JSON body is a real answer, not an outage. `HEALTHCHECK_BASE_URL` points it at the other front door |
| `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/release.sh` | **publishes.** Bump, guard, build, verify the image's own version label, push `:vX.Y.Z`, commit last, tag. Refuses to overwrite a published tag, to build on a dirty tree, or to build when the three hard-coded copies of the public origin disagree. Adapted from PrivacyLLC-Web's — see below |
| `scripts/deploy.sh` | **deploys, and does not build.** Points Portainer stack 58 at an already-published **numbered** version — it refuses a floating tag — taking a verified backup first and preserving the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |

| `scripts/docker-test.sh` | builds the image and runs it locally on 3001. Predates the template |
| `scripts/qa-browser.mjs` | renders the site in real Chromium at real viewports and **measures** it — horizontal scroll, broken images, elements past the right edge, CLS and LCP. Exit 2 if playwright is missing, because "could not check" is not a pass. Not in `verify.sh`: playwright is global here, not a project dependency |
| `scripts/prerender.js` | the build step that emits static HTML for every route. Predates the template |

**Why `release.sh` and `deploy.sh` are two scripts.** Publishing an image and
running it are separate decisions, which is the rule both the template and
PrivacyLLC-Web's release script state and the reason neither of them deploys.
The template's `deploy.py` does build, push *and* deploy; adopting it beside
`release.sh` would have produced two commands that both build, a second image
for the same code, and two answers to "what is running". So `deploy.sh` does
only the half that was missing.

Three things about `release.sh` differ from the script it was adapted from, and
each is a fact about this project rather than a preference:

- **It gates on `verify.sh`, not on a test suite,** because there is not one.
  The original refuses to release on a half-run 1,600-test run; this one says out
  loud that a build, a secret scan and a doc-header check are not tests and that
  nothing in the gate exercised a route, a form or an API response.
- **It publishes one tag and does not move `:dev`.** Both scripts follow the
  policy below: production always runs a numbered version, so nothing deploys a
  pointer and publishing one would only misrepresent what is running.
- **It checks the public origin in three files rather than one.** The original
  passes its origin in as a build arg, so it has one copy to validate. Here
  `https://queuenorth.com` is written out in `src/lib/seo.js`,
  `src/components/SEO.jsx` and `scripts/prerender.js`, and it is baked into every
  canonical URL, `og:url`, `sitemap.xml` and `robots.txt`. The guard asks whether
  the three still agree, because a wrong origin cannot be corrected without
  another build and is invisible until somebody reads the page source.

**Neither script prunes the registry.** The original does, because it releases
often enough for that to matter. This project has published thirteen tags in its
life, deleting a published image is irreversible, and the one that matters is
whichever the running container was created from — exactly what a newest-N rule
gets wrong.

**Why `secrets.sh` has a tightened pattern.** Its `user:pass@host in a URL`
rule excludes only `/`, `@`, `:` and whitespace in the template. That is right
for source and wrong for this project's build output: every prerendered page
carries schema.org JSON-LD, and `//queuenorth.com"},"areaServed":{"@` parses as
a host, a password and an `@`. Ten findings per `--built` run, one more for
every page added — the noise that turns a scanner into something people mute.
Quotes, braces, commas and angle brackets cannot occur in real userinfo, so
excluding them costs nothing and was checked against three real credential URLs
before being applied. The reasoning is in the script's own header.

**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.**
