From 7838ced6c2607252f6bf1b43bd09e4e88f644235 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 21:10:51 +0530 Subject: [PATCH] feat(client-detail): quotation-step document link + ticket classification (backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend half of the Client Detail redesign follow-ups (#7, #6, #4). #7 — quotation step links its document - project_milestone gains a nullable `document_id`, landed on BOTH engines: a PRAGMA-guarded ALTER in db.ts migrate() and pg migration 016. - setMilestone(..., doneOn?, paymentId?, documentId?) validates the document belongs to the same client as the project (mirroring the paymentId check), stores it on tick and clears it on untick; Milestone gains `documentId`. - POST /client-modules/:id/milestones accepts `documentId`. - migrateOnboardingTemplates carries document_id across the template swap so a linked quotation is not lost. #6 — ticket classification - ticket gains `type TEXT NOT NULL DEFAULT 'service'` (db.ts SCHEMA + guarded ALTER, pg migration 017); imported APEX rows fall to the default. - Config over code: the list is the `ticket.types` setting (seeded on first boot) resolved by ticketTypes(), with TICKET_TYPE_FALLBACK in code. - GET /tickets/types; create/update accept + validate `type`; list rows carry it and GET /tickets?type= filters (blank/omitted = every type). #4 — cleanup - Refreshed the stale file comment atop repos-milestones.ts and the project_milestone comment in db.ts: both now describe the composed front + per-module tail model (they still described the old flat template). - Migration tests for the interim keys advance_payment / balance_payment -> payment_received, plus the document-link carry. npm run typecheck clean; npm test 473 passed / 5 skipped (was 453/5). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/api.ts | 15 +- apps/hq/src/db.ts | 20 ++- apps/hq/src/migrate-onboarding-templates.ts | 17 +- apps/hq/src/migrations-pg.ts | 10 ++ apps/hq/src/repos-milestones.ts | 56 ++++-- apps/hq/src/repos-tickets.ts | 62 ++++++- apps/hq/src/seed.ts | 11 ++ .../test/migrate-onboarding-templates.test.ts | 73 ++++++++ apps/hq/test/milestone-document.test.ts | 122 +++++++++++++ apps/hq/test/ticket-types.test.ts | 161 ++++++++++++++++++ 10 files changed, 511 insertions(+), 36 deletions(-) create mode 100644 apps/hq/test/milestone-document.test.ts create mode 100644 apps/hq/test/ticket-types.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 283ff24..c168457 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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), diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 8ba8410..4e9417a 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -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:, 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) } diff --git a/apps/hq/src/migrate-onboarding-templates.ts b/apps/hq/src/migrate-onboarding-templates.ts index 6c8ba27..fd7ab33 100644 --- a/apps/hq/src/migrate-onboarding-templates.ts +++ b/apps/hq/src/migrate-onboarding-templates.ts @@ -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 = { 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:` 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( - `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() 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++ diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 54abbd0..aa2967f 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -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';`, + }, ] diff --git a/apps/hq/src/repos-milestones.ts b/apps/hq/src/repos-milestones.ts index be5303d..49d2556 100644 --- a/apps/hq/src/repos-milestones.ts +++ b/apps/hq/src/repos-milestones.ts @@ -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:` 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 { const rows = await db.all( - `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 { return db.transaction(async () => { const before = await db.get( - `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) }) diff --git a/apps/hq/src/repos-tickets.ts b/apps/hq/src/repos-tickets.ts index a26b4c5..034c97b 100644 --- a/apps/hq/src/repos-tickets.ts +++ b/apps/hq/src/repos-tickets.ts @@ -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 { + 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 { + 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 { 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 { 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) } diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 24f7072..1b5c0e3 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -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 { } 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. diff --git a/apps/hq/test/migrate-onboarding-templates.test.ts b/apps/hq/test/migrate-onboarding-templates.test.ts index 69500b0..0193a99 100644 --- a/apps/hq/test/migrate-onboarding-templates.test.ts +++ b/apps/hq/test/migrate-onboarding-templates.test.ts @@ -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) diff --git a/apps/hq/test/milestone-document.test.ts b/apps/hq/test/milestone-document.test.ts new file mode 100644 index 0000000..978fc83 --- /dev/null +++ b/apps/hq/test/milestone-document.test.ts @@ -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>['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) + }) +}) diff --git a/apps/hq/test/ticket-types.test.ts b/apps/hq/test/ticket-types.test.ts new file mode 100644 index 0000000..944eb2a --- /dev/null +++ b/apps/hq/test/ticket-types.test.ts @@ -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 }> { + 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) + }) +})