From c58f0fdfc5c2573434e7396d5bef39b6f6a9eb92 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 19:05:20 +0530 Subject: [PATCH] =?UTF-8?q?feat(d20):=20P2=20=E2=80=94=20ticket/branch/ser?= =?UTF-8?q?vice-data=20routes,=20gated=20+=20audited?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq/src/api.ts | 119 ++++++++++++++++++++++++++++++++++- apps/hq/test/tickets.test.ts | 76 +++++++++++++++++++++- 2 files changed, 191 insertions(+), 4 deletions(-) diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 5207bd4..007d2d8 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -13,10 +13,16 @@ import { } from './repos-employees' import { 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 PriceInput, } 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 { cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft, @@ -520,12 +526,121 @@ export function apiRouter( res.status(404).json({ ok: false, error: 'Client module not found' }); return } 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 }) } catch (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 ---------- r.post('/documents', requireAuth, async (req, res) => { diff --git a/apps/hq/test/tickets.test.ts b/apps/hq/test/tickets.test.ts index 33d477b..21a5e65 100644 --- a/apps/hq/test/tickets.test.ts +++ b/apps/hq/test/tickets.test.ts @@ -1,7 +1,9 @@ -// apps/hq/test/tickets.test.ts — D20 P1: ticket desk + branches + module service data. -import { describe, it, expect } from 'vitest' +// apps/hq/test/tickets.test.ts — D20 P1/P2: ticket desk + branches + module service data. +import express from 'express' +import { describe, it, expect, afterAll } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' +import { apiRouter } from '../src/api' import { createClient } from '../src/repos-clients' import { createModule, assignModule, updateClientModule, setModulePassword, revealModulePassword, getClientModule } from '../src/repos-modules' import { createBranch, listBranches, updateBranch } from '../src/repos-branches' @@ -127,3 +129,73 @@ describe('client_module service data (D20)', () => { })).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; 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) + }) +})