From 25b96e25142e1059aa6c3c75db844d45abdde363 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 13 Jul 2026 18:09:50 +0530 Subject: [PATCH] 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 --- apps/hq/src/api.ts | 13 +++-- apps/hq/test/pdf-download.test.ts | 80 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 apps/hq/test/pdf-download.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index c5a4a82..d606e3e 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -52,7 +52,11 @@ import { sendDocumentEmail, type GmailDeps } from './gmail' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id -export function apiRouter(db: DB, gmailDeps?: Partial): Router { +export function apiRouter( + db: DB, + gmailDeps?: Partial, + renderPdfImpl: (html: string) => Promise = renderPdf, +): Router { const r = Router() const companySettings = (): Record => { // The full letterhead settings map documentHtml reads — company.* identity + template.* text. @@ -304,13 +308,16 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { const client = getClient(db, document.clientId) if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } 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 () => { try { - const pdf = await renderPdf(documentHtml(document, client, company)) + const pdf = await renderPdfImpl(documentHtml(document, client, company)) res.setHeader('Content-Type', 'application/pdf') // docNo contains '/' (QT/26-27-0001) — not legal in a filename. 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) } catch (err) { res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/apps/hq/test/pdf-download.test.ts b/apps/hq/test/pdf-download.test.ts new file mode 100644 index 0000000..bf504ea --- /dev/null +++ b/apps/hq/test/pdf-download.test.ts @@ -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.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 => + (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"') + }) +})