From 953cf1dff8d3f6d1841466304b9b8f2d7ff27985 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 04:13:54 +0530 Subject: [PATCH] =?UTF-8?q?feat(modules):=20module=E2=86=92client=20roster?= =?UTF-8?q?,=20notify-all,=20CSV=20export=20(Phase=2010,=20spec=20=C2=A710?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listClientsByModule: reverse lookup over active links, ordered by client name, paginated with a true total; per-row dated price via priceOn + next renewal; footer revenue total spans ALL links (active=0 excluded by design, stated) - GET /modules/:id/clients (paginated) + /clients.csv (streams every row, no cap) - POST /modules/:id/notify (owner/manager): resolves the full roster first, sends via the Gmail path, one email_log + one audit row per attempted recipient, names every unreachable client in the response (warn, never truncate) - Modules page roster panel: table + revenue footer + Notify-all composer + CSV - 8 tests; suite green Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/api.ts | 37 ++++++ apps/hq-web/src/pages/Modules.tsx | 96 ++++++++++++++- apps/hq/src/api.ts | 99 +++++++++++++++- apps/hq/src/repos-modules.ts | 59 ++++++++++ apps/hq/test/module-roster.test.ts | 181 +++++++++++++++++++++++++++++ 5 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 apps/hq/test/module-roster.test.ts diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index f8181a8..aa7f6bc 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -429,6 +429,43 @@ export const putTemplateSettings = ( export const uploadTemplateLogo = (dataUri: string): Promise => apiFetch<{ logo: string }>('/settings/template/logo', { method: 'POST', body: JSON.stringify({ dataUri }) }).then((r) => r.logo) +// ---------- module → client roster (spec §10) ---------- + +export interface ModuleClientRow { + clientId: string; clientName: string; clientCode: string + status: string; kind: Kind; edition: string + nextRenewal: string | null; pricePaise: number | null +} +export interface ModuleClientsPage { + clients: ModuleClientRow[]; total: number; page: number; pageSize: number; totalPricePaise: number +} +export const getModuleClients = (moduleId: string, page = 1): Promise => + apiFetch(`/modules/${moduleId}/clients?page=${page}`) +export const notifyModuleClients = ( + moduleId: string, body: { subject: string; body: string }, +): Promise<{ sent: number; total: number; failed: { client: string; error: string }[] }> => + apiFetch(`/modules/${moduleId}/notify`, { method: 'POST', body: JSON.stringify(body) }) +/** CSV export needs the bearer header an can't carry — blob + transient anchor. */ +export async function downloadModuleClientsCsv(moduleId: string, moduleCode: string): Promise { + const token = localStorage.getItem(TOKEN_KEY) ?? '' + const res = await fetch(`/api/modules/${moduleId}/clients.csv`, { + headers: token !== '' ? { authorization: `Bearer ${token}` } : {}, + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(body.error ?? `CSV failed: HTTP ${res.status}`) + } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `module-${moduleCode}-clients.csv` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + /** The PDF route needs the bearer header, which an