Compare commits
36 Commits
a9e5aa79d9
...
b146de990f
| Author | SHA1 | Date |
|---|---|---|
|
|
b146de990f | 1 day ago |
|
|
4ee8f17b37 | 1 day ago |
|
|
e8e2a202c1 | 1 day ago |
|
|
7494f53e2f | 1 day ago |
|
|
ac16d8c896 | 1 day ago |
|
|
3ac4e2a47d | 1 day ago |
|
|
7838ced6c2 | 1 day ago |
|
|
357735e04d | 1 day ago |
|
|
88a19f154e | 1 day ago |
|
|
83633c86ee | 1 day ago |
|
|
d6faf0d5bd | 1 day ago |
|
|
4e747427d2 | 1 day ago |
|
|
45cfb83d03 | 1 day ago |
|
|
371cdb1c69 | 2 days ago |
|
|
db8ec9c546 | 2 days ago |
|
|
0050f73a70 | 2 days ago |
|
|
025b334b09 | 2 days ago |
|
|
78ed947422 | 2 days ago |
|
|
f1a7eac9dd | 2 days ago |
|
|
e8f00503a4 | 2 days ago |
|
|
e5ed3517dc | 2 days ago |
|
|
343fa94efd | 2 days ago |
|
|
8c653eb41a | 2 days ago |
|
|
9d3087c1fe | 2 days ago |
|
|
d05c7ee31b | 2 days ago |
|
|
67f69fc999 | 2 days ago |
|
|
75fb41c366 | 2 days ago |
|
|
195d821bef | 2 days ago |
|
|
fdf31b4dbd | 2 days ago |
|
|
4b2e44c3ea | 2 days ago |
|
|
6bce248c2e | 2 days ago |
|
|
cd235b622b | 2 days ago |
|
|
b2c49832d5 | 2 days ago |
|
|
9ac2ce9d50 | 2 days ago |
|
|
1e77792beb | 2 days ago |
|
|
6bc92bace3 | 2 days ago |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,80 @@
|
|||||||
|
// 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:
|
||||||
|
// npx tsx apps/hq/scripts/apply-onboarding-pipeline.ts [--force]
|
||||||
|
import { openDb } from '../src/db'
|
||||||
|
import { openPgDb } from '../src/db-pg'
|
||||||
|
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
|
||||||
|
import { getSetting, setSetting } from '../src/repos-reminders'
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const pgUrl = process.env['DATABASE_URL'] ?? ''
|
||||||
|
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
|
||||||
|
const force = process.argv.includes('--force')
|
||||||
|
|
||||||
|
// Audited via setSetting (append-only-audit rule) and, unless --force, skips any key
|
||||||
|
// that's already set — so an owner's edit in the Modules onboarding-template editor is
|
||||||
|
// left alone on a re-run instead of being silently clobbered.
|
||||||
|
const upsert = async (key: string, list: unknown): Promise<void> => {
|
||||||
|
if (!force && (await getSetting(db, key)) !== null) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Skipped ${key} — already set (pass --force to overwrite an owner edit).`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await setSetting(db, 'system', key, JSON.stringify(list))
|
||||||
|
}
|
||||||
|
await upsert('project.milestone_template.front', FRONT)
|
||||||
|
for (const [code, list] of Object.entries(TAILS)) await upsert(`project.milestone_template:${code}`, list)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Template settings step done: front + SMS/RTGS/MOBILEAPP tails.')
|
||||||
|
|
||||||
|
const res = await migrateOnboardingTemplates(db)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Onboarding migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped, ${res.preserved} custom step(s) preserved.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1) })
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
// apps/hq/scripts/migrate-onboarding-templates.ts — Client Detail redesign task 5: one-time
|
||||||
|
// migration swapping existing SMS/RTGS/MobileApp projects off the old generic onboarding
|
||||||
|
// checklist onto their new per-module template, carrying mapped ticks. Idempotent — safe
|
||||||
|
// to re-run (a second run does nothing).
|
||||||
|
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/migrate-onboarding-templates.ts
|
||||||
|
import { openDb } from '../src/db'
|
||||||
|
import { openPgDb } from '../src/db-pg'
|
||||||
|
import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pgUrl = process.env['DATABASE_URL'] ?? ''
|
||||||
|
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
|
||||||
|
|
||||||
|
const res = await migrateOnboardingTemplates(db)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Onboarding template migration: ${res.projects} project(s) rebuilt, ${res.carried} tick(s) carried, ${res.dropped} dropped, ${res.preserved} custom step(s) preserved.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1) })
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
// 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).
|
||||||
|
import { uuidv7 } from '@sims/domain'
|
||||||
|
import { writeAudit } from './audit'
|
||||||
|
import { FRONT_FALLBACK, TAIL_FALLBACK, 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',
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function migrateOnboardingTemplates(
|
||||||
|
db: DB,
|
||||||
|
): Promise<{ projects: number; carried: number; dropped: number; preserved: number }> {
|
||||||
|
// Which module codes have a per-module template setting?
|
||||||
|
const codes = (await db.all<{ key: string }>(
|
||||||
|
`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`))
|
||||||
|
.map((r) => r.key.slice('project.milestone_template:'.length))
|
||||||
|
let projects = 0, carried = 0, dropped = 0, preserved = 0
|
||||||
|
for (const code of codes) {
|
||||||
|
const template = await milestoneTemplate(db, code)
|
||||||
|
const templateKeys = new Set(template.map((t) => t.key))
|
||||||
|
const cms = await db.all<{ id: string }>(
|
||||||
|
`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.
|
||||||
|
const carriedState = new Map<string, OldRow>()
|
||||||
|
const customRows: OldRow[] = []
|
||||||
|
let alreadyNew = true
|
||||||
|
for (const r of oldRows) {
|
||||||
|
if (!templateKeys.has(r.key)) alreadyNew = false
|
||||||
|
const isCustom = !templateKeys.has(r.key) && KEY_MAP[r.key] === undefined && !KNOWN_LEGACY_KEYS.has(r.key)
|
||||||
|
if (isCustom) { customRows.push(r); continue }
|
||||||
|
const target = templateKeys.has(r.key) ? r.key : KEY_MAP[r.key]
|
||||||
|
if (target !== undefined && templateKeys.has(target)) {
|
||||||
|
if (r.done === 1) carriedState.set(target, r)
|
||||||
|
} else if (r.done === 1) {
|
||||||
|
dropped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 () => {
|
||||||
|
await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cmId)
|
||||||
|
let sort = 0
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
uuidv7(), cmId, t.key, t.label,
|
||||||
|
c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, c?.document_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
|
||||||
|
await writeAudit(db, 'system', 'migrate_onboarding', 'client_module', cmId, undefined,
|
||||||
|
{ module: code, carried: [...carriedState.keys()], preserved: customRows.map((c) => c.key) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { projects, carried, dropped, preserved }
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
// apps/hq/test/bulk-sms-purchase.test.ts — Task 7: ad-hoc bulk-SMS top-up documents,
|
||||||
|
// tagged distinctly from subscription renewals via document.source.
|
||||||
|
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, setPrice, updateClientModule } from '../src/repos-modules'
|
||||||
|
import { setUsageRateCard } from '../src/repos-rate-card'
|
||||||
|
import { generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from '../src/repos-renewals'
|
||||||
|
import { createDraft, getDocument } from '../src/repos-documents'
|
||||||
|
|
||||||
|
// The founder's real card: ₹0.40 / 0.37 / 0.34 / 0.30 per SMS = 40/37/34/30 paise.
|
||||||
|
const BANDS = [
|
||||||
|
{ minQty: 50_000, ratePaise: 40 },
|
||||||
|
{ minQty: 100_000, ratePaise: 37 },
|
||||||
|
{ minQty: 200_000, ratePaise: 34 },
|
||||||
|
{ minQty: 300_000, ratePaise: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function world() {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await seedIfEmpty(db) // company state 32, GST18
|
||||||
|
const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' })
|
||||||
|
const sms = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['usage'] })
|
||||||
|
await setUsageRateCard(db, 'u1', sms.id, '2026-04-01', BANDS)
|
||||||
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: sms.id, kind: 'usage' })
|
||||||
|
return { db, c, sms, cm }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generateBulkSmsPurchaseQuote', () => {
|
||||||
|
it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => {
|
||||||
|
const { db, cm } = await world()
|
||||||
|
const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 100_000)
|
||||||
|
expect(doc.docType).toBe('PROFORMA')
|
||||||
|
expect(doc.source).toBe('bulk_sms_topup')
|
||||||
|
expect(doc.payload.lines[0]!.qty).toBe(100_000)
|
||||||
|
expect(doc.payablePaise).toBeGreaterThan(0)
|
||||||
|
// Persisted correctly — a re-fetch from the DB carries the same tag.
|
||||||
|
const full = (await getDocument(db, doc.id))!
|
||||||
|
expect(full.source).toBe('bulk_sms_topup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a non-positive or fractional quantity', async () => {
|
||||||
|
const { db, cm } = await world()
|
||||||
|
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 0)).rejects.toThrow()
|
||||||
|
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, -5)).rejects.toThrow()
|
||||||
|
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 1.5)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unknown client module', async () => {
|
||||||
|
const { db } = await world()
|
||||||
|
await expect(generateBulkSmsPurchaseQuote(db, 'u1', 'nope', 100_000)).rejects.toThrow(/not found/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('document.source tagging', () => {
|
||||||
|
it('tags a renewal proforma module_renewal, and a manual draft keeps the default', async () => {
|
||||||
|
const { db, c } = await world()
|
||||||
|
// A separate yearly-priced module for the renewal path (usage modules enforce a
|
||||||
|
// minimum-order qty that a qty:1 renewal line would fail).
|
||||||
|
const yearly = await createModule(db, 'u1', { code: 'CORE', name: 'Core App', allowedKinds: ['yearly'] })
|
||||||
|
await setPrice(db, 'u1', { moduleId: yearly.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
||||||
|
const cm2 = await assignModule(db, 'u1', { clientId: c.id, moduleId: yearly.id, kind: 'yearly' })
|
||||||
|
await updateClientModule(db, 'u1', cm2.id, { nextRenewal: '2026-08-15' })
|
||||||
|
const renewal = await generateModuleRenewalQuote(db, 'u1', cm2.id)
|
||||||
|
expect(renewal.source).toBe('module_renewal')
|
||||||
|
|
||||||
|
const manual = await createDraft(db, 'u1', {
|
||||||
|
docType: 'PROFORMA', clientId: c.id,
|
||||||
|
lines: [{ moduleId: yearly.id, qty: 1, kind: 'yearly' }],
|
||||||
|
})
|
||||||
|
expect(manual.source).toBe('hq') // default preserved when source is omitted
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,324 @@
|
|||||||
|
// 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).
|
||||||
|
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']
|
||||||
|
|
||||||
|
describe('onboarding template migration', () => {
|
||||||
|
let db: DB
|
||||||
|
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
|
||||||
|
|
||||||
|
async function makeSmsClientModule(): Promise<string> {
|
||||||
|
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
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedOldGenericRows(cmId: string): Promise<void> {
|
||||||
|
const rows: [string, number, string | null][] = [
|
||||||
|
['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'],
|
||||||
|
['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null],
|
||||||
|
]
|
||||||
|
for (const [i, r] of rows.entries()) {
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
|
`m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('swaps an SMS project to the composed front+tail list carrying installed+go_live ticks', async () => {
|
||||||
|
const cmId = await makeSmsClientModule()
|
||||||
|
await seedOldGenericRows(cmId)
|
||||||
|
|
||||||
|
const res = await migrateOnboardingTemplates(db)
|
||||||
|
expect(res.projects).toBe(1)
|
||||||
|
expect(res.carried).toBe(2) // installed -> installation, go_live -> go_live
|
||||||
|
expect(res.dropped).toBe(0) // the two dropped keys (setup_done, amc_start) were both undone
|
||||||
|
|
||||||
|
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',
|
||||||
|
])
|
||||||
|
const installation = ms.find((m) => m.key === 'installation')!
|
||||||
|
expect(installation.done).toBe(true)
|
||||||
|
expect(installation.doneOn).toBe('2026-03-25')
|
||||||
|
expect(installation.paymentId).toBeNull()
|
||||||
|
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)
|
||||||
|
expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false)
|
||||||
|
expect(ms.find((m) => m.key === 'payment_received')!.done).toBe(false)
|
||||||
|
// old setup_done / amc_start gone entirely
|
||||||
|
expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false)
|
||||||
|
|
||||||
|
// audited
|
||||||
|
const audits = await db.all<{ entity_id: string; after_json: string | null }>(
|
||||||
|
`SELECT entity_id, after_json FROM audit_log WHERE action='migrate_onboarding'`,
|
||||||
|
)
|
||||||
|
expect(audits).toHaveLength(1)
|
||||||
|
expect(audits[0]!.entity_id).toBe(cmId)
|
||||||
|
expect(audits[0]!.after_json).toContain('installation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries a done payment_id along with the mapped tick (advance_paid -> 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-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,
|
||||||
|
)
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)`,
|
||||||
|
'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-03-01', paymentId, 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-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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent (second run changes nothing new)', async () => {
|
||||||
|
const cmId = await makeSmsClientModule()
|
||||||
|
await seedOldGenericRows(cmId)
|
||||||
|
|
||||||
|
const first = await migrateOnboardingTemplates(db)
|
||||||
|
expect(first.projects).toBe(1)
|
||||||
|
const afterFirst = await listProjectMilestones(db, cmId)
|
||||||
|
|
||||||
|
const second = await migrateOnboardingTemplates(db)
|
||||||
|
expect(second.projects).toBe(0)
|
||||||
|
expect(second.carried).toBe(0)
|
||||||
|
expect(second.dropped).toBe(0)
|
||||||
|
const afterSecond = await listProjectMilestones(db, cmId)
|
||||||
|
expect(afterSecond).toEqual(afterFirst)
|
||||||
|
|
||||||
|
// no extra audit rows written on the no-op second pass
|
||||||
|
const audits = await db.all<{ n: number }>(
|
||||||
|
`SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding'`,
|
||||||
|
)
|
||||||
|
expect((audits[0] as unknown as { n: number }).n).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts ticked dropped keys (unmapped or not in new template)', async () => {
|
||||||
|
const cmId = await makeSmsClientModule()
|
||||||
|
// Insert old generic rows with two ticked keys that will be dropped:
|
||||||
|
// - amc_start: unmapped (not in KEY_MAP)
|
||||||
|
// - setup_done: maps to 'setup_configuration', but SMS template doesn't have it
|
||||||
|
// Plus one carried key (installed -> installation) to verify carried count too.
|
||||||
|
const rows: [string, number, string | null][] = [
|
||||||
|
['amc_start', 1, '2026-02-15'], // ticked, unmapped -> dropped
|
||||||
|
['setup_done', 1, '2026-03-01'], // ticked, maps to setup_configuration but SMS template lacks it -> dropped
|
||||||
|
['installed', 1, '2026-03-25'], // ticked, maps to installation which IS in SMS template -> carried
|
||||||
|
['advance_paid', 0, null],
|
||||||
|
['go_live', 0, null],
|
||||||
|
['balance_paid', 0, null],
|
||||||
|
]
|
||||||
|
for (const [i, r] of rows.entries()) {
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
|
`m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await migrateOnboardingTemplates(db)
|
||||||
|
expect(res.projects).toBe(1)
|
||||||
|
expect(res.carried).toBe(1) // only installed -> installation
|
||||||
|
expect(res.dropped).toBe(2) // amc_start (unmapped) + setup_done (not in SMS template)
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
])
|
||||||
|
expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false)
|
||||||
|
|
||||||
|
// Verify the carried installation tick is preserved
|
||||||
|
const installation = ms.find((m) => m.key === 'installation')!
|
||||||
|
expect(installation.done).toBe(true)
|
||||||
|
expect(installation.doneOn).toBe('2026-03-25')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a module without a per-module template untouched', async () => {
|
||||||
|
const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' })
|
||||||
|
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).
|
||||||
|
const before = await listProjectMilestones(db, cm.id)
|
||||||
|
expect(before.map((m) => m.key)).toEqual([...FRONT_KEYS, ...TAIL_FALLBACK.map((t) => t.key)])
|
||||||
|
|
||||||
|
const res = await migrateOnboardingTemplates(db)
|
||||||
|
expect(res.projects).toBe(0)
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
// apps/hq/test/milestone-document.test.ts — Client Detail redesign task 7: the quotation
|
||||||
|
// step (`quotation_sent`) can link the quotation / proforma document that was actually sent.
|
||||||
|
import express from 'express'
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { openDb } from '../src/db'
|
||||||
|
import { seedIfEmpty } from '../src/seed'
|
||||||
|
import { apiRouter } from '../src/api'
|
||||||
|
import { createStaff } from '../src/auth'
|
||||||
|
import { createClient } from '../src/repos-clients'
|
||||||
|
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
||||||
|
import { createDraft } from '../src/repos-documents'
|
||||||
|
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
|
||||||
|
|
||||||
|
const KEY = '11'.repeat(32)
|
||||||
|
|
||||||
|
async function world() {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await seedIfEmpty(db)
|
||||||
|
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||||
|
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||||
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
|
||||||
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||||
|
const quote = async (clientId: string) => createDraft(db, 'u1', {
|
||||||
|
docType: 'QUOTATION', clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
||||||
|
})
|
||||||
|
return { db, clientId: c.id, cmId: cm.id, quote }
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = async (db: Awaited<ReturnType<typeof world>>['db'], cmId: string) =>
|
||||||
|
(await listProjectMilestones(db, cmId)).find((s) => s.key === 'quotation_sent')!
|
||||||
|
|
||||||
|
describe('milestone document_id link (quotation step)', () => {
|
||||||
|
it('linking a quotation stores document_id on the ticked step', async () => {
|
||||||
|
const { db, clientId, cmId, quote } = await world()
|
||||||
|
const doc = await quote(clientId)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
||||||
|
const s = await step(db, cmId)
|
||||||
|
expect(s.done).toBe(true)
|
||||||
|
expect(s.doneOn).toBe('2026-05-01')
|
||||||
|
expect(s.documentId).toBe(doc.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a document that belongs to a different client', async () => {
|
||||||
|
const { db, cmId, quote } = await world()
|
||||||
|
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||||
|
const foreign = await quote(other.id)
|
||||||
|
await expect(
|
||||||
|
setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, foreign.id),
|
||||||
|
).rejects.toThrow(/document/i)
|
||||||
|
// …and nothing was written: the step is still pending.
|
||||||
|
const s = await step(db, cmId)
|
||||||
|
expect(s.done).toBe(false)
|
||||||
|
expect(s.documentId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unticking clears document_id along with done_on', async () => {
|
||||||
|
const { db, clientId, cmId, quote } = await world()
|
||||||
|
const doc = await quote(clientId)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
|
||||||
|
expect((await step(db, cmId)).documentId).toBe(doc.id)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'quotation_sent', false)
|
||||||
|
const s = await step(db, cmId)
|
||||||
|
expect(s.done).toBe(false)
|
||||||
|
expect(s.doneOn).toBeNull()
|
||||||
|
expect(s.documentId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ticking without a documentId leaves the link null (opt-in)', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'quotation_sent', true)
|
||||||
|
const s = await step(db, cmId)
|
||||||
|
expect(s.done).toBe(true)
|
||||||
|
expect(s.documentId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('audits the link in the same transaction as the tick', async () => {
|
||||||
|
const { db, clientId, cmId, quote } = await world()
|
||||||
|
const doc = await quote(clientId)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
|
||||||
|
const audits = await db.all<{ after_json: string | null }>(
|
||||||
|
`SELECT after_json FROM audit_log WHERE action='set_milestone'`,
|
||||||
|
)
|
||||||
|
expect(audits).toHaveLength(1)
|
||||||
|
expect(audits[0]!.after_json).toContain(doc.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('milestone route accepts documentId', () => {
|
||||||
|
const servers: { close: () => void }[] = []
|
||||||
|
afterAll(() => { for (const s of servers) s.close() })
|
||||||
|
|
||||||
|
it('POST /client-modules/:id/milestones threads documentId through', async () => {
|
||||||
|
const { db, clientId, cmId, quote } = await world()
|
||||||
|
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
|
||||||
|
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||||
|
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||||
|
const server = app.listen(0); servers.push(server)
|
||||||
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||||
|
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
|
||||||
|
})).json()) as { token: string }).token
|
||||||
|
const H = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
|
||||||
|
|
||||||
|
const doc = await quote(clientId)
|
||||||
|
const out = await (await fetch(`${base}/client-modules/${cmId}/milestones`, {
|
||||||
|
method: 'POST', headers: H,
|
||||||
|
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: doc.id }),
|
||||||
|
})).json() as { ok: boolean; milestones: { key: string; documentId: string | null }[] }
|
||||||
|
expect(out.ok).toBe(true)
|
||||||
|
expect(out.milestones.find((m) => m.key === 'quotation_sent')!.documentId).toBe(doc.id)
|
||||||
|
|
||||||
|
// A foreign document is a 400, not a silent link.
|
||||||
|
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||||
|
const foreign = await quote(other.id)
|
||||||
|
const bad = await fetch(`${base}/client-modules/${cmId}/milestones`, {
|
||||||
|
method: 'POST', headers: H,
|
||||||
|
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: foreign.id }),
|
||||||
|
})
|
||||||
|
expect(bad.status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
// apps/hq/test/milestone-payment.test.ts — payment_id on milestone + link on payment-step tick.
|
||||||
|
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 } from '../src/repos-modules'
|
||||||
|
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
|
||||||
|
import { recordPayment } from '../src/repos-payments'
|
||||||
|
|
||||||
|
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' })
|
||||||
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||||
|
return { db, clientId: c.id, cmId: cm.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('milestone payment_id link (payment-step tick)', () => {
|
||||||
|
it('linking a payment to payment_received 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')!
|
||||||
|
expect(ms.done).toBe(true)
|
||||||
|
expect(ms.doneOn).toBe('2026-05-01')
|
||||||
|
expect(ms.paymentId).toBe(pay.payment.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a payment that belongs to a different client', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||||
|
const pay = await recordPayment(db, 'u1', {
|
||||||
|
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),
|
||||||
|
).rejects.toThrow(/payment/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unticking clears payment_id along with done_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)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'payment_received', false)
|
||||||
|
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
|
||||||
|
expect(ms.done).toBe(false)
|
||||||
|
expect(ms.doneOn).toBeNull()
|
||||||
|
expect(ms.paymentId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an explicit doneOn wins over the payment 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, '2026-05-10', pay.payment.id)
|
||||||
|
const ms = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'payment_received')!
|
||||||
|
expect(ms.doneOn).toBe('2026-05-10')
|
||||||
|
expect(ms.paymentId).toBe(pay.payment.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
// apps/hq/test/milestone-status.test.ts — milestone→status auto-drive (never downgrade).
|
||||||
|
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 { 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' })
|
||||||
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||||
|
return { db, c, m, cmId: cm.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
const cm = await getClientModule(db, cmId)
|
||||||
|
expect(cm!.status).toBe('installed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ticking go_live advances status to live', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'go_live', true)
|
||||||
|
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unticking never downgrades status', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'go_live', true)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'go_live', false)
|
||||||
|
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a lower step never downgrades a higher status', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'go_live', true) // live
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', true) // still live
|
||||||
|
expect((await getClientModule(db, cmId))!.status).toBe('live')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Final whole-branch review FIX 5: installedOn / trainedOn became write-only once the UI's
|
||||||
|
// RowDate / summary-table date writers were removed with nothing to replace them — the
|
||||||
|
// milestone-driven status advance is now that replacement.
|
||||||
|
describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
|
||||||
|
it("ticking 'installation' (which advances status to installed) stamps installed_on from the milestone's done_on", async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
||||||
|
const cm = await getClientModule(db, cmId)
|
||||||
|
expect(cm!.status).toBe('installed')
|
||||||
|
expect(cm!.installedOn).toBe('2026-03-25')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ticking 'training' (which advances status to trained) stamps trained_on from the milestone's done_on", async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
|
||||||
|
const cm = await getClientModule(db, cmId)
|
||||||
|
expect(cm!.status).toBe('trained')
|
||||||
|
expect(cm!.trainedOn).toBe('2026-05-02')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never overwrites an installed_on already on file (e.g. set by import / direct edit)', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await updateClientModule(db, 'u1', cmId, { installedOn: '2025-01-01' })
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
||||||
|
const cm = await getClientModule(db, cmId)
|
||||||
|
expect(cm!.status).toBe('installed') // status still advances
|
||||||
|
expect(cm!.installedOn).toBe('2025-01-01') // date untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-ticking after an untick does not move an already-stamped installed_on', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', false)
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-04-01')
|
||||||
|
expect((await getClientModule(db, cmId))!.installedOn).toBe('2026-03-25')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves installed_on / trained_on untouched for a step with no date-column mapping (e.g. go_live)', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'go_live', true, '2026-06-01')
|
||||||
|
const cm = await getClientModule(db, cmId)
|
||||||
|
expect(cm!.status).toBe('live')
|
||||||
|
expect(cm!.installedOn).toBeNull()
|
||||||
|
expect(cm!.trainedOn).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the status-advance audit payload reflects the installed_on stamp', async () => {
|
||||||
|
const { db, cmId } = await world()
|
||||||
|
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
|
||||||
|
const audits = await db.all<{ after_json: string | null }>(
|
||||||
|
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
|
||||||
|
cmId,
|
||||||
|
)
|
||||||
|
const stamped = audits.find((a) => (a.after_json ?? '').includes('installedOn'))
|
||||||
|
expect(stamped).toBeDefined()
|
||||||
|
expect(JSON.parse(stamped!.after_json!)).toMatchObject({ status: 'installed', installedOn: '2026-03-25' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
// apps/hq/test/milestones-templates.test.ts — composed (front + per-module tail) 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'
|
||||||
|
|
||||||
|
describe('composed milestone templates (front + tail)', () => {
|
||||||
|
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 () => {
|
||||||
|
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' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tail: 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' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,224 @@
|
|||||||
|
// apps/hq/test/onboarding-template-routes.test.ts — final whole-branch review FIX 4: the
|
||||||
|
// owner onboarding-template editor surface (normalizeTemplateSteps / setFrontTemplate /
|
||||||
|
// setModuleTail / getFrontTemplate / getModuleTail + the four routes) had no direct test
|
||||||
|
// coverage. Also exercises the cross-half (front <-> tail) key collision rejection added by
|
||||||
|
// FIX 1, at both the repo level and over HTTP.
|
||||||
|
import express from 'express'
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { openDb, type DB } from '../src/db'
|
||||||
|
import { seedIfEmpty } from '../src/seed'
|
||||||
|
import { apiRouter } from '../src/api'
|
||||||
|
import { createStaff } from '../src/auth'
|
||||||
|
import { createModule } from '../src/repos-modules'
|
||||||
|
import {
|
||||||
|
normalizeTemplateSteps, setFrontTemplate, setModuleTail, getFrontTemplate, getModuleTail,
|
||||||
|
FRONT_FALLBACK, TAIL_FALLBACK,
|
||||||
|
} from '../src/repos-milestones'
|
||||||
|
|
||||||
|
const KEY = '33'.repeat(32)
|
||||||
|
|
||||||
|
describe('normalizeTemplateSteps (pure validation)', () => {
|
||||||
|
it('rejects a non-array or empty body', () => {
|
||||||
|
expect(() => normalizeTemplateSteps(undefined)).toThrow(/at least one/i)
|
||||||
|
expect(() => normalizeTemplateSteps('nope')).toThrow(/at least one/i)
|
||||||
|
expect(() => normalizeTemplateSteps([])).toThrow(/at least one/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a blank label', () => {
|
||||||
|
expect(() => normalizeTemplateSteps([{ label: ' ' }])).toThrow(/label/i)
|
||||||
|
expect(() => normalizeTemplateSteps([{ key: 'a', label: 'A' }, { label: '' }])).toThrow(/label/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a duplicate explicit key', () => {
|
||||||
|
expect(() => normalizeTemplateSteps([
|
||||||
|
{ key: 'dup', label: 'First' }, { key: 'dup', label: 'Second' },
|
||||||
|
])).toThrow(/Duplicate step key/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives a key by slugifying the label when none is given, bumping on collision', () => {
|
||||||
|
const out = normalizeTemplateSteps([
|
||||||
|
{ label: 'Site Visit!' }, { label: 'site visit' }, { label: 'Site -- Visit' },
|
||||||
|
])
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ key: 'site_visit', label: 'Site Visit!' },
|
||||||
|
{ key: 'site_visit_2', label: 'site visit' },
|
||||||
|
{ key: 'site_visit_3', label: 'Site -- Visit' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a key derived from a label collides with a later EXPLICIT key of the same value -> rejected', () => {
|
||||||
|
expect(() => normalizeTemplateSteps([{ label: 'Go Live' }, { key: 'go_live', label: 'Go-live (explicit)' }]))
|
||||||
|
.toThrow(/Duplicate step key/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setFrontTemplate / setModuleTail / getFrontTemplate / getModuleTail (repo level)', () => {
|
||||||
|
it('getFrontTemplate / getModuleTail fall back to the code defaults on an empty DB', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
|
||||||
|
expect(await getModuleTail(db, 'SMS')).toEqual(TAIL_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFrontTemplate rejects a key colliding with the default tail', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await expect(setFrontTemplate(db, 'u1', [{ key: 'installed', label: 'Site installed' }]))
|
||||||
|
.rejects.toThrow(/used by both the front/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFrontTemplate rejects a key colliding with an already-configured per-module tail', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await setModuleTail(db, 'u1', 'SMS', [{ key: 'kickoff', label: 'Kickoff call' }])
|
||||||
|
await expect(setFrontTemplate(db, 'u1', [{ key: 'kickoff', label: 'Kickoff' }]))
|
||||||
|
.rejects.toThrow(/SMS/)
|
||||||
|
// The front setting was never written — still the fallback.
|
||||||
|
expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setModuleTail rejects a key colliding with the shared front', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'enquiry', label: 'Enquiry (dup)' }]))
|
||||||
|
.rejects.toThrow(/front/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setModuleTail rejects a key colliding with a CUSTOM front (not just the code fallback)', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await setFrontTemplate(db, 'u1', [{ key: 'lead_in', label: 'Lead in' }])
|
||||||
|
await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'lead_in', label: 'Also lead in' }]))
|
||||||
|
.rejects.toThrow(/front/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a non-colliding edit round-trips through the getters and is audited', async () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
const front = [{ key: 'lead_in', label: 'Lead in' }]
|
||||||
|
const tail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
|
||||||
|
await setFrontTemplate(db, 'u1', front)
|
||||||
|
await setModuleTail(db, 'u1', 'SMS', tail)
|
||||||
|
expect(await getFrontTemplate(db)).toEqual(front)
|
||||||
|
expect(await getModuleTail(db, 'SMS')).toEqual(tail)
|
||||||
|
// A different, unconfigured module still gets the code fallback tail.
|
||||||
|
expect(await getModuleTail(db, 'RTGS')).toEqual(TAIL_FALLBACK)
|
||||||
|
const audited = await db.all<{ n: number }>(
|
||||||
|
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'project.milestone_template%'`,
|
||||||
|
)
|
||||||
|
expect((audited[0] as unknown as { n: number }).n).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onboarding-template routes (owner-only editor)', () => {
|
||||||
|
const servers: { close: () => void }[] = []
|
||||||
|
afterAll(() => { for (const s of servers) s.close() })
|
||||||
|
|
||||||
|
async function httpWorld(): Promise<{ db: DB; base: string; ownerH: Record<string, string>; staffH: Record<string, string> }> {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await seedIfEmpty(db)
|
||||||
|
await createStaff(db, { email: 'owner2@t.in', displayName: 'Owner2', role: 'owner', password: 'owner-pass-1' })
|
||||||
|
await createStaff(db, { email: 'staff@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' })
|
||||||
|
await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||||
|
// seedIfEmpty pre-seeds a `project.milestone_template:SMS` setting (the shipped SMS
|
||||||
|
// pipeline), so use an UNconfigured module for tests that assert the code TAIL_FALLBACK.
|
||||||
|
await createModule(db, 'u1', { code: 'WIDGET', name: 'Unconfigured Widget' })
|
||||||
|
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||||
|
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||||
|
const server = app.listen(0); servers.push(server)
|
||||||
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||||
|
const login = async (email: string, password: string): Promise<Record<string, string>> => {
|
||||||
|
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})).json()) as { token: string }).token
|
||||||
|
return { 'content-type': 'application/json', authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
return { db, base, ownerH: await login('owner2@t.in', 'owner-pass-1'), staffH: await login('staff@t.in', 'staff-pass-1') }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET /onboarding-template/front: owner only, serves the fallback by default', async () => {
|
||||||
|
const { base, ownerH, staffH } = await httpWorld()
|
||||||
|
const denied = await fetch(`${base}/onboarding-template/front`, { headers: staffH })
|
||||||
|
expect(denied.status).toBe(403)
|
||||||
|
const out = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
|
||||||
|
{ ok: boolean; steps: { key: string; label: string }[] }
|
||||||
|
expect(out.ok).toBe(true)
|
||||||
|
expect(out.steps).toEqual(FRONT_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PUT /onboarding-template/front: owner only; rejects blank label / duplicate key; round-trips on GET', async () => {
|
||||||
|
const { base, ownerH, staffH } = await httpWorld()
|
||||||
|
const denied = await fetch(`${base}/onboarding-template/front`, {
|
||||||
|
method: 'PUT', headers: staffH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
|
||||||
|
})
|
||||||
|
expect(denied.status).toBe(403)
|
||||||
|
|
||||||
|
const blank = await fetch(`${base}/onboarding-template/front`, {
|
||||||
|
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: ' ' }] }),
|
||||||
|
})
|
||||||
|
expect(blank.status).toBe(400)
|
||||||
|
|
||||||
|
const dup = await fetch(`${base}/onboarding-template/front`, {
|
||||||
|
method: 'PUT', headers: ownerH,
|
||||||
|
body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }, { key: 'a', label: 'A2' }] }),
|
||||||
|
})
|
||||||
|
expect(dup.status).toBe(400)
|
||||||
|
|
||||||
|
const newFront = [{ label: 'Lead In!' }, { key: 'confirmed', label: 'Confirmed' }]
|
||||||
|
const put = await fetch(`${base}/onboarding-template/front`, {
|
||||||
|
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newFront }),
|
||||||
|
})
|
||||||
|
expect(put.status).toBe(200)
|
||||||
|
const putBody = await put.json() as { steps: { key: string; label: string }[] }
|
||||||
|
expect(putBody.steps[0]).toEqual({ key: 'lead_in', label: 'Lead In!' })
|
||||||
|
|
||||||
|
const reGet = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as
|
||||||
|
{ steps: { key: string; label: string }[] }
|
||||||
|
expect(reGet.steps).toEqual(putBody.steps)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET/PUT /modules/:code/onboarding-template: 404 on an unknown module code', async () => {
|
||||||
|
const { base, ownerH } = await httpWorld()
|
||||||
|
const getMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, { headers: ownerH })
|
||||||
|
expect(getMissing.status).toBe(404)
|
||||||
|
const putMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, {
|
||||||
|
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }),
|
||||||
|
})
|
||||||
|
expect(putMissing.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET/PUT /modules/:code/onboarding-template: owner only, round-trips a valid edit', async () => {
|
||||||
|
const { base, ownerH, staffH } = await httpWorld()
|
||||||
|
const denied = await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: staffH })
|
||||||
|
expect(denied.status).toBe(403)
|
||||||
|
|
||||||
|
const before = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
|
||||||
|
{ steps: { key: string; label: string }[] }
|
||||||
|
expect(before.steps).toEqual(TAIL_FALLBACK)
|
||||||
|
|
||||||
|
const newTail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }]
|
||||||
|
const put = await fetch(`${base}/modules/WIDGET/onboarding-template`, {
|
||||||
|
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newTail }),
|
||||||
|
})
|
||||||
|
expect(put.status).toBe(200)
|
||||||
|
|
||||||
|
const after = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as
|
||||||
|
{ steps: { key: string; label: string }[] }
|
||||||
|
expect(after.steps).toEqual(newTail)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cross-half collision (FIX 1) is rejected with 400 over HTTP, both directions', async () => {
|
||||||
|
const { base, ownerH } = await httpWorld()
|
||||||
|
// Front step whose key collides with the (fallback) SMS tail's 'installed'... use the
|
||||||
|
// module tail directly since SMS starts from TAIL_FALLBACK (has 'installed').
|
||||||
|
const frontCollide = await fetch(`${base}/onboarding-template/front`, {
|
||||||
|
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'installed', label: 'Installed' }] }),
|
||||||
|
})
|
||||||
|
expect(frontCollide.status).toBe(400)
|
||||||
|
const frontCollideBody = await frontCollide.json() as { ok: boolean; error: string }
|
||||||
|
expect(frontCollideBody.error).toMatch(/used by both the front/i)
|
||||||
|
|
||||||
|
// Tail step whose key collides with the shared front's 'enquiry'.
|
||||||
|
const tailCollide = await fetch(`${base}/modules/SMS/onboarding-template`, {
|
||||||
|
method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'enquiry', label: 'Enquiry (dup)' }] }),
|
||||||
|
})
|
||||||
|
expect(tailCollide.status).toBe(400)
|
||||||
|
const tailCollideBody = await tailCollide.json() as { ok: boolean; error: string }
|
||||||
|
expect(tailCollideBody.error).toMatch(/front/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
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)', () => {
|
||||||
|
let db: DB
|
||||||
|
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
|
||||||
|
|
||||||
|
it('composes the shared front with the SMS tail', 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',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('composes the shared front with the RTGS tail ending in go-live + payment received', async () => {
|
||||||
|
const keys = (await milestoneTemplate(db, 'RTGS')).map((e) => e.key)
|
||||||
|
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',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
// apps/hq/test/stalled-onboarding.test.ts — Task 8: stalled-onboarding query (dashboard/MIS tile).
|
||||||
|
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 } from '../src/repos-modules'
|
||||||
|
import { setMilestone, stalledProjects } 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' })
|
||||||
|
// A module code with no per-module milestone template seeded (D22 seed.ts only carries
|
||||||
|
// SMS/RTGS/MOBILEAPP overrides), so the global template applies: advance_paid, setup_done,
|
||||||
|
// installed, go_live, balance_paid, amc_start.
|
||||||
|
const m = await createModule(db, 'u1', { code: 'MISC', name: 'Misc Module' })
|
||||||
|
return { db, c, m }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stalledProjects (D22 — dashboard/MIS tile)', () => {
|
||||||
|
it('lists a project parked past the threshold', async () => {
|
||||||
|
const { db, c, m } = await world()
|
||||||
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||||
|
// Early steps done on an old date; later steps (incl. go_live) stay pending — project is
|
||||||
|
// still active (status stays 'quoted'/'installed', never 'live').
|
||||||
|
await setMilestone(db, 'u1', cm.id, 'advance_paid', true, '2026-03-01')
|
||||||
|
await setMilestone(db, 'u1', cm.id, 'setup_done', true, '2026-03-01')
|
||||||
|
// last done_on = 2026-03-01, today = 2026-07-19 → ~140 days parked.
|
||||||
|
const stalled = await stalledProjects(db, 30, '2026-07-19')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes a recently-progressed project', async () => {
|
||||||
|
const { db, c, m } = await world()
|
||||||
|
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||||
|
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).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes projects with no pending steps (fully ticked) and inactive/live projects', async () => {
|
||||||
|
const { db, c, m } = await world()
|
||||||
|
// Fully ticked project: nothing pending, so it should never appear regardless of age.
|
||||||
|
const done = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||||
|
for (const key of ['advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start']) {
|
||||||
|
await setMilestone(db, 'u1', done.id, key, true, '2026-01-01')
|
||||||
|
}
|
||||||
|
const stalled = await stalledProjects(db, 1, '2026-07-19')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
// apps/hq/test/ticket-types.test.ts — Client Detail redesign task 6: ticket classification.
|
||||||
|
// The list is config over code (the `ticket.types` setting, code fallback); tickets carry
|
||||||
|
// a `type`, the desk filters by it.
|
||||||
|
import express from 'express'
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { openDb, type DB } from '../src/db'
|
||||||
|
import { seedIfEmpty } from '../src/seed'
|
||||||
|
import { apiRouter } from '../src/api'
|
||||||
|
import { createStaff } from '../src/auth'
|
||||||
|
import { createClient } from '../src/repos-clients'
|
||||||
|
import { setSetting } from '../src/repos-reminders'
|
||||||
|
import {
|
||||||
|
createTicket, updateTicket, listTickets, ticketTypes, TICKET_TYPE_FALLBACK,
|
||||||
|
} from '../src/repos-tickets'
|
||||||
|
|
||||||
|
const KEY = '11'.repeat(32)
|
||||||
|
|
||||||
|
async function world() {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
await seedIfEmpty(db)
|
||||||
|
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||||
|
return { db, c }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ticket type list (config over code)', () => {
|
||||||
|
it('seeds `ticket.types` on first boot, matching the code fallback', async () => {
|
||||||
|
const { db } = await world()
|
||||||
|
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
|
||||||
|
expect(row).toBeDefined()
|
||||||
|
expect(JSON.parse(row!.value)).toEqual(TICKET_TYPE_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the resolver prefers the setting over the code fallback', async () => {
|
||||||
|
const { db } = await world()
|
||||||
|
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([
|
||||||
|
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
|
||||||
|
]))
|
||||||
|
expect(await ticketTypes(db)).toEqual([
|
||||||
|
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the code list when the setting is missing, empty or unparseable', async () => {
|
||||||
|
const { db } = await world()
|
||||||
|
await db.run(`DELETE FROM setting WHERE key='ticket.types'`)
|
||||||
|
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||||
|
await setSetting(db, 'u1', 'ticket.types', '[]')
|
||||||
|
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||||
|
await setSetting(db, 'u1', 'ticket.types', 'not json at all')
|
||||||
|
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||||
|
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([{ label: 'no key' }]))
|
||||||
|
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ticket.type on create / update / list', () => {
|
||||||
|
it("defaults to 'service' when the caller sends no type", async () => {
|
||||||
|
const { db, c } = await world()
|
||||||
|
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'no type given' })
|
||||||
|
expect(t.type).toBe('service')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a configured type and rejects an unknown one', async () => {
|
||||||
|
const { db, c } = await world()
|
||||||
|
const t = await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'renewal' })
|
||||||
|
expect(t.type).toBe('amc')
|
||||||
|
await expect(createTicket(db, 'u1', { clientId: c.id, type: 'nonsense' })).rejects.toThrow(/type/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the type (audited) and rejects an unknown one', async () => {
|
||||||
|
const { db, c } = await world()
|
||||||
|
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'reclassify me' })
|
||||||
|
const after = await updateTicket(db, 'u1', t.id, { type: 'modification' })
|
||||||
|
expect(after.type).toBe('modification')
|
||||||
|
await expect(updateTicket(db, 'u1', t.id, { type: 'nonsense' })).rejects.toThrow(/type/i)
|
||||||
|
// still 'modification' — the rejected patch wrote nothing
|
||||||
|
expect((await listTickets(db, { clientId: c.id })).tickets[0]!.type).toBe('modification')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters the list by type; a blank/omitted filter returns every type', async () => {
|
||||||
|
const { db, c } = await world()
|
||||||
|
await createTicket(db, 'u1', { clientId: c.id, type: 'service', description: 'a' })
|
||||||
|
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'b' })
|
||||||
|
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'c' })
|
||||||
|
expect((await listTickets(db, { type: 'amc' })).total).toBe(2)
|
||||||
|
expect((await listTickets(db, { type: 'service' })).total).toBe(1)
|
||||||
|
expect((await listTickets(db, { type: 'modification' })).total).toBe(0)
|
||||||
|
expect((await listTickets(db, { type: '' })).total).toBe(3)
|
||||||
|
expect((await listTickets(db, {})).total).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an existing (pre-migration) row reads back as the default type', async () => {
|
||||||
|
const { db, c } = await world()
|
||||||
|
// Insert the way the APEX importer does — without naming the new column.
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO ticket (id, client_id, kind, description, opened_on, created_by, created_at, source)
|
||||||
|
VALUES ('legacy-1', ?, 'MODIFICATION', 'imported', '2025-01-01', 'u1', '2025-01-01T00:00:00.000Z', 'apex')`,
|
||||||
|
c.id,
|
||||||
|
)
|
||||||
|
const listed = await listTickets(db, { clientId: c.id })
|
||||||
|
expect(listed.tickets[0]!.type).toBe('service')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ticket type routes', () => {
|
||||||
|
const servers: { close: () => void }[] = []
|
||||||
|
afterAll(() => { for (const s of servers) s.close() })
|
||||||
|
|
||||||
|
async function httpWorld(): Promise<{ db: DB; clientId: string; base: string; H: Record<string, string> }> {
|
||||||
|
const { db, c } = await world()
|
||||||
|
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
|
||||||
|
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||||
|
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||||
|
const server = app.listen(0); servers.push(server)
|
||||||
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||||
|
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
|
||||||
|
})).json()) as { token: string }).token
|
||||||
|
return { db, clientId: c.id, base, H: { 'content-type': 'application/json', authorization: `Bearer ${token}` } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('GET /tickets/types serves the configured list', async () => {
|
||||||
|
const { base, H } = await httpWorld()
|
||||||
|
const out = await (await fetch(`${base}/tickets/types`, { headers: H })).json() as
|
||||||
|
{ ok: boolean; types: { key: string; label: string }[] }
|
||||||
|
expect(out.ok).toBe(true)
|
||||||
|
expect(out.types).toEqual(TICKET_TYPE_FALLBACK)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /tickets accepts a type, and GET /tickets?type= filters on it', async () => {
|
||||||
|
const { clientId, base, H } = await httpWorld()
|
||||||
|
for (const type of ['service', 'amc', 'amc']) {
|
||||||
|
const created = await (await fetch(`${base}/tickets`, {
|
||||||
|
method: 'POST', headers: H, body: JSON.stringify({ clientId, type, description: type }),
|
||||||
|
})).json() as { ok: boolean; ticket: { type: string } }
|
||||||
|
expect(created.ticket.type).toBe(type)
|
||||||
|
}
|
||||||
|
const amc = await (await fetch(`${base}/tickets?type=amc`, { headers: H })).json() as
|
||||||
|
{ total: number; tickets: { type: string }[] }
|
||||||
|
expect(amc.total).toBe(2)
|
||||||
|
expect(amc.tickets.every((t) => t.type === 'amc')).toBe(true)
|
||||||
|
const all = await (await fetch(`${base}/tickets?type=`, { headers: H })).json() as { total: number }
|
||||||
|
expect(all.total).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PATCH /tickets/:id reclassifies; an unknown type is a 400', async () => {
|
||||||
|
const { clientId, base, H } = await httpWorld()
|
||||||
|
const created = await (await fetch(`${base}/tickets`, {
|
||||||
|
method: 'POST', headers: H, body: JSON.stringify({ clientId, description: 'x' }),
|
||||||
|
})).json() as { ticket: { id: string } }
|
||||||
|
const patched = await (await fetch(`${base}/tickets/${created.ticket.id}`, {
|
||||||
|
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'module' }),
|
||||||
|
})).json() as { ticket: { type: string } }
|
||||||
|
expect(patched.ticket.type).toBe('module')
|
||||||
|
const bad = await fetch(`${base}/tickets/${created.ticket.id}`, {
|
||||||
|
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'nonsense' }),
|
||||||
|
})
|
||||||
|
expect(bad.status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,257 @@
|
|||||||
|
# Client Detail redesign — module lifecycle, status line & payments context
|
||||||
|
|
||||||
|
**Date:** 2026-07-19
|
||||||
|
**Area:** `apps/hq-web/src/pages/ClientDetail.tsx`, `client-forms.tsx`, `apps/hq-web/src/app.css`,
|
||||||
|
`apps/hq/src/repos-milestones.ts`, `repos-payments.ts`, `apps/hq/src/api.ts`, seed + migration.
|
||||||
|
**Mockup:** `scratchpad/client-detail-mockup.html` (v2) — approved direction.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The Client Detail page reads as a crowded dump. The per-module "service details" run every field
|
||||||
|
into one wrapping row (`provider · username · password · balance · reseller · alerts …`), so you
|
||||||
|
can't land on what you want. Onboarding is a flat checkbox grid divorced from the module's real
|
||||||
|
lifecycle. "Assign module" looks like a one-click action when in reality a module is the *end* of a
|
||||||
|
process (call → conversation → quotation → start → onboarding → live). And "Payments & plans" lets
|
||||||
|
you record a payment with no view of what is actually owed.
|
||||||
|
|
||||||
|
This redesign restructures the Modules and Payments tabs around how the business actually works,
|
||||||
|
without changing the money engine, the audit rule, or the data model's spine.
|
||||||
|
|
||||||
|
## Constraints (house rules that bind this work)
|
||||||
|
|
||||||
|
- **Config over code, dated:** per-module onboarding step lists are DB rows resolved by module (and
|
||||||
|
date), never constants. (`docs/16`, `docs/07`.)
|
||||||
|
- **Append-only audit:** every mutation (milestone toggle, module-field edit, template change,
|
||||||
|
go-live auto-status) calls `writeAudit` in the same transaction. The Outstanding panel and the
|
||||||
|
per-module document list are **reads** — they write nothing.
|
||||||
|
- **Portable-repo pattern:** plain functions over `DB`; multi-write ops in `db.transaction`.
|
||||||
|
- **Strict TS, green before landing:** `npm run typecheck` + `npm test` clean; money math stays
|
||||||
|
test-locked (this work does not touch `computeBill` or allocation math).
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
|
||||||
|
1. **Module details → 50/50 sheet.** Access (credentials) left half, Details right half; aligned
|
||||||
|
`label → value` rows, read-only, one **Edit** per group. No `*` markers, no always-on input
|
||||||
|
boxes. Applies to **every** module, not just SMS. Reseller + SMS balance are ordinary editable
|
||||||
|
Details fields, not headline columns.
|
||||||
|
2. **Onboarding → horizontal continuous status line** (point-by-point), **per-module** step
|
||||||
|
templates, owner-editable (config). SMS / RTGS / Mobile App each get their own list.
|
||||||
|
3. **Lifecycle:** keep the coarse `client_module.status` field (reports/roster read it); the status
|
||||||
|
line is the detailed view. Ticking the **go-live** step auto-flips status to `live`. "Add module"
|
||||||
|
still starts at `quoted`.
|
||||||
|
4. **Migration:** swap SMS (and RTGS / Mobile App) projects from the old generic checklist to their
|
||||||
|
new per-module list, **preserving mapped ticks** (dates carried).
|
||||||
|
5. **Per-module Documents:** each block lists documents whose line items include that module.
|
||||||
|
6. **Payments:** add an **Outstanding** panel (open docs + amount due + advance) and a **Settled →
|
||||||
|
document** column on each past payment. Record-payment behaviour (auto-allocate oldest invoice
|
||||||
|
first) is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Module block — 50/50 sheet, report-not-boxes, single Edit
|
||||||
|
|
||||||
|
Rework `ServiceDataCard` + `ModuleFieldsForm` (in `ClientDetail.tsx`) so the **read view is the
|
||||||
|
default** and editing is behind a button.
|
||||||
|
|
||||||
|
**Layout (per module block, inside the existing `<details class="mod-section">`):**
|
||||||
|
- Header unchanged: module name, coarse **status badge**, kind badge.
|
||||||
|
- Body groups, each with a hairline-ruled header + an **Edit** button:
|
||||||
|
- **Access** (left half): Provider, Login (username + Copy), Password (`••••` + managerial
|
||||||
|
Reveal/Copy), Portal (URL field → `Open ↗`).
|
||||||
|
- **Details** (right half): the module's `fieldSpec` fields **plus** the free-form `details`
|
||||||
|
rows, shown as `label → value` (dash when empty). Includes SMS balance / reseller.
|
||||||
|
- CSS: new `.split` (grid `1fr 1fr`, centre divider, stacks < 720px) and `.kv` (fixed-width label
|
||||||
|
column so values align). Reuse existing tokens; add to `app.css` near `.mod-section`.
|
||||||
|
|
||||||
|
**Editing:** each group's Edit swaps that group's rows for inputs (the existing edit-state
|
||||||
|
machinery in `ServiceDataCard`, extended to also carry the `fieldSpec` values that
|
||||||
|
`ModuleFieldsForm` edits today). Save = one audited `patchClientModule` / `setModuleFields`
|
||||||
|
call, then back to read view. Secret fields keep the `SecretField` reveal/set pattern.
|
||||||
|
`ModuleFieldsForm`'s always-on inputs + separate "Save fields" button are removed — folded into
|
||||||
|
the Details group's Edit.
|
||||||
|
|
||||||
|
## 2 · Onboarding — horizontal status line, per-module templates
|
||||||
|
|
||||||
|
Replace `MilestoneChecklist`'s checkbox grid with a **horizontal track** (`.track` / `.node` CSS,
|
||||||
|
already prototyped in the mockup): ordered points = the module's template steps, filled to the
|
||||||
|
current point, current point ringed, completed dates under nodes, `overflow-x:auto` for narrow
|
||||||
|
screens. Clicking a node toggles done / stamps the date (reuse `setMilestone`); "+ Add step" keeps
|
||||||
|
appending per-project custom steps (`addProjectMilestone`). The toggle POST still returns the fresh
|
||||||
|
list so the block re-renders in place.
|
||||||
|
|
||||||
|
**Per-module templates (config-over-code):**
|
||||||
|
- `repos-milestones.ts` — `milestoneTemplate(db, moduleCode?)` resolves in order:
|
||||||
|
`project.milestone_template:<MODULE_CODE>` setting → global `project.milestone_template` →
|
||||||
|
`FALLBACK_TEMPLATE`. `ensureProjectMilestones(db, clientModuleId)` looks up the client_module's
|
||||||
|
module code and resolves the module-specific template.
|
||||||
|
- Seed settings (`seed.ts`, additive) for:
|
||||||
|
- **SMS:** Document collection · DLT registration · Account creation & registration · Installation
|
||||||
|
· Testing · Training · Go-live · Advance payment · Balance payment.
|
||||||
|
- **RTGS:** Document collection · Server checkup · Setup & configuration · Installation · Testing ·
|
||||||
|
Training · Go-live · Advance payment · Balance payment.
|
||||||
|
- **Mobile App (draft, editable):** Requirement & setup · Configuration · Data import ·
|
||||||
|
Installation · Testing · Training · Go-live · Advance payment · Balance payment.
|
||||||
|
|
||||||
|
Every list ends with **Advance payment → Balance payment** (payment is part of onboarding for all
|
||||||
|
modules). Ticking a payment step stamps its date on the line — that is the payment date of record
|
||||||
|
for the onboarding view; the money itself is still recorded in the Payments tab / ledger.
|
||||||
|
- Owner-editable on the **Modules** page: a per-module template editor (list of `{key,label}`
|
||||||
|
rows). Minimum viable: edit the JSON list per module; every mutation audited. New/changed steps
|
||||||
|
reach existing projects additively via `ensureProjectMilestones` (which already only adds missing
|
||||||
|
keys).
|
||||||
|
|
||||||
|
**Go-live → status:** when a step with key `go_live` is ticked, set `client_module.status='live'`
|
||||||
|
in the same transaction as the milestone write, with its own audit entry. Unticking does **not**
|
||||||
|
revert status (manual only).
|
||||||
|
|
||||||
|
## 3 · Migration — swap seeded modules to their new lists, preserve ticks
|
||||||
|
|
||||||
|
One-time, idempotent migration (in `migrate()` / a scripted pass like `d35`), for every
|
||||||
|
`client_module` whose module is SMS / RTGS / Mobile App:
|
||||||
|
|
||||||
|
1. Materialise the module's new template (unticked).
|
||||||
|
2. Carry ticks from old generic keys by mapping, preserving `done` + `done_on`:
|
||||||
|
|
||||||
|
| old generic key | → SMS | → RTGS / Mobile App |
|
||||||
|
|---|---|---|
|
||||||
|
| `installed` | `installation` | `installation` |
|
||||||
|
| `go_live` | `go_live` | `go_live` |
|
||||||
|
| `advance_paid` | `advance_payment` | `advance_payment` |
|
||||||
|
| `balance_paid` | `balance_payment` | `balance_payment` |
|
||||||
|
| `setup_done` | *(drop)* | `setup_configuration` (RTGS) |
|
||||||
|
| `amc_start` | *(drop — AMC tracked separately)* | *(drop)* |
|
||||||
|
|
||||||
|
Every module list now ends with advance/balance payment steps, so no payment tick is ever dropped.
|
||||||
|
|
||||||
|
3. Remove the unmapped old rows so a project shows exactly its new list (no doubling).
|
||||||
|
|
||||||
|
Modules with **no** custom template keep the generic list untouched. The mapping and the
|
||||||
|
per-project result are covered by tests (below). Dropped ticks are logged in the migration summary
|
||||||
|
(no silent loss).
|
||||||
|
|
||||||
|
## 4 · Per-module Documents
|
||||||
|
|
||||||
|
Client-side only — the ledger already returns full `Doc`s and each `payload.lines[].itemId` **is**
|
||||||
|
the module id. In each module block add a **Documents** group listing
|
||||||
|
`ledger.documents.filter(d => d.payload.lines.some(l => l.itemId === cm.moduleId))`, newest first:
|
||||||
|
doc no · type badge · date · amount · status, row click → `/documents/:id`. Empty → nothing shown
|
||||||
|
(no empty card).
|
||||||
|
|
||||||
|
### 4a · SMS bulk-credit purchase (a document, within the module)
|
||||||
|
|
||||||
|
SMS has two distinct money events: the **annual subscription/renewal** and ad-hoc **bulk SMS
|
||||||
|
credit purchases** (top-ups). The bulk purchase is not onboarding and not the subscription — it is
|
||||||
|
its own transaction and must be **captured as a document**.
|
||||||
|
|
||||||
|
- In the SMS module block's Documents group, a **"Bulk SMS purchase"** action raises a proforma for
|
||||||
|
a chosen SMS quantity — the existing usage-priced path (`generateModuleRenewalQuote` /
|
||||||
|
`resolveUsageRate` tiered SMS pricing), pre-filled with the SMS line. (This is the same mechanism
|
||||||
|
the SMS-balances top-up already uses; here it lives in the module block.)
|
||||||
|
- The resulting proforma/invoice appears in that block's Documents list (labelled so a bulk top-up
|
||||||
|
reads distinctly from a renewal), flows into the **Outstanding** panel, and the client's payment
|
||||||
|
settles it — so "what is this payment for?" resolves to a real document.
|
||||||
|
- Generalisation: the Documents group also offers **"New document for this module"** (quotation
|
||||||
|
pre-filled with the module line) so a block is where you start the sale — matching the
|
||||||
|
quote → conversation → start flow. Bulk SMS purchase is the SMS-specific flavour of this.
|
||||||
|
|
||||||
|
## 5 · Payments & plans — Outstanding panel + Settled column
|
||||||
|
|
||||||
|
Extend the ledger read (no money-math change):
|
||||||
|
- `repos-payments.ts` `clientLedger` → `ClientLedger` gains:
|
||||||
|
- `outstanding: { docId; docNo; docType; label; outstandingPaise }[]` — issued invoices with
|
||||||
|
`outstandingPaise > 0` plus open (sent/accepted) proformas, oldest first. `label` is derived
|
||||||
|
from the doc's line module names ("SMS annual renewal", "Bulk SMS top-up").
|
||||||
|
- each `payment` gains `allocations: { docNo; amountPaise }[]` (from `payment_allocation`), empty
|
||||||
|
⇒ shows "advance".
|
||||||
|
- `api.ts` types (`Ledger`, `Payment`) updated to match.
|
||||||
|
- **UI (Payments tab):** an **Outstanding** card above the record-payment row (open docs + amount
|
||||||
|
due + advance-on-account), and a **Settled** column on the payments table showing the doc no(s)
|
||||||
|
each payment landed on (or "advance"). Record-payment form and its auto-allocation are unchanged.
|
||||||
|
|
||||||
|
## 6 · Active modules summary + assign reframe
|
||||||
|
|
||||||
|
- **Summary table** (Modules tab): dates now live in each block's status line, so simplify the
|
||||||
|
table to **Module · Kind · Status · Billed · Settled** (drop Installed/Completed/Trained columns);
|
||||||
|
right-align the numeric columns. This is the "tidy alignment / give it life" item.
|
||||||
|
- **Assign form:** rename "Assign module" → **"Add a module"**; copy notes it starts the module at
|
||||||
|
`quoted` and seeds its onboarding line. Behaviour otherwise unchanged (module + kind + optional
|
||||||
|
creds). Coarse status stays editable via a small control in the block header (kept for reports).
|
||||||
|
|
||||||
|
## 7 · Adopted refinements
|
||||||
|
|
||||||
|
1. **Status line auto-drives coarse status.** A step→status map advances `client_module.status` when
|
||||||
|
a step is ticked (never downgrades on untick): `installation → installed`, `training → trained`,
|
||||||
|
`go_live → live` (intermediate `ordered`/`installing` remain manual). The line becomes the single
|
||||||
|
driver of status — the dropdown is no longer hand-set in normal use. Each auto-advance is audited
|
||||||
|
in the milestone-toggle transaction. This is the full resolution of the point-1 overlap.
|
||||||
|
2. **Payment steps ↔ real payments.** Ticking `advance_payment` / `balance_payment` opens the
|
||||||
|
record-payment form pre-filled (or links an existing payment); the step's `done_on` mirrors the
|
||||||
|
payment's `receivedOn`, so the line and the ledger never disagree on the payment date. The link
|
||||||
|
is stored on the milestone row (nullable `payment_id`).
|
||||||
|
3. **Document-type labels in the block.** Rows in a module's Documents list are tagged
|
||||||
|
**Quote / Renewal / Bulk top-up / Invoice / Credit note**, derived from `docType` + the
|
||||||
|
generator `source` (renewal vs bulk-purchase stamp distinct sources) so a bulk SMS top-up reads
|
||||||
|
distinctly from a subscription renewal.
|
||||||
|
4. **Flag stalled onboarding.** Dashboard/MIS surface active modules parked at the same pending step
|
||||||
|
for more than N days (config setting), reusing `milestoneBoard` / `projectsPendingMilestone`.
|
||||||
|
A read-only tile + drill-down; no new write path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `apps/hq-web/src/pages/ClientDetail.tsx` — module block (50/50 sheet, status line, documents),
|
||||||
|
payments tab (outstanding + settled), summary table.
|
||||||
|
- `apps/hq-web/src/pages/client-forms.tsx` — `AssignModuleForm` copy; any shared status control.
|
||||||
|
- `apps/hq-web/src/app.css` — `.split`, `.kv`, `.track`/`.node`, outstanding-panel styles.
|
||||||
|
- `apps/hq-web/src/api.ts` — `Ledger`/`Payment` types.
|
||||||
|
- `apps/hq/src/repos-milestones.ts` — per-module template resolution; go-live→status.
|
||||||
|
- `apps/hq/src/repos-payments.ts` — `clientLedger` outstanding + allocations.
|
||||||
|
- `apps/hq/src/seed.ts` — seed SMS/RTGS/Mobile App template settings.
|
||||||
|
- `apps/hq/src/db.ts` (`migrate()`) or a `scripts/` pass — the milestone migration; `project_milestone`
|
||||||
|
gains a nullable `payment_id`; step→status auto-advance in `setMilestone`.
|
||||||
|
- `apps/hq-web/src/pages/Modules.tsx` — per-module template editor.
|
||||||
|
- `apps/hq/src/repos-documents.ts` — bulk-SMS-purchase generator `source` tag (vs renewal).
|
||||||
|
- `apps/hq-web/src/pages/Dashboard.tsx` / `Mis.tsx` — stalled-onboarding tile + drill-down.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Milestone resolution:** module-specific template wins; falls back to global then code default.
|
||||||
|
- **Migration:** an SMS project with `installed`+`go_live` ticked → new list with Installation +
|
||||||
|
Go-live ticked (dates carried), five new steps unticked, old `setup_done`/`amc_start` gone; a
|
||||||
|
module with no custom template is untouched. Idempotent on re-run.
|
||||||
|
- **Go-live status:** ticking `go_live` sets status `live` + writes audit; unticking leaves it.
|
||||||
|
- **Ledger:** `outstanding` lists exactly the open invoices/proformas with correct
|
||||||
|
`outstandingPaise`; a fully-paid client shows none; each payment's `allocations` match
|
||||||
|
`payment_allocation`. Money totals unchanged (existing tests stay green).
|
||||||
|
- Manual: exercise assign → walk the status line → go-live flips status → record payment against an
|
||||||
|
outstanding invoice, in light and dark.
|
||||||
|
- **Bulk SMS purchase:** from the SMS block, raise a bulk-credit proforma → it appears in the SMS
|
||||||
|
Documents list and in the Outstanding panel → a payment settles it.
|
||||||
|
|
||||||
|
### Responsive / mobile + red-team alignment (explicit requirement)
|
||||||
|
|
||||||
|
The redesign must be **pixel-tight on a full mobile view**, not just desktop. Every new surface
|
||||||
|
is verified at phone (~375px), tablet (~768px) and desktop widths, in **both themes**:
|
||||||
|
|
||||||
|
- **50/50 sheet** collapses cleanly to one column < 720px; labels never clip their values; no row
|
||||||
|
runs together again on any width.
|
||||||
|
- **Status line** scrolls horizontally on narrow screens with no clipped nodes/labels; dots stay
|
||||||
|
aligned to the connector; the current-point ring isn't cut off.
|
||||||
|
- **Outstanding panel, payments table, summary table, per-module Documents** — tables scroll inside
|
||||||
|
their own `overflow-x:auto`; the page body never scrolls sideways; numeric columns stay
|
||||||
|
right-aligned with `tabular-nums`.
|
||||||
|
- **Red-team alignment pass:** a deliberate adversarial review (via the browser QA / design-review
|
||||||
|
tooling) that hunts for misalignment, overflow, overlap, collapsed spacing, and theme-contrast
|
||||||
|
breaks across the three widths — findings fixed before landing, re-checked after. Alignment is a
|
||||||
|
landing gate, not a nice-to-have.
|
||||||
|
|
||||||
|
## Risks / open items
|
||||||
|
|
||||||
|
- **Mobile App step list** is a draft — owner can edit post-seed.
|
||||||
|
- **Summary-table column drop** removes inline date editing from the table; dates are edited on the
|
||||||
|
status line instead. Confirm no report depends on the table columns (they read `client_module`
|
||||||
|
directly, not the table).
|
||||||
|
- **Bulk SMS pricing path:** the bulk-purchase proforma relies on the existing usage/tiered SMS
|
||||||
|
pricing (`resolveUsageRate`); the plan must confirm the SMS module is priced as a usage kind and
|
||||||
|
the quantity input maps to that rate. No new money math — reuses the vetted path.
|
||||||
Loading…
Reference in New Issue