Queue-North-Website/docs/architecture/README.md

183 lines
9.4 KiB
Markdown

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