Commit Graph

85 Commits (9c59430c3150a21d761d975144eb2e826036017c)

Author SHA1 Message Date
Thomas Joise 9c59430c31 perf/fix(d24): DB indexes (both engines) + case-insensitive client search
Red-team backend-quality highs/mediums:
- Added 30 indexes on the foreign-key / status / date / renewal columns the
  lists, reports, joins and scans read — zero existed, so every query full-scanned
  (tolerable on dev SQLite, a real problem on production Postgres at 300 clients /
  thousands of tickets & documents). Same DDL on both engines: INDEX_DDL in the
  SQLite SCHEMA + pg migration 007 (CREATE INDEX IF NOT EXISTS, idempotent).
- Client search + district/sector filters now LOWER(...) LIKE LOWER(?): bare LIKE
  is case-insensitive on SQLite but case-SENSITIVE on Postgres, so search silently
  broke on the prod engine. Test locks case-insensitivity.
Verified: pg migrations 006+007 apply cleanly on local Postgres; suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise aaad1e7a3a fix(sec/d24): concurrency + workflow isolation
Red-team correctness findings:
- SqliteDb.transaction now serializes top-level transactions with an async
  mutex and tracks re-entrancy via AsyncLocalStorage (nested calls -> savepoints,
  no deadlock). The old shared txnDepth counter let concurrent async callers
  interleave BEGIN/COMMIT on the one connection -> silent data loss; a 50-way
  concurrent read-modify-write test now lands exactly.
- generateRecurring wraps each plan in try/catch: one misconfigured plan is
  parked (audited 'generate_failed') and the scan continues, instead of aborting
  all remaining generation and the entire auto-send drain, every scan.
- POST /modules/:id/notify broadcast IIFE gets a trailing catch so a mid-loop
  throw answers the request instead of hanging it.
Full suite 388 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise f812555528 fix(sec/d24): money-integrity guards — CN cap, atomic doc-no, qty, TDS, markStatus
Red-team money findings:
- createCreditNote caps the CN to the invoice's remaining creditable value
  (invoice payable − already-credited by non-cancelled CNs), read+insert in
  one txn. Blocks a CN exceeding its invoice / unlimited CNs flipping it 'paid'.
- nextDocNo allocates via one atomic UPDATE ... RETURNING (row lock) instead of
  read-then-bump — no duplicate/burned numbers under concurrent issue (Postgres).
- markStatus rejects accepted/lost on non-QUOTATION docs — an issued invoice can
  no longer be hidden from overdue chasing via a quote-only transition.
- computeBill bounds qty magnitude (keeps fractional for weighed Store goods);
  HQ buildLines additionally requires whole-unit integer qty (SMS/module seats).
- recordPayment rejects TDS greater than the cash received — no fabricated-TDS
  'settlement' with no money.
money-guards test locks all four. Suite money paths green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 54cde7961b fix(sec/d24): gate destructive/sensitive ops to managerial
Red-team authorization findings:
- New requireManagerial middleware. Applied to POST /documents/:id/cancel,
  /credit-note (financially destructive — reverse money / consume a number)
  and /documents/:id/share (mints an UNAUTHENTICATED public PDF link — the
  IDOR exfiltration high). Any signed-in staff could previously do all three.
- PATCH /interactions/:id now 403s unless the viewer is the interaction's
  author or managerial — staff can no longer overwrite another employee's
  logged call/visit.
New authz-gates test locks both. Note (deliberate): general client read/edit
stays open to staff — a shared support-team book is the intended model; the
genuinely sensitive credential reveals are already managerial + rate-limited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 10022965d1 fix(sec/d24): auth hardening — login lockout, rate limits, constant-time, trust proxy
Red-team highs/mediums (auth surface):
- staff_user gains failed_count/locked_until; login() locks an account for 15min
  after 8 consecutive failures, clears the counter on success (both engines).
- login() is now constant-time: a missing email still spends one scrypt verify
  against a fixed dummy hash — no timing/enumeration oracle. Returns a typed
  {ok:false,reason} instead of null.
- POST /auth/login gets per-IP (20/min) AND per-email (8/15min) fixed-window
  rate limiting layered over the lockout; 429 on trip.
- Credential reveal routes (db-password, module password, module secret) get a
  per-user reveal limiter (20/min) so one insider cannot bulk-decrypt everything;
  added missing getClientModule 404 guards.
- app.set(trust proxy,1) so req.ip is the real client behind nginx/Caddy and the
  per-IP limiters actually bucket per client.
- makeRateLimiter extracted to rate-limit.ts (shared, no server<->api cycle).
Tests: lockout, counter-reset, unknown-email, and existing auth all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 3c2e51ae6c feat(d22): P1 — onboarding milestone tracker (backend) + monotonic uuidv7
project_milestone: each client_module (a project) carries a checklist
seeded from the dated project.milestone_template setting (advance paid /
setup / installed / go-live / balance / AMC — owner-editable). Ticking
stamps the date; audited. ensureProjectMilestones is idempotent and
back-fills imported projects. Cross-project board (per-milestone pending
counts) + drill-down (who's pending go-live). Routes: GET/POST
/client-modules/:id/milestones, GET /projects/board, /projects/pending/:key.
assignModule seeds the checklist for new projects.

Also fixes a latent flaky-ordering bug: uuidv7() now bumps monotonically
within a process, so ORDER BY id is genuine creation order (the guarantee
pagination + the sync cursor already assume) — sub-millisecond ties no
longer fall back to the random tail. Suite 377 green, stable across runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise e91e1741b8 feat(d23): SMS volume-band billing (dated rate card, money locked)
usage_rate_band: a dated per-module rate card of volume bands (generic;
SMS packs the first user). resolveUsageRate picks the band by count
(highest min_qty <= count); below the lowest band = under the minimum
order. buildLines resolves price as: explicit per-line override >
tiered rate card > dated price book — so an SMS line prices by count
automatically, a save under 50,000 is refused, preview warns. Rates are
integer paise (0.40=40p), a new card is a new dated set of rows so issued
invoices keep their rate. Owner routes GET/POST /modules/:id/rate-card.
Tests lock the founder card to the paisa: 50k→₹23,600, 100k→₹43,660,
200k→₹80,240, 300k→₹1,06,200 incl 18% GST. Suite 372 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 51023f9ec4 feat(d21): P4 — importer seeds module field_spec + writes field_values
ensureModule seeds each module field_spec (SMS/RTGS/MOBILEAPP declared
fields; ATM/WHATSAPP empty — their one datum stays in the D20 provider
column), back-filling only when a module spec is still empty so an
owner-customized spec is never clobbered. SMS/RTGS/MobileApp service data
now writes into client_module.field_values keyed to the spec; provider/
username/portal-password keep the D20 columns; unkeyed values still land in
details so nothing is lost. Tests assert seeded specs + keyed values; suite
363 green. Real dry run unchanged: 273 clients, 150 docs, 167 links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 427ac31c57 feat(d21): P1+P2 — module-defined field schema (backend)
module.field_spec (FieldDef[]: key/label/type/options/required) declares a
module's own operational fields, validated on create/update. client_module
gains field_values (JSON map of non-secret values, type-checked against the
owning module's spec) + secrets_enc (one AES-GCM blob over a {key:plaintext}
map; the clear key-list prefix lets the UI show which secrets are set without
decrypting). Routes: PUT /client-modules/:id/fields (staff), POST
/client-modules/:id/secrets + /reveal-secret (managerial, audited, plaintext
never logged). Additive over D20 — provider/username/password stay. Both
engines (SCHEMA + migrate guards + pg 003). 11 new tests; module suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 1328a9e898 feat(d20): importer v2 rework from the 46-agent real-data audit
Bills are LINE ITEMS not documents: group by INVOICE_NO across three GST
eras (GST pseudo-line / 2026 columns-only / pre-GST zero-tax). 46 raw
ticket statuses -> 5 via ordered pattern map; OLD_ID re-key chains
collapse so superseded rows do not double-import. YES/NO flags respect
real vocabulary (GENERAL ENQUIRY != yes); ATM cell value becomes the
module provider. SID 329 test client + orphan refs skip (counted notes,
distinct from commit-blocking problems). District normalize, NO DATA
scrub, payment normalize, richer-row merge for duplicate service rows,
shared-number-space series seed. Dry run over the real export: 273
clients, 150 documents = Rs 33,16,800.24, 72 payments, 1,826 tickets,
667 interactions, INVOICE seed 10252 (matches APEX next_val). Suite 358
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise b301272464 feat(d20): P4 — full-APEX importer v2 (9 files, stage/commit, CLI)
parseWorkbookCsvs + stageApexFull (pure validation pass, zero writes) +
commitApexFull (one transaction): clients w/ encrypted DB passwords +
secretary contacts, YES-flags -> live client_module rows for the 5
modules, branches, bill_filed -> QT/PI/INV history with paisa-exact
CGST/SGST split + payment/allocation rows + series seed, 4.5k tickets
with status/date mapping, SMS/RTGS service data -> client_module
provider/username/password_enc/details, project lists -> interaction
notes with tolerant APEX date parsing. Plaintext passwords never stored
or audited; missing HQ_SECRET_KEY is a blocking problem. CLI:
npm run import:full -- --dir <folder> [--commit]. 7 new tests; suite
359 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise c58f0fdfc5 feat(d20): P2 — ticket/branch/service-data routes, gated + audited
GET/POST/PATCH /tickets (chips filters, mine=1, search, honest pagination,
counts + kind suggestions in one payload); client branches GET/POST/PATCH;
PATCH /client-modules/:id gains the managerial-gated portal password (atomic
with the rest of the patch) + POST /client-modules/:id/reveal-password
(audited per reveal). HTTP gating test: staff 403 on password set/reveal,
full ticket + branch round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 9fd4062cfe feat(d20): P1 — ticket desk, client branches, per-service data (schema + repos)
New tables ticket + client_branch (both engines: SCHEMA + pg 002-apex-parity);
client_module gains provider/username/password_enc/details/remark with
migrate guards. repos-tickets: team-visible desk, honest pagination, status
lifecycle stamping closed_on, counts + kind suggestions from history.
repos-branches: minimal audited CRUD. repos-modules: service fields on
ClientModule (hasPassword only — ciphertext never leaves), validated details,
setModulePassword/revealModulePassword mirroring the client DB password
discipline. 5 new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 95df5cb5b9 feat(db): D19 P4 — DATABASE_URL engine selection + Postgres integration suite
startServer picks Postgres when DATABASE_URL is set, SQLite file otherwise.
New pg-engine.test.ts (opt-in via HQ_PG_TEST_URL, wipes its target schema):
migrations from zero, invoice issue with series numbering + due-date stamp,
paisa-exact payment to paid, exactly-once reminder upsert via ON CONFLICT,
savepoint-nested rollback (incl. the mixed dbPassword+patch write), and a
full HTTP boot smoke (health/login/clients) on the postgres engine.
Verified locally against PostgreSQL 17: 5/5. Default suite (sqlite)
346/346, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 5f9f961d20 feat(db): D19 P1-P3 — async DB interface, dual engines, Postgres adapter
P1: every repo, route, scheduler path and test now speaks one async DB
interface (all/get/run/exec/transaction). better-sqlite3 stays underneath
for dev/tests via SqliteDb (statement cache, manual BEGIN/SAVEPOINT
nesting); openDb stays sync so fixtures remain one-liners. Zero behavior
change: 346/346 tests, typecheck clean.

P2: migrations-pg.ts — numbered Postgres migration set (001-init: bigint
paise, integer flags, TEXT ISO dates, TEXT JSON; reminder CHECK born in
final widened form) + schema_migrations runner.

P3: db-pg.ts — pg Pool behind the same interface: ?->$n rewrite outside
string literals, transactions pinned to one pooled client via
AsyncLocalStorage with savepoint nesting, int8/numeric parsed to JS
numbers so paise math is identical on both engines.

Also: reminder upsert INSERT OR IGNORE -> ON CONFLICT DO NOTHING
(portable, same changes-count semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 days ago
Thomas Joise 60698fc08f fix(iwa): final-review fixes — dialog layering/scrim/scroll-lock, a11y labels+disabled+alerts, settings guards+errors, edit-mode locks, strict offsets, titles
27 adversarially-confirmed findings from the multi-agent whole-branch review (2 deferred).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 days ago
Thomas Joise 94018b9ae4 fix(review): close all 8 findings from the D18 slice review
MAJOR
- Documents register: Convert & send no longer bubbles into row navigation
  (stopPropagation guard on the actions cell)
- Pipeline rows: convert_invoice now uses the same confirmed convert-and-send
  as the Documents page (was the old plain draft convert)
- APEX importer carries anydesk/os/district/sector: stg_client columns
  (+ migrate guard), optional CSV headers staged, commit INSERT maps them
- Clients page: district/sector filter inputs wired to the server filters
  (server side widened to LIKE for narrow-as-you-type)

MINOR
- WhatsApp/secretary typed contact roles: datalist on the contact Role input,
  labeled rows (with Copy) on the Client 360 support card
- Dashboard overdue + dues-aging now anchor on COALESCE(due_date, doc_date) —
  an unpaid invoice not yet due no longer shows as overdue (test added)
- PATCH /clients/:id is atomic: dbPassword + field patch in one transaction
- Audit same-txn: setClientDbPassword, stageCsv, and the import stage/commit
  route audits all wrapped in db.transaction

Suite: 345 tests / 66 files, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise f30532b7d1 feat(import): APEX cutover web UI — stage/verify/commit over the existing importer (Phase 7, D18 WS-B)
- POST /import/stage (CSV text), GET /import/status (report + first-50 problem
  rows with honest totals), POST /import/commit — all owner-only, audited;
  commit still refuses while ANY staged row carries problems (existing guard)
- /import page (ADMIN nav, owner-only): file pickers → problem tables →
  verification stats (incl. invoice-value cross-check) → confirm-gated Commit;
  re-stage freely, series seeding message surfaced
- 2 route tests (403s for staff; bad→refuse→clean→commit round-trip incl.
  series seeding past legacy numbers); suite 344 green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise b5d252b7dc feat(shell): sidebar collapse + My Day dashboard (Phase 5, D18 WS-E)
- sidebar collapses to an icon rail (footer toggle, localStorage-persisted;
  nav tooltips via title; <900px hamburger untouched)
- dashboardView gains mine-scoping: staff are ALWAYS scoped to their book
  (clients they own, docs/payments they created, their follow-ups, their queue);
  owner/manager get a Mine/Everyone toggle (?mine=1)
- 1 test; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 30c2e3b60b feat(clients): support-access data — AnyDesk/OS, district+sector filters, encrypted DB password with audited reveal (Phase 6, D18 WS-F)
- additive client columns; toClient never maps db_password_enc (payloads carry
  only hasDbPassword); listClients gains district/sector filters
- setClientDbPassword: AES-256-GCM via HQ_SECRET_KEY, refuses loudly without a
  key; revealClientDbPassword decrypts + writes one audit row per reveal
- PATCH /clients/🆔 dbPassword is owner/manager-gated inside the same route;
  POST /clients/:id/reveal-db-password (owner/manager)
- client dialog gains district/sector/AnyDesk/OS; Client 360 gains the Support
  card (copy AnyDesk, •••• + Reveal/Set for managerial)
- 5 tests; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 828f533a69 feat(templates): section-per-module quotation PDF (Phase 9, D18 WS-H)
- QUOTATION renders one titled section per line: module name, its what's-
  included block, SAC + qty × rate + amount; sections never split mid-page and
  each module after the first opens a fresh page in print
- summary line up top ('This proposal covers: …'); totals/terms/signature
  unchanged; INVOICE/PROFORMA/CN/RECEIPT keep the compact grid (regression-
  asserted); preview-fidelity golden test untouched and green
- 3 tests; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 747026fa3b feat(modules): roster full-edit + assign/unassign; one-click Convert & send on the documents register (Phase 8, D18 WS-G)
- listClientsByModule rows carry cmId; ClientModulePatch gains kind/edition
  (kind validated against module.allowedKinds — same audited write path as
  Client 360, so the two views can never disagree)
- Modules roster: inline edit (kind/edition/renewal/status), ✕ unassign with
  confirm (deactivates the link, totals shrink), Assign client… type-ahead
- Documents register: issued proformas get a Convert & send quick action
  (same convert-and-send endpoint + confirm as the document view)
- 2 tests; suite 333 green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise d95bf6d467 feat(employees): self-service profile + change-own-password; phone/title fields (Phase 4, D18 WS-E)
- GET/PATCH /me (self-scoped whitelist — role/email untouchable) + POST /me/password
- changeOwnPassword: current password required; every OTHER session dies in the
  same txn, the changing session survives; audited without hashes
- staff_user.phone/title additive columns, surfaced on Employees (columns + edit
  dialog) and the new Profile page; user chip in the shell opens /profile
- 7 tests; suite 331/331 across 64 files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise e612c5cf81 feat(hq-web): standalone Reminders page (go-live WS-C, phase 3)
- /reminders page: Queued/Sent/Failed/Dismissed chips mapping to ?status=,
  managerial-only Mine/All toggle via ?owner= (staff are server-forced to
  their own rows), kind/client/doc/due/status table with Send/Preview/
  Dismiss on open rows, honest server-driven pager (rule 8).
- Extract the Dashboard queue actions into shared
  components/ReminderQueue.tsx (QueueActions, RULE_LABEL, reminderTone)
  with a re-entry guard on Send/Dismiss; Dashboard queue card links
  "View all" to /reminders.
- api.ts: getReminders now takes { status, owner, page, pageSize } and
  returns the full QueueItem rows + page metadata; login stores the
  staff id (hq.id) so Mine can narrow by owner.
- Nav item under WORK + route; no backend changes. A contract test pins
  the sent/dismissed chips, ?owner= narrowing and cross-chip staff scope
  at HTTP level (apps/hq/test/reminders-page.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 584fb291f8 feat(documents): paginated register with honest total + Documents page (Phase 2, WS-D)
listDocuments now returns { documents, total, page, pageSize } (default 50,
cap 200, newest first) with the client name joined server-side — no N+1.
GET /documents passes page/pageSize through; type/status/clientId filters
compose against the same WHERE for both count and page (rule 8: no silent
caps). clientLedger walks the paginated repo in 200-row batches so the
ledger still surfaces every document. New /documents screen: type chips,
status filter, mono doc numbers, amount column, pager honouring the server
total; rows open /documents/:id. Nav entry under WORK (exact-match so New
Document keeps its own highlight).

Note: the GET /documents route hunk itself landed via 8861b46, swept into
a concurrent commit from the same working tree; this commit carries the
repo/ledger/web halves and the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 8861b46afd feat(settings): configurable default share-link expiry (owner, audited)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 days ago
Thomas Joise d28bca5cf0 fix(settings): validate whole reminders PUT body before writing any setting
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 days ago
Thomas Joise 36fcf795a8 test(documents): assert due-date stamp on the recurring-generation issue path
Closes the WS-A coverage gap: due-dates.test.ts proved the manual-issue and
convert paths but relied on code inspection for recurring generation. A manual
recurring plan now runs through runDailyScan and the generated invoice's
due_date is asserted as doc_date + seeded 15-day terms. Suite 310/310.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise bc140fc54c feat(documents): invoice due dates — terms default + override, honest overdue chase (Phase 1, D18)
- document.due_date additive column; billing.payment_terms_days seeded (15)
- issueDocument stamps doc_date+terms on INVOICE when absent — covers manual
  issue, convert-and-send and recurring generation in one place
- drafts accept dueDate (INVOICE only, validated); composer date field
  auto-filled from GET /settings/billing; invoice PDF prints Due Date
- overdue scan + reminder anchor on COALESCE(due_date, doc_date); email says
  'was due on X and is now N day(s) overdue' when a due date exists
- legacy fixtures pinned to due_date=NULL (sanctioned: they test the doc_date
  anchor); 9 new tests; suite 309/309 across 60 files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise aa05060181 feat(settings): reminder day-settings + append-only dated schedule endpoints (owner, audited)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 days ago
Thomas Joise 953cf1dff8 feat(modules): module→client roster, notify-all, CSV export (Phase 10, spec §10)
- listClientsByModule: reverse lookup over active links, ordered by client name,
  paginated with a true total; per-row dated price via priceOn + next renewal;
  footer revenue total spans ALL links (active=0 excluded by design, stated)
- GET /modules/:id/clients (paginated) + /clients.csv (streams every row, no cap)
- POST /modules/:id/notify (owner/manager): resolves the full roster first, sends
  via the Gmail path, one email_log + one audit row per attempted recipient,
  names every unreachable client in the response (warn, never truncate)
- Modules page roster panel: table + revenue footer + Notify-all composer + CSV
- 8 tests; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 4a1747958e feat(reminders): invoice dN milestone escalation from the dated schedule (Phase 9, spec §8)
- invoice_overdue scan: monthly bucket → d7/d15/d30 milestones resolved via
  resolveSchedule (dated config, rule 3); each fires once via the idempotency key
- F7 catch-up guard: scan gaps and the monthly→dN cutover enqueue only the
  highest crossed milestone, never the whole ladder
- ctx.daysOverdue populated in reminderContext (A7: anchored on doc_date) so the
  'N day(s) past due' template text renders; injectable today for determinism
- daysBetweenIso helper; 5 tests; suite green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise fffcc2a05d feat(documents): one-click convert-and-send + proforma supersede (Phases 7-8, spec §8)
- POST /documents/:id/convert-and-send: guards before any write (proforma-only,
  Gmail alive, recipient resolved), ONE txn convert+issue, email strictly after
  commit — failed send never rolls back the issued invoice (warning instead)
- supersedeProforma: cancel (number stays consumed) + fresh linked PROFORMA
  draft carrying the payload verbatim; INVOICE hard-rejected to credit-note path
- POST /documents/:id/supersede route; DocumentView gains both buttons
- 10 tests; suite 280 green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 400abc224f fix(funnel): review fixes — stop-chase on convert/cancel/lost, scoped ?status=, send guards, F3/F4 convert hardening
Review findings on the quote-to-close slice, each verified against the code:

- convertDocument: ANY conversion now flips the source to 'invoiced' (previously
  only ->INVOICE), so a QT->PI quote leaves the daily scan's 'sent' set and the
  pipeline's Quoted/Waiting — the chase never resumes after convert (spec 7 stop
  conditions). Scan additionally excludes quotes with a live forward child
  (covers rows converted before this fix) and quotes of lost clients.
- convertDocument rule-4 guard (spec 8 F4): a live non-cancelled forward child
  blocks re-convert — one sale, one legal document; cancelling the child
  re-opens the path. Rule-2 fix (spec 8 F3): ->INVOICE recomputes the carried
  lines through computeBill on the invoice's own doc_date (mirrors
  createCreditNote); QT->PI stays copy-only (both non-legal).
- cancelDocument dismisses a cancelled quotation's open follow-up nudges in the
  same transaction, per-row audited — dead paper is not chased.
- GET /reminders?status= no longer bypasses the owner scope and pagination: the
  status param narrows the same scoped listQueue view. Web badge (Layout) now
  uses the honest scoped total instead of an unscoped flat list length.
- listQueue/queueCounts staff scope keeps doc-less reminders (renewal_due,
  amc_expiring, follow_up, email_bounced) visible — they have no derived owner
  and are shared work; previously they vanished for every staff viewer.
- sendReminder resolves the recipient BEFORE minting a public share (a send
  that can never succeed leaves no live artifact) and refuses quote_followup
  sends loudly when share.base_url is unset instead of emailing a dead relative
  link. The scheduler auto-drain parks a hard-failing send as 'failed' with its
  reason instead of crashing the scan.
- reminderContext takes an injectable today (threaded from deps.now in the send
  path) so dated schedule text resolves deterministically.
- Pipeline: quote-less lost clients (client.status=lost) now appear under
  filter=lost (spec 9); Dashboard queue table marks first-page truncation
  honestly (rule 8).

Typecheck clean; 270 tests green (255 before, +15 covering each fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise c45b5692be feat(reminders): escalating quote follow-up (Phase 6, spec §7)
- reminder.rule_kind gains 'quote_followup': SCHEMA widened + rebuild-once
  migration via the shared rebuildTable helper (UNIQUE idempotency key and
  status/policy_applied CHECKs preserved verbatim; guarded, idempotent)
- runDailyScan: new scan block over sent quotations anchored on the first
  'sent' event; dated day offsets from resolveSchedule; catch-up guard fires
  only the single highest crossed milestone; policy from setting
  quote.followup.policy (default manual); auto sends drain strictly after
  the scan writes commit, at-most-once via the unique key
- reminderContext resolves (never mints) a live share and dated subject/body;
  graceful wording when the quote has no docNo; minting happens ONLY in the
  send path with a ~60-day expiry, reusing a live share
- STOP cleanup: markStatus(accepted|lost) and convertDocument dismiss open
  quote_followup rows per-row (each audited) in the same transaction
- listQueue: paginated with honest total, owner-scoped via
  doc_id -> document.created_by, labelled rows; dashboard + /reminders wired

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise eaecaedd3f feat(pipeline): cross-client chase-list — derived stages, schedule-driven bands, role-gated, paginated
Phase 5 (D-PIPE option B, spec §6b/§9): repos-pipeline.ts listPipeline unions the
latest non-cancelled QUOTATION per client with bare leads (Enquiry); stage is never
stored; age anchors on the first 'sent' document_event; next action + colour band
read resolveSchedule('quote_followup') day_offsets (no hardcoded 3/7/14); staff are
server-forced to their own rows via ownerScope; Lost hidden unless filtered; sorted
actionable-oldest-first with LIMIT/OFFSET + honest total. GET /pipeline route plus
the Pipeline page (filter chips, managerial owner dropdown, band row tones, lifecycle
action buttons, pager) wired into nav right after Dashboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 56b00b60c6 fix(reminders): deterministic tie-break in resolveSchedule on equal effective_from
ORDER BY effective_from DESC alone picks an engine/plan-dependent winner
when two reminder_schedule rows for the same rule_kind share an
effective_from (a same-day cadence correction under rule 3's
new-row-never-edit makes this likely). Append id DESC — UUIDv7 ids are
time-ordered, so the latest insert wins deterministically on SQLite and
Postgres alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 5865aafe06 fix(employees): purge sessions on password reset; reject empty patches and duplicate emails cleanly
Review findings on the Phase 1-2 employee slice:

- setEmployeePassword now deletes the employee's sessions in the same
  transaction — resetting a suspected-compromised password must not leave
  the attacker's existing 14-day bearer token valid (deactivateEmployee
  already did this; the reset path is the natural lockout action).
- updateEmployee throws 'Nothing to update' when the patch resolves to
  zero recognized fields, instead of returning 200 and writing a no-op
  before==after audit row; PATCH /employees/:id with {} or wrongly-typed
  fields now 400s.
- createEmployee pre-checks UNIQUE(email) inside the transaction and
  throws 'Email already in use' instead of surfacing the raw
  engine-specific 'UNIQUE constraint failed: staff_user.email' text
  (would differ on Postgres, the locked production engine — D15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise dc3d651b08 feat(reminders): dated reminder_schedule table + resolveSchedule resolver (Phase 4)
reminder_schedule (id, rule_kind, effective_from/_to, day_offsets CSV,
subject, body) added to SCHEMA (CREATE TABLE IF NOT EXISTS, exec'd on every
open = the additive migration path). resolveSchedule(db, ruleKind, today)
picks the row active on the business date (from<=today, to NULL or >today,
latest from wins) with code-constant fallbacks (quote_followup 3/7/14 +
spec s7 subject/body; invoice_overdue 7/15/30). seedIfEmpty seeds one
open-ended dated row per kind, audited in the same transaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise efdb34e710 feat(clients): account-owner plumbing — client.owner_id, audited owner route, role gate
Phase 3 of the quote-to-close funnel spec: client.owner_id added additively
(SCHEMA + PRAGMA-guarded migrate) so a bare lead is routable to an employee;
setClientOwner repo fn (validates active employee, audits set_owner in-txn);
PATCH /clients/:id/owner gated inline to owner/manager; shared ownerScope role
gate (staff forced to self, widening ignored) for later phases; ClientDetail
gains an owner/manager-only Account owner dropdown fed by GET /employees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise d4388c4f63 feat(employees): management surface — /employees API routes + owner-only Employees page
Phase 2 of the quote-to-close funnel slice. Six /employees routes in
apps/hq/src/api.ts (GET requireAuth for the picker; POST/PATCH/deactivate/
reactivate/password owner-only, acting as the signed-in user); Employees
page (table + add/edit/reset-password forms, email immutable on edit,
count shown, no truncation); owner-only nav item beside Document Template;
typed client fns in hq-web api.ts. 6 new route tests (192 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 1af7b87aab feat(hq): employee foundation — manager role, employee repo, session hardening (phase 1)
- rebuildTable helper: guarded, txn-wrapped CHECK-constraint rebuild (SQLite-only path)
- staff_user.role CHECK widened to owner|manager|staff (SCHEMA + migrate for old DBs)
- repos-employees.ts: list/get/create/update/setPassword/deactivate/reactivate,
  audited in-txn, password columns never selected, last-active-owner + self guards
- verifySession requires active=1; deactivation purges sessions in the same txn
- 9 tests (apps/hq/test/employees.test.ts); suite now 186 green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 days ago
Thomas Joise 25b96e2514 feat(hq): attachment download filename on GET /documents/:id/pdf?download=1
Serve the document PDF as an attachment with a doc-no-derived filename
("QT/26-27-0001" -> "QT-26-27-0001.pdf", null docNo -> "draft.pdf") when
?download=1 is present; keep the default inline so the composer iframe
preview still works. renderPdf is now injectable into apiRouter so the
header logic is tested without launching Chrome (house DI pattern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise 63e377d649 feat(hq): public GET /share/:token document view route
Mount the one unauthenticated read route OUTSIDE the /api auth router.
A live token resolves via validateShare to exactly one document, rendered
inline (application/pdf) through the existing documentHtml -> renderPdf path;
unknown/expired/revoked tokens return a plain 404 "link expired or invalid"
HTML page carrying no document data. Adds a dependency-free per-IP fixed-window
rate limiter to blunt token brute-forcing. renderPdf is injected so the route
is TDD-tested without launching Chrome. Public reads write no audit; the token
is never logged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise a1759864bf feat(hq): shareable document link backend (mint/validate/revoke/list)
Add document_share table (CREATE TABLE IF NOT EXISTS; opaque token, optional
expiry, revoke flag) and repos-shares.ts:

- mintShare: 256-bit randomBytes hex token (64 hex, unique), default +30d expiry,
  audited — the token itself is never written to the audit trail or any log.
- validateShare: resolves a live token to its one document; null on
  unknown / revoked / expired (ISO-8601 UTC lexical compare).
- revokeShare: flips revoked=1, audited.
- listShares: a document's shares, scoped and newest-first.

Wire POST /api/documents/:id/share and
POST /api/documents/:id/share/:shareId/revoke (requireAuth, document-scoped
share guard) and surface shares on GET /api/documents/:id. TDD: 14 tests cover
token shape/uniqueness, validate rejections, revoke, and audit-without-token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise d29025189c feat(hq): beautify document template — "Slate Ledger" visual system + template.accent
Rewrite documentHtml to a restrained, print-first GST layout: hairline table
rules, tabular money aligned to the paisa, a single accent driving every
structural color (letterhead rule, title, table-header rule + color-mix tint,
docmeta edge, amount-in-words edge, filled grand-total band).

Add optional template.accent setting (validated hex-or-fallback #334155
slate-indigo) read from the settings map and interpolated only after
validation, so a raw setting string can never be injected into the CSS.
print-color-adjust:exact so the accent tint and grand-total fill survive
puppeteer. DRAFT stays: accent chip in docmeta + optional watermark for
un-issued docs. lineRow/totalsRows untouched; @media screen paper + @page
14mm preserved; renderer stays one pure function so preview===pdf identity holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise 52e8a03c81 feat(hq): GET /reminders/:id/preview — exact email via shared reminderContext
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise 48731fdb2b feat(hq): GET/PUT /settings/template + logo upload (owner-gated, audited)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise c2f94df06d feat(hq): screen paper styles + documentHtmlSample + POST /previews/sample
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago
Thomas Joise a948a92350 feat(hq): render editable template.* letterhead fields with default fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 week ago