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 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; type: 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; 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 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, type: r.type, 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 /** 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 } /** 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.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) } 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])) } /** Count of still-open tickets past the SLA cutoff (opened on/before it) — for the Overdue chip. */ export async function overdueCount(db: DB, cutoff: string): Promise { return (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM ticket WHERE status IN ('open','in_progress','waiting') AND opened_on<=?`, cutoff, ))!.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 /** Classification key from `ticket.types`; omitted = the first configured type ('service'). */ type?: string 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 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, type, 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(), type, (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 /** Classification key from `ticket.types` — must be one of the configured keys. */ type?: 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.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) } 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') }