diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 7ca29c1..316d437 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -106,7 +106,28 @@ CREATE TABLE IF NOT EXISTS client_module ( ('quoted','ordered','installing','installed','trained','live','expired','cancelled')), kind TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', installed_on TEXT, completed_on TEXT, trained_on TEXT, - next_renewal TEXT, active INTEGER NOT NULL DEFAULT 1 + next_renewal TEXT, active INTEGER NOT NULL DEFAULT 1, + -- D20 per-service operational data (APEX parity). password_enc is AES-256-GCM + -- (HQ_SECRET_KEY), reveal-only + audited, never in list payloads — like the + -- client DB password. details is JSON [{label, value}] free-form key/values. + provider TEXT, username TEXT, password_enc TEXT, + details TEXT NOT NULL DEFAULT '[]', remark TEXT +); +CREATE TABLE IF NOT EXISTS client_branch ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL, + code TEXT, active INTEGER NOT NULL DEFAULT 1 +); +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) + 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) + online_offline TEXT, -- 'online' | 'offline' | NULL (APEX parity) + opened_on TEXT NOT NULL, closed_on TEXT, + created_by TEXT NOT NULL, created_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'hq' -- 'apex' rows are the imported history ); CREATE TABLE IF NOT EXISTS tax_class ( class_code TEXT NOT NULL, rate_pct_bp INTEGER NOT NULL, @@ -288,6 +309,16 @@ function migrate(db: SqliteRaw): void { db.exec(`ALTER TABLE stg_client ADD COLUMN ${col} TEXT`) } } + // D20 per-service operational data on client_module (APEX parity). + const cmCols = db.prepare(`PRAGMA table_info(client_module)`).all() as { name: string }[] + for (const col of ['provider', 'username', 'password_enc', 'remark']) { + if (!cmCols.some((c) => c.name === col)) { + db.exec(`ALTER TABLE client_module ADD COLUMN ${col} TEXT`) + } + } + if (!cmCols.some((c) => c.name === 'details')) { + db.exec(`ALTER TABLE client_module ADD COLUMN details TEXT NOT NULL DEFAULT '[]'`) + } rebuildStaffUserRoleCheck(db) rebuildReminderRuleKindCheck(db) } diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index eeb070b..3f73658 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -181,6 +181,32 @@ CREATE TABLE IF NOT EXISTS aws_usage ( updated_at text NOT NULL, UNIQUE (client_id, month) ); +`, + }, + { + id: '002-apex-parity', + sql: ` +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS provider text; +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS username text; +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS password_enc text; +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '[]'; +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS remark text; +CREATE TABLE IF NOT EXISTS client_branch ( + id text PRIMARY KEY, client_id text NOT NULL, name text NOT NULL, + code text, active integer NOT NULL DEFAULT 1 +); +CREATE TABLE IF NOT EXISTS ticket ( + id text PRIMARY KEY, client_id text NOT NULL, branch_id text, + module_code text, + kind text NOT NULL DEFAULT '', + description text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')), + assigned_to text, + online_offline text, + opened_on text NOT NULL, closed_on text, + created_by text NOT NULL, created_at text NOT NULL, + source text NOT NULL DEFAULT 'hq' +); `, }, ] diff --git a/apps/hq/src/repos-branches.ts b/apps/hq/src/repos-branches.ts new file mode 100644 index 0000000..36b98ff --- /dev/null +++ b/apps/hq/src/repos-branches.ts @@ -0,0 +1,66 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +/** Client branches (D20 — APEX ho_branch_list parity): minimal, per-client, audited. */ + +export interface Branch { + id: string; clientId: string; name: string; code: string | null; active: boolean +} + +interface BranchRow { id: string; client_id: string; name: string; code: string | null; active: number } + +const toBranch = (r: BranchRow): Branch => ({ + id: r.id, clientId: r.client_id, name: r.name, code: r.code, active: r.active === 1, +}) + +export async function listBranches(db: DB, clientId: string): Promise { + const rows = await db.all( + `SELECT * FROM client_branch WHERE client_id=? ORDER BY active DESC, name`, clientId, + ) + return rows.map(toBranch) +} + +export async function createBranch( + db: DB, userId: string, input: { clientId: string; name: string; code?: string }, +): Promise { + return db.transaction(async () => { + if (input.name.trim() === '') throw new Error('Branch name is required') + const client = await db.get(`SELECT id FROM client WHERE id=?`, input.clientId) + if (client === undefined) throw new Error('Client not found') + const id = uuidv7() + await db.run( + `INSERT INTO client_branch (id, client_id, name, code, active) VALUES (?, ?, ?, ?, 1)`, + id, input.clientId, input.name.trim(), + input.code !== undefined && input.code.trim() !== '' ? input.code.trim() : null, + ) + const branch = toBranch((await db.get(`SELECT * FROM client_branch WHERE id=?`, id))!) + await writeAudit(db, userId, 'create', 'client_branch', id, undefined, branch) + return branch + }) +} + +export async function updateBranch( + db: DB, userId: string, id: string, patch: { name?: string; code?: string; active?: boolean }, +): Promise { + return db.transaction(async () => { + const beforeRow = await db.get(`SELECT * FROM client_branch WHERE id=?`, id) + if (beforeRow === undefined) throw new Error('Branch not found') + const before = toBranch(beforeRow) + const sets: string[] = [] + const args: unknown[] = [] + if (patch.name !== undefined) { + if (patch.name.trim() === '') throw new Error('Branch name is required') + sets.push('name=?'); args.push(patch.name.trim()) + } + if (patch.code !== undefined) { sets.push('code=?'); args.push(patch.code.trim() === '' ? null : patch.code.trim()) } + if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } + if (sets.length > 0) { + args.push(id) + await db.run(`UPDATE client_branch SET ${sets.join(', ')} WHERE id=?`, ...args) + } + const after = toBranch((await db.get(`SELECT * FROM client_branch WHERE id=?`, id))!) + await writeAudit(db, userId, 'update', 'client_branch', id, before, after) + return after + }) +} diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index 6207b88..ad2de1d 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -1,5 +1,6 @@ import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' +import { decrypt, encrypt } from './crypto' import type { DB } from './db' /** Module catalog + dated price book — plain functions over the handle (D12 portable-repo pattern). */ @@ -162,11 +163,20 @@ const ALL_STATUSES: ClientModuleStatus[] = [ 'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled', ] +/** Free-form labeled value shown on the module's service card (D20 APEX parity). */ +export interface ServiceDetail { label: string; value: string } + export interface ClientModule { id: string; clientId: string; moduleId: string; status: ClientModuleStatus kind: Kind; edition: string installedOn: string | null; completedOn: string | null; trainedOn: string | null nextRenewal: string | null; active: boolean + /** D20 per-service operational data. The portal password is NOT here — it is + * encrypted at rest and readable only via revealModulePassword (audited). */ + provider: string | null; username: string | null + details: ServiceDetail[]; remark: string | null + /** True when an encrypted portal password is stored (UI shows •••• + Reveal). */ + hasPassword: boolean } interface ClientModuleRow { @@ -174,6 +184,8 @@ interface ClientModuleRow { kind: string; edition: string installed_on: string | null; completed_on: string | null; trained_on: string | null next_renewal: string | null; active: number + provider: string | null; username: string | null; password_enc: string | null + details: string; remark: string | null } function toClientModule(r: ClientModuleRow): ClientModule { @@ -182,6 +194,18 @@ function toClientModule(r: ClientModuleRow): ClientModule { status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition, installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on, nextRenewal: r.next_renewal, active: r.active === 1, + provider: r.provider, username: r.username, + details: JSON.parse(r.details) as ServiceDetail[], remark: r.remark, + hasPassword: r.password_enc !== null, // the ciphertext itself never leaves the repo + } +} + +function assertDetails(details: ServiceDetail[]): void { + if (!Array.isArray(details)) throw new Error('details must be an array of { label, value }') + for (const d of details) { + if (typeof d?.label !== 'string' || typeof d?.value !== 'string' || d.label.trim() === '') { + throw new Error('Each detail needs a non-empty label and a string value') + } } } @@ -303,6 +327,10 @@ export interface ClientModulePatch { trainedOn?: string | null; nextRenewal?: string | null; active?: boolean /** D18 WS-G (roster full-edit): kind must be one the module allows; edition free text. */ kind?: Kind; edition?: string + /** D20 service data ('' clears the text fields). The portal password is NOT + * patchable here — setModulePassword is its own gated, audited action. */ + provider?: string; username?: string; remark?: string + details?: ServiceDetail[] } export async function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): Promise { @@ -331,6 +359,16 @@ export async function updateClientModule(db: DB, userId: string, id: string, pat if (patch.trainedOn !== undefined) { sets.push('trained_on=?'); args.push(patch.trainedOn) } if (patch.nextRenewal !== undefined) { sets.push('next_renewal=?'); args.push(patch.nextRenewal) } if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } + const optText = (col: string, v: string | undefined) => { + if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) } + } + optText('provider', patch.provider) + optText('username', patch.username) + optText('remark', patch.remark) + if (patch.details !== undefined) { + assertDetails(patch.details) + sets.push('details=?'); args.push(JSON.stringify(patch.details)) + } if (sets.length > 0) { args.push(id) await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args) @@ -339,3 +377,37 @@ export async function updateClientModule(db: DB, userId: string, id: string, pat await writeAudit(db, userId, 'update', 'client_module', id, before, after) return after } + +/** + * Store (or clear, with '') the service portal password — AES-256-GCM at rest + * (D20, same discipline as the client DB password). Owner/manager-gated at the + * route; the plaintext never appears in audit rows. Empty keyHex fails loudly. + */ +export async function setModulePassword( + db: DB, userId: string, id: string, plain: string, keyHex: string, +): Promise { + return db.transaction(async () => { + const before = await getClientModule(db, id) + if (before === null) throw new Error('Client module not found') + if (plain !== '' && keyHex === '') { + throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a portal password unencrypted`) + } + const enc = plain === '' ? null : encrypt(plain, keyHex) + await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, enc, id) + await writeAudit(db, userId, 'set_module_password', 'client_module', id, undefined, { set: enc !== null }) + return (await getClientModule(db, id))! + }) +} + +/** Decrypt-and-return the stored portal password — every reveal writes an audit row. */ +export async function revealModulePassword(db: DB, userId: string, id: string, keyHex: string): Promise { + const row = await db.get<{ password_enc: string | null }>( + `SELECT password_enc FROM client_module WHERE id=?`, id, + ) + if (row === undefined) throw new Error('Client module not found') + if (row.password_enc === null) throw new Error('No portal password stored for this module') + if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`) + const plain = decrypt(row.password_enc, keyHex) + await writeAudit(db, userId, 'reveal_module_password', 'client_module', id) + return plain +} diff --git a/apps/hq/src/repos-tickets.ts b/apps/hq/src/repos-tickets.ts new file mode 100644 index 0000000..2186b80 --- /dev/null +++ b/apps/hq/src/repos-tickets.ts @@ -0,0 +1,193 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +/** + * Support ticket desk (D20 — APEX "manager work bench" parity). Tickets are + * team-visible: any signed-in user sees every ticket (support staff pick up + * unassigned work), "mine" is a filter, not a gate. Every mutation is audited + * in the same transaction. Imported APEX history carries source='apex'. + */ + +export const TICKET_STATUSES = ['open', 'in_progress', 'waiting', 'closed', 'dropped'] as const +export type TicketStatus = (typeof TICKET_STATUSES)[number] + +export interface Ticket { + id: string; clientId: string; clientName: string + branchId: string | null; branchName: string | null + moduleCode: string | null; kind: string; description: string + status: TicketStatus + assignedTo: string | null; assignedName: string | null + onlineOffline: string | null + openedOn: string; closedOn: string | null + createdBy: string; createdAt: string; source: string +} + +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 + status: string; assigned_to: string | null; assigned_name: string | null + online_offline: string | null + opened_on: string; closed_on: string | null + created_by: string; created_at: string; source: string +} + +const SELECT = ` + SELECT t.*, c.name AS client_name, b.name AS branch_name, u.display_name AS assigned_name + FROM ticket t + JOIN client c ON c.id = t.client_id + LEFT JOIN client_branch b ON b.id = t.branch_id + LEFT JOIN staff_user u ON u.id = t.assigned_to` + +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, + status: r.status as TicketStatus, + assignedTo: r.assigned_to, assignedName: r.assigned_name, + onlineOffline: r.online_offline, + openedOn: r.opened_on, closedOn: r.closed_on, + createdBy: r.created_by, createdAt: r.created_at, source: r.source, + } +} + +export async function getTicket(db: DB, id: string): Promise { + const row = await db.get(`${SELECT} WHERE t.id=?`, id) + return row === undefined ? null : toTicket(row) +} + +export interface ListTicketsOpts { + status?: TicketStatus; clientId?: string; assignedTo?: string; moduleCode?: string + q?: string; page?: number; pageSize?: number +} + +/** Honest pagination (rule 8): returns the true total alongside the page. */ +export async function listTickets( + db: DB, opts: ListTicketsOpts = {}, +): Promise<{ tickets: Ticket[]; total: number; page: number; pageSize: number }> { + let where = ' WHERE 1=1' + const args: unknown[] = [] + if (opts.status !== undefined) { where += ` AND t.status=?`; args.push(opts.status) } + 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.q !== undefined && opts.q !== '') { + where += ` AND (LOWER(t.description) LIKE LOWER(?) OR LOWER(t.kind) LIKE LOWER(?) OR LOWER(c.name) LIKE LOWER(?))` + const like = `%${opts.q}%` + args.push(like, like, like) + } + const total = (await db.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM ticket t JOIN client c ON c.id = t.client_id${where}`, ...args, + ))!.n + const page = Math.max(1, opts.page ?? 1) + const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50)) + const rows = await db.all( + `${SELECT}${where} ORDER BY t.opened_on DESC, t.id DESC LIMIT ? OFFSET ?`, + ...args, pageSize, (page - 1) * pageSize, + ) + return { tickets: rows.map(toTicket), total, page, pageSize } +} + +/** Open-per-status counts for the page chips (whole desk, honest numbers). */ +export async function ticketCounts(db: DB): Promise> { + const rows = await db.all<{ status: string; n: number }>( + `SELECT status, COUNT(*) AS n FROM ticket GROUP BY status`, + ) + return Object.fromEntries(rows.map((r) => [r.status, r.n])) +} + +/** Distinct kinds seen so far — the UI suggestion list (config over code: history, not constants). */ +export async function ticketKinds(db: DB): Promise { + const rows = await db.all<{ kind: string }>( + `SELECT DISTINCT kind FROM ticket WHERE kind <> '' ORDER BY kind`, + ) + return rows.map((r) => r.kind) +} + +export interface CreateTicketInput { + clientId: string; branchId?: string | null; moduleCode?: string | null + kind?: string; description?: string; onlineOffline?: string | null + assignedTo?: string | null; openedOn?: string +} + +export async function createTicket(db: DB, userId: string, input: CreateTicketInput): Promise { + return db.transaction(async () => { + const client = await db.get(`SELECT id FROM client WHERE id=?`, input.clientId) + if (client === undefined) throw new Error('Client not found') + if (input.branchId != null) { + const branch = await db.get( + `SELECT id FROM client_branch WHERE id=? AND client_id=?`, input.branchId, input.clientId, + ) + if (branch === undefined) throw new Error('Branch not found for this client') + } + if (input.assignedTo != null) await assertActiveStaff(db, input.assignedTo) + const id = uuidv7() + const now = new Date().toISOString() + await db.run( + `INSERT INTO ticket (id, client_id, branch_id, module_code, kind, description, status, + assigned_to, online_offline, opened_on, closed_on, created_by, created_at, source) + VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL, ?, ?, 'hq')`, + id, input.clientId, input.branchId ?? null, input.moduleCode ?? null, + (input.kind ?? '').trim(), (input.description ?? '').trim(), + input.assignedTo ?? null, input.onlineOffline ?? null, + input.openedOn ?? now.slice(0, 10), userId, now, + ) + const ticket = (await getTicket(db, id))! + await writeAudit(db, userId, 'create', 'ticket', id, undefined, ticket) + return ticket + }) +} + +export interface TicketPatch { + status?: TicketStatus + /** null clears the assignment. */ + assignedTo?: string | null + kind?: string; description?: string + moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null +} + +export async function updateTicket(db: DB, userId: string, id: string, patch: TicketPatch): Promise { + return db.transaction(async () => { + const before = await getTicket(db, id) + if (before === null) throw new Error('Ticket not found') + if (patch.status !== undefined && !TICKET_STATUSES.includes(patch.status)) { + throw new Error(`Invalid status: ${String(patch.status)}`) + } + if (patch.assignedTo != null) await assertActiveStaff(db, patch.assignedTo) + if (patch.branchId != null) { + const branch = await db.get( + `SELECT id FROM client_branch WHERE id=? AND client_id=?`, patch.branchId, before.clientId, + ) + if (branch === undefined) throw new Error('Branch not found for this client') + } + const sets: string[] = [] + const args: unknown[] = [] + if (patch.status !== undefined) { + sets.push('status=?'); args.push(patch.status) + // Closing stamps the date; reopening clears it (dropped counts as terminal too). + const terminal = patch.status === 'closed' || patch.status === 'dropped' + sets.push('closed_on=?'); args.push(terminal ? new Date().toISOString().slice(0, 10) : null) + } + 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.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) } + if (patch.onlineOffline !== undefined) { sets.push('online_offline=?'); args.push(patch.onlineOffline) } + if (sets.length > 0) { + args.push(id) + await db.run(`UPDATE ticket SET ${sets.join(', ')} WHERE id=?`, ...args) + } + const after = (await getTicket(db, id))! + await writeAudit(db, userId, 'update', 'ticket', id, before, after) + return after + }) +} + +async function assertActiveStaff(db: DB, staffId: string): Promise { + const emp = await db.get<{ active: number }>(`SELECT active FROM staff_user WHERE id=?`, staffId) + if (emp === undefined) throw new Error('Employee not found') + if (emp.active !== 1) throw new Error('Cannot assign an inactive employee') +} diff --git a/apps/hq/test/tickets.test.ts b/apps/hq/test/tickets.test.ts new file mode 100644 index 0000000..33d477b --- /dev/null +++ b/apps/hq/test/tickets.test.ts @@ -0,0 +1,129 @@ +// apps/hq/test/tickets.test.ts — D20 P1: ticket desk + branches + module service data. +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, updateClientModule, setModulePassword, revealModulePassword, getClientModule } from '../src/repos-modules' +import { createBranch, listBranches, updateBranch } from '../src/repos-branches' +import { createTicket, updateTicket, listTickets, ticketCounts, ticketKinds } from '../src/repos-tickets' +import { createStaff } from '../src/auth' + +const KEY = '11'.repeat(32) + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const staff = await createStaff(db, { email: 't@t.in', displayName: 'Tibin', role: 'staff', password: 'password-1' }) + const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) + return { db, c, staffId: staff.id } +} + +describe('client branches (D20)', () => { + it('creates, lists (active first), edits and deactivates — audited', async () => { + const { db, c } = await world() + const b1 = await createBranch(db, 'u1', { clientId: c.id, name: 'HEAD OFFICE', code: '1' }) + await createBranch(db, 'u1', { clientId: c.id, name: 'PALA BRANCH', code: '2' }) + expect((await listBranches(db, c.id)).map((b) => b.name)).toEqual(['HEAD OFFICE', 'PALA BRANCH']) + await updateBranch(db, 'u1', b1.id, { active: false }) + const after = await listBranches(db, c.id) + expect(after.find((b) => b.id === b1.id)!.active).toBe(false) + expect(after[0]!.name).toBe('PALA BRANCH') // active sorts first + const audits = (await db.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM audit_log WHERE entity='client_branch'`, + ))!.n + expect(audits).toBe(3) + await expect(createBranch(db, 'u1', { clientId: c.id, name: ' ' })).rejects.toThrow(/name/i) + }) +}) + +describe('ticket desk (D20)', () => { + it('walks open → assign → in_progress → closed (stamps closed_on), reopen clears it', async () => { + const { db, c, staffId } = await world() + const t = await createTicket(db, 'u1', { + clientId: c.id, moduleCode: 'SMS', kind: 'MODIFICATION', description: 'Loan form change', + }) + expect(t.status).toBe('open') + expect(t.closedOn).toBeNull() + const assigned = await updateTicket(db, 'u1', t.id, { assignedTo: staffId, status: 'in_progress' }) + expect(assigned.assignedName).toBe('Tibin') + const closed = await updateTicket(db, 'u1', t.id, { status: 'closed' }) + expect(closed.closedOn).not.toBeNull() + const reopened = await updateTicket(db, 'u1', t.id, { status: 'open' }) + expect(reopened.closedOn).toBeNull() + await expect(updateTicket(db, 'u1', t.id, { status: 'bogus' as never })).rejects.toThrow(/status/i) + }) + + it('validates client, branch ownership and active assignee', async () => { + const { db, c, staffId } = await world() + const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) + const foreign = await createBranch(db, 'u1', { clientId: other.id, name: 'HO' }) + await expect(createTicket(db, 'u1', { clientId: 'nope' })).rejects.toThrow(/client/i) + await expect(createTicket(db, 'u1', { clientId: c.id, branchId: foreign.id })).rejects.toThrow(/branch/i) + await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, staffId) + await expect(createTicket(db, 'u1', { clientId: c.id, assignedTo: staffId })).rejects.toThrow(/inactive/i) + }) + + it('lists with chips filters, mine filter, search and honest pagination; counts + kind suggestions', async () => { + const { db, c, staffId } = await world() + for (let i = 0; i < 55; i++) { + await createTicket(db, 'u1', { clientId: c.id, kind: 'MODIFICATION', description: `bulk ${i}` }) + } + const mine = await createTicket(db, 'u1', { + clientId: c.id, kind: 'SOFTWARE ADDONS', description: 'RTGS cert', assignedTo: staffId, + }) + await updateTicket(db, 'u1', mine.id, { status: 'waiting' }) + const page1 = await listTickets(db, { page: 1, pageSize: 50 }) + expect(page1.total).toBe(56) + expect(page1.tickets).toHaveLength(50) + const waiting = await listTickets(db, { status: 'waiting' }) + expect(waiting.total).toBe(1) + expect(waiting.tickets[0]!.id).toBe(mine.id) + const assigned = await listTickets(db, { assignedTo: staffId }) + expect(assigned.total).toBe(1) + const search = await listTickets(db, { q: 'rtgs cert' }) + expect(search.total).toBe(1) + const counts = await ticketCounts(db) + expect(counts['open']).toBe(55) + expect(counts['waiting']).toBe(1) + expect(await ticketKinds(db)).toEqual(['MODIFICATION', 'SOFTWARE ADDONS']) + }) +}) + +describe('client_module service data (D20)', () => { + it('patches provider/username/details/remark; portal password encrypts, reveals audited, never in payloads', async () => { + const { db, c } = await world() + 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' }) + const upd = await updateClientModule(db, 'u1', cm.id, { + provider: 'BSNL', username: '20250043', remark: 'DEPO CR DR active', + details: [{ label: 'SMS balance', value: '82,000' }, { label: 'Reseller', value: 'SiMS' }], + }) + expect(upd.provider).toBe('BSNL') + expect(upd.details).toHaveLength(2) + expect(upd.hasPassword).toBe(false) + expect(JSON.stringify(upd)).not.toContain('password_enc') + + await expect(setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', '')).rejects.toThrow(/HQ_SECRET_KEY/) + const withPw = await setModulePassword(db, 'u1', cm.id, 'rfUb9kW0', KEY) + expect(withPw.hasPassword).toBe(true) + const stored = (await db.get<{ password_enc: string }>( + `SELECT password_enc FROM client_module WHERE id=?`, cm.id, + ))! + expect(stored.password_enc).not.toContain('rfUb9kW0') // encrypted at rest + expect(await revealModulePassword(db, 'u1', cm.id, KEY)).toBe('rfUb9kW0') + const reveals = (await db.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM audit_log WHERE action='reveal_module_password'`, + ))!.n + expect(reveals).toBe(1) + // Clearing with '' works and the audit payload never carried the plaintext. + const cleared = await setModulePassword(db, 'u1', cm.id, '', KEY) + expect(cleared.hasPassword).toBe(false) + const auditBlob = (await db.all<{ after_json: string | null }>( + `SELECT after_json FROM audit_log WHERE action='set_module_password'`, + )).map((r) => r.after_json ?? '').join('') + expect(auditBlob).not.toContain('rfUb9kW0') + await expect(updateClientModule(db, 'u1', cm.id, { + details: [{ label: '', value: 'x' }], + })).rejects.toThrow(/label/i) + }) +})