Compare commits

..

No commits in common. 'b1e1420b4ccc7251144c6f561aaa342a571ebb33' and 'b146de990f54a31e2c8c0b38a0e89966258877da' have entirely different histories.

@ -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`) | **528 tests pass across 97 files** (5 Postgres-gated skipped) |
| `npm test` (`vitest run`) | **473 tests pass across 94 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,14 +270,10 @@ button.dash-row:hover { background: var(--accent-soft); }
.hq-content { padding: 8px 14px 20px; }
.wf-page { padding: 14px 2px 28px; }
}
/* 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. */
/* Tables scroll inside their page rather than breaking the layout. */
.hq-content { overflow-x: hidden; }
.wf-table-wrap { overflow-x: auto; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
@ -516,17 +512,10 @@ 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; flex-wrap: wrap; row-gap: 4px; gap: 12px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.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: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, getTicketTypes,
getBranches, createBranch, patchBranch, getTickets,
getMilestones, setMilestone, addMilestone,
generateBulkSmsPurchase,
CLIENT_STATUSES, KIND_LABEL, TICKET_TYPE_FALLBACK,
CLIENT_STATUSES, KIND_LABEL,
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, ticketTypeLabel,
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
} from './Tickets'
import {
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
@ -65,9 +65,6 @@ 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>()
@ -320,9 +317,8 @@ 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. */}
@ -344,15 +340,9 @@ 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}
@ -382,8 +372,7 @@ export function ClientDetail() {
<DataTable
columns={[
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' },
{ key: 'module', label: 'Module', mono: true }, { key: 'type', label: 'Type' },
{ key: 'kind', label: 'Kind' },
{ key: 'module', label: 'Module', mono: true }, { key: 'kind', label: 'Kind' },
{ key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
]}
@ -394,8 +383,6 @@ 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 ?? '—',
@ -692,9 +679,6 @@ 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))
@ -719,7 +703,6 @@ export function ClientDetail() {
.then(() => {
toast.ok('Document linked')
setMilestonesVersion((v) => v + 1)
cms.reload()
setDocPicker(undefined)
})
.catch((e: Error) => setDocLinkError(e.message))
@ -729,19 +712,15 @@ 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. 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 } : {})}
/>
)}
module"), so it lives outside the tab switch the module code pre-fills. */}
<NewTicketDialog
open={ticketDialog !== undefined}
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
@ -776,7 +755,6 @@ export function ClientDetail() {
.then(() => {
toast.ok('Step marked done')
setMilestonesVersion((v) => v + 1)
cms.reload()
setCredsDialog(undefined)
})
.catch((e: Error) => setCredsError(e.message))
@ -1414,10 +1392,9 @@ 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, 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;
* `balance_payment` keys on un-migrated projects) 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;
@ -1425,27 +1402,15 @@ 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. `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.
* link lands from outside this component.
*/
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])
@ -1458,7 +1423,7 @@ function StatusLine(props: {
if (busyKey !== '') return
setBusyKey(m.key)
setMilestone(props.clientModuleId, m.key, done, doneOn)
.then(() => { list.reload(); props.onChanged() })
.then(() => list.reload())
.catch((e: Error) => toast.err(e.message))
.finally(() => setBusyKey(''))
}
@ -1475,15 +1440,10 @@ 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 (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.
// `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.
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'
@ -1546,27 +1506,6 @@ 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>
@ -1618,7 +1557,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', flexWrap: 'wrap', rowGap: 4, gap: 10, padding: '7px 0', borderTop: '1px solid var(--border)', cursor: 'pointer', fontSize: 12.5 }}>
style={{ display: 'flex', alignItems: 'center', 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,19 +145,11 @@ export function Dashboard() {
))}
</Card>
<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} />
))}
<Card title="Onboarding stalled" count={stalled.data?.length ?? 0} empty="Nothing stalled." isEmpty={(stalled.data ?? []).length === 0}>
{(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}>

@ -380,9 +380,7 @@ export function Tickets() {
: rows === undefined ? <Skeleton rows={6} />
: rows.length === 0 ? (
<EmptyState>
{/* `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
No {status !== 'all' ? TICKET_STATUS_LABEL[status as TicketStatus].toLowerCase() : ''} tickets
{scope === 'mine' ? ' of yours' : ''}{q !== '' ? ` matching “${q}` : ''}.
</EmptyState>
) : (

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

@ -2,10 +2,9 @@
// 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, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { openDb } from '../src/db'
import { openPgDb } 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 }
@ -13,38 +12,26 @@ 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
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)}`)
}
let applied = 0, skipped = 0
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) })

@ -7,11 +7,10 @@
// 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, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { openDb } from '../src/db'
import { openPgDb } 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' },
@ -53,45 +52,29 @@ const TAILS: Record<string, { key: string; label: string }[]> = {
}
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')
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
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))
// 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 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()
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.preserved} custom step(s) preserved.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -3,10 +3,9 @@
// 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, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { openDb } from '../src/db'
import { openPgDb } 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 }[] = [
@ -29,42 +28,31 @@ function slug(name: string): string {
}
async function main() {
// 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) {
const username = slug(e.name)
const password = `${username}@123`
const email = `${username}@sims.local` // placeholder (no real emails in the master)
try {
const emp = await createEmployee(db, 'system', {
email, username, displayName: e.name, role: 'staff', password,
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)}`)
}
// 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'])
let added = 0, skipped = 0
const summary: string[] = []
for (const e of EMPLOYEES) {
const username = slug(e.name)
const password = `${username}@123`
const email = `${username}@sims.local` // placeholder (no real emails in the master)
try {
const emp = await createEmployee(db, 'system', {
email, username, displayName: e.name, role: 'staff', password,
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) })

@ -3,33 +3,17 @@
// 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, sqliteFilePath } from '../src/db'
import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { openDb } from '../src/db'
import { openPgDb } from '../src/db-pg'
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
import type { DB } from '../src/db'
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.collapsed} collapsed (two old ticks -> one key), ` +
`${res.preserved} custom step(s) preserved.`,
)
} finally {
await db.close()
}
async function main() {
const pgUrl = process.env['DATABASE_URL'] ?? ''
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
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.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

@ -782,43 +782,18 @@ 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) })
}
res.json({ ok: true, board: await milestoneBoard(db) })
})
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) })
}
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) })
})
// 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']
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) })
}
const q = req.query['days']
const days = typeof q === 'string' && q !== '' && Number.isFinite(Number(q))
? Number(q) : 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) })
})
// The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor).

@ -102,56 +102,6 @@ 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,10 +247,7 @@ 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,
-- 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
field_values TEXT NOT NULL DEFAULT '{}', secrets_enc TEXT
);
CREATE TABLE IF NOT EXISTS client_branch (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
@ -416,26 +413,16 @@ 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
const target = sqliteFilePath(dataDir)
if (target === ':memory:') {
if (dataDir === ':memory:') {
raw = new Database(':memory:')
} else {
fs.mkdirSync(path.dirname(target), { recursive: true })
raw = new Database(target)
const dir = dataDir ?? path.resolve(process.cwd(), 'data')
fs.mkdirSync(dir, { recursive: true })
raw = new Database(path.join(dir, 'hq.db'))
raw.pragma('journal_mode = WAL')
}
raw.exec(SCHEMA)
@ -527,13 +514,6 @@ 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,21 +32,6 @@ 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
@ -59,12 +44,12 @@ function pickSurvivingTick(a: OldRow, b: OldRow): OldRow {
*/
export async function migrateOnboardingTemplates(
db: DB,
): Promise<{ projects: number; carried: number; dropped: number; preserved: number; collapsed: number }> {
): Promise<{ projects: number; carried: number; dropped: number; preserved: 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, collapsed = 0
let projects = 0, carried = 0, dropped = 0, preserved = 0
for (const code of codes) {
const template = await milestoneTemplate(db, code)
const templateKeys = new Set(template.map((t) => t.key))
@ -84,17 +69,7 @@ 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) {
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++
}
}
if (r.done === 1) carriedState.set(target, r)
} else if (r.done === 1) {
dropped++
}
@ -133,13 +108,10 @@ export async function migrateOnboardingTemplates(
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,
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined,
{ module: code, carried: [...carriedState.keys()], preserved: customRows.map((c) => c.key) })
})
}
}
return { projects, carried, dropped, preserved, collapsed }
return { projects, carried, dropped, preserved }
}

@ -367,10 +367,4 @@ 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,13 +35,6 @@ 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',
}
/**
@ -337,11 +330,6 @@ 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,
@ -354,17 +342,11 @@ export async function bulkSetMilestone(
return db.transaction(async () => {
let updated = 0
for (const id of clientModuleIds) {
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
}
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
}
await writeAudit(db, userId, 'bulk_set_milestone', 'project_milestone', '*', undefined,
{ key, done, doneOn: stamp, projects: clientModuleIds.length, updated })
@ -418,20 +400,15 @@ 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 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".
* steps; a project with nothing ticked yet counts as parked since creation (no lower bound).
* 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; created_at: string | null }>(
const rows = await db.all<PendingProject & { last_done: 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, MIN(cm.created_at) AS created_at
MAX(pm.done_on) AS last_done
FROM project_milestone pm
JOIN client_module cm ON cm.id = pm.client_module_id
JOIN client c ON c.id = cm.client_id
@ -442,10 +419,6 @@ 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) => {
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)
.filter((r) => (r.last_done ?? '0000-00-00') < cutoff)
.map(({ last_done: _last_done, ...rest }) => rest)
}

@ -491,12 +491,9 @@ export async function assignModule(db: DB, userId: string, input: AssignModuleIn
}
const id = uuidv7()
await db.run(
// 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 (?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition)
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, resolveDbUrl } from './db-pg'
import { openPgDb } from './db-pg'
import { makeRateLimiter } from './rate-limit'
import { renderPdf } from './pdf'
import { getClient } from './repos-clients'
@ -144,11 +144,15 @@ 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)
// 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()
// 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 : '')
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

@ -1,59 +0,0 @@
// 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, getClientModule } from '../src/repos-modules'
import { createModule, setPrice, assignModule } from '../src/repos-modules'
import { createDraft, issueDocument, createCreditNote, listDocuments, getDocument } from '../src/repos-documents'
import { gstSummary } from '../src/repos-reports'
import { listProjectMilestones, bulkSetMilestone, setMilestone } from '../src/repos-milestones'
import { listProjectMilestones, bulkSetMilestone } from '../src/repos-milestones'
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company state 32, GST18
@ -81,48 +81,4 @@ 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,75 +321,4 @@ 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,26 +109,3 @@ 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')
})
})

@ -1,75 +0,0 @@
// 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,49 +54,4 @@ 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,7 +134,6 @@ export function DataTable(props: {
}
return (
<div className="wf-table-wrap">
<table className="wf">
<thead>
<tr>
@ -181,7 +180,6 @@ export function DataTable(props: {
})}
</tbody>
</table>
</div>
)
}

@ -462,12 +462,10 @@ 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 + tablet layout ================= */
@media (max-width: 860px) {
/* ================= mobile (phone) layout ================= */
@media (max-width: 640px) {
/* 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. */
crowding them on one row. Desktop layout (outside this query) is untouched. */
.wf-rec { flex-wrap: wrap; }
.wf-rec-actions { width: 100%; }
}

Loading…
Cancel
Save