227 lines
13 KiB
Markdown
227 lines
13 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
|
|
|
|
|
+── 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` **and** move `:dev`, 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 tag, takes a verified backup first, preserves the stack's twelve environment variables, then waits for health and checks both public origins. Reports the digest before and after |
|
|
| `scripts/docker-push.sh` | builds and pushes `queue-north-website:dev` only, with no version bump and no guards. Predates the template and is **superseded by `release.sh`** — kept for now because it is what the muscle memory reaches for |
|
|
| `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 `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 moves `:dev` as well as publishing `:vX.Y.Z`,** because stack 58 follows
|
|
`:dev`. That makes `:dev` a pointer and never evidence — `status.sh` reads the
|
|
digest and the version label for that.
|
|
- **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.**
|