Compare commits

..

9 Commits

Author SHA1 Message Date
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

@ -68,7 +68,7 @@ header stacks instead of overlapping the title).
| --- | --- |
| `npm install` | clean |
| `npm run typecheck` (root + workspaces) | clean, no errors |
| `npm test` (`vitest run`) | **473 tests pass across 94 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
grown from the D18 go-live cluster through the D20–D33 work (tickets, module fields,

@ -270,10 +270,14 @@ button.dash-row:hover { background: var(--accent-soft); }
.hq-content { padding: 8px 14px 20px; }
.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. */
.hq-content { overflow-x: hidden; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
.wf-table-wrap { overflow-x: auto; }
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
@ -512,10 +516,17 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse
.cdr-node.done .nm { color: var(--text-dim); }
.cdr-node.now .nm { font-weight: 600; }
.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 */
.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; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */
.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 .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 .due { margin-left: auto; font-family: var(--mono); font-weight: 600; }
/* document-type pill */

@ -14,10 +14,10 @@ import {
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage, getCompanyProfile,
getBranches, createBranch, patchBranch, getTickets,
getBranches, createBranch, patchBranch, getTickets, getTicketTypes,
getMilestones, setMilestone, addMilestone,
generateBulkSmsPurchase,
CLIENT_STATUSES, KIND_LABEL,
CLIENT_STATUSES, KIND_LABEL, TICKET_TYPE_FALLBACK,
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
type ClientStatus, type Doc, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome,
type Payment, type RecurringPlan, type ServiceDetail,
@ -25,7 +25,7 @@ import {
import { buildStatement } from '../statement'
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import {
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, ticketTypeLabel,
} from './Tickets'
import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
@ -65,6 +65,9 @@ export function ClientDetail() {
const branches = useData(() => getBranches(id), [id])
const [ticketPage, setTicketPage] = useState(1)
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
// Same classification list the Tickets desk uses, so the Type column reads identically here.
const ticketTypes = useData(getTicketTypes, [])
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>()
@ -317,8 +320,9 @@ export function ClientDetail() {
<details key={cm.id} open className="mod-section">
<summary>
<span className="mod-name">{moduleName(cm.moduleId)}</span>
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</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. */}
@ -340,9 +344,15 @@ export function ClientDetail() {
clientModuleId={cm.id}
name={moduleName(cm.moduleId)}
refreshToken={milestonesVersion}
payments={ledger.data?.payments ?? []}
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}
@ -372,7 +382,8 @@ export function ClientDetail() {
<DataTable
columns={[
{ 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: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
@ -383,6 +394,8 @@ export function ClientDetail() {
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 : '—',
desc: <TicketDescription text={t.description} />,
assigned: t.assignedName ?? '—',
@ -679,6 +692,9 @@ export function ClientDetail() {
.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))
@ -703,6 +719,7 @@ export function ClientDetail() {
.then(() => {
toast.ok('Document linked')
setMilestonesVersion((v) => v + 1)
cms.reload()
setDocPicker(undefined)
})
.catch((e: Error) => setDocLinkError(e.message))
@ -712,15 +729,19 @@ export function ClientDetail() {
/>
)}
{/* 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. */}
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={ticketDialog !== undefined}
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 } : {})}
{...(ticketDialog.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})}
/>
)}
{credsDialog !== undefined && (
<CredentialsDialog
open
@ -755,6 +776,7 @@ export function ClientDetail() {
.then(() => {
toast.ok('Step marked done')
setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
@ -1392,9 +1414,10 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* 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) calls `onTickPayment`
* `PaymentPickerDialog`, which links a real ledger payment
* (`setMilestone(..., paymentId)`) rather than stamping a bare date;
* `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;
@ -1402,15 +1425,27 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* `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.
* "+ 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.
* 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 StatusLine(props: {
clientModuleId: string; name: string; refreshToken: number
payments: Payment[]
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 list = useData(() => getMilestones(props.clientModuleId), [props.clientModuleId, props.refreshToken])
@ -1423,7 +1458,7 @@ function StatusLine(props: {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => list.reload())
.then(() => { list.reload(); props.onChanged() })
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey(''))
}
@ -1440,10 +1475,15 @@ function StatusLine(props: {
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 template uses; advance_/balance_payment are
// legacy keys that still exist on projects not yet run through the onboarding migration.
// `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'
@ -1506,6 +1546,27 @@ function StatusLine(props: {
</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. */}
{m.done && m.documentId !== null && (
<button
type="button" className="ref" title="Open the linked document"
onClick={() => props.onOpenDocument(m.documentId!)}
>
view document
</button>
)}
{m.done && m.paymentId !== null && (() => {
const p = props.payments.find((x) => x.id === m.paymentId)
return (
<button
type="button" className="ref" title="The linked payment — open Payments & plans"
onClick={() => props.onOpenPayments()}
>
{p !== undefined ? `${p.receivedOn} · ${inr(p.amountPaise)}` : 'payment linked'}
</button>
)
})()}
</div>
))}
</div></div>
@ -1557,7 +1618,7 @@ function ModuleDocuments(props: { cm: ClientModule; docs: Doc[]; moduleCode: str
{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', gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}>
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>

@ -145,8 +145,16 @@ export function Dashboard() {
))}
</Card>
<Card title="Onboarding stalled" count={stalled.data?.length ?? 0} empty="Nothing stalled." isEmpty={(stalled.data ?? []).length === 0}>
{(stalled.data ?? []).map((p) => (
<Card
title="Onboarding stalled" count={stalled.data?.length ?? 0}
empty="Nothing stalled."
// A fetch failure is not the same as a genuine empty result — show the error
// (with retry) instead of quietly reporting "Nothing stalled." on failure.
isEmpty={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} />
))}

@ -380,7 +380,9 @@ export function Tickets() {
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? (
<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}` : ''}.
</EmptyState>
) : (

@ -5,9 +5,10 @@
// 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
import { readFileSync } from 'node:fs'
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { updateClient } from '../src/repos-clients'
import type { DB } from '../src/db'
interface Res { clientId: string; ltdNo: string; found: boolean }
@ -16,9 +17,18 @@ async function main() {
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 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'])
// 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 {
let applied = 0, already = 0, skipped = 0
const seen = new Set<string>()
for (const r of results) {
@ -37,6 +47,9 @@ async function main() {
}
// 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()
}
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -2,9 +2,10 @@
// 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
import { readFileSync } from 'node:fs'
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { updateClient } from '../src/repos-clients'
import type { DB } from '../src/db'
interface Row { id: string; current: string; suggested: string; changed: boolean }
@ -12,9 +13,18 @@ async function main() {
const path = process.env['REVIEW_JSON']
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 pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
// 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 {
let applied = 0, skipped = 0
for (const r of rows) {
if (!r.changed || r.suggested === r.current) continue
@ -32,6 +42,9 @@ async function main() {
}
// eslint-disable-next-line no-console
console.log(`Name corrections: ${applied} applied, ${skipped} skipped.`)
} finally {
await db.close()
}
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -7,10 +7,11 @@
// template with the settings below — this DOES clobber owner edits, use with care.
// Run from repo root:
// npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
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' },
@ -52,8 +53,17 @@ const TAILS: Record<string, { key: string; label: string }[]> = {
}
async function main(): Promise<void> {
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
// 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
@ -74,7 +84,14 @@ async function main(): Promise<void> {
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.preserved} custom step(s) preserved.`)
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) })

@ -3,9 +3,10 @@
// and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT.
// Idempotent: skips a username/email that already exists.
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { createEmployee } from '../src/repos-employees'
import type { DB } from '../src/db'
// From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank.
const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [
@ -28,9 +29,17 @@ function slug(name: string): string {
}
async function main() {
// Same engine selection as the server: DATABASE_URL → Postgres, else SQLite at HQ_DATA_DIR.
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
// 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 {
let added = 0, skipped = 0
const summary: string[] = []
for (const e of EMPLOYEES) {
@ -53,6 +62,9 @@ async function main() {
}
// eslint-disable-next-line no-console
console.log(`Employee import: ${added} added, ${skipped} skipped.\n${summary.join('\n')}`)
} finally {
await db.close()
}
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -3,17 +3,33 @@
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
// to re-run (a second run does nothing).
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import type { DB } from '../src/db'
async function main() {
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
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 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.preserved} custom step(s) preserved.`)
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) })

@ -782,18 +782,43 @@ export function apiRouter(
})
// The cross-project board + drill-down (the "who's pending go-live" view).
r.get('/projects/board', requireAuth, async (_req, res) => {
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) => {
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).
// 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) => {
try {
const q = req.query['days']
const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q))
? Number(q) : await getNumberSetting(db, 'project.stall_days', 30)
let days: number
if (typeof q === 'string' && q !== '') {
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)
res.json({ ok: true, projects: await stalledProjects(db, days, today) })
} 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).

@ -102,6 +102,56 @@ 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 SQLite when running in
* production for one-off deploy scripts, where a throwaway SQLite write is never the right
* outcome (unlike the server, which legitimately runs SQLite in dev). `hardcoded` is
* injectable so tests can simulate the hardcoded URL being accidentally emptied out.
*/
export function requireDbUrlForEnv(env: NodeJS.ProcessEnv = process.env, hardcoded = HARDCODED_DATABASE_URL): string {
const pgUrl = resolveDbUrl(env, hardcoded)
if (pgUrl === '' && env['NODE_ENV'] === 'production') {
throw new Error(
'[db] refusing to run: NODE_ENV=production but no Postgres target resolved ' +
'(DATABASE_URL is unset and the hardcoded production URL is empty) — this would ' +
'otherwise silently create a throwaway SQLite database instead of touching production data.',
)
}
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. */
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)`)

@ -247,7 +247,10 @@ CREATE TABLE IF NOT EXISTS client_module (
-- 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
-- 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 (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
@ -413,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
* on the raw handle before it is wrapped, so test fixtures stay one-liners. */
export function openDb(dataDir?: string): DB {
let raw: SqliteRaw
if (dataDir === ':memory:') {
const target = sqliteFilePath(dataDir)
if (target === ':memory:') {
raw = new Database(':memory:')
} else {
const dir = dataDir ?? path.resolve(process.cwd(), 'data')
fs.mkdirSync(dir, { recursive: true })
raw = new Database(path.join(dir, 'hq.db'))
fs.mkdirSync(path.dirname(target), { recursive: true })
raw = new Database(target)
raw.pragma('journal_mode = WAL')
}
raw.exec(SCHEMA)
@ -514,6 +527,13 @@ function migrate(db: SqliteRaw): void {
if (!cmCols.some((c) => c.name === 'field_values')) {
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`)
}
const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)

@ -32,6 +32,21 @@ interface OldRow {
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
* (a `project.milestone_template:<CODE>` setting task 2), rebuild its milestone rows
@ -44,12 +59,12 @@ interface OldRow {
*/
export async function migrateOnboardingTemplates(
db: DB,
): Promise<{ projects: number; carried: number; dropped: number; preserved: number }> {
): Promise<{ projects: number; carried: number; dropped: number; preserved: number; collapsed: number }> {
// Which module codes have a per-module template setting?
const codes = (await db.all<{ key: string }>(
`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`))
.map((r) => r.key.slice('project.milestone_template:'.length))
let projects = 0, carried = 0, dropped = 0, preserved = 0
let projects = 0, carried = 0, dropped = 0, preserved = 0, collapsed = 0
for (const code of codes) {
const template = await milestoneTemplate(db, code)
const templateKeys = new Set(template.map((t) => t.key))
@ -69,7 +84,17 @@ export async function migrateOnboardingTemplates(
if (isCustom) { customRows.push(r); continue }
const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key]
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) {
dropped++
}
@ -108,10 +133,13 @@ export async function migrateOnboardingTemplates(
sort++
}
preserved += customRows.length
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined,
// 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, preserved }
return { projects, carried, dropped, preserved, collapsed }
}

@ -367,4 +367,10 @@ END;
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;`,
},
]

@ -35,6 +35,13 @@ const STATUS_RANK: Record<string, number> = {
}
const STEP_STATUS: Record<string, string> = {
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',
}
/**
@ -330,6 +337,11 @@ export async function addProjectMilestone(
* 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
* 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-clearing 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.
*/
export async function bulkSetMilestone(
db: DB, userId: string, clientModuleIds: string[], key: string, done: boolean, doneOn?: string,
@ -342,11 +354,17 @@ export async function bulkSetMilestone(
return db.transaction(async () => {
let updated = 0
for (const id of clientModuleIds) {
const r = await db.run(
`UPDATE project_milestone SET done=?, done_on=? WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, id, key,
)
updated += r.changes
try {
await setMilestone(db, userId, id, key, done, doneOn)
updated++
} catch (err) {
// 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,
{ key, done, doneOn: stamp, projects: clientModuleIds.length, updated })
@ -400,15 +418,20 @@ export async function projectsPendingMilestone(db: DB, key: string): Promise<Pen
* 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
* 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".
* 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).
*/
export async function stalledProjects(db: DB, olderThanDays: number, today: string): Promise<PendingProject[]> {
const rows = await db.all<PendingProject & { last_done: string | null }>(
const rows = 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",
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
JOIN client_module cm ON cm.id = pm.client_module_id
JOIN client c ON c.id = cm.client_id
@ -419,6 +442,10 @@ export async function stalledProjects(db: DB, olderThanDays: number, today: stri
)
const cutoff = new Date(Date.parse(today) - olderThanDays * 86_400_000).toISOString().slice(0, 10)
return rows
.filter((r) => (r.last_done ?? '0000-00-00') < cutoff)
.map(({ last_done: _last_done, ...rest }) => rest)
.filter((r) => {
if (r.last_done !== null) return r.last_done < cutoff
if (r.created_at !== null) return r.created_at.slice(0, 10) < cutoff
return false
})
.map(({ last_done: _last_done, created_at: _created_at, ...rest }) => rest)
}

@ -491,9 +491,12 @@ export async function assignModule(db: DB, userId: string, input: AssignModuleIn
}
const id = uuidv7()
await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition)
VALUES (?, ?, ?, ?, ?, ?)`,
// created_at (FIX D — stalled-onboarding false positives): real assignment time, so a
// 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',
new Date().toISOString(),
)
await ensureProjectMilestones(db, id) // D22: seed the onboarding checklist for the new project
const cm = (await getClientModule(db, id))!

@ -7,7 +7,7 @@ import { apiRouter } from './api'
import { maybePullAwsCosts } from './aws-costs'
import { pollBounces } from './bounces'
import { openDb, type DB } from './db'
import { openPgDb } from './db-pg'
import { openPgDb, resolveDbUrl } from './db-pg'
import { makeRateLimiter } from './rate-limit'
import { renderPdf } from './pdf'
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.
// The proxy MUST strip client-supplied X-Forwarded-For so it cannot be spoofed.
app.set('trust proxy', 1)
// Hardcoded production DB connection (team decision — used instead of a .env DATABASE_URL,
// to match how the other apps on this box are configured). To change the database, edit
// HARDCODED_DATABASE_URL below and rebuild the image. 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'
// 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 : '')
// D19 engine selection (pre-ship audit FIX A: the ONE shared resolver — server.ts and
// every deploy script must call this, never re-derive it locally, or a script can select
// a different DB than the running server without anyone noticing). To change the
// production database, edit HARDCODED_DATABASE_URL in db-pg.ts and rebuild the image.
const pgUrl = resolveDbUrl()
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
if (pgUrl !== '') console.log('[db] engine: postgres')
await seedIfEmpty(db) // first boot prints the owner password once

@ -0,0 +1,59 @@
// 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 { describe, it, expect } from 'vitest'
import { pgTargetDescription, requireDbUrlForEnv, resolveDbUrl } from '../src/db-pg'
const HARDCODED = 'postgres://postgres:inv123@host.docker.internal:5432/hq'
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 — hard-fail instead of a throwaway SQLite write)', () => {
it('behaves exactly like resolveDbUrl when a Postgres target is available', () => {
expect(requireDbUrlForEnv({ DATABASE_URL: 'postgres://x/y' })).toBe('postgres://x/y')
expect(requireDbUrlForEnv({ NODE_ENV: 'production' }, HARDCODED)).toBe(HARDCODED)
expect(requireDbUrlForEnv({})).toBe('') // dev with no env: SQLite is fine, no hard-fail
})
it('hard-fails instead of silently returning SQLite when NODE_ENV=production resolves nothing', () => {
// Simulates the hardcoded URL having been accidentally emptied out.
expect(() => requireDbUrlForEnv({ NODE_ENV: 'production' }, '')).toThrow(/refusing to run/i)
})
})
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)
})
})

@ -3,10 +3,10 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
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 { gstSummary } from '../src/repos-reports'
import { listProjectMilestones, bulkSetMilestone } from '../src/repos-milestones'
import { listProjectMilestones, bulkSetMilestone, setMilestone } from '../src/repos-milestones'
async function world() {
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)
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
})
})

@ -321,4 +321,75 @@ describe('onboarding template migration', () => {
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
})
})

@ -109,3 +109,26 @@ describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
expect(JSON.parse(stamped!.after_json!)).toMatchObject({ status: 'installed', installedOn: '2026-03-25' })
})
})
// 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')
})
})

@ -0,0 +1,75 @@
// 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[] }
expect(body.ok).toBe(true)
expect(Array.isArray(body.projects)).toBe(true)
})
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)
})
}
})

@ -54,4 +54,49 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
// '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')
})
// 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.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
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.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.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
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()
})
})
})

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

@ -462,10 +462,12 @@ button.wf.pill { border-radius: 999px; padding: 8px 18px; }
.wf-formgrid .wide { grid-column: 1 / -1; }
@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } }
/* ================= mobile (phone) layout ================= */
@media (max-width: 640px) {
/* ================= mobile + tablet layout ================= */
@media (max-width: 860px) {
/* Record header: stack the action-button cluster below the avatar/name/meta instead of
crowding them on one row. Desktop layout (outside this query) is untouched. */
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%; }
}

Loading…
Cancel
Save