Adds an "Email to vendor" button on the PO detail (available once approved,
through CLOSED, and again after payment) that opens an Outlook draft addressed
to the vendor's primary contact with a time-limited PDF download link.
Since mailto: can't attach files, the PDF is rendered and stored, and the draft
carries a link (the approach chosen for this issue):
- PdfService/: new standalone Express + Playwright microservice (GstService/
EpfoService pattern) — POST /pdf { url } renders a page to a real PDF via
headless Chromium. SSRF-guarded (shared token + optional origin allowlist).
- export route: accepts a server-only `svc` token (PDF_SERVICE_TOKEN) so
PdfService can fetch /api/po/[id]/export?format=pdf without a user session;
`pdf=1` drops the print button + window.print() auto-trigger.
- lib/pdf-service.ts renderPoPdf(); prepareVendorEmail() server action renders →
uploads to R2 (po-pdf/…) → presigns a 7-day link → returns a mailto draft.
- po-detail: EmailVendorButton, shown when approved + vendor has a contact email.
- Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN; friendly error if unconfigured.
- No DB model/migration. Tests: prepareVendorEmail (6, PdfService/storage mocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
44 lines
1.8 KiB
TypeScript
44 lines
1.8 KiB
TypeScript
/**
|
|
* Client for PdfService (issue #14) — renders a PO's export page to a real PDF.
|
|
*
|
|
* The app's own /api/po/:id/export?format=pdf produces a print-styled HTML page;
|
|
* PdfService (headless Chromium) navigates to it and returns PDF bytes. We pass a
|
|
* short-lived service token so the export route serves the page without a user
|
|
* session. Configured via:
|
|
* PDF_SERVICE_URL — e.g. http://localhost:3005
|
|
* PDF_SERVICE_TOKEN — shared secret echoed by the export route
|
|
* APP_INTERNAL_URL — base URL PdfService can reach the app at (falls back to NEXTAUTH_URL)
|
|
*/
|
|
export class PdfServiceError extends Error {}
|
|
|
|
export function isPdfServiceConfigured(): boolean {
|
|
return !!process.env.PDF_SERVICE_URL && !!process.env.PDF_SERVICE_TOKEN;
|
|
}
|
|
|
|
/** Render a PO to a PDF buffer via PdfService. Throws PdfServiceError on failure. */
|
|
export async function renderPoPdf(poId: string): Promise<Buffer> {
|
|
const serviceUrl = process.env.PDF_SERVICE_URL;
|
|
const token = process.env.PDF_SERVICE_TOKEN;
|
|
if (!serviceUrl || !token) {
|
|
throw new PdfServiceError("PDF service is not configured.");
|
|
}
|
|
|
|
const appBase = (process.env.APP_INTERNAL_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
const exportUrl = `${appBase}/api/po/${poId}/export?format=pdf&pdf=1&svc=${encodeURIComponent(token)}`;
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${serviceUrl.replace(/\/$/, "")}/pdf`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "x-pdf-token": token },
|
|
body: JSON.stringify({ url: exportUrl }),
|
|
});
|
|
} catch (e) {
|
|
throw new PdfServiceError(`PDF service unreachable: ${String(e)}`);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new PdfServiceError(`PDF service returned ${res.status}`);
|
|
}
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|