feat(d20): P1 — ticket desk, client branches, per-service data (schema + repos)

New tables ticket + client_branch (both engines: SCHEMA + pg 002-apex-parity);
client_module gains provider/username/password_enc/details/remark with
migrate guards. repos-tickets: team-visible desk, honest pagination, status
lifecycle stamping closed_on, counts + kind suggestions from history.
repos-branches: minimal audited CRUD. repos-modules: service fields on
ClientModule (hasPassword only — ciphertext never leaves), validated details,
setModulePassword/revealModulePassword mirroring the client DB password
discipline. 5 new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 7d5223908f
commit 9fd4062cfe

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

@ -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'
);
`,
},
]

@ -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<Branch[]> {
const rows = await db.all<BranchRow>(
`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<Branch> {
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<BranchRow>(`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<Branch> {
return db.transaction(async () => {
const beforeRow = await db.get<BranchRow>(`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<BranchRow>(`SELECT * FROM client_branch WHERE id=?`, id))!)
await writeAudit(db, userId, 'update', 'client_branch', id, before, after)
return after
})
}

@ -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<ClientModule> {
@ -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<ClientModule> {
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<string> {
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
}

@ -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<Ticket | null> {
const row = await db.get<TicketRow>(`${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<TicketRow>(
`${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<Record<string, number>> {
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<string[]> {
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<Ticket> {
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<Ticket> {
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<void> {
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')
}

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