feat(d20): P2 — ticket/branch/service-data routes, gated + audited

GET/POST/PATCH /tickets (chips filters, mine=1, search, honest pagination,
counts + kind suggestions in one payload); client branches GET/POST/PATCH;
PATCH /client-modules/:id gains the managerial-gated portal password (atomic
with the rest of the patch) + POST /client-modules/:id/reveal-password
(audited per reveal). HTTP gating test: staff 403 on password set/reveal,
full ticket + branch round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 9fd4062cfe
commit c58f0fdfc5

@ -13,10 +13,16 @@ import {
} from './repos-employees' } from './repos-employees'
import { import {
assignModule, createModule, getClientModule, getModule, listClientModules, assignModule, createModule, getClientModule, getModule, listClientModules,
listClientsByModule, listModules, listPrices, setPrice, updateClientModule, updateModule, listClientsByModule, listModules, listPrices, revealModulePassword, setModulePassword,
setPrice, updateClientModule, updateModule,
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch,
type PriceInput, type PriceInput,
} from './repos-modules' } from './repos-modules'
import { createBranch, listBranches, updateBranch } from './repos-branches'
import {
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, updateTicket,
TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus,
} from './repos-tickets'
import { import {
cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, cancelDocument, convertDocument, createCreditNote, createDraft, getDocument,
issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft, issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft,
@ -520,12 +526,121 @@ export function apiRouter(
res.status(404).json({ ok: false, error: 'Client module not found' }); return res.status(404).json({ ok: false, error: 'Client module not found' }); return
} }
try { try {
const cm = await updateClientModule(db, staffId(res), id, req.body as ClientModulePatch) // D20: the portal password rides the same PATCH but is its own gated, audited
// write — owner/manager only, encrypted at rest, never in ClientModulePatch.
const { password, ...rest } = req.body as ClientModulePatch & { password?: unknown }
if (password !== undefined) {
if (typeof password !== 'string') throw new Error('password must be a string')
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) {
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
}
// One transaction: a bad field elsewhere must not leave a half-applied password.
const cm = await db.transaction(async () => {
if (typeof password === 'string') {
await setModulePassword(db, staffId(res), id, password, gmail().keyHex)
}
return Object.keys(rest).length > 0
? await updateClientModule(db, staffId(res), id, rest)
: (await getClientModule(db, id))!
})
res.json({ ok: true, clientModule: cm }) res.json({ ok: true, clientModule: cm })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
} }
}) })
r.post('/client-modules/:id/reveal-password', requireAuth, async (req, res) => {
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) {
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
try {
const id = String(req.params['id'] ?? '')
res.json({ ok: true, password: await revealModulePassword(db, viewer.id, id, gmail().keyHex) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- client branches (D20) ----------
r.get('/clients/:id/branches', requireAuth, async (req, res) => {
try {
const id = String(req.params['id'] ?? '')
if ((await getClient(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
res.json({ ok: true, branches: await listBranches(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/clients/:id/branches', requireAuth, async (req, res) => {
try {
const body = req.body as { name?: unknown; code?: unknown }
const branch = await createBranch(db, staffId(res), {
clientId: String(req.params['id'] ?? ''),
name: typeof body.name === 'string' ? body.name : '',
...(typeof body.code === 'string' ? { code: body.code } : {}),
})
res.json({ ok: true, branch })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/branches/:id', requireAuth, async (req, res) => {
try {
const body = req.body as { name?: unknown; code?: unknown; active?: unknown }
const branch = await updateBranch(db, staffId(res), String(req.params['id'] ?? ''), {
...(typeof body.name === 'string' ? { name: body.name } : {}),
...(typeof body.code === 'string' ? { code: body.code } : {}),
...(typeof body.active === 'boolean' ? { active: body.active } : {}),
})
res.json({ ok: true, branch })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- ticket desk (D20 — team-visible workbench) ----------
r.get('/tickets', requireAuth, async (req, res) => {
try {
const q = req.query
const status = typeof q['status'] === 'string' && (TICKET_STATUSES as readonly string[]).includes(q['status'])
? q['status'] as TicketStatus : undefined
const out = await listTickets(db, {
...(status !== undefined ? { status } : {}),
...(typeof q['clientId'] === 'string' && q['clientId'] !== '' ? { clientId: q['clientId'] } : {}),
...(q['mine'] === '1'
? { assignedTo: staffId(res) }
: typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}),
...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}),
...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}),
page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50),
})
res.json({ ok: true, ...out, counts: await ticketCounts(db), kinds: await ticketKinds(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/tickets', requireAuth, async (req, res) => {
try {
res.json({ ok: true, ticket: await createTicket(db, staffId(res), req.body as CreateTicketInput) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/tickets/:id', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
if ((await getTicket(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Ticket not found' }); return
}
try {
res.json({ ok: true, ticket: await updateTicket(db, staffId(res), id, req.body as TicketPatch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- documents ---------- // ---------- documents ----------
r.post('/documents', requireAuth, async (req, res) => { r.post('/documents', requireAuth, async (req, res) => {

@ -1,7 +1,9 @@
// apps/hq/test/tickets.test.ts — D20 P1: ticket desk + branches + module service data. // apps/hq/test/tickets.test.ts — D20 P1/P2: ticket desk + branches + module service data.
import { describe, it, expect } from 'vitest' import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { apiRouter } from '../src/api'
import { createClient } from '../src/repos-clients' import { createClient } from '../src/repos-clients'
import { createModule, assignModule, updateClientModule, setModulePassword, revealModulePassword, getClientModule } from '../src/repos-modules' import { createModule, assignModule, updateClientModule, setModulePassword, revealModulePassword, getClientModule } from '../src/repos-modules'
import { createBranch, listBranches, updateBranch } from '../src/repos-branches' import { createBranch, listBranches, updateBranch } from '../src/repos-branches'
@ -127,3 +129,73 @@ describe('client_module service data (D20)', () => {
})).rejects.toThrow(/label/i) })).rejects.toThrow(/label/i)
}) })
}) })
describe('D20 routes (gating + round-trip)', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
async function httpWorld() {
const { db, c } = await world()
await createStaff(db, { email: 'own@t.in', displayName: 'O', role: 'owner', password: 'owner-pass-1' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0); servers.push(server)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
const tok = async (email: string, password: string) =>
((await (await fetch(`${base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json()) as { token: string }).token
return { db, c, base, tok }
}
const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` })
it('staff can run the ticket desk but NOT touch/reveal module passwords; owner can', async () => {
const { db, c, base, tok } = await httpWorld()
const staffT = await tok('t@t.in', 'password-1')
const ownerT = await tok('own@t.in', 'owner-pass-1')
// Staff: full ticket flow.
const created = await (await fetch(`${base}/tickets`, {
method: 'POST', headers: H(staffT),
body: JSON.stringify({ clientId: c.id, kind: 'MODIFICATION', description: 'from http' }),
})).json() as { ok: boolean; ticket: { id: string } }
expect(created.ok).toBe(true)
const listed = await (await fetch(`${base}/tickets?status=open`, { headers: H(staffT) })).json() as {
ok: boolean; total: number; counts: Record<string, number>; kinds: string[]
}
expect(listed.total).toBe(1)
expect(listed.kinds).toContain('MODIFICATION')
const closed = await (await fetch(`${base}/tickets/${created.ticket.id}`, {
method: 'PATCH', headers: H(staffT), body: JSON.stringify({ status: 'closed' }),
})).json() as { ticket: { status: string; closedOn: string | null } }
expect(closed.ticket.status).toBe('closed')
// Module password: staff 403, owner sets + reveals.
const m = await createModule(db, 'u1', { code: 'RTGS', name: 'RTGS' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const deny = await fetch(`${base}/client-modules/${cm.id}`, {
method: 'PATCH', headers: H(staffT), body: JSON.stringify({ password: 'x' }),
})
expect(deny.status).toBe(403)
const set = await fetch(`${base}/client-modules/${cm.id}`, {
method: 'PATCH', headers: H(ownerT), body: JSON.stringify({ password: 'portal-pw', provider: 'SBI' }),
})
expect(set.status).toBe(200)
const denyReveal = await fetch(`${base}/client-modules/${cm.id}/reveal-password`, {
method: 'POST', headers: H(staffT), body: '{}',
})
expect(denyReveal.status).toBe(403)
const reveal = await (await fetch(`${base}/client-modules/${cm.id}/reveal-password`, {
method: 'POST', headers: H(ownerT), body: '{}',
})).json() as { ok: boolean; password: string }
expect(reveal.password).toBe('portal-pw')
// Branches round-trip.
const b = await (await fetch(`${base}/clients/${c.id}/branches`, {
method: 'POST', headers: H(staffT), body: JSON.stringify({ name: 'HEAD OFFICE', code: '1' }),
})).json() as { ok: boolean; branch: { id: string } }
expect(b.ok).toBe(true)
const branches = await (await fetch(`${base}/clients/${c.id}/branches`, { headers: H(staffT) })).json() as {
branches: unknown[]
}
expect(branches.branches).toHaveLength(1)
})
})

Loading…
Cancel
Save