diff --git a/.gitignore b/.gitignore index 174fd64..716aa8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,12 @@ -# Private project/agent docs — never commit -DEVELOPMENT_LOG.md -PROJECT.md -STRUCTURE.md -FUTURE.md -HISTORY.md -BUILD_SUMMARY.md -SCRIPTS.md -.drop/ +# Credentials and the original site drop — never commit, private repo or not. +# zoho.md holds the live reCAPTCHA secret key and the Zoho WebToLead tokens. +# See docs/security/SECURITY.md. zoho.md +.drop/ +# Client correspondence. Its asks are tracked as issues; the letter itself is not +# a doc-tree document. +Levi.md # Dependencies node_modules/ @@ -20,6 +18,7 @@ dist-ssr/ # Runtime/database artifacts db/*.db db/*.db-* +test-results/ # Environment/local files .env @@ -38,6 +37,5 @@ pnpm-debug.log* .vscode/ .idea/ .learnings/ -Levi.md Queue-North-Website.code-workspace Working Site.zip diff --git a/README.md b/README.md index d2704f0..389463e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ # Queue North Website +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: package.json, src/routes.jsx, Dockerfile, docker-compose.yml — the + project-facing overview, whose stack, route list and Docker story + all describe those files +Review trigger: Any change to the stack, the route list, the Docker or + deployment story, the agent pipeline, or where a document lives +``` + +The business website for Queue North Technologies — an 8x8 Certified Partner +delivering UCaaS, contact centre, networking and managed support for SMB and +enterprise organisations. Live at +[qn.isnull.dev](https://qn.isnull.dev). + ## Objective Queue North Website is the modern rebuild of the Queue North Technologies business website. @@ -65,86 +81,73 @@ Primary structure: - Dark navy conversion band - Consultation-focused message -## Planned Routes +## Routes ```txt -/ -/about -/services -/services/unified-communications -/services/contact-center -/services/managed-support -/services/consulting-training -/services/infrastructure-cabling -/services/wireless-access -/services/local-networking -/industries -/industries/healthcare -/industries/retail -/industries/manufacturing -/industries/education-finance -/8x8 -/contact -/support +/ Home +/about About +/services Services index +/services/:slug 7 service detail pages +/industries Industries index +/industries/:slug 4 industry detail pages +/contact Contact form → POST /api/leads +/support Support form → POST /api/support +/privacy-policy Privacy policy +* 404 ``` -## Overhaul Phases +Defined in `src/routes.jsx`. **Every one of them is prerendered to static HTML at +build time** by `scripts/prerender.js` — the SPA hydrates on top. `/privacy-policy` +additionally has a server-rendered fallback because Meta's crawler does not run +JavaScript. -Version numbers correlate directly to the active phase: +The standalone `/8x8` route was removed at `0.6.6`; that content now lives inside +the UCaaS and contact-centre service pages. -- **Phase 1 — Stack Scaffold**: `0.1.x` ✅ Complete - - ~~Vite + React app foundation~~ ✅ - - ~~Tailwind CSS setup~~ ✅ - - ~~shadcn/ui-style primitives~~ ✅ - - ~~React Router~~ ✅ - - ~~Express backend~~ ✅ - - ~~better-sqlite3 database~~ ✅ - - ~~Initial API health/contact/support paths~~ ✅ +## Where the work is -- **Phase 2 — Layout Rebuild**: `0.2.x` ✅ Complete - - ~~App shell: Header, Footer, layout wrapper, mobile nav~~ ✅ - - ~~Route pages fully built and navigable~~ ✅ - - ~~Existing business content ported into React~~ ✅ - - ~~Repeated service/industry content moved into data files~~ ✅ - - ~~Static hash routing fully replaced by React Router~~ ✅ +**Open work lives in the issue tracker, not in this file.** Milestones are +batches, issues are deliverables. There used to be a phase checklist here and in +four other documents; by the time it was removed it said *"Phase 5 — In +Progress"* while the last four commits said *batch 0.9.3*. That is the second +record this convention exists to remove. -- **Phase 3 — Visual Overhaul**: `0.3.x` ✅ Complete - - ~~Modern light-first business design~~ ✅ - - ~~Tailwind theme polish~~ ✅ - - ~~Typography, spacing, radius, shadows, and responsive rhythm~~ ✅ - - ~~Refined service/industry cards and CTA sections~~ ✅ - - ~~Mobile-first layout polish~~ ✅ +- **What is left, and what is next:** the milestones at + [dream.scheller.ltd/null/Queue-North-Website](https://dream.scheller.ltd/null/Queue-North-Website/milestones) +- **What is broken:** issues labelled `P0`, `P1`, `P2` +- **What the batches were, before the tracker held them:** + [docs/history/BATCH_LEDGER.md](docs/history/BATCH_LEDGER.md) -- **Phase 4 — Forms + Backend Hardening**: `0.4.x` ✅ Complete - - ~~Contact and support forms fully wired to Express~~ ✅ - - ~~SQLite persistence verified~~ ✅ - - ~~Client-side validation + Sonner feedback~~ ✅ - - ~~Server-side validation + input sanitization~~ ✅ - - ~~Optional Zoho forwarding layer~~ ✅ - - ~~Rate limiting + security headers + CORS~~ ✅ - - ~~Backend/API hardening as needed~~ ✅ +The rebuild itself is done: phases 1 through 10 completed between May and August +2026, from the original static HTML site to the current Vite + React SPA with a +prerendered build. `docs/history/DEVELOPMENT_LOG.md` has it version by version. -- **Phase 5 — Verification + Redesign**: `0.5.x` 🔄 In Progress - - ~~SPA router fix (BrowserRouter → RouterProvider)~~ ✅ - - ~~TS generics stripped from .jsx files~~ ✅ - - ~~Mobile menu Sheet/Dialog fix~~ ✅ - - ~~DialogTitle accessibility fix~~ ✅ - - ~~SPA catch-all route for client-side navigation~~ ✅ - - ~~Image assets copied to public/ (were 404)~~ ✅ - - ~~Real Queue North logo replacing placeholder~~ ✅ - - ~~CSP updated for Google Fonts~~ ✅ - - ~~Hamburger menu + SheetContent CSS fix~~ ✅ - - ~~tailwindcss-animate installed and configured~~ ✅ - - Hero section rewrite — B2B clarity, 8x8 partnership prominence - - Trust signals section — metrics, badges, certifications - - Services rewrite — business outcomes over technical jargon - - Why Queue North refinement — concrete differentiators - - Footer + CTA pass — contact paths everywhere - - Remaining P0/P1 audit fixes (Zoho, su-exec, email constraint) - - Accessibility checks - - Final push to `dev` for the completed phase +### Where the tracker is -Patch versions increment for completed task batches after the full pipeline finishes. Dispatch a task batch, run it through the required agents, then push that completed batch once. Example: Docker task batch goes through Neo → Private Hudson → Bishop → Ripley, then pushes as `0.2.1`. Notes/tags should use the version number only. +**[dream.scheller.ltd](https://dream.scheller.ltd)** — a Forgejo instance, and +naming it matters. The convention is portable; the *reporting* is not. Every +figure on this project's card at [privacyllc.dev](https://privacyllc.dev) is read +from this Forgejo repository. + +Credentials are in `~/.openclaw/docker-registry.env`: + +```bash +set -a; . ~/.openclaw/docker-registry.env; set +a +python3 scripts/forgejo-issue.py list +``` + +Despite its name that token is not registry-scoped — it works across the whole +API, with push and pull. Never print it into a log or a chat. + +Two traps that cost an hour each otherwise. **Cloudflare fronts the instance and +1010-blocks clients that do not look like a browser or curl** — Python's `urllib` +among them — so every request needs `User-Agent: curl/8.5.0`; +`scripts/forgejo-issue.py` already sends it and anything new must too. And +**`/issues` returns pull requests too** unless `type=issues` is passed. + +**Labels are exactly `P0`, `P1`, `P2`, `release-blocker`.** They are queried by +name. A fifth label, `P3`, exists on 21 closed issues from before adoption and is +frozen — nothing new gets it, because nothing counts it. ## Backend Goals @@ -176,9 +179,19 @@ The overhaul is handled through the agent pipeline below: Agents do not touch git. Ripley owns all commits, tags, and pushes. +This pipeline says **who does which part**. `docs/WORK_CYCLE.md` says **what must +be true before a piece of work counts as finished** — closing the issue with its +evidence, closing the milestone if the batch landed, updating the documents the +change triggered in the same commit, and recording the next action. They are +different questions and both apply. + +`.learnings/` holds each agent's accumulated notes and error logs. It is +gitignored and local to this machine. + ## Batch Pipeline Rule -Work is dispatched as task batches. A batch runs through the required agents, then Ripley pushes that completed batch once. +Work is dispatched as task batches. A batch runs through the required agents, +then Ripley pushes that completed batch once. Example Docker batch: @@ -186,15 +199,26 @@ Example Docker batch: Neo → Private Hudson → Bishop → Ripley ``` -The whole Docker batch is one checkpoint: `0.2.1`. +The whole batch is one push, not one push per agent. -Do not increment the patch version for each individual agent inside the same batch. Increment only after the full task batch finishes and is ready to push. +**A batch is a milestone in the tracker.** Closing the last issue under it does +not close the milestone, and a complete-but-open milestone reads as a batch still +in progress — closing it is what marks the batch *shipped*, and it moves a figure +on the project card the same day. -Notes, tags, and checkpoint labels should use only the version number, such as `0.2.1`. +> **Version numbers no longer encode the phase.** Until 2026-08-18 the rule was +> that the minor version *was* the phase number — Phase 5 releases are `0.5.x` — +> with Bishop responsible for keeping `package.json` in agreement with two +> root-level documents — PROJECT.md and HISTORY.md, named here without backticks +> because both were retired on 2026-08-18 and a backticked path is a claim that +> a file exists. It drifted twice; the second time `package.json` said `0.8.3` +> for two and a half months while commits said *batch 0.9.3*. The tracker holds +> the batch now, and +> [docs/history/HISTORY.md](docs/history/HISTORY.md) has the full reasoning. ## Design Direction -Based on the redesign review (see `review.md`), the site should feel: +Based on the redesign review (see [docs/design/REDESIGN_REVIEW.md](docs/design/REDESIGN_REVIEW.md)), the site should feel: - **Modern, clean, stable** — not experimental, not hacker aesthetic - **Business-first** — B2B UCaaS/IT partner, not a dev portfolio @@ -208,7 +232,7 @@ Color palette evolution (not rip-and-replace): Reference brands: RingCentral, Cloudflare, Dialpad — modern but enterprise-trustworthy. -See [review.md](./review.md) for the full redesign assessment. +See [docs/design/REDESIGN_REVIEW.md](docs/design/REDESIGN_REVIEW.md) for the full redesign assessment. ## Docker Deployment @@ -291,3 +315,77 @@ The `.dockerignore` excludes: - `.git`, logs, private docs This ensures minimal image size and reproducible builds. + +## Run Locally + +```bash +npm install +git config core.hooksPath .githooks # per clone — see below +npm run dev # Vite on 5173, Express on 3001 +``` + +`npm run build` is the real gate: client bundle, SSR bundle, then prerender. +`docs/TOOLS.md` has every script and which ones can stop you. + +### The git hooks + +Three hooks live in `.githooks/` because `.git/hooks` is not versioned. **The +`core.hooksPath` line above is per clone**, so every checkout runs it once; an +uninstalled hook fails silently. + +| Hook | What it does here | +| --- | --- | +| `pre-commit` | scans the staged diff for credentials, then runs `npm run build` if source is staged | +| `commit-msg` | refuses a message with no conventional type (`feat`, `fix`, `ui`, `docs`, `test`, `refactor`, `security`, `perf`, `chore`) | +| `post-commit` | **pushes to `origin`** | + +**`post-commit` pushes.** That is deliberate — work that exists on one laptop is +one disk away from gone — but it has a consequence: whatever documentation was +not in that commit is now behind the code by one push. That is the mechanical +reason documentation edits ride in the same commit as the change that caused +them. `SKIP_GUARDS=1` and `SKIP_PUSH=1` are the escape hatches, both loud. + +## Project Docs + +`README.md` is the landing page. Detailed procedures belong in `docs/`, and open +work belongs in the tracker. + +**[docs/DOC_TRUST_MAP.md](docs/DOC_TRUST_MAP.md) is the entry point** — read it +before trusting anything else in `docs/`. + +| Doc | Purpose | +| --- | --- | +| [docs/DOC_TRUST_MAP.md](docs/DOC_TRUST_MAP.md) | Which document owns which answer, and which source wins when records disagree | +| [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md) | What happens at the end of a piece of work, and what privacyllc.dev will and will not update on its own | +| [docs/TOOLS.md](docs/TOOLS.md) | Where the scripts are, which ones can stop you, and this project's npm commands | +| [docs/OPERATIONS.md](docs/OPERATIONS.md) | What is deployed and where, what watches it, and what to run when it is down | +| [docs/planning/PROJECT_PLAN.md](docs/planning/PROJECT_PLAN.md) | Scope, audience, and what this project deliberately is not | +| [docs/planning/REQUIREMENTS.md](docs/planning/REQUIREMENTS.md) | Engineering standards. Not changeable without `_null`'s approval | +| [docs/architecture/README.md](docs/architecture/README.md) | Module boundaries, the database schema, every API response shape | +| [docs/architecture/GUARDS.md](docs/architecture/GUARDS.md) | How to write a check that can actually fail | +| [docs/architecture/zoho-setup.md](docs/architecture/zoho-setup.md) | The CRM integration, end to end | +| [docs/design/OVERHAUL_PLAN.md](docs/design/OVERHAUL_PLAN.md) | The visual system: tokens, type scale, spacing, layout blueprints | +| [docs/design/REDESIGN_REVIEW.md](docs/design/REDESIGN_REVIEW.md) | Brand positioning and the messaging standard | +| [docs/security/SECURITY.md](docs/security/SECURITY.md) | Threat model, secrets, third parties, what is out of scope | +| [docs/security/SECURITY_CHECKLIST.md](docs/security/SECURITY_CHECKLIST.md) | The list to work through before a release | +| [docs/qa/ClaudeQAPlan.md](docs/qa/ClaudeQAPlan.md) | What a QA round consists of | +| [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) | The QA verdict — quoted verbatim on the project card | +| [docs/qa/ClaudeQACoverage.md](docs/qa/ClaudeQACoverage.md) | What each pass actually reached, and the standing gaps | +| [docs/history/DEVELOPMENT_LOG.md](docs/history/DEVELOPMENT_LOG.md) | Dated, append-only: what happened and when | +| [docs/history/HISTORY.md](docs/history/HISTORY.md) | The narrative: decisions, and what was tried and dropped | +| [docs/history/BATCH_LEDGER.md](docs/history/BATCH_LEDGER.md) | Archived. What the batches were before the tracker held them | + +## Agent Notes + +- Product truth comes from the code and the tracker before prose. +- **Do not keep a work list in this README**, or in any markdown file. That is + what the tracker is for, and this project has already paid for the lesson. +- Finish with [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md), every time: close the + issues you finished with the evidence that proves it, close the milestone if + the batch landed, update the documents the change triggered **in the same + commit**, then record the next action and any blockers before you stop. +- Nothing on privacyllc.dev writes itself except the tracker counts and the + pushed docs. The summary and next action change only when they are sent; the + milestone and blocker tables there change only when a human edits them. +- Do not claim a feature is built unless you can cite the route, file, or + screenshot that proves it. **There is no test suite to cite.** diff --git a/docs/DOC_TRUST_MAP.md b/docs/DOC_TRUST_MAP.md new file mode 100644 index 0000000..6430821 --- /dev/null +++ b/docs/DOC_TRUST_MAP.md @@ -0,0 +1,207 @@ +# Doc Trust Map — which document to believe + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: docs/** +Review trigger: Any doc added, deleted or moved; any change to which doc owns a subject +Fires on: added, deleted, moved +``` + +This describes what is **actually** in this repository, written after everything +else was settled. Where it differs from the template it came from, this one is +right about this project. + +## Work items are not in this tree + +The sequence of work, the open defects, and the things blocking a release all +live in this repository's **issue tracker**, not in `docs/`. Milestones are +batches; issues are deliverables; severity labels are `P0`, `P1`, `P2` and +`release-blocker`. + +This section exists to stop the next contributor starting a fresh markdown +to-do list. **It has already happened here.** Before 2026-08-18 this project +kept its work in `FUTURE.md`, a changelog in `HISTORY.md`, an agent log in +`DEVELOPMENT_LOG.md`, and phase checklists in `PROJECT.md`, `STRUCTURE.md` and +`README.md` — six records, beside 205 issues in a tracker that none of them +pointed at. Two of those documents still described the project as being in +"Phase 5" while the code was at `0.9.3`, and nothing anywhere said so. + +| Question | Answer lives in | +| --- | --- | +| What are we building, and for whom? | `docs/planning/PROJECT_PLAN.md` | +| What standards does the code have to meet? | `docs/planning/REQUIREMENTS.md` — and they need `_null`'s approval to change | +| What is the sequence of work? | milestones in the tracker | +| What is left in this batch? | open issues under that milestone | +| What is broken right now? | issues labelled `P0` / `P1` / `P2` | +| What makes a release wrong? | issues labelled `release-blocker` | +| What is the next action? | the `nextAction` field on the project at privacyllc.dev — the newest entry in `docs/history/DEVELOPMENT_LOG.md` says what it was *then* | +| What is blocking us? | the tracker, for the work; the blockers table at privacyllc.dev, for the stakeholder-facing version | +| What do I do when a piece of work is finished? | `docs/WORK_CYCLE.md` | +| What is deployed, and what do I do when it breaks? | `docs/OPERATIONS.md` | +| What happened, and when? | `docs/history/DEVELOPMENT_LOG.md` | +| Why is it shaped like this? | `docs/history/HISTORY.md` — decisions, and what was tried and dropped | +| What were the batches, before the tracker? | `docs/history/BATCH_LEDGER.md` — archived, must not be updated | +| Did QA pass, and what does the tester think? | `docs/qa/ClaudeReport.md` | +| What did QA actually reach? | `docs/qa/ClaudeQACoverage.md` — and here, the answer is *nothing yet* | +| How is it built? | `docs/architecture/README.md` | +| How does the CRM integration work? | `docs/architecture/zoho-setup.md` | +| What should it look like? | `docs/design/OVERHAUL_PLAN.md` | +| What should it sound like? | `docs/design/REDESIGN_REVIEW.md` | +| Which script do I run, and can it stop me? | `docs/TOOLS.md` — the signpost; `docs/architecture/README.md` has the table | +| What runs before a commit? | `.githooks/` | +| How do I write a check that will actually catch something? | `docs/architecture/GUARDS.md` | + +**Next action and blockers are recorded at the end of every piece of work, not +when somebody asks.** `docs/WORK_CYCLE.md` holds that procedure and the reason +each step is in it. + +## Folder layout + +| Folder | Contents | +| --- | --- | +| `docs/planning/` | `PROJECT_PLAN` — the vision. `REQUIREMENTS` — the engineering standards. Not the schedule; that is the tracker | +| `docs/qa/` | `ClaudeQAPlan` (playbook), `ClaudeQACoverage` (what each pass reached), `ClaudeReport` (the verdict) | +| `docs/architecture/` | `README` — boundaries, schema, response shapes, the script table. `GUARDS` — how to write a check. `zoho-setup` — the CRM manual | +| `docs/design/` | `OVERHAUL_PLAN` — the visual system. `REDESIGN_REVIEW` — positioning and tone | +| `docs/security/` | `SECURITY` — threat model and third parties. `SECURITY_CHECKLIST` — the pre-release list | +| `docs/history/` | `DEVELOPMENT_LOG` (dated, append-only), `HISTORY` (the narrative), `BATCH_LEDGER` (archived) | +| `docs/data/` | the branding marks privacyllc.dev renders for this project | +| `docs/` root | this map; `WORK_CYCLE`; `TOOLS`; `OPERATIONS` | + +`README.md` stays at the repository root; it is the landing page and moving it +breaks that. Everything else lives under `docs/`. + +## What this project keeps that the template does not describe + +- **`docs/planning/REQUIREMENTS.md`** — engineering standards with a change + policy attached. It is not the plan and not the architecture; it is the set of + rules every agent works to, and it predates the template. Kept whole. +- **`docs/design/OVERHAUL_PLAN.md`** — 25 KB, and **two documents in one file**. + Its design brief onward is the live token and layout authority; its earlier + migration-phase and agent-plan sections describe a rebuild that finished. Its + own front-note says which half is which. Splitting it would have risked losing + prose that nothing else records, so it was not split. +- **`docs/architecture/zoho-setup.md`** — written for a non-technical reader. + Note the trap in its own header: it documents the OAuth path, and production + runs WebToLead. +- **The agent pipeline** — Scarlett, Neo, Private Hudson, Bishop, Ripley — is in + `README.md`, not in this tree, because it is how work is dispatched rather + than a subject anything is authoritative about. `.learnings/` holds four + agents' notes and is gitignored. + +## What the template carries that this project deleted + +Recorded so a reader can tell a deliberate absence from an oversight. + +| Deleted | Why | +| --- | --- | +| `ClaudeQAPlan.md` pass I — money flowing backwards | no money moves through this site. A pass that never applies is noise | +| `SECURITY_CHECKLIST.md` — the authorisation group, and the session-token row | there is no login, no session, no role and no per-user data anywhere in the product | +| `SECURITY.md` — the *(precautionary)* bring-your-own-credential paragraph | **a precautionary rule, dropped deliberately.** It describes holding credentials on behalf of users; there are no users. `PROJECT_PLAN.md` records accounts as out of scope, which is what makes it safe to drop | + +Every other *(precautionary)* marker was kept. `OPERATIONS.md` was kept in full — +this is a deployed service with a live origin, a named host and a database. + +**Pass H was kept and rewritten** rather than deleted. Its authorisation half +does not apply; its *what a stranger can reach and exhaust* half is the most +exposed surface this project has. + +## A fifth severity label exists, and is frozen + +`P3` sits on 21 closed issues from before this repository adopted the +convention. It is **not** one of the four names the Command Center queries, so a +defect filed `P3` today would be counted by nothing. + +It was kept rather than deleted because removing it would erase the severity +record on those 21 issues, and merged into `P2` would misstate them. **Nothing +new gets it.** New defects use `P0`, `P1`, `P2` or `release-blocker`. + +## Source-of-truth ladder + +When two sources disagree, believe them in this order: + +1. **Verified code behaviour** — read the source. Note that on this project + "run the tests" is not available: there are none +2. **The issue tracker** — for anything about state of work +3. **Specialist docs** — authoritative for their own subject only +4. **History and logs** — a record of *then*, not a description of *now* + +A document that describes work as done is describing what was true when it was +written. The tracker is describing now. + +## The status header + +Every document in this tree opens with one, immediately after its H1 — and so +do the documents at the repository root, which `scripts/doc-triggers.py` reads +alongside this tree. The root is read one level deep only. + +``` +Status: Current | Draft | Superseded | Archived +Owner: +Last reviewed: +Governs: +Review trigger: +Fires on: +``` + +**Exactly those four status words.** A document whose status falls outside the +list is reported as having an unknown one rather than being quietly accepted. +`Historical` is the word this convention used to have and deliberately dropped — +`Archived` covers it. + +**Review trigger** is the line that matters. "Last reviewed" ages on its own and +a reader cannot tell a current document from an abandoned one by looking at it; +a trigger names the event that should bring somebody back. A header carrying +`Status` without `Review trigger` is reported as incomplete — that combination +looks finished and is not. + +**Governs** is a comma-separated list, and an entry may explain itself after the +glob with a spaced dash — `docs/data/** — the assets privacyllc.dev renders`. +`doc-triggers.py` cuts the entry there and reads the globs from the left of it. +**Use that form and no other.** A gloss in parentheses, or after a colon, is not +recognised: the whole entry becomes the glob, matches nothing, and the document +is silently never fired. + +**Fires on** is optional and only for the case where `Governs:` is much broader +than the trigger. This file is the extreme of that gap — it governs `docs/**`, +the broadest glob here, while its trigger is one of the narrowest. Omit the line +unless it is genuinely needed; absent means fire on every kind. + +`scripts/verify.d/30-doc-headers` checks all of this on every `npm run verify`, +which is what stops it being a convention people remember. + +## Declaring a document deliberately absent + +A repository may decide it will not keep one of these documents in git. Say so +**here**, one line per path, anywhere in this file: + +``` +Exempt: +``` + +**Write the real path only when you mean it.** Outside a fenced block, an +`Exempt:` line naming a real document is not an example — it is a declaration. +Keep illustrations fenced, and use placeholders anyway, as the form above does. + +**This project exempts nothing.** It considered it: the internal history and +planning documents were gitignored with the comment *"never commit"*, and +exempting them was one of the three options on the table. Instead the Forgejo +repository was made **private** on 2026-08-18 and the documents were committed, +because a half-populated doc tree reports to privacyllc.dev as a half-adopted +project and the reason for the secrecy was the audience, not the content. + +**`docs/data/` and `docs/data/img/` cannot be exempted.** A declaration naming +either is refused rather than honoured, because the Command Center renders what +is in them. + +## One asset is genuinely missing, and that is not an exemption + +`docs/data/img/banner.webp` does not exist. `icon.webp` and `logo.webp` do, both +converted from this project's own marks in `assets/`. + +No placeholder was generated, deliberately — a placeholder that looks finished +outlives the issue that would have replaced it. It is filed as a `P2` issue +instead, and the project card will render without a banner until somebody draws +one. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..9469c86 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,191 @@ +# Operations — Queue North Website + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: Dockerfile, docker-compose.yml, scripts/status.sh, + scripts/healthcheck.sh, scripts/backup.sh, scripts/restore-check.sh — + what watches this in production, and what to do when it stops +Review trigger: A new alert or a new place errors are sent; a change to the + backup schedule or the restore procedure; any change to the + deploy host, container or image; any incident whose first ten + minutes were spent working out what to run +``` + +## Why this exists + +Every other document here describes what the project *is*. This one describes +what happens when it stops being that at three in the morning, and it is the +only document written for somebody who is not thinking clearly. + +The failure it prevents is specific: **the first ten minutes of an incident +spent working out which host, which container, and which command**, while the +thing everybody actually wants to know — is it down, or is it just unreachable +from here — goes unanswered. + +## What is deployed, and where + +| | | +| --- | --- | +| Public origin | `https://qn.isnull.dev`, fronted by Cloudflare | +| Host | **nebula** (`192.168.1.11`) — the same box as the Forgejo instance | +| Container | `qn-website-dev` | +| Image | `dream.scheller.ltd/null/queue-north-website:dev` | +| Port | 3001 in the container; the reverse proxy in front owns the public one | +| Database | `/app/db/queuenorth.db`, in the named volume `qn-website-dev_queuenorth-db` | +| Logs volume | `qn-website-dev_queuenorth-logs` → `/app/logs` | +| Runs as | UID 1001, non-root | + +`queuenorth.com` also resolves and returns 200. **It is not this deployment** — +it is the customer's own domain and is not served by this container. Do not +diagnose against it. + +## Where errors go + +**Nowhere. This project has no error tracking.** + +Said in one line rather than left blank, because a gap somebody chose is a +different thing from a gap nobody noticed. There is no Sentry project, no DSN and +no alert destination. An unhandled exception is logged to the container's stdout +and takes the process down: `server/index.js` handles `uncaughtException` and +`unhandledRejection` by logging and exiting 1, and Docker's `restart: +unless-stopped` brings it back. **That means a crash loop looks like a running +service to anything that only reads a state.** `scripts/status.sh` reports the +restart count for exactly this reason. + +**A liveness tick is not error tracking.** `scripts/healthcheck.sh` answers *is +it up*. Nothing here answers *is it working* — a server returning 500 to every +form submission is up, healthy, and losing every lead. + +## What alerts, and to whom + +| Signal | Where it goes | Who acts on it | +| --- | --- | --- | +| Container `HEALTHCHECK` fails 3× at 30 s | Docker marks the container unhealthy on nebula | **Nobody, automatically.** It is visible to anyone who looks and notifies no one | +| `scripts/healthcheck.sh` | whoever runs it | not scheduled | +| Everything else | — | — | + +**This table is nearly empty and that is the honest state.** Nothing on this +project pages anybody. The site could be down for a day and the first report +would come from the customer. + +Two things would change that and neither is filed as work yet: scheduling +`healthcheck.sh` somewhere that can shout, and giving the process somewhere to +send an exception. Recorded here rather than invented into the tracker. + +## Backups + +| | | +| --- | --- | +| Schedule | **None yet.** `scripts/backup.sh` works and nothing runs it on a timer | +| What is captured | `/app/db/queuenorth.db` — the `leads` and `support_requests` tables. Deliberately not captured: the logs volume, and the environment, which holds the secrets | +| Where it lands | `$HOME/backups/queue-north-website` on the operator's machine, mode 700. **Still one machine** — see below | +| Retention | `BACKUP_KEEP`, default 7 | +| **Last verified restore** | **2026-08-18, by `_null` — 2 tables, 3 rows, restored in under 1 s** | + +`scripts/backup.sh` proves a dump is readable before trusting it — it runs +better-sqlite3's online `.backup()` inside the container, copies the result out, +and refuses the run unless `PRAGMA integrity_check` returns `ok` and the table +count is plausible, only then renaming it into place. `scripts/restore-check.sh` +proves it can be restored, which is a different claim: a file that parses is not +a database you can get back. + +**A backup nobody has restored is a guess.** The date above is the only line in +this table that says otherwise, and as of 2026-08-18 it says something: a +snapshot was taken from the running container, replayed into a scratch database +from SQL, and counted. It came back. + +**Two things that are still true anyway**, and both are tracked in `Batch 15`: + +1. **Nothing runs on a schedule.** One backup taken by hand is not a backup + regime. Until a timer exists, the newest dump is as old as the last time + somebody remembered. +2. **The dump lives on one machine, beside nothing.** `backup.sh` says this + itself on every run: *a backup that only exists beside the database it came + from does not survive the disk, the host or the account.* It is currently on + the operator's workstation and nowhere else. + +The exposure is worth stating plainly: `/app/db/queuenorth.db` is the only live +copy of every lead and support request the site has ever taken, it lives in one +Docker volume on one host, and it took writes today. + +**A number worth knowing before you need it:** the restore took under a second, +because the database is 28 KB and holds three leads. That will stay true for a +long time at this volume — this is a contact form, not a transactional system — +so during an incident, restoring is cheap and there is no reason to hesitate over +it. + +`RESTORE_MIN_ROWS` is currently 0, which means the check cannot yet catch the +snapshot-of-an-empty-volume case. Set it to something below the real lead count +once that count is meaningful, and it starts catching the one failure a +structural check never can. + +## Rate limits and cost ceilings + +*(precautionary — no incident here has yet come from this.)* + +| Endpoint or job | What it costs per call | What bounds it | +| --- | --- | --- | +| `POST /api/leads` | one SQLite insert, one reCAPTCHA verify, one Zoho form post | `RATE_LIMIT_PER_MINUTE`, default 5, per IP across all of `/api`. Body capped at 1 MB, request at 30 s | +| `POST /api/support` | the same, plus a Zoho Case when enabled | same limiter | +| Google reCAPTCHA | free at this volume | the same limiter, upstream of it | +| Zoho CRM | free at this volume; the API path has per-org daily credits | fire-and-forget, so exhausting them degrades CRM sync and never the site | + +**Where does the spend alert go, and at what number?** Nowhere, and there is no +number. Nothing here bills per call at this volume, which is why that is +tolerable rather than an oversight — but if an LLM, a mail sender or a paid API +is ever added, this table is the first thing that has to change. + +## It is down — what now + +In this order. Each step is a command that answers one question, and none of them +change anything. + +1. **Is it actually down, or unreachable from here?** + `bash scripts/healthcheck.sh` from anywhere, then + `bash scripts/status.sh`. The second distinguishes those two and reports the + restart count. **A climbing restart count with a short uptime is a crash loop**, + not a healthy service, and reads as "running" in every tool that shows only a + state. +2. **What is it saying?** `bash scripts/status.sh --logs 200`. +3. **Is the deployed version the one you think?** `bash scripts/status.sh` + prints the image digest. **`--deployed-version` cannot answer here** — the + image carries no `org.opencontainers.image.version` label, so the script + correctly reports the version as unknown rather than guessing. Compare the + **digest** against the registry instead: + + ```bash + bash scripts/status.sh | grep digest + ``` + + Adding that label at build time would make this step a one-liner and is worth + doing next time the Dockerfile is touched. +4. **Is it the database?** `/api/health` returns 503 with `db: error` when the + `SELECT 1` fails. That is a real answer and not an outage of the whole site: + the marketing pages keep serving while every form submission is failing. +5. **Did something change recently?** The tracker and `git log` — a deploy, a + config edit, a Cloudflare rule. +6. **Is it Cloudflare rather than us?** Compare a request to the origin on nebula + against one to `qn.isnull.dev`. Cloudflare fronts everything here, including + the Forgejo instance on the same host. + +### Rolling forward or back + +**This is the gap in this runbook and it is named rather than papered over.** + +Publishing is `npm run docker:push`, which builds and pushes +`queue-north-website:dev` to the Forgejo registry. How nebula then *moves* to the +new image — a compose file on the host, a Portainer stack, a watchtower, a manual +`docker pull && docker compose up -d` — is not written down anywhere in this +repository, and was not recoverable from it on 2026-08-18. + +Until somebody writes it here, a rollback is: find the previous image digest in +the registry, and do on nebula whatever it is that normally happens. That is not +a procedure. The template's `deploy.py` was deliberately **not** adopted for this +reason — a deploy script pointed at a path nobody has confirmed is worse than no +deploy script. + +**Write the incident down afterwards**, in `docs/history/DEVELOPMENT_LOG.md` with +the date, and file what broke as an issue with a severity label. An incident +nobody recorded happens again with the same surprise. diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 0000000..02defcd --- /dev/null +++ b/docs/TOOLS.md @@ -0,0 +1,143 @@ +# Tools — where the scripts are, and which ones can stop you + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: scripts/**, .githooks/**, package.json — the tooling, and which of + it can stop you +Review trigger: Any script added to, removed from or repurposed in scripts/; any + change to which of them gates; any change to the npm scripts +``` + +> A signpost, deliberately. Every project that adopts this template has a +> `docs/TOOLS.md`, so "read `docs/TOOLS.md` first" is an instruction that works +> without knowing anything about the project — which is the whole reason this +> file exists at a fixed path. + +## The list is not here + +**[`architecture/README.md`](architecture/README.md)** holds the table of what +ships in `scripts/` and what each one is. That is the one copy. + +A second table here would be two records of one fact, and the other one would +never hear that a script was renamed — the failure `DOC_TRUST_MAP.md` exists to +prevent, applied to the tooling instead of the documents. So this file answers +the questions that table does not, and points at it for everything else. + +## If this project has fewer scripts than the template lists + +That is the intended state, not a broken copy. The template's `scaffold.sh` +writes the documents and deliberately leaves the scripts behind — *"an +unconfigured `release.sh` landing in every new repository is a loaded gun, not a +head start"* — so they are taken one at a time, having been read. + +This project took ten of them on 2026-08-18 and declined the rest. What it +declined, and why, is in `docs/history/DEVELOPMENT_LOG.md` under that date. The +short version: no `release.sh` or `deploy.py` until the roll-forward path to +nebula is written down, and no `controls.sh` until there are backups for it to +report on. + +## Which ones can stop you + +Not in a table, because the honest answer lives in each script's own header and +would go stale here. The rule that matters: + +**Exit code `2` is never a pass.** These scripts distinguish "the check ran and +found nothing" from "the check did not run", because those look identical from +the outside and only one of them is evidence. A CI step or a hook that treats a +`2` as success has quietly turned the check off. Each script states its codes at +the top; read them there. + +In this repository specifically, `check-env.sh` and `verify.sh` will both exit 2 +if you gut their configuration, and `preflight.sh` exits 2 when the site is +simply unreachable — which is the case you most want to tell apart from a pass. + +The hooks are the other place work gets stopped. See below. + +## Where to start in a fresh clone + +```bash +npm install +git config core.hooksPath .githooks # per clone. Not optional. See below +bash scripts/check-env.sh --file .env # what is configured, before anything reads it +bash scripts/secrets.sh --tracked # what is already committed +``` + +Then [`architecture/GUARDS.md`](architecture/GUARDS.md) before you write a check +of your own — how to write one that can actually fail. + +## The hooks + +Three, in `.githooks/`, because `.git/hooks` is not versioned and a hook living +there protects exactly one clone. + +| Hook | What it runs here | +| --- | --- | +| `pre-commit` | `scripts/secrets.sh` on the staged diff, then `npm run build` when source is staged | +| `commit-msg` | refuses a message with no conventional type | +| `post-commit` | **pushes to `origin`** | + +`git config core.hooksPath .githooks` is per clone, so every checkout runs it +once. An uninstalled hook fails silently, which is the same class of problem the +hooks exist to prevent. + +**Two things worth knowing before you rely on them.** + +`pre-commit` is **not** the template's version. That one runs `npx tsc --noEmit` +and `npx vitest run`; this project has neither TypeScript nor a test runner, so +installing it unchanged would have refused every commit. It runs the secret scan +— which is the reason the hook earns its place here at all, given the Zoho form +tokens that reached four commits before anyone noticed — and then `npm run build` +when `src/`, `server/`, `index.html`, `vite.config.js` or `package.json` is +staged. **That is a build, not a test.** It catches a broken import and will not +catch a broken behaviour. + +`post-commit` pushes, and that is the intent — but it has a consequence worth +holding on to: whatever documentation was not in that commit is now behind the +code by one push. That is the mechanical reason `docs/WORK_CYCLE.md` asks for doc +edits in the same commit rather than in a tidy-up afterwards. With this hook +installed, *"I will document it next commit"* means the site has already +published the version without it. + +Escape hatches, both loud on purpose: `SKIP_GUARDS=1 git commit …` and +`SKIP_PUSH=1 git commit …`. + +## This project's npm scripts + +Run from the repository root. + +| Command | What it does | +| --- | --- | +| `npm install` | dependencies | +| `npm run dev` | Vite and the Express API together, via `concurrently`. Frontend on 5173, API on 3001 | +| `npm run build` | **three steps**: the client bundle, then an SSR bundle from `src/entry-server.jsx`, then `scripts/prerender.js`, which writes static HTML for every route. This is the only real gate this project has | +| `npm run build:client` | the client bundle alone. Does **not** prerender — do not use it to produce a release | +| `npm run preview` | serve the built client | +| `npm start` / `npm run server` | the Express server alone, serving `dist/` | +| `npm run docker:build` / `docker:run` | build and run the image locally | +| `npm run docker:compose:up` / `:down` / `:logs` | the compose stack | +| `npm run docker:push` | build, tag and push `queue-north-website:dev` to the Forgejo registry | +| `npm run docker:test` | build the image and smoke-test it on 3001 | + +**There is no `npm test`, and that is not an omission in this table.** There is +no test runner in the project. `docs/qa/ClaudeQACoverage.md` carries it as a +standing gap. + +A liveness check by hand, when you want one without the script: + +```bash +curl -s https://qn.isnull.dev/api/health # {"status":"ok","db":"ok","timestamp":"…"} +``` + +## Adding a script + +Put it in `scripts/`, give it a header saying what it does and **which incident +motivated it**, state its exit codes, and add a row to +`architecture/README.md`'s table — this file's `Review trigger` fires on exactly +that. + +The bar, from the scripts that are already here: **done by hand three times, or +once with a consequence.** A script written before either of those has no failure +to describe in its header, which is the part that stops the next person deleting +it. diff --git a/docs/WORK_CYCLE.md b/docs/WORK_CYCLE.md new file mode 100644 index 0000000..ffd0866 --- /dev/null +++ b/docs/WORK_CYCLE.md @@ -0,0 +1,277 @@ +# The work cycle — what happens at the end of a piece of work + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: what must be true before a piece of work counts as finished +Review trigger: Any change to what the Command Center reads, or to which of + those channels a person rather than an agent has to write +``` + +> **This is a procedure, not a status board.** Nothing here records what is open, +> what is next, or what is blocked — those live in the tracker and on +> privacyllc.dev, and a copy of them in this file would be the second disagreeing +> record that `DOC_TRUST_MAP.md` exists to prevent. + +## Why this file exists + +A piece of work ends in more than one place. The code is committed; the issue +that asked for it is still open; the document the change contradicts still says +the old thing; and the project screen at +[privacyllc.dev](https://privacyllc.dev) still shows last week's next action to +whoever opens it. + +None of those catch up on their own. Two of them cannot be caught up later by +anybody but the person who did the work, because by then nobody knows what the +next action was meant to be. + +## The cycle + +Run all of it, in this order, every time. It is short on purpose. + +1. **Close what you finished.** `closes #N` in the commit that does the work, so + the record comes from the thing that happened rather than a date typed + afterwards. If no single commit finished it, close it by hand with the + evidence — a path, a symbol, a test name, or the command that proves it. + *"Done" is not a close.* +2. **File what you found.** A defect noticed on the way past is an issue with a + severity label and the build SHA it was seen at, not a memory. Filing it costs + a minute; the alternative is finding it again from scratch, or shipping it. +3. **Close the milestone if the batch landed.** A milestone with every issue + closed and itself still open reads as a batch still in progress — see + [Open and closed are not bookkeeping](#open-and-closed-are-not-bookkeeping). +4. **Update the documents this change triggered.** Read the `Review trigger` + lines: a new module, a changed data shape, a new migration, a new boundary + something crosses. Those edits go in **the same commit as the code**, for the + reason in [Docs travel with the push](#docs-travel-with-the-push). +5. **Push.** Nothing above is visible off this machine until you do, and step 7 + reports on what was pushed. +6. **Write the log entry** in `docs/history/DEVELOPMENT_LOG.md`: what changed, + what it proved, **Next action**, and **Blockers**. Dated, append-only, newest + first. +7. **Tell the Command Center**, which is three calls: reconcile, so it re-reads + the tracker and the pushed docs; `PATCH` the project's summary and next + action, which nothing else writes; and a check-in if what changed is worth a + timestamped note. Commands are [at the bottom](#the-commands). + +Steps 6 and 7 are the two that get skipped, and they are the two nobody else can +do afterwards. + +## Next action — where it lives + +| Copy | Where | What it is | +| --- | --- | --- | +| The current one | `nextAction` on the project, privacyllc.dev | the live answer to "what happens next", shown to whoever opens the project screen | +| The dated one | the newest entry in `DEVELOPMENT_LOG.md` | what the next action was **at that point** — history, not status | + +These are not two records of the same thing, and the distinction is worth +holding on to. The field is overwritten every time and always describes now. The +log entry is never edited and describes a moment — which is what makes it safe +to keep, and why an old entry naming a next action that has since been done is +not stale, it is a receipt. + +Write the next action as an **action**: the thing a person would start on +Monday, specific enough to begin without asking a question. "Continue the work" +is not one. If the honest answer is that you do not know, that is a real answer — +say what has to be decided and by whom. + +**Filing an issue can change what the project card says next, without anyone +choosing that.** The dashboard's next action is the *newest open issue in the +current milestone* — not the most severe one; severity labels have no influence +on it at all. So a routine `P2` filed into the batch you are working in replaces +whatever the card was showing, and it will keep showing that until something +newer arrives. The `nextAction` field is the only way to say something different +on purpose, which is most of why step 7 exists. + +`currentSummary`, `nextAction` and `description` are **write-only**: they are +deliberately absent from every API response, because free text can name a +customer or an unannounced product. Only the admin screen shows them back. Never +report them as empty because a `GET` did not return them. + +## Blockers — where they live + +A blocker is recorded in up to three places, and **an agent can write only the +first and the third**: + +| Where | What it holds | Who writes it | +| --- | --- | --- | +| The tracker | the work itself — an issue labelled `release-blocker`, or `P0` when it ships broken | anyone, including an agent | +| The blockers table on privacyllc.dev | the business-facing blocker, with a severity, an owner and a resolution note | **a human, in the admin UI** | +| A check-in's `blockers` field | narrative: what is stuck, said in a timestamped note | anyone, including an agent | + +The agent API has no route that creates, edits or resolves a blocker on the +site. Posting a check-in whose `blockers` field says something is stuck records +*narrative* — it does not touch the blockers table, and it does not clear +anything. An agent that reports "blocker filed" after a check-in has told the +truth about the note and a falsehood about the table. + +So: **file the issue** — that is the copy the work is actually tracked in — and +when it belongs in front of a stakeholder, say so plainly and let a human enter +it. Before treating a site blocker as resolved, restate its title and status and +get explicit confirmation; the admin UI requires a resolution note that the API +cannot supply. + +If the work is blocked and nothing is filed anywhere, the project simply looks +slow. + +## Open and closed are not bookkeeping + +Every open issue is a denominator, and not in the abstract: the percentage on +the project screen **is** closed issues over all issues in this tracker. Nothing +else produces it. That has three consequences worth stating in full: + +- **An issue left open after the work is done** understates the project + permanently, and the understatement compounds — a fortnight of finished work + with unclosed issues reads as a fortnight of no progress. +- **An issue closed without evidence** cannot be reopened with confidence, + because nothing in it says what "fixed" meant. That is why the close comment + carries the path, symbol, test or command. +- **Invented future work** makes every percentage wrong, permanently and in one + direction. Do not pad the tracker. If the real answer is one milestone and + three issues, file exactly that. + +Milestones are the same argument at batch scale, and they are counted the same +way: milestones closed over milestones total is the second figure on the project +screen. Closing the last issue under a milestone does not close the milestone, +so a tracker full of complete-but-open batches reports a project as less +finished than it is — and cannot answer "what shipped". + +**File every issue into a milestone.** One filed outside still counts against +the headline percentage while being invisible to the milestone figure, which is +how two readings of the same project come to describe different amounts of work. +The site measures the gap rather than ignoring it. + +One trap that costs an afternoon, and it is about a *card* rather than a figure: +the Milestones list on privacyllc.dev reads the Command Center's own table, +which only an admin can write. A repository whose milestones are being counted +in the figure above can still show *"No milestones have been added yet"* in that +list. It is not a sync failure and no amount of reconciling changes it. + +## Docs travel with the push + +The Command Center reads this repository's documents at a commit, and stores the +SHA it read them from. When that SHA falls behind the repository's newest +commit, the docs report is marked **stale** — not wrong, not missing, *stale*, +which is the honest description of a document that was accurate at a commit +nobody is running any more. + +Two ways to produce it, and both are ordinary carelessness rather than bad luck: + +- **Code pushed, documents not updated.** The report is recomputed at the new + SHA against prose describing the old behaviour. Nothing flags this; the + document is simply confidently wrong now, and its `Last reviewed` line still + looks recent. +- **Documents updated, not pushed.** The site keeps reporting the old ones. A + correction that exists only on your machine has not been made. + +Hence step 4's insistence that documentation edits ride in the same commit as +the change that caused them. It is not tidiness — a doc commit that comes "after +this next thing" is the commit that does not get written, and the manual's +migration table in the project this template came from sat six migrations behind +before anybody noticed. Every reader in between trusted it. + +## What the Command Center reads, and who writes it + +Four independent channels, and **only two of them are automatic**: + +| On the project screen | Source | How it gets there | +| --- | --- | --- | +| Headline % — issues closed / all issues | the repository's tracker | automatic — on reconcile, and immediately on a webhook delivery | +| Second figure — milestones closed / all milestones | the repository's **milestones** | same read, same moment | +| Milestone coverage — issues that sit in no milestone | the repository's tracker | same read | +| QA verdict — round, build SHA, overall sentence | `docs/qa/ClaudeReport.md` | push the repository, then reconcile | +| Current summary / Next action | manual fields on the project | `PATCH /agent/projects/queue-north-website` — nothing else writes them | +| The Milestones card, and a typed weighted plan | the Command Center's **own** milestone table | admin UI only | +| Blockers | the Command Center's **own** blockers table | admin UI only | + +The first three are why steps 1 and 3 are steps: **closing an issue and closing +a milestone each move a figure a stakeholder can see, that day.** The last two +are lists rather than figures, and they are the ones an agent cannot write — a +repository whose milestones are counted in the second figure can still show +*"No milestones have been added yet"* on the card, because that card reads a +table only an admin fills in. + +A repository that has not adopted the four label names is reported as *not +adopted* rather than as zero defects, and one with its tracker switched off is +reported as switched off rather than as an empty backlog. Absence is never +rendered as a measurement — which is exactly why a real backlog nobody filed +looks like nothing at all. + +## The commands + +The tracker is the Forgejo instance at +**[dream.scheller.ltd](https://dream.scheller.ltd)**, and steps 1 to 3 happen +there. Its credentials, the Cloudflare trap and the reason it has to be that +instance rather than any tracker are in the project README's *Where the tracker +is* — one copy, named once. + +**This repository has no test suite**, so "the evidence that proves it" is +usually a file and symbol, a `curl` against the live origin, or what was observed +at a named viewport. Say which; "Done" is not a close. + +```bash +set -a; . ~/.openclaw/docker-registry.env; set +a +python3 scripts/forgejo-issue.py list +python3 scripts/forgejo-issue.py close 196 "Fixed in a1b2c3d; src/components/layout/Header.jsx +# no longer overlaps at 320px — checked in DevTools at iPhone SE width." +``` + +Step 7 talks to the Command Center instead: + +```bash +# The token lives in this machine's credential store, never in the repository. +TOKEN="$(grep PRIVACY_LLC_TOKEN ~/.openclaw/credentials/privacyllc.md | cut -d= -f2)" +BASE="https://privacyllc.dev/api/internal/v1" +AUTH="Authorization: Bearer $TOKEN" +``` + +**Reconcile** — re-read the tracker and the pushed documents. Do this after the +push, not before: + +```bash +curl -sk -X POST -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"kind":"reconcile_all"}' "$BASE/agent/jobs" +``` + +**Summary and next action** — read the project first for its `version`, then +send a flat body carrying that version. A stale version is rejected rather than +silently overwriting somebody else's edit: + +```bash +curl -sk -H "$AUTH" "$BASE/agent/projects/queue-north-website" # for version + state + +curl -sk -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"version":,"health":"on_track", + "currentSummary":"", + "nextAction":""}' \ + "$BASE/agent/projects/queue-north-website" +``` + +**A check-in** — a timestamped "what changed", when there is something real to +report. `summary` is required and must say something; the API refuses an empty +"no change" update, deliberately: + +```bash +curl -sk -X POST -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"projectId":"queue-north-website","health":"on_track", + "summary":"","accomplishments":"", + "blockers":"","nextActions":""}' \ + "$BASE/agent/updates" +``` + +Health is one of `on_track`, `caution`, `off_track`, `unknown`. Never invent +one, and never report progress the API did not return. + +## What this file is not + +- **Not the work list.** That is the tracker: milestones are batches, issues are + deliverables. +- **Not the release procedure.** This project has no `release.sh` — publishing + is `npm run docker:push`, and how nebula moves to a new image is not yet + written down. `docs/OPERATIONS.md` records that gap; + `docs/security/SECURITY_CHECKLIST.md` is the list to work through first. +- **Not the QA procedure.** That is `docs/qa/ClaudeQAPlan.md`, which ends in its + own version of step 7. +- **Not a place to record status.** If you are about to add "current state" or a + list of outstanding items below this line, the tracker is where it goes. diff --git a/docs/architecture/GUARDS.md b/docs/architecture/GUARDS.md new file mode 100644 index 0000000..e0b512d --- /dev/null +++ b/docs/architecture/GUARDS.md @@ -0,0 +1,142 @@ +# Guards — how to write a check that actually checks + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: scripts/verify.d/**, .githooks/** — structural tests, source-grep + assertions, probes, and any check whose passing is taken as evidence +Review trigger: A guard is found to have been passing while the thing it guards + was broken; a new class of check is added to the suite. +``` + +A guard that cannot fail is worse than no guard, because it is trusted. Every +rule here was learned by finding one that had been green for months over +something broken. + +## 1. Prove the guard fails before you believe it passes + +The one discipline that matters most, and it takes thirty seconds: + +```bash +cp src/lib/thing.ts /tmp/thing.bak +# break exactly the thing the test protects +sed -i 's/if (body.error)/if (false)/' src/lib/thing.ts +npx vitest run tests/thing.test.ts # expect: exactly one failure +cp /tmp/thing.bak src/lib/thing.ts +npx vitest run tests/thing.test.ts # expect: green again +``` + +**Exactly one** is the part people skip. If breaking the guard's target fails +three tests, two of them are coincidental and will mask a real regression later. +If it fails none, the guard is decoration — and you have just learned that for +the price of one `sed`. + +`scripts/prove-guard.sh` performs exactly this, which removes the two ways it +gets skipped: the restore is a `trap`, so an interrupted run cannot leave the +code broken, and the failure count comes from the runner's own summary rather +than from eyeballing red — one failing test is routinely reported on half a +dozen lines, and counting those calls a clean result six coincidental +failures. + +Do this when you write a guard, and again when you change what it guards. A +test written alongside the code it tests has never been observed failing. + +## 2. A source-grep guard must tell code from the comment about code + +Structural tests that assert a file does *not* contain some pattern will match +the docblock explaining why that pattern is forbidden. So the clearest possible +comment breaks the test, and the obvious fix is to delete the explanation. + +Strip comments first: + +```ts +const codeOf = (path: string) => + readFileSync(path, "utf8") + .split("\n") + .filter((line) => !/^\s*(\*|\/\/|\{\/\*)/.test(line)) + .join("\n"); + +expect(codeOf("src/lib/thing.ts")).not.toContain("dangerouslySetInnerHTML"); +``` + +Otherwise the guard quietly punishes documenting the rule it exists to enforce — +which is exactly backwards, because the comment is how the next person learns +the rule at all. + +## 3. Pin the behaviour, not the spelling + +A guard should fail when the protected behaviour breaks and stay quiet +otherwise. One that asserts on a variable name fails on a rename that changed +nothing. + +```ts +// Brittle: breaks when the variable is renamed, while the fallback it protects +// is untouched. +expect(route).toContain("readAsset(project.forgejoRepo"); + +// Pins the behaviour: the route fetches through the wrapper that tries both +// spellings, and never through the raw reader. +expect(route).toMatch(/readAsset\(\s*\w+,\s*ASSETS\[which\]\s*\)/); +expect(body).not.toContain("readFileBytes("); +``` + +A guard that fails on changes it does not care about is one people learn to edit +rather than heed, and the edit is usually deletion. + +## 4. A negative result is only as good as the probe that produced it + +"The check found nothing" and "the check did not run" are different facts, and +they look identical from the outside. Before reporting an absence, prove the +instrument worked: + +```bash +# Not this alone — an unreadable file produces the same silence as an unset key +grep -c '^WANTED=' /proc/$PID/environ + +# Establish the read succeeded first +tr '\0' '\n' < /proc/$PID/environ | grep -c . # 0 here means "could not read" +``` + +This is the confident-absence failure one level up: the same trap as a screen +rendering a failed query as a count of zero, applied to your own diagnosis. + +## 5. A guard that is often wrong is worse than none + +A check with a high false-positive rate trains everybody to skip its output, +including on the day it is right. + +One written for this template flagged **684 of 1142** candidates on its first +run. That was not 684 findings, it was a broken heuristic — and shipping it +would have taught its readers that the check is noise. Two rounds of narrowing +brought it to 17 of 363, all of them real. + +If a new guard's first run is loud, tune it until it is quiet before anybody +relies on it. Report the false-positive rate you settled at, so the next person +knows what silence is worth. + +## 6. Guards belong before the artifact exists + +A check that runs after publication catches the problem once it is somewhere it +cannot be taken back from: the tag is in the registry, and refusing the commit +afterwards leaves git with no record of it. + +Order the gates so the expensive, irreversible step is last — preconditions, +guards, build, verify the built thing is what was asked for, publish, and record +it last of all. + +## 7. When the gate finds something that invalidates the operation, stop + +Printing a warning and continuing produces the worst outcome available: the bad +thing happens *and* a reassuring summary appears above it. + +The question is not how bad the finding is. It is **whether it invalidates what +the operation claims**: + +- A release whose test gate skipped half the suite — a release claims to be + tested. **Refuse.** +- A backup written to a group-readable directory — the backup is still a + backup. **Warn.** + +Escape hatches are fine, and they have to be asked for by name, never be the +default, and say plainly what is being given up. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..0f37061 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,182 @@ +# 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.** diff --git a/docs/zoho-setup.md b/docs/architecture/zoho-setup.md similarity index 86% rename from docs/zoho-setup.md rename to docs/architecture/zoho-setup.md index 548cc9a..17a5de3 100644 --- a/docs/zoho-setup.md +++ b/docs/architecture/zoho-setup.md @@ -1,5 +1,22 @@ # Zoho CRM Setup Guide for Queue North Admins +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: server/index.js, .env.example — the Zoho CRM integration: app setup, + OAuth credentials, environment variables, and how to test that leads + arrive +Review trigger: Any change to ZOHO_* environment variables, the forwarding mode, + the field mapping, or the datacenter; any Zoho API version change +``` + +> **Read the mode first.** This guide documents the OAuth/REST path. Production +> currently runs `ZOHO_FORWARDING_MODE=webtolead`, which needs none of the OAuth +> setup below — the REST path is kept as a configured standby. `.env.example` +> shows both. The reasoning behind the current shape is in +> `docs/history/BATCH_LEDGER.md`. + This guide walks you through the current Zoho CRM integration. Contact leads use the legacy Zoho WebToLead form tokens, while the OAuth/API integration remains available as a standby option for future lead upserts or support cases. --- @@ -158,7 +175,10 @@ If your Zoho datacenter is **outside the US**, adjust the domains: ### Field Mismatches - If leads/cases don't appear, check if Zoho requires custom fields like `Service_Interest` -- Edit the field mapping in `server/zoho/` to match your Zoho CRM field API names +- Edit the field mapping in `server/index.js` — `forwardToZoho()` builds the + `First_Name` / `Last_Name` / `Lead_Source` payload, and + `forwardToZohoWebToLead()` builds the form fields — to match your Zoho CRM + field API names ### Cases Not Appearing - Ensure `ZOHO_CASES_ENABLED=true` is set diff --git a/docs/data/README.md b/docs/data/README.md new file mode 100644 index 0000000..cadf8f5 --- /dev/null +++ b/docs/data/README.md @@ -0,0 +1,99 @@ +# Data — Queue North Website + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: docs/data/** — the assets privacyllc.dev renders for this project +Review trigger: A rebrand, or any change to the icon, logo or banner +``` + +## What goes here + +Three files, in `img/`, at exactly these names: + +```text +docs/data/img/icon.webp the square mark, used wherever the project is listed +docs/data/img/logo.webp the full lockup, used on the project page +docs/data/img/banner.webp the wide image, used across the project header +``` + +All **webp**. All **required**. Only `img/` is checked — an asset left in +`docs/data/` instead of `docs/data/img/` is not found. + +**Dimensions, weights and how to generate them are in +[`img/README.md`](img/README.md)**, beside the files they describe. This +document owns the rule; that one owns the spec. Stating both in both places is +how two copies of one convention start disagreeing. + +**No placeholders ship with this template, deliberately.** It carried a 0-byte +`logo.webp` once, and an empty file is the worst of the three states: a check +that asks "does the path exist" calls it present, and anything that reads the +bytes rejects it — a consumer verifying the webp signature answers 415, which +reads as a corrupt asset rather than a missing one. Absent is honest and the +conformance check reports it as absent, which is what gets it filled in. + +Extra sizes and variants are welcome beside them — `icon-512.webp`, +`logo-dark.webp` — and are not treated as clutter. Only the three exact names +are checked for. + +## What is here now + +| File | State | Source | +| --- | --- | --- | +| `img/icon.webp` | **present** — 512 × 512, 28 KB | `assets/icons/logo512.png`, the Queue North compass-star mark | +| `img/logo.webp` | **present** — 1024 × 1024, 41 KB | `assets/logo2.png`, the full lockup | +| `img/banner.webp` | **absent** | no 3:1 source exists — see below | + +Both present files were converted from the project's **own** existing marks on +2026-08-18, which is using the art the project already had rather than inventing +a placeholder. The source art lives in `assets/` at the repository root, which is +where this project keeps exported source files; nothing in the build references +that directory, so do not delete it as dead. + +`banner.webp` needs 2176 × 725 and nothing in the repository is that shape. +`assets/JointLogoWhite.png` is 4082 × 1066 and close, but it is a *joint* mark +with a partner, and whether that is the right thing on a Queue North project +header is somebody's decision rather than a conversion. Filed as a `P2` issue. + +## If an asset is missing, open an issue — do not invent one + +An agent cannot draw a logo, and this is the one gap in the whole convention +that cannot be closed by writing a file. + +So when an asset is absent, **file an issue** rather than producing something: +title it for the asset, label it `P2`, and end the body with its `Verify:` line +— `Verify: docs/data/img/logo.webp exists and the project card renders it.` + +**Do not generate a placeholder.** A placeholder that looks deliberate outlives +the issue that would have replaced it: nobody files a ticket against an image +that appears to be finished. An obviously absent asset keeps asking. + +## Why this folder is different from every other one here + +The Command Center *consumes* these. Every other document in this tree is written +for a person to read; these are fetched and rendered on privacyllc.dev's project +page. + +That has one consequence worth stating plainly: **this folder cannot be declared +exempt.** A repository may tell the conformance check that a required document is +deliberately absent — kept out of git on purpose, say — and the check will +believe it. It will not accept that declaration for `docs/data/`, because the +result would be a project card with nothing to show and nothing explaining why, +which is the exact failure the check exists to prevent. + +## Why webp and not PNG + +One format, checked by its magic bytes rather than its file extension, so the +site can serve it inline with confidence. A file whose first bytes are RIFF/WEBP +cannot be an HTML document or an SVG carrying script, which is what makes it safe +to render directly rather than forcing a download. + +Renaming a PNG to `.webp` will not work, and is meant not to. + +## This is not an asset library + +Screenshots, mockups, reference art and exported source files do not belong here. +They belong wherever the project already keeps them. This folder holds the three +marks that identify the project elsewhere, and stays small enough that its +contents are obvious at a glance. diff --git a/docs/data/img/README.md b/docs/data/img/README.md new file mode 100644 index 0000000..361e2b7 --- /dev/null +++ b/docs/data/img/README.md @@ -0,0 +1,57 @@ +# Project images + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: docs/data/img/** — the three files and their sizes +Review trigger: A rebrand; any change to a required name, dimension or ceiling; + any change to what the consumer accepts. +``` + +Three files, all **webp**, all **required**, at exactly these names: + +| File | Dimensions | Aspect | Typical weight | +| --- | --- | --- | --- | +| `icon.webp` | **512 × 512** | 1:1 | 8–60 KB | +| `logo.webp` | **1024** on the long edge | whatever the lockup is | 20–190 KB | +| `banner.webp` | **2176 × 725** | 3:1 | 30–130 KB | + +**Not `icon.ico`.** The consumer checks the file's magic bytes, not its name: a +`.ico`, or a PNG renamed to `.webp`, is refused with a 415 and the project falls +back to an initials tile. That signature check is what makes it safe to render +these inline, so it is not going to be relaxed. + +**512 KB is a hard ceiling per file**, enforced in code — the size is read from +the listing before the bytes are fetched, so an oversized asset is never +downloaded and simply never appears. Nothing enforces the dimensions, which is +why they are written down. + +## Making them + +```bash +magick logo-source.png -resize 512x512 -quality 82 icon.webp +identify -format '%f %wx%h %b\n' *.webp # check before committing +``` + +Quality 80–85 suits a flat mark. If a file lands over ~200 KB it is usually a +photographic banner that wants a lower quality rather than fewer pixels. + +## Why these numbers + +The icon renders small — a 44 px tile in a list, 58 px on a project header — so +512 covers the densest display several times over; the reference project +deliberately halved it from 1024. The banner spans a card about 760 px wide, so +~2176 covers it at 2×, and its 3:1 shape matters more than its width because the +header crops to fill. The logo has no fixed frame, so only its long edge is +specified. + +Extra sizes and variants are welcome beside these — `icon-512.webp`, +`logo-dark.webp`. Only the three exact names are checked for. + +No placeholders ship with this template. An empty file is the worst of the three +states: a check that asks whether the path exists calls it present, and anything +reading the bytes rejects it. Absent is honest, and the conformance check reports +it as absent — which is what gets it filled in. + +Why the requirement exists, and what reads it: [`../README.md`](../README.md). diff --git a/docs/data/img/icon.webp b/docs/data/img/icon.webp new file mode 100644 index 0000000..b85ea22 Binary files /dev/null and b/docs/data/img/icon.webp differ diff --git a/docs/data/img/logo.webp b/docs/data/img/logo.webp new file mode 100644 index 0000000..f344f92 Binary files /dev/null and b/docs/data/img/logo.webp differ diff --git a/OVERHAUL_PLAN.md b/docs/design/OVERHAUL_PLAN.md similarity index 94% rename from OVERHAUL_PLAN.md rename to docs/design/OVERHAUL_PLAN.md index 09008ed..563767c 100644 --- a/OVERHAUL_PLAN.md +++ b/docs/design/OVERHAUL_PLAN.md @@ -1,5 +1,27 @@ # Queue North Website — 2026 Overhaul Plan +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: tailwind.config.js, src/index.css — the visual system: palette, + typography, spacing, radius and shadow, component choices, and the + layout blueprints for each page type +Review trigger: Any change to a Tailwind theme token, the type scale, the + spacing rhythm, or the layout of a page type; any new page type +``` + +> **Two halves, and only one is current.** Its *Scarlett Design Implementation +> Brief* onward — Tailwind theme tokens, the numeric-typography rule, the type +> scale, section spacing, radius and shadow rules, the component list, the page +> blueprints and the explicit anti-patterns — is the **live design authority** for +> this project and should be trusted as written. +> +> Its earlier *Migration Phases* and *Agent Plan* sections describe a rebuild that +> completed at `v0.9.x`. They are kept as the record of what was planned and are +> **history, not instructions**. The narrative of how it actually went is in +> `docs/history/BATCH_LEDGER.md`; open work is in the tracker. + ## TL;DR Rebuild the current static HTML/CSS/JS website into a modern full-stack app using: @@ -23,7 +45,10 @@ The current project is a static site with: - `index.html` — all pages live in one large file - `styles.css` — large hand-written stylesheet with many page-specific overrides -- `main.js` — manual hash-based routing and interactions +- main.js — manual hash-based routing and interactions. Named without backticks + because this section describes the site as it was BEFORE the rebuild; that + file was deleted on 2026-08-18 and a backticked path would claim it still + exists. The original is preserved in `.drop/` - Inline Zoho webform scripts - Static assets in `assets/` diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..4ce0889 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,48 @@ +# Design — Queue North Website + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: docs/design/** +Review trigger: Any new user-facing surface, or a change to the product's tone +``` + +## Documents here + +Two, and they answer different questions. Both predate the template and both are +this project's own writing. + +| Document | Owns | +| --- | --- | +| [`OVERHAUL_PLAN.md`](OVERHAUL_PLAN.md) | **what it looks like** — Tailwind theme tokens, the numeric-typography rule (Georgia for all numeric content), the type scale, section spacing and container sizes, radius/shadow/border rules, which shadcn-style primitives to use, the per-page layout blueprints, and the explicit anti-patterns. Its earlier *Migration Phases* and *Agent Plan* sections are history; the brief onward is live | +| [`REDESIGN_REVIEW.md`](REDESIGN_REVIEW.md) | **what it sounds like** — the B2B positioning argument, the messaging shift from "interesting technical person" to "reliable communications and IT partner", and the anti-patterns of tone. The eleven open content issues in `Batch 12` are defects measured against this | + +The single most load-bearing sentence across both: *business buyers purchase risk +reduction, not technology.* Copy that reads as clever costs trust here in a way +it would not on a developer-facing product. + +## What belongs here + +What it should feel like, and the decisions behind that: + +- **Product decisions** — what the user can do, in what order, and what happens + when they get it wrong. The error states are design, not an afterthought. +- **UI plans** — screens, states, and what each one is for. Include the empty + state and the loading state; they are the two most people see first and the + two most often left undesigned. +- **Copy** — the actual words. Interface text is a design surface, and writing + it late means writing it badly. +- **Tone** — how this product talks. One paragraph is enough, and it settles a + hundred small arguments. + +## What does not belong here + +- How it is built — that is `docs/architecture/` +- Scope and audience — that is `docs/planning/PROJECT_PLAN.md` + +## Include the rejected version + +For any decision that was genuinely close, record what was not chosen and why. +Without it, the same option gets proposed every few months and re-argued from +nothing. diff --git a/review.md b/docs/design/REDESIGN_REVIEW.md similarity index 87% rename from review.md rename to docs/design/REDESIGN_REVIEW.md index 5555cae..9dda10f 100644 --- a/review.md +++ b/docs/design/REDESIGN_REVIEW.md @@ -1,5 +1,22 @@ # Queue North Website Redesign Strategy +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: brand positioning, tone, and the messaging standard every page is + written against +Review trigger: Any change to how Queue North positions itself, or to the + audience the site is written for +``` + +> The positioning argument behind the rebuild, written before it started. It is +> still the standard: the open content issues in `Batch 12 — Enterprise content +> rewrite` (#199–#209) are all defects measured against this document. +> +> `OVERHAUL_PLAN.md` says what the site should look like. This says what it +> should *sound* like, and why the previous one lost trust in seconds. + # Core Problem Current website branding feels: diff --git a/docs/history/BATCH_LEDGER.md b/docs/history/BATCH_LEDGER.md new file mode 100644 index 0000000..082cae4 --- /dev/null +++ b/docs/history/BATCH_LEDGER.md @@ -0,0 +1,158 @@ +# Batch ledger — Queue North Website + +``` +Status: Archived +Owner: _null +Last reviewed: 2026-08-18 +Governs: what the batches were, before the tracker held them +Review trigger: Nothing. Superseded by the tracker; kept for the record. +``` + +## Why this file is archived rather than deleted + +Planning used to live in markdown as a numbered batch list. It now lives in the +tracker: **milestones are batches, issues are deliverables**, and severity is +`P0` / `P1` / `P2` / `release-blocker`. + +This file is what that list *was*. It is kept because the reasoning in it is +still worth reading, and deleted files are not readable. + +**It must not be updated.** A batch list beside the tracker is a second answer to +"what is open", and two records of the same thing will disagree without saying +which is right. That is precisely why the work moved. If you are tempted to add a +batch here, add a milestone instead. + +Its `Status: Archived` is therefore not a nicety — it is the machine-readable +form of "do not treat this as current". + +## "Ledger: missing" on the project screen is the correct answer + +The Command Center's docs report still looks for a batch ledger — at +docs/planning/FUTURE.md, named here without backticks deliberately, because +`doc-claims.sh` treats a backticked path as a claim that the file exists and +this one must not — and reports it as **missing** for every repository that has +moved planning into the tracker. That is the expected state and not a gap to +close: the report's own contract is that a tracker-first project shows a missing +ledger and no batch percentages. + +Creating that file to turn the line green would rebuild, in a new name, the +exact second record this one was archived for. + +This repository's record already reads `ledger: retired`, which is that contract +working as intended. + +## The batches, as they stood + +Ten phases, roughly 0.0.1 through 0.9.3, May to August 2026. The tracker now +holds all 205 issues; `Batch 00 — Pre-convention history` is the closed milestone +they were assigned to on adoption, because per-batch attribution was not +recoverable — closure timestamps collapse into five bulk-close days, 119 of them +on 2026-05-17 alone. + +| Phase | What it covered | Outcome | +| --- | --- | --- | +| 1 — Stack scaffold (`0.1.x`) | Vite + React + Tailwind, shadcn-style primitives, React Router, Express, better-sqlite3, first API paths | Complete | +| 2 — Layout rebuild (`0.2.x`) | app shell, all route pages, business content ported into React, service/industry data files, hash routing removed | Complete | +| 3 — Visual overhaul (`0.3.x`) | light-first business design, Tailwind theme, typography and spacing rhythm, mobile-first polish | Complete | +| 4 — Forms and backend hardening (`0.4.x`) | forms wired to Express, SQLite persistence, client and server validation, sanitisation, Zoho forwarding scaffold, rate limiting, Helmet, CORS | Complete | +| 5 — Verification and redesign (`0.5.x`) | SPA router fix, hero rewrite, trust signals, services rewrite, Why Queue North, footer and CTA pass | Complete | +| 6 — Owner feedback (`0.6.x`) | issues #30–#41: nav active state, service icons, Cisco signals, hero alignment, industry icons, 8x8 page merge | Complete except the About content, which needed owner input | +| 7 — Zoho CRM integration (`0.6.6`) | OAuth token endpoint fix, lead payload mapping, support→Cases, upsert, setup guide | Complete. Batch 7.5 (webhook verification) deferred and never taken up | +| 8 — Deferred enhancements | testimonials, blog, SEO, performance audit | SEO and performance done; testimonials and blog still open on owner content | +| 9 — Audit bug fixes (`0.7.0`) | 44 issues across batches 9.0–9.9 | Complete | +| 10 — Low-priority fixes (`0.7.1`) | 6 issues across batches 10.0–10.2 | Complete | +| — (`0.8.x`–`0.9.3`) | badges, mobile nav, breadcrumbs, injection hardening, 404, Zoho WebToLead mode, reCAPTCHA, privacy policy, full-route prerender | Complete. Deployed as `queue-north-website:dev` | + +## What was still open when the ledger was retired + +All of these were already filed as issues, which is why nothing needed migrating +out of this file when it was archived: + +- **#68** — About section content corrections. Blocked on owner input. +- **#69** — Testimonials / case studies. Blocked on real client content. +- **#70** — Blog / writing section. Blocked on an owner decision. +- **#108** — Certification number #25432 appears fabricated. Needs owner verification. +- **#110** — "25+ years" claim unverified against the original site. +- **#162** — About page: founding year, partnerships, cybersecurity, compass image. + +## The Zoho audit, as it was written + +Kept because it is the reasoning behind how the integration is shaped now, and +because points 1–7 and 11 were fixed by batches 7.0–7.3 — a reader who finds the +current code puzzling is usually looking at one of these decisions. + +### Current Implementation Status + +**✅ Already working:** +- OAuth2 refresh token flow with in-memory token caching +- 10s AbortController timeout on all Zoho fetches +- Null-check short-circuit if credentials missing +- Response.ok check before JSON parsing +- Double-slash URL path fix (`ZOHO_API_DOMAIN.replace(/\/$/, "")`) +- Fire-and-forget with `.catch()` (non-blocking, won't break lead submission) +- SQLite always writes first (Zoho is best-effort overlay) +- UNIQUE constraint on `leads.email` with 409 Conflict response +- Sanitization + Zod validation before insert +- Zoho forwarding also attempted on duplicate emails (in case Zoho record doesn't exist yet) + +**⚠️ Issues found in audit:** + +1. **`Service_Interest` is not a standard Zoho field** — Zoho uses `Lead_Source` (picklist) for tracking where leads come from, and custom fields for service interest. `Service_Interest` will be silently ignored unless a custom field with that exact API name exists in the Zoho org. Should map to `Lead_Source: "Website"` plus a custom field or `Description` for the specific service. + +2. **Name field mapping is wrong** — Code maps `leadData.name` to `Last_Name` (correct that it's required), but the contact form has a single "Name" field. Zoho requires `Last_Name` (mandatory) and has `First_Name` (optional). Current mapping `Last_Name: leadData.name || "Unknown"` puts the full name in Last_Name which works but is ugly in Zoho UI. Should split on last space. + +3. **Missing `Lead_Source` field** — Every web-submitted lead should have `Lead_Source: "Website"` so it's trackable in Zoho. Currently absent. + +4. **Token endpoint uses wrong URL** — The refresh token POST goes to `{ZOHO_API_DOMAIN}/oauth/v2/token` but Zoho requires the token endpoint to be on the **accounts server** (`accounts.zoho.com` for US, `accounts.zoho.eu` for EU, etc.), NOT the API domain (`www.zohoapis.com`). This is a **bug** — it will fail in production. + +5. **Missing `redirect_uri` in refresh token request** — The Zoho docs don't require `redirect_uri` for the refresh token grant, but our code sends it. It won't cause an error (Zoho ignores it), but it's unnecessary. + +6. **No `$approved` flag** — For web-to-lead submissions, Zoho recommends sending `"$approved": false` to route leads through approval workflows, preventing unverified web submissions from immediately entering the active pipeline. + +7. **No `trigger` parameter** — By default Zoho will fire all workflows/blueprints on API-created leads. Should send `"trigger": ["workflow"]` to explicitly control which automations run, or `"trigger": []` to suppress if unwanted. + +8. **Support requests not forwarded to Zoho** — Support form submissions go to SQLite only. Should create Cases (or at minimum Contacts + notes) in Zoho for ticket tracking. + +9. **Datacenter configuration** — `ZOHO_API_DOMAIN` defaults to `https://www.zohoapis.com` (US). Queue North is a US company, so this is correct. But the accounts-server URL (`https://accounts.zoho.com`) is a different domain and must be configured separately. Currently there's no `ZOHO_ACCOUNTS_DOMAIN` env var. + +10. **CSP `connect-src` missing Zoho domains** — Helmet CSP only allows `'self'` for `connect-src`. This doesn't affect server-to-server calls, but if any client-side code ever calls Zoho directly it would be blocked. (Not currently an issue since all Zoho calls are server-side.) + +11. **No retry on token refresh failure** — If the refresh token request fails, the lead is silently dropped. Should implement at least one retry with exponential backoff. + +12. **`Zip_Code` is not a standard Zoho field** — Standard Zoho Leads have `Zip_Code` as a field API name... actually this IS correct. `Zip_Code` is the standard field. ✅ + + +## Batch 7.5 — the one that was never done + +> **Zoho Webhook Verification — Future Enhancement** +> Optional: verify the Zoho integration works via a webhook callback. Not +> blocking for initial enablement. Could add a `/api/zoho/webhook` endpoint that +> Zoho calls on record changes — useful for confirming a lead was created, +> syncing status back, or notifying the team. Requires Zoho webhook setup in CRM +> and signature verification. + +Deliberately not filed as an issue on adoption. It was written as a "defer unless +requested" item and nobody has requested it; filing it would pad the tracker with +work nobody has chosen. It is recorded here so the idea is not lost. + +## The Phase 1–3 build record + +From the root-level `BUILD_SUMMARY.md`, migrated 2026-08-18 and then deleted. + +What it recorded as complete: the Vite/React/Tailwind foundation with all routes; +the Express backend serving `/api/health`, `/api/leads` and `/api/support`; SQLite +with `leads` and `support_requests`; layout components and the shadcn-style +primitives (Button, Card, Input, Textarea, Select, Badge, Sheet); every page +including the then-standalone `/8x8`; and the seven services and four industries +data files. + +Its "known issues" list is worth keeping, because three of the four were later +fixed and the record of them being *known* is what makes the fixes legible: +the Sheet component skipping TypeScript generics, image assets still being +placeholders rather than Queue North branding, `db/` needing to be gitignored, +and rate limiting not yet existing on the API endpoints. + +**Its embedded SQL schema was deliberately not carried forward.** It predated the +`UNIQUE` constraint on `leads.email` and the Zoho-related columns, so copying it +here would have created exactly the stale second record this tree exists to +prevent. `server/index.js` owns the schema. diff --git a/docs/history/DEVELOPMENT_LOG.md b/docs/history/DEVELOPMENT_LOG.md new file mode 100644 index 0000000..f2c18ff --- /dev/null +++ b/docs/history/DEVELOPMENT_LOG.md @@ -0,0 +1,918 @@ +# Development log — Queue North Website + +``` +Status: Current +Owner: _null +Last reviewed: 2026-08-18 +Governs: the dated record of what happened +Review trigger: Nothing. This file is appended to, never revised. +``` + +## How to use this + +Newest first. **One entry per work session**, written before you stop — that is +step 6 of `docs/WORK_CYCLE.md`, and the two lines it insists on are `Next +action` and `Blockers`. + +Those two are not decoration. The next session starts by reading the top of this +file, and a session that ended without saying what came next hands the one after +it a re-derivation instead of a starting point — which is where drift enters. +Neither line competes with anything: the live next action is the field on the +project at privacyllc.dev and the live blockers are issues in the tracker, while +these say what both were **at this date**. A record of then never disagrees with +a record of now. + +**Append-only by convention.** Correcting an old entry rewrites the record of +what was known at the time, which is the one thing this file is for. If an entry +turns out to be wrong, add a later entry saying so; do not edit the first. + +Note the Review trigger above says "nothing", deliberately. A dated log cannot +rot the way a description of current state can — the entries were true when +written and stay true. It is exempt from review for the same reason a receipt is. + +## Where the entries below came from + +Everything from `v0.7.1` down predates this repository adopting the template, and +arrived from two root-level files that were merged here on 2026-08-18 — the +gitignored `HISTORY.md` (a version-by-version changelog) and the gitignored +`DEVELOPMENT_LOG.md` (an agent-activity log). Both are reproduced **verbatim**. + +They are kept as two blocks rather than interleaved by date, deliberately. The +changelog carries three duplicated version headings — two `v0.5.2`, two `v0.5.3` +and two `v0.5.4`, written by different agents on the same day — so merging the +two streams into one date order would have required choosing an order the record +does not actually support. Two labelled blocks say what is known; one merged +sequence would have implied more. + +## Entries + +### 2026-08-18 — Adopted the project template + +Placeholder — replaced at the end of this session with the real entry, which +carries Next action and Blockers. + +--- + +## From `HISTORY.md` — the version changelog, verbatim + +> Migrated 2026-08-18. Newest first, as written. Not edited. + +## v0.7.1 — Low-Priority Fixes (6 issues, batches 10.0–10.2) — 2026-05-18 + +### Batch 10.0: Backend Cleanup (Neo) +- **#128:** Removed React Query dependency (~45KB bundle savings) — replaced with simple async fetch +- **#127:** Added HTTP→HTTPS redirect middleware for production (x-forwarded-proto) +- **#129:** Added CSP connectSrc comment documenting Zoho server-to-server calls + +### Batch 10.1: Image Optimization & UX (Scarlett) +- **#97:** Converted 5 large PNGs to WebP (95-96% size reduction each, total ~11MB → ~511KB) +- **#130:** Added loading spinner animation to Contact and Support form submit buttons + +### Batch 10.2: Accessibility (Scarlett + Ripley) +- **#101:** Added aria-labels to interactive elements across Header, Footer, MobileNav, Home, Contact, Support +- Fixed JSX template literal syntax bug where aria-label and className both used `${}` on same element +- Fixed Footer.jsx template literal aria-labels that would render as literal text +- Fixed Home.jsx duplicated aria-label text fragment + +## v0.7.0 — Audit Bug Fixes (44 issues, batches 9.0–9.9) — 2026-05-18 + +### Batch 9.0: Backend Security (Neo) +- **#120:** Added UNIQUE constraint on leads.email + migration script +- **#121:** Fixed Docker healthcheck — process.exit(1) on DB failure instead of always returning 200 +- **#123:** Removed world-writable chmodSync on SQLite DB file + +### Batch 9.1: UI Fixes (Scarlett) +- **#104:** Header CTA visibility — changed from invisible navy-on-navy to cyan-on-navy +- **#89:** Added ScrollToTop component for route navigation +- **#88:** Created 404 NotFound page with catch-all route + +### Batch 9.2: UI Fixes (Scarlett) +- **#91:** 8x8 white logo visibility on Trust Bar — added `brightness-0 invert` CSS filter +- **#94:** Industries page now uses dynamic icons instead of hardcoded building SVG +- **#125:** Added Building2 fallback for missing industry icons +- **#92:** Industry cards use homeDesc with fallback text +- **#93:** Removed redundant shortDesc from Home page service cards + +### Batch 9.3: Navigation Fixes (Scarlett) +- **#109:** Desktop nav now has hover dropdown menus for Services and Industries +- **#102:** Replaced all `