You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-tickets.ts

258 lines
12 KiB
TypeScript

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<TicketType[]> {
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<string> {
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<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
/** 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<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]))
}
/** 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<number> {
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<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
/** 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<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 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<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.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<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')
}