pelagia-portal/PdfService/src/index.ts
Hardik 3edd1ffcc5
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
feat(po): email PO to vendor — PDF link in an Outlook draft (#14)
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>
2026-06-24 02:45:48 +05:30

81 lines
3.4 KiB
TypeScript

/**
* PdfService — renders a Pelagia PO export page to a real PDF via headless
* Chromium (Playwright). Mirrors GstService/EpfoService: a tiny internal-only
* Express proxy the Next app calls.
*
* POST /pdf { url } → application/pdf
*
* The app builds the PO export URL (its own /api/po/:id/export?format=pdf&pdf=1
* carrying a short-lived service token) and posts it here; we navigate to it and
* return the printed PDF bytes. The app then stores the PDF (R2) and emails the
* vendor a download link (issue #14).
*
* Safety: this renders arbitrary URLs, so it is internal-only and guards against
* SSRF by (a) requiring a shared token when PDF_SERVICE_TOKEN is set and
* (b) only navigating to URLs whose origin matches ALLOWED_ORIGIN when set.
*/
import express from "express";
import { chromium, type Browser } from "playwright";
const PORT = Number(process.env.PORT ?? 3005);
const NAV_TIMEOUT_MS = Number(process.env.NAV_TIMEOUT_MS ?? 30_000);
const TOKEN = process.env.PDF_SERVICE_TOKEN ?? "";
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN ?? ""; // e.g. http://localhost:3000
function log(level: string, msg: string, ctx?: Record<string, unknown>) {
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx });
(level === "ERROR" || level === "WARN" ? process.stderr : process.stdout).write(line + "\n");
}
// ── Browser (lazy singleton) ────────────────────────────────────────────────
let _browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (_browser?.isConnected()) return _browser;
_browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
_browser.on("disconnected", () => { _browser = null; });
return _browser;
}
function originOf(url: string): string | null {
try { return new URL(url).origin; } catch { return null; }
}
const app = express();
app.use(express.json({ limit: "1mb" }));
app.get("/health", (_req, res) => {
res.json({ status: "ok", browser: _browser?.isConnected() ? "up" : "idle" });
});
app.post("/pdf", async (req, res) => {
const started = Date.now();
const { url } = (req.body ?? {}) as { url?: string };
if (TOKEN && req.header("x-pdf-token") !== TOKEN) {
return res.status(401).json({ error: "Unauthorized" });
}
const origin = url ? originOf(url) : null;
if (!url || !origin) return res.status(400).json({ error: "A valid url is required" });
if (ALLOWED_ORIGIN && origin !== ALLOWED_ORIGIN) {
return res.status(403).json({ error: "URL origin not allowed" });
}
let context;
try {
const browser = await getBrowser();
context = await browser.newContext();
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: NAV_TIMEOUT_MS });
const pdf = await page.pdf({ format: "A4", printBackground: true, preferCSSPageSize: true });
res.setHeader("Content-Type", "application/pdf");
res.send(pdf);
log("INFO", "Rendered PDF", { origin, ms: Date.now() - started, bytes: pdf.length });
} catch (e) {
log("ERROR", "POST /pdf failed", { err: String(e) });
res.status(502).json({ error: `PDF render failed: ${String(e)}` });
} finally {
await context?.close().catch(() => {});
}
});
app.listen(PORT, () => log("INFO", "PdfService listening", { port: PORT, allowedOrigin: ALLOWED_ORIGIN || "(any)" }));