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.
258 lines
12 KiB
TypeScript
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')
|
|
}
|