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
parent
7d5223908f
commit
9fd4062cfe
@ -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
|
||||
})
|
||||
}
|
||||
@ -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…
Reference in New Issue