diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 89e9801..53d1d46 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -915,3 +915,19 @@ export interface MisCockpit { } export const getMisCockpit = (): Promise => apiFetch<{ mis: MisCockpit }>('/reports/mis').then((r) => r.mis) + +// D27: audit log viewer (owner-only). +export interface AuditRow { + id: string; at_wall: string; user_id: string; action: string + entity: string; entity_id: string; before_json: string | null; after_json: string | null +} +export interface AuditPage { + rows: AuditRow[]; total: number; page: number; pageSize: number + facets: { entities: string[]; actions: string[] } +} +export const getAudit = (params: Record = {}): Promise => { + const p = new URLSearchParams() + for (const [k, v] of Object.entries(params)) if (v !== '' && v !== undefined) p.set(k, String(v)) + const qs = p.toString() + return apiFetch(`/audit${qs !== '' ? `?${qs}` : ''}`) +} diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 3e46573..2a0b62f 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -26,6 +26,7 @@ import { Tickets } from './pages/Tickets' import { Projects } from './pages/Projects' import { Renewals } from './pages/Renewals' import { Mis } from './pages/Mis' +import { Audit } from './pages/Audit' function App() { return ( @@ -39,6 +40,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 0b41f12..744f922 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,6 +1,6 @@ import { BarChart3, BellRing, CalendarClock, Gauge, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks, - Settings as SettingsIcon, Upload, UserCog, Users, Wrench, + ScrollText, Settings as SettingsIcon, Upload, UserCog, Users, Wrench, type LucideIcon, } from 'lucide-react' @@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true }, { to: '/import', label: 'APEX Import', icon: Upload, ownerOnly: true }, + { to: '/audit', label: 'Audit log', icon: ScrollText, ownerOnly: true }, { to: '/settings', label: 'Settings', icon: SettingsIcon }, ], }, diff --git a/apps/hq-web/src/pages/Audit.tsx b/apps/hq-web/src/pages/Audit.tsx new file mode 100644 index 0000000..c77e55b --- /dev/null +++ b/apps/hq-web/src/pages/Audit.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { Badge, DataTable, EmptyState, ErrorState, PageHeader, Skeleton, Toolbar } from '@sims/ui' +import { getAudit, getEmployees, type AuditPage, type Employee } from '../api' +import { useData } from './Clients' + +const PAGE_SIZE = 50 + +/** Compact before→after diff of the changed keys in an audit row. */ +function diff(before: string | null, after: string | null): string { + const b = before !== null ? JSON.parse(before) as Record : {} + const a = after !== null ? JSON.parse(after) as Record : {} + const keys = [...new Set([...Object.keys(b), ...Object.keys(a)])] + const changed = keys.filter((k) => JSON.stringify(b[k]) !== JSON.stringify(a[k])) + if (changed.length === 0) return before === null && after !== null ? 'created' : after === null ? '' : '—' + return changed.slice(0, 6).map((k) => `${k}: ${JSON.stringify(a[k] ?? b[k])}`).join(', ') +} + +/** Audit log viewer (D27, owner-only): who did what, filterable + paginated. */ +export function Audit() { + const [entity, setEntity] = useState('') + const [action, setAction] = useState('') + const [page, setPage] = useState(1) + const list = useData( + () => getAudit({ entity, action, page, pageSize: PAGE_SIZE }), [entity, action, page]) + const emps = useData<{ employees: Employee[]; total: number } | undefined>(() => getEmployees(), []) + const nameOf = (id: string) => id === 'system' ? 'system' + : (emps.data?.employees.find((e) => e.id === id)?.displayName ?? id.slice(0, 8)) + + const data = list.data + const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1 + + return ( +
+ + + + + + {data !== undefined && {data.total} entries} + + {list.error !== undefined ? + : data === undefined ? + : data.rows.length === 0 ? No audit entries match. : ( + <> + ({ + when: r.at_wall.replace('T', ' ').slice(0, 19), + who: nameOf(r.user_id), + action: {r.action}, + entity: {r.entity}, + change: {diff(r.before_json, r.after_json)}, + }))} + /> + + + + Page {data.page} / {totalPages} + + + + )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index d05fc59..2d7fbc0 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -72,7 +72,7 @@ import { documentHtml, documentHtmlSample } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' import { sendDocumentEmail, sendReminderEmail, type GmailDeps } from './gmail' -import { writeAudit } from './audit' +import { writeAudit, listAuditPage, auditFacets, type AuditFilter } from './audit' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id @@ -1513,6 +1513,22 @@ export function apiRouter( } }) + // ---------- audit log viewer (D27, owner-only) ---------- + r.get('/audit', requireAuth, requireOwner, async (req, res) => { + try { + const q = req.query + const f: AuditFilter = {} + for (const k of ['entity', 'action', 'userId', 'entityId', 'from', 'to'] as const) { + if (typeof q[k] === 'string' && q[k] !== '') f[k] = q[k] as string + } + const page = Number(q['page']); if (Number.isInteger(page) && page >= 1) f.page = page + const pageSize = Number(q['pageSize']); if (Number.isInteger(pageSize) && pageSize >= 1) f.pageSize = pageSize + res.json({ ok: true, ...await listAuditPage(db, f), facets: await auditFacets(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- owner MIS cockpit (D27) ---------- r.get('/reports/mis', requireAuth, requireOwner, async (_req, res) => { try { diff --git a/apps/hq/src/audit.ts b/apps/hq/src/audit.ts index 5fa534b..513d846 100644 --- a/apps/hq/src/audit.ts +++ b/apps/hq/src/audit.ts @@ -32,3 +32,41 @@ export async function writeAudit( export async function listAudit(db: DB, limit = 200): Promise { return await db.all(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`, limit) } + +/** + * Filtered, paginated audit view (D27) — the owner-facing "who did what" browser. + * Honest total (rule 8). Filters compose server-side; newest first (UUIDv7 ids are + * time-ordered). Reading the log writes NO audit row (append-only intent preserved). + */ +export interface AuditFilter { + entity?: string; action?: string; userId?: string; entityId?: string + from?: string; to?: string // ISO date bounds on at_wall + page?: number; pageSize?: number +} +export interface AuditPage { rows: AuditRow[]; total: number; page: number; pageSize: number } + +export async function listAuditPage(db: DB, f: AuditFilter = {}): Promise { + let where = ' WHERE 1=1' + const args: unknown[] = [] + if (f.entity !== undefined && f.entity !== '') { where += ' AND entity=?'; args.push(f.entity) } + if (f.action !== undefined && f.action !== '') { where += ' AND action=?'; args.push(f.action) } + if (f.userId !== undefined && f.userId !== '') { where += ' AND user_id=?'; args.push(f.userId) } + if (f.entityId !== undefined && f.entityId !== '') { where += ' AND entity_id=?'; args.push(f.entityId) } + if (f.from !== undefined && f.from !== '') { where += ' AND at_wall>=?'; args.push(f.from) } + if (f.to !== undefined && f.to !== '') { where += ' AND at_wall<=?'; args.push(f.to + 'T23:59:59.999Z') } + const total = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log${where}`, ...args))!).n + const page = Math.max(1, f.page ?? 1) + const pageSize = Math.min(200, Math.max(1, f.pageSize ?? 50)) + const rows = await db.all( + `SELECT * FROM audit_log${where} ORDER BY id DESC LIMIT ? OFFSET ?`, + ...args, pageSize, (page - 1) * pageSize, + ) + return { rows, total, page, pageSize } +} + +/** Distinct entities + actions present in the log, for the filter dropdowns. */ +export async function auditFacets(db: DB): Promise<{ entities: string[]; actions: string[] }> { + const entities = (await db.all<{ entity: string }>(`SELECT DISTINCT entity FROM audit_log ORDER BY entity`)).map((r) => r.entity) + const actions = (await db.all<{ action: string }>(`SELECT DISTINCT action FROM audit_log ORDER BY action`)).map((r) => r.action) + return { entities, actions } +} diff --git a/apps/hq/test/audit-viewer.test.ts b/apps/hq/test/audit-viewer.test.ts new file mode 100644 index 0000000..1f9d2de --- /dev/null +++ b/apps/hq/test/audit-viewer.test.ts @@ -0,0 +1,28 @@ +// apps/hq/test/audit-viewer.test.ts — D27: filtered/paginated audit viewer. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient, updateClient } from '../src/repos-clients' +import { listAuditPage, auditFacets } from '../src/audit' + +describe('listAuditPage (D27)', () => { + it('filters by entity/action and paginates with an honest total', async () => { + const db = openDb(':memory:'); await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'A', stateCode: '32' }) + for (let i = 0; i < 5; i++) await updateClient(db, 'u1', c.id, { notes: `n${i}` }) + const clientRows = await listAuditPage(db, { entity: 'client' }) + expect(clientRows.total).toBe(6) // 1 create + 5 updates + const updates = await listAuditPage(db, { entity: 'client', action: 'update' }) + expect(updates.total).toBe(5) + // pagination: honest total, page size honored, newest first. + const p1 = await listAuditPage(db, { entity: 'client', pageSize: 2, page: 1 }) + expect(p1.total).toBe(6) + expect(p1.rows).toHaveLength(2) + const p2 = await listAuditPage(db, { entity: 'client', pageSize: 2, page: 2 }) + expect(p2.rows[0]!.id).not.toBe(p1.rows[0]!.id) // no overlap + // facets surface the entities/actions present. + const f = await auditFacets(db) + expect(f.entities).toContain('client') + expect(f.actions).toEqual(expect.arrayContaining(['create', 'update'])) + }) +})