Compare commits

...

50 Commits

Author SHA1 Message Date
Thomas Joise 7e139d91b8 docs(deploy): record go-live (D39) and correct the deploy references
deploy/PATHS-AND-NAMES.md now reflects reality: Docker Compose V2 isn't
installed on the box (raw docker build/run instead), DB connection lives in
db-pg.ts not server.ts, real password is committed (accepted, not a
CHANGE_ME_PASSWORD placeholder). docs/DEPLOY-HQ.md marked superseded — it
described an RDS/GoDaddy/.com plan that isn't what shipped. STATUS.md and
06-DECISIONS.md (D39) record the actual go-live: real data carried over from
local Postgres (not "born empty + APEX import"), and the npm/cli#4828
Dockerfile fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 day ago
Thomas Joise 8456e45206 fix(deploy): drop package-lock.json before container npm install
Windows-generated lockfile hits npm/cli#4828 - Linux container build fails
resolving @rollup/rollup-linux-x64-gnu even on a fresh npm install. Removing
the lockfile before install forces same-platform dependency resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 day ago
Thomas Joise 12e95e7be4 docs: record known issues, blocked work and unverified areas; untrack agent scratch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 551c9e3d30 merge: readable linked payment/document chip + SMS alert loading/error state 1 day ago
Thomas Joise a267cd07e6 merge: readable linked payment/document chip + SMS alert loading/error state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/hq-web/src/pages/Dashboard.tsx
1 day ago
Thomas Joise 23b0b64de2 merge: script DATABASE_URL docs + SQLite opt-in, always-stamp install/train dates, keep links on re-tick, backup resolves prod DB
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 0adce5844b fix(backup): resolve the production database the way the server does
`npm run backup` ran scripts/backup.mjs, which read process.env.DATABASE_URL
directly and exited when it was unset. On the production box DATABASE_URL is NOT
exported -- the connection is resolved (D19) -- so the backup command could never
reach the real data.

The dump now lives in apps/hq (src/backup.ts + a thin scripts/backup.ts CLI, the
same split the other maintenance scripts use, so it is typechecked and unit
tested) and resolves its target through the shared resolveDbUrl. It logs the
resolved host/db -- never the password -- before pg_dump runs, and a SQLite
outcome is a loud error naming backup-hq.sh instead of a dump of nothing.
Behaviour otherwise unchanged: -Fc dump under ./backups (or HQ_BACKUP_DIR),
rotation to the newest HQ_BACKUP_KEEP (default 14), same pg_dump discovery.

The .mjs entry could not import the TS resolver, and duplicating the resolution
would have re-introduced the copy of the production connection string that
6f50799 deliberately removed -- hence the move to TS. `npm run backup` is
unchanged for the operator; prod already runs `npx tsx apps/hq/scripts/...` for
maintenance (see DEPLOY-HQ.md), so tsx is on the box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 4c1514c315 fix(milestones): stamp installed_on/trained_on on every tick, keep links on a re-tick
Two bugs in the same tick path.

1. installed_on / trained_on were written only as a side effect of a status
   UPGRADE. The ~300 APEX-imported projects are already at 'live' -- the furthest
   status -- so ticking Installation or Training upgraded nothing and therefore
   recorded nothing, and the UI's own date writers were removed in the Client
   Detail redesign, leaving those columns unfillable and the Module Directory's
   "Installed" column blank forever. The date is now stamped whenever the step is
   ticked, independent of any status move: same transaction, same audit payload,
   still write-once (an existing date is never overwritten) and still no downgrade.
   A tick that changes neither status nor a date now writes nothing at all.

2. setMilestone wrote payment_id/document_id as `done ? linked : null`, so any tick
   that did not resend the ids cleared them. bulkSetMilestone routes through it and
   never passes ids (one key, many projects), so a single bulk re-tick silently cut
   every project's step loose from the payment or quotation it had been linked to.
   An already-done step now keeps the link it carries when nothing is supplied.
   An explicit empty id is still an unlink, and unticking still clears both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 77f135f0e2 fix(scripts): document the DATABASE_URL-prefixed invocation, make SQLite an explicit opt-in
apply-onboarding-pipeline.ts documented its own command with no `DATABASE_URL=`
prefix -- the only hardened data script missing it. Followed literally on the
production host it resolves to SQLite, not the real database. Header corrected to
match its siblings.

The deeper problem is that landing on SQLite was a DEFAULT, not a decision: with no
DATABASE_URL, resolution quietly returned '' and the run was only saved by the
"never CREATE a database" guard -- which passes as soon as any stale ./data/hq.db
exists under the operator shell's cwd. requireDbUrlForEnv now refuses a SQLite
outcome unless it was explicitly asked for (HQ_DB_TARGET=sqlite, a --sqlite flag,
or HQ_DATA_DIR=:memory:), and the error names both escape hatches. The create guard
is unchanged and still applies after the opt-in. server.ts calls resolveDbUrl
directly, so first-boot DB creation is untouched.

All five maintenance-script headers now state the SQLite opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 367fc0324e fix(dashboard): never report "all good" when the fetch failed
The SMS low-balance alert rendered only when the request succeeded with
lowCount > 0, so a FAILED check looked exactly like "no client is low" --
the dangerous direction. It now has three distinct states: a skeleton
while the check is in flight, an ErrorState with retry when it fails, and
a quiet positive caption only for a loaded, genuinely-empty result (which
also spells out `unknownCount`, since balances the gateway could not
return are not "fine" either).

Same three-state audit on the rest of the page: the "Onboarding stalled"
tile had the error case fixed already but still showed count 0 and
"Nothing stalled." while its fetch was in flight -- it now takes a
`loading` prop (new on Card) and shows a skeleton, and its count appears
only once the list has loaded. Every other bento card is fed by the one
dashboard payload, whose loading/error/empty states are already
distinguished at page level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise b32f7c32c8 fix(client-detail): make the linked payment/document reference legible
The status-line reference under a done step was `white-space: nowrap` +
`overflow: hidden` inside a 104px node, so the linked payment's amount --
the only reason the chip exists -- was ellipsised away, and the `title`
that could have revealed it held static text instead.

- the reference now wraps (up to two short lines) rather than clipping;
  it grows the node downwards only, so the track keeps its 112px columns
  and its horizontal scroll on narrow screens is unchanged
- `title` + `aria-label` now carry the real detail: payment date, amount
  and reference / document type, number, date and payable
- the document chip (quotation_sent), which clipped the same way, is
  resolved against the ledger too and shows the document number instead
  of the static "view document ->"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 1717900e02 merge: restore sticky table headers (bounded 70vh wrapper), dashboard loading state, ticket-type over-fetch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise aa541270d6 fix(onboarding): APEX-imported projects were invisible on the stalled tile
client_module.created_at was added and stamped in assignModule, and
stalledProjects now correctly excludes no-tick projects whose created_at
is NULL — but the APEX importer (the path that created the real ~300
clients' projects) inserted client_module WITHOUT created_at. Every
imported project with zero ticks was therefore permanently invisible on
the "Onboarding stalled" tile, exactly the population it exists for.

Three parts:
- Stamp created_at in the APEX importer's client_module insert.
- Backfill existing NULL rows from installed_on where known (both
  db.ts migrate() for SQLite and migrations-pg.ts for Postgres); never
  fabricate a date when installed_on is also unknown.
- stalledProjects now returns { rows, unknownAgeExcluded } instead of a
  bare array, so projects excluded for unprovable age are counted, not
  silently dropped. GET /projects/stalled responds with
  { ok, projects, unknownAgeExcluded }; UI wiring left to the parallel
  agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 4de152a7f4 fix(ui): restore sticky table headers, dashboard loading flash, ticket-type over-fetch
- packages/ui/src/tokens.css: bound .wf-table-wrap to a viewport-derived max-height
  and matching overflow-y so it's a real scroll container again. overflow-x: auto
  alone left overflow-y at its computed "auto" (per the CSS overflow spec, a
  visible/non-visible axis pairing promotes visible to auto) with no height bound,
  so its scrollTop was permanently 0 and table.wf th's `position: sticky` never had
  anything to stick against — the header just scrolled away with the page on every
  table screen. Chose the bounded-wrapper fix (over deleting the dead sticky rule)
  since it keeps the header pinned as advertised; short tables render unchanged,
  long ones scroll inside the box exactly like the existing .dash-list pattern.
  Also moved the .wf-table-wrap rule out of apps/hq-web/src/app.css into tokens.css
  next to the other table.wf rules, since it's part of the @sims/ui component.
- apps/hq-web/src/pages/Dashboard.tsx: the "Onboarding stalled" card treated
  "still loading" the same as "genuinely empty", flashing "Nothing stalled."
  before the fetch resolved. Card now takes a loading prop and shows the usual
  skeleton placeholder while pending; the positive empty copy and error state
  are unchanged.
- apps/hq-web/src/pages/ClientDetail.tsx: getTicketTypes was fetched unconditionally
  on every Client 360 load for the tickets tab's Type column. Gated it behind the
  tickets tab being active (moved the tab computation earlier so it's available to
  gate the loader); NewTicketDialog's own getTicketTypes call for the new-ticket
  pre-fill is untouched.
- STATUS.md: prose under the verification table still said "The 416 figure..." while
  the table itself says 528 — updated the prose to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 591d0e9b59 fix(onboarding-migration): idempotency check must ignore ad-hoc custom_* rows
alreadyNew was set false for EVERY row whose key wasn't a template key,
including custom_* rows that the same function deliberately preserves
verbatim. So a project with even one ad-hoc "+ Add step" entry was
never "identical" and got DELETE-and-rebuilt on every single run (new
row ids, a fresh audit row each time) — contradicting the documented
"safe to re-run / a second run does nothing".

Idempotency is now computed over non-custom rows only, using the same
custom_* discriminator addProjectMilestone's key generation relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise ee0367dfb5 fix(db-pg): make requireDbUrlForEnv guard outcome-based, not NODE_ENV-gated
The guard only threw when NODE_ENV==='production' AND resolution came
back empty. But NODE_ENV is exactly what isn't reliably set for the
maintenance scripts this guards (they're run by hand from an operator
shell with no service-definition env and no dotenv), so on the real
prod box the guard never fired: resolution silently returned '', the
script opened a brand-new empty SQLite file under <cwd>/data, and
printed a false "0 project(s) rebuilt" success.

Now the guard fires on the OUTCOME instead: whenever resolution
selects SQLite, refuse to run unless the target file already exists —
a migration/maintenance script must never CREATE a database. Applies
only to requireDbUrlForEnv (the script entry path); server.ts calls
resolveDbUrl directly and is untouched, so first boot still creates
its DB as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 6f50799d3c test(db-resolve): remove duplicated production password from test file
The test never connects to Postgres, it only asserts resolution
behaviour, so the real prod URL had no reason to be duplicated here.
Swapped for a dummy postgres://u:p@h:5432/db throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise b1e1420b4c docs: refresh test count (528)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise a0225b2ba3 merge: frontend audit fixes + UI completion (status-line links, tickets guards, dashboard error state)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise f66bab122b fix(scripts): harden apply-ltd-numbers/apply-name-fixes/import-employees DB resolution
Same pre-ship audit fix as apply-onboarding-pipeline.ts and migrate-onboarding-
templates.ts: replace the raw `DATABASE_URL ?? ''` read with the shared
requireDbUrlForEnv() resolver, log the resolved engine/target before mutating,
hard-fail instead of silently opening a throwaway SQLite file in production, and
close the DB handle in a finally block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 0c3f6c2e3a fix(client-360): audit fixes for onboarding status line, tickets, dashboard
Pre-ship audit swarm findings on the Client Detail redesign:

- StatusLine's isPaymentStep now also matches the legacy advance_paid/
  balance_paid keys (the generic fallback template's vocabulary on modules
  with no seeded per-module tail — WHATSAPP, ATM, AMC, ...), so ticking the
  payment step there opens the picker instead of silently stamping a date.
- Every milestone-tick path (plain toggle, payment link, credentials,
  document link) now reloads the client-modules list so the module block
  header, summary table and status select never go stale after a tick.
- A done payment/document milestone step now surfaces its link (linked
  document -> /documents/<id>; linked payment -> date/amount from the
  loaded ledger) instead of showing no evidence the link landed.
- Tickets: guard TICKET_STATUS_LABEL lookups against non-status chip
  values (e.g. the synthetic "overdue" chip) that were throwing and
  blanking the whole console.
- Dashboard: distinguish a failed "Onboarding stalled" fetch from a
  genuine empty result instead of reporting "Nothing stalled." on error.
- Client 360 tickets tab: add the Type column, same rendering as the
  Tickets desk.
- Drop the redundant read-only status badge next to the editable
  ClientModuleStatusSelect in the module block header.
- Only mount NewTicketDialog while actually open, so it stops firing
  getModules/getTicketTypes on every Client 360 load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 9457b0f145 fix(milestones): fresh projects false-flagged as stalled, fallback modules stuck at quoted, bulk ops skipped status/link logic (FIX D, E, F)
FIX D: stalledProjects treated a no-tick project's missing done_on as the
sentinel '0000-00-00', always older than any cutoff, so every
freshly-assigned project immediately showed up on the "Onboarding stalled"
tile. It's now measured against client_module.created_at (stamped by
assignModule); when that's also unknown (predates the column, or an import
path that doesn't stamp it) the project is excluded rather than assumed
ancient.

FIX E: STEP_STATUS only mapped the new template's keys, but TAIL_FALLBACK
(any module with no seeded/owner-configured tail -- WHATSAPP, ATM, AMC, ...)
still uses the legacy 'installed' key, so ticking "Installation done" on
those modules left status at 'quoted' forever. Added installed->installed
to STEP_STATUS (checked: no legacy payment key needs a mapping, since
payment never drove status even in the new vocabulary).

FIX F: bulkSetMilestone did a bare UPDATE, so a bulk go_live never advanced
client_module.status and a bulk untick never cleared a stale
payment_id/document_id link. Each id now routes through setMilestone itself
(same status-advance + link-clearing path a single tick uses), inside the
existing transaction -- nested transaction() calls become savepoints on
both engines, so a project without the milestone key is caught and skipped
without losing "bulk ops only touch projects that have the milestone".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise e8baa1111e fix(onboarding-migration): deterministic survivor when two old ticks collapse onto one key (FIX C)
advance_paid/balance_paid (and the interim advance_payment/balance_payment)
all collapse onto the single new payment_received key, and the carry used
Map.set -- whichever row was visited second silently overwrote the first's
done_on/payment_id, counted as neither carried nor dropped, with the audit
`before` left `undefined` so the loss was unreconstructible.

pickSurvivingTick() now picks deterministically (prefers the tick with a
real linked payment_id; otherwise the later done_on), the losing tick is
counted in a new `collapsed` field, and the audit `before` payload is now
the full pre-migration row set instead of `undefined` -- any discarded tick
is reconstructible from the append-only log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 04aef0aa36 fix(api): validate ?days= and guard the onboarding-project routes against a crash (FIX B)
GET /projects/stalled had no try/catch, and a caller-supplied ?days= flowed
straight into `new Date(...).toISOString()`. Confirmed the exact crash:
Number('1e300') passes Number.isFinite (so the old guard let it through),
but the resulting cutoff math overflows the valid Date range and
.toISOString() throws RangeError. Uncaught in an async Express handler this
is an unhandled rejection, which kills the whole process on Node 22 -- any
authenticated role, including plain staff, could take the console down with
one GET.

`days` is now validated (integer, 0-3650) with an explicit 4xx on anything
hostile, falling back to the configured default only when the param is
absent (matches the frontend, which never sends ?days= at all). Audited
every other route this feature added to the same section: /projects/board
and /projects/pending/:key had the identical no-try/catch shape and are now
wrapped too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 31335cd4ad fix(deploy): one shared DB-engine resolver so scripts can't silently target a throwaway SQLite file (FIX A)
apply-onboarding-pipeline.ts and migrate-onboarding-templates.ts resolved the
engine as `DATABASE_URL ?? ''`, diverging from server.ts's production
fallback. On the prod box (DATABASE_URL unset) each script opened a brand-new
empty SQLite file, "succeeded" against it, and Postgres was never touched.

resolveDbUrl()/requireDbUrlForEnv()/pgTargetDescription() now live in
db-pg.ts as the one place this is decided; server.ts and both scripts call
it. The scripts additionally hard-fail (never fall back to SQLite) when
NODE_ENV=production resolves nothing, log their target engine before doing
any work, and close the DB handle in a finally.

Also lands client_module.created_at (nullable, additive) — schema groundwork
FIX D uses to measure a no-tick project's age from its real creation date
instead of treating a missing done_on as infinitely old.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 0bcbf8d410 fix(responsive): stop desktop tables and mobile rows from silently clipping content
Red-team pass on the redesigned Client 360 / Tickets / Dashboard / Modules screens found
real overlap/clipping bugs, all fixed:

- Client header still broke at 768px (tablet): the button cluster only stacked below the
  name under 640px, so at 768 the name got squeezed to ~38px and force-wrapped mid-word.
  Bumped the stacking breakpoint to 860px.
- Per-module Documents rows and the Payments Outstanding-panel rows were plain nowrap flex
  rows with no scroll; at 375px the amount/status badge silently clipped past the card
  edge with no way to reach them. Both now wrap.
- Biggest one: every DataTable silently lost its rightmost columns at desktop width
  (>=901px) — table.wf's display:block/table toggle assumed overflow-x:auto still worked
  once display flipped to table, but Chromium computes overflow-x as visible on a
  display:table box, so wide tables (Tickets' 11 columns) overflowed the viewport with no
  scrollbar. Fixed at the root: DataTable now wraps its <table> in a .wf-table-wrap scroll
  container instead of relying on the table element to scroll itself.

Verified against screenshots at 375/768/1440, light+dark, before and after; typecheck and
the full test suite (498 passed) stay clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise b146de990f fix(scripts): audit + guard apply-onboarding-pipeline's setting writes
Final whole-branch review FIX 2.

The live-deploy script upserted the onboarding template settings with raw
SQL (ON CONFLICT DO UPDATE), bypassing setSetting -> no audit row, violating
the append-only-audit rule -- and it unconditionally overwrote templates an
owner may have already edited in the Modules onboarding-template editor,
making the "Idempotent" header comment false and dangerous for a script that
runs against ~167 real projects.

Upserts now go through setSetting(db, 'system', key, value) (audited), and
only write a key that is still absent, unless run with an explicit --force
argument (documented). Header comment corrected to say idempotency holds
only while the templates haven't been owner-edited, and that --force
overwrites unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 4ee8f17b37 fix(onboarding-migration): preserve ad-hoc custom steps across the template swap
Final whole-branch review FIX 3.

migrateOnboardingTemplates previously DELETEd every project_milestone row and
reinserted only template keys, so a project-specific step added via
addProjectMilestone ("+ Add step", custom_* keys) vanished with no trace if
un-ticked, or was miscounted as `dropped` if ticked.

Old rows are now partitioned before the DELETE into template-mapped vs
ad-hoc custom (not a template key, not in KEY_MAP, and not a known old
generic FRONT_FALLBACK/TAIL_FALLBACK key like amc_start — those keep their
existing drop behavior). Custom rows are re-inserted unchanged after the
template block, sort continuing past it, preserving
label/done/done_on/payment_id/document_id. The return type and the audit
`after` payload both gained a `preserved` count/list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise e8e2a202c1 fix(milestones): reject front/tail key collisions; dedupe on collision; stamp installed/trained dates
Final whole-branch review FIX 1 + FIX 5.

- setFrontTemplate / setModuleTail now reject a key that collides across the
  front/tail halves (checked against the default tail, every configured
  per-module tail, and the shared front respectively) instead of letting
  milestoneTemplate silently compose a duplicate-key list.
- ensureProjectMilestones adds each inserted key to its own `existing` set
  inside the loop, so a template that still ends up with a duplicate key
  (a pre-existing bad setting) is deduped instead of hitting the
  UNIQUE (client_module_id, key) violation a second time.
- GET /client-modules/:id/milestones now wraps ensureProjectMilestones +
  listProjectMilestones in try/catch -> 400, matching its POST sibling,
  instead of an unhandled async rejection that hung the request.
- advanceStatusForStep now stamps client_module.installed_on / trained_on
  from the triggering milestone's own done_on the first time status
  advances into 'installed' / 'trained' (never overwriting an existing
  date), replacing the UI date-writers the Client Detail redesign removed
  without a replacement. Reflected in the same audit row as the status
  change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 7494f53e2f fix(client-detail): payment step gate matches shipped key payment_received
The status-line payment picker only fired for advance_payment/balance_payment,
which no shipped template contains (the composed tails and the migration both
standardise on payment_received) — so payment linking was unreachable and
project_milestone.payment_id was never written by the UI. Keeps the legacy keys
for projects not yet migrated. Also refreshes the stale STATUS test count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise ac16d8c896 merge: frontend — quotation-step dialog, ticket types + deep-links, cleanup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 3ac4e2a47d merge: backend — quotation-step document link + ticket classification
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 7838ced6c2 feat(client-detail): quotation-step document link + ticket classification (backend)
Backend half of the Client Detail redesign follow-ups (#7, #6, #4).

#7 — quotation step links its document
- project_milestone gains a nullable `document_id`, landed on BOTH engines:
  a PRAGMA-guarded ALTER in db.ts migrate() and pg migration 016.
- setMilestone(..., doneOn?, paymentId?, documentId?) validates the document
  belongs to the same client as the project (mirroring the paymentId check),
  stores it on tick and clears it on untick; Milestone gains `documentId`.
- POST /client-modules/:id/milestones accepts `documentId`.
- migrateOnboardingTemplates carries document_id across the template swap so a
  linked quotation is not lost.

#6 — ticket classification
- ticket gains `type TEXT NOT NULL DEFAULT 'service'` (db.ts SCHEMA + guarded
  ALTER, pg migration 017); imported APEX rows fall to the default.
- Config over code: the list is the `ticket.types` setting (seeded on first
  boot) resolved by ticketTypes(), with TICKET_TYPE_FALLBACK in code.
- GET /tickets/types; create/update accept + validate `type`; list rows carry
  it and GET /tickets?type= filters (blank/omitted = every type).

#4 — cleanup
- Refreshed the stale file comment atop repos-milestones.ts and the
  project_milestone comment in db.ts: both now describe the composed
  front + per-module tail model (they still described the old flat template).
- Migration tests for the interim keys advance_payment / balance_payment ->
  payment_received, plus the document-link carry.

npm run typecheck clean; npm test 473 passed / 5 skipped (was 453/5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 357735e04d feat(client-detail): quotation step links a document; tickets get a type
Frontend half of two Client-Detail follow-ups, plus the StatusLine doc refresh.

Quotation step -> document link:
- Milestone gains documentId; setMilestone threads an optional trailing
  documentId (contract: doneOn, paymentId, documentId).
- Ticking `quotation_sent` on the status line now opens DocumentPickerDialog
  instead of stamping a bare date. It mirrors PaymentPickerDialog exactly —
  same state shape in ClientDetail, same Dialog props — and lists the client's
  QUOTATION/PROFORMA documents straight from the already-loaded ledger (no
  extra fetch), with "Create new document…" handing off to the composer.

Ticket classification:
- Ticket gains `type`; getTicketTypes() reads the `ticket.types` setting
  (config over code) with a code fallback only for the load window.
- New-ticket dialog gets a Type select; Tickets page gets Type filter chips
  wired to the ?type= list param and a Type column.
- Every ticket's module cell deep-links to /clients/<id>?tab=modules, and each
  Client 360 module block gets "Raise ticket for this module", which opens the
  new-ticket dialog pre-filled with that client + module code.

Also refreshes the now-stale StatusLine JSDoc so it describes all three
hand-off dialogs — payment, credentials (account_creation) and document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 88a19f154e chore(client-detail): drop the dead .cdr-out .ofoot CSS rule
The outstanding panel never rendered a footer row — the rule has had no
matching markup since the panel landed. Removing it keeps app.css honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 83633c86ee feat(dashboard): add Onboarding stalled tile
Surfaces stalledProjects() on the Dashboard bento grid, listing active
projects parked past the stall threshold; each row jumps to the
client's Modules tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise d6faf0d5bd merge: Client Detail redesign (D38) — composed onboarding pipeline, status line, payments context, mobile fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	STATUS.md
#	docs/06-DECISIONS.md
1 day ago
Thomas Joise 4e747427d2 feat(onboarding): owner editor for shared front + per-module tail templates
Adds owner-only, audited routes for the two config settings milestoneTemplate
composes (project.milestone_template.front and project.milestone_template:<CODE>),
plus a Modules page section to edit them: staged label rows with add/remove/
reorder/Save, keys auto-derived from labels when blank, uniqueness enforced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 45cfb83d03 chore(deploy): apply prod DB URL + onboarding-pipeline deploy script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 day ago
Thomas Joise 371cdb1c69 fix(ui): drop redundant table-scroll CSS, rely on existing app.css mechanism
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise db8ec9c546 feat(client-detail): prompt for module credentials on account_creation tick
Ticking the "Account creation & registration" onboarding step now opens a
small credentials dialog (mirrors the existing payment-step picker) instead
of a bare toggle. Saving reuses ServiceDataCard's exact patchClientModule
call and managerial password gate, then marks the milestone done; "Skip"
marks it done without touching credentials so the step never hard-blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 0050f73a70 fix(ui): stack record-header actions and scroll wide tables on mobile
RecordHeader's action-button cluster crowded/overlapped the client name on
narrow phones, and DataTable (Tickets, etc.) ran wider than the viewport,
clipping columns with no way to reach them.

Add a <=640px media query: .wf-rec wraps so .wf-rec-actions drops to its
own full-width row below the avatar/name/meta; desktop layout is untouched
outside the query. Wrap DataTable's <table> in a new .wf-table-wrap div
that scrolls horizontally on overflow at that breakpoint (nowrap cells so
the table can exceed 100% instead of being squeezed illegibly), keeping
the page body itself from scrolling sideways.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 025b334b09 feat(client-detail): drop mandatory assign creds, add per-module status control, trim summary table
- AssignModuleForm: remove SMS-forced username/password gate at assign time
  (creds now captured later via module Access -> Edit); reword to "Add a
  module" with a one-line hint on what assigning starts.
- Modules summary table: drop Installed/Completed/Trained columns (dates
  live in the per-module status line); keep Module/Kind/Status/Billed/Settled.
- Each module's collapsible block now has an inline ClientModuleStatusSelect
  next to its status badge, so status is editable without opening the table.
- Payments tab: remove the Outstanding panel's duplicate "Advance on
  account" footer line -- the pre-existing StatCard is the single source.
- Remove RowDate, now unused after the summary-table trim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 78ed947422 feat(payments): outstanding panel + settled-to-document column
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise f1a7eac9dd feat(onboarding): composed milestone templates (shared front + per-module tail)
Supersedes the flat per-module onboarding templates: milestoneTemplate(db, code)
now returns [...front, ...tail]. front is a shared setting
(project.milestone_template.front, FRONT_FALLBACK code default: enquiry ->
visit/meeting -> quotation sent -> quotation approved) prepended to every
module's checklist. tail resolves per-module setting -> global setting ->
TAIL_FALLBACK (the old generic list), unchanged in precedence.

- STEP_STATUS gains quotation_approved -> ordered (never-downgrade guard kept).
- seed.ts seeds the front once plus SMS/RTGS/MOBILEAPP tails (advance/balance
  payment steps collapsed into a single payment_received tail step); no longer
  seeds a bare project.milestone_template default.
- migrate-onboarding-templates.ts KEY_MAP updated so advance_paid, balance_paid,
  advance_payment and balance_payment all carry forward to payment_received;
  its idempotency check holds against the longer composed list.
- Updated seed-templates, milestones-templates, milestone-status,
  migrate-onboarding-templates, milestones and milestone-payment tests to the
  composed content/mappings (TDD: reverted the implementation, confirmed the
  updated tests red, restored the implementation, confirmed green).

Backend + tests only; the live-DB migration run is a separate step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise e8f00503a4 feat(client-detail): per-module documents list + bulk SMS purchase action
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise e5ed3517dc feat(client-detail): onboarding as horizontal status line with payment-step linking
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 343fa94efd docs(client-detail): D38 redesign decision + STATUS in-progress note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise 8c653eb41a fix(payments): exclude draft proformas from outstanding (owner decision)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 days ago
Thomas Joise a9e5aa79d9 docs: bring README/STATUS/CLAUDE/DECISIONS current (D34-D37)
Records the work since the last doc pass: employee master import + invited/first-login
flag (D34), client type PACS/Store + directory-sourced LTD numbers and the name-correction
session (D35), login history with IP/device (D36), and the society category matching the
Kerala co-op directory taxonomy (D37). Adds the login_event table, the Login history
screen, and the client type/category fields; bumps the schema table count 31->32. Decisions
D34-D37 appended to 06-DECISIONS.md.
2 days ago

@ -0,0 +1 @@
sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319

@ -0,0 +1,42 @@
{
"adoptedAt": 1784565886864,
"championId": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
"manifest": {
"schema": "ruflo.proven-config/v1",
"policy": {
"ref": "sha256:6141a8ea990c5063b77e090ae8f37f9c539d8aa8f58dcceb30f3a82f97e57319",
"value": {
"alpha": 0.3,
"subjectWeight": 1,
"mmrLambda": 0.5,
"bodyWeight": 1.5,
"typePenaltyFactor": 0.5
}
},
"layer": "framework/node-cli",
"compatibility": {
"ruflo": ">=3.24.0"
},
"benchmark": {
"corpus": "ADR-081-labelled-v1",
"corpusHash": "sha256:2f700b5c363e20a3bd88ce2bc9b87bbbbaa61732c6177894c6ec37890f888982"
},
"receipt": {
"heldOutDelta": 0.07381404928570845,
"redblue": "PASS",
"drift": 0,
"canary": {
"rollbackRate": 0,
"latencyP95": 244.612458000076,
"costPerTask": 0
},
"receiptCoverage": 1
},
"platform": [
"linux",
"macOS",
"windows"
]
},
"previous": ""
}

4
.gitignore vendored

@ -25,3 +25,7 @@ ruvector.db
# Database backups (D33) — contain plaintext credentials (D32); never commit # Database backups (D33) — contain plaintext credentials (D32); never commit
backups/ backups/
# agent scratch (claude-flow daemon state/metrics)
.claude-flow/
.claude/

@ -18,7 +18,13 @@ WORKDIR /app
# Install all workspaces (also fetches puppeteer's Chromium + better-sqlite3 prebuilt # Install all workspaces (also fetches puppeteer's Chromium + better-sqlite3 prebuilt
# binary for linux-x64), then build the web UI and bundle the server. # binary for linux-x64), then build the web UI and bundle the server.
COPY . . COPY . .
RUN npm install \ # package-lock.json is generated on Windows dev machines; npm's optional-dependency
# resolution has a known bug (npm/cli#4828) where a lockfile from one platform doesn't
# pull in another platform's native binaries (here, @rollup/rollup-linux-x64-gnu) even
# though npm install runs fresh in this (Linux) container. Dropping the lockfile before
# install forces a same-platform resolve.
RUN rm -f package-lock.json \
&& npm install \
&& npm run build --workspace @sims/hq-web \ && npm run build --workspace @sims/hq-web \
&& npm run build --workspace @sims/hq && npm run build --workspace @sims/hq

@ -47,15 +47,35 @@ directory via a multi-agent web-lookup (D35). **Login history** (D36): every sig
owner **Login history** screen; an invited user is sent to Profile to change their default owner **Login history** screen; an invited user is sent to Profile to change their default
password on first sign-in. password on first sign-in.
**Since 2026-07-20 (D38).** Client 360 **Modules + Payments redesign**. Onboarding is now a
**composed status line** — a shared lead front (Enquiry → Visit/meeting scheduled → Quotation sent
→ Quotation approved) plus a per-module tail (SMS / RTGS / Mobile App), both owner-editable config —
whose ticks **auto-drive `client_module.status`** (quotation approved→ordered, installation→installed,
training→trained, go-live→live; never downgrades). A **Payment received** step links to a real
payment, and the **Account creation** step prompts for the module credentials — which are no longer
demanded when a module is added. Module details render as a read-only **50/50 sheet** (Access |
Details) with one Edit per group; each module block lists **its own documents** (Quote / Renewal /
**Bulk top-up** / Invoice) and SMS can raise a **bulk-credit purchase** proforma. The Payments tab
gains an **Outstanding** panel (issued invoices with a balance + sent/accepted proformas; drafts
excluded) and a *settled → document* column — the money engine is unchanged. Also: a
**stalled-onboarding** query, an owner **onboarding-template editor**, and mobile fixes (the record
header stacks instead of overlapping the title).
**2026-07-21 (D39): live.** HQ is deployed at **`https://hq.simssoftware.in`** — Docker on the
existing company EC2 box, the shared `accurate-postgres` container, real client data carried
over (273 clients, 14 staff, 156 documents, 1826 tickets). See `deploy/PATHS-AND-NAMES.md` for
the accurate deployment reference and `docs/06-DECISIONS.md` D39 for what deviated from plan.
## Verification state ## Verification state
| Check | Result | | Check | Result |
| --- | --- | | --- | --- |
| `npm install` | clean | | `npm install` | clean |
| `npm run typecheck` (root + workspaces) | clean, no errors | | `npm run typecheck` (root + workspaces) | clean, no errors |
| `npm test` (`vitest run`) | **416 tests pass across 85 files** (5 Postgres-gated skipped) | | `npm test` (`vitest run`) | **528 tests pass across 97 files** (5 Postgres-gated skipped) |
The 416 figure is the live `vitest run` count measured 2026-07-19; the suite has The 528 figure is the live `vitest run` count measured 2026-07-19; the suite has
grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields, grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields,
renewals, cockpit, audit viewer, SMS gateway, module directory/portals/search, and renewals, cockpit, audit viewer, SMS gateway, module directory/portals/search, and
the D31/D32 plaintext-credential change with its own migration + review). Test files the D31/D32 plaintext-credential change with its own migration + review). Test files

@ -328,6 +328,20 @@ export const addPrice = (moduleId: string, body: Record<string, unknown>): Promi
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) }) apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) })
.then((r) => r.prices) .then((r) => r.prices)
// Onboarding step templates (owner editor): shared "front" (every module) + per-module "tail".
export interface TemplateStep { key: string; label: string }
export const getFrontTemplate = (): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front').then((r) => r.steps)
export const setFrontTemplate = (steps: TemplateStep[]): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>('/onboarding-template/front', { method: 'PUT', body: JSON.stringify({ steps }) })
.then((r) => r.steps)
export const getModuleTemplate = (code: string): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`).then((r) => r.steps)
export const setModuleTemplate = (code: string, steps: TemplateStep[]): Promise<TemplateStep[]> =>
apiFetch<{ steps: TemplateStep[] }>(`/modules/${encodeURIComponent(code)}/onboarding-template`, {
method: 'PUT', body: JSON.stringify({ steps }),
}).then((r) => r.steps)
export const getClientModules = (clientId: string): Promise<ClientModule[]> => export const getClientModules = (clientId: string): Promise<ClientModule[]> =>
apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules) apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules)
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> => export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>
@ -362,18 +376,32 @@ export const revealModuleSecret = (id: string, key: string): Promise<string> =>
// ---------- onboarding milestones (D22) ---------- // ---------- onboarding milestones (D22) ----------
/** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ /** One checklist step on a project (client_module). Ticking stamps `doneOn`; the
export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } * advance/balance payment steps additionally carry `paymentId`, linking a real
* payment from the client's ledger, and the quotation step carries `documentId`,
* linking the quotation/proforma that was actually sent. */
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number
paymentId: string | null; documentId: string | null
}
/** The project's checklist; the server auto-seeds the standard template on first read. */ /** The project's checklist; the server auto-seeds the standard template on first read. */
export const getMilestones = (clientModuleId: string): Promise<Milestone[]> => export const getMilestones = (clientModuleId: string): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones)
/** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */ /** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it.
* `paymentId` links a real payment to a step (advance_payment / balance_payment);
* `documentId` links a real document to a step (quotation_sent). Un-ticking clears both. */
export const setMilestone = ( export const setMilestone = (
clientModuleId: string, key: string, done: boolean, doneOn?: string, clientModuleId: string, key: string, done: boolean, doneOn?: string, paymentId?: string, documentId?: string,
): Promise<Milestone[]> => ): Promise<Milestone[]> =>
apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, {
method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), method: 'POST',
body: JSON.stringify({
key, done,
...(doneOn !== undefined ? { doneOn } : {}),
...(paymentId !== undefined ? { paymentId } : {}),
...(documentId !== undefined ? { documentId } : {}),
}),
}).then((r) => r.milestones) }).then((r) => r.milestones)
/** Add a project-specific step beyond the standard template. */ /** Add a project-specific step beyond the standard template. */
export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> => export const addMilestone = (clientModuleId: string, label: string): Promise<Milestone[]> =>
@ -405,6 +433,11 @@ export const bulkSetMilestone = (
body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }),
}).then((r) => ({ updated: r.updated })) }).then((r) => ({ updated: r.updated }))
/** Active projects parked past the stall threshold (dashboard "Onboarding stalled" tile)
* same row shape as PendingProject; server defaults the threshold from `project.stall_days`. */
export const getStalledProjects = (): Promise<PendingProject[]> =>
apiFetch<{ projects: PendingProject[] }>('/projects/stalled').then((r) => r.projects)
// ---------- ticket desk + client branches (D20 APEX parity) ---------- // ---------- ticket desk + client branches (D20 APEX parity) ----------
export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped' export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped'
@ -414,6 +447,8 @@ export interface Ticket {
id: string; clientId: string; clientName: string id: string; clientId: string; clientName: string
branchId: string | null; branchName: string | null branchId: string | null; branchName: string | null
moduleCode: string | null; kind: string; description: string moduleCode: string | null; kind: string; description: string
/** Classification key from the `ticket.types` setting (service / module / …). */
type: string
status: TicketStatus status: TicketStatus
assignedTo: string | null; assignedName: string | null assignedTo: string | null; assignedName: string | null
onlineOffline: string | null onlineOffline: string | null
@ -421,6 +456,20 @@ export interface Ticket {
createdBy: string; createdAt: string; source: string createdBy: string; createdAt: string; source: string
} }
/** One ticket classification (config over code — the `ticket.types` setting row). */
export interface TicketType { key: string; label: string }
/** Fallback used only while the setting-backed list is loading / unreachable
* the server's `ticket.types` setting row stays the source of truth. */
export const TICKET_TYPE_FALLBACK: TicketType[] = [
{ key: 'service', label: 'Service' }, { key: 'module', label: 'Module issue' },
{ key: 'modification', label: 'Modification' }, { key: 'amc', label: 'AMC' },
{ key: 'other', label: 'Other' },
]
export const getTicketTypes = (): Promise<TicketType[]> =>
apiFetch<{ ok: boolean; types: TicketType[] }>('/tickets/types').then((r) => r.types)
export interface TicketsPage { export interface TicketsPage {
tickets: Ticket[]; total: number; page: number; pageSize: number tickets: Ticket[]; total: number; page: number; pageSize: number
/** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */ /** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */
@ -433,11 +482,14 @@ export interface TicketsPage {
export const getTickets = ( export const getTickets = (
opts: { opts: {
status?: TicketStatus; mine?: boolean; clientId?: string; module?: string status?: TicketStatus; mine?: boolean; clientId?: string; module?: string
/** Classification filter — omit / blank means every type. */
type?: string
overdue?: boolean; q?: string; page?: number; pageSize?: number overdue?: boolean; q?: string; page?: number; pageSize?: number
} = {}, } = {},
): Promise<TicketsPage> => { ): Promise<TicketsPage> => {
const p = new URLSearchParams() const p = new URLSearchParams()
if (opts.status !== undefined) p.set('status', opts.status) if (opts.status !== undefined) p.set('status', opts.status)
if (opts.type !== undefined && opts.type !== '') p.set('type', opts.type)
if (opts.mine === true) p.set('mine', '1') if (opts.mine === true) p.set('mine', '1')
if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId) if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId)
if (opts.module !== undefined && opts.module !== '') p.set('module', opts.module) if (opts.module !== undefined && opts.module !== '') p.set('module', opts.module)
@ -449,13 +501,13 @@ export const getTickets = (
return apiFetch<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`) return apiFetch<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`)
} }
export const createTicket = (body: { export const createTicket = (body: {
clientId: string; branchId?: string; moduleCode?: string; kind?: string clientId: string; branchId?: string; moduleCode?: string; kind?: string; type?: string
description?: string; onlineOffline?: string; assignedTo?: string description?: string; onlineOffline?: string; assignedTo?: string
}): Promise<Ticket> => }): Promise<Ticket> =>
apiFetch<{ ticket: Ticket }>('/tickets', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.ticket) apiFetch<{ ticket: Ticket }>('/tickets', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.ticket)
/** assignedTo: null clears the assignment; closing stamps closedOn server-side. */ /** assignedTo: null clears the assignment; closing stamps closedOn server-side. */
export const patchTicket = (id: string, body: { export const patchTicket = (id: string, body: {
status?: TicketStatus; assignedTo?: string | null; kind?: string; description?: string status?: TicketStatus; assignedTo?: string | null; kind?: string; type?: string; description?: string
moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null
}): Promise<Ticket> => }): Promise<Ticket> =>
apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket) apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket)
@ -957,6 +1009,11 @@ export const getRenewals = (from?: string, to?: string): Promise<RenewalRow[]> =
} }
export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> => export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document) apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document)
/** Raise a bulk SMS top-up proforma for a client's SMS module (client-detail Task 4). */
export const generateBulkSmsPurchase = (clientModuleId: string, smsQty: number): Promise<Doc> =>
apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/bulk-sms-quote`, {
method: 'POST', body: JSON.stringify({ smsQty }),
}).then((r) => r.document)
// D27: owner MIS cockpit. // D27: owner MIS cockpit.
export interface MisCockpit { export interface MisCockpit {

@ -270,10 +270,15 @@ button.dash-row:hover { background: var(--accent-soft); }
.hq-content { padding: 8px 14px 20px; } .hq-content { padding: 8px 14px 20px; }
.wf-page { padding: 14px 2px 28px; } .wf-page { padding: 14px 2px 28px; }
} }
/* Tables scroll inside their page rather than breaking the layout. */ /* Tables scroll inside their own box rather than the page (rule 8 no silent caps).
This needs a wrapping div: a `<table>` (display: table) doesn't reliably become its
own horizontal-scroll container from `overflow-x: auto` in Chromium, so at desktop
widths a wide table (e.g. Tickets' 11 columns) was overflowing past the viewport and
getting silently clipped by `.hq-content`'s overflow-x: hidden below, unreachable at
any width. The wrapper scrolls at every width now, not just <901px.
(`.wf-table-wrap` itself is styled in @sims/ui packages/ui/src/tokens.css next to
the other table.wf rules, since it's part of that component, not this app's shell.) */
.hq-content { overflow-x: hidden; } .hq-content { overflow-x: hidden; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
/* Relationship-pulse ribbon (Client 360 overview). */ /* Relationship-pulse ribbon (Client 360 overview). */
.pulse { .pulse {
@ -512,12 +517,22 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.cdr-node.done .nm { color: var(--text-dim); } .cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; } .cdr-node.now .nm { font-weight: 600; }
.cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; } .cdr-node .dt { font-size: 10px; color: var(--text-dim); font-family: var(--mono); margin-top: 3px; }
/* Linked payment/document reference under a done step's date. It WRAPS (up to two short
lines) instead of being ellipsised to one 104px line: the reference exists to show the
payment's amount / the document's number, and a single mono line at this width cut
exactly that off. Wrapping grows the node downwards only, so the track keeps its 112px
columns and its horizontal scroll on narrow screens. */
.cdr-node .ref {
font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none;
padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px;
white-space: normal; overflow-wrap: anywhere; line-height: 1.3; text-align: center;
}
.cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */ /* outstanding panel */
.cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; } .cdr-out { border: 1px solid color-mix(in srgb, var(--warn) 30%, var(--border)); border-radius: var(--radius); overflow: hidden; }
.cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); } .cdr-out .oh { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
.cdr-out .orow { display: flex; align-items: center; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; } .cdr-out .orow { display: flex; align-items: center; flex-wrap: wrap; row-gap: 4px; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.cdr-out .orow:last-child { border-bottom: none; } .cdr-out .orow:last-child { border-bottom: none; }
.cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; } .cdr-out .orow .due { margin-left: auto; font-family: var(--mono); font-weight: 600; }
.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); }
/* document-type pill */ /* document-type pill */
.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); } .doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); }

@ -2,7 +2,7 @@ import { useState, type CSSProperties, type ReactNode } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { import {
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton, Badge, Button, ConfirmDialog, DataTable, Dialog, EmptyState, ErrorState, Field, Notice, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar, useToast, StatCard, Stats, Tabs, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { import {
@ -14,20 +14,22 @@ import {
getAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction, getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getCompanyProfile, getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets, getBranches, createBranch, patchBranch, getTickets, getTicketTypes,
getMilestones, setMilestone, addMilestone, getMilestones, setMilestone, addMilestone,
CLIENT_STATUSES, KIND_LABEL, generateBulkSmsPurchase,
CLIENT_STATUSES, KIND_LABEL, TICKET_TYPE_FALLBACK,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, type ClientStatus, type Doc, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome,
type Payment, type RecurringPlan, type ServiceDetail,
} from '../api' } from '../api'
import { buildStatement } from '../statement' import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, ticketTypeLabel,
} from './Tickets' } from './Tickets'
import { import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect, AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
InteractionDialog, PaymentForm, PlanDialog, RowDate, InteractionDialog, PaymentForm, PlanDialog,
} from './client-forms' } from './client-forms'
import { ClientFormDialog } from '../components/ClientFormDialog' import { ClientFormDialog } from '../components/ClientFormDialog'
import { PulseRibbon } from '../components/PulseRibbon' import { PulseRibbon } from '../components/PulseRibbon'
@ -50,6 +52,9 @@ export function ClientDetail() {
const id = useParams()['id'] ?? '' const id = useParams()['id'] ?? ''
const nav = useNavigate() const nav = useNavigate()
const [sp, setSp] = useSearchParams() const [sp, setSp] = useSearchParams()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true })
const client = useData(() => getClient(id), [id]) const client = useData(() => getClient(id), [id])
const modules = useData(getModules, []) const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id]) const cms = useData(() => getClientModules(id), [id])
@ -63,7 +68,14 @@ export function ClientDetail() {
const branches = useData(() => getBranches(id), [id]) const branches = useData(() => getBranches(id), [id])
const [ticketPage, setTicketPage] = useState(1) const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage]) const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
const [ticketDialog, setTicketDialog] = useState(false) // Same classification list the Tickets desk uses, so the Type column reads identically here.
// Only needed for that column, so only fetch it once the tickets tab is actually open —
// NewTicketDialog fetches its own copy for the new-ticket type picker, independent of this.
const ticketTypes = useData(() => (tab === 'tickets' ? getTicketTypes() : Promise.resolve(TICKET_TYPE_FALLBACK)), [tab === 'tickets'])
const ticketTypeOptions = ticketTypes.data ?? TICKET_TYPE_FALLBACK
// Undefined = closed. `moduleCode` pre-fills the module when the ticket is raised from
// a module block ("Raise ticket for this module") rather than the Tickets tab button.
const [ticketDialog, setTicketDialog] = useState<{ moduleCode?: string } | undefined>()
const isOwner = role() === 'owner' const isOwner = role() === 'owner'
const canRoute = isManagerial() const canRoute = isManagerial()
const toast = useToast() const toast = useToast()
@ -73,10 +85,24 @@ export function ClientDetail() {
const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>() const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>()
const [confirm, setConfirm] = useState<Confirm | undefined>() const [confirm, setConfirm] = useState<Confirm | undefined>()
const [statementOpen, setStatementOpen] = useState(false) const [statementOpen, setStatementOpen] = useState(false)
// Onboarding status line (D38): ticking the advance/balance payment step opens this
const rawTab = sp.get('tab') ?? 'overview' // picker instead of stamping a bare date. `milestonesVersion` bumps to force every
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' // StatusLine's milestone list to refetch after a pick lands (see `refreshToken` below).
const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true }) const [paymentPicker, setPaymentPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>()
const [linkingPayment, setLinkingPayment] = useState(false)
const [linkError, setLinkError] = useState<string | undefined>()
const [milestonesVersion, setMilestonesVersion] = useState(0)
// Ticking the account_creation step opens this credentials dialog instead of a bare
// toggle (mirrors the payment picker above) — `cm` is captured via closure at the JSX
// callback below, exactly like `onTickPayment` captures it for the payment picker.
const [credsDialog, setCredsDialog] = useState<{ cm: ClientModule; key: string; label: string } | undefined>()
const [savingCreds, setSavingCreds] = useState(false)
const [credsError, setCredsError] = useState<string | undefined>()
// Ticking the quotation step opens this document picker instead of a bare toggle — same
// state shape as the payment picker above (the ledger is already loaded here).
const [docPicker, setDocPicker] = useState<{ clientModuleId: string; key: string; label: string } | undefined>()
const [linkingDoc, setLinkingDoc] = useState(false)
const [docLinkError, setDocLinkError] = useState<string | undefined>()
const c = client.data const c = client.data
if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} /> if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
@ -259,11 +285,8 @@ export function ClientDetail() {
{modules.data !== undefined && ( {modules.data !== undefined && (
<AssignModuleForm <AssignModuleForm
modules={modules.data} modules={modules.data}
onAssign={(moduleId, kind, creds) => { onAssign={(moduleId, kind) => {
assignClientModule(id, { moduleId, kind }) assignClientModule(id, { moduleId, kind })
.then((cm) => creds !== undefined
? patchClientModule(cm.id, { username: creds.username, password: creds.password })
: cm)
.then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() }) .then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() })
.catch((err: Error) => toast.err(err.message)) .catch((err: Error) => toast.err(err.message))
}} }}
@ -276,8 +299,6 @@ export function ClientDetail() {
columns={[ columns={[
{ key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' }, { key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
{ key: 'status', label: 'Status' }, { key: 'status', label: 'Status' },
{ key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
{ key: 'trained', label: 'Trained' },
{ key: 'billed', label: 'Billed', numeric: true }, { key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true }, { key: 'settled', label: 'Settled', numeric: true },
]} ]}
@ -287,9 +308,6 @@ export function ClientDetail() {
module: moduleName(cm.moduleId), module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind], kind: KIND_LABEL[cm.kind],
status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />, status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={toast.err} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={toast.err} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={toast.err} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—', billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—', settled: paid !== undefined ? inr(paid.settledPaise) : '—',
} }
@ -303,8 +321,18 @@ export function ClientDetail() {
<details key={cm.id} open className="mod-section"> <details key={cm.id} open className="mod-section">
<summary> <summary>
<span className="mod-name">{moduleName(cm.moduleId)}</span> <span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
<Badge>{KIND_LABEL[cm.kind]}</Badge> <Badge>{KIND_LABEL[cm.kind]}</Badge>
{/* The editable select below is the one status control a read-only badge
next to it would just repeat the same value (audit fix: no dual status). */}
<span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex', gap: 6 }}>
<ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={toast.err} />
{/* Support calls arrive per module — raise one without leaving the block. */}
<Button onClick={() => setTicketDialog({
moduleCode: modules.data?.find((m) => m.id === cm.moduleId)?.code ?? '',
})}>
Raise ticket for this module
</Button>
</span>
</summary> </summary>
<div className="mod-section-body"> <div className="mod-section-body">
<ServiceDataCard <ServiceDataCard
@ -313,7 +341,27 @@ export function ClientDetail() {
fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []} fieldSpec={modules.data?.find((m) => m.id === cm.moduleId)?.fieldSpec ?? []}
onChanged={() => cms.reload()} onChanged={() => cms.reload()}
/> />
<MilestoneChecklist clientModuleId={cm.id} name={moduleName(cm.moduleId)} /> <StatusLine
clientModuleId={cm.id}
name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []}
docs={ledger.data?.documents ?? []}
onTickPayment={(key, label) => { setLinkError(undefined); setPaymentPicker({ clientModuleId: cm.id, key, label }) }}
onTickCreds={(key, label) => { setCredsError(undefined); setCredsDialog({ cm, key, label }) }}
onTickDocument={(key, label) => { setDocLinkError(undefined); setDocPicker({ clientModuleId: cm.id, key, label }) }}
// The server auto-advances client_module.status on a tick — reload so the
// block header / summary table / status select never go stale (audit fix).
onChanged={() => cms.reload()}
onOpenDocument={(documentId) => nav(`/documents/${documentId}`)}
onOpenPayments={() => setTab('payments')}
/>
<ModuleDocuments
cm={cm}
docs={ledger.data?.documents ?? []}
moduleCode={modules.data?.find((m) => m.id === cm.moduleId)?.code ?? ''}
onCreated={() => ledger.reload()}
/>
</div> </div>
</details> </details>
))} ))}
@ -323,7 +371,7 @@ export function ClientDetail() {
{tab === 'tickets' && ( {tab === 'tickets' && (
<> <>
<Toolbar> <Toolbar>
<Button tone="primary" onClick={() => setTicketDialog(true)}>New ticket</Button> <Button tone="primary" onClick={() => setTicketDialog({})}>New ticket</Button>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{tickets.data !== undefined && <Badge>{tickets.data.total} total</Badge>} {tickets.data !== undefined && <Badge>{tickets.data.total} total</Badge>}
</Toolbar> </Toolbar>
@ -336,14 +384,20 @@ export function ClientDetail() {
<DataTable <DataTable
columns={[ columns={[
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' }, { key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'kind', label: 'Kind' }, { key: 'module', label: 'Module', mono: true }, { key: 'type', label: 'Type' },
{ key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' }, { key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' }, { key: 'status', label: 'Status' }, { key: 'act', label: '' },
]} ]}
rows={tickets.data.tickets.map((t) => ({ rows={tickets.data.tickets.map((t) => ({
opened: t.openedOn, opened: t.openedOn,
branch: t.branchName ?? '—', branch: t.branchName ?? '—',
module: t.moduleCode ?? '—', // Deep-link to this client's Modules tab — the project behind the ticket.
module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${id}?tab=modules`}>{t.moduleCode}</Link>
: '—',
// Same data + rendering as the Tickets desk (D-ticket-type parity).
type: ticketTypeLabel(ticketTypeOptions, t.type),
kind: t.kind !== '' ? t.kind : '—', kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />, desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—', assigned: t.assignedName ?? '—',
@ -361,13 +415,6 @@ export function ClientDetail() {
)} )}
</> </>
)} )}
<NewTicketDialog
open={ticketDialog}
onClose={() => setTicketDialog(false)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
/>
</> </>
)} )}
@ -402,12 +449,33 @@ export function ClientDetail() {
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" /> <StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
</Stats> </Stats>
)} )}
{ledger.data !== undefined && ledger.data.outstanding.length > 0 && (
<div className="wf-card">
<h3 style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px' }}>
Outstanding
<Badge tone="warn">{inr(ledger.data.outstanding.reduce((s, o) => s + o.outstandingPaise, 0))} due</Badge>
</h3>
<div className="cdr-out">
<div className="oh"><span style={{ fontWeight: 700, fontSize: 13 }}>Open documents</span>
<span style={{ color: 'var(--text-dim)', fontSize: 12 }}> oldest settles first</span></div>
{ledger.data.outstanding.map((o) => (
<div key={o.docId} className="orow" onClick={() => nav(`/documents/${o.docId}`)} style={{ cursor: 'pointer' }}>
<span className="mono" style={{ minWidth: 78 }}>{o.docNo ?? 'draft'}</span>
<span className="doc-pill">{DOC_TYPE_LABEL[o.docType as keyof typeof DOC_TYPE_LABEL] ?? o.docType}</span>
<span style={{ color: 'var(--text-dim)' }}>{o.label}</span>
<span className="due">{inr(o.outstandingPaise)}</span>
</div>
))}
</div>
</div>
)}
<PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} /> <PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} />
{ledger.data !== undefined && ledger.data.payments.length > 0 && ( {ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable <DataTable
columns={[ columns={[
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' }, { key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
{ key: 'ref', label: 'Reference', mono: true }, { key: 'ref', label: 'Reference', mono: true },
{ key: 'settled', label: 'Settled' },
{ key: 'amount', label: 'Amount', numeric: true }, { key: 'amount', label: 'Amount', numeric: true },
{ key: 'tds', label: 'TDS', numeric: true }, { key: 'tds', label: 'TDS', numeric: true },
]} ]}
@ -415,6 +483,9 @@ export function ClientDetail() {
.sort((a, b) => (a.receivedOn === b.receivedOn ? (a.id < b.id ? 1 : -1) : b.receivedOn.localeCompare(a.receivedOn))) .sort((a, b) => (a.receivedOn === b.receivedOn ? (a.id < b.id ? 1 : -1) : b.receivedOn.localeCompare(a.receivedOn)))
.map((p) => ({ .map((p) => ({
on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—', on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
settled: p.allocations.length === 0
? <span style={{ color: 'var(--text-dim)' }}>advance</span>
: <span style={{ color: 'var(--text-dim)', fontSize: 12 }}> {p.allocations.map((a) => a.docNo).join(', ')}</span>,
amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—', amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
}))} }))}
/> />
@ -607,6 +678,114 @@ export function ClientDetail() {
{statementOpen && ( {statementOpen && (
<ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} /> <ClientStatement client={c} ledger={ledger.data} onClose={() => setStatementOpen(false)} />
)} )}
{paymentPicker !== undefined && (
<PaymentPickerDialog
open
stepLabel={paymentPicker.label}
payments={[...(ledger.data?.payments ?? [])].sort((a, b) => b.receivedOn.localeCompare(a.receivedOn))}
saving={linkingPayment}
error={linkError}
onClose={() => { if (!linkingPayment) setPaymentPicker(undefined) }}
onPick={(paymentId) => {
if (linkingPayment) return
setLinkingPayment(true)
setLinkError(undefined)
setMilestone(paymentPicker.clientModuleId, paymentPicker.key, true, undefined, paymentId)
.then(() => {
toast.ok('Payment linked')
setMilestonesVersion((v) => v + 1)
// The server auto-advances client_module.status on a milestone tick — reload
// so the module block header / summary table / status select don't go stale.
cms.reload()
setPaymentPicker(undefined)
})
.catch((e: Error) => setLinkError(e.message))
.finally(() => setLinkingPayment(false))
}}
onRecordNew={() => { setPaymentPicker(undefined); setTab('payments') }}
/>
)}
{docPicker !== undefined && (
<DocumentPickerDialog
open
stepLabel={docPicker.label}
docs={docsNewest.filter((d) => d.docType === 'QUOTATION' || d.docType === 'PROFORMA')}
saving={linkingDoc}
error={docLinkError}
onClose={() => { if (!linkingDoc) setDocPicker(undefined) }}
onPick={(documentId) => {
if (linkingDoc) return
setLinkingDoc(true)
setDocLinkError(undefined)
setMilestone(docPicker.clientModuleId, docPicker.key, true, undefined, undefined, documentId)
.then(() => {
toast.ok('Document linked')
setMilestonesVersion((v) => v + 1)
cms.reload()
setDocPicker(undefined)
})
.catch((e: Error) => setDocLinkError(e.message))
.finally(() => setLinkingDoc(false))
}}
onCreateNew={() => { setDocPicker(undefined); nav(`/documents/new?client=${id}`) }}
/>
)}
{/* Raised either from the Tickets tab or from a module block ("Raise ticket for this
module"), so it lives outside the tab switch the module code pre-fills. Mounted
only while actually open it otherwise fires getModules + getTicketTypes on every
Client 360 load even though it's never opened. */}
{ticketDialog !== undefined && (
<NewTicketDialog
open
onClose={() => setTicketDialog(undefined)}
onCreated={() => tickets.reload()}
kinds={tickets.data?.kinds ?? []}
initialClient={{ id: c.id, code: c.code, name: c.name }}
{...(ticketDialog.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})}
/>
)}
{credsDialog !== undefined && (
<CredentialsDialog
open
stepLabel={credsDialog.label}
cm={credsDialog.cm}
saving={savingCreds}
error={credsError}
onClose={() => { if (!savingCreds) setCredsDialog(undefined) }}
onSave={(fields) => {
if (savingCreds) return
setSavingCreds(true)
setCredsError(undefined)
patchClientModule(credsDialog.cm.id, {
provider: fields.provider, username: fields.username,
...(canRoute && fields.password.trim() !== '' ? { password: fields.password } : {}),
})
.then(() => setMilestone(credsDialog.cm.id, credsDialog.key, true))
.then(() => {
toast.ok('Credentials saved')
setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
.finally(() => setSavingCreds(false))
}}
onSkip={() => {
if (savingCreds) return
setSavingCreds(true)
setCredsError(undefined)
setMilestone(credsDialog.cm.id, credsDialog.key, true)
.then(() => {
toast.ok('Step marked done')
setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
.finally(() => setSavingCreds(false))
}}
/>
)}
</div> </div>
) )
} }
@ -1229,14 +1408,55 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
} }
/** /**
* Onboarding checklist (D22 per-project detail): the project's milestones as a row * Onboarding as a horizontal status line (D38 redesign per-project detail): the
* of toggles. Ticking one stamps the date (a compact date input adjusts it; toggling * project's milestones as a point-by-point track (`.cdr-track`) a connector line
* on with no date uses today); "+ Add step" appends a project-specific milestone. Every * joins ordered dots, the current (first not-done) step is ringed ("now"), done steps
* change is one audited POST that returns the fresh list, so the card re-renders in place. * show a check and their date. Ticking a plain step stamps today's date and drives the
* module status (installed/live) exactly as before. Three steps instead hand off to a
* dialog the parent (`ClientDetail`) owns, because each needs data this component does
* not hold:
* - the **payment** step (`payment_received`, plus the legacy `advance_payment` /
* `balance_payment` keys on un-migrated projects, AND the older `advance_paid` /
* `balance_paid` keys the generic fallback template uses on modules with no seeded
* per-module tail) calls `onTickPayment` `PaymentPickerDialog`, which links a real
* ledger payment (`setMilestone(..., paymentId)`) rather than stamping a bare date;
* - the `account_creation` **credentials** step calls `onTickCreds`
* `CredentialsDialog`, which captures the module's provider/username/password
* (managerial gate) at the moment the account actually exists;
* - the `quotation_sent` **document** step calls `onTickDocument`
* `DocumentPickerDialog`, which links the quotation/proforma that was sent
* (`setMilestone(..., documentId)`) from the client's already-loaded ledger.
* Un-ticking any of them toggles directly (the server clears the link).
* A done step carrying `paymentId`/`documentId` also renders a small reference under its
* date (`onOpenDocument` / `onOpenPayments`) otherwise a linked step looks identical to a
* bare-toggled one. That reference is resolved against the client's ledger (`payments` /
* `docs`) so it reads as the real date + amount / document number, wraps to a second line
* inside the node rather than being ellipsised, and carries the full detail in its
* tooltip + accessible name.
* "+ Add step" appends a project-specific milestone. Every change is one audited POST
* that returns the fresh list; `refreshToken` lets the parent force a refetch after a
* link lands from outside this component. `onChanged` additionally reloads the parent's
* client-modules list after every tick (plain toggle included) so the server's
* auto-advanced `client_module.status` never goes stale in the block header / summary
* table / status select.
*/ */
function MilestoneChecklist(props: { clientModuleId: string; name: string }) { function StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number
/** The client's ledger — used to render the linked payment / document by its real
* date + amount (and to fill the reference's tooltip), not a generic "linked" label. */
payments: Payment[]
docs: Doc[]
onTickPayment: (key: string, label: string) => void
onTickCreds: (key: string, label: string) => void
onTickDocument: (key: string, label: string) => void
/** Reloads the parent's client-modules list every tick path calls this so the module
* block header / summary table / status select never show a stale status (audit fix). */
onChanged: () => void
onOpenDocument: (documentId: string) => void
onOpenPayments: () => void
}) {
const toast = useToast() const toast = useToast()
const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId]) const list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken])
const [busyKey, setBusyKey] = useState('') const [busyKey, setBusyKey] = useState('')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [label, setLabel] = useState('') const [label, setLabel] = useState('')
@ -1246,7 +1466,7 @@ function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
if (busyKey !== '') return if (busyKey !== '') return
setBusyKey(m.key) setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn) setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload()) .then(() => { list.reload(); props.onChanged() })
.catch((e: Error) => toast.err(e.message)) .catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey('')) .finally(() => setBusyKey(''))
} }
@ -1261,64 +1481,324 @@ function MilestoneChecklist(props: { clientModuleId: string; name: string }) {
const ms = list.data const ms = list.data
const done = ms?.filter((m) => m.done).length ?? 0 const done = ms?.filter((m) => m.done).length ?? 0
// current = first not-done step
const currentKey = ms?.find((m) => !m.done)?.key
// `payment_received` is the key every shipped (seeded) template uses; `advance_payment` /
// `balance_payment` are legacy keys still on projects not yet run through the onboarding
// migration; `advance_paid` / `balance_paid` (note: `_paid`, not `_payment`) are the OLDER
// legacy vocabulary from the generic fallback template — the one modules with no seeded
// per-module tail (WHATSAPP, ATM, AMC, …) actually get. Without these two, ticking the
// payment step on those modules silently stamped a bare date instead of opening the picker.
const isPaymentStep = (key: string) =>
key === 'payment_received' || key === 'advance_payment' || key === 'balance_payment'
|| key === 'advance_paid' || key === 'balance_paid'
const isCredsStep = (key: string) => key === 'account_creation'
const isDocumentStep = (key: string) => key === 'quotation_sent'
// Ticking ON a payment step opens the picker (parent owns that dialog — it needs the
// client's ledger + the Payments tab); ticking ON account_creation opens the parent's
// credentials dialog (it needs the module's provider/username/password + managerial
// gate); ticking ON quotation_sent opens the document picker (it needs the ledger's
// documents). Un-ticking and every other step toggle directly.
const onDotClick = (m: Milestone) => {
if (busyKey !== '') return
if (!m.done && isPaymentStep(m.key)) { props.onTickPayment(m.key, m.label); return }
if (!m.done && isCredsStep(m.key)) { props.onTickCreds(m.key, m.label); return }
if (!m.done && isDocumentStep(m.key)) { props.onTickDocument(m.key, m.label); return }
toggle(m, !m.done)
}
return ( return (
<div className="wf-card" style={{ padding: '10px 14px' }}> <div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 600 }}>{props.name} onboarding</span> <span style={{ fontWeight: 600 }}>{props.name} status line</span>
{ms !== undefined && ms.length > 0 && ( {ms !== undefined && ms.length > 0 && (
<Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge> <Badge tone={done === ms.length ? 'ok' : undefined}>{done}/{ms.length}</Badge>
)} )}
<span style={{ flex: 1 }} />
{!adding ? <Button onClick={() => setAdding(true)}>+ Add step</Button> : (
<>
<input
className="wf" style={{ maxWidth: 200 }} placeholder="Step label" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</div> </div>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} /> {list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: ms === undefined ? <Skeleton rows={2} /> : ms === undefined ? <Skeleton rows={2} />
: ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : ( : ms.length === 0 ? <EmptyState>No steps yet.</EmptyState> : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}> <div className="cdr-track-wrap"><div className="cdr-track">
{ms.map((m) => ( {ms.map((m) => (
<div <div
key={m.key} key={m.key}
style={{ className={`cdr-node ${m.done ? 'done' : m.key === currentKey ? 'now' : ''}`}
display: 'inline-flex', alignItems: 'center', gap: 8, style={{ opacity: busyKey === m.key ? 0.5 : 1 }}
border: '1px solid var(--border)', borderRadius: 6, padding: '4px 10px',
opacity: busyKey === m.key ? 0.6 : 1,
}}
> >
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}> <span className="bar" />
<input <button
type="checkbox" checked={m.done} disabled={busyKey === m.key} className="dot" disabled={busyKey === m.key}
onChange={(e) => toggle(m, e.target.checked)} title={
/> m.done ? 'Click to un-tick'
<span style={m.done ? { textDecoration: 'line-through', opacity: 0.7 } : undefined}>{m.label}</span> : isPaymentStep(m.key) ? 'Click to link a payment'
</label> : isCredsStep(m.key) ? 'Click to set account credentials'
{m.done && ( : isDocumentStep(m.key) ? 'Click to link the quotation / proforma'
<> : 'Click to mark done (today)'
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>on</span> }
<input onClick={() => onDotClick(m)}
type="date" className="wf" style={{ width: 140 }} value={m.doneOn ?? ''} >
disabled={busyKey === m.key} {m.done ? '✓' : m.key === currentKey ? '●' : ''}
onChange={(e) => toggle(m, true, e.target.value !== '' ? e.target.value : undefined)} </button>
/> <span className="nm">{m.label}</span>
</> <span className="dt">{m.done ? (m.doneOn ?? '—') : m.key === currentKey ? 'now' : '—'}</span>
)} {/* The linked payment/document itself otherwise a done payment/quotation
step shows only a date, with no evidence the link actually landed. The
reference wraps inside the node (it used to be ellipsised to one 104px
line, which cut the amount the one thing it exists to show), and the
tooltip/accessible name carries the full detail rather than static text. */}
{m.done && m.documentId !== null && (() => {
const d = props.docs.find((x) => x.id === m.documentId)
const detail = d === undefined
? 'Linked document — open it'
: `${docLabel(d)} ${d.docNo ?? '(draft)'} · ${d.docDate} · ${inr(d.payablePaise)} — open it`
return (
<button
type="button" className="ref" title={detail} aria-label={detail}
onClick={() => props.onOpenDocument(m.documentId!)}
>
{d === undefined ? 'view document' : d.docNo ?? `${docLabel(d)} (draft)`}
</button>
)
})()}
{m.done && m.paymentId !== null && (() => {
const p = props.payments.find((x) => x.id === m.paymentId)
const detail = p === undefined
? 'Linked payment — open Payments & plans'
: `Payment ${p.receivedOn} · ${inr(p.amountPaise)}`
+ `${p.reference !== '' ? ` · ${p.reference}` : ''} — open Payments & plans`
return (
<button
type="button" className="ref" title={detail} aria-label={detail}
onClick={() => props.onOpenPayments()}
>
{p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'}
</button>
)
})()}
</div> </div>
))} ))}
</div></div>
)}
</div>
)
}
/** Doc.source values that don't map onto DOC_TYPE_LABEL get a friendlier, source-aware label. */
const DOC_SOURCE_LABEL: Record<string, string> = {
module_renewal: 'Renewal', bulk_sms_topup: 'Bulk top-up',
}
function docLabel(d: Doc): string {
if (DOC_SOURCE_LABEL[d.source] !== undefined) return DOC_SOURCE_LABEL[d.source]!
return DOC_TYPE_LABEL[d.docType] ?? d.docType
}
/**
* Task 4: per-module documents every doc whose line items include this module (quotes,
* renewals, bulk SMS top-ups, invoices, credit notes), newest first. SMS-coded modules get
* a "Bulk SMS purchase" action that raises a proforma and jumps straight to it.
*/
function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: string; onCreated: () => void }) {
const nav = useNavigate()
const toast = useToast()
const [busy, setBusy] = useState(false)
const mine = props.docs
.filter((d) => d.payload.lines.some((l) => l.itemId === props.cm.moduleId))
.sort((a, b) => b.docDate.localeCompare(a.docDate))
const isSms = props.moduleCode.toUpperCase() === 'SMS'
const bulk = () => {
const qty = Number(window.prompt('Bulk SMS quantity to purchase?', '100000') ?? '')
if (!Number.isInteger(qty) || qty <= 0) return
setBusy(true)
generateBulkSmsPurchase(props.cm.id, qty)
.then((doc) => { toast.ok('Bulk SMS proforma created'); props.onCreated(); nav(`/documents/${doc.id}`) })
.catch((e: Error) => { toast.err(e.message); setBusy(false) })
}
return (
<div className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="lbl" style={{ fontWeight: 700 }}>Documents</span>
<Badge>{mine.length}</Badge>
<span style={{ flex: 1 }} />
{isSms && <Button disabled={busy} onClick={bulk}>Bulk SMS purchase</Button>}
</div>
{mine.length === 0 ? <div style={{ color: 'var(--text-dim)', fontSize: 12.5 }}>No documents for this module yet.</div>
: mine.map((d) => (
<div key={d.id} onClick={() => nav(`/documents/${d.id}`)}
style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', rowGap: 4, gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}>
<span className="mono" style={{ minWidth: 74 }}>{d.docNo ?? 'draft'}</span>
<span className="doc-pill">{docLabel(d)}</span>
<span className="mono" style={{ color: 'var(--text-dim)' }}>{d.docDate}</span>
<span className="mono" style={{ marginLeft: 'auto' }}>{inr(d.payablePaise)}</span>
<Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>
</div> </div>
))}
</div>
)
}
/**
* Payment-step picker (D38): ticking the advance/balance payment status step opens this
* instead of stamping a bare date it links a real payment row so the milestone and the
* ledger agree (`setMilestone(..., paymentId)`; the server mirrors that payment's
* `receivedOn` into `doneOn`). Lists the client's payments, newest first; "Record new
* payment" defers to the Payments tab rather than guessing an amount here.
*/
function PaymentPickerDialog(props: {
open: boolean; onClose: () => void; stepLabel: string
payments: Payment[]; saving: boolean; error?: string
onPick: (paymentId: string) => void; onRecordNew: () => void
}) {
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Link a payment — ${props.stepLabel}`}
size="sm"
footer={<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>}
>
{props.payments.length === 0
? <EmptyState>No payments recorded for this client yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'amount', label: 'Amount', numeric: true },
{ key: 'act', label: '' },
]}
rows={props.payments.map((p) => ({
on: p.receivedOn,
amount: inr(p.amountPaise),
act: <Button tone="primary" disabled={props.saving} onClick={() => props.onPick(p.id)}>Use</Button>,
}))}
/>
)} )}
<Toolbar> <Toolbar>
{!adding <span style={{ flex: 1 }} />
? <Button onClick={() => setAdding(true)}>+ Add step</Button> <Button disabled={props.saving} onClick={props.onRecordNew}>Record new payment</Button>
: (
<>
<input
className="wf" style={{ maxWidth: 240 }} placeholder="Step label" aria-label="New step label"
value={label} onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStep() }}
/>
<Button tone="primary" disabled={addBusy || label.trim() === ''} onClick={addStep}>{addBusy ? 'Adding…' : 'Add'}</Button>
<Button onClick={() => { setAdding(false); setLabel('') }}>Cancel</Button>
</>
)}
</Toolbar> </Toolbar>
</div> {props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
)
}
/**
* Quotation-step document picker (D38 follow-up): ticking the "Quotation sent" status
* step opens this instead of stamping a bare date it links the quotation/proforma that
* was actually sent (`setMilestone(..., documentId)`) so the status line and the document
* register agree. Mirrors `PaymentPickerDialog`'s structure/props exactly, reading the
* client's already-loaded ledger (no extra fetch); "Create new document" defers to the
* composer rather than inventing a document here.
*/
function DocumentPickerDialog(props: {
open: boolean; onClose: () => void; stepLabel: string
docs: Doc[]; saving: boolean; error?: string
onPick: (documentId: string) => void; onCreateNew: () => void
}) {
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Link a document — ${props.stepLabel}`}
size="sm"
footer={<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>}
>
{props.docs.length === 0
? <EmptyState>No quotation or proforma on record for this client yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
rows={props.docs.map((d) => ({
no: d.docNo ?? 'draft',
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
act: <Button tone="primary" disabled={props.saving} onClick={() => props.onPick(d.id)}>Use</Button>,
}))}
/>
)}
<Toolbar>
<span style={{ flex: 1 }} />
<Button disabled={props.saving} onClick={props.onCreateNew}>Create new document</Button>
</Toolbar>
{props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
)
}
/**
* Account-creation credentials dialog (D38 follow-up): ticking the "Account creation &
* registration" status-line step opens this instead of a bare toggle mirrors
* `PaymentPickerDialog`'s structure/props exactly. Captures the module's Access fields
* right at the point the account actually exists, reusing `ServiceDataCard`'s exact save
* call (`patchClientModule`) and its managerial gate on the password field (blank keeps
* whatever is already stored). Fields pre-fill from the client_module so re-ticking after
* creds already exist is never blocked "Skip" marks the step done without touching
* credentials at all, for modules that don't need one.
*/
function CredentialsDialog(props: {
open: boolean; onClose: () => void; stepLabel: string; cm: ClientModule
saving: boolean; error?: string
onSave: (fields: { provider: string; username: string; password: string }) => void
onSkip: () => void
}) {
const managerial = isManagerial()
const [provider, setProvider] = useState(props.cm.provider ?? '')
const [username, setUsername] = useState(props.cm.username ?? '')
const [password, setPassword] = useState('')
return (
<Dialog
open={props.open}
onClose={() => { if (!props.saving) props.onClose() }}
title={`Account credentials — ${props.stepLabel}`}
size="sm"
footer={
<>
<Button onClick={props.onClose} disabled={props.saving}>Cancel</Button>
<Button disabled={props.saving} onClick={props.onSkip}>Skip mark done</Button>
<Button tone="primary" disabled={props.saving} onClick={() => props.onSave({ provider, username, password })}>
{props.saving ? 'Saving…' : 'Save & mark done'}
</Button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Field label="Provider">
<input className="wf" value={provider} onChange={(e) => setProvider(e.target.value)} />
</Field>
<Field label="Username">
<input className="wf mono" value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
{managerial && (
<Field label="Password">
<input
className="wf mono" type="password"
placeholder={props.cm.hasPassword ? 'leave blank to keep' : 'set password'}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
</Field>
)}
</div>
{props.error !== undefined && <Notice tone="err">{props.error}</Notice>}
</Dialog>
) )
} }

@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain' import { formatINR } from '@sims/domain'
import { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui' import { Badge, DataTable, EmptyState, ErrorState, Notice, Skeleton, StatCard, Stats } from '@sims/ui'
import { displayName, getDashboard, getSmsBalances, isManagerial } from '../api' import { displayName, getDashboard, getSmsBalances, getStalledProjects, isManagerial } from '../api'
import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue' import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue'
import { useData } from './Clients' import { useData } from './Clients'
@ -34,6 +34,7 @@ export function Dashboard() {
const [mine, setMine] = useState(false) const [mine, setMine] = useState(false)
const dash = useData(() => getDashboard(mine), [mine]) const dash = useData(() => getDashboard(mine), [mine])
const smsLow = useData(getSmsBalances, []) // D28: low-balance alert, seen even without opening the SMS screen const smsLow = useData(getSmsBalances, []) // D28: low-balance alert, seen even without opening the SMS screen
const stalled = useData(getStalledProjects, []) // parked onboarding — active projects with no recent progress
const [err, setErr] = useState('') const [err, setErr] = useState('')
const v = dash.data const v = dash.data
const name = displayName() const name = displayName()
@ -69,13 +70,27 @@ export function Dashboard() {
</div> </div>
</section> </section>
{/* SMS low-balance alert (D28) — surfaced here so it's seen without opening the SMS screen. */} {/* SMS low-balance alert (D28) surfaced here so it's seen without opening the SMS
{smsLow.data !== undefined && smsLow.data.lowCount > 0 && ( screen. Three states, never collapsed into one: while the check is in flight a
skeleton stands in; a FAILED check says so with a retry (rendering nothing there
would read as "no client is low" the dangerous direction); and only a loaded,
genuinely-empty result gets the positive line. `unknownCount` is spelled out in
that line because balances the gateway couldn't return aren't "fine" either. */}
{smsLow.error !== undefined ? (
<ErrorState message={`SMS balances could not be checked — ${smsLow.error}`} onRetry={smsLow.reload} />
) : smsLow.data === undefined ? (
<Skeleton rows={1} />
) : smsLow.data.lowCount > 0 ? (
<Notice tone="warn"> <Notice tone="warn">
<Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}> <Link to="/sms-balances" style={{ color: 'inherit', fontWeight: 600 }}>
{smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up {smsLow.data.lowCount} SMS client{smsLow.data.lowCount === 1 ? '' : 's'} low on balance review &amp; top up
</Link> </Link>
</Notice> </Notice>
) : (
<p className="dash-cap">
SMS balances checked none low
{smsLow.data.unknownCount > 0 ? ` (${smsLow.data.unknownCount} could not be read)` : ''}.
</p>
)} )}
{/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */} {/* KPIs — the app's existing stat cards (kept minimal; red only when a send failed). */}
@ -144,6 +159,25 @@ export function Dashboard() {
))} ))}
</Card> </Card>
<Card
title="Onboarding stalled" count={stalled.isLoading ? undefined : (stalled.data?.length ?? 0)}
empty="Nothing stalled."
// Still fetching (data undefined, no error yet) is not the same as a genuine
// empty result — show the usual loading placeholder, not "Nothing stalled.",
// until the request actually resolves one way or the other.
loading={stalled.isLoading}
// A fetch failure is likewise not the same as empty — show the error (with
// retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={!stalled.isLoading && stalled.error === undefined && (stalled.data ?? []).length === 0}
>
{stalled.error !== undefined
? <ErrorState message={stalled.error} onRetry={stalled.reload} />
: (stalled.data ?? []).map((p) => (
<Row key={p.clientModuleId} onClick={() => nav(`/clients/${p.clientId}?tab=modules`)}
title={p.clientName} sub={p.moduleName} />
))}
</Card>
<Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}> <Card title="Follow-ups today" count={v.followUpsToday.length} empty="No follow-ups due." isEmpty={v.followUpsToday.length === 0}>
{v.followUpsToday.map((f) => ( {v.followUpsToday.map((f) => (
<Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)} <Row key={f.id} onClick={() => nav(`/clients/${f.clientId}`)}
@ -166,10 +200,12 @@ export function Dashboard() {
} }
/** One bento card: an uppercase-labelled header (+ count and optional link) over a /** One bento card: an uppercase-labelled header (+ count and optional link) over a
* scrollable list, or an empty-state line when there's nothing to show. */ * scrollable list, or an empty-state line when there's nothing to show. Cards fed by
* their own request pass `loading` so a fetch still in flight shows a skeleton instead
* of the empty line "nothing to show" must mean the data loaded and was empty. */
function Card(props: { function Card(props: {
title: string; count?: number; link?: { to: string; label?: string } title: string; count?: number; link?: { to: string; label?: string }
empty: string; isEmpty: boolean; children: ReactNode empty: string; isEmpty: boolean; loading?: boolean; children: ReactNode
}) { }) {
return ( return (
<section className="dash-card"> <section className="dash-card">
@ -178,7 +214,9 @@ function Card(props: {
{props.count !== undefined && <span className="count">{props.count}</span>} {props.count !== undefined && <span className="count">{props.count}</span>}
{props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>} {props.link !== undefined && <Link to={props.link.to}>{props.link.label ?? 'All'} </Link>}
</div> </div>
{props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>} {props.loading === true
? <div className="dash-list"><Skeleton rows={2} /></div>
: props.isEmpty ? <div className="dash-empty">{props.empty}</div> : <div className="dash-list">{props.children}</div>}
</section> </section>
) )
} }

@ -5,11 +5,11 @@ import {
FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast, FormGrid, Notice, PageHeader, Skeleton, Tabs, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { import {
addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, addPrice, assignClientModule, createModule, downloadModuleClientsCsv, getClients, getFrontTemplate,
getModuleClients, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule, getModuleClients, getModuleTemplate, getModules, getPrices, notifyModuleClients, patchClientModule, patchModule,
previewSample, role, previewSample, role, setFrontTemplate, setModuleTemplate,
CLIENT_MODULE_STATUSES, KIND_LABEL, CLIENT_MODULE_STATUSES, KIND_LABEL,
type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, type Client, type ClientModuleStatus, type FieldDef, type FieldType, type Kind, type Module, type TemplateStep,
} from '../api' } from '../api'
import { LivePreview } from '../components/LivePreview' import { LivePreview } from '../components/LivePreview'
import { useData } from './Clients' import { useData } from './Clients'
@ -121,6 +121,7 @@ export function Modules() {
onCreated={() => { setCreating(false); modules.reload() }} onCreated={() => { setCreating(false); modules.reload() }}
/> />
)} )}
{isOwner && modules.data !== undefined && <OnboardingTemplatesSection modules={modules.data} />}
</div> </div>
) )
} }
@ -832,3 +833,126 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod
</Dialog> </Dialog>
) )
} }
/**
* Owner-only editor for the COMPOSED onboarding checklist template: one shared "front" (same
* lead-in steps for every module) plus each module's own "tail". Both are config (`setting`
* rows) resolved by `milestoneTemplate` this just edits the two halves. Staged local edits;
* nothing is written until Save.
*/
function OnboardingTemplatesSection(props: { modules: Module[] }) {
const front = useData(getFrontTemplate, [])
const [expanded, setExpanded] = useState<string | undefined>()
return (
<section style={{ marginTop: 28 }}>
<h3>Onboarding steps</h3>
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 14, fontSize: 13, color: 'var(--text-dim)', lineHeight: 1.5 }}>
Every project's onboarding checklist is this shared <strong style={{ color: 'var(--text)' }}>front</strong> followed
by that module's own <strong style={{ color: 'var(--text)' }}>tail</strong> below. Changes reach existing projects
additively new steps appear on projects already in progress, and any step already ticked stays ticked as-is.
Re-ordering or renaming steps that existing projects already have doesn't retro-fit those projects that needs
the onboarding-template migration script.
</div>
<h4>Shared front <span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-dim)' }}> applies to every module</span></h4>
{front.error !== undefined ? <ErrorState message={front.error} onRetry={front.reload} />
: front.data === undefined ? <Skeleton rows={3} /> : (
<div className="wf-card" style={{ padding: 14, marginBottom: 20 }}>
<StepListEditor
steps={front.data}
onSave={(steps) => setFrontTemplate(steps).then((saved) => { front.reload(); return saved })}
/>
</div>
)}
<h4>Per-module tail</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{props.modules.map((m) => (
<div key={m.id} className="wf-card" style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span><strong>{m.name}</strong> <span className="mono" style={{ fontSize: 12, color: 'var(--text-dim)' }}>{m.code}</span></span>
<Button onClick={() => setExpanded((v) => (v === m.id ? undefined : m.id))}>
{expanded === m.id ? 'Close' : 'Edit onboarding steps'}
</Button>
</div>
{expanded === m.id && <ModuleTailEditor code={m.code} />}
</div>
))}
</div>
</section>
)
}
/** Lazily loads and edits one module's tail — only fetched once its row is expanded. */
function ModuleTailEditor(props: { code: string }) {
const tail = useData(() => getModuleTemplate(props.code), [props.code])
return (
<div style={{ marginTop: 10 }}>
{tail.error !== undefined ? <ErrorState message={tail.error} onRetry={tail.reload} />
: tail.data === undefined ? <Skeleton rows={3} /> : (
<StepListEditor
steps={tail.data}
onSave={(steps) => setModuleTemplate(props.code, steps).then((saved) => { tail.reload(); return saved })}
/>
)}
</div>
)
}
/**
* One editable ordered list of template steps label-only rows (the key is auto-derived
* server-side from the label when blank); add / remove / reorder, staged locally until Save.
* Shared by the front editor and every per-module tail editor above.
*/
function StepListEditor(props: { steps: TemplateStep[]; onSave: (steps: TemplateStep[]) => Promise<TemplateStep[]> }) {
const toast = useToast()
const [rows, setRows] = useState<TemplateStep[]>(() => props.steps.map((s) => ({ ...s })))
const [dirty, setDirty] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | undefined>()
useEffect(() => { setRows(props.steps.map((s) => ({ ...s }))); setDirty(false) }, [props.steps])
const setLabel = (i: number, label: string) => {
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, label } : r)))
setDirty(true)
}
const addRow = () => { setRows((rs) => [...rs, { key: '', label: '' }]); setDirty(true) }
const removeRow = (i: number) => { setRows((rs) => rs.filter((_r, j) => j !== i)); setDirty(true) }
const move = (i: number, dir: -1 | 1) => {
const j = i + dir
if (j < 0 || j >= rows.length) return
setRows((rs) => { const next = [...rs]; [next[i], next[j]] = [next[j]!, next[i]!]; return next })
setDirty(true)
}
const save = () => {
if (busy) return
if (rows.length === 0) { setError('Add at least one step'); return }
if (rows.some((r) => r.label.trim() === '')) { setError('Every step needs a label'); return }
setError(undefined); setBusy(true)
props.onSave(rows.map((r) => ({ key: r.key, label: r.label.trim() })))
.then((saved) => { setRows(saved.map((s) => ({ ...s }))); setDirty(false); toast.ok('Onboarding steps saved') })
.catch((e: Error) => { setError(e.message); toast.err(e.message) })
.finally(() => setBusy(false))
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r, i) => (
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input className="wf" style={{ flex: 1 }} placeholder="Step label…"
value={r.label} onChange={(e) => setLabel(i, e.target.value)} />
<Button disabled={busy || i === 0} aria-label="Move step up" onClick={() => move(i, -1)}></Button>
<Button disabled={busy || i === rows.length - 1} aria-label="Move step down" onClick={() => move(i, 1)}></Button>
<Button tone="danger" disabled={busy} aria-label="Remove step" onClick={() => removeRow(i)}></Button>
</div>
))}
<Toolbar>
<Button disabled={busy} onClick={addRow}>+ Add step</Button>
<Button tone="primary" disabled={busy || !dirty} onClick={save}>{busy ? 'Saving…' : 'Save'}</Button>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}

@ -5,9 +5,9 @@ import {
FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast, FormField, FormGrid, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui' } from '@sims/ui'
import { import {
createTicket, getBranches, getClients, getEmployees, getModules, getTickets, patchTicket, staffId, createTicket, getBranches, getClients, getEmployees, getModules, getTicketTypes, getTickets, patchTicket, staffId,
TICKET_STATUSES, TICKET_STATUSES, TICKET_TYPE_FALLBACK,
type Branch, type Client, type Employee, type Ticket, type TicketStatus, type Branch, type Client, type Employee, type Ticket, type TicketStatus, type TicketType,
} from '../api' } from '../api'
import { useData, useDebounced } from './Clients' import { useData, useDebounced } from './Clients'
@ -25,6 +25,14 @@ export const TICKET_TONE: Record<TicketStatus, 'ok' | 'warn' | 'err' | 'accent'
open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err', open: 'accent', in_progress: 'warn', waiting: undefined, closed: 'ok', dropped: 'err',
} }
/** Classification key label, resolved against the setting-backed list (config over
* code); an unknown key shows verbatim rather than disappearing. */
export function ticketTypeLabel(types: TicketType[], key: string): string {
const k = key ?? ''
if (k === '') return '—'
return types.find((t) => t.key === k)?.label ?? k
}
/** Whole days between an opened-on date (YYYY-MM-DD) and today. */ /** Whole days between an opened-on date (YYYY-MM-DD) and today. */
function ageDays(openedOn: string): number { function ageDays(openedOn: string): number {
const opened = Date.parse(`${openedOn}T00:00:00Z`) const opened = Date.parse(`${openedOn}T00:00:00Z`)
@ -124,32 +132,44 @@ export function TicketAssignSelect(props: {
* New-ticket dialog. Client via type-ahead (Modules assign pattern) unless the * New-ticket dialog. Client via type-ahead (Modules assign pattern) unless the
* caller pre-picks one (Client 360 tab); branches load once a client is picked; * caller pre-picks one (Client 360 tab); branches load once a client is picked;
* module and kind are free text with suggestions (config over code kinds come * module and kind are free text with suggestions (config over code kinds come
* from history via the list response). * from history via the list response). The Type select is the classification
* (`ticket.types` setting row, code fallback while it loads); `initialModuleCode`
* pre-fills the module so Client 360's per-module "Raise ticket" lands here ready.
*/ */
export function NewTicketDialog(props: { export function NewTicketDialog(props: {
open: boolean; onClose: () => void; onCreated: () => void open: boolean; onClose: () => void; onCreated: () => void
kinds: string[] kinds: string[]
/** Pre-picked client (Client 360) — hides the type-ahead. */ /** Pre-picked client (Client 360) — hides the type-ahead. */
initialClient?: { id: string; code: string; name: string } initialClient?: { id: string; code: string; name: string }
/** Pre-filled module code (Client 360 per-module "Raise ticket for this module"). */
initialModuleCode?: string
}) { }) {
const toast = useToast() const toast = useToast()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hits, setHits] = useState<Client[]>([]) const [hits, setHits] = useState<Client[]>([])
const [pick, setPick] = useState<{ id: string; code: string; name: string } | undefined>() const [pick, setPick] = useState<{ id: string; code: string; name: string } | undefined>()
const [branches, setBranches] = useState<Branch[]>([]) const [branches, setBranches] = useState<Branch[]>([])
const [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' }) const [f, setF] = useState({ branchId: '', moduleCode: '', kind: '', type: '', description: '', onlineOffline: '' })
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Live catalog feeds the suggestions — future modules appear with zero code changes. // Live catalog feeds the suggestions — future modules appear with zero code changes.
const catalog = useData(getModules, []) const catalog = useData(getModules, [])
// Classification list is a DB setting; the code fallback only covers the load window.
const types = useData(getTicketTypes, [])
const typeOptions = types.data ?? TICKET_TYPE_FALLBACK
// Blank state means "whatever the list offers first" — never a hardcoded key.
const typeValue = f.type !== '' ? f.type : (typeOptions[0]?.key ?? '')
useEffect(() => { useEffect(() => {
if (!props.open) return if (!props.open) return
setError(undefined) setError(undefined)
setQ(''); setHits([]); setPick(props.initialClient) setQ(''); setHits([]); setPick(props.initialClient)
setF({ branchId: '', moduleCode: '', kind: '', description: '', onlineOffline: '' }) setF({
branchId: '', moduleCode: props.initialModuleCode ?? '', kind: '', type: '',
description: '', onlineOffline: '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open, props.initialClient?.id]) }, [props.open, props.initialClient?.id, props.initialModuleCode])
useEffect(() => { useEffect(() => {
if (pick === undefined) { setBranches([]); return } if (pick === undefined) { setBranches([]); return }
@ -174,6 +194,7 @@ export function NewTicketDialog(props: {
...(f.branchId !== '' ? { branchId: f.branchId } : {}), ...(f.branchId !== '' ? { branchId: f.branchId } : {}),
...(f.moduleCode.trim() !== '' ? { moduleCode: f.moduleCode.trim() } : {}), ...(f.moduleCode.trim() !== '' ? { moduleCode: f.moduleCode.trim() } : {}),
...(f.kind.trim() !== '' ? { kind: f.kind.trim() } : {}), ...(f.kind.trim() !== '' ? { kind: f.kind.trim() } : {}),
...(typeValue !== '' ? { type: typeValue } : {}),
...(f.description.trim() !== '' ? { description: f.description.trim() } : {}), ...(f.description.trim() !== '' ? { description: f.description.trim() } : {}),
...(f.onlineOffline !== '' ? { onlineOffline: f.onlineOffline } : {}), ...(f.onlineOffline !== '' ? { onlineOffline: f.onlineOffline } : {}),
}) })
@ -234,6 +255,11 @@ export function NewTicketDialog(props: {
value={f.moduleCode} onChange={set('moduleCode')} value={f.moduleCode} onChange={set('moduleCode')}
/> />
</FormField> </FormField>
<FormField label="Type">
<select className="wf" value={typeValue} onChange={set('type')}>
{typeOptions.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</FormField>
<FormField label="Kind"> <FormField label="Kind">
<input <input
className="wf" list="hq-ticket-kinds" placeholder="e.g. NEW WORK" className="wf" list="hq-ticket-kinds" placeholder="e.g. NEW WORK"
@ -273,6 +299,7 @@ export function NewTicketDialog(props: {
export function Tickets() { export function Tickets() {
const [status, setStatus] = useState('open') const [status, setStatus] = useState('open')
const [scope, setScope] = useState<'all' | 'mine'>('all') const [scope, setScope] = useState<'all' | 'mine'>('all')
const [type, setType] = useState('all')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [err, setErr] = useState('') const [err, setErr] = useState('')
@ -280,6 +307,9 @@ export function Tickets() {
const me = staffId() const me = staffId()
const nav = useNavigate() const nav = useNavigate()
const employees = useData(() => getEmployees(), []) const employees = useData(() => getEmployees(), [])
// Classification chips come from the `ticket.types` setting (config over code).
const types = useData(getTicketTypes, [])
const typeOptions = types.data ?? TICKET_TYPE_FALLBACK
// Debounced so typing in the search box doesn't fire a server request per keystroke. // Debounced so typing in the search box doesn't fire a server request per keystroke.
const qDebounced = useDebounced(q) const qDebounced = useDebounced(q)
@ -287,10 +317,11 @@ export function Tickets() {
() => getTickets({ () => getTickets({
...(status === 'overdue' ? { overdue: true } : status !== 'all' ? { status: status as TicketStatus } : {}), ...(status === 'overdue' ? { overdue: true } : status !== 'all' ? { status: status as TicketStatus } : {}),
...(scope === 'mine' ? { mine: true } : {}), ...(scope === 'mine' ? { mine: true } : {}),
...(type !== 'all' ? { type } : {}),
...(qDebounced !== '' ? { q: qDebounced } : {}), ...(qDebounced !== '' ? { q: qDebounced } : {}),
page, pageSize: PAGE_SIZE, page, pageSize: PAGE_SIZE,
}), }),
[status, scope, qDebounced, page], [status, scope, type, qDebounced, page],
) )
const data = list.data const data = list.data
@ -320,6 +351,14 @@ export function Tickets() {
active={status} active={status}
onChange={(k) => { setStatus(k); setPage(1) }} onChange={(k) => { setStatus(k); setPage(1) }}
/> />
<FilterChips
chips={[
{ key: 'all', label: 'All types' },
...typeOptions.map((t) => ({ key: t.key, label: t.label })),
]}
active={type}
onChange={(k) => { setType(k); setPage(1) }}
/>
{me !== '' && ( {me !== '' && (
<FilterChips <FilterChips
chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]} chips={[{ key: 'all', label: 'All' }, { key: 'mine', label: 'Mine' }]}
@ -341,7 +380,9 @@ export function Tickets() {
: rows === undefined ? <Skeleton rows={6} /> : rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? ( : rows.length === 0 ? (
<EmptyState> <EmptyState>
No {status !== 'all' ? TICKET_STATUS_LABEL[status as TicketStatus].toLowerCase() : ''} tickets {/* `status` can be 'overdue' (a synthetic chip, not a real TicketStatus) or 'all'
guarded with a raw-key fallback so an unmapped value never throws (was a white-screen bug). */}
No {status !== 'all' ? (TICKET_STATUS_LABEL[status as TicketStatus] ?? status).toLowerCase() : ''} tickets
{scope === 'mine' ? ' of yours' : ''}{q !== '' ? ` matching “${q}` : ''}. {scope === 'mine' ? ' of yours' : ''}{q !== '' ? ` matching “${q}` : ''}.
</EmptyState> </EmptyState>
) : ( ) : (
@ -353,6 +394,7 @@ export function Tickets() {
{ key: 'client', label: 'Client' }, { key: 'client', label: 'Client' },
{ key: 'branch', label: 'Branch' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'module', label: 'Module', mono: true },
{ key: 'type', label: 'Type' },
{ key: 'kind', label: 'Kind' }, { key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'desc', label: 'Description' },
{ key: 'assigned', label: 'Assigned' }, { key: 'assigned', label: 'Assigned' },
@ -364,7 +406,12 @@ export function Tickets() {
age: <TicketAge openedOn={t.openedOn} status={t.status} slaDays={data?.slaDays ?? 7} />, age: <TicketAge openedOn={t.openedOn} status={t.status} slaDays={data?.slaDays ?? 7} />,
client: <Link to={`/clients/${t.clientId}`}>{t.clientName}</Link>, client: <Link to={`/clients/${t.clientId}`}>{t.clientName}</Link>,
branch: t.branchName ?? '—', branch: t.branchName ?? '—',
module: t.moduleCode ?? '—', // The module cell jumps straight to that client's Modules tab, where the
// project's status line, service data and documents all live.
module: t.moduleCode !== null && t.moduleCode !== ''
? <Link to={`/clients/${t.clientId}?tab=modules`}>{t.moduleCode}</Link>
: '—',
type: ticketTypeLabel(typeOptions, t.type),
kind: t.kind !== '' ? t.kind : '—', kind: t.kind !== '' ? t.kind : '—',
desc: <TicketDescription text={t.description} />, desc: <TicketDescription text={t.description} />,
assigned: employees.data !== undefined assigned: employees.data !== undefined

@ -284,25 +284,21 @@ export function InteractionDialog(props: {
export function AssignModuleForm(props: { export function AssignModuleForm(props: {
modules: Module[] modules: Module[]
onAssign: (moduleId: string, kind: Kind, creds?: { username: string; password: string }) => void onAssign: (moduleId: string, kind: Kind) => void
}) { }) {
const [moduleId, setModuleId] = useState('') const [moduleId, setModuleId] = useState('')
const mod = props.modules.find((m) => m.id === moduleId) const mod = props.modules.find((m) => m.id === moduleId)
const [kind, setKind] = useState<Kind | ''>('') const [kind, setKind] = useState<Kind | ''>('')
const [username, setUsername] = useState('') const ready = moduleId !== '' && kind !== ''
const [password, setPassword] = useState('') const reset = () => { setModuleId(''); setKind('') }
// SMS is a gateway service — its login is required at assign time so the balance pull works.
const needsCreds = mod?.code === 'SMS'
const ready = moduleId !== '' && kind !== '' && (!needsCreds || (username.trim() !== '' && password.trim() !== ''))
const reset = () => { setModuleId(''); setKind(''); setUsername(''); setPassword('') }
return ( return (
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}> <div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}>
<Toolbar> <Toolbar>
<select <select
className="wf" value={moduleId} className="wf" value={moduleId}
onChange={(e) => { setModuleId(e.target.value); setKind(''); setUsername(''); setPassword('') }} onChange={(e) => { setModuleId(e.target.value); setKind('') }}
> >
<option value="">Assign module</option> <option value="">Add a module</option>
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)} {props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select> </select>
{mod !== undefined && ( {mod !== undefined && (
@ -311,30 +307,20 @@ export function AssignModuleForm(props: {
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} {mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select> </select>
)} )}
{needsCreds && (
<>
<input className="wf mono" style={{ width: 150 }} placeholder="SMS username *" aria-label="SMS username"
value={username} onChange={(e) => setUsername(e.target.value)} />
<input className="wf mono" style={{ width: 150 }} placeholder="SMS password *" aria-label="SMS password"
value={password} onChange={(e) => setPassword(e.target.value)} />
</>
)}
<Button <Button
tone="primary" disabled={!ready} tone="primary" disabled={!ready}
onClick={() => { onClick={() => {
if (!ready) return if (!ready) return
props.onAssign(moduleId, kind as Kind, needsCreds ? { username: username.trim(), password: password.trim() } : undefined) props.onAssign(moduleId, kind as Kind)
reset() reset()
}} }}
> >
Assign Add a module
</Button> </Button>
</Toolbar> </Toolbar>
{needsCreds && ( <span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}> Starts the module at "quoted" and opens its onboarding status line.
SMS needs the provider gateway login username and password are required so the balance can be pulled. </span>
</span>
)}
</div> </div>
) )
} }
@ -355,22 +341,6 @@ export function ClientModuleStatusSelect(props: {
) )
} }
export function RowDate(props: {
cm: ClientModule; field: 'installedOn' | 'completedOn' | 'trainedOn'
onPatched: () => void; onError: (msg: string) => void
}) {
return (
<input
type="date" className="wf" style={{ width: 140 }}
value={props.cm[props.field] ?? ''}
onChange={(e) => {
patchClientModule(props.cm.id, { [props.field]: e.target.value !== '' ? e.target.value : null })
.then(props.onPatched).catch((err: Error) => props.onError(err.message))
}}
/>
)
}
/** /**
* Record a payment against the client amounts entered in rupees, stored in * Record a payment against the client amounts entered in rupees, stored in
* integer paise via fromRupees. Shared with DocumentView's "Record payment". * integer paise via fromRupees. Shared with DocumentView's "Record payment".

@ -4,10 +4,13 @@
// skips a client whose name already contains that number, and only touches found=true rows. // skips a client whose name already contains that number, and only touches found=true rows.
// Does NOT overwrite the name's spelling/casing — only appends the number. // Does NOT overwrite the name's spelling/casing — only appends the number.
// LTD_JSON=<results.json> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts // LTD_JSON=<results.json> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { openDb } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { updateClient } from '../src/repos-clients' import { updateClient } from '../src/repos-clients'
import type { DB } from '../src/db'
interface Res { clientId: string; ltdNo: string; found: boolean } interface Res { clientId: string; ltdNo: string; found: boolean }
@ -16,27 +19,39 @@ async function main() {
if (path === undefined) throw new Error('Set LTD_JSON to the workflow result json path') if (path === undefined) throw new Error('Set LTD_JSON to the workflow result json path')
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { results?: Res[] } | Res[] const parsed = JSON.parse(readFileSync(path, 'utf8')) as { results?: Res[] } | Res[]
const results = Array.isArray(parsed) ? parsed : (parsed.results ?? []) const results = Array.isArray(parsed) ? parsed : (parsed.results ?? [])
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
let applied = 0, already = 0, skipped = 0 // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
const seen = new Set<string>() // unlike the server, this one-off script hard-fails rather than silently falling back to a
for (const r of results) { // throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
const ltd = (r.ltdNo ?? '').trim() const pgUrl = requireDbUrlForEnv()
if (!r.found || ltd === '' || seen.has(r.clientId)) { skipped++; continue } if (pgUrl !== '') {
seen.add(r.clientId) console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.clientId) } else {
if (cur === undefined) { skipped++; continue } console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
// Already carries this number (bare, or "LTD. K 1028" with spaces/punct)? Don't duplicate. }
// Compare with all non-alphanumerics stripped so "K 1028" matches "K1028". const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const squish = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, '') try {
if (squish(cur.name).includes(squish(ltd))) { already++; continue } let applied = 0, already = 0, skipped = 0
const newName = `${cur.name} LTD NO. ${ltd}` const seen = new Set<string>()
await updateClient(db, 'system', r.clientId, { name: newName }) for (const r of results) {
applied++ const ltd = (r.ltdNo ?? '').trim()
if (!r.found || ltd === '' || seen.has(r.clientId)) { skipped++; continue }
seen.add(r.clientId)
const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.clientId)
if (cur === undefined) { skipped++; continue }
// Already carries this number (bare, or "LTD. K 1028" with spaces/punct)? Don't duplicate.
// Compare with all non-alphanumerics stripped so "K 1028" matches "K1028".
const squish = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, '')
if (squish(cur.name).includes(squish(ltd))) { already++; continue }
const newName = `${cur.name} LTD NO. ${ltd}`
await updateClient(db, 'system', r.clientId, { name: newName })
applied++
}
// eslint-disable-next-line no-console
console.log(`LTD numbers: ${applied} appended, ${already} already present, ${skipped} skipped (not found / no number).`)
} finally {
await db.close()
} }
// eslint-disable-next-line no-console
console.log(`LTD numbers: ${applied} appended, ${already} already present, ${skipped} skipped (not found / no number).`)
} }
main().catch((e) => { console.error(e); process.exit(1) }) main().catch((e) => { console.error(e); process.exit(1) })

@ -1,10 +1,13 @@
// apps/hq/scripts/apply-name-fixes.ts — one-off (D35 data correction): apply the approved // apps/hq/scripts/apply-name-fixes.ts — one-off (D35 data correction): apply the approved
// client-name corrections from name-review.json via the audited updateClient path. // client-name corrections from name-review.json via the audited updateClient path.
// REVIEW_JSON=<path> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts // REVIEW_JSON=<path> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { openDb } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { updateClient } from '../src/repos-clients' import { updateClient } from '../src/repos-clients'
import type { DB } from '../src/db'
interface Row { id: string; current: string; suggested: string; changed: boolean } interface Row { id: string; current: string; suggested: string; changed: boolean }
@ -12,26 +15,38 @@ async function main() {
const path = process.env['REVIEW_JSON'] const path = process.env['REVIEW_JSON']
if (path === undefined) throw new Error('Set REVIEW_JSON to the name-review.json path') if (path === undefined) throw new Error('Set REVIEW_JSON to the name-review.json path')
const rows = JSON.parse(readFileSync(path, 'utf8')) as Row[] const rows = JSON.parse(readFileSync(path, 'utf8')) as Row[]
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
let applied = 0, skipped = 0 // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
for (const r of rows) { // unlike the server, this one-off script hard-fails rather than silently falling back to a
if (!r.changed || r.suggested === r.current) continue // throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
try { const pgUrl = requireDbUrlForEnv()
// Guard: only touch a client whose stored name still matches what was reviewed. if (pgUrl !== '') {
const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.id) console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
if (cur === undefined || cur.name !== r.current) { skipped++; continue } } else {
await updateClient(db, 'system', r.id, { name: r.suggested }) console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
applied++ }
} catch (e) { const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
skipped++ try {
// eslint-disable-next-line no-console let applied = 0, skipped = 0
console.error(` skip ${r.current}: ${e instanceof Error ? e.message : String(e)}`) for (const r of rows) {
if (!r.changed || r.suggested === r.current) continue
try {
// Guard: only touch a client whose stored name still matches what was reviewed.
const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.id)
if (cur === undefined || cur.name !== r.current) { skipped++; continue }
await updateClient(db, 'system', r.id, { name: r.suggested })
applied++
} catch (e) {
skipped++
// eslint-disable-next-line no-console
console.error(` skip ${r.current}: ${e instanceof Error ? e.message : String(e)}`)
}
} }
// eslint-disable-next-line no-console
console.log(`Name corrections: ${applied} applied, ${skipped} skipped.`)
} finally {
await db.close()
} }
// eslint-disable-next-line no-console
console.log(`Name corrections: ${applied} applied, ${skipped} skipped.`)
} }
main().catch((e) => { console.error(e); process.exit(1) }) main().catch((e) => { console.error(e); process.exit(1) })

@ -0,0 +1,101 @@
// One-off deploy step for the Client Detail redesign: writes the live onboarding-template
// settings with the composed quotation-led pipeline (shared front + per-module tail), then runs
// the tick-preserving migration so existing SMS/RTGS/MobileApp projects adopt the new steps.
// Idempotent ONLY as long as the templates below haven't been owner-edited since: each setting
// is written only if it is still ABSENT, so a plain re-run never clobbers an owner's edit made
// in the Modules onboarding-template editor. Pass --force to unconditionally overwrite every
// template with the settings below — this DOES clobber owner edits, use with care.
// Run from repo root, naming the database explicitly (this header previously omitted the
// DATABASE_URL prefix — followed literally on the production host it resolves to SQLite, not
// the real database, and the script now refuses rather than migrating the wrong DB):
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
// To deliberately run against a local SQLite database instead, opt in explicitly:
// HQ_DB_TARGET=sqlite npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts (or --sqlite)
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import { getSetting, setSetting } from '../src/repos-reminders'
import type { DB } from '../src/db'
const FRONT = [
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
]
const TAILS: Record<string, { key: string; label: string }[]> = {
SMS: [
{ key: 'document_collection', label: 'Document collection' },
{ key: 'dlt_registration', label: 'DLT registration' },
{ key: 'account_creation', label: 'Account creation & registration' },
{ key: 'installation', label: 'Installation' },
{ key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'payment_received', label: 'Payment received' },
],
RTGS: [
{ key: 'document_collection', label: 'Document collection' },
{ key: 'server_checkup', label: 'Server checkup' },
{ key: 'setup_configuration', label: 'Setup & configuration' },
{ key: 'installation', label: 'Installation' },
{ key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'payment_received', label: 'Payment received' },
],
MOBILEAPP: [
{ key: 'requirement_setup', label: 'Requirement & setup' },
{ key: 'configuration', label: 'Configuration' },
{ key: 'data_import', label: 'Data import' },
{ key: 'installation', label: 'Installation' },
{ key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' },
{ key: 'payment_received', label: 'Payment received' },
],
}
async function main(): Promise<void> {
// Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
// unlike the server, this one-off script hard-fails rather than silently falling back to a
// throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
const pgUrl = requireDbUrlForEnv()
if (pgUrl !== '') {
console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
} else {
console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
}
const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
try {
const force = process.argv.includes('--force')
// Audited via setSetting (append-only-audit rule) and, unless --force, skips any key
// that's already set — so an owner's edit in the Modules onboarding-template editor is
// left alone on a re-run instead of being silently clobbered.
const upsert = async (key: string, list: unknown): Promise<void> => {
if (!force && (await getSetting(db, key)) !== null) {
// eslint-disable-next-line no-console
console.log(`Skipped ${key} — already set (pass --force to overwrite an owner edit).`)
return
}
await setSetting(db, 'system', key, JSON.stringify(list))
}
await upsert('project.milestone_template.front', FRONT)
for (const [code, list] of Object.entries(TAILS)) await upsert(`project.milestone_template:${code}`, list)
// eslint-disable-next-line no-console
console.log('Template settings step done: front + SMS/RTGS/MOBILEAPP tails.')
const res = await migrateOnboardingTemplates(db)
// eslint-disable-next-line no-console
console.log(
`Onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ` +
`${res.dropped} dropped, ${res.collapsed} collapsed (two old ticks -> one key), ` +
`${res.preserved} custom step(s) preserved.`,
)
} finally {
await db.close()
}
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -0,0 +1,24 @@
// apps/hq/scripts/backup.ts — `npm run backup` (D33). Dumps the production Postgres to a
// timestamped, compressed, restorable file under ./backups (or $HQ_BACKUP_DIR) and rotates to
// the newest $HQ_BACKUP_KEEP (default 14). Run from the repo root:
// npm run backup
// DATABASE_URL=postgres://… npm run backup (explicit target, overrides resolution)
// The target is resolved through the SAME shared resolver the server and every other
// maintenance script uses (`requireDbUrlForEnv`, D19) — the old scripts/backup.mjs read
// DATABASE_URL directly and so could never find the production database, which is resolved
// rather than exported on that box. The resolved host/db is logged (never the password)
// before pg_dump runs.
//
// Restore a dump with:
// pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
//
// SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
// logins. Keep backups/ access-controlled and off any shared drive (it is gitignored).
import { runBackup } from '../src/backup'
try {
runBackup()
} catch (e) {
console.error(e instanceof Error ? e.message : String(e))
process.exit(1)
}

@ -3,9 +3,12 @@
// and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT. // and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT.
// Idempotent: skips a username/email that already exists. // Idempotent: skips a username/email that already exists.
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts // DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts
import { openDb } from '../src/db' // SQLite is never the default: to target a local SQLite database, opt in explicitly with
import { openPgDb } from '../src/db-pg' // HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { createEmployee } from '../src/repos-employees' import { createEmployee } from '../src/repos-employees'
import type { DB } from '../src/db'
// From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank. // From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank.
const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [ const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [
@ -28,31 +31,42 @@ function slug(name: string): string {
} }
async function main() { async function main() {
// Same engine selection as the server: DATABASE_URL → Postgres, else SQLite at HQ_DATA_DIR. // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
const pgUrl = process.env['DATABASE_URL'] ?? '' // unlike the server, this one-off script hard-fails rather than silently falling back to a
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) // throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
let added = 0, skipped = 0 const pgUrl = requireDbUrlForEnv()
const summary: string[] = [] if (pgUrl !== '') {
for (const e of EMPLOYEES) { console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
const username = slug(e.name) } else {
const password = `${username}@123` console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
const email = `${username}@sims.local` // placeholder (no real emails in the master) }
try { const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const emp = await createEmployee(db, 'system', { try {
email, username, displayName: e.name, role: 'staff', password, let added = 0, skipped = 0
mustChangePassword: true, ...(e.title !== undefined ? { title: e.title } : {}), const summary: string[] = []
}) for (const e of EMPLOYEES) {
// Set inactive employees inactive (createEmployee makes them active by default). const username = slug(e.name)
if (!e.active) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, emp.id) const password = `${username}@123`
added++ const email = `${username}@sims.local` // placeholder (no real emails in the master)
summary.push(` + ${e.name} → username: ${username} password: ${password} ${e.active ? '' : '(inactive)'}`) try {
} catch (err) { const emp = await createEmployee(db, 'system', {
skipped++ email, username, displayName: e.name, role: 'staff', password,
summary.push(` - ${e.name} skipped: ${err instanceof Error ? err.message : String(err)}`) mustChangePassword: true, ...(e.title !== undefined ? { title: e.title } : {}),
})
// Set inactive employees inactive (createEmployee makes them active by default).
if (!e.active) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, emp.id)
added++
summary.push(` + ${e.name} → username: ${username} password: ${password} ${e.active ? '' : '(inactive)'}`)
} catch (err) {
skipped++
summary.push(` - ${e.name} skipped: ${err instanceof Error ? err.message : String(err)}`)
}
} }
// eslint-disable-next-line no-console
console.log(`Employee import: ${added} added, ${skipped} skipped.\n${summary.join('\n')}`)
} finally {
await db.close()
} }
// eslint-disable-next-line no-console
console.log(`Employee import: ${added} added, ${skipped} skipped.\n${summary.join('\n')}`)
} }
main().catch((e) => { console.error(e); process.exit(1) }) main().catch((e) => { console.error(e); process.exit(1) })

@ -3,17 +3,35 @@
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe // checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
// to re-run (a second run does nothing). // to re-run (a second run does nothing).
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts // DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
import { openDb } from '../src/db' // SQLite is never the default: to target a local SQLite database, opt in explicitly with
import { openPgDb } from '../src/db-pg' // HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import type { DB } from '../src/db'
async function main() { async function main(): Promise<void> {
const pgUrl = process.env['DATABASE_URL'] ?? '' // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) // unlike the server, this one-off script hard-fails rather than silently falling back to a
// throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
const res = await migrateOnboardingTemplates(db) const pgUrl = requireDbUrlForEnv()
// eslint-disable-next-line no-console if (pgUrl !== '') {
console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped.`) console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
} else {
console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
}
const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
try {
const res = await migrateOnboardingTemplates(db)
// eslint-disable-next-line no-console
console.log(
`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ` +
`${res.dropped} dropped, ${res.collapsed} collapsed (two old ticks -> one key), ` +
`${res.preserved} custom step(s) preserved.`,
)
} finally {
await db.close()
}
} }
main().catch((e) => { console.error(e); process.exit(1) }) main().catch((e) => { console.error(e); process.exit(1) })

@ -0,0 +1,75 @@
// Go-live data correction: the earlier migrate-sqlite-to-pg.ts run pulled from local SQLite,
// which turned out to be stale/near-empty — the real working data was in a local Postgres
// instance instead. This wipes the TARGET (cloud) hq database's public schema and reloads it
// from the real SOURCE (local Postgres). Both engines are Postgres here, so every column type
// matches exactly — no conversion needed.
// SOURCE_DATABASE_URL and TARGET_DATABASE_URL must both be set explicitly, no defaults, so
// this can never accidentally point at (or wipe) the wrong database.
//
// Run from repo root:
// SOURCE_DATABASE_URL=postgres://hq:<local-pw>@127.0.0.1:5432/hq ^
// TARGET_DATABASE_URL=postgres://postgres:<cloud-pw>@13.232.53.115:5432/hq ^
// npx tsx apps/hq/scripts/migrate-pg-to-pg.ts
import { Pool } from 'pg'
import { openPgDb, type PgDb } from '../src/db-pg'
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
async function main(): Promise<void> {
const sourceUrl = process.env['SOURCE_DATABASE_URL']
const targetUrl = process.env['TARGET_DATABASE_URL']
if (sourceUrl === undefined || sourceUrl === '') throw new Error('Set SOURCE_DATABASE_URL (local Postgres).')
if (targetUrl === undefined || targetUrl === '') throw new Error('Set TARGET_DATABASE_URL (cloud Postgres).')
if (sourceUrl === targetUrl) throw new Error('SOURCE and TARGET are identical — refusing.')
// Wipe the target's public schema completely, then let the app's own migrations rebuild
// it clean — guarantees the target schema matches this codebase exactly, not a stale copy.
const wipePool = new Pool({ connectionString: targetUrl, max: 1 })
await wipePool.query('DROP SCHEMA public CASCADE')
await wipePool.query('CREATE SCHEMA public')
await wipePool.end()
console.log('Target schema wiped.')
const target = (await openPgDb(targetUrl)) as PgDb // recreates schema via migrations, empty
const source = (await openPgDb(sourceUrl)) as PgDb // idempotent no-op if already migrated
const tables = await source.all<{ tablename: string }>(`SELECT tablename FROM pg_tables WHERE schemaname='public'`)
const client = await target.pool.connect()
try {
await client.query('BEGIN')
// Load in discovery order and ignore FK/trigger checks — safe: one transaction into a
// target we just wiped and confirmed empty.
await client.query('SET LOCAL session_replication_role = replica')
for (const { tablename: name } of tables) {
if (SKIP_TABLES.has(name)) {
console.log(`skip ${name}`)
continue
}
const rows = await source.all<Record<string, unknown>>(`SELECT * FROM ${name}`)
if (rows.length === 0) {
console.log(`${name}: 0 rows`)
continue
}
const cols = Object.keys(rows[0]!)
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
for (const row of rows) {
await client.query(insertSql, cols.map((c) => row[c]))
}
console.log(`${name}: ${rows.length} row(s) migrated`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
console.log('Migration complete.')
}
main().catch((e: unknown) => {
console.error(e)
process.exit(1)
})

@ -0,0 +1,76 @@
// Go-live data carry-over: copies every row from the local SQLite dev database into the
// target (cloud) Postgres database, preserving values and ids as-is — this is a one-time
// full copy, not the "born in Postgres" empty-start path in docs/DEPLOY-HQ.md.
// Column types are a straight 1:1 map on this schema (money is bigint paise, flags stay
// integer, JSON payloads stay text — see migrations-pg.ts header), so no value conversion
// is needed; every column is copied through unchanged.
// Refuses to run if the target already has staff_user rows, so a re-run (or pointing at
// the wrong DATABASE_URL) can't silently duplicate or clobber data.
// stg_client/stg_invoice are APEX-import scratch tables, not real data — skipped.
//
// Run from repo root, source = local ./data/hq.db (or HQ_DATA_DIR), target = DATABASE_URL:
// DATABASE_URL=postgres://postgres:<pw>@<host>:5432/hq npx tsx apps/hq/scripts/migrate-sqlite-to-pg.ts
import { openDb, type SqliteDb } from '../src/db'
import { openPgDb, type PgDb } from '../src/db-pg'
const SKIP_TABLES = new Set(['stg_client', 'stg_invoice', 'schema_migrations'])
async function main(): Promise<void> {
const pgUrl = process.env['DATABASE_URL']
if (pgUrl === undefined || pgUrl === '') {
throw new Error('Set DATABASE_URL to the target (cloud) Postgres connection string.')
}
const sqlite = openDb(process.env['HQ_DATA_DIR']) as SqliteDb
const pg = (await openPgDb(pgUrl)) as PgDb // schema is created by migrations here; seedIfEmpty is NOT called
const existingStaff = (await pg.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!.n
if (existingStaff > 0) {
throw new Error(
`Target already has ${existingStaff} staff_user row(s) — refusing to import over existing data. ` +
`Wrong DATABASE_URL, or this has already been migrated.`,
)
}
const tables = sqlite.raw
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.all() as { name: string }[]
const client = await pg.pool.connect()
try {
await client.query('BEGIN')
// Load in table-discovery order and ignore FK/trigger checks — safe here because the
// whole load is one transaction into a database we just confirmed is empty.
await client.query('SET LOCAL session_replication_role = replica')
for (const { name } of tables) {
if (SKIP_TABLES.has(name)) {
console.log(`skip ${name}`)
continue
}
const rows = sqlite.raw.prepare(`SELECT * FROM ${name}`).all() as Record<string, unknown>[]
if (rows.length === 0) {
console.log(`${name}: 0 rows`)
continue
}
const cols = Object.keys(rows[0]!)
const placeholders = cols.map((_, i) => `$${i + 1}`).join(',')
const insertSql = `INSERT INTO ${name} (${cols.join(',')}) VALUES (${placeholders})`
for (const row of rows) {
await client.query(insertSql, cols.map((c) => row[c]))
}
console.log(`${name}: ${rows.length} row(s) migrated`)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
console.log('Migration complete.')
}
main().catch((e: unknown) => {
console.error(e)
process.exit(1)
})

@ -21,7 +21,7 @@ import {
} from './repos-modules' } from './repos-modules'
import { createBranch, listBranches, updateBranch } from './repos-branches' import { createBranch, listBranches, updateBranch } from './repos-branches'
import { import {
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, overdueCount, updateTicket, createTicket, getTicket, listTickets, ticketCounts, ticketKinds, ticketTypes, overdueCount, updateTicket,
TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus, TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus,
} from './repos-tickets' } from './repos-tickets'
import { import {
@ -69,8 +69,9 @@ import { clearLegacyCiphertext } from './migrate-plaintext'
import { assertSafeGatewayUrl } from './sms-gateway' import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
import { import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail,
projectsPendingMilestone, setMilestone, stalledProjects, listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone,
setModuleTail, stalledProjects,
} from './repos-milestones' } from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex' import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates' import { documentHtml, documentHtmlSample } from './templates'
@ -481,6 +482,29 @@ export function apiRouter(
} }
}) })
// ---------- onboarding step templates (owner editor): shared front + per-module tail ----------
r.get('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
try {
const code = String(req.params['code'] ?? '')
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
res.json({ ok: true, steps: await getModuleTail(db, code) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
try {
const code = String(req.params['code'] ?? '')
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
const body = req.body as { steps?: unknown }
res.json({ ok: true, steps: await setModuleTail(db, staffId(res), code, body.steps) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- module → client roster (spec §10, D-ROSTER) ---------- // ---------- module → client roster (spec §10, D-ROSTER) ----------
// Module directory (D28): every client on a module with its service fields, for review. // Module directory (D28): every client on a module with its service fields, for review.
r.get('/modules/:id/directory', requireAuth, async (req, res) => { r.get('/modules/:id/directory', requireAuth, async (req, res) => {
@ -722,8 +746,12 @@ export function apiRouter(
if ((await getClientModule(db, id)) === null) { if ((await getClientModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client module not found' }); return res.status(404).json({ ok: false, error: 'Client module not found' }); return
} }
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports) try {
res.json({ ok: true, milestones: await listProjectMilestones(db, id) }) await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
}) })
r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => { r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '') const id = String(req.params['id'] ?? '')
@ -731,7 +759,10 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return res.status(404).json({ ok: false, error: 'Client module not found' }); return
} }
try { try {
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown } const body = req.body as {
key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown
paymentId?: unknown; documentId?: unknown
}
if (typeof body.label === 'string') { if (typeof body.label === 'string') {
// Add a project-specific milestone. // Add a project-specific milestone.
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) }) res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
@ -742,7 +773,8 @@ export function apiRouter(
} }
const milestones = await setMilestone(db, staffId(res), id, body.key, body.done, const milestones = await setMilestone(db, staffId(res), id, body.key, body.done,
typeof body.doneOn === 'string' ? body.doneOn : undefined, typeof body.doneOn === 'string' ? body.doneOn : undefined,
typeof body.paymentId === 'string' ? body.paymentId : undefined) typeof body.paymentId === 'string' ? body.paymentId : undefined,
typeof body.documentId === 'string' ? body.documentId : undefined)
res.json({ ok: true, milestones }) res.json({ ok: true, milestones })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@ -750,18 +782,64 @@ export function apiRouter(
}) })
// The cross-project board + drill-down (the "who's pending go-live" view). // The cross-project board + drill-down (the "who's pending go-live" view).
r.get('/projects/board', requireAuth, async (_req, res) => { r.get('/projects/board', requireAuth, async (_req, res) => {
res.json({ ok: true, board: await milestoneBoard(db) }) try {
res.json({ ok: true, board: await milestoneBoard(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
}) })
r.get('/projects/pending/:key', requireAuth, async (req, res) => { r.get('/projects/pending/:key', requireAuth, async (req, res) => {
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) }) try {
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
}) })
// Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile). // Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile).
// FIX B (pre-ship audit): a caller-supplied `days` used to flow straight into `new Date(...)`
// math with no bound and no try/catch — an out-of-range/NaN value threw a RangeError that,
// as an unhandled rejection, crashed the whole process. `days` is now validated (finite
// integer, sane bounds) and the handler is wrapped like its siblings.
r.get('/projects/stalled', requireAuth, async (req, res) => { r.get('/projects/stalled', requireAuth, async (req, res) => {
const q = req.query['days'] try {
const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q)) const q = req.query['days']
? Number(q) : await getNumberSetting(db, 'project.stall_days', 30) let days: number
const today = new Date().toISOString().slice(0, 10) if (typeof q === 'string' && q !== '') {
res.json({ ok: true, projects: await stalledProjects(db, days, today) }) const parsed = Number(q)
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 3650) {
res.status(400).json({ ok: false, error: 'days must be an integer between 0 and 3650' })
return
}
days = parsed
} else {
days = await getNumberSetting(db, 'project.stall_days', 30)
}
const today = new Date().toISOString().slice(0, 10)
// FIX 4 (no silent caps): unknownAgeExcluded surfaces the count of pending projects the
// query could not prove old (no ticks and no client_module.created_at) instead of
// silently dropping them off the tile with no trace.
const { rows, unknownAgeExcluded } = await stalledProjects(db, days, today)
res.json({ ok: true, projects: rows, unknownAgeExcluded })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor).
r.get('/onboarding-template/front', requireAuth, requireOwner, async (_req, res) => {
try {
res.json({ ok: true, steps: await getFrontTemplate(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/onboarding-template/front', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { steps?: unknown }
res.json({ ok: true, steps: await setFrontTemplate(db, staffId(res), body.steps) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
}) })
// ---------- client branches (D20) ---------- // ---------- client branches (D20) ----------
@ -804,6 +882,10 @@ export function apiRouter(
}) })
// ---------- ticket desk (D20 — team-visible workbench) ---------- // ---------- ticket desk (D20 — team-visible workbench) ----------
// The classification list (config over code: the `ticket.types` setting, code fallback).
r.get('/tickets/types', requireAuth, async (_req, res) => {
res.json({ ok: true, types: await ticketTypes(db) })
})
r.get('/tickets', requireAuth, async (req, res) => { r.get('/tickets', requireAuth, async (req, res) => {
try { try {
const q = req.query const q = req.query
@ -819,6 +901,7 @@ export function apiRouter(
? { assignedTo: staffId(res) } ? { assignedTo: staffId(res) }
: typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}), : typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}),
...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}), ...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}),
...(typeof q['type'] === 'string' && q['type'] !== '' ? { type: q['type'] } : {}),
...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}), ...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}),
...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}), ...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}),
page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50), page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50),

@ -0,0 +1,128 @@
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { sqliteFilePath } from './db'
import { pgTargetDescription, resolveDbUrl } from './db-pg'
/**
* Database backup (D33): `pg_dump` of the production Postgres to a timestamped, compressed,
* restorable file under ./backups (or $HQ_BACKUP_DIR), rotated to the newest N.
*
* Lives in src/ (not scripts/) so it is typechecked and unit-testable; `scripts/backup.ts` is
* the thin CLI wrapper `npm run backup` invokes.
*
* The fix this file exists for: the previous `scripts/backup.mjs` read `process.env.DATABASE_URL`
* directly and bailed out when it was unset. On the production box DATABASE_URL is NOT set the
* connection is resolved by `resolveDbUrl` (D19) so the backup command could never reach the
* real database. It now resolves its target through the SAME shared resolver every other
* maintenance script uses, and logs the resolved target (never the password) before it runs.
*
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext logins.
* Keep the backups/ directory access-controlled and off any shared drive; it is gitignored so it
* can never be committed.
*/
/** Env knobs, defaulted here so the CLI wrapper stays a one-liner. */
export const DEFAULT_KEEP = 14
/**
* The Postgres URL this backup run must dump via `resolveDbUrl`, the one shared resolver
* (D19: DATABASE_URL, else the production connection). This is the fix: the target is
* RESOLVED exactly as the server resolves it, instead of being read straight off
* `process.env.DATABASE_URL`, which is not set on the production box.
*
* A SQLite outcome is fatal here rather than an opt-in (unlike the migration scripts, which
* can legitimately run against SQLite): `pg_dump` cannot read a SQLite file at all, so the
* only useful answer is a loud error naming the SQLite snapshot script instead.
*/
export function resolveBackupTarget(env: NodeJS.ProcessEnv = process.env): string {
const url = resolveDbUrl(env)
if (url === '') {
throw new Error(
'[backup] refusing to run: no Postgres target resolved, so this would back up nothing. ' +
`The database resolved to SQLite at ${sqliteFilePath(env['HQ_DATA_DIR'])}, and this ` +
'backup dumps Postgres (pg_dump). Set DATABASE_URL=postgres://… to back up the real ' +
'database, or use apps/hq/scripts/backup-hq.sh for a SQLite snapshot.',
)
}
return url
}
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
export function findPgDump(): string | null {
try {
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
return 'pg_dump'
} catch { /* not on PATH */ }
if (process.platform === 'win32') {
const base = 'C:/Program Files/PostgreSQL'
try {
for (const v of readdirSync(base).sort().reverse()) {
const cand = join(base, v, 'bin', 'pg_dump.exe')
if (existsSync(cand)) return cand
}
} catch { /* no install dir */ }
}
return null
}
/** `hq-YYYYMMDD-HHMMSS.dump` for the given local time. Pure — the rotation regex must match it. */
export function backupFileName(now: Date): string {
const p2 = (n: number): string => String(n).padStart(2, '0')
const ts = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}-` +
`${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`
return `hq-${ts}.dump`
}
const DUMP_RE = /^hq-\d{8}-\d{6}\.dump$/
/** Which of `files` fall outside the newest `keep` dumps. Pure, so rotation is testable. */
export function dumpsToRotate(files: readonly string[], keep: number): string[] {
const dumps = files.filter((f) => DUMP_RE.test(f)).sort() // name sorts chronologically
return dumps.slice(0, Math.max(0, dumps.length - Math.max(1, keep)))
}
export interface BackupResult { file: string; bytes: number; rotated: string[]; retained: number }
/**
* Run the backup end to end. `log` is injected so the CLI prints and tests stay quiet.
* Env: HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
*/
export function runBackup(
env: NodeJS.ProcessEnv = process.env,
log: (msg: string) => void = console.log,
): BackupResult {
const url = resolveBackupTarget(env)
// Log the resolved target BEFORE doing anything: a backup that quietly dumped the wrong
// database is indistinguishable from a correct one until the day you need the restore.
log(`[db] engine: postgres ${pgTargetDescription(url)}`)
const pgDump = findPgDump()
if (pgDump === null) {
throw new Error('[backup] pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
}
const out = env['HQ_BACKUP_DIR'] || join(process.cwd(), 'backups')
const keep = Math.max(1, Number(env['HQ_BACKUP_KEEP'] || DEFAULT_KEEP))
mkdirSync(out, { recursive: true })
const file = join(out, backupFileName(new Date()))
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
try {
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
} catch (e) {
throw new Error(`[backup] pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
}
const bytes = statSync(file).size
log(`✓ backup written: ${file} (${(bytes / 1024).toFixed(1)} KB)`)
const rotated = dumpsToRotate(readdirSync(out), keep)
for (const f of rotated) {
unlinkSync(join(out, f))
log(` rotated out: ${f}`)
}
const retained = readdirSync(out).filter((f) => DUMP_RE.test(f)).length
log(`retained ${retained} backup(s) in ${out} (HQ_BACKUP_KEEP=${keep})`)
return { file, bytes, rotated, retained }
}

@ -1,6 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks' import { AsyncLocalStorage } from 'node:async_hooks'
import fs from 'node:fs'
import pg from 'pg' import pg from 'pg'
import type { DB } from './db' import { sqliteFilePath, type DB } from './db'
import { PG_MIGRATIONS } from './migrations-pg' import { PG_MIGRATIONS } from './migrations-pg'
/** /**
@ -102,6 +103,97 @@ export class PgDb implements DB {
} }
} }
// D19 engine selection, the ONE place it is decided (pre-ship audit FIX A): DATABASE_URL
// (or, in production with no DATABASE_URL set, this hardcoded connection — team decision,
// used instead of a .env DATABASE_URL to match how the other apps on this box are
// configured) → Postgres; otherwise SQLite. Gated to NODE_ENV=production so dev and the
// test suite keep selecting SQLite (an unset/empty DATABASE_URL) and stay green.
// ⚠️ Once the real password is filled in, this file holds a secret — do not push the
// filled-in value to a repo others can read.
const HARDCODED_DATABASE_URL = 'postgres://postgres:inv123@host.docker.internal:5432/hq'
/**
* Resolve which DB engine + target this process should use server.ts AND every deploy
* script MUST call this (never re-derive it locally). Before this fix, two deploy scripts
* computed it differently (`DATABASE_URL ?? ''`, with no production fallback) and, on the
* production box where DATABASE_URL is unset, silently opened a brand-new empty SQLite file
* instead of the real Postgres the migration "succeeded" against a throwaway DB while
* production was never touched. '' means SQLite.
*/
export function resolveDbUrl(env: NodeJS.ProcessEnv = process.env, hardcoded = HARDCODED_DATABASE_URL): string {
return env['DATABASE_URL'] || (env['NODE_ENV'] === 'production' ? hardcoded : '')
}
/**
* Same resolution, but hard-fails instead of silently falling back to a THROWAWAY SQLite
* database for one-off deploy/maintenance scripts, where creating a brand-new empty DB is
* never the right outcome (unlike the server, which legitimately creates one on first boot in
* dev this function is never called from server.ts).
*
* The guard is OUTCOME-based, not NODE_ENV-based. The original fix gated the hard-fail on
* `NODE_ENV === 'production'`, but NODE_ENV is exactly what these scripts don't reliably have:
* the server gets it from its service definition, while the scripts are run by hand
* (`npx tsx apps/hq/scripts/…`) from an operator shell with no NODE_ENV and no dotenv. So on
* the real prod box the old guard never fired, resolution returned `''`, and the script
* silently opened/created a brand-new empty SQLite file in `<cwd>/data` "succeeding" against
* a throwaway DB while production was never touched.
*
* Fix: whenever resolution selects SQLite, refuse UNLESS the target file already exists a
* migration/maintenance script must never CREATE a database, regardless of what NODE_ENV is or
* isn't set to. `hardcoded` and `sqliteExists` are both injectable so tests never touch a real
* Postgres URL or the real filesystem.
*
* Second guard (same family of bug, one step earlier): landing on SQLite at all must be a
* DECISION, never a default. An operator who follows a script's documented command with no
* `DATABASE_URL=` in front of it should be told so, not quietly pointed at whatever `./data/hq.db`
* happens to exist under their shell's cwd. So SQLite additionally requires an explicit opt-in
* `HQ_DB_TARGET=sqlite` or a `--sqlite` argv flag (`HQ_DATA_DIR=:memory:` counts too: nobody sets
* that by accident). Without one, the script refuses and the error names both options.
* `argv` is injectable for the same reason as the rest: tests never depend on the real process.
*/
export function requireDbUrlForEnv(
env: NodeJS.ProcessEnv = process.env,
hardcoded = HARDCODED_DATABASE_URL,
sqliteExists: (file: string) => boolean = fs.existsSync,
argv: readonly string[] = process.argv,
): string {
const pgUrl = resolveDbUrl(env, hardcoded)
if (pgUrl === '') {
const file = sqliteFilePath(env['HQ_DATA_DIR'])
const optedIn =
file === ':memory:' ||
(env['HQ_DB_TARGET'] ?? '').trim().toLowerCase() === 'sqlite' ||
argv.includes('--sqlite')
if (!optedIn) {
throw new Error(
'[db] refusing to run: no Postgres target resolved (DATABASE_URL is unset), and SQLite ' +
'has not been explicitly requested — running against the wrong database is worse than ' +
'not running at all. Either set DATABASE_URL=postgres://… to target the real database, ' +
`or opt into SQLite explicitly with HQ_DB_TARGET=sqlite (or the --sqlite flag), which ` +
`would use ${file}.`,
)
}
if (file !== ':memory:' && !sqliteExists(file)) {
throw new Error(
`[db] refusing to run: this would CREATE a new SQLite database at ${file}. ` +
'Set DATABASE_URL to target Postgres, or point HQ_DATA_DIR at the existing database.',
)
}
}
return pgUrl
}
/** Human-readable engine target for startup/deploy-script logs. Never includes the password. */
export function pgTargetDescription(pgUrl: string): string {
try {
const u = new URL(pgUrl)
const port = u.port !== '' ? `:${u.port}` : ''
return `${u.hostname}${port}${u.pathname}`
} catch {
return '(unparsable connection string)'
}
}
/** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */ /** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */
export async function runPgMigrations(db: PgDb): Promise<string[]> { export async function runPgMigrations(db: PgDb): Promise<string[]> {
await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (id text PRIMARY KEY, applied_at text NOT NULL)`) await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (id text PRIMARY KEY, applied_at text NOT NULL)`)

@ -247,15 +247,22 @@ CREATE TABLE IF NOT EXISTS client_module (
-- module's non-secret declared fields; secrets_enc is ONE AES-256-GCM blob over a JSON -- module's non-secret declared fields; secrets_enc is ONE AES-256-GCM blob over a JSON
-- map { fieldKey: plaintext } for its secret fields (N secrets, one encrypt; reveal -- map { fieldKey: plaintext } for its secret fields (N secrets, one encrypt; reveal
-- audited, never in list payloads). Keys are validated against the module's field_spec. -- audited, never in list payloads). Keys are validated against the module's field_spec.
field_values TEXT NOT NULL DEFAULT '{}', secrets_enc TEXT field_values TEXT NOT NULL DEFAULT '{}', secrets_enc TEXT,
-- Pre-ship audit FIX D: when this project was created/assigned (nullable set by
-- assignModule going forward; NULL for rows that predate this column, never fabricated).
created_at TEXT
); );
CREATE TABLE IF NOT EXISTS client_branch ( CREATE TABLE IF NOT EXISTS client_branch (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL, id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
code TEXT, active INTEGER NOT NULL DEFAULT 1 code TEXT, active INTEGER NOT NULL DEFAULT 1
); );
-- D22 onboarding tracker: each client_module (a "project") gets a checklist of milestones -- D22 onboarding tracker: each client_module (a "project") gets a COMPOSED checklist
-- seeded from the dated project.milestone_template setting on assignment. done_on stamps -- a shared "front" (enquiry quotation approved, from the project.milestone_template.front
-- when a milestone is ticked. Reportable across all projects (who is pending go-live). -- setting) followed by a per-module "tail" (project.milestone_template:<CODE>, else the
-- global project.milestone_template, else the code fallback) materialized on assignment.
-- done_on stamps when a step is ticked; payment_id links a payment step to the real payment
-- row and document_id links the quotation step to the quotation/proforma that was sent (both
-- added by migrate(), below). Reportable across all projects (who is pending go-live).
CREATE TABLE IF NOT EXISTS project_milestone ( CREATE TABLE IF NOT EXISTS project_milestone (
id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL, id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL,
key TEXT NOT NULL, label TEXT NOT NULL, key TEXT NOT NULL, label TEXT NOT NULL,
@ -266,6 +273,7 @@ CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT, id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT,
module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention) module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention)
kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions) kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions)
type TEXT NOT NULL DEFAULT 'service', -- classification key from the ticket.types setting (config over code)
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')), status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')),
assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench) assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench)
@ -408,16 +416,26 @@ CREATE TABLE IF NOT EXISTS aws_usage (
); );
` `
/** Absolute path (or ':memory:') the given HQ_DATA_DIR resolves to the same rule openDb
* uses to pick its file, exposed so deploy scripts can log their target BEFORE opening it
* (pre-ship audit FIX A: a script that silently opens the wrong DB is indistinguishable
* from a correct idempotent no-op run unless it logs where it actually landed). */
export function sqliteFilePath(dataDir?: string): string {
if (dataDir === ':memory:') return ':memory:'
const dir = dataDir !== undefined ? path.resolve(dataDir) : path.resolve(process.cwd(), 'data')
return path.join(dir, 'hq.db')
}
/** Open the SQLite engine (dev/tests). Deliberately sync schema + migrate run /** Open the SQLite engine (dev/tests). Deliberately sync schema + migrate run
* on the raw handle before it is wrapped, so test fixtures stay one-liners. */ * on the raw handle before it is wrapped, so test fixtures stay one-liners. */
export function openDb(dataDir?: string): DB { export function openDb(dataDir?: string): DB {
let raw: SqliteRaw let raw: SqliteRaw
if (dataDir === ':memory:') { const target = sqliteFilePath(dataDir)
if (target === ':memory:') {
raw = new Database(':memory:') raw = new Database(':memory:')
} else { } else {
const dir = dataDir ?? path.resolve(process.cwd(), 'data') fs.mkdirSync(path.dirname(target), { recursive: true })
fs.mkdirSync(dir, { recursive: true }) raw = new Database(target)
raw = new Database(path.join(dir, 'hq.db'))
raw.pragma('journal_mode = WAL') raw.pragma('journal_mode = WAL')
} }
raw.exec(SCHEMA) raw.exec(SCHEMA)
@ -509,6 +527,22 @@ function migrate(db: SqliteRaw): void {
if (!cmCols.some((c) => c.name === 'field_values')) { if (!cmCols.some((c) => c.name === 'field_values')) {
db.exec(`ALTER TABLE client_module ADD COLUMN field_values TEXT NOT NULL DEFAULT '{}'`) db.exec(`ALTER TABLE client_module ADD COLUMN field_values TEXT NOT NULL DEFAULT '{}'`)
} }
// Pre-ship audit FIX D: a no-tick project's "parked since" needs its own real creation
// date. Nullable — already-existing rows keep an unknown/NULL creation date (never
// fabricated) so they're never mistakenly flagged as ancient; assignModule stamps every
// NEW client_module going forward.
if (!cmCols.some((c) => c.name === 'created_at')) {
db.exec(`ALTER TABLE client_module ADD COLUMN created_at TEXT`)
}
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at
// (only assignModule stamped it), so every imported project — the ~300-client real book —
// permanently NULL'd out of the "Onboarding stalled" tile's age measurement. Backfill from
// installed_on where it's known; a row with neither is genuinely unknown-age and stays
// NULL (never fabricated a date). Runs every migrate() pass — idempotent (only touches rows
// still NULL), so it also heals DBs that already had the column from before this fix.
db.exec(
`UPDATE client_module SET created_at = installed_on WHERE created_at IS NULL AND installed_on IS NOT NULL`,
)
const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[] const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!modCols2.some((c) => c.name === 'field_spec')) { if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`) db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)
@ -521,6 +555,15 @@ function migrate(db: SqliteRaw): void {
if (!milestoneCols.some((c) => c.name === 'payment_id')) { if (!milestoneCols.some((c) => c.name === 'payment_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`) db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`)
} }
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
if (!milestoneCols.some((c) => c.name === 'document_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN document_id TEXT`)
}
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
const ticketCols = db.prepare(`PRAGMA table_info(ticket)`).all() as { name: string }[]
if (!ticketCols.some((c) => c.name === 'type')) {
db.exec(`ALTER TABLE ticket ADD COLUMN type TEXT NOT NULL DEFAULT 'service'`)
}
rebuildStaffUserRoleCheck(db) rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db) rebuildReminderRuleKindCheck(db)
} }

@ -1138,12 +1138,15 @@ export async function commitApexFull(
) )
if (existingCm === undefined) { if (existingCm === undefined) {
await db.run( await db.run(
// FIX 4 (regression): stamp created_at like assignModule does — otherwise every
// imported project with zero ticks has a NULL created_at and is permanently
// invisible on the "Onboarding stalled" tile, exactly the population it exists for.
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition, active, `INSERT INTO client_module (id, client_id, module_id, status, kind, edition, active,
installed_on, provider, username, password_enc, details, remark, field_values) installed_on, provider, username, password_enc, details, remark, field_values, created_at)
VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, 'yearly', 'standard', 1, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.id, a.clientId, moduleId, a.status, a.installedOn, a.id, a.clientId, moduleId, a.status, a.installedOn,
a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark, a.provider, a.username, a.passwordEnc, JSON.stringify(a.details), a.remark,
JSON.stringify(a.fieldValues), JSON.stringify(a.fieldValues), now,
) )
} else if (a.serviceTouched) { } else if (a.serviceTouched) {
// Client pre-dated this import with a live assignment: service data wins. // Client pre-dated this import with a live assignment: service data wins.

@ -1,37 +1,70 @@
// apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time // apps/hq/src/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
// migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding // migration swapping seeded modules (SMS/RTGS/MobileApp) off the old generic onboarding
// checklist onto their new per-module template (task 2), carrying mapped ticks // checklist onto their new per-module template (task 2), carrying mapped ticks
// (done + done_on + payment_id) so onboarding history is not lost. // (done + done_on + payment_id + document_id) so onboarding history is not lost. Ad-hoc
// project-specific steps ("+ Add step" / `addProjectMilestone`, `custom_*` keys) are never
// template-mapped — they are preserved verbatim, appended after the template block, so a
// staffer's own checklist entries survive the swap too (see `preserved`, below).
import { uuidv7 } from '@sims/domain' import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import { milestoneTemplate } from './repos-milestones' import { FRONT_FALLBACK, TAIL_FALLBACK, milestoneTemplate } from './repos-milestones'
import type { DB } from './db' import type { DB } from './db'
/** old generic key -> new template key (only applied if the new template has that key). */ /** old generic key -> new template key (only applied if the new template has that key). */
const KEY_MAP: Record<string, string> = { const KEY_MAP: Record<string, string> = {
installed: 'installation', go_live: 'go_live', installed: 'installation', go_live: 'go_live',
advance_paid: 'advance_payment', balance_paid: 'balance_payment', advance_paid: 'payment_received', balance_paid: 'payment_received',
advance_payment: 'payment_received', balance_payment: 'payment_received',
setup_done: 'setup_configuration', setup_done: 'setup_configuration',
} }
interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null } /**
* Old generic (pre-redesign) keys that intentionally have NO forward mapping e.g.
* `amc_start` (AMC/renewal is tracked elsewhere now). These are code-level defaults from
* FRONT_FALLBACK/TAIL_FALLBACK, not ad-hoc project-specific steps (those always use
* `addProjectMilestone`'s `custom_*` key pattern), so a ticked one still counts as `dropped`
* never `preserved` same as before this fix.
*/
const KNOWN_LEGACY_KEYS = new Set([...FRONT_FALLBACK, ...TAIL_FALLBACK].map((t) => t.key))
interface OldRow {
key: string; label: string; done: number; done_on: string | null
payment_id: string | null; document_id: string | null
}
/**
* When two+ old keys collapse onto the SAME new key (e.g. `advance_paid` + `balance_paid`
* both -> `payment_received`), pick which tick survives, deterministically: prefer whichever
* carries a real linked payment (`payment_id`) that link is the more "meaningful" tick;
* if both or neither do, prefer the one with the LATEST `done_on` (the more recent progress).
* The loser is never silently overwritten the caller counts it (`collapsed`) and the full
* pre-migration row set goes into the audit `before` payload, so it's always reconstructible.
*/
function pickSurvivingTick(a: OldRow, b: OldRow): OldRow {
const aHasPayment = a.payment_id !== null && a.payment_id !== ''
const bHasPayment = b.payment_id !== null && b.payment_id !== ''
if (aHasPayment !== bHasPayment) return aHasPayment ? a : b
return (a.done_on ?? '') >= (b.done_on ?? '') ? a : b
}
/** /**
* Idempotent: for every client_module whose module has a per-module onboarding template * Idempotent: for every client_module whose module has a per-module onboarding template
* (a `project.milestone_template:<CODE>` setting task 2), rebuild its milestone rows * (a `project.milestone_template:<CODE>` setting task 2), rebuild its milestone rows
* from that template, carrying mapped ticks (done/done_on/payment_id) forward under their * from that template, carrying mapped ticks (done/done_on/payment_id/document_id) forward under their
* new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new * new key. Unmapped old GENERIC ticks (e.g. `amc_start`, or `setup_done` for a module whose
* template has no matching step) are dropped (and counted). Client_modules whose module * new template has no matching step) are dropped (and counted). Ad-hoc project-specific rows
* has no per-module template are left completely untouched. * (any key that isn't a template key, isn't in KEY_MAP, and isn't a known old generic key) are
* preserved unchanged and re-appended after the template block (counted in `preserved`).
* Client_modules whose module has no per-module template are left completely untouched.
*/ */
export async function migrateOnboardingTemplates( export async function migrateOnboardingTemplates(
db: DB, db: DB,
): Promise<{ projects: number; carried: number; dropped: number }> { ): Promise<{ projects: number; carried: number; dropped: number; preserved: number; collapsed: number }> {
// Which module codes have a per-module template setting? // Which module codes have a per-module template setting?
const codes = (await db.all<{ key: string }>( const codes = (await db.all<{ key: string }>(
`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)) `SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`))
.map((r) => r.key.slice('project.milestone_template:'.length)) .map((r) => r.key.slice('project.milestone_template:'.length))
let projects = 0, carried = 0, dropped = 0 let projects = 0, carried = 0, dropped = 0, preserved = 0, collapsed = 0
for (const code of codes) { for (const code of codes) {
const template = await milestoneTemplate(db, code) const template = await milestoneTemplate(db, code)
const templateKeys = new Set(template.map((t) => t.key)) const templateKeys = new Set(template.map((t) => t.key))
@ -39,23 +72,42 @@ export async function migrateOnboardingTemplates(
`SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code) `SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code)
for (const { id: cmId } of cms) { for (const { id: cmId } of cms) {
const oldRows = await db.all<OldRow>( const oldRows = await db.all<OldRow>(
`SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId) `SELECT key, label, done, done_on, payment_id, document_id FROM project_milestone
// Build the carried tick-state keyed by NEW key. WHERE client_module_id=? ORDER BY sort`, cmId)
// Build the carried tick-state keyed by NEW key, and set aside ad-hoc custom rows.
const carriedState = new Map<string, OldRow>() const carriedState = new Map<string, OldRow>()
let alreadyNew = true const customRows: OldRow[] = []
for (const r of oldRows) { for (const r of oldRows) {
if (!templateKeys.has(r.key)) alreadyNew = false const isAdHocCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key)
if (isAdHocCustom) { customRows.push(r); continue }
const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key] const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key]
if (target !== undefined && templateKeys.has(target)) { if (target !== undefined && templateKeys.has(target)) {
if (r.done === 1) carriedState.set(target, r) if (r.done === 1) {
const existing = carriedState.get(target)
if (existing === undefined) {
carriedState.set(target, r)
} else {
// Two old keys collapse onto the same new key — deterministically pick a
// survivor (never a silent Map.set overwrite) and count the one that loses.
carriedState.set(target, pickSurvivingTick(existing, r))
collapsed++
}
}
} else if (r.done === 1) { } else if (r.done === 1) {
dropped++ dropped++
} }
} }
// Idempotency: if the project's keys already ARE exactly the template, skip. // Idempotency: if the project's keys already ARE exactly the template, skip. Computed
const oldKeySet = new Set(oldRows.map((r) => r.key)) // over NON-CUSTOM rows only (regression fix): the old check flagged `alreadyNew = false`
const identical = alreadyNew && oldKeySet.size === templateKeys.size // for ANY row whose key isn't a template key — including deliberately-preserved `custom_*`
&& [...templateKeys].every((k) => oldKeySet.has(k)) // rows (the `addProjectMilestone` "+ Add step" key pattern) — so a project with even one
// ad-hoc step was never "identical" and got DELETE-and-rebuilt on every single run (new
// row ids, a fresh audit row each time), contradicting "safe to re-run / a second run
// does nothing". Using the same discriminator the preservation logic above relies on.
const isCustom = (k: string): boolean => k.startsWith('custom_')
const templateRowKeys = new Set(oldRows.filter((r) => !isCustom(r.key)).map((r) => r.key))
const identical = templateRowKeys.size === templateKeys.size
&& [...templateKeys].every((k) => templateRowKeys.has(k))
if (identical) continue if (identical) continue
projects++ projects++
await db.transaction(async () => { await db.transaction(async () => {
@ -64,18 +116,35 @@ export async function migrateOnboardingTemplates(
for (const t of template) { for (const t of template) {
const c = carriedState.get(t.key) const c = carriedState.get(t.key)
await db.run( await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) `INSERT INTO project_milestone
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, (id, client_module_id, key, label, done, done_on, payment_id, document_id, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuidv7(), cmId, t.key, t.label, uuidv7(), cmId, t.key, t.label,
c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, sort, c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, c?.document_id ?? null, sort,
) )
if (c !== undefined) carried++ if (c !== undefined) carried++
sort++ sort++
} }
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined, // Ad-hoc project-specific steps survive the swap unchanged, appended after the
{ module: code, carried: [...carriedState.keys()] }) // template (sort continues past it) so this project's own checklist entries — and
// whatever tick state they carry — are never silently lost.
for (const c of customRows) {
await db.run(
`INSERT INTO project_milestone
(id, client_module_id, key, label, done, done_on, payment_id, document_id, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
uuidv7(), cmId, c.key, c.label, c.done, c.done_on, c.payment_id, c.document_id, sort,
)
sort++
}
preserved += customRows.length
// The full pre-migration row set is recorded as `before` (not just a summary) so
// ANY discarded tick — dropped, or the losing side of a collapse — is reconstructible
// from the append-only audit log, never silently unrecoverable (FIX C).
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, oldRows,
{ module: code, carried: [...carriedState.keys()], preserved: customRows.map((c) => c.key) })
}) })
} }
} }
return { projects, carried, dropped } return { projects, carried, dropped, preserved, collapsed }
} }

@ -357,4 +357,28 @@ END;
id: '015-milestone-payment-id', id: '015-milestone-payment-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`, sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`,
}, },
{
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
id: '016-milestone-document-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS document_id text;`,
},
{
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
id: '017-ticket-type',
sql: `ALTER TABLE ticket ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'service';`,
},
{
// Pre-ship audit FIX D: real creation date for the "no-tick project" stalled-onboarding
// measurement (nullable — existing rows keep an unknown/NULL date, never fabricated).
id: '018-client-module-created-at',
sql: `ALTER TABLE client_module ADD COLUMN IF NOT EXISTS created_at text;`,
},
{
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at
// (only assignModule stamped it going forward), so every one of the ~300 imported clients'
// projects permanently NULL'd out of the stalled-onboarding age measurement. Backfill from
// installed_on where known; a row with neither stays NULL (never fabricated).
id: '019-client-module-created-at-backfill',
sql: `UPDATE client_module SET created_at = installed_on WHERE created_at IS NULL AND installed_on IS NOT NULL;`,
},
] ]

@ -1,19 +1,29 @@
import { uuidv7 } from '@sims/domain' import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import { setSetting } from './repos-reminders'
import type { DB } from './db' import type { DB } from './db'
/** /**
* Onboarding tracker (D22). Each client_module (a "project") carries a checklist of * Onboarding tracker (D22), COMPOSED front + tail model. Each client_module (a "project")
* milestones advance paid, setup, install, go-live, balance, AMC seeded from the * carries a checklist built as [...front, ...tail]:
* dated `project.milestone_template` setting. Ticking one stamps the date. Reportable * - the **front** is shared by every module enquiry visit quotation sent quotation
* across the whole book: "who's pending go-live", "who hasn't paid the advance". * approved from the `project.milestone_template.front` setting (code: FRONT_FALLBACK);
* - the **tail** is module-specific the `project.milestone_template:<CODE>` setting, else
* the global `project.milestone_template`, else the code TAIL_FALLBACK.
* Both are owner-editable settings (config over code); a project may also add its own steps.
* Ticking a step stamps the date, can advance the coarse client_module.status (never
* downgrades), and can carry a link: a payment step to the real `payment` row, the quotation
* step to the `document` (quotation / proforma) that was sent. Reportable across the whole
* book: "who's pending go-live", "who hasn't paid".
*/ */
export interface Milestone { export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null key: string; label: string; done: boolean; doneOn: string | null; sort: number
paymentId: string | null; documentId: string | null
} }
interface MilestoneRow { interface MilestoneRow {
key: string; label: string; done: number; done_on: string | null; sort: number; payment_id: string | null key: string; label: string; done: number; done_on: string | null; sort: number
payment_id: string | null; document_id: string | null
} }
export interface TemplateEntry { key: string; label: string } export interface TemplateEntry { key: string; label: string }
@ -24,20 +34,75 @@ const STATUS_RANK: Record<string, number> = {
quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7, quoted: 0, ordered: 1, installing: 2, installed: 3, trained: 4, live: 5, expired: 6, cancelled: 7,
} }
const STEP_STATUS: Record<string, string> = { const STEP_STATUS: Record<string, string> = {
installation: 'installed', training: 'trained', go_live: 'live', quotation_approved: 'ordered', installation: 'installed', training: 'trained', go_live: 'live',
// Pre-ship audit FIX E: TAIL_FALLBACK (used by any module with no seeded/owner-configured
// tail — e.g. WHATSAPP, ATM, AMC) still uses the legacy 'installed' key, not the new
// template vocabulary's 'installation'. Map it to the same target so those modules still
// auto-advance status (and stamp installed_on, below) instead of getting stuck at 'quoted'.
// TAIL_FALLBACK has no training-equivalent step and no payment key ever drove status, so
// no other legacy key needs a mapping here.
installed: 'installed',
} }
async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise<void> { /**
* The two things ticking a milestone does to its parent `client_module`, applied in the
* caller's transaction and audited together:
*
* 1. **Status**, advanced to the step's target never downgraded.
* 2. **installed_on / trained_on**, stamped from the triggering milestone's own done_on.
* This also replaces the removed UI date-writers (the Client Detail redesign dropped
* `RowDate` / the summary-table date columns without a replacement), so a tick is now the
* ONLY way those columns get filled.
*
* The two are INDEPENDENT (bug fix): the date used to be stamped only as a side effect of a
* status upgrade, so on the ~300 APEX-imported projects already at `live`, the furthest
* status ticking Installation or Training upgraded nothing and therefore recorded nothing,
* leaving the Module Directory's "Installed" column blank forever with no way to fill it.
* Ticking the step now stamps the date whether or not the coarse status moves. Still
* write-once: a date already on file (from the APEX importer or an earlier tick) is never
* overwritten. If neither the status nor a date would change, nothing is written and no audit
* row is added a re-tick stays a no-op.
*/
async function applyStepEffects(
db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null,
): Promise<void> {
const target = STEP_STATUS[key] const target = STEP_STATUS[key]
if (target === undefined) return if (target === undefined) return
const cm = await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, clientModuleId) const cm = await db.get<{ status: string; installed_on: string | null; trained_on: string | null }>(
`SELECT status, installed_on, trained_on FROM client_module WHERE id=?`, clientModuleId)
if (cm === undefined) return if (cm === undefined) return
if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade const sets: string[] = []
await db.run(`UPDATE client_module SET status=? WHERE id=?`, target, clientModuleId) const args: unknown[] = []
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target }) const before: Record<string, unknown> = {}
const after: Record<string, unknown> = {}
if ((STATUS_RANK[cm.status] ?? 0) < (STATUS_RANK[target] ?? 0)) { // never downgrade
sets.push('status=?'); args.push(target)
before['status'] = cm.status; after['status'] = target
}
if (target === 'installed' && cm.installed_on === null && doneOn !== null) {
sets.push('installed_on=?'); args.push(doneOn)
before['installedOn'] = null; after['installedOn'] = doneOn
}
if (target === 'trained' && cm.trained_on === null && doneOn !== null) {
sets.push('trained_on=?'); args.push(doneOn)
before['trainedOn'] = null; after['trainedOn'] = doneOn
}
if (sets.length === 0) return
args.push(clientModuleId)
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, before, after)
} }
export const FALLBACK_TEMPLATE: TemplateEntry[] = [ /** The shared lead-in every module's checklist starts with (code default for the "front"). */
export const FRONT_FALLBACK: TemplateEntry[] = [
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
]
/** Code default for the module-specific "tail" when nothing is configured. */
export const TAIL_FALLBACK: TemplateEntry[] = [
{ key: 'advance_paid', label: 'Advance payment received' }, { key: 'advance_paid', label: 'Advance payment received' },
{ key: 'setup_done', label: 'Setup / configuration done' }, { key: 'setup_done', label: 'Setup / configuration done' },
{ key: 'installed', label: 'Installation done' }, { key: 'installed', label: 'Installation done' },
@ -46,23 +111,123 @@ export const FALLBACK_TEMPLATE: TemplateEntry[] = [
{ key: 'amc_start', label: 'AMC / renewal start' }, { key: 'amc_start', label: 'AMC / renewal start' },
] ]
/** The checklist for a module (owner-editable per module via setting; global default; code fallback). */ function parseTemplate(value: string): TemplateEntry[] | undefined {
export async function milestoneTemplate(db: DB, moduleCode?: string): Promise<TemplateEntry[]> { try {
const parse = (value: string): TemplateEntry[] | undefined => { const p = JSON.parse(value) as TemplateEntry[]
try { return Array.isArray(p) && p.length > 0 ? p : undefined
const p = JSON.parse(value) as TemplateEntry[] } catch { return undefined }
return Array.isArray(p) && p.length > 0 ? p : undefined }
} catch { return undefined }
} /** The shared "front" alone — setting row, else the code fallback. */
if (moduleCode !== undefined && moduleCode !== '') { export async function getFrontTemplate(db: DB): Promise<TemplateEntry[]> {
const frontRow = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key='project.milestone_template.front'`)
return (frontRow !== undefined ? parseTemplate(frontRow.value) : undefined) ?? FRONT_FALLBACK
}
/** One module's "tail" alone (not composed with the front): its own per-module setting,
* else the global tail setting, else the code fallback. `moduleCode` may be '' (no module
* context) resolves straight to the global/fallback tail. */
export async function getModuleTail(db: DB, moduleCode: string): Promise<TemplateEntry[]> {
let tail: TemplateEntry[] | undefined
if (moduleCode !== '') {
const perModule = await db.get<{ value: string }>( const perModule = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`) `SELECT value FROM setting WHERE key=?`, `project.milestone_template:${moduleCode}`)
const parsed = perModule !== undefined ? parse(perModule.value) : undefined tail = perModule !== undefined ? parseTemplate(perModule.value) : undefined
if (parsed !== undefined) return parsed }
if (tail === undefined) {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`)
tail = row !== undefined ? parseTemplate(row.value) : undefined
}
return tail ?? TAIL_FALLBACK
}
/**
* The checklist for a module: a shared "front" (same for every module enquiry through
* quotation-approved) followed by a per-module "tail" (owner-editable per module via
* setting; global default; code fallback). Composed as [...front, ...tail].
*/
export async function milestoneTemplate(db: DB, moduleCode?: string): Promise<TemplateEntry[]> {
const front = await getFrontTemplate(db)
const tail = await getModuleTail(db, moduleCode ?? '')
return [...front, ...tail]
}
/** Make a valid step key from a label: lowercase, non-alphanumeric -> `_`, collapse repeats, trim `_`. */
function slugifyStepLabel(label: string): string {
return label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')
}
/**
* Validate + normalize a template-editor PUT body's `steps` into `TemplateEntry[]`: a
* non-empty array, every label non-blank after trim, every key unique. A missing/blank key
* is derived by slugifying its label (bumping a numeric suffix on collision). Throws with a
* clear message otherwise the caller turns that into a 400.
*/
export function normalizeTemplateSteps(rawSteps: unknown): TemplateEntry[] {
if (!Array.isArray(rawSteps) || rawSteps.length === 0) throw new Error('Provide at least one step')
const used = new Set<string>()
const out: TemplateEntry[] = []
for (const raw of rawSteps) {
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown }
const label = typeof r.label === 'string' ? r.label.trim() : ''
if (label === '') throw new Error('Every step needs a non-blank label')
let key = typeof r.key === 'string' ? r.key.trim() : ''
if (key === '') {
const base = slugifyStepLabel(label) || 'step'
key = base
let n = 2
while (used.has(key)) key = `${base}_${n++}`
}
if (used.has(key)) throw new Error(`Duplicate step key: "${key}"`)
used.add(key)
out.push({ key, label })
}
return out
}
/** Every module code with its OWN configured tail setting (not the global fallback). */
async function configuredTailCodes(db: DB): Promise<string[]> {
const rows = await db.all<{ key: string }>(`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)
return rows.map((r) => r.key.slice('project.milestone_template:'.length))
}
/** Throws a clear 400-able error if any of `frontKeys` collides with a key in `tail`.
* `tailLabel` identifies the tail in the message (a module code, or 'default'). */
function rejectCollision(frontKeys: Set<string>, tail: TemplateEntry[], tailLabel: string): void {
const hit = tail.find((t) => frontKeys.has(t.key))
if (hit !== undefined) {
throw new Error(`Step key "${hit.key}" is used by both the front and the ${tailLabel} tail template — keys must be unique across the whole checklist`)
} }
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='project.milestone_template'`) }
const parsedGlobal = row !== undefined ? parse(row.value) : undefined
return parsedGlobal ?? FALLBACK_TEMPLATE /**
* Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`.
* The front is composed with EVERY module's tail ([...front, ...tail]), so a front key must
* not collide with any tail currently in play: the global/default tail, and every module that
* has its own configured tail.
*/
export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
const frontKeys = new Set(steps.map((s) => s.key))
rejectCollision(frontKeys, await getModuleTail(db, ''), 'default')
for (const code of await configuredTailCodes(db)) {
rejectCollision(frontKeys, await getModuleTail(db, code), code)
}
await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps))
return steps
}
/**
* Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`.
* This tail is always composed after the shared front, so its keys must not collide with it.
*/
export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
const frontKeys = new Set((await getFrontTemplate(db)).map((f) => f.key))
rejectCollision(frontKeys, steps, moduleCode || 'default')
await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps))
return steps
} }
/** /**
@ -88,6 +253,10 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
VALUES (?, ?, ?, ?, 0, NULL, ?)`, VALUES (?, ?, ?, ?, 0, NULL, ?)`,
uuidv7(), clientModuleId, t.key, t.label, sort, uuidv7(), clientModuleId, t.key, t.label, sort,
) )
// A template with a front/tail key collision (owner-editor bug, or a legacy setting
// written before that was validated) would otherwise INSERT the same
// (client_module_id, key) twice and violate the UNIQUE constraint mid-loop.
existing.add(t.key)
} }
sort++ sort++
} }
@ -96,29 +265,39 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> { export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> {
const rows = await db.all<MilestoneRow>( const rows = await db.all<MilestoneRow>(
`SELECT key, label, done, done_on, sort, payment_id FROM project_milestone `SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone
WHERE client_module_id=? ORDER BY sort, label`, WHERE client_module_id=? ORDER BY sort, label`,
clientModuleId, clientModuleId,
) )
return rows.map((r) => ({ return rows.map((r) => ({
key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id, key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort,
paymentId: r.payment_id, documentId: r.document_id,
})) }))
} }
/** /**
* Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears * Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears
* it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can * it (and any linked payment / document). A payment-step tick (`payment_received`) can pass
* pass `paymentId` to link a real payment it must belong to the same client as this * `paymentId` to link a real payment it must belong to the same client as this project,
* project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is * and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is also given.
* also given. Audited. * A quotation-step tick (`quotation_sent`) can likewise pass `documentId` to link the
* quotation / proforma that was sent it must belong to the same client too. Audited.
*
* Link lifecycle (bug fix): re-ticking an ALREADY-DONE step without resending its ids keeps
* the links already on file. They used to be written as `done ? linked : null`, so any tick
* that didn't carry the ids wiped them most visibly `bulkSetMilestone`, which never passes
* them, so one bulk re-tick silently cut every project's step loose from its payment /
* document. Passing an explicit empty string still clears a link (that is the "unlink" the
* picker sends), and unticking still clears both, as before.
*/ */
export async function setMilestone( export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean, db: DB, userId: string, clientModuleId: string, key: string, done: boolean,
doneOn?: string, paymentId?: string, doneOn?: string, paymentId?: string, documentId?: string,
): Promise<Milestone[]> { ): Promise<Milestone[]> {
return db.transaction(async () => { return db.transaction(async () => {
const before = await db.get<MilestoneRow>( const before = await db.get<MilestoneRow>(
`SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`, `SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone
WHERE client_module_id=? AND key=?`,
clientModuleId, key, clientModuleId, key,
) )
if (before === undefined) throw new Error('Milestone not found on this project') if (before === undefined) throw new Error('Milestone not found on this project')
@ -126,7 +305,9 @@ export async function setMilestone(
throw new Error('doneOn must be YYYY-MM-DD') throw new Error('doneOn must be YYYY-MM-DD')
} }
let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null let stamp = done ? (doneOn ?? new Date().toISOString().slice(0, 10)) : null
let linkedPayment: string | null = null // Nothing supplied for an already-done step means "leave the link alone", not "clear it".
const wasDone = before.done === 1
let linkedPayment: string | null = paymentId === undefined && wasDone ? before.payment_id : null
if (done && paymentId !== undefined && paymentId !== '') { if (done && paymentId !== undefined && paymentId !== '') {
const pay = await db.get<{ received_on: string }>( const pay = await db.get<{ received_on: string }>(
`SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`, `SELECT received_on FROM payment WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`,
@ -136,13 +317,26 @@ export async function setMilestone(
linkedPayment = paymentId linkedPayment = paymentId
if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one
} }
let linkedDocument: string | null = documentId === undefined && wasDone ? before.document_id : null
if (done && documentId !== undefined && documentId !== '') {
const doc = await db.get<{ id: string }>(
`SELECT id FROM document WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`,
documentId, clientModuleId,
)
if (doc === undefined) throw new Error('Document not found for this client')
linkedDocument = documentId
}
// Unticking always clears both links, whatever was resolved above.
const finalPayment = done ? linkedPayment : null
const finalDocument = done ? linkedDocument : null
await db.run( await db.run(
`UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`, `UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=?
done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key, WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, finalPayment, finalDocument, clientModuleId, key,
) )
await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined, await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined,
{ key, done, doneOn: stamp, paymentId: linkedPayment }) { key, done, doneOn: stamp, paymentId: finalPayment, documentId: finalDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key) if (done) await applyStepEffects(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId) return listProjectMilestones(db, clientModuleId)
}) })
} }
@ -171,6 +365,15 @@ export async function addProjectMilestone(
* Bulk-tick one milestone across many projects at once (D26) for onboarding cleanup * Bulk-tick one milestone across many projects at once (D26) for onboarding cleanup
* (e.g. mark the already-live services 'installed' + 'go-live'). One transaction; a * (e.g. mark the already-live services 'installed' + 'go-live'). One transaction; a
* single summary audit row. Only touches projects that actually have the milestone. * single summary audit row. Only touches projects that actually have the milestone.
*
* FIX F: routes each project through `setMilestone` the SAME status-advance and
* payment/document link logic a single tick uses instead of a bare UPDATE.
* Before this, a bulk `go_live` left `client_module.status` at 'quoted' (never advanced)
* and a bulk untick left a stale `payment_id`/`document_id` link on the row.
*
* A bulk call never carries per-project ids, so it relies on `setMilestone` PRESERVING the
* links already on an already-done step (see there) otherwise re-ticking a step in bulk
* would wipe every payment/document link that had been set one at a time.
*/ */
export async function bulkSetMilestone( export async function bulkSetMilestone(
db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string, db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string,
@ -183,11 +386,17 @@ export async function bulkSetMilestone(
return db.transaction(async () => { return db.transaction(async () => {
let updated = 0 let updated = 0
for (const id of clientModuleIds) { for (const id of clientModuleIds) {
const r = await db.run( try {
`UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`, await setMilestone(db, userId, id, key, done, doneOn)
done ? 1 : 0, stamp, id, key, updated++
) } catch (err) {
updated += r.changes // Bulk ops only ever touch projects that actually carry this milestone key — same
// as before this fix, just expressed via setMilestone's own "not found" error
// instead of a bare UPDATE quietly affecting 0 rows. Anything else (a genuine,
// unexpected failure) must still surface, not be swallowed.
if (err instanceof Error && err.message === 'Milestone not found on this project') continue
throw err
}
} }
await writeAudit(db, userId, 'bulk_set_milestone', 'project_milestone', '*', undefined, await writeAudit(db, userId, 'bulk_set_milestone', 'project_milestone', '*', undefined,
{ key, done, doneOn: stamp, projects: clientModuleIds.length, updated }) { key, done, doneOn: stamp, projects: clientModuleIds.length, updated })
@ -237,19 +446,37 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
) )
} }
export interface StalledProjectsResult {
rows: PendingProject[]
/** FIX 4 (no silent caps): projects that have a pending step but whose age can't be proven
* (no ticks AND no client_module.created_at e.g. a pre-created_at row, or one an import
* didn't stamp) are excluded from `rows` rather than guessed at. This count says how many
* were dropped for that reason, so the caller can surface "N of unknown age" instead of
* silently under-reporting the stalled list. */
unknownAgeExcluded: number
}
/** /**
* Active projects with at least one pending step whose progress last moved more than * Active projects with at least one pending step whose progress last moved more than
* `olderThanDays` ago (measured against `today`, passed in never `new Date()` here, so this * `olderThanDays` ago (measured against `today`, passed in never `new Date()` here, so this
* stays testable). "Last moved" is the most recent `done_on` across the project's ticked * stays testable). "Last moved" is the most recent `done_on` across the project's ticked
* steps; a project with nothing ticked yet counts as parked since creation (no lower bound). * steps; a project with nothing ticked yet is measured from `client_module.created_at`
* instead (FIX D treating a NULL last-moved date as an infinitely old one made every
* freshly-assigned project show up as stalled immediately). When even that isn't known (a
* project created before the created_at column existed, or carried over by an import that
* doesn't stamp it) there is no way to prove it's actually old, so it is excluded rather than
* guessed at the intent is "parked past N days", not "we don't know, so assume forever"
* and counted in `unknownAgeExcluded` (FIX 4) so that exclusion is never silent.
* Non-aggregated columns are wrapped in MIN() grouping by `pm.client_module_id` alone isn't * Non-aggregated columns are wrapped in MIN() grouping by `pm.client_module_id` alone isn't
* enough for Postgres to infer they're single-valued per group (D15 portability). * enough for Postgres to infer they're single-valued per group (D15 portability).
*/ */
export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise<PendingProject[]> { export async function stalledProjects(
const rows = await db.all<PendingProject & { last_done: string | null }>( db: DB, olderThanDays: number, today: string,
): Promise<StalledProjectsResult> {
const allRows = await db.all<PendingProject & { last_done: string | null; created_at: string | null }>(
`SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId", `SELECT pm.client_module_id AS "clientModuleId", MIN(cm.client_id) AS "clientId",
MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(m.name) AS "moduleName", MIN(c.name) AS "clientName", MIN(m.code) AS "moduleCode", MIN(m.name) AS "moduleName",
MAX(pm.done_on) AS last_done MAX(pm.done_on) AS last_done, MIN(cm.created_at) AS created_at
FROM project_milestone pm FROM project_milestone pm
JOIN client_module cm ON cm.id = pm.client_module_id JOIN client_module cm ON cm.id = pm.client_module_id
JOIN client c ON c.id = cm.client_id JOIN client c ON c.id = cm.client_id
@ -259,7 +486,19 @@ export async function stalledProjects(db: DB, olderThanDays: number, today: stri
HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`, HAVING SUM(CASE WHEN pm.done = 0 THEN 1 ELSE 0 END) > 0`,
) )
const cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10) const cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10)
return rows const rows: PendingProject[] = []
.filter((r) => (r.last_done ?? '0000-00-00') < cutoff) let unknownAgeExcluded = 0
.map(({ last_done: _last_done, ...rest }) => rest) for (const r of allRows) {
const { last_done, created_at, ...rest } = r
if (last_done !== null) {
if (last_done < cutoff) rows.push(rest)
continue
}
if (created_at !== null) {
if (created_at.slice(0, 10) < cutoff) rows.push(rest)
continue
}
unknownAgeExcluded++
}
return { rows, unknownAgeExcluded }
} }

@ -491,9 +491,12 @@ export async function assignModule(db: DB, userId: string, input: AssignModuleIn
} }
const id = uuidv7() const id = uuidv7()
await db.run( await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition) // created_at (FIX D — stalled-onboarding false positives): real assignment time, so a
VALUES (?, ?, ?, ?, ?, ?)`, // brand-new project with zero ticks is measured from here, not treated as ancient.
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard', id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard',
new Date().toISOString(),
) )
await ensureProjectMilestones(db, id) // D22: seed the onboarding checklist for the new project await ensureProjectMilestones(db, id) // D22: seed the onboarding checklist for the new project
const cm = (await getClientModule(db, id))! const cm = (await getClientModule(db, id))!

@ -278,7 +278,7 @@ export async function clientLedger(db: DB, clientId: string): Promise<ClientLedg
if (d.docType === 'INVOICE' && d.docNo !== null) { if (d.docType === 'INVOICE' && d.docNo !== null) {
const out = await outstandingPaise(db, d.id) const out = await outstandingPaise(db, d.id)
if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out }) if (out > 0) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: out })
} else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted' || d.status === 'draft')) { } else if (d.docType === 'PROFORMA' && (d.status === 'sent' || d.status === 'accepted')) {
outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise }) outstanding.push({ docId: d.id, docNo: d.docNo, docType: d.docType, label: await moduleName(d), outstandingPaise: d.payablePaise })
} }
} }

@ -12,10 +12,51 @@ import type { DB } from './db'
export const TICKET_STATUSES = ['open', 'in_progress', 'waiting', 'closed', 'dropped'] as const export const TICKET_STATUSES = ['open', 'in_progress', 'waiting', 'closed', 'dropped'] as const
export type TicketStatus = (typeof TICKET_STATUSES)[number] export type TicketStatus = (typeof TICKET_STATUSES)[number]
export interface TicketType { key: string; label: string }
/**
* Code fallback for the ticket classification list. The live list is the `ticket.types`
* setting (config over code seeded on first boot, owner-editable); this is only what
* `ticketTypes` falls back to when the row is missing or unreadable.
*/
export const TICKET_TYPE_FALLBACK: TicketType[] = [
{ key: 'service', label: 'Service' },
{ key: 'module', label: 'Module issue' },
{ key: 'modification', label: 'Modification' },
{ key: 'amc', label: 'AMC' },
{ key: 'other', label: 'Other' },
]
/** The classification list: the `ticket.types` setting when present and usable, else the code fallback. */
export async function ticketTypes(db: DB): Promise<TicketType[]> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
if (row === undefined) return TICKET_TYPE_FALLBACK
try {
const parsed = JSON.parse(row.value) as unknown
if (!Array.isArray(parsed) || parsed.length === 0) return TICKET_TYPE_FALLBACK
const out: TicketType[] = []
for (const raw of parsed) {
const t = (typeof raw === 'object' && raw !== null ? raw : {}) as { key?: unknown; label?: unknown }
if (typeof t.key !== 'string' || t.key.trim() === '') return TICKET_TYPE_FALLBACK
out.push({ key: t.key.trim(), label: typeof t.label === 'string' && t.label.trim() !== '' ? t.label.trim() : t.key.trim() })
}
return out
} catch { return TICKET_TYPE_FALLBACK }
}
/** Normalize a caller-supplied type: blank → the first configured type; unknown → rejected. */
async function resolveType(db: DB, raw: unknown): Promise<string> {
const types = await ticketTypes(db)
const wanted = typeof raw === 'string' ? raw.trim() : ''
if (wanted === '') return types[0]!.key
if (!types.some((t) => t.key === wanted)) throw new Error(`Invalid ticket type: ${wanted}`)
return wanted
}
export interface Ticket { export interface Ticket {
id: string; clientId: string; clientName: string id: string; clientId: string; clientName: string
branchId: string | null; branchName: string | null branchId: string | null; branchName: string | null
moduleCode: string | null; kind: string; description: string moduleCode: string | null; kind: string; type: string; description: string
status: TicketStatus status: TicketStatus
assignedTo: string | null; assignedName: string | null assignedTo: string | null; assignedName: string | null
onlineOffline: string | null onlineOffline: string | null
@ -26,7 +67,7 @@ export interface Ticket {
interface TicketRow { interface TicketRow {
id: string; client_id: string; client_name: string id: string; client_id: string; client_name: string
branch_id: string | null; branch_name: string | null branch_id: string | null; branch_name: string | null
module_code: string | null; kind: string; description: string module_code: string | null; kind: string; type: string; description: string
status: string; assigned_to: string | null; assigned_name: string | null status: string; assigned_to: string | null; assigned_name: string | null
online_offline: string | null online_offline: string | null
opened_on: string; closed_on: string | null opened_on: string; closed_on: string | null
@ -44,7 +85,7 @@ function toTicket(r: TicketRow): Ticket {
return { return {
id: r.id, clientId: r.client_id, clientName: r.client_name, id: r.id, clientId: r.client_id, clientName: r.client_name,
branchId: r.branch_id, branchName: r.branch_name, branchId: r.branch_id, branchName: r.branch_name,
moduleCode: r.module_code, kind: r.kind, description: r.description, moduleCode: r.module_code, kind: r.kind, type: r.type, description: r.description,
status: r.status as TicketStatus, status: r.status as TicketStatus,
assignedTo: r.assigned_to, assignedName: r.assigned_name, assignedTo: r.assigned_to, assignedName: r.assigned_name,
onlineOffline: r.online_offline, onlineOffline: r.online_offline,
@ -60,6 +101,8 @@ export async function getTicket(db: DB, id: string): Promise<Ticket | null> {
export interface ListTicketsOpts { export interface ListTicketsOpts {
status?: TicketStatus; clientId?: string; assignedTo?: string; moduleCode?: string status?: TicketStatus; clientId?: string; assignedTo?: string; moduleCode?: string
/** Classification key from `ticket.types`; omitted/blank = every type. */
type?: string
/** Overdue = still-open (open/in_progress/waiting) and opened on or before this date. */ /** Overdue = still-open (open/in_progress/waiting) and opened on or before this date. */
overdueBefore?: string overdueBefore?: string
q?: string; page?: number; pageSize?: number q?: string; page?: number; pageSize?: number
@ -75,6 +118,7 @@ export async function listTickets(
if (opts.clientId !== undefined) { where += ` AND t.client_id=?`; args.push(opts.clientId) } if (opts.clientId !== undefined) { where += ` AND t.client_id=?`; args.push(opts.clientId) }
if (opts.assignedTo !== undefined) { where += ` AND t.assigned_to=?`; args.push(opts.assignedTo) } if (opts.assignedTo !== undefined) { where += ` AND t.assigned_to=?`; args.push(opts.assignedTo) }
if (opts.moduleCode !== undefined) { where += ` AND t.module_code=?`; args.push(opts.moduleCode) } if (opts.moduleCode !== undefined) { where += ` AND t.module_code=?`; args.push(opts.moduleCode) }
if (opts.type !== undefined && opts.type !== '') { where += ` AND t.type=?`; args.push(opts.type) }
if (opts.overdueBefore !== undefined) { if (opts.overdueBefore !== undefined) {
where += ` AND t.status IN ('open','in_progress','waiting') AND t.opened_on<=?` where += ` AND t.status IN ('open','in_progress','waiting') AND t.opened_on<=?`
args.push(opts.overdueBefore) args.push(opts.overdueBefore)
@ -123,6 +167,8 @@ export async function ticketKinds(db: DB): Promise<string[]> {
export interface CreateTicketInput { export interface CreateTicketInput {
clientId: string; branchId?: string | null; moduleCode?: string | null clientId: string; branchId?: string | null; moduleCode?: string | null
kind?: string; description?: string; onlineOffline?: string | null kind?: string; description?: string; onlineOffline?: string | null
/** Classification key from `ticket.types`; omitted = the first configured type ('service'). */
type?: string
assignedTo?: string | null; openedOn?: string assignedTo?: string | null; openedOn?: string
} }
@ -137,14 +183,15 @@ export async function createTicket(db: DB, userId: string, input: CreateTicketIn
if (branch === undefined) throw new Error('Branch not found for this client') if (branch === undefined) throw new Error('Branch not found for this client')
} }
if (input.assignedTo != null) await assertActiveStaff(db, input.assignedTo) if (input.assignedTo != null) await assertActiveStaff(db, input.assignedTo)
const type = await resolveType(db, input.type)
const id = uuidv7() const id = uuidv7()
const now = new Date().toISOString() const now = new Date().toISOString()
await db.run( await db.run(
`INSERT INTO ticket (id, client_id, branch_id, module_code, kind, description, status, `INSERT INTO ticket (id, client_id, branch_id, module_code, kind, type, description, status,
assigned_to, online_offline, opened_on, closed_on, created_by, created_at, source) assigned_to, online_offline, opened_on, closed_on, created_by, created_at, source)
VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL, ?, ?, 'hq')`, VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL, ?, ?, 'hq')`,
id, input.clientId, input.branchId ?? null, input.moduleCode ?? null, id, input.clientId, input.branchId ?? null, input.moduleCode ?? null,
(input.kind ?? '').trim(), (input.description ?? '').trim(), (input.kind ?? '').trim(), type, (input.description ?? '').trim(),
input.assignedTo ?? null, input.onlineOffline ?? null, input.assignedTo ?? null, input.onlineOffline ?? null,
input.openedOn ?? now.slice(0, 10), userId, now, input.openedOn ?? now.slice(0, 10), userId, now,
) )
@ -159,6 +206,8 @@ export interface TicketPatch {
/** null clears the assignment. */ /** null clears the assignment. */
assignedTo?: string | null assignedTo?: string | null
kind?: string; description?: string kind?: string; description?: string
/** Classification key from `ticket.types` — must be one of the configured keys. */
type?: string
moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null
} }
@ -186,6 +235,7 @@ export async function updateTicket(db: DB, userId: string, id: string, patch: Ti
} }
if (patch.assignedTo !== undefined) { sets.push('assigned_to=?'); args.push(patch.assignedTo) } if (patch.assignedTo !== undefined) { sets.push('assigned_to=?'); args.push(patch.assignedTo) }
if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind.trim()) } if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind.trim()) }
if (patch.type !== undefined) { sets.push('type=?'); args.push(await resolveType(db, patch.type)) }
if (patch.description !== undefined) { sets.push('description=?'); args.push(patch.description.trim()) } if (patch.description !== undefined) { sets.push('description=?'); args.push(patch.description.trim()) }
if (patch.moduleCode !== undefined) { sets.push('module_code=?'); args.push(patch.moduleCode) } if (patch.moduleCode !== undefined) { sets.push('module_code=?'); args.push(patch.moduleCode) }
if (patch.branchId !== undefined) { sets.push('branch_id=?'); args.push(patch.branchId) } if (patch.branchId !== undefined) { sets.push('branch_id=?'); args.push(patch.branchId) }

@ -5,6 +5,7 @@ import { writeAudit } from './audit'
import type { DB } from './db' import type { DB } from './db'
import { createModule } from './repos-modules' import { createModule } from './repos-modules'
import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders' import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders'
import { TICKET_TYPE_FALLBACK } from './repos-tickets'
/** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ /** First-boot seed: owner login, company settings placeholders, GST18 tax class. */
@ -86,22 +87,33 @@ export async function seedIfEmpty(db: DB): Promise<void> {
} }
if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS)
// Onboarding milestone template (D22): the standard checklist every new project // Ticket classification list (config over code): the Tickets desk reads `ticket.types`,
// (client_module) starts with. Config over code — owner-editable; a project can also add // falling back to TICKET_TYPE_FALLBACK in repos-tickets.ts when the row is missing.
// its own milestones. Order here is the display order. // Order here is display order; the first entry is the default for a new ticket.
const MILESTONE_TEMPLATE = JSON.stringify([ const seededTicketTypes = (await db.run(
{ key: 'advance_paid', label: 'Advance payment received' }, INSERT_SETTING_SQL, 'ticket.types', JSON.stringify(TICKET_TYPE_FALLBACK))).changes
{ key: 'setup_done', label: 'Setup / configuration done' }, if (seededTicketTypes > 0) {
{ key: 'installed', label: 'Installation done' }, await writeAudit(db, 'system', 'seed', 'setting', 'ticket.types', undefined,
{ key: 'go_live', label: 'Go-live' }, { types: TICKET_TYPE_FALLBACK.map((t) => t.key) })
{ key: 'balance_paid', label: 'Balance payment received' }, }
{ key: 'amc_start', label: 'AMC / renewal start' },
// Onboarding milestone template (D22), composed model: a shared "front" (same for every
// module — enquiry through quotation-approved) plus a per-module "tail". Config over code
// — owner-editable; a project can also add its own milestones. Order here is display order.
const MILESTONE_TEMPLATE_FRONT = JSON.stringify([
{ key: 'enquiry', label: 'Enquiry received' },
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
{ key: 'quotation_sent', label: 'Quotation sent' },
{ key: 'quotation_approved', label: 'Quotation approved' },
]) ])
const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes const seededFront = (await db.run(
if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) INSERT_SETTING_SQL, 'project.milestone_template.front', MILESTONE_TEMPLATE_FRONT)).changes
if (seededFront > 0) {
await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template.front', undefined, { set: true })
}
// Per-module onboarding templates (config over code). SMS/RTGS/Mobile App differ; each ends // Per-module onboarding tails (config over code). SMS/RTGS/Mobile App differ; each ends
// with advance + balance payment so a payment tick maps cleanly on migration. // with go-live + payment received so a payment tick maps cleanly on migration.
const PER_MODULE_TEMPLATES: Record<string, { key: string; label: string }[]> = { const PER_MODULE_TEMPLATES: Record<string, { key: string; label: string }[]> = {
SMS: [ SMS: [
{ key: 'document_collection', label: 'Document collection' }, { key: 'document_collection', label: 'Document collection' },
@ -111,8 +123,7 @@ export async function seedIfEmpty(db: DB): Promise<void> {
{ key: 'testing', label: 'Testing' }, { key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' }, { key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' }, { key: 'go_live', label: 'Go-live' },
{ key: 'advance_payment', label: 'Advance payment' }, { key: 'payment_received', label: 'Payment received' },
{ key: 'balance_payment', label: 'Balance payment' },
], ],
RTGS: [ RTGS: [
{ key: 'document_collection', label: 'Document collection' }, { key: 'document_collection', label: 'Document collection' },
@ -122,8 +133,7 @@ export async function seedIfEmpty(db: DB): Promise<void> {
{ key: 'testing', label: 'Testing' }, { key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' }, { key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' }, { key: 'go_live', label: 'Go-live' },
{ key: 'advance_payment', label: 'Advance payment' }, { key: 'payment_received', label: 'Payment received' },
{ key: 'balance_payment', label: 'Balance payment' },
], ],
MOBILEAPP: [ MOBILEAPP: [
{ key: 'requirement_setup', label: 'Requirement & setup' }, { key: 'requirement_setup', label: 'Requirement & setup' },
@ -133,8 +143,7 @@ export async function seedIfEmpty(db: DB): Promise<void> {
{ key: 'testing', label: 'Testing' }, { key: 'testing', label: 'Testing' },
{ key: 'training', label: 'Training' }, { key: 'training', label: 'Training' },
{ key: 'go_live', label: 'Go-live' }, { key: 'go_live', label: 'Go-live' },
{ key: 'advance_payment', label: 'Advance payment' }, { key: 'payment_received', label: 'Payment received' },
{ key: 'balance_payment', label: 'Balance payment' },
], ],
} }
let seededPerModule = 0 let seededPerModule = 0

@ -7,7 +7,7 @@ import { apiRouter } from './api'
import { maybePullAwsCosts } from './aws-costs' import { maybePullAwsCosts } from './aws-costs'
import { pollBounces } from './bounces' import { pollBounces } from './bounces'
import { openDb, type DB } from './db' import { openDb, type DB } from './db'
import { openPgDb } from './db-pg' import { openPgDb, resolveDbUrl } from './db-pg'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { getClient } from './repos-clients' import { getClient } from './repos-clients'
@ -144,15 +144,11 @@ export async function startServer(port: number, opts: { scheduler?: boolean } =
// shares the proxy's IP and per-IP rate limiting collapses to one global bucket. // shares the proxy's IP and per-IP rate limiting collapses to one global bucket.
// The proxy MUST strip client-supplied X-Forwarded-For so it cannot be spoofed. // The proxy MUST strip client-supplied X-Forwarded-For so it cannot be spoofed.
app.set('trust proxy', 1) app.set('trust proxy', 1)
// Hardcoded production DB connection (team decision — used instead of a .env DATABASE_URL, // D19 engine selection (pre-ship audit FIX A: the ONE shared resolver — server.ts and
// to match how the other apps on this box are configured). To change the database, edit // every deploy script must call this, never re-derive it locally, or a script can select
// HARDCODED_DATABASE_URL below and rebuild the image. Gated to NODE_ENV=production so dev // a different DB than the running server without anyone noticing). To change the
// and the test suite keep selecting SQLite (an unset/empty DATABASE_URL) and stay green. // production database, edit HARDCODED_DATABASE_URL in db-pg.ts and rebuild the image.
// ⚠️ Once the real password is filled in, this file holds a secret — do not push the const pgUrl = resolveDbUrl()
// filled-in value to a repo others can read.
const HARDCODED_DATABASE_URL = 'postgres://postgres:CHANGE_ME_PASSWORD@host.docker.internal:5432/hq'
// D19 engine selection: DATABASE_URL (or the hardcoded prod value) → Postgres; else SQLite.
const pgUrl = process.env['DATABASE_URL'] || (process.env['NODE_ENV'] === 'production' ? HARDCODED_DATABASE_URL : '')
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
if (pgUrl !== '') console.log('[db] engine: postgres') if (pgUrl !== '') console.log('[db] engine: postgres')
await seedIfEmpty(db) // first boot prints the owner password once await seedIfEmpty(db) // first boot prints the owner password once

@ -0,0 +1,51 @@
// apps/hq/test/backup.test.ts — `npm run backup` (D33) must find the PRODUCTION database.
// It used to read process.env.DATABASE_URL directly and exit when it was unset — but on the
// prod box DATABASE_URL is not exported; the connection is RESOLVED (D19). So the backup
// command could never reach the real data. It now goes through the one shared resolver, and
// a SQLite outcome is a loud error rather than a pg_dump against a file it cannot read.
import { describe, it, expect } from 'vitest'
import { backupFileName, dumpsToRotate, resolveBackupTarget } from '../src/backup'
describe('resolveBackupTarget (same resolver as the server + every maintenance script)', () => {
it('uses DATABASE_URL when it is set', () => {
expect(resolveBackupTarget({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
})
it('resolves the production target with no DATABASE_URL exported — the whole point of the fix', () => {
// NODE_ENV=production with no DATABASE_URL used to be an instant "DATABASE_URL is not set"
// exit; it must now resolve to the same Postgres the server picks.
const url = resolveBackupTarget({ NODE_ENV: 'production' })
expect(url).toMatch(/^postgres:\/\//)
})
it('refuses (never silently no-ops) when nothing resolves a target', () => {
expect(() => resolveBackupTarget({})).toThrow(/refusing to run/i)
})
it('says WHY, naming pg_dump, the SQLite path it landed on, and the SQLite snapshot script', () => {
const err = (() => { try { resolveBackupTarget({ HQ_DATA_DIR: 'D:/hq-data' }); return '' } catch (e) { return String(e) } })()
expect(err).toMatch(/pg_dump/)
expect(err).toMatch(/backup-hq\.sh/)
expect(err).toMatch(/hq\.db/)
expect(err).toMatch(/DATABASE_URL=postgres/)
})
})
describe('backup file naming + rotation', () => {
it('names dumps hq-YYYYMMDD-HHMMSS.dump', () => {
expect(backupFileName(new Date(2026, 6, 19, 2, 5, 9))).toBe('hq-20260719-020509.dump')
})
it('rotates out everything past the newest KEEP, ignoring unrelated files', () => {
const files = [
'hq-20260101-000000.dump', 'hq-20260102-000000.dump', 'hq-20260103-000000.dump',
'notes.txt', 'hq-old.dump',
]
expect(dumpsToRotate(files, 2)).toEqual(['hq-20260101-000000.dump'])
expect(dumpsToRotate(files, 14)).toEqual([])
})
it('keeps at least one dump however low KEEP is set', () => {
expect(dumpsToRotate(['hq-20260101-000000.dump'], 0)).toEqual([])
})
})

@ -0,0 +1,149 @@
// apps/hq/test/db-resolve.test.ts — pre-ship audit FIX A: server.ts and the deploy scripts
// (apps/hq/scripts/apply-onboarding-pipeline.ts, apps/hq/scripts/migrate-onboarding-templates.ts)
// must all resolve the DB engine + target through the ONE shared function. Before this fix
// the scripts computed it as `DATABASE_URL ?? ''` (no production fallback), so on the
// production box — where DATABASE_URL is unset — each script silently opened a brand-new
// empty SQLite file instead of the real Postgres.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, it, expect } from 'vitest'
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg'
const HARDCODED = 'postgres://u:p@h:5432/db'
describe('resolveDbUrl (D19 engine selection, the one shared resolver)', () => {
it('DATABASE_URL wins regardless of NODE_ENV', () => {
expect(resolveDbUrl({ DATABASE_URL: 'postgres://x/y', NODE_ENV: 'production' })).toBe('postgres://x/y')
expect(resolveDbUrl({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
})
it('falls back to the hardcoded production URL only when NODE_ENV=production and DATABASE_URL is unset', () => {
expect(resolveDbUrl({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
})
it('resolves to SQLite (empty string) outside production with no DATABASE_URL — dev/tests stay green', () => {
expect(resolveDbUrl({})).toBe('')
expect(resolveDbUrl({ NODE_ENV: 'development' })).toBe('')
expect(resolveDbUrl({ NODE_ENV: 'test' })).toBe('')
})
})
describe('requireDbUrlForEnv (deploy-script guard — OUTCOME-based, not NODE_ENV-based)', () => {
it('behaves exactly like resolveDbUrl when a Postgres target is available (no SQLite file check runs at all)', () => {
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
})
it('resolves to SQLite without hard-failing when it is opted into and the target file exists (mocked fs check)', () => {
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => true)).toBe('')
// Even NODE_ENV=production is fine as long as the SQLite file it would land on already exists.
expect(requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => true)).toBe('')
})
it(':memory: never hard-fails — the guard never even calls the file-existence check', () => {
expect(
requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' }, undefined, () => {
throw new Error('must not be called for :memory:')
}),
).toBe('')
})
it('refuses when resolution selects SQLite and the target does not exist — regardless of NODE_ENV', () => {
// This is the original prod bug: an operator shell has no NODE_ENV set at all, so the old
// NODE_ENV==='production' gate never fired. The guard must fire on the OUTCOME instead.
const optIn = { HQ_DB_TARGET: 'sqlite' }
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/refusing to run/i)
expect(() => requireDbUrlForEnv(optIn, undefined, () => false)).toThrow(/CREATE a new SQLite database/)
// Also still refuses in the NODE_ENV=production case (hardcoded emptied out + no existing file).
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production', HQ_DB_TARGET: 'sqlite' }, '', () => false))
.toThrow(/refusing to run/i)
})
})
describe('requireDbUrlForEnv — SQLite must be an explicit opt-in, never an accident', () => {
// The bug this closes: apply-onboarding-pipeline.ts documented its own invocation WITHOUT a
// `DATABASE_URL=` prefix. An operator who copy-pastes that on the prod box resolves to SQLite;
// if a stale ./data/hq.db happens to exist under their cwd, the file-existence guard passes and
// the migration "succeeds" against the wrong database. Landing on SQLite must be a decision.
const existingFile = (): boolean => true
it('refuses when nothing selects a target, even though the SQLite file exists', () => {
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/refusing to run/i)
})
it('names BOTH escape hatches in the error — DATABASE_URL and the SQLite opt-in', () => {
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/DATABASE_URL=postgres/)
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/HQ_DB_TARGET=sqlite/)
expect(() => requireDbUrlForEnv({}, undefined, existingFile)).toThrow(/--sqlite/)
})
it('accepts HQ_DB_TARGET=sqlite (case/space tolerant)', () => {
expect(requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, existingFile)).toBe('')
expect(requireDbUrlForEnv({ HQ_DB_TARGET: ' SQLite ' }, undefined, existingFile)).toBe('')
})
it('accepts a --sqlite argv flag (alongside the script\'s own flags)', () => {
expect(requireDbUrlForEnv({}, undefined, existingFile, ['node', 'script.ts', '--force', '--sqlite'])).toBe('')
})
it('rejects an unrelated HQ_DB_TARGET value rather than guessing', () => {
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'postgres' }, undefined, existingFile)).toThrow(/refusing to run/i)
})
it('the opt-in does NOT weaken the "never CREATE a database" guard', () => {
expect(() => requireDbUrlForEnv({ HQ_DB_TARGET: 'sqlite' }, undefined, () => false))
.toThrow(/CREATE a new SQLite database/)
})
it('DATABASE_URL needs no opt-in — Postgres is the intended target and never touches the flag', () => {
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' }, undefined, () => false, [])).toBe('postgres://x/y')
})
})
describe('requireDbUrlForEnv — real filesystem (the actual script entry path, no mocks)', () => {
it('refuses a non-existent SQLite path', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
try {
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' }))
.toThrow(/CREATE a new SQLite database/)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it('accepts an existing SQLite path once SQLite is opted into', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-resolve-'))
try {
fs.writeFileSync(path.join(dir, 'hq.db'), '')
expect(requireDbUrlForEnv({ HQ_DATA_DIR: dir, HQ_DB_TARGET: 'sqlite' })).toBe('')
// …and still refuses the same existing path with no opt-in at all.
expect(() => requireDbUrlForEnv({ HQ_DATA_DIR: dir })).toThrow(/HQ_DB_TARGET=sqlite/)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it(':memory: still works with no filesystem check', () => {
expect(requireDbUrlForEnv({ HQ_DATA_DIR: ':memory:' })).toBe('')
})
})
describe('pgTargetDescription (never leaks the password into logs)', () => {
it('extracts host:port/dbname', () => {
expect(pgTargetDescription('postgres://user:secret@db.example.com:5432/hq'))
.toBe('db.example.com:5432/hq')
})
it('omits the port when absent', () => {
expect(pgTargetDescription('postgres://user:secret@db.example.com/hq')).toBe('db.example.com/hq')
})
it('never includes the credentials', () => {
expect(pgTargetDescription('postgres://user:hunter2@host/db')).not.toContain('hunter2')
})
it('degrades gracefully on an unparsable string', () => {
expect(pgTargetDescription('not-a-url')).toMatch(/unparsable/i)
})
})

@ -1,6 +1,12 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { writeAudit, listAudit } from '../src/audit' import { writeAudit, listAudit } from '../src/audit'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules'
describe('hq db', () => { describe('hq db', () => {
it('creates every HQ-1 table', async () => { it('creates every HQ-1 table', async () => {
@ -20,4 +26,55 @@ describe('hq db', () => {
expect(rows[0]!.action).toBe('update') expect(rows[0]!.action).toBe('update')
expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' }) expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' })
}) })
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at, so
// every imported project with zero ticks was permanently NULL-aged. migrate() must backfill
// it from installed_on where that's known — using a real (non-:memory:) DB reopened twice,
// since the backfill runs as part of migrate() on every open, not just column-creation.
describe('client_module.created_at backfill (FIX 4)', () => {
it('backfills created_at from installed_on for a legacy row missing it', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-backfill-'))
try {
let db = openDb(dir)
await seedIfEmpty(db)
const client = await createClient(db, 'u1', { name: 'Legacy Client', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'LEGACYBF', name: 'Legacy Module' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// Simulate the actual APEX-importer bug: installed_on known, created_at never stamped.
await db.run(`UPDATE client_module SET created_at=NULL, installed_on=? WHERE id=?`, '2025-11-03', cm.id)
await db.close()
db = openDb(dir) // reopening re-runs migrate() — the backfill must heal it unprompted
const row = await db.get<{ created_at: string | null; installed_on: string | null }>(
`SELECT created_at, installed_on FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).toBe('2025-11-03')
await db.close()
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
it('leaves created_at NULL when installed_on is also unknown — never fabricates a date', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-db-backfill-'))
try {
let db = openDb(dir)
await seedIfEmpty(db)
const client = await createClient(db, 'u1', { name: 'Legacy Client 2', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'LEGACYBF2', name: 'Legacy Module 2' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id) // installed_on stays NULL too
await db.close()
db = openDb(dir)
const row = await db.get<{ created_at: string | null }>(
`SELECT created_at FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).toBeNull()
await db.close()
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
})
}) })

@ -3,10 +3,10 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createClient, bulkUpdateClients, getClient } from '../src/repos-clients' import { createClient, bulkUpdateClients, getClient } from '../src/repos-clients'
import { createModule, setPrice, assignModule } from '../src/repos-modules' import { createModule, setPrice, assignModule, getClientModule } from '../src/repos-modules'
import { createDraft, issueDocument, createCreditNote, listDocuments, getDocument } from '../src/repos-documents' import { createDraft, issueDocument, createCreditNote, listDocuments, getDocument } from '../src/repos-documents'
import { gstSummary } from '../src/repos-reports' import { gstSummary } from '../src/repos-reports'
import { listProjectMilestones, bulkSetMilestone } from '../src/repos-milestones' import { listProjectMilestones, bulkSetMilestone, setMilestone } from '../src/repos-milestones'
async function world() { async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company state 32, GST18 const db = openDb(':memory:'); await seedIfEmpty(db) // company state 32, GST18
@ -81,4 +81,48 @@ describe('bulk operations (D26)', () => {
const ms = await listProjectMilestones(db, p.id) const ms = await listProjectMilestones(db, p.id)
expect(ms.find((x) => x.key === 'installed')).toMatchObject({ done: true, doneOn: '2026-07-10' }) expect(ms.find((x) => x.key === 'installed')).toMatchObject({ done: true, doneOn: '2026-07-10' })
}) })
// Pre-ship audit FIX F: bulkSetMilestone used a bare UPDATE, so it never drove
// client_module.status (a bulk go_live left status at 'quoted') and never cleared a stale
// payment/document link on untick. It now routes through setMilestone — the same path a
// single tick uses.
it('bulk-tick drives client_module.status the same as a single tick (FIX F)', async () => {
const { db, c, m } = await world()
const p = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const out = await bulkSetMilestone(db, 'u1', [p.id], 'go_live', true, '2026-07-10')
expect(out.updated).toBe(1)
const cm = await getClientModule(db, p.id)
expect(cm!.status).toBe('live')
})
it('bulk-untick clears a stale payment/document link, same as a single untick (FIX F)', async () => {
const { db, c, m } = await world()
const p = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const paymentId = 'pay-bulk-1'
await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at)
VALUES (?, ?, '2026-07-01', 'bank', 500000, 'u1', '2026-07-01T00:00:00.000Z')`,
paymentId, c.id,
)
// Single-tick with a linked payment (as the UI does)...
await setMilestone(db, 'u1', p.id, 'advance_paid', true, '2026-07-01', paymentId)
let ms = await listProjectMilestones(db, p.id)
expect(ms.find((x) => x.key === 'advance_paid')!.paymentId).toBe(paymentId)
// ...then a bulk untick must clear that link, not leave it stale.
const out = await bulkSetMilestone(db, 'u1', [p.id], 'advance_paid', false)
expect(out.updated).toBe(1)
ms = await listProjectMilestones(db, p.id)
const advancePaid = ms.find((x) => x.key === 'advance_paid')!
expect(advancePaid.done).toBe(false)
expect(advancePaid.doneOn).toBeNull()
expect(advancePaid.paymentId).toBeNull()
})
it('bulk ops only touch projects that actually have the milestone key, others are silently skipped', async () => {
const { db, c, m } = await world()
const p = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const out = await bulkSetMilestone(db, 'u1', [p.id, 'not-a-real-client-module-id'], 'go_live', true, '2026-07-10')
expect(out.updated).toBe(1) // only the real one counted
})
}) })

@ -201,10 +201,10 @@ const cmFor = async (db: DB, clientCode: string, moduleCode: string) =>
db.get<{ db.get<{
status: string; kind: string; provider: string | null; username: string | null status: string; kind: string; provider: string | null; username: string | null
password_enc: string | null; details: string; remark: string | null; installed_on: string | null password_enc: string | null; details: string; remark: string | null; installed_on: string | null
field_values: string field_values: string; created_at: string | null
}>( }>(
`SELECT cm.status, cm.kind, cm.provider, cm.username, cm.password_enc, cm.details, cm.remark, `SELECT cm.status, cm.kind, cm.provider, cm.username, cm.password_enc, cm.details, cm.remark,
cm.installed_on, cm.field_values cm.installed_on, cm.field_values, cm.created_at
FROM client_module cm JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id FROM client_module cm JOIN module m ON m.id=cm.module_id JOIN client c ON c.id=cm.client_id
WHERE c.code=? AND m.code=?`, clientCode, moduleCode) WHERE c.code=? AND m.code=?`, clientCode, moduleCode)
@ -281,6 +281,9 @@ describe('APEX full importer v2 (real-export shapes)', () => {
const atm = (await cmFor(db, '103', 'ATM'))! const atm = (await cmFor(db, '103', 'ATM'))!
expect(atm.status).toBe('live') expect(atm.status).toBe('live')
expect(atm.provider).toBe('EWIRE') expect(atm.provider).toBe('EWIRE')
// FIX 4 (regression): the importer must stamp created_at like assignModule does — a NULL
// here means the project permanently vanishes off the "Onboarding stalled" tile.
expect(atm.created_at).not.toBeNull()
const mob = (await cmFor(db, '103', 'MOBILEAPP'))! const mob = (await cmFor(db, '103', 'MOBILEAPP'))!
expect(JSON.parse(mob.field_values)).toEqual({ contact: '8301932166' }) // D21 declared field expect(JSON.parse(mob.field_values)).toEqual({ contact: '8301932166' }) // D21 declared field
expect(JSON.parse(mob.details)).toEqual([]) expect(JSON.parse(mob.details)).toEqual([])

@ -61,6 +61,17 @@ describe('clientLedger — outstanding + per-payment allocations', () => {
expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined() expect(led.outstanding.find((o) => o.docId === inv.id)).toBeUndefined()
}) })
it('excludes a draft proforma from outstanding but still includes a sent one', async () => {
const db = openDb(':memory:')
const { clientId, inv, pf } = await buildFixture(db)
const draftPf = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId, lines: [{ moduleId: inv.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }],
}) // left as 'draft' — never issued/sent
const led = await clientLedger(db, clientId)
expect(led.outstanding.find((o) => o.docId === draftPf.id)).toBeUndefined()
expect(led.outstanding.find((o) => o.docId === pf.id)).toMatchObject({ docId: pf.id, docNo: pf.docNo })
})
it('attaches allocations to each payment', async () => { it('attaches allocations to each payment', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const { clientId, inv, payment } = await buildFixture(db) const { clientId, inv, payment } = await buildFixture(db)

@ -1,13 +1,16 @@
// apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time // apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time
// migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding // migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding
// checklist onto their new per-module templates, carrying mapped ticks (done/done_on/payment_id). // checklist onto their new composed (front + per-module tail) templates, carrying mapped
// ticks (done/done_on/payment_id).
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { openDb, type DB } from '../src/db' import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients' import { createClient } from '../src/repos-clients'
import { createModule, assignModule } from '../src/repos-modules' import { createModule, assignModule } from '../src/repos-modules'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import { listProjectMilestones } from '../src/repos-milestones' import { listProjectMilestones, TAIL_FALLBACK } from '../src/repos-milestones'
const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved']
describe('onboarding template migration', () => { describe('onboarding template migration', () => {
let db: DB let db: DB
@ -17,7 +20,7 @@ describe('onboarding template migration', () => {
const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// assignModule auto-seeds the NEW per-module template (task 2/4) — wipe it so the test // assignModule auto-seeds the NEW composed template (task 2/4) — wipe it so the test
// can plant the OLD generic rows a legacy project would actually have on disk. // can plant the OLD generic rows a legacy project would actually have on disk.
await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id) await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id)
return cm.id return cm.id
@ -37,7 +40,7 @@ describe('onboarding template migration', () => {
} }
} }
it('swaps an SMS project to the 9-step list carrying installed+go_live ticks', async () => { it('swaps an SMS project to the composed front+tail list carrying installed+go_live ticks', async () => {
const cmId = await makeSmsClientModule() const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) await seedOldGenericRows(cmId)
@ -48,8 +51,9 @@ describe('onboarding template migration', () => {
const ms = await listProjectMilestones(db, cmId) const ms = await listProjectMilestones(db, cmId)
expect(ms.map((m) => m.key)).toEqual([ expect(ms.map((m) => m.key)).toEqual([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation', 'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', 'testing', 'training', 'go_live', 'payment_received',
]) ])
const installation = ms.find((m) => m.key === 'installation')! const installation = ms.find((m) => m.key === 'installation')!
expect(installation.done).toBe(true) expect(installation.done).toBe(true)
@ -58,10 +62,11 @@ describe('onboarding template migration', () => {
const goLive = ms.find((m) => m.key === 'go_live')! const goLive = ms.find((m) => m.key === 'go_live')!
expect(goLive.done).toBe(true) expect(goLive.done).toBe(true)
expect(goLive.doneOn).toBe('2026-04-02') expect(goLive.doneOn).toBe('2026-04-02')
// untouched (never ticked) steps stay pending // untouched (never ticked) steps stay pending, including the newly-prepended front
expect(ms.find((m) => m.key === 'enquiry')!.done).toBe(false)
expect(ms.find((m) => m.key === 'quotation_approved')!.done).toBe(false)
expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false) expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false)
expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false) expect(ms.find((m) => m.key === 'payment_received')!.done).toBe(false)
expect(ms.find((m) => m.key === 'balance_payment')!.done).toBe(false)
// old setup_done / amc_start gone entirely // old setup_done / amc_start gone entirely
expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false)
@ -74,7 +79,7 @@ describe('onboarding template migration', () => {
expect(audits[0]!.after_json).toContain('installation') expect(audits[0]!.after_json).toContain('installation')
}) })
it('carries a done payment_id along with the mapped tick', async () => { it('carries a done payment_id along with the mapped tick (advance_paid -> payment_received)', async () => {
const cmId = await makeSmsClientModule() const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>( const client = await db.get<{ client_id: string }>(
`SELECT client_id FROM client_module WHERE id=?`, cmId, `SELECT client_id FROM client_module WHERE id=?`, cmId,
@ -93,10 +98,98 @@ describe('onboarding template migration', () => {
await migrateOnboardingTemplates(db) await migrateOnboardingTemplates(db)
const ms = await listProjectMilestones(db, cmId) const ms = await listProjectMilestones(db, cmId)
const advance = ms.find((m) => m.key === 'advance_payment')! const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(advance.done).toBe(true) expect(paymentReceived.done).toBe(true)
expect(advance.doneOn).toBe('2026-03-01') expect(paymentReceived.doneOn).toBe('2026-03-01')
expect(advance.paymentId).toBe(paymentId) expect(paymentReceived.paymentId).toBe(paymentId)
})
it('balance_paid also maps to payment_received', async () => {
const cmId = await makeSmsClientModule()
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'm0', cmId, 'balance_paid', 'balance_paid', 1, '2026-04-10', 0,
)
await migrateOnboardingTemplates(db)
const ms = await listProjectMilestones(db, cmId)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-04-10')
})
// The interim shape shipped mid-redesign split the payment step in two
// (advance_payment / balance_payment) before it was collapsed into one
// `payment_received` step. A DB written by that build must migrate too.
it('maps the interim advance_payment key (with its payment link) to payment_received', async () => {
const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>(
`SELECT client_id FROM client_module WHERE id=?`, cmId,
)
const paymentId = 'pay-interim'
await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at)
VALUES (?, ?, '2026-06-01', 'bank', 250000, 'u1', '2026-06-01T00:00:00.000Z')`,
paymentId, client!.client_id,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
VALUES (?,?,?,?,?,?,?,?)`,
'm0', cmId, 'advance_payment', 'Advance payment', 1, '2026-06-01', paymentId, 0,
)
const res = await migrateOnboardingTemplates(db)
expect(res.carried).toBe(1)
expect(res.dropped).toBe(0)
const ms = await listProjectMilestones(db, cmId)
expect(ms.some((m) => m.key === 'advance_payment')).toBe(false)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-06-01')
expect(paymentReceived.paymentId).toBe(paymentId)
})
it('maps the interim balance_payment key to payment_received', async () => {
const cmId = await makeSmsClientModule()
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'm0', cmId, 'balance_payment', 'Balance payment', 1, '2026-06-20', 0,
)
const res = await migrateOnboardingTemplates(db)
expect(res.dropped).toBe(0)
const ms = await listProjectMilestones(db, cmId)
expect(ms.some((m) => m.key === 'balance_payment')).toBe(false)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.doneOn).toBe('2026-06-20')
})
it('carries a quotation-step document link across the swap', async () => {
const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>(
`SELECT client_id FROM client_module WHERE id=?`, cmId,
)
const documentId = 'doc-1'
await db.run(
`INSERT INTO document (id, doc_type, fy, client_id, doc_date, taxable_paise, cgst_paise,
sgst_paise, igst_paise, round_off_paise, payable_paise, payload, created_by, created_at)
VALUES (?, 'QUOTATION', '26-27', ?, '2026-05-01', 100000, 9000, 9000, 0, 0, 118000,
'{"lines":[]}', 'u1', '2026-05-01T00:00:00.000Z')`,
documentId, client!.client_id,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, document_id, sort)
VALUES (?,?,?,?,?,?,?,?)`,
'm0', cmId, 'quotation_sent', 'Quotation sent', 1, '2026-05-01', documentId, 0,
)
await migrateOnboardingTemplates(db)
const quotationSent = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'quotation_sent')!
expect(quotationSent.done).toBe(true)
expect(quotationSent.documentId).toBe(documentId)
}) })
it('is idempotent (second run changes nothing new)', async () => { it('is idempotent (second run changes nothing new)', async () => {
@ -151,8 +244,9 @@ describe('onboarding template migration', () => {
// Verify the dropped keys are gone from the migrated template // Verify the dropped keys are gone from the migrated template
const ms = await listProjectMilestones(db, cmId) const ms = await listProjectMilestones(db, cmId)
expect(ms.map((m) => m.key)).toEqual([ expect(ms.map((m) => m.key)).toEqual([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation', 'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', 'testing', 'training', 'go_live', 'payment_received',
]) ])
expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false) expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false)
@ -167,11 +261,10 @@ describe('onboarding template migration', () => {
const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' }) const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' })
const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' })
// This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones // This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones
// fell back to the global generic template — the OLD generic rows themselves. // composed the front with the code TAIL_FALLBACK (no global 'project.milestone_template' is
// seeded any more either).
const before = await listProjectMilestones(db, cm.id) const before = await listProjectMilestones(db, cm.id)
expect(before.map((m) => m.key)).toEqual([ expect(before.map((m) => m.key)).toEqual([...FRONT_KEYS, ...TAIL_FALLBACK.map((t) => t.key)])
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
])
const res = await migrateOnboardingTemplates(db) const res = await migrateOnboardingTemplates(db)
expect(res.projects).toBe(0) expect(res.projects).toBe(0)
@ -179,4 +272,166 @@ describe('onboarding template migration', () => {
const after = await listProjectMilestones(db, cm.id) const after = await listProjectMilestones(db, cm.id)
expect(after).toEqual(before) expect(after).toEqual(before)
}) })
// Final whole-branch review FIX 3: an ad-hoc project-specific step (`+ Add step` /
// addProjectMilestone, `custom_*` key) must survive the swap — ticked or not — instead of
// being silently deleted because it's in neither templateKeys nor KEY_MAP.
it('preserves ad-hoc custom steps (both ticked and un-ticked), appended after the template, with state intact', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) // baseline so the project isn't already "identical" (forces a rebuild)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'custom-ticked', cmId, 'custom_ab12cd34', 'AWS region confirmed', 1, '2026-05-01', 10,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'custom-unticked', cmId, 'custom_ef56gh78', 'Client-specific step', 0, null, 11,
)
const res = await migrateOnboardingTemplates(db)
expect(res.preserved).toBe(2)
// Neither custom row counts as dropped, even though the ticked one is done=1.
expect(res.dropped).toBe(0)
const ms = await listProjectMilestones(db, cmId)
const ticked = ms.find((m) => m.key === 'custom_ab12cd34')
expect(ticked).toMatchObject({ label: 'AWS region confirmed', done: true, doneOn: '2026-05-01' })
const unticked = ms.find((m) => m.key === 'custom_ef56gh78')
expect(unticked).toMatchObject({ label: 'Client-specific step', done: false, doneOn: null })
// Appended after the whole template block, sort continuing past it (deterministic order:
// the ticked one was inserted before the un-ticked one).
expect(ms.slice(-2).map((m) => m.key)).toEqual(['custom_ab12cd34', 'custom_ef56gh78'])
// Reflected in the audit's `after` payload.
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='migrate_onboarding' AND entity_id=?`, cmId,
)
expect(audits).toHaveLength(1)
expect(audits[0]!.after_json).toContain('custom_ab12cd34')
expect(audits[0]!.after_json).toContain('custom_ef56gh78')
})
// Regression (FIX 3): the idempotency check used to flag `alreadyNew = false` for ANY row
// whose key wasn't a template key — including the deliberately-preserved `custom_*` ad-hoc
// steps — so a project carrying one was never "identical" and got DELETE-and-rebuilt on
// EVERY run (new row ids, a fresh audit row each time), contradicting "a second run does
// nothing". Idempotency must be computed over non-custom rows only.
it('is idempotent on a project carrying a custom_* ad-hoc step (second run is a true no-op)', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'custom-1', cmId, 'custom_ab12cd34', 'AWS region confirmed', 1, '2026-05-01', 10,
)
const first = await migrateOnboardingTemplates(db)
expect(first.projects).toBe(1)
const afterFirst = await listProjectMilestones(db, cmId)
const rowIdsAfterFirst = (await db.all<{ id: string }>(
`SELECT id FROM project_milestone WHERE client_module_id=? ORDER BY sort`, cmId,
)).map((r) => r.id)
const second = await migrateOnboardingTemplates(db)
expect(second.projects).toBe(0) // true no-op, not a DELETE-and-rebuild
expect(second.carried).toBe(0)
expect(second.dropped).toBe(0)
expect(second.preserved).toBe(0)
const afterSecond = await listProjectMilestones(db, cmId)
expect(afterSecond).toEqual(afterFirst)
const rowIdsAfterSecond = (await db.all<{ id: string }>(
`SELECT id FROM project_milestone WHERE client_module_id=? ORDER BY sort`, cmId,
)).map((r) => r.id)
// Same row ids — proof the second run did not delete-and-reinsert.
expect(rowIdsAfterSecond).toEqual(rowIdsAfterFirst)
// No second audit row written for this client_module on the no-op pass.
const audits = await db.all<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding' AND entity_id=?`, cmId,
)
expect((audits[0] as unknown as { n: number }).n).toBe(1)
})
it('a known old-generic key with no forward mapping (amc_start) is still dropped, not treated as custom', async () => {
const cmId = await makeSmsClientModule()
await seedOldGenericRows(cmId) // includes ticked 'installed'/'go_live' and un-ticked 'amc_start'
const res = await migrateOnboardingTemplates(db)
expect(res.preserved).toBe(0)
const ms = await listProjectMilestones(db, cmId)
expect(ms.some((m) => m.key === 'amc_start')).toBe(false)
})
// Pre-ship audit FIX C: advance_paid AND balance_paid (and the interim advance_payment /
// balance_payment) all collapse onto the single new `payment_received` key. The old code
// used Map.set, so whichever row was visited last silently overwrote the other — its
// done_on AND payment_id lost, counted as neither `carried` nor `dropped`, and the audit
// `before` was `undefined` so the loss was unreconstructible.
it('when two old keys collapse onto the same new key, deterministically keeps the linked tick, counts the loser, and records the full pre-migration state in the audit before payload', async () => {
const cmId = await makeSmsClientModule()
const client = await db.get<{ client_id: string }>(`SELECT client_id FROM client_module WHERE id=?`, cmId)
const paymentId = 'pay-collapse-1'
await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at)
VALUES (?, ?, '2026-03-01', 'bank', 500000, 'u1', '2026-03-01T00:00:00.000Z')`,
paymentId, client!.client_id,
)
// advance_paid: ticked LATER, but with NO payment link.
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-06-01', 0,
)
// balance_paid: ticked EARLIER, but WITH a real payment link — this one must survive.
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
VALUES (?,?,?,?,?,?,?,?)`,
'm1', cmId, 'balance_paid', 'balance_paid', 1, '2026-03-01', paymentId, 1,
)
const res = await migrateOnboardingTemplates(db)
expect(res.collapsed).toBe(1)
expect(res.carried).toBe(1) // only ONE surviving tick lands on payment_received
expect(res.dropped).toBe(0) // the loser isn't "dropped" — it's "collapsed"
const ms = await listProjectMilestones(db, cmId)
const paymentReceived = ms.find((m) => m.key === 'payment_received')!
expect(paymentReceived.done).toBe(true)
expect(paymentReceived.paymentId).toBe(paymentId) // the LINKED tick wins, not the later one
expect(paymentReceived.doneOn).toBe('2026-03-01')
// The full pre-migration row set is in the audit `before` — both ticks reconstructible.
const audits = await db.all<{ before_json: string | null }>(
`SELECT before_json FROM audit_log WHERE action='migrate_onboarding' AND entity_id=?`, cmId,
)
expect(audits).toHaveLength(1)
expect(audits[0]!.before_json).not.toBeNull()
const before = JSON.parse(audits[0]!.before_json!) as
{ key: string; done: number; done_on: string | null; payment_id: string | null }[]
const advancePaidBefore = before.find((r) => r.key === 'advance_paid')!
expect(advancePaidBefore).toMatchObject({ done: 1, done_on: '2026-06-01', payment_id: null })
const balancePaidBefore = before.find((r) => r.key === 'balance_paid')!
expect(balancePaidBefore).toMatchObject({ done: 1, done_on: '2026-03-01', payment_id: paymentId })
})
it('when neither collapsing tick has a payment link, keeps the LATEST done_on', async () => {
const cmId = await makeSmsClientModule()
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-02-01', 0,
)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort)
VALUES (?,?,?,?,?,?,?)`,
'm1', cmId, 'balance_paid', 'balance_paid', 1, '2026-05-15', 1,
)
const res = await migrateOnboardingTemplates(db)
expect(res.collapsed).toBe(1)
const ms = await listProjectMilestones(db, cmId)
expect(ms.find((m) => m.key === 'payment_received')!.doneOn).toBe('2026-05-15') // latest wins
})
}) })

@ -0,0 +1,122 @@
// apps/hq/test/milestone-document.test.ts — Client Detail redesign task 7: the quotation
// step (`quotation_sent`) can link the quotation / proforma document that was actually sent.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice, assignModule } from '../src/repos-modules'
import { createDraft } from '../src/repos-documents'
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
const KEY = '11'.repeat(32)
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const quote = async (clientId: string) => createDraft(db, 'u1', {
docType: 'QUOTATION', clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
return { db, clientId: c.id, cmId: cm.id, quote }
}
const step = async (db: Awaited<ReturnType<typeof world>>['db'], cmId: string) =>
(await listProjectMilestones(db, cmId)).find((s) => s.key === 'quotation_sent')!
describe('milestone document_id link (quotation step)', () => {
it('linking a quotation stores document_id on the ticked step', async () => {
const { db, clientId, cmId, quote } = await world()
const doc = await quote(clientId)
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
const s = await step(db, cmId)
expect(s.done).toBe(true)
expect(s.doneOn).toBe('2026-05-01')
expect(s.documentId).toBe(doc.id)
})
it('rejects a document that belongs to a different client', async () => {
const { db, cmId, quote } = await world()
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
const foreign = await quote(other.id)
await expect(
setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, foreign.id),
).rejects.toThrow(/document/i)
// …and nothing was written: the step is still pending.
const s = await step(db, cmId)
expect(s.done).toBe(false)
expect(s.documentId).toBeNull()
})
it('unticking clears document_id along with done_on', async () => {
const { db, clientId, cmId, quote } = await world()
const doc = await quote(clientId)
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
expect((await step(db, cmId)).documentId).toBe(doc.id)
await setMilestone(db, 'u1', cmId, 'quotation_sent', false)
const s = await step(db, cmId)
expect(s.done).toBe(false)
expect(s.doneOn).toBeNull()
expect(s.documentId).toBeNull()
})
it('ticking without a documentId leaves the link null (opt-in)', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'quotation_sent', true)
const s = await step(db, cmId)
expect(s.done).toBe(true)
expect(s.documentId).toBeNull()
})
it('audits the link in the same transaction as the tick', async () => {
const { db, clientId, cmId, quote } = await world()
const doc = await quote(clientId)
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='set_milestone'`,
)
expect(audits).toHaveLength(1)
expect(audits[0]!.after_json).toContain(doc.id)
})
})
describe('milestone route accepts documentId', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
it('POST /client-modules/:id/milestones threads documentId through', async () => {
const { db, clientId, cmId, quote } = await world()
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
})).json()) as { token: string }).token
const H = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
const doc = await quote(clientId)
const out = await (await fetch(`${base}/client-modules/${cmId}/milestones`, {
method: 'POST', headers: H,
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: doc.id }),
})).json() as { ok: boolean; milestones: { key: string; documentId: string | null }[] }
expect(out.ok).toBe(true)
expect(out.milestones.find((m) => m.key === 'quotation_sent')!.documentId).toBe(doc.id)
// A foreign document is a 400, not a silent link.
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
const foreign = await quote(other.id)
const bad = await fetch(`${base}/client-modules/${cmId}/milestones`, {
method: 'POST', headers: H,
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: foreign.id }),
})
expect(bad.status).toBe(400)
})
})

@ -0,0 +1,113 @@
// apps/hq/test/milestone-link-preservation.test.ts — re-ticking an already-done step must not
// wipe the payment / document link it already carries.
//
// The bug: setMilestone wrote `payment_id`/`document_id` as `done ? linked : null`, so any tick
// that didn't resend the ids cleared them. bulkSetMilestone routes through setMilestone and
// never passes ids at all (there is one key for many projects), so a single bulk re-tick — the
// onboarding-cleanup tool — silently cut every project's step loose from the payment or
// quotation it had been linked to one at a time. Unticking must still clear both.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice, assignModule } from '../src/repos-modules'
import { createDraft } from '../src/repos-documents'
import { recordPayment } from '../src/repos-payments'
import { bulkSetMilestone, listProjectMilestones, setMilestone } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const step = async (key: string) => (await listProjectMilestones(db, cm.id)).find((s) => s.key === key)!
return { db, clientId: c.id, moduleId: m.id, cmId: cm.id, step }
}
describe('bulk re-tick preserves the payment link (5a)', () => {
it('tick with a payment link → bulk re-tick keeps it → bulk untick clears it', async () => {
const { db, clientId, cmId, step } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
expect((await step('payment_received')).paymentId).toBe(pay.payment.id)
// The bulk tool carries no ids — the stored link must survive.
const res = await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
expect(res.updated).toBe(1)
const after = await step('payment_received')
expect(after.done).toBe(true)
expect(after.paymentId).toBe(pay.payment.id)
// Unticking still cuts the link loose.
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', false)
const cleared = await step('payment_received')
expect(cleared.done).toBe(false)
expect(cleared.doneOn).toBeNull()
expect(cleared.paymentId).toBeNull()
})
it('a single re-tick that sends no ids keeps the link too', async () => {
const { db, clientId, cmId, step } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-06-01')
const s = await step('payment_received')
expect(s.doneOn).toBe('2026-06-01') // the date the caller did send still moves
expect(s.paymentId).toBe(pay.payment.id)
})
it('an explicit empty id is still an unlink, not a preserve', async () => {
const { db, clientId, cmId, step } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, '')
expect((await step('payment_received')).paymentId).toBeNull()
})
it('the preserved link is what the audit row reports', async () => {
const { db, clientId, cmId } = await world()
const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
})
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE action='set_milestone' ORDER BY id`,
)
expect(JSON.parse(audits.at(-1)!.after_json!)).toMatchObject({ paymentId: pay.payment.id })
})
})
describe('bulk re-tick preserves the document link (5a)', () => {
it('tick with a quotation link → bulk re-tick keeps it → untick clears it', async () => {
const { db, clientId, moduleId, cmId, step } = await world()
const doc = await createDraft(db, 'u1', {
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
})
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
expect((await step('quotation_sent')).documentId).toBe(doc.id)
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
expect((await step('quotation_sent')).documentId).toBe(doc.id)
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', false)
expect((await step('quotation_sent')).documentId).toBeNull()
})
it('a step that was never done still starts with no link (nothing to preserve)', async () => {
const { db, cmId, step } = await world()
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
const s = await step('quotation_sent')
expect(s.done).toBe(true)
expect(s.documentId).toBeNull()
expect(s.paymentId).toBeNull()
})
})

@ -17,13 +17,13 @@ async function world() {
} }
describe('milestone payment_id link (payment-step tick)', () => { describe('milestone payment_id link (payment-step tick)', () => {
it('linking a payment to advance_payment stores payment_id and mirrors received_on', async () => { it('linking a payment to payment_received stores payment_id and mirrors received_on', async () => {
const { db, clientId, cmId } = await world() const { db, clientId, cmId } = await world()
const pay = await recordPayment(db, 'u1', { const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
}) })
await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id) await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
expect(ms.done).toBe(true) expect(ms.done).toBe(true)
expect(ms.doneOn).toBe('2026-05-01') expect(ms.doneOn).toBe('2026-05-01')
expect(ms.paymentId).toBe(pay.payment.id) expect(ms.paymentId).toBe(pay.payment.id)
@ -36,7 +36,7 @@ describe('milestone payment_id link (payment-step tick)', () => {
clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00, clientId: other.id, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 500_00,
}) })
await expect( await expect(
setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id), setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id),
).rejects.toThrow(/payment/i) ).rejects.toThrow(/payment/i)
}) })
@ -45,9 +45,9 @@ describe('milestone payment_id link (payment-step tick)', () => {
const pay = await recordPayment(db, 'u1', { const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
}) })
await setMilestone(db, 'u1', cmId, 'advance_payment', true, undefined, pay.payment.id) await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
await setMilestone(db, 'u1', cmId, 'advance_payment', false) await setMilestone(db, 'u1', cmId, 'payment_received', false)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'advance_payment')! const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
expect(ms.done).toBe(false) expect(ms.done).toBe(false)
expect(ms.doneOn).toBeNull() expect(ms.doneOn).toBeNull()
expect(ms.paymentId).toBeNull() expect(ms.paymentId).toBeNull()
@ -58,8 +58,8 @@ describe('milestone payment_id link (payment-step tick)', () => {
const pay = await recordPayment(db, 'u1', { const pay = await recordPayment(db, 'u1', {
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00, clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
}) })
await setMilestone(db, 'u1', cmId, 'balance_payment', true, '2026-05-10', pay.payment.id) await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-05-10', pay.payment.id)
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'balance_payment')! const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
expect(ms.doneOn).toBe('2026-05-10') expect(ms.doneOn).toBe('2026-05-10')
expect(ms.paymentId).toBe(pay.payment.id) expect(ms.paymentId).toBe(pay.payment.id)
}) })

@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients' import { createClient } from '../src/repos-clients'
import { createModule, assignModule, getClientModule } from '../src/repos-modules' import { createModule, assignModule, getClientModule, updateClientModule } from '../src/repos-modules'
import { setMilestone } from '../src/repos-milestones' import { setMilestone } from '../src/repos-milestones'
async function world() { async function world() {
@ -16,6 +16,13 @@ async function world() {
} }
describe('milestone → status auto-drive (D22 status line)', () => { describe('milestone → status auto-drive (D22 status line)', () => {
it('ticking quotation_approved advances status to ordered', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'quotation_approved', true)
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('ordered')
})
it('ticking installation advances status to installed', async () => { it('ticking installation advances status to installed', async () => {
const { db, cmId } = await world() const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true) await setMilestone(db, 'u1', cmId, 'installation', true)
@ -43,3 +50,142 @@ describe('milestone → status auto-drive (D22 status line)', () => {
expect((await getClientModule(db, cmId))!.status).toBe('live') expect((await getClientModule(db, cmId))!.status).toBe('live')
}) })
}) })
// Final whole-branch review FIX 5: installedOn / trainedOn became write-only once the UI's
// RowDate / summary-table date writers were removed with nothing to replace them — the
// milestone-driven status advance is now that replacement.
describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
it("ticking 'installation' (which advances status to installed) stamps installed_on from the milestone's done_on", async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('installed')
expect(cm!.installedOn).toBe('2026-03-25')
})
it("ticking 'training' (which advances status to trained) stamps trained_on from the milestone's done_on", async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('trained')
expect(cm!.trainedOn).toBe('2026-05-02')
})
it('never overwrites an installed_on already on file (e.g. set by import / direct edit)', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { installedOn: '2025-01-01' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('installed') // status still advances
expect(cm!.installedOn).toBe('2025-01-01') // date untouched
})
it('re-ticking after an untick does not move an already-stamped installed_on', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
await setMilestone(db, 'u1', cmId, 'installation', false)
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-04-01')
expect((await getClientModule(db, cmId))!.installedOn).toBe('2026-03-25')
})
it('leaves installed_on / trained_on untouched for a step with no date-column mapping (e.g. go_live)', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'go_live', true, '2026-06-01')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('live')
expect(cm!.installedOn).toBeNull()
expect(cm!.trainedOn).toBeNull()
})
it('the status-advance audit payload reflects the installed_on stamp', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
cmId,
)
const stamped = audits.find((a) => (a.after_json ?? '').includes('installedOn'))
expect(stamped).toBeDefined()
expect(JSON.parse(stamped!.after_json!)).toMatchObject({ status: 'installed', installedOn: '2026-03-25' })
})
})
// The date-stamp used to ride on the status UPGRADE, so a project already at the furthest
// status could never record one. That is the state of all ~300 APEX-imported projects: they
// come in at 'live', ticking Installation/Training upgrades nothing, and — with the UI's own
// date writers removed — installed_on/trained_on became permanently unfillable, leaving the
// Module Directory's "Installed" column blank forever. Ticking now stamps regardless.
describe('date stamping is independent of the status upgrade (APEX-imported projects at live)', () => {
it("ticking 'installation' on a project already at live records installed_on and leaves the status alone", async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.installedOn).toBe('2026-03-25')
expect(cm!.status).toBe('live') // unchanged — no downgrade, no spurious upgrade
})
it("ticking 'training' on a project already at live records trained_on and leaves the status alone", async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live' })
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
const cm = await getClientModule(db, cmId)
expect(cm!.trainedOn).toBe('2026-05-02')
expect(cm!.status).toBe('live')
})
it('still never overwrites a date already on file, even with no status move', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live', installedOn: '2025-01-01' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
expect((await getClientModule(db, cmId))!.installedOn).toBe('2025-01-01')
})
it('the audit payload records the date-only change (no status key, since status did not move)', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
cmId,
)
// The last 'update' row is the tick's own (the earlier one is the status seed above).
const after = JSON.parse(audits.at(-1)!.after_json!) as Record<string, unknown>
expect(after['installedOn']).toBe('2026-03-25')
expect(after['status']).toBeUndefined()
})
it('a re-tick that changes nothing writes no client_module audit row at all', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { status: 'live', installedOn: '2025-01-01' })
const countBefore = (await db.all(
`SELECT id FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update'`, cmId)).length
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const countAfter = (await db.all(
`SELECT id FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update'`, cmId)).length
expect(countAfter).toBe(countBefore)
})
})
// Pre-ship audit FIX E: STEP_STATUS only had keys from the NEW template vocabulary
// (installation/training/go_live/quotation_approved), but TAIL_FALLBACK — used by any module
// with no seeded/owner-configured tail (e.g. WHATSAPP, ATM, AMC) — still uses the legacy
// 'installed' key and has no training step. So ticking "Installation done" on one of those
// modules left status at 'quoted' and installed_on NULL forever (the UI's date writers were
// removed with nothing to replace them there). STEP_STATUS now maps the legacy key too.
describe('fallback-template (TAIL_FALLBACK) modules auto-advance too (FIX E)', () => {
it("ticking the legacy 'installed' step (no seeded per-module template) advances status to installed and stamps installed_on", async () => {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
// A module code with no seeded per-module tail (seed.ts only carries SMS/RTGS/MOBILEAPP
// overrides) and no global tail setting -> resolves to the code TAIL_FALLBACK, whose
// install step is keyed 'installed', not 'installation'.
const m = await createModule(db, 'u1', { code: 'ATM', name: 'ATM Service' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await setMilestone(db, 'u1', cm.id, 'installed', true, '2026-03-25')
const after = await getClientModule(db, cm.id)
expect(after!.status).toBe('installed')
expect(after!.installedOn).toBe('2026-03-25')
})
})

@ -1,30 +1,58 @@
// apps/hq/test/milestones-templates.test.ts — per-module onboarding template resolution. // apps/hq/test/milestones-templates.test.ts — composed (front + per-module tail) onboarding
// template resolution.
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { openDb, type DB } from '../src/db' import { openDb, type DB } from '../src/db'
import { milestoneTemplate } from '../src/repos-milestones' import { milestoneTemplate, FRONT_FALLBACK, TAIL_FALLBACK } from '../src/repos-milestones'
describe('per-module milestone templates', () => { describe('composed milestone templates (front + tail)', () => {
let db: DB let db: DB
beforeEach(() => { db = openDb(':memory:') }) beforeEach(() => { db = openDb(':memory:') })
it('falls back to the global template when no module-specific one exists', async () => { it('falls back to the code FRONT_FALLBACK + TAIL_FALLBACK when nothing is configured', async () => {
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
})
it('starts with the 4 front keys then the SMS tail', async () => {
const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key)
expect(keys.slice(0, 4)).toEqual(FRONT_FALLBACK.map((f) => f.key))
})
it('uses the configured front for every module and with no moduleCode', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template.front', ?)`,
JSON.stringify([{ key: 'f1', label: 'F1' }]))
expect((await milestoneTemplate(db, 'SMS'))[0]).toEqual({ key: 'f1', label: 'F1' })
expect((await milestoneTemplate(db))[0]).toEqual({ key: 'f1', label: 'F1' })
})
it('tail: falls back to the global tail template when no module-specific one exists', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'a', label: 'A' }])) JSON.stringify([{ key: 'a', label: 'A' }]))
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'a', label: 'A' }]) expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
}) })
it('prefers the module-specific template over the global one', async () => { it('tail: prefers the module-specific template over the global one', async () => {
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`, await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'a', label: 'A' }])) JSON.stringify([{ key: 'a', label: 'A' }]))
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`,
JSON.stringify([{ key: 'doc', label: 'Document collection' }])) JSON.stringify([{ key: 'doc', label: 'Document collection' }]))
expect(await milestoneTemplate(db, 'SMS')).toEqual([{ key: 'doc', label: 'Document collection' }]) expect(await milestoneTemplate(db, 'SMS'))
// A different module still gets the global one. .toEqual([...FRONT_FALLBACK, { key: 'doc', label: 'Document collection' }])
expect(await milestoneTemplate(db, 'RTGS')).toEqual([{ key: 'a', label: 'A' }]) // A different module still gets the global tail.
expect(await milestoneTemplate(db, 'RTGS')).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
})
it('with no moduleCode: front + global tail when set, else TAIL_FALLBACK', async () => {
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'a', label: 'A' }]))
expect(await milestoneTemplate(db)).toEqual([...FRONT_FALLBACK, { key: 'a', label: 'A' }])
}) })
it('falls back to the code default when nothing is seeded', async () => { it('a malformed or empty per-module tail setting is treated as absent', async () => {
const t = await milestoneTemplate(db, 'SMS') await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template:SMS', ?)`, 'not json')
expect(t.map((e) => e.key)).toContain('go_live') expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template:SMS'`, '[]')
expect(await milestoneTemplate(db, 'SMS')).toEqual([...FRONT_FALLBACK, ...TAIL_FALLBACK])
}) })
}) })

@ -21,10 +21,13 @@ async function world() {
} }
describe('project milestones (D22)', () => { describe('project milestones (D22)', () => {
it('seeds the standard checklist on assignment, in template order', async () => { it('seeds the standard checklist on assignment, in template order (front + tail)', async () => {
const { db, p1 } = await world() const { db, p1 } = await world()
const ms = await listProjectMilestones(db, p1.id) const ms = await listProjectMilestones(db, p1.id)
expect(ms.map((m) => m.key)).toEqual(['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) expect(ms.map((m) => m.key)).toEqual([
'enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved',
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
])
expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true) expect(ms.every((m) => !m.done && m.doneOn === null)).toBe(true)
}) })
@ -45,7 +48,7 @@ describe('project milestones (D22)', () => {
it('adds a project-specific milestone after the standard set', async () => { it('adds a project-specific milestone after the standard set', async () => {
const { db, p1 } = await world() const { db, p1 } = await world()
const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed') const ms = await addProjectMilestone(db, 'u1', p1.id, 'AWS region confirmed')
expect(ms).toHaveLength(7) expect(ms).toHaveLength(11) // 4 front + 6 tail + 1 custom
expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed') expect(ms[ms.length - 1]!.label).toBe('AWS region confirmed')
await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/) await expect(addProjectMilestone(db, 'u1', p1.id, ' ')).rejects.toThrow(/label/)
}) })
@ -59,9 +62,37 @@ describe('project milestones (D22)', () => {
) )
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(0)
await ensureProjectMilestones(db, 'imported-cm') await ensureProjectMilestones(db, 'imported-cm')
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10) // 4 front + 6 tail
await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing await ensureProjectMilestones(db, 'imported-cm') // second call adds nothing
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(6) expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10)
})
// Final whole-branch review FIX 1(b): setFrontTemplate/setModuleTail now reject a
// cross-half key collision, but a template can still end up with a duplicate key some
// other way (e.g. a setting row written directly, or one that predates that guard) —
// ensureProjectMilestones must not crash with a UNIQUE (client_module_id, key) violation
// or insert the same key twice; it must dedupe within its own insert loop.
it('ensureProjectMilestones tolerates a front/tail key collision in the resolved template', async () => {
const { db, c1, m } = await world()
// Force a front/tail collision by writing the settings directly (bypassing the
// now-guarded setFrontTemplate/setModuleTail). world() ran seedIfEmpty, which already
// seeded the front row, so UPDATE it rather than INSERT.
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template.front'`,
JSON.stringify([{ key: 'enquiry', label: 'Enquiry received' }, { key: 'installed', label: 'Front installed' }]))
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'installed', label: 'Tail installed' }, { key: 'go_live', label: 'Go-live' }]))
await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition) VALUES (?, ?, ?, 'quoted', 'yearly', 'standard')`,
'colliding-cm', c1.id, m.id,
)
await expect(ensureProjectMilestones(db, 'colliding-cm')).resolves.toBeUndefined()
const ms = await listProjectMilestones(db, 'colliding-cm')
// Only ONE row for the colliding key ('installed'), not two.
expect(ms.filter((mm) => mm.key === 'installed')).toHaveLength(1)
expect(ms.map((mm) => mm.key)).toEqual(['enquiry', 'installed', 'go_live'])
// Re-running stays safe and adds nothing new.
await ensureProjectMilestones(db, 'colliding-cm')
expect(await listProjectMilestones(db, 'colliding-cm')).toHaveLength(3)
}) })
it('boards the whole book and drills into who is pending a milestone', async () => { it('boards the whole book and drills into who is pending a milestone', async () => {

@ -0,0 +1,224 @@
// apps/hq/test/onboarding-template-routes.test.ts — final whole-branch review FIX 4: the
// owner onboarding-template editor surface (normalizeTemplateSteps / setFrontTemplate /
// setModuleTail / getFrontTemplate / getModuleTail + the four routes) had no direct test
// coverage. Also exercises the cross-half (front <-> tail) key collision rejection added by
// FIX 1, at both the repo level and over HTTP.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
import { createModule } from '../src/repos-modules'
import {
normalizeTemplateSteps, setFrontTemplate, setModuleTail, getFrontTemplate, getModuleTail,
FRONT_FALLBACK, TAIL_FALLBACK,
} from '../src/repos-milestones'
const KEY = '33'.repeat(32)
describe('normalizeTemplateSteps (pure validation)', () => {
it('rejects a non-array or empty body', () => {
expect(() => normalizeTemplateSteps(undefined)).toThrow(/at least one/i)
expect(() => normalizeTemplateSteps('nope')).toThrow(/at least one/i)
expect(() => normalizeTemplateSteps([])).toThrow(/at least one/i)
})
it('rejects a blank label', () => {
expect(() => normalizeTemplateSteps([{ label: ' ' }])).toThrow(/label/i)
expect(() => normalizeTemplateSteps([{ key: 'a', label: 'A' }, { label: '' }])).toThrow(/label/i)
})
it('rejects a duplicate explicit key', () => {
expect(() => normalizeTemplateSteps([
{ key: 'dup', label: 'First' }, { key: 'dup', label: 'Second' },
])).toThrow(/Duplicate step key/)
})
it('derives a key by slugifying the label when none is given, bumping on collision', () => {
const out = normalizeTemplateSteps([
{ label: 'Site Visit!' }, { label: 'site visit' }, { label: 'Site -- Visit' },
])
expect(out).toEqual([
{ key: 'site_visit', label: 'Site Visit!' },
{ key: 'site_visit_2', label: 'site visit' },
{ key: 'site_visit_3', label: 'Site -- Visit' },
])
})
it('a key derived from a label collides with a later EXPLICIT key of the same value -> rejected', () => {
expect(() => normalizeTemplateSteps([{ label: 'Go Live' }, { key: 'go_live', label: 'Go-live (explicit)' }]))
.toThrow(/Duplicate step key/)
})
})
describe('setFrontTemplate / setModuleTail / getFrontTemplate / getModuleTail (repo level)', () => {
it('getFrontTemplate / getModuleTail fall back to the code defaults on an empty DB', async () => {
const db = openDb(':memory:')
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
expect(await getModuleTail(db, 'SMS')).toEqual(TAIL_FALLBACK)
})
it('setFrontTemplate rejects a key colliding with the default tail', async () => {
const db = openDb(':memory:')
await expect(setFrontTemplate(db, 'u1', [{ key: 'installed', label: 'Site installed' }]))
.rejects.toThrow(/used by both the front/i)
})
it('setFrontTemplate rejects a key colliding with an already-configured per-module tail', async () => {
const db = openDb(':memory:')
await setModuleTail(db, 'u1', 'SMS', [{ key: 'kickoff', label: 'Kickoff call' }])
await expect(setFrontTemplate(db, 'u1', [{ key: 'kickoff', label: 'Kickoff' }]))
.rejects.toThrow(/SMS/)
// The front setting was never written — still the fallback.
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
})
it('setModuleTail rejects a key colliding with the shared front', async () => {
const db = openDb(':memory:')
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'enquiry', label: 'Enquiry (dup)' }]))
.rejects.toThrow(/front/i)
})
it('setModuleTail rejects a key colliding with a CUSTOM front (not just the code fallback)', async () => {
const db = openDb(':memory:')
await setFrontTemplate(db, 'u1', [{ key: 'lead_in', label: 'Lead in' }])
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'lead_in', label: 'Also lead in' }]))
.rejects.toThrow(/front/i)
})
it('a non-colliding edit round-trips through the getters and is audited', async () => {
const db = openDb(':memory:')
const front = [{ key: 'lead_in', label: 'Lead in' }]
const tail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
await setFrontTemplate(db, 'u1', front)
await setModuleTail(db, 'u1', 'SMS', tail)
expect(await getFrontTemplate(db)).toEqual(front)
expect(await getModuleTail(db, 'SMS')).toEqual(tail)
// A different, unconfigured module still gets the code fallback tail.
expect(await getModuleTail(db, 'RTGS')).toEqual(TAIL_FALLBACK)
const audited = await db.all<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'project.milestone_template%'`,
)
expect((audited[0] as unknown as { n: number }).n).toBe(2)
})
})
describe('onboarding-template routes (owner-only editor)', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld(): Promise<{ db: DB; base: string; ownerH: Record<string, string>; staffH: Record<string, string> }> {
const db = openDb(':memory:')
await seedIfEmpty(db)
await createStaff(db, { email: 'owner2@t.in', displayName: 'Owner2', role: 'owner', password: 'owner-pass-1' })
await createStaff(db, { email: 'staff@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
// seedIfEmpty pre-seeds a `project.milestone_template:SMS` setting (the shipped SMS
// pipeline), so use an UNconfigured module for tests that assert the code TAIL_FALLBACK.
await createModule(db, 'u1', { code: 'WIDGET', name: 'Unconfigured Widget' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const login = async (email: string, password: string): Promise<Record<string, string>> => {
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json()) as { token: string }).token
return { 'content-type': 'application/json', authorization: `Bearer ${token}` }
}
return { db, base, ownerH: await login('owner2@t.in', 'owner-pass-1'), staffH: await login('staff@t.in', 'staff-pass-1') }
}
it('GET /onboarding-template/front: owner only, serves the fallback by default', async () => {
const { base, ownerH, staffH } = await httpWorld()
const denied = await fetch(`${base}/onboarding-template/front`, { headers: staffH })
expect(denied.status).toBe(403)
const out = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
{ ok: boolean; steps: { key: string; label: string }[] }
expect(out.ok).toBe(true)
expect(out.steps).toEqual(FRONT_FALLBACK)
})
it('PUT /onboarding-template/front: owner only; rejects blank label / duplicate key; round-trips on GET', async () => {
const { base, ownerH, staffH } = await httpWorld()
const denied = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: staffH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
})
expect(denied.status).toBe(403)
const blank = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: ' ' }] }),
})
expect(blank.status).toBe(400)
const dup = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH,
body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }, { key: 'a', label: 'A2' }] }),
})
expect(dup.status).toBe(400)
const newFront = [{ label: 'Lead In!' }, { key: 'confirmed', label: 'Confirmed' }]
const put = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newFront }),
})
expect(put.status).toBe(200)
const putBody = await put.json() as { steps: { key: string; label: string }[] }
expect(putBody.steps[0]).toEqual({ key: 'lead_in', label: 'Lead In!' })
const reGet = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
{ steps: { key: string; label: string }[] }
expect(reGet.steps).toEqual(putBody.steps)
})
it('GET/PUT /modules/:code/onboarding-template: 404 on an unknown module code', async () => {
const { base, ownerH } = await httpWorld()
const getMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, { headers: ownerH })
expect(getMissing.status).toBe(404)
const putMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
})
expect(putMissing.status).toBe(404)
})
it('GET/PUT /modules/:code/onboarding-template: owner only, round-trips a valid edit', async () => {
const { base, ownerH, staffH } = await httpWorld()
const denied = await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: staffH })
expect(denied.status).toBe(403)
const before = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
{ steps: { key: string; label: string }[] }
expect(before.steps).toEqual(TAIL_FALLBACK)
const newTail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
const put = await fetch(`${base}/modules/WIDGET/onboarding-template`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newTail }),
})
expect(put.status).toBe(200)
const after = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
{ steps: { key: string; label: string }[] }
expect(after.steps).toEqual(newTail)
})
it('cross-half collision (FIX 1) is rejected with 400 over HTTP, both directions', async () => {
const { base, ownerH } = await httpWorld()
// Front step whose key collides with the (fallback) SMS tail's 'installed'... use the
// module tail directly since SMS starts from TAIL_FALLBACK (has 'installed').
const frontCollide = await fetch(`${base}/onboarding-template/front`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'installed', label: 'Installed' }] }),
})
expect(frontCollide.status).toBe(400)
const frontCollideBody = await frontCollide.json() as { ok: boolean; error: string }
expect(frontCollideBody.error).toMatch(/used by both the front/i)
// Tail step whose key collides with the shared front's 'enquiry'.
const tailCollide = await fetch(`${base}/modules/SMS/onboarding-template`, {
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'enquiry', label: 'Enquiry (dup)' }] }),
})
expect(tailCollide.status).toBe(400)
const tailCollideBody = await tailCollide.json() as { ok: boolean; error: string }
expect(tailCollideBody.error).toMatch(/front/i)
})
})

@ -0,0 +1,78 @@
// apps/hq/test/projects-stalled-route.test.ts — pre-ship audit FIX B: GET /projects/stalled
// had no try/catch, and a caller-supplied `?days=` flowed straight into
// `new Date(...).toISOString()`, which throws a RangeError on an out-of-range/NaN value.
// On Express 4 + Node 22 an unhandled rejection from an async route handler crashes the
// whole process — ANY authenticated role (including plain staff) could take the console
// down with one GET. `days` is now validated and the handler wrapped like its siblings.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
describe('GET /projects/stalled — hostile ?days= must not crash the server', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld(): Promise<{ base: string; staffH: Record<string, string> }> {
const db = openDb(':memory:')
await seedIfEmpty(db)
await createStaff(db, { email: 'staff-stalled@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'staff-stalled@t.in', password: 'staff-pass-1' }),
})).json()) as { token: string }).token
return { base, staffH: { authorization: `Bearer ${token}` } }
}
it('a plain request (no ?days=) works — falls back to the configured default', async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled`, { headers: staffH })
expect(res.status).toBe(200)
const body = await res.json() as { ok: boolean; projects: unknown[]; unknownAgeExcluded: number }
expect(body.ok).toBe(true)
expect(Array.isArray(body.projects)).toBe(true)
// FIX 4: the count of pending projects excluded for unprovable age travels alongside the
// list itself now, so the UI can surface "N of unknown age" instead of a silent drop.
expect(typeof body.unknownAgeExcluded).toBe('number')
})
it('a sane ?days= still works normally', async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled?days=45`, { headers: staffH })
expect(res.status).toBe(200)
expect((await res.json() as { ok: boolean }).ok).toBe(true)
})
const hostileValues = [
'1e300', // finite in JS but overflows Date math -> used to throw RangeError
'-99999999999999', // huge negative -> overflows the other direction
'3.5', // not an integer
'abc', // NaN
'NaN',
'Infinity',
'-Infinity',
'99999999999999999999', // way outside the sane 0-3650 bound
'-1', // out of bound (negative)
]
for (const hostile of hostileValues) {
it(`?days=${hostile} is rejected with a 4xx, never throws / crashes the server`, async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled?days=${encodeURIComponent(hostile)}`, { headers: staffH })
expect(res.status).toBeGreaterThanOrEqual(400)
expect(res.status).toBeLessThan(500)
const body = await res.json() as { ok: boolean }
expect(body.ok).toBe(false)
// The server process must still be alive for the NEXT request — the original bug's
// unhandled RangeError rejection killed the whole process.
const health = await fetch(`${base}/health`)
expect(health.status).toBe(200)
})
}
})

@ -3,23 +3,36 @@ import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { milestoneTemplate } from '../src/repos-milestones' import { milestoneTemplate } from '../src/repos-milestones'
describe('seed per-module onboarding templates', () => { const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved']
describe('seed onboarding templates (composed front + per-module tail)', () => {
let db: DB let db: DB
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) }) beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
it('seeds the SMS 9-step list', async () => { it('composes the shared front with the SMS tail', async () => {
const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key) const keys = (await milestoneTemplate(db, 'SMS')).map((e) => e.key)
expect(keys).toEqual([ expect(keys).toEqual([
...FRONT_KEYS,
'document_collection', 'dlt_registration', 'account_creation', 'installation', 'document_collection', 'dlt_registration', 'account_creation', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', 'testing', 'training', 'go_live', 'payment_received',
]) ])
}) })
it('seeds the RTGS list ending in payment steps', async () => { it('composes the shared front with the RTGS tail ending in go-live + payment received', async () => {
const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key) const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key)
expect(keys).toEqual([ expect(keys).toEqual([
...FRONT_KEYS,
'document_collection', 'server_checkup', 'setup_configuration', 'installation', 'document_collection', 'server_checkup', 'setup_configuration', 'installation',
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment', 'testing', 'training', 'go_live', 'payment_received',
])
})
it('composes the shared front with the Mobile App tail', async () => {
const keys = (await milestoneTemplate(db, 'MOBILEAPP')).map((e) => e.key)
expect(keys).toEqual([
...FRONT_KEYS,
'requirement_setup', 'configuration', 'data_import', 'installation',
'testing', 'training', 'go_live', 'payment_received',
]) ])
}) })
}) })

@ -27,10 +27,11 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked. // last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
const stalled = await stalledProjects(db, 30, '2026-07-19') const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id) expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.find((p) => p.clientModuleId === cm.id)! const row = stalled.rows.find((p) => p.clientModuleId === cm.id)!
expect(row.clientName).toBe('Kothavara SCB') expect(row.clientName).toBe('Kothavara SCB')
expect(row.moduleCode).toBe('MISC') expect(row.moduleCode).toBe('MISC')
expect(stalled.unknownAgeExcluded).toBe(0)
}) })
it('excludes a recently-progressed project', async () => { it('excludes a recently-progressed project', async () => {
@ -39,7 +40,8 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01') await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01') await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
const stalled = await stalledProjects(db, 365, '2026-07-19') const stalled = await stalledProjects(db, 365, '2026-07-19')
expect(stalled).toHaveLength(0) expect(stalled.rows).toHaveLength(0)
expect(stalled.unknownAgeExcluded).toBe(0)
}) })
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => { it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
@ -50,8 +52,65 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01') await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
} }
const stalled = await stalledProjects(db, 1, '2026-07-19') const stalled = await stalledProjects(db, 1, '2026-07-19')
expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id) expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(done.id)
// 'live' status (from ticking go_live) also excludes it, which the loop above proves. // 'live' status (from ticking go_live) also excludes it, which the loop above proves.
expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live') expect((await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, done.id))!.status).toBe('live')
}) })
// Pre-ship audit FIX D: a project with ZERO ticks used to be measured against the sentinel
// '0000-00-00', which is always older than any cutoff — so a project assigned moments ago
// immediately showed up as "stalled" on the dashboard tile. It should instead be measured
// from client_module.created_at (stamped by assignModule), and excluded entirely when that
// isn't known (never guessed to be old).
describe('a no-tick project is measured from its creation date, not treated as infinitely old (FIX D)', () => {
it('a project assigned "today" with zero ticks is NOT stalled', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Deterministic: pin created_at to the test's fictional "today" rather than relying on
// assignModule's real wall-clock stamp matching whatever the test's fixed date happens
// to be — this test is about the QUERY logic, not the real-time write path.
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2026-07-19T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('a project created long ago with zero ticks IS stalled', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2025-01-01T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
})
it('a no-tick project with an UNKNOWN creation date (created_at NULL) is excluded, not assumed ancient', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Simulates a client_module that predates the created_at column (or an import that
// doesn't stamp it) — created_at is genuinely unknown.
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19') // even a 0-day cutoff must not flag it
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
// FIX 4 (no silent caps): the row above used to just vanish with no trace. It must now be
// counted so the UI can surface "N of unknown age" instead of silently under-reporting.
it('a no-tick row with NULL created_at lands in the unknownAgeExcluded count, not just silently dropped', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(1)
})
it('assignModule itself stamps a real (non-null) created_at', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = await db.get<{ created_at: string | null }>(
`SELECT created_at FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).not.toBeNull()
})
})
}) })

@ -0,0 +1,161 @@
// apps/hq/test/ticket-types.test.ts — Client Detail redesign task 6: ticket classification.
// The list is config over code (the `ticket.types` setting, code fallback); tickets carry
// a `type`, the desk filters by it.
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { setSetting } from '../src/repos-reminders'
import {
createTicket, updateTicket, listTickets, ticketTypes, TICKET_TYPE_FALLBACK,
} from '../src/repos-tickets'
const KEY = '11'.repeat(32)
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
return { db, c }
}
describe('ticket type list (config over code)', () => {
it('seeds `ticket.types` on first boot, matching the code fallback', async () => {
const { db } = await world()
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
expect(row).toBeDefined()
expect(JSON.parse(row!.value)).toEqual(TICKET_TYPE_FALLBACK)
})
it('the resolver prefers the setting over the code fallback', async () => {
const { db } = await world()
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
]))
expect(await ticketTypes(db)).toEqual([
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
])
})
it('falls back to the code list when the setting is missing, empty or unparseable', async () => {
const { db } = await world()
await db.run(`DELETE FROM setting WHERE key='ticket.types'`)
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
await setSetting(db, 'u1', 'ticket.types', '[]')
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
await setSetting(db, 'u1', 'ticket.types', 'not json at all')
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([{ label: 'no key' }]))
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
})
})
describe('ticket.type on create / update / list', () => {
it("defaults to 'service' when the caller sends no type", async () => {
const { db, c } = await world()
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'no type given' })
expect(t.type).toBe('service')
})
it('stores a configured type and rejects an unknown one', async () => {
const { db, c } = await world()
const t = await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'renewal' })
expect(t.type).toBe('amc')
await expect(createTicket(db, 'u1', { clientId: c.id, type: 'nonsense' })).rejects.toThrow(/type/i)
})
it('updates the type (audited) and rejects an unknown one', async () => {
const { db, c } = await world()
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'reclassify me' })
const after = await updateTicket(db, 'u1', t.id, { type: 'modification' })
expect(after.type).toBe('modification')
await expect(updateTicket(db, 'u1', t.id, { type: 'nonsense' })).rejects.toThrow(/type/i)
// still 'modification' — the rejected patch wrote nothing
expect((await listTickets(db, { clientId: c.id })).tickets[0]!.type).toBe('modification')
})
it('filters the list by type; a blank/omitted filter returns every type', async () => {
const { db, c } = await world()
await createTicket(db, 'u1', { clientId: c.id, type: 'service', description: 'a' })
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'b' })
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'c' })
expect((await listTickets(db, { type: 'amc' })).total).toBe(2)
expect((await listTickets(db, { type: 'service' })).total).toBe(1)
expect((await listTickets(db, { type: 'modification' })).total).toBe(0)
expect((await listTickets(db, { type: '' })).total).toBe(3)
expect((await listTickets(db, {})).total).toBe(3)
})
it('an existing (pre-migration) row reads back as the default type', async () => {
const { db, c } = await world()
// Insert the way the APEX importer does — without naming the new column.
await db.run(
`INSERT INTO ticket (id, client_id, kind, description, opened_on, created_by, created_at, source)
VALUES ('legacy-1', ?, 'MODIFICATION', 'imported', '2025-01-01', 'u1', '2025-01-01T00:00:00.000Z', 'apex')`,
c.id,
)
const listed = await listTickets(db, { clientId: c.id })
expect(listed.tickets[0]!.type).toBe('service')
})
})
describe('ticket type routes', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld(): Promise<{ db: DB; clientId: string; base: string; H: Record<string, string> }> {
const { db, c } = await world()
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const token = ((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
})).json()) as { token: string }).token
return { db, clientId: c.id, base, H: { 'content-type': 'application/json', authorization: `Bearer ${token}` } }
}
it('GET /tickets/types serves the configured list', async () => {
const { base, H } = await httpWorld()
const out = await (await fetch(`${base}/tickets/types`, { headers: H })).json() as
{ ok: boolean; types: { key: string; label: string }[] }
expect(out.ok).toBe(true)
expect(out.types).toEqual(TICKET_TYPE_FALLBACK)
})
it('POST /tickets accepts a type, and GET /tickets?type= filters on it', async () => {
const { clientId, base, H } = await httpWorld()
for (const type of ['service', 'amc', 'amc']) {
const created = await (await fetch(`${base}/tickets`, {
method: 'POST', headers: H, body: JSON.stringify({ clientId, type, description: type }),
})).json() as { ok: boolean; ticket: { type: string } }
expect(created.ticket.type).toBe(type)
}
const amc = await (await fetch(`${base}/tickets?type=amc`, { headers: H })).json() as
{ total: number; tickets: { type: string }[] }
expect(amc.total).toBe(2)
expect(amc.tickets.every((t) => t.type === 'amc')).toBe(true)
const all = await (await fetch(`${base}/tickets?type=`, { headers: H })).json() as { total: number }
expect(all.total).toBe(3)
})
it('PATCH /tickets/:id reclassifies; an unknown type is a 400', async () => {
const { clientId, base, H } = await httpWorld()
const created = await (await fetch(`${base}/tickets`, {
method: 'POST', headers: H, body: JSON.stringify({ clientId, description: 'x' }),
})).json() as { ticket: { id: string } }
const patched = await (await fetch(`${base}/tickets/${created.ticket.id}`, {
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'module' }),
})).json() as { ticket: { type: string } }
expect(patched.ticket.type).toBe('module')
const bad = await fetch(`${base}/tickets/${created.ticket.id}`, {
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'nonsense' }),
})
expect(bad.status).toBe(400)
})
})

@ -5,6 +5,10 @@ If a name here disagrees with a command you're about to run, this file wins. Ser
existing company **Amazon Linux 2** EC2 box; HQ runs as its own Docker container beside the existing company **Amazon Linux 2** EC2 box; HQ runs as its own Docker container beside the
other apps. (D20 target, revised: `.in` not `.com`, Docker not RDS — see notes at bottom.) other apps. (D20 target, revised: `.in` not `.com`, Docker not RDS — see notes at bottom.)
> ✅ **Live** at `https://hq.simssoftware.in` since 2026-07-21 (D39 in `docs/06-DECISIONS.md`).
> `docs/DEPLOY-HQ.md` describes an earlier RDS/GoDaddy/`.com` plan that was **not** what
> shipped — this file is the accurate one.
## Identity ## Identity
| Thing | Value | | Thing | Value |
@ -27,7 +31,7 @@ other apps. (D20 target, revised: `.in` not `.com`, Docker not RDS — see notes
| Path | What it is | | Path | What it is |
|---|---| |---|---|
| `/usr/share/nginx/sims-hq` | **App folder** — the whole project (owned by `ec2-user`). Docker builds from here. | | `/usr/share/nginx/sims-hq` | **App folder** — the whole project (owned by `ec2-user`). Docker builds from here. |
| `/usr/share/nginx/sims-hq/apps/hq/src/server.ts` | Holds the **hardcoded DB connection** (`HARDCODED_DATABASE_URL`) — no `.env` is used. | | `/usr/share/nginx/sims-hq/apps/hq/src/db-pg.ts` | Holds the **hardcoded DB connection** (`HARDCODED_DATABASE_URL`) — moved here from `server.ts` (pre-ship audit FIX A, one shared `resolveDbUrl()`). No `.env` is used. |
| `/usr/share/nginx/sims-hq/deploy/sims.hq.conf` | nginx vhost **source** (copied into nginx below). | | `/usr/share/nginx/sims-hq/deploy/sims.hq.conf` | nginx vhost **source** (copied into nginx below). |
| `/etc/nginx/conf.d/sims.hq.conf` | **Live nginx vhost** (must end in `.conf`; `sudo` to edit). | | `/etc/nginx/conf.d/sims.hq.conf` | **Live nginx vhost** (must end in `.conf`; `sudo` to edit). |
| `/etc/ssl/sims/sims.crt` · `/etc/ssl/sims/sims.key` | Wildcard TLS cert for `*.simssoftware.in` (shared). | | `/etc/ssl/sims/sims.crt` · `/etc/ssl/sims/sims.key` | Wildcard TLS cert for `*.simssoftware.in` (shared). |
@ -65,16 +69,19 @@ activates the hardcoded values; dev and the test suite are unaffected and still
| Setting | Where | Value | | Setting | Where | Value |
|---|---|---| |---|---|---|
| DB connection | `apps/hq/src/server.ts` → `HARDCODED_DATABASE_URL` | `postgres://postgres:<password>@host.docker.internal:5432/hq`**replace `CHANGE_ME_PASSWORD` on the box, then rebuild** | | DB connection | `apps/hq/src/db-pg.ts` → `HARDCODED_DATABASE_URL` | `postgres://postgres:inv123@host.docker.internal:5432/hq` |
| Owner login email | `apps/hq/src/seed.ts``OWNER_EMAIL` default | `simssoftware13@gmail.com` | | Owner login email | `apps/hq/src/seed.ts``OWNER_EMAIL` default | `simssoftware13@gmail.com` |
| Port | `HQ_PORT` env, else default | `5182` | | Port | `HQ_PORT` env, else default | `5182` |
| PIN pepper | *(unset)* | password hashes are un-peppered — a future hardening | | PIN pepper | *(unset)* | password hashes are un-peppered — a future hardening |
| Gmail secret key | *(unset)* | set only when connecting Gmail | | Gmail secret key | *(unset)* | set only when connecting Gmail |
> ⚠️ **To change the DB later:** edit `HARDCODED_DATABASE_URL` in `server.ts` on the box, then > ⚠️ **Deviation from the original plan (accepted, D39):** the real password is committed to
> `sudo docker compose up -d --build` (a rebuild, not just a restart). The real password lives > Gitea, not kept as a `CHANGE_ME_PASSWORD` placeholder — Gitea is internal/single-user, so
> only in the box's copy — **never push the filled-in `server.ts` to Gitea** (the repo keeps > this was accepted for now. Rotating this password is the top item whenever the hardening
> the `CHANGE_ME_PASSWORD` placeholder). > pass happens; whoever does it must also update the value in `db-pg.ts` and rebuild.
>
> **To change the DB later:** edit `HARDCODED_DATABASE_URL` in `db-pg.ts`, then rebuild
> (see Everyday commands below).
## Owner login ## Owner login
@ -85,14 +92,24 @@ activates the hardcoded values; dev and the test suite are unaffected and still
## Everyday commands (run from `/usr/share/nginx/sims-hq`) ## Everyday commands (run from `/usr/share/nginx/sims-hq`)
> ⚠️ **Docker Compose V2 is not installed on this box** (only the standalone `docker-compose`
> 1.21.0 from 2018, too old for this file's `extra_hosts: host-gateway`). `docker-compose.yml`
> is kept as the source of truth for what the container needs, but it's run manually via plain
> `docker build`/`docker run` below until the Compose V2 plugin is installed (see install
> command in `docs/06-DECISIONS.md` D39, or run `docker compose version` to check if someone
> already added it).
| Do this | Command | | Do this | Command |
|---|---| |---|---|
| Build + start | `sudo docker compose up -d --build` | | Build | `sudo docker build -t sims-hq .` |
| Start (first time / after removing the old container) | `sudo docker run -d --name sims-hq --restart unless-stopped -p 127.0.0.1:5182:5182 --add-host host.docker.internal:host-gateway sims-hq` |
| Rebuild + replace (code changed) | `sudo docker stop sims-hq && sudo docker rm sims-hq`, then Build + Start above |
| View logs | `sudo docker logs sims-hq` (`-f` to follow) | | View logs | `sudo docker logs sims-hq` (`-f` to follow) |
| Restart | `sudo docker compose restart hq` | | Restart (no code change) | `sudo docker restart sims-hq` |
| Stop | `sudo docker compose down` | | Stop | `sudo docker stop sims-hq` |
| Health check | `curl -s localhost:5182/api/health``{"ok":true}` | | Health check | `curl -s localhost:5182/api/health``{"ok":true}` |
| nginx test + reload | `sudo nginx -t && sudo systemctl reload nginx` | | nginx test + reload | `sudo nginx -t && sudo systemctl reload nginx` |
| Deploy code changes | WinSCP-copy the whole project folder (except `node_modules`, `dist`, `.git`, `.env`, `data`, `*.log`) into `/usr/share/nginx/sims-hq`, overwriting — this box doesn't use `git pull` |
## Notes / decisions ## Notes / decisions
- Domain is **`.in`** (not `.com`): the wildcard cert is `*.simssoftware.in` and every other - Domain is **`.in`** (not `.com`): the wildcard cert is `*.simssoftware.in` and every other

@ -571,13 +571,29 @@ refresh no-ops with a clear message, it never stores anything unencrypted.
(Superseded by D31/D32: the key was removed; the pull now runs keyless.) (Superseded by D31/D32: the key was removed; the pull now runs keyless.)
## D33 — Database backups (ops, 2026-07-19) ## D33 — Database backups (ops, 2026-07-19)
`scripts/backup.mjs` (`npm run backup`) `pg_dump`s the database to a timestamped, compressed, `apps/hq/scripts/backup.ts``apps/hq/src/backup.ts` (`npm run backup`) `pg_dump`s the
database to a timestamped, compressed,
`pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP` `pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP`
(default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin` (default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin`
install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" <dump>`. install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" <dump>`.
Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir Priority rose with D32 — dumps now contain **plaintext credentials**, so the `backups/` dir
must stay access-controlled and off shared drives. Still pending in the ops slice: schedule must stay access-controlled and off shared drives. Still pending in the ops slice: schedule
it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`. it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`.
It resolves its target through the SAME shared resolver as the server and the maintenance
scripts (`resolveDbUrl`, D19) and logs the resolved host/db before dumping — the original
`scripts/backup.mjs` read `DATABASE_URL` straight off the environment and so could never
reach production, where that variable is not exported.
**Working invocation (final review FIX 3):** `resolveDbUrl` only returns the hardcoded
production connection when `NODE_ENV=production` — and an operator's own interactive shell on
the prod box does NOT have that set (only the deployed service's env does), so a bare
`npm run backup` typed by hand still resolves nothing and refuses. Run the backup with ONE of:
- `NODE_ENV=production npm run backup` — matches how the deployed service resolves it, or
- `DATABASE_URL=postgres://… npm run backup` — explicit target, overrides resolution.
The refusal error itself now names both of these exact commands, so an operator who hits it
can act without reading source. This does not change `resolveDbUrl`'s production semantics and
does not embed the connection string anywhere new.
## D34 — Employee master import + invited/first-login flag (2026-07-19) ## D34 — Employee master import + invited/first-login flag (2026-07-19)
Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via
@ -616,3 +632,73 @@ a "VANITHA … SOCIETY" is Vanitha, not the generic Society), backfilled by a po
applied to new/edited clients via `categoryFromName`. Clients screen gains a Category filter + applied to new/edited clients via `categoryFromName`. Clients screen gains a Category filter +
column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural, column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural,
11 Urban, … 11 Urban, …
## D38 — Client Detail redesign: composed onboarding pipeline, status line & payments context (2026-07-20)
Reworks the Client 360 Modules + Payments tabs around how the business actually runs — a module is
the *end* of a sales process, not a one-click assignment.
**Composed onboarding templates (config over code).** The step list is resolved as a **shared lead
front + a per-module tail**: `project.milestone_template.front` (Enquiry received · Visit / meeting
scheduled · Quotation sent · Quotation approved) applies to **every** module, and
`project.milestone_template:<CODE>` supplies that module's delivery steps (SMS: Document collection ·
DLT registration · Account creation · Installation · Testing · Training · Go-live · Payment received;
RTGS and Mobile App have their own). Edit the front once and every module follows. Both are
owner-editable in a new **Modules → Onboarding steps** editor. A one-time, idempotent migration
(`scripts/apply-onboarding-pipeline.ts`) moves existing projects onto the composed list, carrying
mapped ticks.
**The status line drives the record.** The checklist became a **horizontal status line** whose ticks
**auto-advance `client_module.status`** (quotation approved→ordered, installation→installed,
training→trained, go-live→live; never downgrades), so the status dropdown stops being hand-set. The
**Payment received** step links to a real payment (`project_milestone.payment_id`, step date mirrors
`received_on`). The **Account creation** step prompts for the module credentials — and credentials
are **no longer required when adding a module** (they don't exist yet at that point); they stay
editable anytime via the module's Access panel.
**Module + payments surfaces.** Details render as a read-only **50/50 sheet** (Access | Details) with
one Edit per group — no always-on input boxes. Each module block lists **its own documents**
(line-item match) labelled Quote / Renewal / **Bulk top-up** / Invoice; SMS can raise a
**bulk-credit purchase** proforma (`source='bulk_sms_topup'`) distinct from a subscription renewal
(`source='module_renewal'`). The Payments tab gains an **Outstanding** panel — issued invoices with a
balance plus **sent/accepted** proformas (un-issued drafts excluded, owner decision) — and a
*Settled → document* column per payment. The money engine is untouched (read-only ledger extension).
Also: a **stalled-onboarding** query for the dashboard/MIS, and a mobile fix so the record header
stacks instead of overlapping the client name.
Spec + plans under `docs/superpowers/`.
## D39 — Go-live: hq.simssoftware.in ✅ DELIVERED (2026-07-20/21)
HQ is live in production at **`https://hq.simssoftware.in`**, on the existing company EC2 box,
superseding the RDS/GoDaddy/`.com` plan in `docs/DEPLOY-HQ.md` (that doc predates the actual
setup — `deploy/PATHS-AND-NAMES.md` is the accurate reference; DEPLOY-HQ.md is marked superseded).
**Deployed as built, three deviations from the original runbook:**
- **Docker Compose V2 isn't installed** on the box (only the old standalone `docker-compose`
1.21.0, too old for this file's `extra_hosts: host-gateway`). Deployed via plain
`docker build` + `docker run --add-host host.docker.internal:host-gateway ...` instead —
the exact equivalent of `docker compose up -d --build`. `deploy/PATHS-AND-NAMES.md`
documents both forms; installing the Compose v2 plugin is a later nice-to-have, not a blocker.
- **`Dockerfile` drops `package-lock.json` before `npm install`** (new step). A Windows-generated
lockfile hit npm/cli#4828 — `@rollup/rollup-linux-x64-gnu` failed to resolve during the
container build even on a clean install. Removing the lockfile forces same-platform resolution.
- **Real data was carried over, not "born empty + APEX import."** The working dataset turned out
to live in a local Postgres instance (273 clients, 14 staff, 156 documents, 1826 tickets, etc.),
not the local SQLite file assumed by D19/D20 — SQLite only had 2 leftover test accounts. A
Postgres→Postgres copy (`apps/hq/scripts/migrate-pg-to-pg.ts`) wiped the target `hq` database's
schema, rebuilt it via the app's own migrations, then copied every table across in one
transaction (FK checks disabled for the load via `session_replication_role=replica`). The
APEX CSV importer is no longer needed for the initial 300 clients — they're already in.
(`apps/hq/scripts/migrate-sqlite-to-pg.ts` was the first attempt, against the wrong source;
left in the repo, harmless — it refuses to run against a non-empty target.)
**Known accepted debt, deferred on purpose (owner decision):**
- `HARDCODED_DATABASE_URL` (`db-pg.ts`) carries the real Postgres password in git history —
Gitea is internal/single-user, so this is accepted for now; rotating it is the top item
whenever the hardening pass happens.
- Postgres port 5432 is open to the public internet (`0.0.0.0`), not restricted to the EC2
security group — same deferred bucket.
- `SIMS_PIN_PEPPER` is still unset (login hashes un-peppered) — same bucket.
- Shared `postgres` superuser role, not a dedicated least-privilege `hq` role — same bucket.
**Still open, not yet done:** `share.base_url` setting, and a backup cron for the shared
Postgres (`pg_dump` — no RDS automated snapshots here).

@ -1,5 +1,11 @@
# Go-Live Runbook — HQ ops console (`apps/hq`) # Go-Live Runbook — HQ ops console (`apps/hq`)
> ⚠️ **SUPERSEDED (2026-07-21, D39).** HQ is live at `https://hq.simssoftware.in` — Docker on
> the existing EC2 box against the shared `accurate-postgres` container, **not** the RDS +
> GoDaddy + `.com` plan below. For the actual setup, commands, and paths, use
> **`deploy/PATHS-AND-NAMES.md`** instead. This file is kept for its Postgres-engine background
> (§7) but its go-live steps (§0§6, §6b) do not reflect what shipped.
How to put the internal console on your AWS box for real use. The store product is How to put the internal console on your AWS box for real use. The store product is
unaffected (it stays local, D14); this is the vendor-side cloud app doc 11 always unaffected (it stays local, D14); this is the vendor-side cloud app doc 11 always
prescribed. Engine: SQLite for dev, **Postgres in production via `DATABASE_URL`** prescribed. Engine: SQLite for dev, **Postgres in production via `DATABASE_URL`**

@ -0,0 +1,120 @@
# Known issues & pending work — HQ Ops Console
_Last updated: 2026-07-21 · after the D38 Client Detail redesign landed on `main`._
This is the honest list of what is **not** done, what is **not** verified, and what needs a human
decision. Everything here was found by review; none of it is speculative. Ordered by consequence.
---
## 1. SECURITY — production Postgres password is in the repo and on the remote
**Status: open. Owner decision parked.**
The production Postgres connection string (with password) is hardcoded in
`apps/hq/src/db-pg.ts` (`HARDCODED_DATABASE_URL`), and it **is present on the `origin` remote**
(`gitea.simssoftware.in`). It was introduced in commit `45cfb83` (then in `server.ts`) and later moved
into `db-pg.ts`; both states have been pushed. A duplicate copy that had been added to
`apps/hq/test/db-resolve.test.ts` was removed in `6f50799`.
**Why it matters:** under D31/D32 every client portal/gateway password, module secret and client DB
password is stored **in the clear**. This one credential is therefore the key to all of them.
**The only real remediation is to ROTATE the Postgres password on the box.** Editing or reverting the
file does not remove it from history, and history is already distributed.
Secondary (after rotation), pick one:
- move the value to a deploy-time env var / secrets file and revert the code to a placeholder —
**note the AWS box currently has no `.env`, so this requires an ops change there first**; or
- accept the hardcoded pattern and treat repo read access as equivalent to production DB access.
---
## 2. BLOCKED — the real data has not been touched yet
**Status: blocked on the local Postgres password.**
The following were prepared and tested but **never run against the real database**, because the
local PostgreSQL 17 instance's credentials are not available to this session:
- **Scrub of the "tecnostac" name from the real DB.** The earlier scrub ran against
`data/hq.db` — a near-empty leftover SQLite file (2 clients), **not** the live Postgres. The live
database very likely still contains the old company name, owner login and audit snapshots.
- **The onboarding template migration** over ~167 real projects
(`apps/hq/scripts/apply-onboarding-pipeline.ts`).
- **Redeploy** — the server currently running on `:5182` predates all of this work, so the console
in use does not yet reflect the redesign.
Until this runs, the redesign is **code-complete but not live**.
---
## 3. NOT VERIFIED — things nobody has actually observed working
- **The Postgres migrations have never been executed against a real Postgres instance.**
Migrations `015``018` (`project_milestone.payment_id`, `.document_id`, `ticket.type`,
`client_module.created_at`) are statically reviewed and mirrored from the SQLite schema, but the
test suite's Postgres path is skipped without `HQ_PG_TEST_URL`. 5 tests are skipped for this reason.
- **The Payments tab was not screenshotted at 768px, nor at 1440px in dark theme.** The rest of the
redesign was verified in a real browser at 375 / 768 / 1440 × light + dark.
---
## 4. DEFERRED TO ROADMAP (owner call)
- **`gmail-connect.ts` and the APEX importer CLI use an unconditional `openDb()`** and therefore can
never reach Postgres — they will always target SQLite. `gmail-connect.ts` is a documented production
runbook step. Deferred because that area is not in active use yet.
---
## 5. BEHAVIOUR CHANGES worth knowing
- **Tables now scroll inside their own box, bounded at `70vh`, with a sticky header.** Previously the
whole page scrolled and (at desktop widths) tables silently dropped their rightmost columns. Short
tables are unchanged; long tables scroll internally. This affects every table screen.
- **Adding a module no longer asks for credentials.** They are captured when the "Account creation"
onboarding step is ticked, and remain editable from the module's Access panel.
- **The coarse module status is now driven by the status line.** Ticking Installation / Training /
Go-live advances it automatically; it never downgrades.
---
## 6. MINOR BACKLOG (real, non-blocking)
From the review passes, not yet fixed:
- `bulkSetMilestone` couples to a literal error string (`'Milestone not found on this project'`);
rewording that throw would turn a skip into a whole-batch abort. Prefer a sentinel/typed error.
- `project.stall_days` (the setting, as opposed to the `?days=` query param) is not range-validated;
an absurd value produces a 400 rather than a crash, but it should be clamped for symmetry.
- Whitespace-only `?days=` input is treated as `0` rather than rejected.
- Two `/client-modules/:id/milestones` handlers `await` outside their `try` block (not reachable with
attacker-controlled input, and not a regression).
- `DraftInput.source` is a free-form string taken from the request body — any authenticated user can
tag a document `module_renewal` or `bulk_sms_topup`. Provenance label only, no money impact; a union
type would give a compile-time guard.
- `ClientDetail` still calls `getModules` / `getTicketTypes` on load in some paths; only the duplicate
fetch was removed.
- Chip/button tap targets are ~23px high on mobile — below the comfortable minimum, but this is a
pre-existing system-wide convention, not something this work introduced.
---
## 7. REPO HYGIENE
- **`.claude-flow/` is tracked in git** (6 files of agent daemon state/metrics). It is scratch and
should be removed from tracking and added to `.gitignore`.
- `apps/hq/scripts/migrate-sqlite-to-pg.ts` and `migrate-pg-to-pg.ts` are untracked working files.
- `apps/hq/schema.sql` is a stale generated snapshot (dated 2026-07-10) that no longer matches
`db.ts`, which is the source of truth. It does not even contain `project_milestone`.
---
## How this list was produced
The redesign went through per-task reviews during the build, then a whole-branch review, a four-lens
audit swarm, a real-browser responsive red-team, and a review of the fix commits themselves. Each
layer found defects the previous one could not — including a payment feature that was dead code, a
deploy script that would have written to a phantom database while reporting success, and a route any
authenticated user could crash the server with. Those are fixed. The items above are what remains.

@ -11,7 +11,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces --if-present", "typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces --if-present",
"backup": "node scripts/backup.mjs" "backup": "tsx apps/hq/scripts/backup.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",

@ -134,6 +134,7 @@ export function DataTable(props: {
} }
return ( return (
<div className="wf-table-wrap">
<table className="wf"> <table className="wf">
<thead> <thead>
<tr> <tr>
@ -180,6 +181,7 @@ export function DataTable(props: {
})} })}
</tbody> </tbody>
</table> </table>
</div>
) )
} }

@ -194,6 +194,22 @@ input.wf:focus, select.wf:focus, textarea.wf:focus {
.wf-toolbar .spacer { flex: 1; } .wf-toolbar .spacer { flex: 1; }
/* ================= tables ================= */ /* ================= tables ================= */
/* Horizontal-scroll wrapper (emitted by DataTable) so a wide table scrolls sideways
inside its own box instead of silently losing its rightmost columns off-page at
any width (rule 8 no silent caps). Bounded to a fraction of the viewport height
and given a matching `overflow-y` so it is a real, self-contained scroll box:
`overflow-x: auto` alone would still leave `overflow-y` at its initial `visible`,
but per the CSS overflow spec a `visible`/non-`visible` pairing computes the
`visible` side to `auto` too so without this the wrapper silently becomes an
unbounded vertical scroll container (height: auto, scrollTop always 0) and steals
`table.wf th`'s sticky-positioning context from `.hq-content` (the page's real
scroller) for nothing in return: the header can never offset and just scrolls out
of view with the rest of the page. Giving the wrapper an actual bound restores a
working scrollport, so the sticky header sticks again now against this box
rather than the page. A short table never reaches the bound (renders exactly as
before, no inner scrollbar); a long one scrolls inside it, same pattern already
used for `.dash-list`. */
.wf-table-wrap { overflow: auto; max-height: 70vh; }
table.wf { table.wf {
border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0; width: 100%;
background: var(--bg-raised); border: 1px solid var(--border); background: var(--bg-raised); border: 1px solid var(--border);
@ -461,3 +477,13 @@ button.wf.pill { border-radius: 999px; padding: 8px 18px; }
.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } .wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
.wf-formgrid .wide { grid-column: 1 / -1; } .wf-formgrid .wide { grid-column: 1 / -1; }
@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } } @media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } }
/* ================= mobile + tablet layout ================= */
@media (max-width: 860px) {
/* Record header: stack the action-button cluster below the avatar/name/meta instead of
crowding them next to it and squeezing the name into a sliver (seen at ~768px with a
long client name + a full button cluster). Desktop layout (outside this query) is
untouched. */
.wf-rec { flex-wrap: wrap; }
.wf-rec-actions { width: 100%; }
}

@ -1,78 +0,0 @@
#!/usr/bin/env node
/**
* SiMS HQ database backup (ops slice). Dumps the Postgres database to a timestamped,
* compressed file under ./backups (or $HQ_BACKUP_DIR), then rotates to the newest N.
*
* DATABASE_URL=postgres://user:pass@host:5432/db node scripts/backup.mjs
* npm run backup (from apps/hq passes DATABASE_URL through)
*
* Restore a dump with:
* pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" backups/hq-<ts>.dump
*
* SECURITY (D32): credentials are stored in the CLEAR, so these dumps contain plaintext
* logins. Keep the backups/ directory access-controlled and off any shared drive; it is
* gitignored so it can never be committed.
*
* Env: DATABASE_URL (required), HQ_BACKUP_DIR (default ./backups), HQ_BACKUP_KEEP (default 14).
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const url = process.env.DATABASE_URL
if (!url || url.trim() === '') {
console.error('DATABASE_URL is not set. Run: DATABASE_URL=postgres://… node scripts/backup.mjs')
process.exit(1)
}
/** Locate pg_dump: PATH first, then a standard Windows PostgreSQL install. */
function findPgDump() {
try {
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['pg_dump'], { stdio: 'ignore' })
return 'pg_dump'
} catch { /* not on PATH */ }
if (process.platform === 'win32') {
const base = 'C:/Program Files/PostgreSQL'
try {
for (const v of readdirSync(base).sort().reverse()) {
const cand = join(base, v, 'bin', 'pg_dump.exe')
if (existsSync(cand)) return cand
}
} catch { /* no install dir */ }
}
return null
}
const pgDump = findPgDump()
if (pgDump === null) {
console.error('pg_dump not found. Install the PostgreSQL client tools, or add pg_dump to PATH.')
process.exit(1)
}
const OUT = process.env.HQ_BACKUP_DIR || join(process.cwd(), 'backups')
const KEEP = Math.max(1, Number(process.env.HQ_BACKUP_KEEP || 14))
mkdirSync(OUT, { recursive: true })
const d = new Date()
const p2 = (n) => String(n).padStart(2, '0')
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`
const file = join(OUT, `hq-${ts}.dump`)
// -Fc = custom, compressed, restorable with pg_restore. --no-owner keeps it portable.
try {
execFileSync(pgDump, ['-Fc', '--no-owner', '-f', file, url], { stdio: ['ignore', 'inherit', 'inherit'] })
} catch (e) {
console.error(`pg_dump failed: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
const kb = (statSync(file).size / 1024).toFixed(1)
console.log(`✓ backup written: ${file} (${kb} KB)`)
// Rotation: keep the newest KEEP hq-YYYYMMDD-HHMMSS.dump files.
const dumps = readdirSync(OUT).filter((f) => /^hq-\d{8}-\d{6}\.dump$/.test(f)).sort()
for (const f of dumps.slice(0, Math.max(0, dumps.length - KEEP))) {
unlinkSync(join(OUT, f))
console.log(` rotated out: ${f}`)
}
console.log(`retained ${Math.min(dumps.length, KEEP)} backup(s) in ${OUT} (HQ_BACKUP_KEEP=${KEEP})`)
Loading…
Cancel
Save