feat(hq): attachment download filename on GET /documents/:id/pdf?download=1

Serve the document PDF as an attachment with a doc-no-derived filename
("QT/26-27-0001" -> "QT-26-27-0001.pdf", null docNo -> "draft.pdf") when
?download=1 is present; keep the default inline so the composer iframe
preview still works. renderPdf is now injectable into apiRouter so the
header logic is tested without launching Chrome (house DI pattern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent 63e377d649
commit 25b96e2514

@ -52,7 +52,11 @@ import { sendDocumentEmail, type GmailDeps } from './gmail'
const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id
export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router { export function apiRouter(
db: DB,
gmailDeps?: Partial<GmailDeps>,
renderPdfImpl: (html: string) => Promise<Buffer> = renderPdf,
): Router {
const r = Router() const r = Router()
const companySettings = (): Record<string, string> => { const companySettings = (): Record<string, string> => {
// The full letterhead settings map documentHtml reads — company.* identity + template.* text. // The full letterhead settings map documentHtml reads — company.* identity + template.* text.
@ -304,13 +308,16 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
const client = getClient(db, document.clientId) const client = getClient(db, document.clientId)
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
const company = companySettings() const company = companySettings()
// ?download=1 forces a Save-As (attachment); the default stays inline so the
// composer iframe can preview the PDF in place.
const disposition = req.query['download'] === '1' ? 'attachment' : 'inline'
void (async () => { void (async () => {
try { try {
const pdf = await renderPdf(documentHtml(document, client, company)) const pdf = await renderPdfImpl(documentHtml(document, client, company))
res.setHeader('Content-Type', 'application/pdf') res.setHeader('Content-Type', 'application/pdf')
// docNo contains '/' (QT/26-27-0001) — not legal in a filename. // docNo contains '/' (QT/26-27-0001) — not legal in a filename.
const filename = (document.docNo ?? 'draft').replaceAll('/', '-') const filename = (document.docNo ?? 'draft').replaceAll('/', '-')
res.setHeader('Content-Disposition', `inline; filename="${filename}.pdf"`) res.setHeader('Content-Disposition', `${disposition}; filename="${filename}.pdf"`)
res.send(pdf) res.send(pdf)
} catch (err) { } catch (err) {
res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) })

@ -0,0 +1,80 @@
// apps/hq/test/pdf-download.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument } from '../src/repos-documents'
import { apiRouter } from '../src/api'
/**
* GET /api/documents/:id/pdf serves the PDF INLINE by default (so the composer iframe
* preview keeps working). With ?download=1 it switches to an attachment with a filename
* derived from the doc no ("QT/26-27-0001" "QT-26-27-0001.pdf"; a null docNo draft
* "draft.pdf"). renderPdf is injected (a fake %PDF- buffer) so the header logic is
* exercised without launching Chrome the house pattern for PDF-touching tests.
*/
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
// An issued quotation carries a docNo with a '/' (QT/26-27-0001)…
const issued = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
// …a separate, un-issued draft keeps docNo=null.
const draftDoc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const fakePdf = async (): Promise<Buffer> => Buffer.from('%PDF-1.4 fake')
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, undefined, fakePdf))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, base, issued, draftDoc }
}
describe('GET /documents/:id/pdf — download filename (Content-Disposition)', () => {
const ctx = setup()
afterAll(() => ctx.server.close())
const login = async (): Promise<string> =>
(await (await fetch(`${ctx.base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }),
})).json() as any).token
const getPdf = async (path: string, token: string) =>
fetch(`${ctx.base}${path}`, { headers: { authorization: `Bearer ${token}` } })
it('serves inline by default (no ?download) so the composer iframe preview still works', async () => {
const token = await login()
const res = await getPdf(`/documents/${ctx.issued.id}/pdf`, token)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/pdf')
expect(res.headers.get('content-disposition')).toMatch(/^inline/)
// Not an attachment.
expect(res.headers.get('content-disposition')).not.toMatch(/^attachment/)
})
it('serves an attachment with the slugged doc-no filename on ?download=1', async () => {
const token = await login()
const res = await getPdf(`/documents/${ctx.issued.id}/pdf?download=1`, token)
expect(res.status).toBe(200)
const cd = res.headers.get('content-disposition') ?? ''
expect(cd).toMatch(/^attachment/)
// QT/26-27-0001 → QT-26-27-0001.pdf (the '/' is not a legal filename char).
const slug = ctx.issued.docNo!.replaceAll('/', '-')
expect(cd).toContain(`filename="${slug}.pdf"`)
expect(slug).toMatch(/^QT-\d{2}-\d{2}-\d{4}$/)
})
it('names a null-docNo draft "draft.pdf" on download', async () => {
const token = await login()
const res = await getPdf(`/documents/${ctx.draftDoc.id}/pdf?download=1`, token)
expect(res.status).toBe(200)
const cd = res.headers.get('content-disposition') ?? ''
expect(cd).toMatch(/^attachment/)
expect(cd).toContain('filename="draft.pdf"')
})
})
Loading…
Cancel
Save