Compare commits

..

No commits in common. 'main' and 'feat/client-detail-redesign' have entirely different histories.

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

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

4
.gitignore vendored

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

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

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

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

@ -270,15 +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.
(`.wf-table-wrap` itself is styled in @sims/ui packages/ui/src/tokens.css next to
the other table.wf rules, since it's part of that component, not this app's shell.) */
/* Tables scroll inside their page rather than breaking the layout. */
.hq-content { overflow-x: hidden; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
@ -517,22 +512,12 @@ 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. It WRAPS (up to two short
lines) instead of being ellipsised to one 104px line: the reference exists to show the
payment's amount / the document's number, and a single mono line at this width cut
exactly that off. Wrapping grows the node downwards only, so the track keeps its 112px
columns and its horizontal scroll on narrow screens. */
.cdr-node .ref {
font-size: 10px; font-family: var(--mono); color: var(--accent); background: none; border: none;
padding: 0; margin-top: 3px; cursor: pointer; max-width: 104px;
white-space: normal; overflow-wrap: anywhere; line-height: 1.3; text-align: center;
}
.cdr-node .ref:hover { text-decoration: underline; }
/* outstanding panel */
.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; }
.cdr-out .ofoot { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-inset); }
/* document-type pill */
.doc-pill { font-size: 10.5px; font-weight: 600; letter-spacing: .3px; border-radius: 999px; padding: 1px 8px; border: 1px solid var(--border); color: var(--text-dim); background: var(--bg-inset); }

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

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

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

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

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

@ -4,13 +4,10 @@
// skips a client whose name already contains that number, and only touches found=true rows.
// Does NOT overwrite the name's spelling/casing — only appends the number.
// LTD_JSON=<results.json> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { readFileSync } from 'node:fs'
import { 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 }
@ -19,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) })

@ -1,13 +1,10 @@
// apps/hq/scripts/apply-name-fixes.ts — one-off (D35 data correction): apply the approved
// client-name corrections from name-review.json via the audited updateClient path.
// REVIEW_JSON=<path> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { readFileSync } from 'node:fs'
import { 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 }
@ -15,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) })

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

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

@ -3,12 +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
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { 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 }[] = [
@ -31,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,35 +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
// SQLite is never the default: to target a local SQLite database, opt in explicitly with
// HQ_DB_TARGET=sqlite (or the --sqlite flag). Without either, the script refuses to run.
import { 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.`)
}
main().catch((e) => { console.error(e); process.exit(1) })

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

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

@ -21,7 +21,7 @@ import {
} from './repos-modules'
import { createBranch, listBranches, updateBranch } from './repos-branches'
import {
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, ticketTypes, overdueCount, updateTicket,
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, overdueCount, updateTicket,
TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus,
} from './repos-tickets'
import {
@ -69,9 +69,8 @@ import { clearLegacyCiphertext } from './migrate-plaintext'
import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail,
listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone,
setModuleTail, stalledProjects,
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
projectsPendingMilestone, setMilestone, stalledProjects,
} from './repos-milestones'
import { commitImport, stageCsv, verificationReport } from './import-apex'
import { documentHtml, documentHtmlSample } from './templates'
@ -482,29 +481,6 @@ export function apiRouter(
}
})
// ---------- onboarding step templates (owner editor): shared front + per-module tail ----------
r.get('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
try {
const code = String(req.params['code'] ?? '')
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
res.json({ ok: true, steps: await getModuleTail(db, code) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
try {
const code = String(req.params['code'] ?? '')
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
const body = req.body as { steps?: unknown }
res.json({ ok: true, steps: await setModuleTail(db, staffId(res), code, body.steps) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- module → client roster (spec §10, D-ROSTER) ----------
// Module directory (D28): every client on a module with its service fields, for review.
r.get('/modules/:id/directory', requireAuth, async (req, res) => {
@ -746,12 +722,8 @@ export function apiRouter(
if ((await getClientModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
try {
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
})
r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
@ -759,10 +731,7 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
try {
const body = req.body as {
key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown
paymentId?: unknown; documentId?: unknown
}
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown }
if (typeof body.label === 'string') {
// Add a project-specific milestone.
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
@ -773,8 +742,7 @@ export function apiRouter(
}
const milestones = await setMilestone(db, staffId(res), id, body.key, body.done,
typeof body.doneOn === 'string' ? body.doneOn : undefined,
typeof body.paymentId === 'string' ? body.paymentId : undefined,
typeof body.documentId === 'string' ? body.documentId : undefined)
typeof body.paymentId === 'string' ? body.paymentId : undefined)
res.json({ ok: true, milestones })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@ -782,64 +750,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)
// FIX 4 (no silent caps): unknownAgeExcluded surfaces the count of pending projects the
// query could not prove old (no ticks and no client_module.created_at) instead of
// silently dropping them off the tile with no trace.
const { rows, unknownAgeExcluded } = await stalledProjects(db, days, today)
res.json({ ok: true, projects: rows, unknownAgeExcluded })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor).
r.get('/onboarding-template/front', requireAuth, requireOwner, async (_req, res) => {
try {
res.json({ ok: true, steps: await getFrontTemplate(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/onboarding-template/front', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { steps?: unknown }
res.json({ ok: true, steps: await setFrontTemplate(db, staffId(res), body.steps) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
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) })
})
// ---------- client branches (D20) ----------
@ -882,10 +804,6 @@ export function apiRouter(
})
// ---------- ticket desk (D20 — team-visible workbench) ----------
// The classification list (config over code: the `ticket.types` setting, code fallback).
r.get('/tickets/types', requireAuth, async (_req, res) => {
res.json({ ok: true, types: await ticketTypes(db) })
})
r.get('/tickets', requireAuth, async (req, res) => {
try {
const q = req.query
@ -901,7 +819,6 @@ export function apiRouter(
? { assignedTo: staffId(res) }
: typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}),
...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}),
...(typeof q['type'] === 'string' && q['type'] !== '' ? { type: q['type'] } : {}),
...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}),
...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}),
page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50),

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

@ -1,7 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import fs from 'node:fs'
import pg from 'pg'
import { sqliteFilePath, type DB } from './db'
import type { DB } from './db'
import { PG_MIGRATIONS } from './migrations-pg'
/**
@ -103,97 +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 a THROWAWAY SQLite
* database for one-off deploy/maintenance scripts, where creating a brand-new empty DB is
* never the right outcome (unlike the server, which legitimately creates one on first boot in
* dev this function is never called from server.ts).
*
* The guard is OUTCOME-based, not NODE_ENV-based. The original fix gated the hard-fail on
* `NODE_ENV === 'production'`, but NODE_ENV is exactly what these scripts don't reliably have:
* the server gets it from its service definition, while the scripts are run by hand
* (`npx tsx apps/hq/scripts/…`) from an operator shell with no NODE_ENV and no dotenv. So on
* the real prod box the old guard never fired, resolution returned `''`, and the script
* silently opened/created a brand-new empty SQLite file in `<cwd>/data` "succeeding" against
* a throwaway DB while production was never touched.
*
* Fix: whenever resolution selects SQLite, refuse UNLESS the target file already exists a
* migration/maintenance script must never CREATE a database, regardless of what NODE_ENV is or
* isn't set to. `hardcoded` and `sqliteExists` are both injectable so tests never touch a real
* Postgres URL or the real filesystem.
*
* Second guard (same family of bug, one step earlier): landing on SQLite at all must be a
* DECISION, never a default. An operator who follows a script's documented command with no
* `DATABASE_URL=` in front of it should be told so, not quietly pointed at whatever `./data/hq.db`
* happens to exist under their shell's cwd. So SQLite additionally requires an explicit opt-in
* `HQ_DB_TARGET=sqlite` or a `--sqlite` argv flag (`HQ_DATA_DIR=:memory:` counts too: nobody sets
* that by accident). Without one, the script refuses and the error names both options.
* `argv` is injectable for the same reason as the rest: tests never depend on the real process.
*/
export function requireDbUrlForEnv(
env: NodeJS.ProcessEnv = process.env,
hardcoded = HARDCODED_DATABASE_URL,
sqliteExists: (file: string) => boolean = fs.existsSync,
argv: readonly string[] = process.argv,
): string {
const pgUrl = resolveDbUrl(env, hardcoded)
if (pgUrl === '') {
const file = sqliteFilePath(env['HQ_DATA_DIR'])
const optedIn =
file === ':memory:' ||
(env['HQ_DB_TARGET'] ?? '').trim().toLowerCase() === 'sqlite' ||
argv.includes('--sqlite')
if (!optedIn) {
throw new Error(
'[db] refusing to run: no Postgres target resolved (DATABASE_URL is unset), and SQLite ' +
'has not been explicitly requested — running against the wrong database is worse than ' +
'not running at all. Either set DATABASE_URL=postgres://… to target the real database, ' +
`or opt into SQLite explicitly with HQ_DB_TARGET=sqlite (or the --sqlite flag), which ` +
`would use ${file}.`,
)
}
if (file !== ':memory:' && !sqliteExists(file)) {
throw new Error(
`[db] refusing to run: this would CREATE a new SQLite database at ${file}. ` +
'Set DATABASE_URL to target Postgres, or point HQ_DATA_DIR at the existing database.',
)
}
}
return pgUrl
}
/** Human-readable engine target for startup/deploy-script logs. Never includes the password. */
export function pgTargetDescription(pgUrl: string): string {
try {
const u = new URL(pgUrl)
const port = u.port !== '' ? `:${u.port}` : ''
return `${u.hostname}${port}${u.pathname}`
} catch {
return '(unparsable connection string)'
}
}
/** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */
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,22 +247,15 @@ 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,
code TEXT, active INTEGER NOT NULL DEFAULT 1
);
-- D22 onboarding tracker: each client_module (a "project") gets a COMPOSED checklist
-- a shared "front" (enquiry quotation approved, from the project.milestone_template.front
-- setting) followed by a per-module "tail" (project.milestone_template:<CODE>, else the
-- global project.milestone_template, else the code fallback) materialized on assignment.
-- done_on stamps when a step is ticked; payment_id links a payment step to the real payment
-- row and document_id links the quotation step to the quotation/proforma that was sent (both
-- added by migrate(), below). Reportable across all projects (who is pending go-live).
-- D22 onboarding tracker: each client_module (a "project") gets a checklist of milestones
-- seeded from the dated project.milestone_template setting on assignment. done_on stamps
-- when a milestone is ticked. Reportable across all projects (who is pending go-live).
CREATE TABLE IF NOT EXISTS project_milestone (
id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL,
key TEXT NOT NULL, label TEXT NOT NULL,
@ -273,7 +266,6 @@ CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT,
module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention)
kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions)
type TEXT NOT NULL DEFAULT 'service', -- classification key from the ticket.types setting (config over code)
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')),
assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench)
@ -416,26 +408,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,22 +509,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`)
}
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at
// (only assignModule stamped it), so every imported project — the ~300-client real book —
// permanently NULL'd out of the "Onboarding stalled" tile's age measurement. Backfill from
// installed_on where it's known; a row with neither is genuinely unknown-age and stays
// NULL (never fabricated a date). Runs every migrate() pass — idempotent (only touches rows
// still NULL), so it also heals DBs that already had the column from before this fix.
db.exec(
`UPDATE client_module SET created_at = installed_on WHERE created_at IS NULL AND installed_on IS NOT NULL`,
)
const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!modCols2.some((c) => c.name === 'field_spec')) {
db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`)
@ -555,15 +521,6 @@ function migrate(db: SqliteRaw): void {
if (!milestoneCols.some((c) => c.name === 'payment_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`)
}
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
if (!milestoneCols.some((c) => c.name === 'document_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN document_id TEXT`)
}
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
const ticketCols = db.prepare(`PRAGMA table_info(ticket)`).all() as { name: string }[]
if (!ticketCols.some((c) => c.name === 'type')) {
db.exec(`ALTER TABLE ticket ADD COLUMN type TEXT NOT NULL DEFAULT 'service'`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}

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

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

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

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

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

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

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

@ -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:CHANGE_ME_PASSWORD@host.docker.internal:5432/hq'
// D19 engine selection: DATABASE_URL (or the hardcoded prod value) → Postgres; else SQLite.
const pgUrl = process.env['DATABASE_URL'] || (process.env['NODE_ENV'] === 'production' ? HARDCODED_DATABASE_URL : '')
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
if (pgUrl !== '') console.log('[db] engine: postgres')
await seedIfEmpty(db) // first boot prints the owner password once

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

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

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

@ -3,10 +3,10 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { 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
})
})

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

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

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

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

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

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

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

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

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

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

@ -1,78 +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[]; unknownAgeExcluded: number }
expect(body.ok).toBe(true)
expect(Array.isArray(body.projects)).toBe(true)
// FIX 4: the count of pending projects excluded for unprovable age travels alongside the
// list itself now, so the UI can surface "N of unknown age" instead of a silent drop.
expect(typeof body.unknownAgeExcluded).toBe('number')
})
it('a sane ?days= still works normally', async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled?days=45`, { headers: staffH })
expect(res.status).toBe(200)
expect((await res.json() as { ok: boolean }).ok).toBe(true)
})
const hostileValues = [
'1e300', // finite in JS but overflows Date math -> used to throw RangeError
'-99999999999999', // huge negative -> overflows the other direction
'3.5', // not an integer
'abc', // NaN
'NaN',
'Infinity',
'-Infinity',
'99999999999999999999', // way outside the sane 0-3650 bound
'-1', // out of bound (negative)
]
for (const hostile of hostileValues) {
it(`?days=${hostile} is rejected with a 4xx, never throws / crashes the server`, async () => {
const { base, staffH } = await httpWorld()
const res = await fetch(`${base}/projects/stalled?days=${encodeURIComponent(hostile)}`, { headers: staffH })
expect(res.status).toBeGreaterThanOrEqual(400)
expect(res.status).toBeLessThan(500)
const body = await res.json() as { ok: boolean }
expect(body.ok).toBe(false)
// The server process must still be alive for the NEXT request — the original bug's
// unhandled RangeError rejection killed the whole process.
const health = await fetch(`${base}/health`)
expect(health.status).toBe(200)
})
}
})

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

@ -27,11 +27,10 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.rows.find((p) => p.clientModuleId === cm.id)!
expect(stalled.map((p) => p.clientModuleId)).toContain(cm.id)
const row = stalled.find((p) => p.clientModuleId === cm.id)!
expect(row.clientName).toBe('Kothavara SCB')
expect(row.moduleCode).toBe('MISC')
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('excludes a recently-progressed project', async () => {
@ -40,8 +39,7 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
const stalled = await stalledProjects(db, 365, '2026-07-19')
expect(stalled.rows).toHaveLength(0)
expect(stalled.unknownAgeExcluded).toBe(0)
expect(stalled).toHaveLength(0)
})
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
@ -52,65 +50,8 @@ describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
}
const stalled = await stalledProjects(db, 1, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(done.id)
expect(stalled.map((p) => p.clientModuleId)).not.toContain(done.id)
// '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.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(0)
})
it('a project created long ago with zero ticks IS stalled', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=? WHERE id=?`, '2025-01-01T09:00:00.000Z', cm.id)
const stalled = await stalledProjects(db, 30, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).toContain(cm.id)
})
it('a no-tick project with an UNKNOWN creation date (created_at NULL) is excluded, not assumed ancient', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
// Simulates a client_module that predates the created_at column (or an import that
// doesn't stamp it) — created_at is genuinely unknown.
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19') // even a 0-day cutoff must not flag it
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
})
// FIX 4 (no silent caps): the row above used to just vanish with no trace. It must now be
// counted so the UI can surface "N of unknown age" instead of silently under-reporting.
it('a no-tick row with NULL created_at lands in the unknownAgeExcluded count, not just silently dropped', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET created_at=NULL WHERE id=?`, cm.id)
const stalled = await stalledProjects(db, 0, '2026-07-19')
expect(stalled.rows.map((p) => p.clientModuleId)).not.toContain(cm.id)
expect(stalled.unknownAgeExcluded).toBe(1)
})
it('assignModule itself stamps a real (non-null) created_at', async () => {
const { db, c, m } = await world()
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = await db.get<{ created_at: string | null }>(
`SELECT created_at FROM client_module WHERE id=?`, cm.id,
)
expect(row!.created_at).not.toBeNull()
})
})
})

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

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

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

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

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

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

@ -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>
)
}

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

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