This repository is the one the template's README describes: 205 issues, zero
milestones, and severity labels spelled "P0 Critical" / "P1 High" / "P2 Medium",
which privacyllc.dev reports as NOT ADOPTED rather than as 87% complete.
Six markdown records described the same work and none pointed at the tracker.
Two of them said the project was in "Phase 5" while the code was at 0.9.3.
Migrated, then deleted in this commit:
FUTURE.md -> docs/history/BATCH_LEDGER.md (Archived). Its open
items were all already filed as issues, so nothing
needed migrating into the tracker
HISTORY.md -> docs/history/DEVELOPMENT_LOG.md, verbatim, 0 lines lost
DEVELOPMENT_LOG.md -> the same file, as a second labelled block. Not
interleaved: the changelog has three duplicated version
headings, so one date order would have implied more
than the record supports
PROJECT.md -> docs/planning/PROJECT_PLAN.md
STRUCTURE.md -> the agent pipeline into README.md; its versioning rules
retired
BUILD_SUMMARY.md -> BATCH_LEDGER.md. Its embedded SQL schema deliberately
NOT carried: it predated the UNIQUE constraint on
leads.email, and server/index.js owns the schema
SCRIPTS.md -> docs/TOOLS.md, corrected for the SSR + prerender build
Moved with history (git detects all four as renames):
OVERHAUL_PLAN.md, review.md, project-requirements.md, docs/zoho-setup.md
Kept because this project earned them: the five-agent pipeline, the design
system in OVERHAUL_PLAN.md (Status: Current, with a front-note saying which half
is history), the positioning argument in REDESIGN_REVIEW.md, and REQUIREMENTS.md
whole, including its change policy.
Deleted from the template because they do not apply, each said out loud in
DOC_TRUST_MAP.md: QA pass I (no money moves), the authorisation checklist group
and the session-token row (no accounts, no sessions), and one PRECAUTIONARY
paragraph in SECURITY.md about holding credentials on behalf of users — there
are none, and PROJECT_PLAN.md records accounts as out of scope. Pass H was kept
and rewritten: its authorisation half does not apply, its what-a-stranger-can-
reach half is the most exposed surface here.
Also removed: main.js, the old static site's hash router, referenced by nothing
and preserved in .drop/; and test-results/.last-run.json, a May Playwright
artifact reading {"status":"failed"} for a suite that does not exist.
The repository was made private on Forgejo before this commit. That is what let
the internal history be committed rather than exempted — null/fruit-fall is
already private and reports normally.
Two defects found on the way in and fixed here: zoho-setup.md told admins to
edit `server/zoho/`, a directory that has never existed in any commit (the
mapping is in server/index.js), and README.md's route list still advertised
/8x8, removed at 0.6.6, while omitting /privacy-policy.
Branding: icon.webp and logo.webp converted from this project's own marks in
assets/. banner.webp is absent and is filed as an issue rather than faked.
Verified: verify.sh 3/3, doc-claims 71 claimed paths all present, backup and a
first-ever restore of the live leads database (2 tables, 3 rows, under 1s).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| GUARDS.md | ||
| README.md | ||
| zoho-setup.md | ||
README.md
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.
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
-
The privacy policy has two renderers, one source.
src/data/privacyPolicy.jsis the single source of truth;src/pages/PrivacyPolicy.jsxrenders 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. -
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.
forwardLeadToZohodispatches toforwardToZohoWebToLeadorforwardToZohoonZOHO_FORWARDING_MODE; both carry a 10 sAbortController. -
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_ENABLEDis 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. 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.