183 lines
9.4 KiB
Markdown
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.**
|