merge: backend — quotation-step document link + ticket classification

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 1 day ago
commit 3ac4e2a47d

@ -21,7 +21,7 @@ import {
} from './repos-modules'
import { createBranch, listBranches, updateBranch } from './repos-branches'
import {
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, overdueCount, updateTicket,
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, ticketTypes, overdueCount, updateTicket,
TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus,
} from './repos-tickets'
import {
@ -755,7 +755,10 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
try {
const body = req.body as { key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown; paymentId?: unknown }
const body = req.body as {
key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown
paymentId?: unknown; documentId?: unknown
}
if (typeof body.label === 'string') {
// Add a project-specific milestone.
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
@ -766,7 +769,8 @@ export function apiRouter(
}
const milestones = await setMilestone(db, staffId(res), id, body.key, body.done,
typeof body.doneOn === 'string' ? body.doneOn : undefined,
typeof body.paymentId === 'string' ? body.paymentId : undefined)
typeof body.paymentId === 'string' ? body.paymentId : undefined,
typeof body.documentId === 'string' ? body.documentId : undefined)
res.json({ ok: true, milestones })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@ -845,6 +849,10 @@ export function apiRouter(
})
// ---------- ticket desk (D20 — team-visible workbench) ----------
// The classification list (config over code: the `ticket.types` setting, code fallback).
r.get('/tickets/types', requireAuth, async (_req, res) => {
res.json({ ok: true, types: await ticketTypes(db) })
})
r.get('/tickets', requireAuth, async (req, res) => {
try {
const q = req.query
@ -860,6 +868,7 @@ export function apiRouter(
? { assignedTo: staffId(res) }
: typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}),
...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}),
...(typeof q['type'] === 'string' && q['type'] !== '' ? { type: q['type'] } : {}),
...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}),
...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}),
page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50),

@ -253,9 +253,13 @@ CREATE TABLE IF NOT EXISTS client_branch (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL,
code TEXT, active INTEGER NOT NULL DEFAULT 1
);
-- D22 onboarding tracker: each client_module (a "project") gets a checklist of milestones
-- seeded from the dated project.milestone_template setting on assignment. done_on stamps
-- when a milestone is ticked. Reportable across all projects (who is pending go-live).
-- D22 onboarding tracker: each client_module (a "project") gets a COMPOSED checklist
-- a shared "front" (enquiry quotation approved, from the project.milestone_template.front
-- setting) followed by a per-module "tail" (project.milestone_template:<CODE>, else the
-- global project.milestone_template, else the code fallback) materialized on assignment.
-- done_on stamps when a step is ticked; payment_id links a payment step to the real payment
-- row and document_id links the quotation step to the quotation/proforma that was sent (both
-- added by migrate(), below). Reportable across all projects (who is pending go-live).
CREATE TABLE IF NOT EXISTS project_milestone (
id TEXT PRIMARY KEY, client_module_id TEXT NOT NULL,
key TEXT NOT NULL, label TEXT NOT NULL,
@ -266,6 +270,7 @@ CREATE TABLE IF NOT EXISTS ticket (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, branch_id TEXT,
module_code TEXT, -- 'SMS' | 'RTGS' | ... (free text; module codes by convention)
kind TEXT NOT NULL DEFAULT '', -- MODIFICATION / SOFTWARE ADDONS / ... (config over code: free text + history suggestions)
type TEXT NOT NULL DEFAULT 'service', -- classification key from the ticket.types setting (config over code)
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')),
assigned_to TEXT, -- staff_user.id; NULL = unassigned (team-visible workbench)
@ -521,6 +526,15 @@ function migrate(db: SqliteRaw): void {
if (!milestoneCols.some((c) => c.name === 'payment_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN payment_id TEXT`)
}
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
if (!milestoneCols.some((c) => c.name === 'document_id')) {
db.exec(`ALTER TABLE project_milestone ADD COLUMN document_id TEXT`)
}
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
const ticketCols = db.prepare(`PRAGMA table_info(ticket)`).all() as { name: string }[]
if (!ticketCols.some((c) => c.name === 'type')) {
db.exec(`ALTER TABLE ticket ADD COLUMN type TEXT NOT NULL DEFAULT 'service'`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}

@ -1,7 +1,7 @@
// 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) so onboarding history is not lost.
// (done + done_on + payment_id + document_id) so onboarding history is not lost.
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import { milestoneTemplate } from './repos-milestones'
@ -15,12 +15,14 @@ const KEY_MAP: Record<string, string> = {
setup_done: 'setup_configuration',
}
interface OldRow { key: string; done: number; done_on: string | null; payment_id: string | null }
interface OldRow {
key: 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) forward under their
* from that template, carrying mapped ticks (done/done_on/payment_id/document_id) forward under their
* new key. Unmapped old ticks (e.g. `amc_start`, or `setup_done` for a module whose new
* template has no matching step) are dropped (and counted). Client_modules whose module
* has no per-module template are left completely untouched.
@ -40,7 +42,7 @@ export async function migrateOnboardingTemplates(
`SELECT cm.id FROM client_module cm JOIN module m ON m.id = cm.module_id WHERE m.code=?`, code)
for (const { id: cmId } of cms) {
const oldRows = await db.all<OldRow>(
`SELECT key, done, done_on, payment_id FROM project_milestone WHERE client_module_id=?`, cmId)
`SELECT key, done, done_on, payment_id, document_id FROM project_milestone WHERE client_module_id=?`, cmId)
// Build the carried tick-state keyed by NEW key.
const carriedState = new Map<string, OldRow>()
let alreadyNew = true
@ -65,10 +67,11 @@ export async function migrateOnboardingTemplates(
for (const t of template) {
const c = carriedState.get(t.key)
await db.run(
`INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
`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, sort,
c !== undefined ? 1 : 0, c?.done_on ?? null, c?.payment_id ?? null, c?.document_id ?? null, sort,
)
if (c !== undefined) carried++
sort++

@ -357,4 +357,14 @@ END;
id: '015-milestone-payment-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`,
},
{
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
id: '016-milestone-document-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS document_id text;`,
},
{
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
id: '017-ticket-type',
sql: `ALTER TABLE ticket ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'service';`,
},
]

@ -4,17 +4,26 @@ import { setSetting } from './repos-reminders'
import type { DB } from './db'
/**
* Onboarding tracker (D22). Each client_module (a "project") carries a checklist of
* milestones advance paid, setup, install, go-live, balance, AMC seeded from the
* dated `project.milestone_template` setting. Ticking one stamps the date. Reportable
* across the whole book: "who's pending go-live", "who hasn't paid the advance".
* Onboarding tracker (D22), COMPOSED front + tail model. Each client_module (a "project")
* carries a checklist built as [...front, ...tail]:
* - the **front** is shared by every module enquiry visit quotation sent quotation
* approved from the `project.milestone_template.front` setting (code: FRONT_FALLBACK);
* - the **tail** is module-specific the `project.milestone_template:<CODE>` setting, else
* the global `project.milestone_template`, else the code TAIL_FALLBACK.
* Both are owner-editable settings (config over code); a project may also add its own steps.
* Ticking a step stamps the date, can advance the coarse client_module.status (never
* downgrades), and can carry a link: a payment step to the real `payment` row, the quotation
* step to the `document` (quotation / proforma) that was sent. Reportable across the whole
* book: "who's pending go-live", "who hasn't paid".
*/
export interface Milestone {
key: string; label: string; done: boolean; doneOn: string | null; sort: number; paymentId: string | null
key: string; label: string; done: boolean; doneOn: string | null; sort: number
paymentId: string | null; documentId: string | null
}
interface MilestoneRow {
key: string; label: string; done: number; done_on: string | null; sort: number; payment_id: string | null
key: string; label: string; done: number; done_on: string | null; sort: number
payment_id: string | null; document_id: string | null
}
export interface TemplateEntry { key: string; label: string }
@ -176,29 +185,32 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
export async function listProjectMilestones(db: DB, clientModuleId: string): Promise<Milestone[]> {
const rows = await db.all<MilestoneRow>(
`SELECT key, label, done, done_on, sort, payment_id FROM project_milestone
`SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone
WHERE client_module_id=? ORDER BY sort, label`,
clientModuleId,
)
return rows.map((r) => ({
key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort, paymentId: r.payment_id,
key: r.key, label: r.label, done: r.done === 1, doneOn: r.done_on, sort: r.sort,
paymentId: r.payment_id, documentId: r.document_id,
}))
}
/**
* Tick / untick a milestone. Ticking stamps done_on (given date or today); unticking clears
* it (and any linked payment). A payment-step tick (advance_payment / balance_payment) can
* pass `paymentId` to link a real payment it must belong to the same client as this
* project, and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is
* also given. Audited.
* it (and any linked payment / document). A payment-step tick (`payment_received`) can pass
* `paymentId` to link a real payment it must belong to the same client as this project,
* and its `received_on` mirrors into `done_on` unless an explicit `doneOn` is also given.
* A quotation-step tick (`quotation_sent`) can likewise pass `documentId` to link the
* quotation / proforma that was sent it must belong to the same client too. Audited.
*/
export async function setMilestone(
db: DB, userId: string, clientModuleId: string, key: string, done: boolean,
doneOn?: string, paymentId?: string,
doneOn?: string, paymentId?: string, documentId?: string,
): Promise<Milestone[]> {
return db.transaction(async () => {
const before = await db.get<MilestoneRow>(
`SELECT key, label, done, done_on, sort, payment_id FROM project_milestone WHERE client_module_id=? AND key=?`,
`SELECT key, label, done, done_on, sort, payment_id, document_id FROM project_milestone
WHERE client_module_id=? AND key=?`,
clientModuleId, key,
)
if (before === undefined) throw new Error('Milestone not found on this project')
@ -216,12 +228,22 @@ export async function setMilestone(
linkedPayment = paymentId
if (doneOn === undefined) stamp = pay.received_on // link date wins unless caller forced one
}
let linkedDocument: string | null = null
if (done && documentId !== undefined && documentId !== '') {
const doc = await db.get<{ id: string }>(
`SELECT id FROM document WHERE id=? AND client_id=(SELECT client_id FROM client_module WHERE id=?)`,
documentId, clientModuleId,
)
if (doc === undefined) throw new Error('Document not found for this client')
linkedDocument = documentId
}
await db.run(
`UPDATE project_milestone SET done=?, done_on=?, payment_id=? WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, done ? linkedPayment : null, clientModuleId, key,
`UPDATE project_milestone SET done=?, done_on=?, payment_id=?, document_id=?
WHERE client_module_id=? AND key=?`,
done ? 1 : 0, stamp, done ? linkedPayment : null, done ? linkedDocument : null, clientModuleId, key,
)
await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined,
{ key, done, doneOn: stamp, paymentId: linkedPayment })
{ key, done, doneOn: stamp, paymentId: linkedPayment, documentId: linkedDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key)
return listProjectMilestones(db, clientModuleId)
})

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

@ -5,6 +5,7 @@ import { writeAudit } from './audit'
import type { DB } from './db'
import { createModule } from './repos-modules'
import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders'
import { TICKET_TYPE_FALLBACK } from './repos-tickets'
/** First-boot seed: owner login, company settings placeholders, GST18 tax class. */
@ -86,6 +87,16 @@ export async function seedIfEmpty(db: DB): Promise<void> {
}
if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS)
// Ticket classification list (config over code): the Tickets desk reads `ticket.types`,
// falling back to TICKET_TYPE_FALLBACK in repos-tickets.ts when the row is missing.
// Order here is display order; the first entry is the default for a new ticket.
const seededTicketTypes = (await db.run(
INSERT_SETTING_SQL, 'ticket.types', JSON.stringify(TICKET_TYPE_FALLBACK))).changes
if (seededTicketTypes > 0) {
await writeAudit(db, 'system', 'seed', 'setting', 'ticket.types', undefined,
{ types: TICKET_TYPE_FALLBACK.map((t) => t.key) })
}
// Onboarding milestone template (D22), composed model: a shared "front" (same for every
// module — enquiry through quotation-approved) plus a per-module "tail". Config over code
// — owner-editable; a project can also add its own milestones. Order here is display order.

@ -119,6 +119,79 @@ describe('onboarding template migration', () => {
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)

@ -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,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)
})
})
Loading…
Cancel
Save