> Historical V2 progress notes. For this release, use the fresh root VERIFICATION.md and RELEASE-NOTES.md; historical checked items below are not new verification claims.

# Upgrade status — Justice Choice

Tracks the "LegalRanker / Justice Choice — Complete Project Analysis & Upgrade Specification" against the code.
Last updated: 2026-10-04 (Phases 1–5 done).

Legend: ✅ done and tested · 🟡 partly done · ⬜ not started · 👤 needs your action (not code)

Decisions taken: brand stays **Justice Choice**; the owner is an explicit **`owner`** role (all former `super_admin` accounts were migrated to it).

---

## P0 — before serious production use

| # | Item | Status | Where / notes |
|---|---|---|---|
| P0.1 | Don't ship `node_modules` in the ZIP | 👤 | Zip only source + `package.json` + `package-lock.json`; run `npm ci` on the target machine. |
| P0.2 | Replace development secrets | 🟡 | API now **refuses to start in production** with placeholder `JWT_SECRET` / `ENCRYPTION_KEY` / `IP_HASH_SALT`, a localhost `ADMIN_URL` or missing SMTP (`apps/api/src/common/config.ts`). You still have to generate the real values. |
| P0.3 | Password reset + email infrastructure | ✅ | Forgot/reset password, SMTP (Gmail App Password), queued email with retries and a delivery log. |
| P0.4 | Server-side session revocation | ✅ | `user_sessions` table; every request re-reads the user and session, so disable/reset/role change apply instantly. |
| P0.5 | Protected Owner role | ✅ | `owner` role; last owner can't be disabled or demoted; admins can't touch owners/admins; owner/admin changes need password re-entry. |
| P0.6 | User management CRUD | ✅ | Invite, edit role/links, enable/disable, send reset, force reset, reset 2FA, sign out everywhere, mark verified, security history. Hard delete is deliberately not offered — disable instead (audit history keeps references). |
| P0.7 | `firm_admin` flow | ✅ | Login routing, `/portal` and the lead API work for firm admins (firm-wide enquiries). Full firm dashboard is Phase 2. |
| P0.8 | Webhook secret decrypted before signing | ✅ | `packages/database/src/pipelines/leads.ts` |
| P0.9 | Webhook retries = DB failure threshold | ✅ | `LEAD_WEBHOOK_MAX_ATTEMPTS = 5` used by both. Reroute now also queues webhooks. |
| P0.10 | Direct leads respect verified + active licence | ✅ | `isLeadEligibleLawyer()` shared by direct, matched and rerouted leads. |
| P0.11 | Stripe invoice → local subscription | ✅ | Tested with signed Stripe test events (not yet against a live Stripe account). |
| P0.12 | Enforce paid-plan entitlements | ✅ | `apps/api/src/common/entitlements.ts` (Phase 4). |
| P0.13 | Lead PII limited to owner/admin | ✅ | Editors and verifiers get 403 on `/admin/leads`. |
| P0.14 | Remove demo data before launch | 👤 | Use `npm run owner:bootstrap` for the real owner; don't run the seed in production. |

## Phase 1 — Foundation & security

| Item | Status | Notes |
|---|---|---|
| Owner architecture | ✅ | |
| Permissions | 🟡 | Role-based guards (`OwnerOnly`, `AdminOnly`, `LeadAccess`, `StaffOnly`). A fine-grained permission table (`users.read`, `leads.manage`, …) is not built. |
| User management | ✅ | Admin → Users, Users → user detail page |
| Sessions / revocation / logout-all | ✅ | Admin → My account security, Portal → Security |
| Password change | ✅ | Owners/admins with 2FA also confirm with an authenticator code |
| Forgot / reset password | ✅ | 30-minute, one-use, hashed tokens; same answer for unknown emails; signs out all devices |
| Gmail SMTP | ✅ | `.env.example` → Email section. Without SMTP in development, emails are printed to the API log |
| Email verification | 🟡 | Flow, page and "resend" exist. It becomes important once self-registration/claims exist (Phase 2). |
| MFA recovery codes | ✅ | 10 hashed one-use codes; usable at sign-in; regenerate with a TOTP code |
| Invitations (staff and users) | ✅ | Email link → person sets their own password |
| Forced password change | ✅ | Admin "Force reset" and the owner bootstrap |
| Security emails | ✅ | Password changed, 2FA on/off/reset, recovery code used, account disabled |
| Admin Email page (status, test email, log) | ✅ | Admin → Email; test email is owner-only |
| Production owner bootstrap | ✅ | `npm run owner:bootstrap` |
| Change email address | ⬜ | |
| New-device / login alert emails | ⬜ | |
| Tests | ✅ | Unit tests + `scripts/e2e/accounts.mjs` (45 checks), run in CI. |

## Phase 2 — Account workflows

| Item | Status | Where / notes |
|---|---|---|
| Lawyer profile claim | ✅ | Public: profile → "Claim this profile" (`/claim/<slug>/`). Claimant confirms email, can upload proof. Automatic checks: firm-domain email match (free mailboxes never count) and licence-number match. Staff: Admin → **Profile claims** (approve / reject / ask for documents). Approval creates or links the professional account, gives a solo organization, closes competing claims and emails an activation link. |
| Professional portal expansion | ✅ | Portal tabs: **My profile** (photo, bio, contact, languages, years, video link, completeness), **Credentials** (on file + "add a credential" with proof → staff review), **Rankings** (all 9 components, weights, how to improve, history), **My enquiries**, **Reviews**. |
| Credential verification queue | ✅ | Admin → **Credential requests**. Approval writes the licence/education/award/matter as verified; it counts from the next ranking run. |
| Firm dashboard | ✅ | Portal tabs: **Firm** (profile, logo, offices, stats, seats), **Lawyers** (status, pause enquiries, give a lawyer their own login), **Firm enquiries** (filters, accept/decline, CSV export with formula-injection protection), **Team**, **Integrations** (webhook URL with private-address blocking, secret rotation, receiver docs). |
| `organization_members` | ✅ | Roles owner / admin / billing / member; existing firm admins and solo lawyers were backfilled. Last firm owner can't be removed or demoted. Removing a member signs them out. |
| Team invitations and seats | ✅ | Seats: 25 on the Firm plan, 3 otherwise — from entitlements (Phase 4). |
| In-app notifications | ✅ | Bell + unread count (staff sidebar, portal header), notification page, per-topic in-app/email preferences. Sent for: new enquiry, new claim, claim decision, credential request/decision, team changes. |
| File uploads / storage | ✅ | S3-compatible (AWS S3, Cloudflare R2, MinIO) via `S3_BUCKET`, local disk in development. File type checked from bytes, size limits, random keys, private documents download-only for reviewers/uploader. Malware scanning ⬜. |
| Firm offices (`firm_offices`) | ✅ | Backfilled from each firm's address. Not yet shown on the public firm page ⬜. |
| Tests | ✅ | `scripts/e2e/firm-claims.mjs` (61 checks), run in CI. |

## Phase 3 — Leads

| Item | Status |
|---|---|
| Direct-lead eligibility, webhook secret, retry alignment, PII access | ✅ (see P0) |
| Webhook SSRF protection | ✅ Checked when the URL is saved **and** inside the socket's own DNS lookup at delivery (defeats DNS rebinding); HTTPS only in production; no redirects; 10 s timeout; 64 KB response cap. Blocked addresses fail permanently instead of retrying. (`packages/database/src/net.ts`) |
| Lead rules (business-configurable) | ✅ Admin → **Lead rules**: first-round and total recipients, response deadline, reminder, reroute on decline/expiry, close others on accept, lead expiry, retention. Changes are audited. |
| Lead SLA, reminders, auto-reroute, expiry | ✅ Sweep every 10 minutes (BullMQ job scheduler, one run across all API instances; timer fallback without Redis) plus "Run now". Lawyers see "Reply by …" on open enquiries. |
| Consent-safe rerouting | ✅ Consent text states the real recipient limit and is stored per enquiry; reroutes never exceed it; a direct enquiry is never sent to anyone but the chosen lawyer. Rerouting adds only lawyers not yet contacted (it used to resend to the same ones). |
| Lead emails | ✅ Client: received (with reference), a lawyer accepted, no lawyer accepted (expired). Lawyer: new enquiry, reminder, expired, taken by another lawyer (when enabled). |
| Dead-letter view for failed deliveries | ✅ Admin → **Failed deliveries** (failed / retrying) with Retry. |
| Firm webhooks for firm lawyers | ✅ Webhooks now resolve the lawyer's own organization or their firm's (firm lawyers were never delivered before). |
| Answering closed enquiries | ✅ Expired/declined/accepted routings can no longer be answered (409); webhook routings that are retrying or failed stay answerable from the dashboard. |
| Data retention | ✅ Client contact details and case text are anonymised after the retention period; opening an enquiry in the admin console is audited (`lead.viewed`). Export/deletion requests by clients ⬜ (Phase 5, client accounts). |
| Tests | ✅ | `scripts/e2e/leads.mjs` (33 checks), run in CI. |

## Phase 4 — Billing

| Item | Status | Notes |
|---|---|---|
| Payment ↔ subscription link | ✅ | Invoices also store number, hosted page, PDF, period and failure reason. |
| `stripe_events` ledger (idempotency) | ✅ | Every webhook stored by Stripe event id and applied once; failures return an error so Stripe retries. Admin → Commerce shows the ledger with **Re-apply**. |
| Entitlements and enforcement | ✅ | One place (`entitlements.ts`). **Premium**: analytics, intro video, biography beyond 1,000 characters, priority verification (queue order only). **Firm**: Premium + firm page editing, lead webhooks, CRM export, 25 seats. **Featured**: sponsored slot. When a plan lapses, the video hides, the bio shows its free length, webhooks fall back to the dashboard. |
| Ranking separation | ✅ | Photo, contact details and a 1,000-character bio stay **free** because they count toward the profile-quality score; the ranking engine and pipeline reference no payment, subscription or sponsored data (checked). |
| Grace period | ✅ | Past-due plans keep features for `BILLING_GRACE_DAYS` (7); banner on the Billing page; owners/billing managers notified. |
| Billing page | ✅ | Portal → **Billing** (firm owner/admin/billing manager): plans and status, cancel at period end / keep plan, switch Premium ↔ Firm (prorated), Stripe Customer Portal (cards, invoices), invoice history with links, Featured checkout. |
| Premium analytics | ✅ | Portal → **Analytics**: 90-day views and enquiries, view→enquiry, acceptance, average response time, referring sites (host only — the beacon now records the referrer host, never the full URL). |
| Featured placement provisioning | ✅ | Checkout picks city, practice area and lawyer/firm with live slot availability (`FEATURED_SLOTS_PER_PAGE`). Payment creates the labelled placement; renewals extend it; cancellation or lapse turns it off; a race for the last slot is held and staff are alerted. |
| Billing notifications | ✅ | Plan active, payment failed (with grace end), plan ending/ended, plan changed — in-app and email to firm owners/admins/billing managers. |
| Refunds | 🟡 | `charge.refunded` marks the payment refunded; issuing refunds is done in the Stripe dashboard. |
| Tests | ✅ | `scripts/e2e/billing.mjs` (34 checks, signed Stripe test events against a second API on :4100), run in CI. Checkout/portal calls still need a real Stripe test account. |

## Phase 5 — Advanced product

| Item | Status | Notes |
|---|---|---|
| Optional client accounts | ✅ | Public site → **Sign in / Create account** (`/account/`). Browsing and enquiries still need no account. Registration never reveals whether an email exists (the owner gets an "account exists" email instead). Client links in emails open the public site; staff/lawyer links open the admin app. Client accounts can’t sign in to the admin app, and staff can’t invite or convert them. |
| Enquiry tracking | ✅ | **My enquiries**: status, which lawyers have it and whether they accepted, reference. Enquiries link to an account **only after its email is confirmed** (past ones on confirmation, new ones automatically). The client can close an enquiry; lawyers who haven’t replied are told. |
| Saved lawyers & firms | ✅ | ♥ **Save** on profiles, firm pages and the compare table; **Saved** page. |
| Compare | ✅ | **Compare** button on profiles (up to 4, kept in the browser, works without an account) → `/compare/`: verification, licences, admission year, experience, practice areas, languages, best rankings, reviews, verified awards and matters, accepting enquiries. |
| Saved searches & alerts | ✅ | **Save this search** on search results; daily email when lawyers newly match (each announced once); alerts need a confirmed email. |
| Privacy for clients | ✅ | **Settings & privacy**: change password, sign out other devices, **download my data** (JSON), **delete account** (password + typing DELETE; saved items removed, enquiries anonymised). |
| Admin funnel analytics | ✅ | Admin → **Analytics**: search → profile → form → sent → delivered → seen → accepted, with daily charts, by city, by practice area, referring sites, client-account numbers. |
| Google Search Console | 🟡 | Admin → **Search Console**: daily import of clicks/impressions/CTR/position per page, sitemap status, dashboard KPIs filled. Uses a service account (no SDK). **Not tested against a real Google property** — needs `GSC_SITE_URL` + `GSC_CREDENTIALS`. |
| Recently viewed, notification centre for clients | ⬜ | Not built. |
| Tests | ✅ | `scripts/e2e/clients.mjs` (45 checks), run in CI. |

## Continuous integration

`.github/workflows/ci.yml`: install → build packages → typecheck → unit tests (ranking 12, SEO 8, API 25) → build apps → migrate + seed → smoke test → all five end-to-end suites (`node scripts/e2e/run.mjs`, which restarts the API between suites via `scripts/e2e/ci-restart.sh`). Logs are uploaded if it fails. The whole pipeline was rehearsed twice on a fresh, isolated Postgres/Redis: 218/218 end-to-end checks passed both times. The smoke test was fixed to accept login status 200.

Run locally against your own stack: `node scripts/e2e/run.mjs` (see the env vars at the top of `scripts/e2e/lib.mjs`; it never needs your real owner password — use the seed owner or set `E2E_OWNER_EMAIL`/`E2E_OWNER_PASSWORD` in your shell).

## Other spec items

| Item | Status |
|---|---|
| Central brand/domain config (instead of hard-coded "Justice Choice") | ⬜ |
| Ranking version approval workflow, simulator, appeals | ⬜ |
| Admin settings (general, lead rules, security policy) | ⬜ |
| System health: email status | ✅ (in `/admin/health` API and the Email page) — workers/Stripe/storage/backups ⬜ |
| Strict CSP / CSRF review | ✅ Content-Security-Policy + HSTS on web and admin (`next.config.ts`; no framing of admin, no plugins, same-origin scripts/connections; checked in a real browser with zero violations). CSRF: every change goes through Next server actions (Origin checked by Next), the admin cookie is `SameSite=Strict`, the client cookie `Lax`, the API accepts Bearer tokens only, and cookie-reading GET routes change nothing. Inline scripts still allowed (Next hydration) — nonce-based CSP would be the next step. |
| Data retention and privacy process for leads | 🟡 Retention, anonymisation, access audit (Phase 3) and client self-service export/deletion (Phase 5) done; privacy-policy wording ⬜ |
| Delete stale `web-build.log` | 👤 |

---

## Local environment note

The local Postgres volume had been created under the old `legalranker` user, so the current `justice_choice` connection was refused. On 2026-10-04 a `justice_choice` role and database were added to that volume (the old `legalranker` database was left untouched), and the stack was rebuilt with `docker compose -p legalranker up --build -d`.
