feat(d27): #8 audit-log viewer (owner-only)

listAuditPage (filters: entity/action/user/entity-id/date; honest total; newest
first) + auditFacets. GET /audit owner-only. New /audit 'Audit log' page: entity/
action filters from server facets, paginated table with a per-row before->after
key diff, actor names mapped via employees. Reading the log writes no audit row.
Test locks filter+pagination. typecheck + web build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 7f6e2ce904
commit 5761544c74

@ -915,3 +915,19 @@ export interface MisCockpit {
} }
export const getMisCockpit = (): Promise<MisCockpit> => export const getMisCockpit = (): Promise<MisCockpit> =>
apiFetch<{ mis: MisCockpit }>('/reports/mis').then((r) => r.mis) 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<string, string | number> = {}): Promise<AuditPage> => {
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<AuditPage>(`/audit${qs !== '' ? `?${qs}` : ''}`)
}

@ -26,6 +26,7 @@ import { Tickets } from './pages/Tickets'
import { Projects } from './pages/Projects' import { Projects } from './pages/Projects'
import { Renewals } from './pages/Renewals' import { Renewals } from './pages/Renewals'
import { Mis } from './pages/Mis' import { Mis } from './pages/Mis'
import { Audit } from './pages/Audit'
function App() { function App() {
return ( return (
@ -39,6 +40,7 @@ function App() {
<Route path="/projects" element={<Projects />} /> <Route path="/projects" element={<Projects />} />
<Route path="/renewals" element={<Renewals />} /> <Route path="/renewals" element={<Renewals />} />
<Route path="/mis" element={<Mis />} /> <Route path="/mis" element={<Mis />} />
<Route path="/audit" element={<Audit />} />
<Route path="/tickets" element={<Tickets />} /> <Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} /> <Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />

@ -1,6 +1,6 @@
import { import {
BarChart3, BellRing, CalendarClock, Gauge, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks, 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, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [ items: [
{ to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true }, { to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
{ to: '/import', label: 'APEX Import', icon: Upload, 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 }, { to: '/settings', label: 'Settings', icon: SettingsIcon },
], ],
}, },

@ -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<string, unknown> : {}
const a = after !== null ? JSON.parse(after) as Record<string, unknown> : {}
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<AuditPage | undefined>(
() => 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 (
<div className="wf-page">
<PageHeader title="Audit log" desc="Every change, append-only — who did what, when. Owner-only." />
<Toolbar>
<select className="wf" style={{ maxWidth: 200 }} value={entity} aria-label="Entity"
onChange={(e) => { setEntity(e.target.value); setPage(1) }}>
<option value="">Entity: any</option>
{(data?.facets.entities ?? []).map((x) => <option key={x} value={x}>{x}</option>)}
</select>
<select className="wf" style={{ maxWidth: 200 }} value={action} aria-label="Action"
onChange={(e) => { setAction(e.target.value); setPage(1) }}>
<option value="">Action: any</option>
{(data?.facets.actions ?? []).map((x) => <option key={x} value={x}>{x}</option>)}
</select>
<span style={{ flex: 1 }} />
{data !== undefined && <Badge>{data.total} entries</Badge>}
</Toolbar>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: data === undefined ? <Skeleton rows={8} />
: data.rows.length === 0 ? <EmptyState>No audit entries match.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'when', label: 'When' }, { key: 'who', label: 'Who' },
{ key: 'action', label: 'Action' }, { key: 'entity', label: 'Entity' },
{ key: 'change', label: 'Change' },
]}
rows={data.rows.map((r) => ({
when: r.at_wall.replace('T', ' ').slice(0, 19),
who: nameOf(r.user_id),
action: <Badge>{r.action}</Badge>,
entity: <span className="mono" style={{ fontSize: 12 }}>{r.entity}</span>,
change: <span style={{ fontSize: 12.5, opacity: 0.85 }}>{diff(r.before_json, r.after_json)}</span>,
}))}
/>
<Toolbar>
<span style={{ flex: 1 }} />
<button type="button" className="wf" disabled={data.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}> Prev</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data.page} / {totalPages}</span>
<button type="button" className="wf" disabled={data.page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next </button>
</Toolbar>
</>
)}
</div>
)
}

@ -72,7 +72,7 @@ import { documentHtml, documentHtmlSample } from './templates'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { emailStatus } from './repos-email' import { emailStatus } from './repos-email'
import { sendDocumentEmail, sendReminderEmail, type GmailDeps } from './gmail' 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 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) ---------- // ---------- owner MIS cockpit (D27) ----------
r.get('/reports/mis', requireAuth, requireOwner, async (_req, res) => { r.get('/reports/mis', requireAuth, requireOwner, async (_req, res) => {
try { try {

@ -32,3 +32,41 @@ export async function writeAudit(
export async function listAudit(db: DB, limit = 200): Promise<AuditRow[]> { export async function listAudit(db: DB, limit = 200): Promise<AuditRow[]> {
return await db.all<AuditRow>(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`, limit) return await db.all<AuditRow>(`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<AuditPage> {
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<AuditRow>(
`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 }
}

@ -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']))
})
})
Loading…
Cancel
Save