feat(modules): module→client roster, notify-all, CSV export (Phase 10, spec §10)

- 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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 4a1747958e
commit 953cf1dff8

@ -429,6 +429,43 @@ export const putTemplateSettings = (
export const uploadTemplateLogo = (dataUri: string): Promise<string> => export const uploadTemplateLogo = (dataUri: string): Promise<string> =>
apiFetch<{ logo: string }>('/settings/template/logo', { method: 'POST', body: JSON.stringify({ dataUri }) }).then((r) => r.logo) 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<ModuleClientsPage> =>
apiFetch<ModuleClientsPage & { ok: boolean }>(`/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 <a href> can't carry — blob + transient anchor. */
export async function downloadModuleClientsCsv(moduleId: string, moduleCode: string): Promise<void> {
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 <iframe src> can't carry — fetch a blob instead. */ /** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */
export async function fetchPdfBlob(documentId: string): Promise<Blob> { export async function fetchPdfBlob(documentId: string): Promise<Blob> {
const token = localStorage.getItem(TOKEN_KEY) ?? '' const token = localStorage.getItem(TOKEN_KEY) ?? ''

@ -2,7 +2,8 @@ import { useMemo, useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain' import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { import {
addPrice, createModule, getModules, getPrices, patchModule, previewSample, role, addPrice, createModule, downloadModuleClientsCsv, getModuleClients, getModules, getPrices,
notifyModuleClients, patchModule, previewSample, role,
KIND_LABEL, type Kind, type Module, KIND_LABEL, type Kind, type Module,
} from '../api' } from '../api'
import { LivePreview } from '../components/LivePreview' import { LivePreview } from '../components/LivePreview'
@ -53,6 +54,7 @@ export function Modules() {
)} )}
{selected !== undefined && ( {selected !== undefined && (
<> <>
<ModuleClients key={`mc-${selected.id}`} module={selected} />
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} /> <QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} /> <PriceBook module={selected} isOwner={isOwner} />
</> </>
@ -61,6 +63,98 @@ export function Modules() {
) )
} }
/**
* "Who's on this module" roster (spec §10): every ACTIVE client link with what
* it pays and its next renewal, plus Notify-all (owner/manager) and CSV export.
*/
function ModuleClients(props: { module: Module }) {
const managerial = role() === 'owner' || role() === 'manager'
const [page, setPage] = useState(1)
const roster = useData(() => getModuleClients(props.module.id, page), [props.module.id, page])
const [notifying, setNotifying] = useState(false)
const [subject, setSubject] = useState('')
const [bodyText, setBodyText] = useState('')
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<string | undefined>()
const [err, setErr] = useState<string | undefined>()
const doNotify = () => {
setBusy(true); setErr(undefined); setResult(undefined)
notifyModuleClients(props.module.id, { subject, body: bodyText })
.then((out) => {
setResult(`Sent ${out.sent}/${out.total}`
+ (out.failed.length > 0 ? ` — unreached: ${out.failed.map((f) => `${f.client} (${f.error})`).join(', ')}` : ''))
setNotifying(false)
})
.catch((e: Error) => setErr(e.message))
.finally(() => setBusy(false))
}
const data = roster.data
const pages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
return (
<section>
<h2>Clients on this module</h2>
<Toolbar>
{managerial && (
<Button onClick={() => setNotifying((v) => !v)}>
{notifying ? 'Close composer' : `Notify all${data !== undefined ? ` (${data.total})` : ''}`}
</Button>
)}
<Button onClick={() => { downloadModuleClientsCsv(props.module.id, props.module.code).catch((e: Error) => setErr(e.message)) }}>
Export CSV
</Button>
</Toolbar>
{notifying && managerial && (
<div className="wf-form">
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
<Field label="Message">
<textarea rows={4} value={bodyText} onChange={(e) => setBodyText(e.target.value)} />
</Field>
<Button
tone="primary"
onClick={() => { if (!busy && subject.trim() !== '' && bodyText.trim() !== '') doNotify() }}
>
{busy ? 'Sending…' : `Send to every client on ${props.module.code}`}
</Button>
</div>
)}
{result !== undefined && <Notice tone="ok">{result}</Notice>}
{err !== undefined && <Notice tone="err">{err}</Notice>}
{roster.error !== undefined ? <ErrorState message={roster.error} onRetry={roster.reload} />
: data === undefined ? <Skeleton rows={4} />
: data.total === 0 ? <EmptyState>No client is on this module yet.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'client', label: 'Client' }, { key: 'code', label: 'Code', mono: true },
{ key: 'status', label: 'Status' }, { key: 'kindEdition', label: 'Kind · Edition' },
{ key: 'pays', label: 'Pays' }, { key: 'renewal', label: 'Next renewal' },
]}
rows={data.clients.map((c) => ({
client: c.clientName, code: c.clientCode,
status: <Badge tone={c.status === 'live' ? 'ok' : undefined}>{c.status}</Badge>,
kindEdition: `${KIND_LABEL[c.kind]} · ${c.edition}`,
pays: c.pricePaise !== null ? formatINR(c.pricePaise) : '—',
renewal: c.nextRenewal ?? '—',
}))}
/>
<div className="wf-pager">
<span>{data.total} client(s) · total {formatINR(data.totalPricePaise)} at current price book</span>
{pages > 1 && (
<>
<Button onClick={() => { if (page > 1) setPage((p) => p - 1) }}> Prev</Button>
<span>Page {data.page}/{pages}</span>
<Button onClick={() => { if (page < pages) setPage((p) => p + 1) }}>Next </Button>
</>
)}
</div>
</>
)}
</section>
)
}
/** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */ /** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */
function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) { function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const [text, setText] = useState(props.module.quoteContent.join('\n')) const [text, setText] = useState(props.module.quoteContent.join('\n'))

@ -12,7 +12,7 @@ import {
} from './repos-employees' } from './repos-employees'
import { import {
assignModule, createModule, getClientModule, getModule, listClientModules, assignModule, createModule, getClientModule, getModule, listClientModules,
listModules, listPrices, setPrice, updateClientModule, updateModule, listClientsByModule, listModules, listPrices, 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'
@ -54,7 +54,8 @@ import { clientProfitability, duesAging, moduleRevenue, type DateRange } from '.
import { documentHtml, documentHtmlSample } from './templates' 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, type GmailDeps } from './gmail' import { sendDocumentEmail, sendReminderEmail, type GmailDeps } from './gmail'
import { writeAudit } 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
@ -271,6 +272,100 @@ export function apiRouter(
} }
}) })
// ---------- module → client roster (spec §10, D-ROSTER) ----------
r.get('/modules/:id/clients', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getModule(db, id) === null) {
res.status(404).json({ ok: false, error: 'Module not found' }); return
}
const num = (v: unknown): number | undefined =>
typeof v === 'string' && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined
const page = num(req.query['page'])
const pageSize = num(req.query['pageSize'])
res.json({
ok: true,
...listClientsByModule(db, id, {
...(page !== undefined ? { page } : {}), ...(pageSize !== undefined ? { pageSize } : {}),
}),
})
})
// CSV export streams EVERY row — an export never silently caps (rule 8).
r.get('/modules/:id/clients.csv', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
const mod = getModule(db, id)
if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
const esc = (v: string): string => /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v
const lines = ['client_code,client_name,status,kind,edition,price_paise,next_renewal']
for (let page = 1; ; page++) {
const batch = listClientsByModule(db, id, { page, pageSize: 200 })
for (const c of batch.clients) {
lines.push([
esc(c.clientCode), esc(c.clientName), c.status, c.kind, esc(c.edition),
c.pricePaise === null ? '' : String(c.pricePaise), c.nextRenewal ?? '',
].join(','))
}
if (page * batch.pageSize >= batch.total) break
}
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="module-${mod.code}-clients.csv"`)
res.send(lines.join('\n') + '\n')
})
// Notify every client on the module (owner/manager — bulk client email is privileged, A6).
// Ad-hoc human broadcast (A5): subject/body typed by the sender, not dated config.
r.post('/modules/:id/notify', requireAuth, (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
}
const id = String(req.params['id'] ?? '')
const mod = getModule(db, id)
if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
const body = req.body as { subject?: unknown; body?: unknown }
const subject = typeof body.subject === 'string' ? body.subject.trim() : ''
const bodyText = typeof body.body === 'string' ? body.body.trim() : ''
if (subject === '' || bodyText === '') {
res.status(400).json({ ok: false, error: 'subject and body are required' }); return
}
const status = emailStatus(db)
if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
// Resolve the FULL roster (internally paginated) before sending anything.
const targets: { clientId: string; clientName: string; email: string | undefined }[] = []
for (let page = 1; ; page++) {
const batch = listClientsByModule(db, id, { page, pageSize: 200 })
for (const c of batch.clients) {
const client = getClient(db, c.clientId)
targets.push({
clientId: c.clientId, clientName: c.clientName,
email: client?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email,
})
}
if (page * batch.pageSize >= batch.total) break
}
void (async () => {
let sent = 0
const failed: { client: string; error: string }[] = []
for (const t of targets) {
if (t.email === undefined) {
failed.push({ client: t.clientName, error: 'no contact email' })
continue
}
// sendReminderEmail logs every attempt to email_log; one audit row per
// recipient records who broadcast what to whom (rule 6).
const out = await sendReminderEmail(db, gmail(), { to: t.email, subject, bodyText })
writeAudit(db, viewer.id, 'notify', 'client', t.clientId, undefined, {
moduleId: id, subject, status: out.ok ? 'sent' : 'failed',
...(out.ok ? {} : { error: out.error }),
})
if (out.ok) sent += 1
else failed.push({ client: t.clientName, error: out.error })
if (!out.ok && out.error === 'gmail-token-dead') break // account died mid-run: stop, report the rest honestly
}
// Warn, never truncate: every unreached client is named in the response.
res.json({ ok: true, sent, failed, total: targets.length })
})()
})
// ---------- client modules ---------- // ---------- client modules ----------
r.get('/clients/:id/modules', requireAuth, (req, res) => { r.get('/clients/:id/modules', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '') const id = String(req.params['id'] ?? '')

@ -195,6 +195,65 @@ export function listClientModules(db: DB, clientId: string): ClientModule[] {
return rows.map(toClientModule) return rows.map(toClientModule)
} }
// ---------- module → client roster (spec §10, D-ROSTER) ----------
export interface ModuleClientRow {
clientId: string; clientName: string; clientCode: string
status: ClientModuleStatus; kind: Kind; edition: string
nextRenewal: string | null
/** Price-book rate for this link's kind/edition on the roster date; null = no price row. */
pricePaise: number | null
}
export interface ModuleClientsPage {
clients: ModuleClientRow[]
total: number; page: number; pageSize: number
/** Sum of price-book rates across ALL active links (not just this page). */
totalPricePaise: number
}
/**
* The reverse lookup: every client on a module (support/impact + revenue lens).
* ACTIVE links only `active=0` assignments are historical and deliberately
* excluded (stated here, not silently dropped). Paginated with a true total
* (rule 8); price per row via the dated price book on `onDate`.
*/
export function listClientsByModule(
db: DB, moduleId: string,
opts: { page?: number; pageSize?: number; onDate?: string } = {},
): ModuleClientsPage {
const page = Math.max(1, opts.page ?? 1)
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50))
const onDate = opts.onDate ?? new Date().toISOString().slice(0, 10)
const total = (db.prepare(
`SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`,
).get(moduleId) as { n: number }).n
const rows = db.prepare(
`SELECT cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.kind, cm.edition, cm.next_renewal
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id
LIMIT ? OFFSET ?`,
).all(moduleId, pageSize, (page - 1) * pageSize) as {
client_id: string; client_name: string; client_code: string
status: string; kind: string; edition: string; next_renewal: string | null
}[]
const clients = rows.map((r): ModuleClientRow => ({
clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
nextRenewal: r.next_renewal,
pricePaise: priceOn(db, moduleId, r.kind as Kind, r.edition, onDate),
}))
// Revenue total spans ALL active links, not the visible page (the footer figure).
const allLinks = db.prepare(
`SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`,
).all(moduleId) as { kind: string; edition: string }[]
const totalPricePaise = allLinks.reduce(
(sum, l) => sum + (priceOn(db, moduleId, l.kind as Kind, l.edition, onDate) ?? 0), 0)
return { clients, total, page, pageSize, totalPricePaise }
}
export interface AssignModuleInput { export interface AssignModuleInput {
clientId: string; moduleId: string; kind: Kind clientId: string; moduleId: string; kind: Kind
edition?: string; status?: ClientModuleStatus edition?: string; status?: ClientModuleStatus

@ -0,0 +1,181 @@
// apps/hq/test/module-roster.test.ts — Phase 10: module → client roster + notify + CSV (spec §10)
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import {
assignModule, createModule, listClientsByModule, setPrice, updateClientModule,
} from '../src/repos-modules'
import { saveAccount } from '../src/repos-email'
import { encrypt } from '../src/crypto'
import { listAudit } from '../src/audit'
import { apiRouter } from '../src/api'
const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake')
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' })
const mkClient = (name: string, email?: string) => createClient(db, 'u1', {
name, stateCode: '32',
contacts: email !== undefined ? [{ name: 'C', email }] : [],
})
return { db, m, mkClient }
}
describe('listClientsByModule', () => {
it('lists active links with price + renewal, ordered by client name, with a true total', () => {
const { db, m, mkClient } = world()
const a = mkClient('Acme Bank'); const z = mkClient('Zeta CCS'); const b = mkClient('Beta Coop')
const cmA = assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' })
assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' })
const page = listClientsByModule(db, m.id, { onDate: '2026-07-17' })
expect(page.total).toBe(3)
expect(page.clients.map((c) => c.clientName)).toEqual(['Acme Bank', 'Beta Coop', 'Zeta CCS'])
expect(page.clients[0]).toMatchObject({ pricePaise: 9_000_00, nextRenewal: '2026-09-01' })
expect(page.totalPricePaise).toBe(27_000_00) // spans all links
})
it('excludes inactive links (historical assignments) and paginates with a stable total', () => {
const { db, m, mkClient } = world()
for (let i = 0; i < 5; i++) {
assignModule(db, 'u1', { clientId: mkClient(`Bank ${i}`).id, moduleId: m.id, kind: 'yearly' })
}
const dead = assignModule(db, 'u1', { clientId: mkClient('Gone Bank').id, moduleId: m.id, kind: 'yearly' })
db.prepare(`UPDATE client_module SET active=0 WHERE id=?`).run(dead.id)
const p1 = listClientsByModule(db, m.id, { page: 1, pageSize: 2 })
const p3 = listClientsByModule(db, m.id, { page: 3, pageSize: 2 })
expect(p1.total).toBe(5) // Gone Bank excluded
expect(p1.clients).toHaveLength(2)
expect(p3.clients).toHaveLength(1)
expect([...p1.clients, ...p3.clients].some((c) => c.clientName === 'Gone Bank')).toBe(false)
})
it('a module nobody uses returns an honest empty page', () => {
const { db, m } = world()
const page = listClientsByModule(db, m.id)
expect(page).toMatchObject({ total: 0, clients: [], totalPricePaise: 0 })
})
})
// ---------- routes ----------
function appWith(fetchImpl: typeof fetch) {
const { db, m, mkClient } = world()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const withMail = mkClient('Acme Bank', 'ravi@acme.in')
const noMail = mkClient('Beta Coop') // no contact email — must be reported, not skipped silently
assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: noMail.id, moduleId: m.id, kind: 'yearly' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
const server = app.listen(0)
const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, baseUrl, m }
}
const okFetch = (async (url: string) =>
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }),
{ status: 200 })) as typeof fetch
async function login(baseUrl: string, email: string, password: string) {
return (await (await fetch(`${baseUrl}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json() as { token: string }).token
}
describe('module roster routes', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
it('GET /modules/:id/clients returns the paginated roster to any signed-in user', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients`, {
headers: { authorization: `Bearer ${token}` },
})
const json = await res.json() as { ok: boolean; total: number; clients: { clientName: string }[] }
expect(res.status).toBe(200)
expect(json.total).toBe(2)
expect(json.clients.map((c) => c.clientName)).toEqual(['Acme Bank', 'Beta Coop'])
})
it('GET /modules/:id/clients.csv exports every row as an attachment', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients.csv`, {
headers: { authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/csv')
expect(res.headers.get('content-disposition')).toContain('module-CORE-clients.csv')
const body = await res.text()
const rows = body.trim().split('\n')
expect(rows[0]).toBe('client_code,client_name,status,kind,edition,price_paise,next_renewal')
expect(rows).toHaveLength(3) // header + 2 clients
expect(body).toContain('Acme Bank')
expect(body).toContain('Beta Coop')
})
it('POST /modules/:id/notify is owner/manager-only', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ subject: 'Update', body: 'RTGS window changes tonight.' }),
})
expect(res.status).toBe(403)
})
it('notify sends to every reachable client, names the unreachable, and audits per recipient', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ subject: 'Core Banking maintenance', body: 'Window: Sunday 02:00.' }),
})
const json = await res.json() as { ok: boolean; sent: number; total: number; failed: { client: string; error: string }[] }
expect(res.status).toBe(200)
expect(json.sent).toBe(1) // Acme has a contact email
expect(json.total).toBe(2)
expect(json.failed).toEqual([{ client: 'Beta Coop', error: 'no contact email' }])
const log = ctx.db.prepare(`SELECT to_addr, status, subject FROM email_log ORDER BY id DESC LIMIT 1`)
.get() as { to_addr: string; status: string; subject: string }
expect(log).toMatchObject({ to_addr: 'ravi@acme.in', status: 'sent', subject: 'Core Banking maintenance' })
// One audit row per attempted recipient; the unreachable client is named in
// the response (warn-not-truncate) but no send happened, so nothing to audit.
const audits = listAudit(ctx.db).filter((a) => a.action === 'notify')
expect(audits).toHaveLength(1)
expect(JSON.parse(audits[0]!.after_json ?? '{}')).toMatchObject({ status: 'sent', subject: 'Core Banking maintenance' })
})
it('notify validates subject/body and the gmail account before sending anything', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
const bad = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ subject: '', body: '' }),
})
expect(bad.status).toBe(400)
ctx.db.prepare(`DELETE FROM email_account`).run()
const disconnected = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ subject: 'S', body: 'B' }),
})
expect(disconnected.status).toBe(409)
expect(ctx.db.prepare(`SELECT COUNT(*) AS n FROM email_log`).get()).toMatchObject({ n: 0 })
})
})
Loading…
Cancel
Save