docs: adopt project template, retire the markdown backlog
This repository is the one the template's README describes: 205 issues, zero
milestones, and severity labels spelled "P0 Critical" / "P1 High" / "P2 Medium",
which privacyllc.dev reports as NOT ADOPTED rather than as 87% complete.
Six markdown records described the same work and none pointed at the tracker.
Two of them said the project was in "Phase 5" while the code was at 0.9.3.
Migrated, then deleted in this commit:
FUTURE.md -> docs/history/BATCH_LEDGER.md (Archived). Its open
items were all already filed as issues, so nothing
needed migrating into the tracker
HISTORY.md -> docs/history/DEVELOPMENT_LOG.md, verbatim, 0 lines lost
DEVELOPMENT_LOG.md -> the same file, as a second labelled block. Not
interleaved: the changelog has three duplicated version
headings, so one date order would have implied more
than the record supports
PROJECT.md -> docs/planning/PROJECT_PLAN.md
STRUCTURE.md -> the agent pipeline into README.md; its versioning rules
retired
BUILD_SUMMARY.md -> BATCH_LEDGER.md. Its embedded SQL schema deliberately
NOT carried: it predated the UNIQUE constraint on
leads.email, and server/index.js owns the schema
SCRIPTS.md -> docs/TOOLS.md, corrected for the SSR + prerender build
Moved with history (git detects all four as renames):
OVERHAUL_PLAN.md, review.md, project-requirements.md, docs/zoho-setup.md
Kept because this project earned them: the five-agent pipeline, the design
system in OVERHAUL_PLAN.md (Status: Current, with a front-note saying which half
is history), the positioning argument in REDESIGN_REVIEW.md, and REQUIREMENTS.md
whole, including its change policy.
Deleted from the template because they do not apply, each said out loud in
DOC_TRUST_MAP.md: QA pass I (no money moves), the authorisation checklist group
and the session-token row (no accounts, no sessions), and one PRECAUTIONARY
paragraph in SECURITY.md about holding credentials on behalf of users — there
are none, and PROJECT_PLAN.md records accounts as out of scope. Pass H was kept
and rewritten: its authorisation half does not apply, its what-a-stranger-can-
reach half is the most exposed surface here.
Also removed: main.js, the old static site's hash router, referenced by nothing
and preserved in .drop/; and test-results/.last-run.json, a May Playwright
artifact reading {"status":"failed"} for a suite that does not exist.
The repository was made private on Forgejo before this commit. That is what let
the internal history be committed rather than exempted — null/fruit-fall is
already private and reports normally.
Two defects found on the way in and fixed here: zoho-setup.md told admins to
edit `server/zoho/`, a directory that has never existed in any commit (the
mapping is in server/index.js), and README.md's route list still advertised
/8x8, removed at 0.6.6, while omitting /privacy-policy.
Branding: icon.webp and logo.webp converted from this project's own marks in
assets/. banner.webp is absent and is filed as an issue rather than faked.
Verified: verify.sh 3/3, doc-claims 71 claimed paths all present, backup and a
first-ever restore of the live leads database (2 tables, 3 rows, under 1s).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5c04195cb1
commit
aa1d805e04
|
|
@ -1,14 +1,12 @@
|
||||||
# Private project/agent docs — never commit
|
# Credentials and the original site drop — never commit, private repo or not.
|
||||||
DEVELOPMENT_LOG.md
|
# zoho.md holds the live reCAPTCHA secret key and the Zoho WebToLead tokens.
|
||||||
PROJECT.md
|
# See docs/security/SECURITY.md.
|
||||||
STRUCTURE.md
|
|
||||||
FUTURE.md
|
|
||||||
HISTORY.md
|
|
||||||
BUILD_SUMMARY.md
|
|
||||||
SCRIPTS.md
|
|
||||||
.drop/
|
|
||||||
zoho.md
|
zoho.md
|
||||||
|
.drop/
|
||||||
|
|
||||||
|
# Client correspondence. Its asks are tracked as issues; the letter itself is not
|
||||||
|
# a doc-tree document.
|
||||||
|
Levi.md
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
@ -20,6 +18,7 @@ dist-ssr/
|
||||||
# Runtime/database artifacts
|
# Runtime/database artifacts
|
||||||
db/*.db
|
db/*.db
|
||||||
db/*.db-*
|
db/*.db-*
|
||||||
|
test-results/
|
||||||
|
|
||||||
# Environment/local files
|
# Environment/local files
|
||||||
.env
|
.env
|
||||||
|
|
@ -38,6 +37,5 @@ pnpm-debug.log*
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.learnings/
|
.learnings/
|
||||||
Levi.md
|
|
||||||
Queue-North-Website.code-workspace
|
Queue-North-Website.code-workspace
|
||||||
Working Site.zip
|
Working Site.zip
|
||||||
|
|
|
||||||
248
README.md
248
README.md
|
|
@ -1,5 +1,21 @@
|
||||||
# Queue North Website
|
# 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
|
## Objective
|
||||||
|
|
||||||
Queue North Website is the modern rebuild of the Queue North Technologies business website.
|
Queue North Website is the modern rebuild of the Queue North Technologies business website.
|
||||||
|
|
@ -65,86 +81,73 @@ Primary structure:
|
||||||
- Dark navy conversion band
|
- Dark navy conversion band
|
||||||
- Consultation-focused message
|
- Consultation-focused message
|
||||||
|
|
||||||
## Planned Routes
|
## Routes
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
/
|
/ Home
|
||||||
/about
|
/about About
|
||||||
/services
|
/services Services index
|
||||||
/services/unified-communications
|
/services/:slug 7 service detail pages
|
||||||
/services/contact-center
|
/industries Industries index
|
||||||
/services/managed-support
|
/industries/:slug 4 industry detail pages
|
||||||
/services/consulting-training
|
/contact Contact form → POST /api/leads
|
||||||
/services/infrastructure-cabling
|
/support Support form → POST /api/support
|
||||||
/services/wireless-access
|
/privacy-policy Privacy policy
|
||||||
/services/local-networking
|
* 404
|
||||||
/industries
|
|
||||||
/industries/healthcare
|
|
||||||
/industries/retail
|
|
||||||
/industries/manufacturing
|
|
||||||
/industries/education-finance
|
|
||||||
/8x8
|
|
||||||
/contact
|
|
||||||
/support
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
## Where the work is
|
||||||
- ~~Vite + React app foundation~~ ✅
|
|
||||||
- ~~Tailwind CSS setup~~ ✅
|
|
||||||
- ~~shadcn/ui-style primitives~~ ✅
|
|
||||||
- ~~React Router~~ ✅
|
|
||||||
- ~~Express backend~~ ✅
|
|
||||||
- ~~better-sqlite3 database~~ ✅
|
|
||||||
- ~~Initial API health/contact/support paths~~ ✅
|
|
||||||
|
|
||||||
- **Phase 2 — Layout Rebuild**: `0.2.x` ✅ Complete
|
**Open work lives in the issue tracker, not in this file.** Milestones are
|
||||||
- ~~App shell: Header, Footer, layout wrapper, mobile nav~~ ✅
|
batches, issues are deliverables. There used to be a phase checklist here and in
|
||||||
- ~~Route pages fully built and navigable~~ ✅
|
four other documents; by the time it was removed it said *"Phase 5 — In
|
||||||
- ~~Existing business content ported into React~~ ✅
|
Progress"* while the last four commits said *batch 0.9.3*. That is the second
|
||||||
- ~~Repeated service/industry content moved into data files~~ ✅
|
record this convention exists to remove.
|
||||||
- ~~Static hash routing fully replaced by React Router~~ ✅
|
|
||||||
|
|
||||||
- **Phase 3 — Visual Overhaul**: `0.3.x` ✅ Complete
|
- **What is left, and what is next:** the milestones at
|
||||||
- ~~Modern light-first business design~~ ✅
|
[dream.scheller.ltd/null/Queue-North-Website](https://dream.scheller.ltd/null/Queue-North-Website/milestones)
|
||||||
- ~~Tailwind theme polish~~ ✅
|
- **What is broken:** issues labelled `P0`, `P1`, `P2`
|
||||||
- ~~Typography, spacing, radius, shadows, and responsive rhythm~~ ✅
|
- **What the batches were, before the tracker held them:**
|
||||||
- ~~Refined service/industry cards and CTA sections~~ ✅
|
[docs/history/BATCH_LEDGER.md](docs/history/BATCH_LEDGER.md)
|
||||||
- ~~Mobile-first layout polish~~ ✅
|
|
||||||
|
|
||||||
- **Phase 4 — Forms + Backend Hardening**: `0.4.x` ✅ Complete
|
The rebuild itself is done: phases 1 through 10 completed between May and August
|
||||||
- ~~Contact and support forms fully wired to Express~~ ✅
|
2026, from the original static HTML site to the current Vite + React SPA with a
|
||||||
- ~~SQLite persistence verified~~ ✅
|
prerendered build. `docs/history/DEVELOPMENT_LOG.md` has it version by version.
|
||||||
- ~~Client-side validation + Sonner feedback~~ ✅
|
|
||||||
- ~~Server-side validation + input sanitization~~ ✅
|
|
||||||
- ~~Optional Zoho forwarding layer~~ ✅
|
|
||||||
- ~~Rate limiting + security headers + CORS~~ ✅
|
|
||||||
- ~~Backend/API hardening as needed~~ ✅
|
|
||||||
|
|
||||||
- **Phase 5 — Verification + Redesign**: `0.5.x` 🔄 In Progress
|
### Where the tracker is
|
||||||
- ~~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
|
|
||||||
|
|
||||||
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
|
## 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.
|
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
|
## 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:
|
Example Docker batch:
|
||||||
|
|
||||||
|
|
@ -186,15 +199,26 @@ Example Docker batch:
|
||||||
Neo → Private Hudson → Bishop → Ripley
|
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
|
## 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
|
- **Modern, clean, stable** — not experimental, not hacker aesthetic
|
||||||
- **Business-first** — B2B UCaaS/IT partner, not a dev portfolio
|
- **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.
|
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
|
## Docker Deployment
|
||||||
|
|
||||||
|
|
@ -291,3 +315,77 @@ The `.dockerignore` excludes:
|
||||||
- `.git`, logs, private docs
|
- `.git`, logs, private docs
|
||||||
|
|
||||||
This ensures minimal image size and reproducible builds.
|
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.**
|
||||||
|
|
|
||||||
|
|
@ -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: <who maintains this>
|
||||||
|
Last reviewed: <YYYY-MM-DD>
|
||||||
|
Governs: <paths or subject this document is authoritative for>
|
||||||
|
Review trigger: <the change that should send someone back to this file>
|
||||||
|
Fires on: <optional — added, deleted, moved, changed>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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: <the path> — <why, in a few words>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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":<n>,"health":"on_track",
|
||||||
|
"currentSummary":"<where the project stands, in a sentence or two>",
|
||||||
|
"nextAction":"<the next concrete thing, specific enough to start>"}' \
|
||||||
|
"$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":"<what changed>","accomplishments":"<what landed>",
|
||||||
|
"blockers":"<what is stuck, or omit>","nextActions":"<what is next>"}' \
|
||||||
|
"$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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.**
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
# Zoho CRM Setup Guide for Queue North Admins
|
# 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.
|
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
|
### Field Mismatches
|
||||||
- If leads/cases don't appear, check if Zoho requires custom fields like `Service_Interest`
|
- 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
|
### Cases Not Appearing
|
||||||
- Ensure `ZOHO_CASES_ENABLED=true` is set
|
- Ensure `ZOHO_CASES_ENABLED=true` is set
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -1,5 +1,27 @@
|
||||||
# Queue North Website — 2026 Overhaul Plan
|
# 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
|
## TL;DR
|
||||||
|
|
||||||
Rebuild the current static HTML/CSS/JS website into a modern full-stack app using:
|
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
|
- `index.html` — all pages live in one large file
|
||||||
- `styles.css` — large hand-written stylesheet with many page-specific overrides
|
- `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
|
- Inline Zoho webform scripts
|
||||||
- Static assets in `assets/`
|
- Static assets in `assets/`
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
# Queue North Website Redesign Strategy
|
# 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
|
# Core Problem
|
||||||
|
|
||||||
Current website branding feels:
|
Current website branding feels:
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 `<Button onClick={navigate}>` with `<Link>` elements in Home.jsx
|
||||||
|
- **#105:** Contact page CTA scrolls to form instead of linking to itself
|
||||||
|
|
||||||
|
### Batch 9.4: UI Fixes (Scarlett)
|
||||||
|
- **#112:** Support CTA uses `bg-white text-primary-navy` instead of undefined `bg-secondary-bg`
|
||||||
|
- **#106:** Support page phone/email are now clickable links
|
||||||
|
- **#90:** Footer tel: links now include `+1` country code
|
||||||
|
|
||||||
|
### Batch 9.5: Backend Security (Neo)
|
||||||
|
- **#119:** Honeypot spam protection on Contact and Support forms
|
||||||
|
- **#126:** 409 Conflict shows friendly toast instead of error
|
||||||
|
|
||||||
|
### Batch 9.6: Build & Security (Neo)
|
||||||
|
- **#122:** Disabled production sourcemaps
|
||||||
|
- **#124:** CORS default changed from `*` to `https://queuenorth.com`
|
||||||
|
- **#131:** CSP allows WebSocket for Vite HMR in development
|
||||||
|
|
||||||
|
### Batch 9.7: Assets & Meta (Scarlett)
|
||||||
|
- **#99:** Added favicon, apple-touch-icon, site.webmanifest
|
||||||
|
- **#117:** og:image now uses PNG (1200x630) instead of unsupported SVG
|
||||||
|
- **#118:** Logo optimized from 348KB SVG to 44KB PNG (87% reduction)
|
||||||
|
|
||||||
|
### Batch 9.8: Icons & Copy (Scarlett)
|
||||||
|
- **#98:** Cisco uses ShieldCheck icon, Veteran uses Award icon (no more text placeholders)
|
||||||
|
- **#100:** Removed invalid Category/slug display from ServiceDetail sidebar
|
||||||
|
- **#103:** Removed JointLogoWhite placeholder section, replaced with clean Trust Bar
|
||||||
|
- **#107:** Footer address changed from marketing copy to "Orlando, FL"
|
||||||
|
|
||||||
|
### Batch 9.9: Cleanup (Neo)
|
||||||
|
- **#95:** Removed 23 unused asset files (24MB freed)
|
||||||
|
- **#113:** MobileNav verified clean (no dead code found)
|
||||||
|
- **#114:** Removed duplicate CardDescription.jsx (properly used from Card.jsx)
|
||||||
|
- **#115:** Removed unused Dialog.jsx component
|
||||||
|
- **#116:** Removed unused zustand dependency
|
||||||
|
|
||||||
|
## v0.6.6 — Phase 7: Zoho CRM Integration Complete — 2026-05-17
|
||||||
|
|
||||||
|
### Zoho Integration (all batches 7.0–7.4)
|
||||||
|
- **#74 (Batch 7.0):** Fixed OAuth token endpoint — accounts domain vs API domain, removed unnecessary redirect_uri, added retry logic on token refresh failure
|
||||||
|
- **#75 (Batch 7.1):** Fixed lead payload mapping — split name into First_Name/Last_Name, added Lead_Source: Website, mapped Service_Interest to Description, added trigger: ["workflow"]
|
||||||
|
- **#76 (Batch 7.2):** Added Zoho Cases forwarding — forwardSupportToZoho() for support requests, ZOHO_CASES_ENABLED toggle, fire-and-forget pattern, priority/name/company mapping
|
||||||
|
- **#77 (Batch 7.3):** Changed to upsert API — duplicate leads update instead of fail, duplicate_check_fields: ["Email"]
|
||||||
|
- **#78 (Batch 7.4):** Created docs/zoho-setup.md — step-by-step setup guide for non-technical admins, datacenter variants, troubleshooting
|
||||||
|
- **#73:** Parent Zoho CRM Integration issue closed
|
||||||
|
|
||||||
|
## v0.6.5 — Batch 0.6.5: Code Review Bug Fixes (10 issues) — 2026-05-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **#63 (P0):** Home.jsx — `industry.href` undefined, navigating to `/industries/undefined`. Fixed to use `/industries/${industry.id}`
|
||||||
|
- **#50 (P1):** Server — `sanitized` scope error in catch block. Moved `let sanitized` before `try` so it's accessible in both `try` and `catch`
|
||||||
|
- **#58 (P1):** Footer.jsx — All internal links converted from `<a href>` to `<Link to>` for client-side navigation
|
||||||
|
- **#61 (P1):** Textarea.jsx — className template literal used regular quotes instead of backticks, so `${className}` was never interpolated
|
||||||
|
- **#59 (P2):** About.jsx — CTA button converted from `<a href>` to `<Link to>`
|
||||||
|
- **#60 (P2):** Support.jsx — Contact Us button converted from `<a href>` to `<Link to>`
|
||||||
|
- **#62 (P2):** Badge.jsx — `text-foreground` (undefined) changed to `text-text`
|
||||||
|
- **#64 (P2):** Support.jsx — `hover:bg-navy-darker` (undefined) changed to `hover:bg-primary-navy-dark`
|
||||||
|
- **#65 (P2):** Server — timeoutMiddleware moved before catch-all routes so it actually applies
|
||||||
|
- **#66 (P3):** Contact.jsx — Self-referencing `<a href="/contact">` converted to `<Link to="/contact">`
|
||||||
|
|
||||||
|
### Open
|
||||||
|
- **#67 (P3):** Asset filenames with spaces (low priority, cosmetic)
|
||||||
|
|
||||||
|
## v0.6.2 — Phase 6 Complete: All Owner Feedback Issues Closed — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- **Issue #45:** Nav active-state styling — already implemented (useLocation + isActive helper, desktop + mobile)
|
||||||
|
- **Issue #46:** Service card icons — already implemented (lucide-react icons from services.js data)
|
||||||
|
- **Issue #47:** Cisco partnership signals — already implemented (Home hero, trust signals, Why Queue North, footer)
|
||||||
|
|
||||||
|
### Note
|
||||||
|
These three issues were resolved in earlier Phase 5/6 batches. Verified in code and closed.
|
||||||
|
|
||||||
|
## v0.6.1 — Batch 0.6.1: Security Hardening, Retry Logic, Debouncing — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- **Batch 0.6.1:**
|
||||||
|
- **Issue #12 (P2):** Removed `'unsafe-inline'` from CSP styleSrc since built SPA has all styles in CSS files (no inline styles)
|
||||||
|
- **Issue #15 (P2):** Added exponential backoff retry logic with 3 max retries (1s, 2s, 4s) to `src/lib/api.js` for 5xx errors and network failures
|
||||||
|
- **Issue #17 (P3):** Added `useDebounce` hook with 300ms delay to debounce validation errors in Contact.jsx and Support.jsx
|
||||||
|
- **Issue #18 (P3):** Closed — React Query already handles GET caching with `staleTime: 5 minutes`
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `server/index.js` — Removed `'unsafe-inline'` from CSP styleSrc
|
||||||
|
- `src/lib/api.js` — Added `retryFetch` helper with exponential backoff for get/post methods
|
||||||
|
- `src/hooks/useDebounce.js` — Created debounce hook
|
||||||
|
- `src/pages/Contact.jsx` — Integrated debounce for validation errors
|
||||||
|
- `src/pages/Support.jsx` — Integrated debounce for validation errors
|
||||||
|
- `package.json` — Version bumped to 0.6.1
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- ✅ `npm run build` passes
|
||||||
|
- ✅ Docker image builds successfully
|
||||||
|
- ✅ Container starts on port 3001 and responds to health check
|
||||||
|
- ✅ Contact and Support forms debounce validation errors
|
||||||
|
- ✅ API retry logic configured (3 retries, exponential backoff)
|
||||||
|
- ✅ CSP correctly configured for static SPA (no inline styles)
|
||||||
|
|
||||||
|
### Forgejo Issues
|
||||||
|
- Issue #12 closed (CSP fix applied)
|
||||||
|
- Issue #15 closed (retry logic implemented)
|
||||||
|
- Issue #17 closed (debouncing added)
|
||||||
|
- Issue #18 closed (React Query handles GET caching)
|
||||||
|
|
||||||
|
**Note for Ripley:** Forgejo issue closing via API requires `FORGEJO_REGISTRY_TOKEN` environment variable. Token must be provided manually or via CI/CD secrets before closing issues via the Forgejo API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.9 — Phase 6 Batches 0.6.5–0.6.7: Industry Icons, 8x8 Page Merge, Image Verification — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- **Batch 0.6.5 (Industry Icons Bigger, Issue #41):**
|
||||||
|
- Industry icons enlarged with improved color and contrast
|
||||||
|
- Icons more visually prominent on all screen sizes
|
||||||
|
|
||||||
|
- **Batch 0.6.6 (8x8 Page Merge, Issue #32):**
|
||||||
|
- Removed standalone /8x8 page and route
|
||||||
|
- Merged 8x8 partnership content into UCaaS and CCaaS service entries
|
||||||
|
- Removed 8x8 nav links from Header and Footer
|
||||||
|
- Deleted src/pages/8x8.jsx
|
||||||
|
|
||||||
|
- **Batch 0.6.7 (Verify Image Issues, Issues #30, #31):**
|
||||||
|
- Confirmed services and industries pages render icons correctly (lucide-react)
|
||||||
|
- No broken image references — Phase 5 redesign resolved both issues
|
||||||
|
- Docker build + runtime test passed
|
||||||
|
|
||||||
|
### Forgejo Issues Closed
|
||||||
|
- #30 (service images broken) — resolved by icon redesign
|
||||||
|
- #31 (industry images broken) — resolved by icon redesign
|
||||||
|
- #32 (8x8 page merge) — merged into service pages
|
||||||
|
- #33–#41 — all owner feedback issues resolved
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Industry icon sizing (batch 0.6.5)
|
||||||
|
- `src/data/services.js` — Expanded UCaaS/CCaaS with 8x8 content (batch 0.6.6)
|
||||||
|
- `src/router.jsx` — Removed /8x8 route
|
||||||
|
- `src/components/layout/Header.jsx` — Removed 8x8 nav link
|
||||||
|
- `src/components/layout/Footer.jsx` — Removed 8x8 nav link
|
||||||
|
- `src/pages/8x8.jsx` — Deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.7 — Phase 5 Batch 9: Hero Left-Alignment + About Section Fixes (Issues #33, #34, #36, #37) — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- **Batch 0.6.3 (Hero Left-Alignment, Issue #33):**
|
||||||
|
- Hero text left-aligned on all screen sizes (was center-aligned on desktop)
|
||||||
|
- Hero image changed from `hidden lg:block` to always visible, `md:order-2` for tablet+ layout
|
||||||
|
- Grid changed from `lg:grid-cols-2` to `md:grid-cols-2` for earlier responsive breakpoint
|
||||||
|
- Spacing refactored from margin utils to gap utils
|
||||||
|
- **Batch 0.6.4 (About Section Fixes, Issues #34, #36, #37):**
|
||||||
|
- Removed "Founded in 2000" → updated to "Founded by military veterans"
|
||||||
|
- Added "We continue the mission" veteran framing paragraph
|
||||||
|
- Replaced "Cybersecurity for Communications" with "Vendor-Neutral Consulting" in expertise list
|
||||||
|
- Updated About page image sizing to `max-h-96 h-auto object-cover`
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Hero section layout changes, image visibility fix
|
||||||
|
- `src/pages/About.jsx` — Story text update, image sizing fix, expertise list update
|
||||||
|
- `package.json` — Version bumped to 0.5.7
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- ✅ `npm run build` passes clean
|
||||||
|
- ✅ Docker image builds successfully
|
||||||
|
- ✅ Container starts on port 3001 and responds to health check
|
||||||
|
- ✅ Home page loads at http://localhost:3001 — hero text left-aligned, image visible
|
||||||
|
- ✅ About page loads at http://localhost:3001/about — veteran framing present, image sized correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cisco Partnership Signals — 2026-05-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Cisco Certified Partner trust card in Trust Signals section (8x8 and Cisco dual partnership)
|
||||||
|
- Cisco platform mentions in Services section descriptions
|
||||||
|
- Cisco partner badge in Footer bottom section
|
||||||
|
- Cisco partner visual indicator (white "C" badge) in Trust Bar section
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Cisco trust card, trust bar Cisco badge, vendor neutrality section
|
||||||
|
- `src/data/services.js` — Cisco platform mentions in unified-communications and contact-center services
|
||||||
|
- `src/components/layout/Footer.jsx` — Cisco in footer bottom section
|
||||||
|
- `package.json` — Version bumped to 0.5.6
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- ✅ `npm run build` passes clean
|
||||||
|
- ✅ Docker image builds successfully
|
||||||
|
- ✅ Container starts on port 3001 and responds to health check
|
||||||
|
- ✅ Home page loads at http://localhost:3001 — Cisco appears in trust signals
|
||||||
|
- ✅ Services page loads at http://localhost:3001/services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zoho P0/P1 Audit Fixes — 2026-05-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- P0 #2: Added null check for Zoho credentials before forwarding (early return)
|
||||||
|
- P0 #3: Added response.ok check for Zoho API responses (log + return on failure)
|
||||||
|
- P0 #4: Confirmed Docker uses USER nodejs (no su-exec needed)
|
||||||
|
- P1 #5: Added 10-second timeout on Zoho fetch using AbortController
|
||||||
|
- P1 #6: UNIQUE constraint on email already handled by db.js migration
|
||||||
|
- P1 #10: Schema normalizes empty strings to null; Zoho payload uses `|| null`
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `server/index.js` — Zoho forwarding logic (credential checks, response validation, timeout, payload normalization)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
- ✅ `npm run build` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.5 — Phase 5 Batch 8: Service Card Icons (Issue #35) — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Services page updated with lucide-react icons matching Home page pattern
|
||||||
|
- 7 distinct icons: MessageCircle, Users, LifeBuoy, GraduationCap, LinkIcon, Wifi, Network
|
||||||
|
- Icon-switching logic in Services.jsx maps each service icon name to correct component
|
||||||
|
- No hardcoded lightning bolt — each service displays its own distinct icon
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Services.jsx` — replaced hardcoded lightning bolt SVG with lucide-react imports and icon-switching logic
|
||||||
|
- `package.json` — version bumped to 0.5.5
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- ✅ `npm run build` passes clean
|
||||||
|
- ✅ Docker image builds successfully
|
||||||
|
- ✅ Container starts on port 3001 and responds to health check
|
||||||
|
- ✅ Services page loads at http://localhost:3001/services
|
||||||
|
- ✅ Each of the 7 service cards displays a distinct icon per services.js data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.4 — Phase 5 Batch 7: Nav Active-State Styling — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Header.jsx and MobileNav.jsx now use `useLocation` hook from React Router 7
|
||||||
|
- Active nav links display white text (`text-white`) with `font-semibold` styling
|
||||||
|
- `isActive()` helper function compares `location.pathname` to link href
|
||||||
|
- Both desktop and mobile navigation use consistent active-state styling
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/components/layout/Header.jsx` — Added `useLocation` import and `isActive()` function, updated desktop nav and mobile nav link styling
|
||||||
|
- `src/components/layout/MobileNav.jsx` — Added `useLocation` import and `isActive()` function, updated all nav links with active-state styling
|
||||||
|
- `package.json` — Version bumped to 0.5.4
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- ✅ `npm run build` passes clean
|
||||||
|
- ✅ Docker image builds successfully
|
||||||
|
- ✅ Container starts on port 3001 and responds to health check
|
||||||
|
- ✅ Home page loads at http://localhost:3001
|
||||||
|
- ✅ Both Header.jsx and MobileNav.jsx import `useLocation` from `react-router-dom`
|
||||||
|
- ✅ `isActive()` function correctly compares `location.pathname` to link href
|
||||||
|
- ✅ Active links display white text with font-semibold styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.3 — Phase 5 Batch 6: Footer + Contact Improvements — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Footer cleanup: email (`info@queuenorth.com`), phone (`(906) 482-6616`), and CTA (`Request Consultation`) all present
|
||||||
|
- Contact page hero: prominent phone/email display + CTA button
|
||||||
|
- Home page CTA links added to Trust Signals, Services, Why Queue North sections
|
||||||
|
- Minor updates to Support page
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/components/layout/Footer.jsx` — Footer cleanup
|
||||||
|
- `src/pages/Home.jsx` — CTA links in sections
|
||||||
|
- `src/pages/Contact.jsx` — Hero CTA
|
||||||
|
- `src/pages/Support.jsx` — Minor updates
|
||||||
|
- `package.json` — Version bumped to 0.5.3
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes
|
||||||
|
- Docker image builds and container starts on port 3001
|
||||||
|
- Footer renders with email, phone, and CTA
|
||||||
|
- Contact page hero displays phone/email and Request Consultation button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.2 — Phase 5 Batch 5: Why Queue North Section Refinement — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Replaced generic "Architecture/Deployment/Lifecycle" pillars with 4 concrete differentiators
|
||||||
|
- Added lucide-react icons: Headphones, UserCheck, Activity, ShieldCheck
|
||||||
|
- Changed grid from 3-col to 4-col responsive layout (1 col mobile, 2 col tablet, 4 col desktop)
|
||||||
|
- Removed unused CardDescription import
|
||||||
|
- Updated Why Queue North section subtitle to "Four concrete differentiators that set us apart"
|
||||||
|
- Clean card layout with centered icon containers and consistent styling
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Why Queue North section complete redesign
|
||||||
|
- `package.json` — Version bumped to 0.5.2
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes
|
||||||
|
- Docker image builds and container starts
|
||||||
|
- Home page loads on http://localhost:3001
|
||||||
|
- Responsive layout verified (1/2/4 columns as specified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.1 — Phase 5 Batch 1: Hamburger Menu Fix + DialogTitle A11y — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Fixed SheetContent missing positional CSS classes (panel was zero-size)
|
||||||
|
- Installed and configured tailwindcss-animate
|
||||||
|
- Added visually hidden SheetTitle for accessibility (Radix DialogTitle requirement)
|
||||||
|
- Forgejo issues #22, #23 closed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.4 — Phase 5 Batch 4: Services Rewrite — Business Outcomes — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Section title changed: "Our Services" → "What We Handle"
|
||||||
|
- Subtitle changed to outcome-focused: "From phones to firewalls, we keep your business running"
|
||||||
|
- Added `homeDesc` field to all 7 services in `src/data/services.js` with business-outcome language
|
||||||
|
- Service cards now show: icon + name + homeDesc (outcome) + shortDesc (supporting) + CTA
|
||||||
|
- lucide-react icons per service: MessageCircle, Users, LifeBuoy, GraduationCap, LinkIcon, Wifi, Network
|
||||||
|
- B2B professional card layout with icon containers (primary-navy/10 bg)
|
||||||
|
- Service detail pages (ServiceDetail.jsx) unchanged
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Services section rewrite
|
||||||
|
- `src/data/services.js` — Added homeDesc field to each service
|
||||||
|
- `FUTURE.md` — Marked batch 0.5.4 complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.3 — Phase 5 Batch 3: Trust Signals Section — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Added dedicated Trust Signals section immediately after hero section
|
||||||
|
- 8x8 Certified Partner badge: prominent white card with logo, certification number, and descriptive text
|
||||||
|
- Veteran Owned badge: VCERT verified with certification #, values-based messaging
|
||||||
|
- "25+ Years Experience": metric card with 25+ using Georgia font (font-numeric class)
|
||||||
|
- Metrics grid: 4 key stats displayed (99.99% uptime, <15m response, 24/7 support, 100% satisfaction)
|
||||||
|
- Clean card layout: white cards on section-alt background with hover effects
|
||||||
|
- Mobile-first: three-column grid on desktop, stacked on mobile
|
||||||
|
- Business outcomes focus: reliability, experience, support承诺, not technical jargon
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Added Trust Signals section after hero
|
||||||
|
- `FUTURE.md` — Marked batch 0.5.3 complete
|
||||||
|
|
||||||
|
### Notes for Next Agent
|
||||||
|
- The trust signals are now front and center after the hero, before services
|
||||||
|
- Georgia font (`font-numeric`) consistently used for all metrics
|
||||||
|
- All metrics use realistic placeholder values that can be replaced with real stats when available
|
||||||
|
- The white card design provides contrast against the section-alt background
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.2 — Phase 5 Batch 2: Hero Section Rewrite — 2026-05-17
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- Hero headline replaced: "Modern Communications Infrastructure Without the Vendor Noise" → "Reliable Business Communications — Without the Runaround"
|
||||||
|
- Subtext updated to emphasize trust, reliability, and business outcomes
|
||||||
|
- 8x8 Certified Partner badge made prominent (featured card with logo, not small pill)
|
||||||
|
- Trust signals immediately visible on mobile without scrolling
|
||||||
|
- CTAs updated: "Schedule Consultation" (primary, /contact), "View Services" (secondary, /services)
|
||||||
|
- Color palette evolved: navy base with teal-900 gradient accent
|
||||||
|
- Mobile-first design verified — trust signals visible above fold on phone viewports
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `src/pages/Home.jsx` — Hero section rewrite
|
||||||
|
- `.learnings/scarlett/LEARNINGS.md` — Added hero rewrite entry
|
||||||
|
- `FUTURE.md` — Marked batch 0.5.2 complete
|
||||||
|
|
||||||
|
### Notes for Next Agent
|
||||||
|
- The 8x8 badge is now prominently featured — keep this prominence in future sections
|
||||||
|
- Georgia font (`font-numeric`) used for "25+ Years Experience" — consistent with design rule
|
||||||
|
- Consider similar trust signal presentation in the Trust Bar section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.8 — Phase 4 Batch 8: Error Handling Hardening + Dep Audit — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Global error handlers: uncaughtException + unhandledRejection → log + exit(1)
|
||||||
|
- 404 catch-all for `/api/*` routes returns `{ error: 'Not found' }`
|
||||||
|
- Health check enhanced: DB connectivity check, returns `{ status: 'ok', db: 'ok' }` or 503
|
||||||
|
- Request timeout: 30-second middleware, 504 response
|
||||||
|
- Consistent error format: 400/404/429/500/503/504 all follow `{ error, fields? }` pattern
|
||||||
|
- `npm audit`: 0 vulnerabilities
|
||||||
|
- `npm outdated`: major updates noted (Express 5, Vite 8, etc.) but not upgraded
|
||||||
|
|
||||||
|
## v0.4.7 — Phase 4 Batch 7: Rate Limiting + Security Headers + CORS — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- express-rate-limit: 5 req/min per IP on /api/leads and /api/support, configurable via RATE_LIMIT_PER_MINUTE
|
||||||
|
- helmet: CSP (scripts 'self', styles 'self' inline, fonts 'self' + Google Fonts), HSTS, X-Content-Type-Options, X-Frame-Options DENY
|
||||||
|
- cors: configurable via CORS_ORIGIN env var, credentials enabled, API routes only
|
||||||
|
- Trust proxy enabled for correct client IP behind Docker/reverse proxy
|
||||||
|
- Rate limit returns 429 with JSON error + retryAfter
|
||||||
|
- All security headers confirmed via curl
|
||||||
|
- Docker config updated: all new env vars in Dockerfile and docker-compose.yml
|
||||||
|
|
||||||
|
## v0.4.6 — Phase 4 Batch 6: Zoho Forwarding Layer — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Zoho CRM forwarding via REST API v8 (POST /crm/v8/Leads)
|
||||||
|
- OAuth2 token management: refresh token → access token, in-memory cache with auto-refresh
|
||||||
|
- Fire-and-forget: Zoho forwarding is async, never blocks form response
|
||||||
|
- Configurable via 6 env vars: ZOHO_ENABLED, ZOHO_API_DOMAIN, ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_REDIRECT_URI
|
||||||
|
- Best-effort design: Zoho failures logged but never propagate to client
|
||||||
|
- ZOHO_ENABLED=false: no Zoho activity, form submits normally
|
||||||
|
- ZOHO_ENABLED=true with invalid creds: form still succeeds, Zoho errors logged to console
|
||||||
|
|
||||||
|
## v0.4.5 — Phase 4 Batch 5: Server-Side Validation + Input Sanitization — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Zod schemas updated with `.trim()` and `.max()` on all fields
|
||||||
|
- Input sanitization: strip HTML/script tags, truncate to max lengths
|
||||||
|
- Request body size limit: 1MB (returns 413)
|
||||||
|
- Validation errors formatted as `{ error, fields }` — no stack traces
|
||||||
|
- All curl tests pass: valid data, empty fields, invalid email, XSS, max length, short issue
|
||||||
|
- XSS test confirmed: `<script>` and `<b>` tags stripped from stored data
|
||||||
|
|
||||||
|
## v0.4.4 — Phase 4 Batch 4: Client-Side Validation + Sonner Feedback — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Contact form: required field validation (company, name, email, message)
|
||||||
|
- Support form: required field validation (name, company, email, issue)
|
||||||
|
- Email format validation with regex on both forms
|
||||||
|
- Issue minimum length validation (10 chars) on Support form
|
||||||
|
- Inline error messages below each invalid field (red text, small)
|
||||||
|
- Sonner toast for validation errors on submit attempt
|
||||||
|
- Error clearing as user corrects fields (onChange)
|
||||||
|
- Red ring/border on Input/Textarea when field has validation error
|
||||||
|
- Form inputs disabled during submission (mutation.isPending)
|
||||||
|
- Form opacity reduced to 70% during submission
|
||||||
|
- Build passes clean
|
||||||
|
|
||||||
|
## v0.4.1 — Phase 4 Batch 1: Contact Form Wired to Express — 2026-05-12
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Contact form submits all fields to `/api/leads` via TanStack Query mutation
|
||||||
|
- Success/error response handling with Sonner toasts
|
||||||
|
- Confirmation shown on success, form resets
|
||||||
|
- Error state shown on failure
|
||||||
|
- Backend Zod validation + SQLite storage working
|
||||||
|
- Already implemented in Phase 1/2, verified end-to-end
|
||||||
|
|
||||||
|
## v0.2.13-fix — Phase 2 Fix: Legacy CSS Consolidation — 2026-05-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed duplicate `src/index.css` (was not imported anywhere)
|
||||||
|
- Created clean `src/index.css` as single Tailwind entry point
|
||||||
|
- Moved `maxWidth.container: 1280px` into `tailwind.config.js` theme extensions
|
||||||
|
- Updated `src/App.jsx` import from `./App.css` to `./index.css`
|
||||||
|
- Deleted redundant `src/App.css`
|
||||||
|
- All `bg-section-alt` usages verified still working
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes
|
||||||
|
- All 14 Phase 2 batches now fully verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.3 — Phase 4 Batch 3: SQLite Persistence Verification — 2026-05-13
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Database file (`db/queuenorth.db`) created on first run if missing
|
||||||
|
- `leads` and `support_requests` tables have correct schema (all columns match server/index.js)
|
||||||
|
- Data persists across server restarts
|
||||||
|
- Docker volume test: stop container, restart, confirm data present
|
||||||
|
- Health check endpoint responds correctly
|
||||||
|
- `docker-test.sh` persistence suite passes all checks
|
||||||
|
|
||||||
|
## v0.4.2 — Phase 4 Batch 2: Support Form Wired to Express — 2026-05-12
|
||||||
|
|
||||||
|
### Verified Complete
|
||||||
|
- Support form submits all fields to `/api/support` via TanStack Query mutation
|
||||||
|
- Success/error response handling with Sonner toasts
|
||||||
|
- Confirmation shown on success, form resets
|
||||||
|
- Error state shown on failure
|
||||||
|
- Backend Zod validation + SQLite storage working
|
||||||
|
- Already implemented in Phase 1/2, verified end-to-end
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2.2 — Docker Image Size Fix — 2026-05-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed duplicate `node_modules` copy in Dockerfile runner stage (was copying full dev+prod modules as a permanent layer)
|
||||||
|
- Reduced image size from 331MB to 215MB (35% reduction)
|
||||||
|
- `npm ci --omit=dev` now runs cleanly without pre-existing dev modules
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Docker build succeeds
|
||||||
|
- Container starts and health check passes
|
||||||
|
- Frontend serves correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Queue-North-Website — Changelog
|
||||||
|
|
||||||
|
## v0.3.4 — Phase 3 Visual Overhaul Complete — 2026-05-12
|
||||||
|
|
||||||
|
### Completed Batches
|
||||||
|
- **Batch 1 (v0.3.1):** Tailwind theme tokens, spacing scale, container width, Inter font
|
||||||
|
- **Batch 2 (v0.3.2):** Home page redesign — hero, trust bar, services grid, why QN pillars, industries preview, final CTA
|
||||||
|
- **Batch 3 (v0.3.3):** Header/footer/mobilenav polish, navy-light color token, fixed Button.jsx TS generics build bug
|
||||||
|
- **Batch 4 (v0.3.4):** Inner pages layout system — consistent hero/card/CTA pattern across About, Services, ServiceDetail, Industries, IndustryDetail, 8x8
|
||||||
|
- **Batch 5:** Contact/Support forms verified compliant, no changes needed
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- All batches build successfully
|
||||||
|
- Contact and Support pages already compliant with OVERHAUL_PLAN.md
|
||||||
|
- No `asChild` usage on Button (unsupported, replaced with styled anchors)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3.2 — Phase 3 Batch 2: Home Page Redesign — 2026-05-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated Home.jsx to import `industries` data from data file
|
||||||
|
- Replaced hardcoded industry data with dynamic rendering from `industries.js`
|
||||||
|
- Updated Services and Industries cards to use Button component for "Learn more" links
|
||||||
|
- Added MapPin icon from lucide-react for industry cards
|
||||||
|
- Added useNavigate hook for programmatic navigation
|
||||||
|
- Ensured consistent use of shadcn/ui Button component across the home page
|
||||||
|
- Updated version to `0.3.2` for Phase 3 Batch 2
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes
|
||||||
|
- All routes respond correctly
|
||||||
|
- Button components render with correct styling
|
||||||
|
- Responsive layout works on mobile and desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3.1 — Phase 3 Batch 1: Theme + Tailwind Config + Typography — 2026-05-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Enhanced Tailwind spacing scale with utility values (24-48rem)
|
||||||
|
- Updated container max-width to 1280px per design spec
|
||||||
|
- Confirmed color palette, Inter font, Georgia numeric font already in place
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1.1 — Phase 1 Agent-Pass Checkpoint — 2026-05-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Corrected Phase 1 version to reflect completed agent-pass checkpoint semantics.
|
||||||
|
- Documented that every completed agent pass/checkpoint within a phase increments the patch version.
|
||||||
|
- Confirmed Phase 1 verified output is pushed to `dev` at `0.1.1`.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes.
|
||||||
|
- Backend health endpoint responds successfully at `/api/health`.
|
||||||
|
|
||||||
|
## v0.2.1 — Dockerization — 2026-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker build for production deployment
|
||||||
|
- docker-compose.yml for local development with SQLite persistence
|
||||||
|
- npm scripts: `docker:build`, `docker:run`, `docker:compose:up/down/logs`
|
||||||
|
- Health check in Dockerfile and docker-compose
|
||||||
|
- Non-root `nodejs` user for security
|
||||||
|
- Layer caching optimization (copy package.json first, install, then copy source)
|
||||||
|
- `.dockerignore` to exclude node_modules, dist, db, logs, git, private docs
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated package.json to `0.2.1` for Docker batch
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Docker build succeeds with `npm run docker:build`
|
||||||
|
- Container starts and health check passes
|
||||||
|
- SQLite database persists in `./db` volume
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2.0 — Phase 2 Layout Complete — 2026-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- All route pages implemented (Home, About, Services, ServiceDetail, Industries, IndustryDetail, 8x8, Contact, Support).
|
||||||
|
- Data files for services and industries moved to `/src/data/`.
|
||||||
|
- Layout components (Header, Footer, MobileNav) built and integrated.
|
||||||
|
- Legacy `styles.css` removed (replaced by Tailwind configuration).
|
||||||
|
- Contact and support forms wired to Express backend via TanStack Query.
|
||||||
|
- All pages render meaningful content matching original business information.
|
||||||
|
- Version bumped to `0.2.0` for Phase 2 baseline.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replaced hash-based routing with React Router 7.
|
||||||
|
- Standardized layout patterns across all pages (page hero, main content, sidebar, CTA band).
|
||||||
|
- Updated package.json, PROJECT.md, OVERHAUL_PLAN.md, HISTORY.md to reflect Phase 2 status.
|
||||||
|
- Overhaul plan updated to note Phase 2 scope and goals.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes.
|
||||||
|
- All routes respond correctly.
|
||||||
|
- Contact form submits via `/api/leads`.
|
||||||
|
- Support form submits via `/api/support`.
|
||||||
|
- No references to legacy `styles.css` remain in source code.
|
||||||
|
|
||||||
|
## v0.1.0 — Phase 1 Foundation — 2026-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Rebuilt project foundation on Vite + React SPA with React Router.
|
||||||
|
- Added Tailwind CSS with Queue North light-first business palette.
|
||||||
|
- Added shadcn/ui-style local primitives for buttons, cards, inputs, textarea, select, badge, sheet, and dialog usage.
|
||||||
|
- Added Sonner toast support and TanStack Query provider/API helper.
|
||||||
|
- Added Express backend with `/api/health`, `/api/leads`, and `/api/support`.
|
||||||
|
- Added better-sqlite3 storage for `leads` and `support_requests`.
|
||||||
|
- Added all planned frontend routes for home, about, services, service details, industries, industry details, 8x8, contact, and support.
|
||||||
|
- Added Phase 1 documentation, build summary, script reference, and phase-based versioning rules.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replaced the static HTML/CSS/JS entry with the Vite React entry.
|
||||||
|
- Updated README to point to `OVERHAUL_PLAN.md` as the design source of truth.
|
||||||
|
- Standardized versioning so Phase 1 uses `0.1.x`, Phase 2 uses `0.2.x`, and later phases follow the same pattern.
|
||||||
|
- Added Bishop verification rules and the requirement that Ripley pushes to `dev` after each verified phase.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- `npm run build` passes.
|
||||||
|
- Backend health endpoint responds successfully at `/api/health`.
|
||||||
|
- Required routes are configured.
|
||||||
|
- Contact and support API paths exist and write through SQLite.
|
||||||
|
|
||||||
|
## v0.0.1 — Project Initialization — 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Project initialized with PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## From `DEVELOPMENT_LOG.md` — the agent-activity log, verbatim
|
||||||
|
|
||||||
|
> Migrated 2026-08-18. Newest first, as written. Not edited. Its entries
|
||||||
|
> overlap the changelog above in date but not in content: that file recorded
|
||||||
|
> what shipped, this one recorded what each agent did and verified.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.6.0 — Navigation Active State Styling (Issue #38) — 2026-05-17
|
||||||
|
|
||||||
|
**Scarlett** — Nav Active-State Styling Implementation
|
||||||
|
|
||||||
|
**Status**: Already implemented in Header.jsx (no changes required)
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
- `useLocation` from `react-router-dom` v7.1.3 is properly imported
|
||||||
|
- `isActive(href)` helper compares `location.pathname === href`
|
||||||
|
- Desktop nav: Active links show `text-white underline underline-offset-4`, inactive use `text-white/70 hover:text-white`
|
||||||
|
- Mobile Sheet nav: Active links use `text-white font-semibold`, inactive use `text-white/70 hover:text-white`
|
||||||
|
- Sub-links (Services/Industries) use brand color `text-navy-light` (#68A3B8) for visual hierarchy
|
||||||
|
|
||||||
|
**Routes supported**: `/`, `/services`, `/industries`, `/about`, `/contact`, `/support` and sub-routes
|
||||||
|
|
||||||
|
**Notes**: Implementation was complete prior to this batch. All navigation links correctly indicate current page with clear visual feedback for both desktop and mobile views. No issues found.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.8 — Error Handling Hardening + Dep Audit Batch — 2026-05-13
|
||||||
|
|
||||||
|
**Neo** — Server-side error handling hardening
|
||||||
|
- Added global error handlers:
|
||||||
|
- `process.on('uncaughtException')`: logs error and exits with code 1
|
||||||
|
- `process.on('unhandledRejection')`: logs error and exits with code 1
|
||||||
|
- Both use `log.error()` for consistent timestamped logging
|
||||||
|
- Added 404 catch-all middleware for API routes:
|
||||||
|
- Returns `{ error: 'Not found' }` for unmatched `/api/*` routes
|
||||||
|
- Non-API routes fall through to SPA (express.static)
|
||||||
|
- Logs warnings for API 404s
|
||||||
|
- Enhanced health check (`/api/health`):
|
||||||
|
- Now verifies DB connection with `db.prepare('SELECT 1').get()`
|
||||||
|
- Returns `{ status: 'ok', db: 'ok' }` on success
|
||||||
|
- Returns 503 with `{ error: 'Service unavailable', db: 'error' }` on DB failure
|
||||||
|
- Added 30-second request timeout middleware:
|
||||||
|
- Returns 504 with `{ error: 'Request timeout' }` for slow requests
|
||||||
|
- Logs timeout events with `log.warn()`
|
||||||
|
- Verified consistent error response format:
|
||||||
|
- 400: `{ error: string, fields: object }` (validation errors)
|
||||||
|
- 404: `{ error: 'Not found' }`
|
||||||
|
- 429: `{ error: 'Too Many Requests', message: string, retryAfter: number }`
|
||||||
|
- 500: `{ error: string }` (no stack traces leaked)
|
||||||
|
- 503: `{ error: 'Service unavailable', db: 'error' }`
|
||||||
|
- 504: `{ error: 'Request timeout' }`
|
||||||
|
- Dependency audit: `npm audit` → 0 vulnerabilities
|
||||||
|
- Dependency review: `npm outdated` → Major updates noted (Express 5, Vite 8, Tailwind 4, etc.) but not upgraded per batch instructions
|
||||||
|
- All client-side code preserved (no changes to React components)
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
- Server starts successfully with all new middleware logged
|
||||||
|
- Build passes: `npm run build`
|
||||||
|
- Health endpoint returns DB status correctly
|
||||||
|
- API 404s return consistent JSON response
|
||||||
|
- Timeout middleware active (30s limit)
|
||||||
|
|
||||||
|
## v0.4.4 — Client-Side Validation + Sonner Feedback Batch — 2026-05-13
|
||||||
|
|
||||||
|
**Scarlett** — Client-side form validation implementation
|
||||||
|
- Added client-side validation to Contact.jsx:
|
||||||
|
- Required field validation: company, name, email, message
|
||||||
|
- Email format validation with regex
|
||||||
|
- Inline error messages below each invalid field (red text, small)
|
||||||
|
- Sonner toast for validation errors on submit attempt
|
||||||
|
- Error clearing as user types (on change)
|
||||||
|
- Red ring/border on Input/Textarea when field has validation error
|
||||||
|
|
||||||
|
- Added client-side validation to Support.jsx:
|
||||||
|
- Required field validation: name, company, email, issue
|
||||||
|
- Email format validation with regex
|
||||||
|
- Issue minimum length validation (10 chars — matches server-side Zod rule)
|
||||||
|
- Inline error messages below each invalid field
|
||||||
|
- Sonner toast for validation errors on submit attempt
|
||||||
|
- Error clearing as user types (on change)
|
||||||
|
- Red ring/border on Input/Textarea when field has validation error
|
||||||
|
|
||||||
|
- Added loading state UI:
|
||||||
|
- Form inputs disabled during submission (`mutation.isPending`)
|
||||||
|
- Form opacity reduced to 70% during submission
|
||||||
|
- Submit button already shows "Submitting..." text
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
- `npm run build` passes without errors
|
||||||
|
- Both forms show validation errors when submitting empty form
|
||||||
|
- Invalid email addresses trigger email field error
|
||||||
|
- Valid submissions trigger success toast
|
||||||
|
- Server errors trigger error toast
|
||||||
|
- Error fields clear when user corrects them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.6 — Security Hardening Batch (Rate Limiting, Helmet, CORS) — 2026-05-13
|
||||||
|
|
||||||
|
**Private_Hudson** — Security hardening and environment variable configuration
|
||||||
|
- Installed security packages: `express-rate-limit`, `helmet`, `cors`
|
||||||
|
- Added rate limiting to `/api` endpoints:
|
||||||
|
- 5 requests per minute (configurable via `RATE_LIMIT_PER_MINUTE`)
|
||||||
|
- IPv6-safe key generator
|
||||||
|
- Standard rate limit headers (`X-RateLimit-Remaining`, `X-RateLimit-Reset`)
|
||||||
|
- 429 response with retry-after information
|
||||||
|
- Added Helmet security headers:
|
||||||
|
- Content Security Policy (CSP) configured with strict default-src, script-src, style-src
|
||||||
|
- HSTS enabled with 1-year max-age and includeSubDomains
|
||||||
|
- Frameguard set to DENY
|
||||||
|
- X-Content-Type-Options nosniff
|
||||||
|
- X-Frame-Options DENY
|
||||||
|
- X-XSS-Protection enabled
|
||||||
|
- Referrer-Policy set to same-origin
|
||||||
|
- Hide Powered-By header
|
||||||
|
- Added CORS configuration:
|
||||||
|
- Configurable via `CORS_ORIGIN` env var (default: `*` for development)
|
||||||
|
- Credentials enabled for authenticated requests
|
||||||
|
- Exposed rate limit headers
|
||||||
|
- Updated server startup logging to show security config status
|
||||||
|
- Request body size limit (`1mb`) already in place
|
||||||
|
|
||||||
|
**Docker Configuration Updates**
|
||||||
|
- Added new environment variables to `docker-compose.yml`:
|
||||||
|
- `RATE_LIMIT_PER_MINUTE=5`
|
||||||
|
- `CORS_ORIGIN=https://queuenorth.com` (prod-safe default)
|
||||||
|
- `LOG_LEVEL=info`
|
||||||
|
- `ZOHO_ENABLED=false` (explicit off for non-CRM deployments)
|
||||||
|
- `ZOHO_API_DOMAIN`, `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, `ZOHO_REFRESH_TOKEN`, `ZOHO_REDIRECT_URI`
|
||||||
|
- Added environment defaults to `Dockerfile`:
|
||||||
|
- All new env vars with reasonable defaults
|
||||||
|
- `CORS_ORIGIN=\*` (development default)
|
||||||
|
- `LOG_LEVEL=info`
|
||||||
|
- `ZOHO_ENABLED=false`
|
||||||
|
- `docker-entrypoint.sh` verified — no changes required (env vars passed through automatically)
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
- `npm run build` passes without errors
|
||||||
|
- Server starts with security headers logged:
|
||||||
|
- `[Security] Helmet enabled with CSP configured`
|
||||||
|
- `[CORS] Enabled with origin: https://queuenorth.com`
|
||||||
|
- `Rate limiting: 5 requests per 60 seconds`
|
||||||
|
- Rate limiting headers present on API responses
|
||||||
|
- All security headers confirmed via `curl -I http://localhost:3001/api/health`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.5 — Server-Side Validation + Input Sanitization Batch — 2026-05-13
|
||||||
|
|
||||||
|
**Neo** — Server-side validation hardening and input sanitization
|
||||||
|
- Added request body size limit: `express.json({ limit: '1mb' })` — returns 413 for oversized payloads
|
||||||
|
- Added input sanitization after Zod validation (prevents XSS, trims whitespace, enforces max lengths at DB level):
|
||||||
|
- `sanitizeString()` helper: trims whitespace, removes HTML/script tags, truncates to max length
|
||||||
|
- `sanitizePayload()` applies sanitization to validated data before DB insert
|
||||||
|
- Updated Zod schemas with comprehensive validation:
|
||||||
|
- All string fields: `.trim()` applied
|
||||||
|
- All string fields: `.max()` limits applied per RFC standards:
|
||||||
|
- `company`: 200 chars
|
||||||
|
- `name`: 100 chars
|
||||||
|
- `email`: 254 chars (RFC 5321)
|
||||||
|
- Updated error responses to match v0.4.6 error format standards:
|
||||||
|
- 400: `{ error: 'Validation failed', fields: object }`
|
||||||
|
- 500: `{ error: 'Failed to submit lead/support' }`
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
- `npm run build` passes without errors
|
||||||
|
- Zod validation correctly rejects invalid inputs
|
||||||
|
- Input sanitization trims whitespace and removes HTML tags
|
||||||
|
- Max length limits enforced at DB level
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.0 — Phase 4 Baseline — 2026-05-13
|
||||||
|
|
||||||
|
**Ripley** — Phase 4 initialization
|
||||||
|
- Updated `package.json` version to `0.4.0`
|
||||||
|
- Updated `PROJECT.md` to indicate Phase 4 active
|
||||||
|
- Updated `HISTORY.md` with v0.4.0 release notes
|
||||||
|
- Updated `FUTURE.md` with Phase 4 roadmap
|
||||||
|
- Updated `OVERHAUL_PLAN.md` with Phase 4 task breakdown
|
||||||
|
|
||||||
|
**Phase 4 Scope**
|
||||||
|
- v0.4.1-0.4.7: Incremental hardening batches (security, validation, error handling)
|
||||||
|
- v0.4.8: Final hardening batch (global error handlers, health check, dependency audit)
|
||||||
|
- Target: Production-ready, audited server with comprehensive error handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.0.1 — 2026-05-11
|
||||||
|
|
||||||
|
**Ripley** — Project initialized
|
||||||
|
- Created project directory at `/home/kaspa/.openclaw/Projects/Queue-North-Website/`.
|
||||||
|
- Set up initial PROJECT.md, STRUCTURE.md, FUTURE.md, HISTORY.md, DEVELOPMENT_LOG.md.
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
# History — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the narrative of how this project got to where it is
|
||||||
|
Review trigger: A decision reversed, a direction abandoned, or a rewrite
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this is for
|
||||||
|
|
||||||
|
Queue North Technologies had a working website: one large `index.html`, a
|
||||||
|
hand-written stylesheet, and `main.js` doing hash-based routing. The content was
|
||||||
|
good and the presentation was early-2000s. This repository is the rebuild — a
|
||||||
|
Vite + React SPA with an Express API behind it, live at `qn.isnull.dev`.
|
||||||
|
|
||||||
|
That much is in the code. What is not in the code is which walls were already
|
||||||
|
walked into, and that is what this file is for.
|
||||||
|
|
||||||
|
**This is a record of *then*, never a description of *now*.** That distinction is
|
||||||
|
what makes it safe to leave alone as the project changes — a history document
|
||||||
|
that gets edited to stay current is not a history, it is a second and competing
|
||||||
|
description of the present.
|
||||||
|
|
||||||
|
If you find yourself updating a sentence here because the code changed, the
|
||||||
|
sentence belongs somewhere else.
|
||||||
|
|
||||||
|
## Decisions and their reasons
|
||||||
|
|
||||||
|
| When | What was decided | Why, at the time |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-05-11 | Vite + React SPA, explicitly **not** Next.js | The rebuild needed a fast client and a small Express API, not SSR or server components. `docs/planning/REQUIREMENTS.md` states it as a requirement rather than a preference, because it kept getting re-proposed |
|
||||||
|
| 2026-05-11 | better-sqlite3 rather than a hosted database | Two forms and two tables. A managed database would have been more infrastructure than the product has data |
|
||||||
|
| 2026-05-12 | Light-first B2B layout with strategic dark navy, evolving the palette rather than replacing it | `docs/design/REDESIGN_REVIEW.md`: the original read as a developer portfolio, and B2B buyers decide on trust in seconds. Rip-and-replace to a light theme would have thrown away the navy brand equity along with the problem |
|
||||||
|
| 2026-05-13 | Zoho forwarding is fire-and-forget, behind SQLite | SQLite always writes first. A CRM outage must never cost a lead — the local row is the record and Zoho is a best-effort overlay |
|
||||||
|
| 2026-05-17 | The standalone `/8x8` page was merged into the UCaaS and CCaaS service entries | Issue #32. A partner page nobody navigated to, when the partnership is better evidenced inside the services it actually powers |
|
||||||
|
| 2026-06-14 | Zoho switched from the REST/OAuth path to **WebToLead** as the default mode | `ZOHO_FORWARDING_MODE=webtolead`. The OAuth path works and is kept as a standby; WebToLead needed no token lifecycle for what is a single lead form |
|
||||||
|
| 2026-08-01 | Every route is prerendered at build time, and the privacy policy has a server-rendered fallback | Meta's crawler does not run JavaScript. See `docs/architecture/README.md` |
|
||||||
|
| 2026-08-18 | Adopted the project template; **retired phase-versioning** | See below |
|
||||||
|
| 2026-08-18 | The Forgejo repository was made **private** | Adoption committed the previously-gitignored internal documents into `docs/`. Those hold the agent narrative and internal findings about the client's own marketing claims (#108, #110), which is not material for a public tree. `null/fruit-fall` was already private and reports to the Command Center normally, so nothing was lost by it |
|
||||||
|
|
||||||
|
## What was tried and dropped
|
||||||
|
|
||||||
|
### Phase-versioning — the minor number *was* the phase number
|
||||||
|
|
||||||
|
`PROJECT.md` and `STRUCTURE.md` both carried the rule: Phase 1 releases are
|
||||||
|
`0.1.x`, Phase 5 releases are `0.5.x`, and *"do not use unrelated semantic
|
||||||
|
version bumps during the overhaul"*. Bishop was made responsible for enforcing it
|
||||||
|
across `package.json`, `PROJECT.md` and `HISTORY.md`.
|
||||||
|
|
||||||
|
**It drifted twice, and the second time nobody noticed for two and a half
|
||||||
|
months.** At adoption `package.json` said `0.8.3` while the last four commit
|
||||||
|
subjects said *batch 0.9.0* through *batch 0.9.3*, and both `PROJECT.md` and
|
||||||
|
`STRUCTURE.md` still described the project as being in Phase 5.
|
||||||
|
|
||||||
|
The rule was not wrong; it was unenforceable. It asked four files to agree, with
|
||||||
|
a human check as the only mechanism, and a rule that needs a person to remember
|
||||||
|
it is a rule that decays. Milestones in the tracker now hold what the phase
|
||||||
|
number was carrying — a batch is a milestone, and closing it is what marks the
|
||||||
|
batch shipped — so the version number no longer has to encode anything.
|
||||||
|
|
||||||
|
`package.json` was corrected to `0.9.3` on adoption to match the commits.
|
||||||
|
|
||||||
|
**What replaced it:** nothing, deliberately. Version numbers are now just version
|
||||||
|
numbers. `docs/WORK_CYCLE.md` holds what happens at the end of a piece of work.
|
||||||
|
|
||||||
|
### The markdown backlog
|
||||||
|
|
||||||
|
Work was planned in `FUTURE.md`, with a parallel changelog in `HISTORY.md`, an
|
||||||
|
agent log in `DEVELOPMENT_LOG.md`, phase checklists in `PROJECT.md` and
|
||||||
|
`README.md`, and 205 issues in Forgejo. Six records of what was happening, and
|
||||||
|
the tracker — the one with atomic state and real timestamps — was the one nothing
|
||||||
|
pointed at.
|
||||||
|
|
||||||
|
The specific damage: 205 issues sat in **zero** milestones, so privacyllc.dev
|
||||||
|
reported the project as *not adopted* rather than as 87% complete, and the
|
||||||
|
severity labels were spelled `P0 Critical` / `P1 High` / `P2 Medium`, which no
|
||||||
|
query matches.
|
||||||
|
|
||||||
|
The markdown records were retired on 2026-08-18. What was reasoning is now in
|
||||||
|
`docs/history/BATCH_LEDGER.md`; what was work is in the tracker.
|
||||||
|
|
||||||
|
### The agent pipeline — kept, not dropped
|
||||||
|
|
||||||
|
Scarlett designs, Neo implements, Private Hudson reviews security, Bishop
|
||||||
|
verifies, Ripley owns git. Agents do not touch git; Ripley commits and pushes.
|
||||||
|
|
||||||
|
This survived adoption because the project earned it — `.learnings/` holds four
|
||||||
|
agents' accumulated notes and error logs against it — and because nothing in the
|
||||||
|
template contradicts it. `docs/WORK_CYCLE.md` says what must be true when a piece
|
||||||
|
of work ends; the pipeline says who does which part. They are different questions.
|
||||||
|
|
||||||
|
The one piece of the pipeline that did not survive is Bishop's version-checking
|
||||||
|
duty, which went with phase-versioning above.
|
||||||
|
|
||||||
|
## What this file is not
|
||||||
|
|
||||||
|
- **Not the plan.** That is `docs/planning/PROJECT_PLAN.md`.
|
||||||
|
- **Not the work list.** Open work lives in the tracker as milestones and issues.
|
||||||
|
- **Not a changelog.** Dated entries go in `docs/history/DEVELOPMENT_LOG.md`.
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Queue North Website — Project Plan
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: scope, audience, and what this project deliberately is not
|
||||||
|
Review trigger: Any change of scope, audience, or platform
|
||||||
|
```
|
||||||
|
|
||||||
|
> The vision. The **milestones in this repository's issue tracker** hold the
|
||||||
|
> sequence of work; this holds what the work is *for*. When the two disagree,
|
||||||
|
> this one is the argument and those are the plan — resolve it rather than
|
||||||
|
> letting both stand.
|
||||||
|
>
|
||||||
|
> Deliberately no batch list here. A schedule in a document goes stale the day
|
||||||
|
> after it is written and nothing notices; a schedule in the tracker is updated
|
||||||
|
> by the act of doing the work.
|
||||||
|
>
|
||||||
|
> The engineering standards that go with this — stack, frontend, backend,
|
||||||
|
> database, code quality and security rules — are in
|
||||||
|
> [`REQUIREMENTS.md`](REQUIREMENTS.md), which carries its own change policy and
|
||||||
|
> is not summarised here.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
The business website for Queue North Technologies — an 8x8 Certified Partner and
|
||||||
|
Cisco partner delivering UCaaS, contact centre, networking, cabling, wireless and
|
||||||
|
managed lifecycle support to SMB and enterprise organisations. It replaces a
|
||||||
|
static early-2000s HTML/CSS/JS site with a Vite + React SPA and a small Express
|
||||||
|
API that captures consultation and support requests.
|
||||||
|
|
||||||
|
Its single job is conversion: a business decision-maker arrives, understands
|
||||||
|
within seconds that Queue North is a credible communications and IT partner, and
|
||||||
|
submits a consultation or support request that reaches the CRM.
|
||||||
|
|
||||||
|
## Who it is for
|
||||||
|
|
||||||
|
**Primary:** SMB and mid-market decision-makers — owners, operations managers, IT
|
||||||
|
managers — evaluating a communications or managed-IT partner. They are usually on
|
||||||
|
a phone, they are comparing two or three vendors, and they are buying risk
|
||||||
|
reduction rather than technology.
|
||||||
|
|
||||||
|
**Secondary:** existing Queue North customers looking for the support path.
|
||||||
|
|
||||||
|
Not developers, not procurement at a Fortune 500, and not people who already know
|
||||||
|
what UCaaS stands for.
|
||||||
|
|
||||||
|
## What it is deliberately not
|
||||||
|
|
||||||
|
The most useful section in this file. Every entry is a decision that stops being
|
||||||
|
re-litigated.
|
||||||
|
|
||||||
|
- **Not Next.js.** Vite + React SPA, no SSR at request time, no server
|
||||||
|
components. Stated as a requirement rather than a preference in
|
||||||
|
`REQUIREMENTS.md` because it kept being re-proposed. The build does prerender
|
||||||
|
every route to static HTML, which is a build step and not a framework change.
|
||||||
|
- **Not a CMS.** Content lives in `src/data/*.js` and in the page components. A
|
||||||
|
blog is an open question (#70), not a commitment, and if it happens it starts
|
||||||
|
as hard-coded articles.
|
||||||
|
- **No user accounts.** There is no login, no session, no authorisation, and none
|
||||||
|
is planned. This is why `docs/security/SECURITY_CHECKLIST.md` has no
|
||||||
|
authorisation section and `docs/qa/ClaudeQAPlan.md` has no pass for it.
|
||||||
|
- **No money moves through it.** No checkout, no subscriptions, no refunds. Pass
|
||||||
|
I of the QA plan is deleted rather than carried as permanently skipped.
|
||||||
|
- **Not a developer portfolio.** The failure mode this rebuild exists to fix.
|
||||||
|
`docs/design/REDESIGN_REVIEW.md` is the standard: hacker aesthetic, terminal
|
||||||
|
cosplay and infrastructure in-jokes destroy enterprise trust faster than
|
||||||
|
anything else on the page.
|
||||||
|
- **Not a support ticketing system.** `/api/support` records a request and
|
||||||
|
forwards it. It does not track state, assign, or escalate — Zoho does that.
|
||||||
|
|
||||||
|
## Stack and platform
|
||||||
|
|
||||||
|
| Concern | Choice | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Build | Vite 6 | Fast, and the project needed a client bundle rather than a framework |
|
||||||
|
| Frontend | React 19, React Router 7 | SPA with client routing; every route also prerendered at build time |
|
||||||
|
| Styling | Tailwind 3 with a custom Queue North theme | Tokens in `docs/design/OVERHAUL_PLAN.md` |
|
||||||
|
| Components | shadcn/ui-style local primitives | Copied in, not depended on. Seven primitives is less surface than a component library |
|
||||||
|
| Backend | Express 4 | Two POST endpoints and a health check. A framework would be larger than the thing |
|
||||||
|
| Storage | SQLite via better-sqlite3 | Two tables, low write volume, one process. Synchronous by design, which suits a request-scoped insert |
|
||||||
|
| CRM | Zoho, WebToLead by default | Fire-and-forget behind SQLite, so a CRM outage cannot cost a lead |
|
||||||
|
| Anti-abuse | Google reCAPTCHA v3 plus a honeypot field | The forms are the only writable surface |
|
||||||
|
| Hosting | Docker on **nebula**, container `qn-website-dev`, image `dream.scheller.ltd/null/queue-north-website:dev`, fronted by Cloudflare at `qn.isnull.dev` | See `docs/OPERATIONS.md` |
|
||||||
|
|
||||||
|
## Success looks like
|
||||||
|
|
||||||
|
Observable, in this order:
|
||||||
|
|
||||||
|
1. A first-time visitor on a phone can tell what Queue North does and reach the
|
||||||
|
consultation form without pinch-zooming or hunting.
|
||||||
|
2. A submitted contact form produces a row in `leads` **and** a lead in Zoho, and
|
||||||
|
the visitor sees a confirmation either way.
|
||||||
|
3. The site passes WCAG 2.1 AA on the pages a buyer actually walks — currently it
|
||||||
|
does not, which is what `Batch 10` in the tracker is for.
|
||||||
|
4. Nothing on the page is a claim the business cannot substantiate. Two open
|
||||||
|
issues say it currently is (#108, #110).
|
||||||
|
|
||||||
|
## Known risks
|
||||||
|
|
||||||
|
- **Unverifiable marketing claims are live.** A fabricated-looking certification
|
||||||
|
number and an unverified "25+ years" both ship today (#108, #110). This is the
|
||||||
|
only risk here that is a credibility problem rather than an engineering one,
|
||||||
|
and neither can be fixed without the owner.
|
||||||
|
- **The lead database has no backup.** `/app/db/queuenorth.db` in a Docker named
|
||||||
|
volume on nebula is the only copy of every inbound lead and support request.
|
||||||
|
Tracked in `Batch 15`. Until a restore has been proven, this project has a
|
||||||
|
backup script and not backups.
|
||||||
|
- **Owner-blocked content is the long pole.** Testimonials (#69), the blog (#70)
|
||||||
|
and the About corrections (#68, #162) are all waiting on somebody who is not on
|
||||||
|
this team. They should not be counted as engineering progress.
|
||||||
|
- **No automated tests at all.** No test runner, no typecheck. `npm run build` is
|
||||||
|
the only gate, and it catches syntax and imports rather than behaviour. Recorded
|
||||||
|
as a standing gap in `docs/qa/ClaudeQACoverage.md`.
|
||||||
|
- **The roll-forward procedure is not written down.** Publishing is
|
||||||
|
`scripts/docker-push.sh`; how nebula moves to a new image is currently in
|
||||||
|
somebody's head. Noted in `docs/OPERATIONS.md`.
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
# Project Requirements — Queue North Website
|
# Project Requirements — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: package.json — the engineering standards every agent works to:
|
||||||
|
stack, frontend, backend, database, code quality and security
|
||||||
|
Review trigger: Any change of stack, runtime or storage; any new class of input
|
||||||
|
reaching the API. Note the change policy at the bottom of this
|
||||||
|
file: these may not be modified without _null's explicit approval
|
||||||
|
```
|
||||||
|
|
||||||
These requirements apply to all agents working on Queue North Website.
|
These requirements apply to all agents working on Queue North Website.
|
||||||
|
|
||||||
## Project Philosophy
|
## Project Philosophy
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Claude QA Coverage — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what each QA pass actually reached
|
||||||
|
Review trigger: Any QA round run
|
||||||
|
```
|
||||||
|
|
||||||
|
> Pass by pass, what was reached and what was not. The point of this file is the
|
||||||
|
> **Blocked** and **Not run** rows: a pass left out of a report reads exactly like
|
||||||
|
> a pass that succeeded, and that is how untested code ships believing it was
|
||||||
|
> tested.
|
||||||
|
|
||||||
|
## Round 0 — 2026-08-18 at `ac6f5d1`
|
||||||
|
|
||||||
|
Not a round. The table is filled in on the day the template was adopted so that
|
||||||
|
the honest state is written down rather than absent, because an absent table
|
||||||
|
reads as one nobody got to and this one is a decision.
|
||||||
|
|
||||||
|
| Pass | Result | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A — First run | **Not run** | Nobody has walked a cold first visit on a real device under a plan |
|
||||||
|
| B — Core flow | **Not run** | The two form paths — contact and support — end to end into Zoho. This is the product's entire purpose and it has never been verified from outside |
|
||||||
|
| C — Failure paths | **Not run** | Zoho unreachable, reCAPTCHA failing, a duplicate email hitting the 409, an oversized body hitting the 413 |
|
||||||
|
| D — Persistence | **Not run** | Container restart with the named volume intact; SQLite surviving it |
|
||||||
|
| E — End of loop | **Not run** | A submitted lead actually appearing in the Zoho CRM UI. Nothing on this side can observe that |
|
||||||
|
| F — Accessibility | **Not run** | Six known defects are already filed from review rather than testing — #189 to #194 |
|
||||||
|
| G — Performance | **Not run** | No numbers exist. Prerendering every route landed at `0.9.3` and its effect has not been measured |
|
||||||
|
| H — Abuse and what a stranger can reach | **Not run** | See below — this pass is rewritten for this project |
|
||||||
|
|
||||||
|
## Pass H, as it applies here
|
||||||
|
|
||||||
|
The template's pass H is written for a product with accounts. This one has none:
|
||||||
|
no login, no session, no roles, no per-user data. Deleting the pass would have
|
||||||
|
been wrong anyway, because the *other* half of it — what a stranger can reach and
|
||||||
|
exhaust — is the most exposed surface this project has. Rewritten, it is:
|
||||||
|
|
||||||
|
- Both POST endpoints called directly, off the form, with hand-made payloads
|
||||||
|
- The rate limiter actually exercised past `RATE_LIMIT_PER_MINUTE` and the 429
|
||||||
|
body checked
|
||||||
|
- The honeypot field and the reCAPTCHA score path bypassed deliberately
|
||||||
|
- `dist/` inspected for anything secret — `VITE_RECAPTCHA_SITE_KEY` is inlined
|
||||||
|
there by design and must be the *site* key, never the secret one
|
||||||
|
- The 1 MB body limit and the 30 s request timeout tripped on purpose
|
||||||
|
|
||||||
|
`scripts/preflight.sh` covers the headers-and-TLS fraction of this and nothing
|
||||||
|
else. Its `--auth` checks are deliberately not used: there is no login to
|
||||||
|
rate-limit and no account to enumerate.
|
||||||
|
|
||||||
|
**Pass I — money flowing backwards — was deleted from the plan, not skipped.** No
|
||||||
|
money moves through this site. Carrying it as a permanently skipped row is the
|
||||||
|
lie this file exists to prevent.
|
||||||
|
|
||||||
|
## Standing gaps
|
||||||
|
|
||||||
|
Things no round has ever covered, carried forward until they are. This list
|
||||||
|
existing is not a failure; it not existing while the gaps do is.
|
||||||
|
|
||||||
|
- **There is no automated test suite at all**, and no typecheck. No test runner
|
||||||
|
is installed, `package.json` has no `test` script, and the project is plain
|
||||||
|
`.jsx` with no TypeScript. `npm run build` is the only gate, and a build proves
|
||||||
|
the imports resolve and nothing else. Closing this gap is not currently filed
|
||||||
|
as an issue — deliberately, because nobody has decided to do it and filing it
|
||||||
|
would put invented work into the denominator. It is recorded here instead,
|
||||||
|
which is where a gap nobody has chosen to close belongs.
|
||||||
|
- **Nothing has ever verified a lead reaching Zoho end to end.** The forward is
|
||||||
|
fire-and-forget by design, so a total failure of it is silent by construction:
|
||||||
|
the visitor sees success, SQLite has the row, and the CRM has nothing. The only
|
||||||
|
way to catch that is pass E, and pass E needs somebody with a Zoho login.
|
||||||
|
- **The lead database has never been backed up or restored.** `backup.sh` and
|
||||||
|
`restore-check.sh` exist as of 2026-08-18 and neither has been run against
|
||||||
|
production. Filed as an issue in `Batch 15`.
|
||||||
|
- **No accessibility testing has ever been done with a screen reader or by
|
||||||
|
keyboard alone.** The six filed defects came from reading the markup.
|
||||||
|
- **No performance numbers exist for any build, ever.**
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Claude QA Plan — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: what a QA round consists of
|
||||||
|
Review trigger: Any new user-facing surface, or a defect class that got through
|
||||||
|
```
|
||||||
|
|
||||||
|
> The playbook. What a round *is*, so two rounds are comparable and a gap is
|
||||||
|
> visible rather than assumed covered.
|
||||||
|
|
||||||
|
## Before a round
|
||||||
|
|
||||||
|
- Build from a clean checkout at a known SHA, and record that SHA.
|
||||||
|
- Run from a detached worktree if other work is in flight, so uncommitted
|
||||||
|
changes cannot contaminate what is under test.
|
||||||
|
- Note the environment: device, OS version, browser, screen size — whatever the
|
||||||
|
product's behaviour actually depends on.
|
||||||
|
|
||||||
|
## The passes
|
||||||
|
|
||||||
|
Each pass gets a letter, so `ClaudeQACoverage.md` can report per pass and a
|
||||||
|
skipped one is visible.
|
||||||
|
|
||||||
|
| Pass | What it covers |
|
||||||
|
| --- | --- |
|
||||||
|
| A | First run: install or load, cold start, permissions, empty states |
|
||||||
|
| B | The core flow, end to end, as a real user would do it |
|
||||||
|
| C | The core flow with things going wrong: no network, denied permission, invalid input |
|
||||||
|
| D | Persistence: quit and return, background and resume, restart |
|
||||||
|
| E | The end of the loop — the state that is hardest to reach on purpose |
|
||||||
|
| F | Accessibility: keyboard only, screen reader labels, contrast, text scaling |
|
||||||
|
| G | Performance under the load this product will actually see |
|
||||||
|
| H | Abuse, and what a stranger can reach — **rewritten for this project, see below** |
|
||||||
|
|
||||||
|
<Add, remove and rename to fit. A pass that never applies is noise; a pass that
|
||||||
|
is always skipped is a lie.>
|
||||||
|
|
||||||
|
### Why H is separate from B, here
|
||||||
|
|
||||||
|
The template's pass H is about authorization: authenticated is not owning, and
|
||||||
|
owning is not permitted. **None of that applies to this project** — there is no
|
||||||
|
login, no session, no role and no per-user row. Every one of those checks would
|
||||||
|
be a box ticked against nothing.
|
||||||
|
|
||||||
|
What does apply is the other half, and it is the most exposed thing here: two
|
||||||
|
unauthenticated `POST` endpoints that write to a database, on a public origin,
|
||||||
|
behind nothing but a rate limiter and a reCAPTCHA score. Pass B submits those
|
||||||
|
forms as a real visitor would. Pass H is somebody who is not being polite:
|
||||||
|
|
||||||
|
- both endpoints called directly, off the form, with hand-made payloads
|
||||||
|
- the rate limiter driven past `RATE_LIMIT_PER_MINUTE`, and the 429 body read
|
||||||
|
- the honeypot field and the reCAPTCHA path bypassed deliberately
|
||||||
|
- the 1 MB body limit and the 30 s request timeout tripped on purpose
|
||||||
|
- a duplicate email driven into the 409, to confirm the Zoho forward still fires
|
||||||
|
- **`dist/` inspected** — `VITE_RECAPTCHA_SITE_KEY` is inlined into the bundle at
|
||||||
|
build time, which is correct for a *site* key and catastrophic for a secret
|
||||||
|
one. `scripts/secrets.sh --built dist/` is the mechanical half of this
|
||||||
|
|
||||||
|
`scripts/preflight.sh` covers headers and TLS and nothing else. Its `--auth`
|
||||||
|
checks stay off: there is no login to rate-limit and no account to enumerate.
|
||||||
|
|
||||||
|
**Pass I — money flowing backwards — is deleted from this plan rather than
|
||||||
|
carried as skipped.** No money moves through this site: no checkout, no
|
||||||
|
subscription, no refund path. A pass that never applies is noise, and one that is
|
||||||
|
always skipped is a lie.
|
||||||
|
|
||||||
|
## What counts as a finding
|
||||||
|
|
||||||
|
A finding needs: what was done, what happened, what should have happened, and
|
||||||
|
the build SHA. Without the SHA it cannot be re-tested, and a finding that cannot
|
||||||
|
be re-tested cannot be closed.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
Findings are filed as issues, labelled:
|
||||||
|
|
||||||
|
- **P0** — ships broken, or loses data
|
||||||
|
- **P1** — materially wrong, but shippable
|
||||||
|
- **P2** — cosmetic or low impact
|
||||||
|
- **release-blocker** — a release built today would be wrong rather than merely
|
||||||
|
incomplete
|
||||||
|
|
||||||
|
Exactly these label names: the Command Center queries them by name, and a
|
||||||
|
repository that spells them differently has its defects reported as *not
|
||||||
|
adopted* rather than counted wrongly. This one did, until 2026-08-18 — the labels
|
||||||
|
read `P0 Critical`, `P1 High` and `P2 Medium`, and 205 issues reported as *not
|
||||||
|
adopted* rather than as 87% complete.
|
||||||
|
|
||||||
|
**Do not use `P3`.** It exists on 21 closed issues from before the convention and
|
||||||
|
is frozen. It is not one of the four names anything queries, so a defect filed
|
||||||
|
`P3` today is counted by nothing.
|
||||||
|
|
||||||
|
Severity is what it costs, not how annoying it is to fix.
|
||||||
|
|
||||||
|
## After a round
|
||||||
|
|
||||||
|
File each finding as a labelled issue. Update `ClaudeReport.md`'s run-state
|
||||||
|
block and its overall sentence, and `ClaudeQACoverage.md` with what each pass
|
||||||
|
actually reached. A pass that could
|
||||||
|
not be run is recorded as blocked, with what blocks it — never quietly left out,
|
||||||
|
which reads identically to "passed".
|
||||||
|
|
||||||
|
Then **push, and reconcile**. The verdict on the project screen at
|
||||||
|
privacyllc.dev is read out of `ClaudeReport.md` in the pushed repository, so a
|
||||||
|
round whose report is committed but not pushed — or pushed but not reconciled —
|
||||||
|
leaves a stakeholder reading the previous round's judgment with no indication
|
||||||
|
that a newer one exists. The rest of the cycle is in `docs/WORK_CYCLE.md`.
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Claude QA Report — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the QA verdict — build SHAs, round summaries, the overall judgment
|
||||||
|
Review trigger: Any QA round run
|
||||||
|
```
|
||||||
|
|
||||||
|
> The QA verdict. Companion to `ClaudeQACoverage.md` (what each pass reached)
|
||||||
|
> and `ClaudeQAPlan.md` (the playbook).
|
||||||
|
>
|
||||||
|
> **Defects are issues, not entries here.** A defect found in a round is filed
|
||||||
|
> in the tracker with a severity label, where it can be assigned, closed by a
|
||||||
|
> commit, and counted. This file keeps the part a tracker is bad at: a human's
|
||||||
|
> judgment about whether the thing is fit to ship.
|
||||||
|
|
||||||
|
## Current run-state
|
||||||
|
|
||||||
|
- **Last QA round:** Round 0 — 2026-08-18, no round has been run
|
||||||
|
- **Last verified build SHA:** `ac6f5d1` on dev, deployed to nebula as `queue-north-website:dev`
|
||||||
|
- **Last tested device / environment:** none under this plan. The container's own
|
||||||
|
healthcheck and a manual `curl` against `qn.isnull.dev` are the only automated
|
||||||
|
observations of the running site
|
||||||
|
- **Overall status:** No QA round has been run against this site under any
|
||||||
|
written plan, and there is no automated test suite to fall back on — the only
|
||||||
|
gate is that the build compiles. The site is up, healthy and taking real form
|
||||||
|
submissions, and 178 issues have been found and fixed by review rather than by
|
||||||
|
testing, which is a real record but a different one. What is untested is
|
||||||
|
everything a round would actually look at: the two form paths end to end into
|
||||||
|
Zoho, the failure behaviour when the CRM is unreachable, accessibility, and
|
||||||
|
what happens to the lead database if the volume is lost.
|
||||||
|
|
||||||
|
## Open defects
|
||||||
|
|
||||||
|
**Do not list them here, and do not read a defect count out of this file.** The
|
||||||
|
Command Center's docs report parses this document for open `P0` / `P1` / `P2`
|
||||||
|
counts, and under this convention they are always zero — the defects are in the
|
||||||
|
tracker, which is the whole point. A repository with seventeen open P1s reports
|
||||||
|
`openP1: 0` in that block and the real figure in its tracker counts. Anything
|
||||||
|
summarising defects reads the tracker's label counts; the zeros here mean *this
|
||||||
|
file does not hold them*, never *there are none*.
|
||||||
|
|
||||||
|
Filed as issues in this repository's tracker, labelled by what they cost:
|
||||||
|
|
||||||
|
- **P0** — ships broken, or loses data
|
||||||
|
- **P1** — materially wrong, but shippable
|
||||||
|
- **P2** — cosmetic or low impact
|
||||||
|
- **release-blocker** — a release built today would be *wrong*, not merely
|
||||||
|
incomplete
|
||||||
|
|
||||||
|
Severity is what it costs, not how annoying it is to fix. Every defect needs
|
||||||
|
the build SHA it was found at — a finding that cannot be re-tested cannot be
|
||||||
|
closed — so put it in the issue body.
|
||||||
|
|
||||||
|
**A note on `P3`.** This repository carries a fifth label, `P3`, on 21 closed
|
||||||
|
issues from before it adopted the convention. It is frozen: nothing new gets it.
|
||||||
|
It is not one of the four names the Command Center queries, so a defect filed
|
||||||
|
`P3` today would be counted by nothing.
|
||||||
|
|
||||||
|
## Round notes
|
||||||
|
|
||||||
|
### Round 0 — 2026-08-18 — baseline, no testing performed
|
||||||
|
|
||||||
|
Recorded on the day the project adopted the template, so that the run-state above
|
||||||
|
is a measurement rather than a blank. Nothing was tested. What is known about the
|
||||||
|
running system on this date, and how:
|
||||||
|
|
||||||
|
| Observation | How it is known |
|
||||||
|
| --- | --- |
|
||||||
|
| `https://qn.isnull.dev/` returns 200 | `curl`, once |
|
||||||
|
| `/api/health` returns `{"status":"ok","db":"ok"}` | `curl`, once |
|
||||||
|
| Container `qn-website-dev` on nebula is healthy, restart count 0 | `docker inspect` |
|
||||||
|
| The lead database holds 3 tables and took writes on 2026-08-18 | `sqlite_master` count and the file mtime |
|
||||||
|
| Security headers, CSP and HSTS are present on the live origin | response headers |
|
||||||
|
|
||||||
|
None of that is a QA pass. It is evidence that the thing is running.
|
||||||
|
|
||||||
|
The first real round should start with pass B — the two form paths end to end,
|
||||||
|
including whether a lead actually lands in Zoho — because that is the product's
|
||||||
|
entire purpose and nothing has ever verified it end to end from outside.
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# Security — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: server/index.js, .env.example, Dockerfile, docker-compose.yml —
|
||||||
|
authentication, secret handling, data at rest and in transit
|
||||||
|
Review trigger: Any new secret, any new external service, any change to auth or storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this protects, and from whom
|
||||||
|
|
||||||
|
This is a public marketing site with two write endpoints. It has no accounts, no
|
||||||
|
sessions and no privileged area, so the entire class of authorisation defect does
|
||||||
|
not exist here. What it does have is **other people's contact details**, arriving
|
||||||
|
unauthenticated from the open internet and landing in a single SQLite file.
|
||||||
|
|
||||||
|
The realistic adversaries, in the order they will actually turn up: a bot
|
||||||
|
spamming the contact form; somebody scraping the lead database if it is ever
|
||||||
|
exposed; a credential in the repository or the bundle; and the loss of the volume
|
||||||
|
the database lives in.
|
||||||
|
|
||||||
|
| Asset | Where it lives | What would it cost to lose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Inbound leads and support requests | `/app/db/queuenorth.db`, a Docker named volume on nebula | **The whole point of the site.** These are prospective customers who filled in a form. There is currently no backup, so loss is total and silent |
|
||||||
|
| Zoho WebToLead form tokens | `zoho.md` on the operator's machine, and the deploy environment | Low. They are public-by-design identifiers, rendered in the browser by any Zoho web-to-lead form. Losing them means re-reading them out of Zoho |
|
||||||
|
| reCAPTCHA **secret** key | `zoho.md` and the deploy environment | Real. With it, an attacker can validate their own tokens and the form's only anti-abuse control is gone |
|
||||||
|
| Zoho OAuth refresh token (standby path) | deploy environment only | Real — it grants write access to the CRM. Currently unset, because production runs WebToLead |
|
||||||
|
| Forgejo registry token | `~/.openclaw/docker-registry.env` | High, and out of scope of this repository: it is not registry-scoped and works across the whole API with push |
|
||||||
|
|
||||||
|
**No credentials are held on behalf of users.** The template carries a
|
||||||
|
*(precautionary)* paragraph about bring-your-own-key and linked accounts; it was
|
||||||
|
deleted rather than kept, because there are no users to hold anything for and
|
||||||
|
none are planned — `docs/planning/PROJECT_PLAN.md` records accounts as
|
||||||
|
deliberately out of scope. Dropping a precautionary rule is worth saying out loud,
|
||||||
|
so: that is the one that went, and that is why.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- **Nothing secret is committed.** Not in source, not in config, not in a test
|
||||||
|
fixture, not in a screenshot.
|
||||||
|
- Secrets arrive from the environment. `server/index.js` reads every one of them
|
||||||
|
at startup through `process.env` and nothing else passes them around.
|
||||||
|
- **`zoho.md` is the operator's credential file and is gitignored.** It holds the
|
||||||
|
live reCAPTCHA secret key and the Zoho tokens. That `.gitignore` line stays
|
||||||
|
even though the repository is now private — private is not the same as safe,
|
||||||
|
and the file has no reason to be in a tree that gets cloned.
|
||||||
|
- `.env` is gitignored; `.env.example` is committed and carries names with empty
|
||||||
|
values.
|
||||||
|
|
||||||
|
**Check before every release:** no private keys, keystores, `.env` files or real
|
||||||
|
credentials in the tracked tree — including in the history, which a `git log -p`
|
||||||
|
search covers and a directory listing does not. `scripts/secrets.sh --tracked`
|
||||||
|
does the tree; `--built dist/` does the bundle, which is the artifact users
|
||||||
|
actually receive and the one the repository scan never sees.
|
||||||
|
|
||||||
|
**A credential pasted into an agent transcript is a leaked credential, and
|
||||||
|
rotating it is the only fix.** Deleting the message does not help and neither
|
||||||
|
does deleting the file: the value was transmitted, stored, and is sitting in a
|
||||||
|
log somebody can read. `secrets.sh` cannot see transcripts and never will — they
|
||||||
|
are not in the repository — which is exactly why this is a written rule rather
|
||||||
|
than a check.
|
||||||
|
|
||||||
|
### What is already in the history
|
||||||
|
|
||||||
|
The Zoho WebToLead tokens `xnQsjsdp` and `xmIwtLD` appear in four commits —
|
||||||
|
`0d3af33` and `b7f7765` in `index.html`, `033bdf6` in `src/pages/Contact.jsx`,
|
||||||
|
and `05b27d2` where they were removed to environment variables. They are **not**
|
||||||
|
in the current tree.
|
||||||
|
|
||||||
|
They were deliberately not rewritten out of the history on 2026-08-18. They are
|
||||||
|
public-by-design form identifiers, the repository is now private, and rewriting
|
||||||
|
152 commits of shared history to remove a value that any visitor's browser can
|
||||||
|
read is cost with no benefit. Recorded in `SECURITY_CHECKLIST.md` under *What got
|
||||||
|
past this list*.
|
||||||
|
|
||||||
|
**The reCAPTCHA secret key never reached git.** Checked on 2026-08-18: zero
|
||||||
|
commits, zero tracked files.
|
||||||
|
|
||||||
|
## Authentication and authorisation
|
||||||
|
|
||||||
|
**There is none, deliberately.** No login, no session, no cookie, no role, no
|
||||||
|
admin area. Every route the server exposes is public on purpose:
|
||||||
|
|
||||||
|
| Route | Public because |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/health` | the container's own `HEALTHCHECK` calls it, and `scripts/healthcheck.sh` does too. It returns a status and a DB flag, and nothing about the data |
|
||||||
|
| `POST /api/leads` | it *is* the contact form |
|
||||||
|
| `POST /api/support` | it *is* the support form |
|
||||||
|
| everything else | static files from `dist/` |
|
||||||
|
|
||||||
|
Nothing reads from the database over HTTP. There is no endpoint that returns a
|
||||||
|
lead, so there is no endpoint that could return somebody else's.
|
||||||
|
|
||||||
|
The corresponding sections of `SECURITY_CHECKLIST.md` and `ClaudeQAPlan.md` were
|
||||||
|
deleted rather than left as unticked boxes.
|
||||||
|
|
||||||
|
## Data in transit
|
||||||
|
|
||||||
|
TLS everywhere, terminated at Cloudflare in front of `qn.isnull.dev`. The server
|
||||||
|
additionally redirects HTTP to HTTPS in production on `x-forwarded-proto`, and
|
||||||
|
sends HSTS with a one-year max-age and `includeSubDomains`.
|
||||||
|
|
||||||
|
Helmet sets the CSP, and it is deliberately narrow: `default-src 'self'`, with
|
||||||
|
named exceptions only for Zoho's form host, Google reCAPTCHA and Google Fonts.
|
||||||
|
`object-src 'none'`, `frame-ancestors 'self'`, `script-src-attr 'none'`.
|
||||||
|
|
||||||
|
## Third parties
|
||||||
|
|
||||||
|
Every row is a decision to send someone else's data somewhere.
|
||||||
|
|
||||||
|
| Service | What it receives | Why that is acceptable |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Zoho CRM** | the full lead: name, company, email, phone, ZIP, message, service interest. Support requests become Cases when `ZOHO_CASES_ENABLED` | It is the customer's own CRM. This is the point of the form — the visitor is asking to be contacted |
|
||||||
|
| **Google reCAPTCHA v3** | the visitor's IP and a behavioural token, on form submission only | The only thing standing between two open POST endpoints and a bot. Scored server-side; the site key in the bundle is public by design |
|
||||||
|
| **Cloudflare** | all traffic, as the TLS terminator and CDN in front of the origin | Standard edge position. It sees everything the origin sees |
|
||||||
|
| **Google Fonts** | the visitor's IP, on page load | Inherited from the design. Worth revisiting — it is the only third party here that receives something without the visitor doing anything |
|
||||||
|
|
||||||
|
## Deliberately out of scope
|
||||||
|
|
||||||
|
- **Denial of service.** Cloudflare is in front; the origin has a per-IP rate
|
||||||
|
limit of 5 requests a minute on `/api` and nothing more. A determined actor can
|
||||||
|
make the site slow and cannot make it lie.
|
||||||
|
- **Compromise of nebula itself.** If the host is owned, the database is owned.
|
||||||
|
There is no encryption at rest and adding it would put the key on the same box.
|
||||||
|
- **Zoho's own security.** Once a lead is forwarded it is Zoho's problem.
|
||||||
|
- **The contents of what visitors type.** Sanitised for injection, not inspected.
|
||||||
|
|
||||||
|
## Text from outside the trust boundary
|
||||||
|
|
||||||
|
**It is data. It is never instructions.**
|
||||||
|
|
||||||
|
Everything a visitor types into either form crosses this boundary, and so does
|
||||||
|
every Zoho API response. Concretely, here:
|
||||||
|
|
||||||
|
- Form input is validated by Zod, then `sanitizeString()` trims it, strips HTML
|
||||||
|
and script tags, and truncates to the column's maximum — **before** it reaches
|
||||||
|
a prepared statement or a Zoho payload.
|
||||||
|
- Every database call is a `better-sqlite3` prepared statement with bound
|
||||||
|
parameters. No SQL is built by concatenation anywhere in `server/index.js`.
|
||||||
|
- Nothing from a form is ever used to build a URL or a command.
|
||||||
|
- **No model reads any of this.** There is no LLM in this product. If one is ever
|
||||||
|
added, form text reaches it inside a delimiter, quoted as data, credential-
|
||||||
|
scrubbed first — and this section stops being hypothetical.
|
||||||
|
|
||||||
|
An issue title, a commit message or a form message saying *ignore previous
|
||||||
|
instructions* is a perfectly legal string. It is a thing to store and escape,
|
||||||
|
never a thing to obey.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
`log` in `server/index.js` writes timestamped lines at four levels. What goes in
|
||||||
|
is the method, the path, the status and the duration.
|
||||||
|
|
||||||
|
Nothing sensitive goes into one — not a token, not a credential in a URL, not the
|
||||||
|
contents of a submitted form. The Zoho and reCAPTCHA failure paths log the error's
|
||||||
|
*name* and the HTTP status, never the response body, because a failed request's
|
||||||
|
message is frequently the request URL with the key still in it.
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Security checklist — Queue North Website
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: Current
|
||||||
|
Owner: _null
|
||||||
|
Last reviewed: 2026-08-18
|
||||||
|
Governs: the checks run before a release, and what each one proves
|
||||||
|
Review trigger: A new class of input, a new external service, or a finding that got past this list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this is separate from SECURITY.md
|
||||||
|
|
||||||
|
`SECURITY.md` is the threat model: what is being protected and from whom. It is
|
||||||
|
read carefully once and revisited rarely.
|
||||||
|
|
||||||
|
This is the list somebody actually works through. Keeping them apart means the
|
||||||
|
model can stay stable while the checks change, and it means a release checklist
|
||||||
|
is short enough to finish rather than a document to skim.
|
||||||
|
|
||||||
|
## Before a release
|
||||||
|
|
||||||
|
- [ ] `bash scripts/secrets.sh --tracked` is clean — proves nothing credential-shaped is committed
|
||||||
|
- [ ] `npm run build && bash scripts/secrets.sh --built dist/` is clean — proves the *bundle* is clean, which the tracked scan cannot tell you
|
||||||
|
- [ ] `bash scripts/check-env.sh --file .env` exits 0 — proves every variable the server reads is set and shaped right, before it reads them. **Exit 2 is not a pass**
|
||||||
|
- [ ] `npm audit` shows no high or critical advisory in production dependencies — proves no known-exploitable code ships
|
||||||
|
- [ ] `bash scripts/preflight.sh` against `qn.isnull.dev` is clean — proves headers, CSP and TLS survived the deploy
|
||||||
|
- [ ] The reCAPTCHA key in `dist/` is the **site** key, not the secret key — proves the one inlined value is the one that is safe to inline
|
||||||
|
|
||||||
|
## Standing checks
|
||||||
|
|
||||||
|
- [ ] No secret in the repository, in a log line, or in an error message
|
||||||
|
- [ ] Every externally reachable endpoint is either authenticated or deliberately public, and the deliberate ones are listed — they are, in `SECURITY.md`: `/api/health`, `/api/leads`, `/api/support`, and static files
|
||||||
|
- [ ] Every input that reaches a query or a filesystem path is validated at the boundary — Zod, then `sanitizeString()`, then a bound prepared statement
|
||||||
|
- [ ] Dependencies audited, and any accepted advisory recorded with a reason
|
||||||
|
|
||||||
|
### What the browser is handed
|
||||||
|
|
||||||
|
- [ ] No secret in the built client bundle, not merely none in the repository — proves the scan reached the artifact users actually receive
|
||||||
|
- [ ] *(only for a deployed service)* Response headers carry a CSP and a frame policy, and nothing is served over plain HTTP — proves clickjacking and injection meet resistance
|
||||||
|
|
||||||
|
> **The authorisation group and the session-token row were deleted from this
|
||||||
|
> list, not left unticked.** This product has no login, no session, no cookie and
|
||||||
|
> no role — see `SECURITY.md`. Three boxes that can only ever be ticked
|
||||||
|
> vacuously teach a reader that the boxes do not mean anything.
|
||||||
|
|
||||||
|
### What a stranger can learn or exhaust
|
||||||
|
|
||||||
|
- [ ] *(precautionary)* The two POST endpoints are rate-limited, and the limit has been exercised rather than assumed — proves a bot cannot run the form unattended overnight
|
||||||
|
- [ ] The honeypot field and reCAPTCHA scoring both actually reject, tested by bypassing them — proves the anti-abuse controls are enforced server-side and not merely present
|
||||||
|
- [ ] No endpoint returns a stored lead or support request — proves the database cannot be read back out over HTTP
|
||||||
|
|
||||||
|
> The account-enumeration row from the template is gone with the accounts.
|
||||||
|
> `preflight.sh --auth` exists and is deliberately never run here for the same
|
||||||
|
> reason.
|
||||||
|
|
||||||
|
### The compliance bar, which is not the launch bar
|
||||||
|
|
||||||
|
- [ ] There is a record of who changed what, and when — proves the question an auditor or a customer eventually asks can be answered at all
|
||||||
|
- [ ] *(only for a deployed service)* There is an environment that is not production to test against — **there is not.** `npm run docker:test` runs the image locally, which is close, and `qn-website-dev` on nebula is both the only deployment and the live site
|
||||||
|
|
||||||
|
The last two are a different bar from everything above them. The rest of this
|
||||||
|
list gets a release out of the door; those two get it through the first
|
||||||
|
compliance review.
|
||||||
|
|
||||||
|
## What got past this list
|
||||||
|
|
||||||
|
Add an entry whenever a real finding was not caught here, and then add the check
|
||||||
|
that would have caught it. A checklist that never grows is one nobody is honest
|
||||||
|
with.
|
||||||
|
|
||||||
|
| When | What was missed | The check now added |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-05-11 → 2026-06-14 | The Zoho WebToLead tokens `xnQsjsdp` and `xmIwtLD` were hardcoded in `index.html` and later `src/pages/Contact.jsx`, and reached four commits on what was then a **public** repository, before being moved to environment variables at `05b27d2`. Low severity — they are public-by-design form identifiers a browser renders anyway — and deliberately **not** rewritten out of the history, which is now private | `scripts/secrets.sh` in the `pre-commit` hook, scanning the staged diff. This is the finding that makes the hook worth having in a project with no test suite to run beside it |
|
||||||
|
| 2026-08-18 | Nobody had ever checked whether the reCAPTCHA **secret** key had reached git. It had not — zero commits, zero tracked files — but "we would have noticed" is not a check | `secrets.sh --tracked` is now the first thing run in a fresh clone, per `docs/TOOLS.md` |
|
||||||
|
| 2026-08-18 | The live lead database had no backup and no restore had ever been attempted, and nothing in any document said so | `scripts/backup.sh` and `scripts/restore-check.sh`, **both run against production the same day**: a verified snapshot was taken from the running container and replayed into a scratch database — 2 tables, 3 rows, under a second. `docs/OPERATIONS.md` now carries a real *Last verified restore* date. What is still missing is a schedule and an off-machine copy, tracked in `Batch 15` |
|
||||||
332
main.js
332
main.js
|
|
@ -1,332 +0,0 @@
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
const pages = Array.from(document.querySelectorAll(".page"));
|
|
||||||
const navLinks = Array.from(document.querySelectorAll("[data-route]"));
|
|
||||||
const dropdowns = Array.from(document.querySelectorAll(".nav-dropdown"));
|
|
||||||
const siteNav = document.querySelector(".site-nav");
|
|
||||||
const mobileNavToggle = document.querySelector(".mobile-nav-toggle");
|
|
||||||
|
|
||||||
function closeMobileNav() {
|
|
||||||
if (!siteNav || !mobileNavToggle) return;
|
|
||||||
siteNav.classList.remove("open");
|
|
||||||
mobileNavToggle.setAttribute("aria-expanded", "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMobileNav() {
|
|
||||||
if (!siteNav || !mobileNavToggle) return;
|
|
||||||
siteNav.classList.add("open");
|
|
||||||
mobileNavToggle.setAttribute("aria-expanded", "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMobileNav() {
|
|
||||||
if (!siteNav || !mobileNavToggle) return;
|
|
||||||
const willOpen = !siteNav.classList.contains("open");
|
|
||||||
if (willOpen) {
|
|
||||||
openMobileNav();
|
|
||||||
} else {
|
|
||||||
closeMobileNav();
|
|
||||||
closeAllDropdowns();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function closeAllDropdowns(exceptDropdown = null) {
|
|
||||||
if (mobileNavToggle) {
|
|
||||||
mobileNavToggle.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleMobileNav();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdowns.forEach((dropdown) => {
|
|
||||||
if (dropdown === exceptDropdown) return;
|
|
||||||
dropdown.classList.remove("open");
|
|
||||||
const toggle = dropdown.querySelector(".nav-dropdown-toggle");
|
|
||||||
if (toggle) {
|
|
||||||
toggle.setAttribute("aria-expanded", "false");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncDropdownState() {
|
|
||||||
dropdowns.forEach((dropdown) => {
|
|
||||||
const toggle = dropdown.querySelector(".nav-dropdown-toggle");
|
|
||||||
if (!toggle) return;
|
|
||||||
const expanded = dropdown.classList.contains("open") ? "true" : "false";
|
|
||||||
toggle.setAttribute("aria-expanded", expanded);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dropdowns.forEach((dropdown) => {
|
|
||||||
const toggle = dropdown.querySelector(".nav-dropdown-toggle");
|
|
||||||
const dropdownMenuLinks = Array.from(dropdown.querySelectorAll(".nav-dropdown-menu [data-route]"));
|
|
||||||
|
|
||||||
if (!toggle) return;
|
|
||||||
|
|
||||||
toggle.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const parentRoute = toggle.dataset.route || "home";
|
|
||||||
|
|
||||||
if (window.innerWidth <= 900) {
|
|
||||||
closeAllDropdowns();
|
|
||||||
window.location.hash = parentRoute === "eightx8" ? "8x8" : parentRoute;
|
|
||||||
setActive(parentRoute);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const willOpen = !dropdown.classList.contains("open");
|
|
||||||
closeAllDropdowns(dropdown);
|
|
||||||
dropdown.classList.toggle("open", willOpen);
|
|
||||||
toggle.setAttribute("aria-expanded", willOpen ? "true" : "false");
|
|
||||||
|
|
||||||
if (willOpen) {
|
|
||||||
window.location.hash = parentRoute === "eightx8" ? "8x8" : parentRoute;
|
|
||||||
setActive(parentRoute);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dropdownMenuLinks.forEach((link) => {
|
|
||||||
link.addEventListener("click", () => {
|
|
||||||
closeAllDropdowns();
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (document.activeElement) {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
const clickedInsideDropdown = dropdowns.some((dropdown) => dropdown.contains(e.target));
|
|
||||||
const clickedInsideNav = siteNav ? siteNav.contains(e.target) : false;
|
|
||||||
const clickedMobileToggle = mobileNavToggle ? mobileNavToggle.contains(e.target) : false;
|
|
||||||
|
|
||||||
if (!clickedInsideDropdown) {
|
|
||||||
closeAllDropdowns();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.innerWidth <= 900 && !clickedInsideNav && !clickedMobileToggle) {
|
|
||||||
closeMobileNav();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
closeAllDropdowns();
|
|
||||||
closeMobileNav();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const serviceRoutes = new Set([
|
|
||||||
"services",
|
|
||||||
"unified-communications",
|
|
||||||
"contact-center",
|
|
||||||
"managed-support",
|
|
||||||
"consulting-training",
|
|
||||||
"infrastructure-cabling",
|
|
||||||
"wireless-access",
|
|
||||||
"local-networking"
|
|
||||||
]);
|
|
||||||
|
|
||||||
const industryRoutes = new Set([
|
|
||||||
"industries",
|
|
||||||
"healthcare",
|
|
||||||
"retail",
|
|
||||||
"manufacturing",
|
|
||||||
"education-finance"
|
|
||||||
]);
|
|
||||||
|
|
||||||
function setActive(route) {
|
|
||||||
const requested = route || "home";
|
|
||||||
const hasRoute = pages.some((p) => p.dataset.page === requested);
|
|
||||||
const target = hasRoute ? requested : "home";
|
|
||||||
|
|
||||||
pages.forEach((p) => {
|
|
||||||
const isActive = p.dataset.page === target;
|
|
||||||
p.classList.toggle("active", isActive);
|
|
||||||
p.setAttribute("aria-hidden", isActive ? "false" : "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
navLinks.forEach((a) => {
|
|
||||||
const routeName = a.dataset.route;
|
|
||||||
const isServiceParent = routeName === "services" && serviceRoutes.has(target);
|
|
||||||
const isIndustryParent = routeName === "industries" && industryRoutes.has(target);
|
|
||||||
const isActive = routeName === target || isServiceParent || isIndustryParent;
|
|
||||||
a.classList.toggle("active", isActive);
|
|
||||||
if (isActive) {
|
|
||||||
a.setAttribute("aria-current", "page");
|
|
||||||
} else {
|
|
||||||
a.removeAttribute("aria-current");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
syncDropdownState();
|
|
||||||
|
|
||||||
window.scrollTo({ top: 0, behavior: "instant" });
|
|
||||||
|
|
||||||
if (target === "home") {
|
|
||||||
startHeroMotion();
|
|
||||||
} else {
|
|
||||||
stopHeroMotion();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeFromHash() {
|
|
||||||
const hash = (window.location.hash || "#home").replace("#", "").trim();
|
|
||||||
if (hash === "8x8") return "eightx8";
|
|
||||||
return hash || "home";
|
|
||||||
}
|
|
||||||
|
|
||||||
navLinks.forEach((a) => {
|
|
||||||
a.addEventListener("click", (e) => {
|
|
||||||
const route = a.dataset.route;
|
|
||||||
if (!route) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
window.location.hash = route === "eightx8" ? "8x8" : route;
|
|
||||||
setActive(route);
|
|
||||||
|
|
||||||
if (window.innerWidth <= 900) {
|
|
||||||
closeMobileNav();
|
|
||||||
closeAllDropdowns();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const cta = document.getElementById("cta-consultation");
|
|
||||||
if (cta) {
|
|
||||||
cta.addEventListener("click", () => {
|
|
||||||
window.location.hash = "contact";
|
|
||||||
setActive("contact");
|
|
||||||
closeMobileNav();
|
|
||||||
closeAllDropdowns();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", () => {
|
|
||||||
setActive(routeFromHash());
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
if (window.innerWidth <= 900) {
|
|
||||||
closeAllDropdowns();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const yearSpan = document.getElementById("year");
|
|
||||||
if (yearSpan) yearSpan.textContent = new Date().getFullYear();
|
|
||||||
|
|
||||||
const rotatorEl = document.getElementById("hero-rotator-text");
|
|
||||||
const lockupEl = document.getElementById("hero-lockup");
|
|
||||||
|
|
||||||
const phrases = [
|
|
||||||
"Technical Precision",
|
|
||||||
"Operational Clarity",
|
|
||||||
"Human-Centered Support"
|
|
||||||
];
|
|
||||||
|
|
||||||
const phraseFadeIn = 1100;
|
|
||||||
const phraseHold = 1900;
|
|
||||||
const phraseFadeOut = 1100;
|
|
||||||
const betweenGap = 500;
|
|
||||||
const lockupFadeIn = 1200;
|
|
||||||
const lockupHold = 6500;
|
|
||||||
const loopGap = 800;
|
|
||||||
|
|
||||||
let heroTimer = null;
|
|
||||||
let heroRunId = 0;
|
|
||||||
|
|
||||||
function showEl(el) {
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.add("is-visible");
|
|
||||||
el.classList.remove("is-hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideEl(el) {
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.remove("is-visible");
|
|
||||||
el.classList.add("is-hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setText(el, text) {
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
heroTimer = window.setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearHeroTimer() {
|
|
||||||
if (heroTimer) {
|
|
||||||
window.clearTimeout(heroTimer);
|
|
||||||
heroTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function heroLoop(runId) {
|
|
||||||
if (!rotatorEl || !lockupEl) return;
|
|
||||||
|
|
||||||
hideEl(lockupEl);
|
|
||||||
hideEl(rotatorEl);
|
|
||||||
|
|
||||||
while (runId === heroRunId) {
|
|
||||||
for (let i = 0; i < phrases.length; i++) {
|
|
||||||
if (runId !== heroRunId) return;
|
|
||||||
|
|
||||||
setText(rotatorEl, phrases[i]);
|
|
||||||
|
|
||||||
rotatorEl.style.setProperty("--hero-fade-ms", `${phraseFadeIn}ms`);
|
|
||||||
rotatorEl.style.setProperty("--hero-drift-ms", `${phraseFadeIn + phraseHold + phraseFadeOut}ms`);
|
|
||||||
showEl(rotatorEl);
|
|
||||||
await sleep(phraseFadeIn);
|
|
||||||
|
|
||||||
await sleep(phraseHold);
|
|
||||||
|
|
||||||
hideEl(rotatorEl);
|
|
||||||
await sleep(phraseFadeOut);
|
|
||||||
|
|
||||||
await sleep(betweenGap);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runId !== heroRunId) return;
|
|
||||||
|
|
||||||
lockupEl.style.setProperty("--hero-lockup-fade-ms", `${lockupFadeIn}ms`);
|
|
||||||
showEl(lockupEl);
|
|
||||||
await sleep(lockupFadeIn);
|
|
||||||
|
|
||||||
await sleep(lockupHold);
|
|
||||||
|
|
||||||
hideEl(lockupEl);
|
|
||||||
await sleep(loopGap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startHeroMotion() {
|
|
||||||
if (!rotatorEl || !lockupEl) return;
|
|
||||||
|
|
||||||
heroRunId += 1;
|
|
||||||
clearHeroTimer();
|
|
||||||
hideEl(lockupEl);
|
|
||||||
hideEl(rotatorEl);
|
|
||||||
heroLoop(heroRunId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHeroMotion() {
|
|
||||||
heroRunId += 1;
|
|
||||||
clearHeroTimer();
|
|
||||||
hideEl(lockupEl);
|
|
||||||
hideEl(rotatorEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
if (window.innerWidth > 900) {
|
|
||||||
closeMobileNav();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setActive(routeFromHash());
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"failedTests": []
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue