feat(po): email PO to vendor � PDF link in an Outlook draft (#14) #101

Merged
shad0w merged 3 commits from feat/email-po-to-vendor into master 2026-06-23 21:55:11 +00:00
14 changed files with 2002 additions and 12 deletions

View file

@ -56,6 +56,16 @@ GST_SERVICE_URL=http://localhost:3003
# validated against a real session first). Aadhaar is NOT handled here (manual).
EPFO_SERVICE_URL=http://localhost:3004
# ── PDF render microservice ("Email PO to vendor", issue #14) ──
# Run the PdfService/ microservice alongside the app (default localhost:3005).
# Start with: cd PdfService && npm install && npm run dev
# PDF_SERVICE_TOKEN is a shared secret: the app puts it on the export URL and
# PdfService echoes it in the x-pdf-token header. APP_INTERNAL_URL is the base URL
# PdfService can reach the app at (falls back to NEXTAUTH_URL).
PDF_SERVICE_URL=http://localhost:3005
PDF_SERVICE_TOKEN=dev-pdf-token-change-me
# APP_INTERNAL_URL=http://localhost:3000
# ── Forgejo issue reporting (Report Issue button) ─────────────
# Token needs write:issue scope on the repo below.
FORGEJO_URL=https://git.pelagiamarine.com

View file

@ -118,6 +118,12 @@ When Accounts records a payment, a **compulsory payment date** is captured (`Pur
`Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code).
### Email PO to vendor (issue #14)
An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once the PO is approved — `MGR_APPROVED` through `CLOSED`, and again after payment — when the vendor has a primary-contact email) opens an **Outlook draft** addressed to that contact with a **time-limited PDF download link** in the body. The user reviews and sends it.
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
### Inventory (feature-flagged)
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
@ -239,6 +245,9 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
PDF_SERVICE_URL # PdfService microservice for PO→PDF render (defaults to localhost:3005)
PDF_SERVICE_TOKEN # Shared secret for PdfService ↔ export-route auth ("Email to vendor")
APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to NEXTAUTH_URL)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)

View file

@ -0,0 +1,83 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
type Result = { ok: true; mailto: string; to: string } | { error: string };
// PO must be approved (a valid document) before it can be emailed to a vendor;
// available through every later state, incl. once payment is recorded (issue #14).
const EMAILABLE = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
const VIEW_ALL_ROLES = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
const LINK_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
/**
* Build an "email this PO to the vendor" Outlook draft: render the PO to a PDF,
* store it (R2), and return a mailto: addressed to the vendor's primary contact
* with a time-limited download link in the body. The user reviews & sends it.
*/
export async function prepareVendorEmail(poId: string): Promise<Result> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: {
company: { select: { name: true } },
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
},
});
if (!po) return { error: "PO not found" };
const canView = VIEW_ALL_ROLES.includes(session.user.role) || po.submitterId === session.user.id;
if (!canView) return { error: "You cannot access this purchase order." };
if (!EMAILABLE.includes(po.status)) {
return { error: "The PO must be approved before it can be emailed to the vendor." };
}
const to = po.vendor?.contacts?.[0]?.email?.trim();
if (!to) {
return { error: "The vendor has no primary contact email. Add one on the vendor before emailing." };
}
if (!isPdfServiceConfigured()) {
return { error: "PDF emailing is not configured on this environment." };
}
// Render → store → presigned link.
let link: string;
try {
const pdf = await renderPoPdf(poId);
const slug = po.poNumber.replace(/\//g, "-");
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
await uploadBuffer(key, pdf, "application/pdf");
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
} catch (e) {
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };
return { error: "Could not generate the PO PDF." };
}
const company = po.company?.name ?? "Pelagia Marine Services Pvt. Ltd.";
const vendorName = po.vendor?.contacts?.[0]?.name || po.vendor?.name || "Sir/Madam";
const sender = session.user.name ?? "";
const subject = `Purchase Order ${po.poNumber}`;
const body = [
`Dear ${vendorName},`,
"",
`Please find our Purchase Order ${po.poNumber} at the link below:`,
link,
"",
"(The link is valid for 7 days.)",
"",
"Regards,",
sender,
company,
].join("\n");
const mailto = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
return { ok: true, mailto, to };
}

View file

@ -28,7 +28,7 @@ export default async function PoDetailPage({ params }: Props) {
submitter: true,
vessel: true,
account: true,
vendor: true,
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
lineItems: { orderBy: { sortOrder: "asc" } },
documents: { orderBy: { uploadedAt: "desc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
@ -57,9 +57,11 @@ export default async function PoDetailPage({ params }: Props) {
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
: [];
const vendorEmail = po.vendor?.contacts?.[0]?.email ?? null;
return (
<div className="max-w-6xl space-y-6">
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} />
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} vendorEmail={vendorEmail} />
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
</div>
);

View file

@ -51,8 +51,14 @@ async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage
interface Props { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, { params }: Props) {
// PdfService renders this page to a real PDF (issue #14). It authenticates with
// a short, server-only token instead of a user session — read-only, PDF only.
const svcToken = request.nextUrl.searchParams.get("svc");
const isService =
!!svcToken && !!process.env.PDF_SERVICE_TOKEN && svcToken === process.env.PDF_SERVICE_TOKEN;
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!session?.user && !isService) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const po = await db.purchaseOrder.findUnique({
@ -67,10 +73,12 @@ export async function GET(request: NextRequest, { params }: Props) {
});
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
// view_all_pos holders, or submitters when the view-all feature flag is on, may export
// any PO; everyone else only their own.
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (!isService) {
// view_all_pos holders, or submitters when the view-all feature flag is on, may export
// any PO; everyone else only their own. (PdfService bypasses this — read-only, PDF only.)
if (!canViewAllPos(session!.user.role) && po.submitterId !== session!.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
@ -86,6 +94,9 @@ export async function GET(request: NextRequest, { params }: Props) {
}
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
// pdf=1 → render a clean page for PdfService: no on-screen print button and no
// window.print() auto-trigger (Chromium's page.pdf() captures it directly).
const cleanPdf = request.nextUrl.searchParams.get("pdf") === "1";
// ── Company data (from linked company, or fallback to constants) ──────────
const co = po.company;
@ -737,11 +748,11 @@ export async function GET(request: NextRequest, { params }: Props) {
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<div class="no-print" style="margin-bottom:8px">
${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
🖨 Print / Save as PDF
</button>
</div>
</div>`}
<!-- ── Header ─────────────────────────────────────────────────── -->
<div class="header-band">
@ -890,7 +901,7 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<!-- ── Brand bar ─────────────────────────────────────────────── -->
<div class="brand-bar"></div>
<script>window.onload = function() { window.print(); };</script>
${cleanPdf ? "" : `<script>window.onload = function() { window.print(); };</script>`}
</body>
</html>`;

View file

@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
/**
* "Email to vendor" (issue #14): generates the PO PDF, stores it, and opens an
* Outlook (default mail client) draft addressed to the vendor's primary contact
* with a download link in the body. The user reviews and sends it themselves.
*/
export function EmailVendorButton({ poId }: { poId: string }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleClick() {
setPending(true);
setError("");
const result = await prepareVendorEmail(poId);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
// Opens the default mail client (Outlook) with a pre-filled draft.
window.location.href = result.mailto;
}
}
return (
<div className="inline-flex flex-col items-start gap-1">
<button
onClick={handleClick}
disabled={pending}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
>
{pending ? "Preparing…" : "Email to vendor"}
</button>
{error && <span className="text-xs text-danger-700 max-w-xs">{error}</span>}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
import { EmailVendorButton } from "@/components/po/email-vendor-button";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
@ -80,6 +81,8 @@ interface Props {
currentUserId: string;
currentRole: Role;
readOnly?: boolean;
// Vendor's primary contact email — enables the "Email to vendor" action (issue #14).
vendorEmail?: string | null;
}
const ACTION_LABELS: Record<string, string> = {
@ -102,7 +105,7 @@ const ACTION_LABELS: Record<string, string> = {
SUPERSEDED: "Superseded",
};
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name,
description: li.description ?? undefined,
@ -228,6 +231,11 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Export XLSX
</a>
</>)}
{/* Email to vendor — approved (not cancelled) + vendor has a contact email (issue #14) */}
{!readOnly && vendorEmail &&
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (
<EmailVendorButton poId={po.id} />
)}
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
{po.status !== "CANCELLED" &&
["MANAGER", "SUPERUSER"].includes(currentRole) &&

44
App/lib/pdf-service.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* 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());
}

View file

@ -46,7 +46,7 @@ export async function generateDownloadUrl(
export function buildStorageKey(
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
// phases — see Crewing-Implementation-Spec §4.5.
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract" | "po-pdf",
ownerId: string,
fileName: string
): string {

View file

@ -0,0 +1,134 @@
/**
* Integration tests for prepareVendorEmail (issue #14) the "Email to vendor"
* action that renders the PO PDF, stores it, and returns an Outlook mailto draft
* with a download link. PdfService + storage are mocked (no Chromium / R2).
*/
import { vi, describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("@/lib/pdf-service", () => ({
renderPoPdf: vi.fn(async () => Buffer.from("%PDF-1.4 fake")),
isPdfServiceConfigured: vi.fn(() => true),
PdfServiceError: class PdfServiceError extends Error {},
}));
vi.mock("@/lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/storage")>();
return {
...actual,
uploadBuffer: vi.fn(async () => {}),
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
};
});
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
import { isPdfServiceConfigured } from "@/lib/pdf-service";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
const PREFIX = "INTTEST_EMAILVENDOR_";
let techId: string;
let vesselId: string;
let accountId: string;
let vendorWithEmailId: string;
let vendorNoEmailId: string;
const as = (userId: string, role: "TECHNICAL" | "MANAGER") =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
async function makePo(status: string, vendorId: string): Promise<string> {
const po = await db.purchaseOrder.create({
data: {
poNumber: `${PREFIX}${status}-${Date.now()}-${Math.round(Math.random() * 1e6)}`,
title: `${PREFIX}PO`,
status: status as never,
totalAmount: 1000,
currency: "INR",
vesselId,
accountId,
submitterId: techId,
vendorId,
},
});
return po.id;
}
beforeAll(async () => {
const [tech, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedVessel("MV Poseidon"),
getSeedAccount("700201"),
]);
techId = tech.id;
vesselId = vessel.id;
accountId = account.id;
const withEmail = await db.vendor.create({
data: { name: `${PREFIX}WithEmail`, contacts: { create: { name: "Vinod", email: "vinod@vendor.test", isPrimary: true } } },
});
vendorWithEmailId = withEmail.id;
const noEmail = await db.vendor.create({ data: { name: `${PREFIX}NoEmail` } });
vendorNoEmailId = noEmail.id;
});
afterEach(() => {
vi.mocked(isPdfServiceConfigured).mockReturnValue(true);
vi.clearAllMocks();
});
afterAll(async () => {
await db.purchaseOrder.deleteMany({ where: { title: { startsWith: PREFIX } } });
await db.vendorContact.deleteMany({ where: { vendor: { name: { startsWith: PREFIX } } } });
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
describe("prepareVendorEmail", () => {
it("builds a mailto draft to the vendor's primary contact with the PDF link", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
const result = await prepareVendorEmail(poId);
expect("ok" in result && result.ok).toBe(true);
if (!("ok" in result)) throw new Error(result.error);
expect(result.to).toBe("vinod@vendor.test");
expect(result.mailto.startsWith("mailto:vinod%40vendor.test?")).toBe(true);
// Subject is the PO number; body carries the (mocked) download link.
expect(decodeURIComponent(result.mailto)).toContain("Purchase Order");
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
});
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
});
it("refuses a PO that is not yet approved (DRAFT)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("DRAFT", vendorWithEmailId);
const result = await prepareVendorEmail(poId);
expect("error" in result).toBe(true);
});
it("errors when the vendor has no primary contact email", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorNoEmailId);
const result = await prepareVendorEmail(poId);
expect("error" in result).toBe(true);
});
it("errors when the PDF service is not configured", async () => {
vi.mocked(isPdfServiceConfigured).mockReturnValue(false);
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
const result = await prepareVendorEmail(poId);
expect("error" in result).toBe(true);
});
it("rejects an unauthenticated caller", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
expect(await prepareVendorEmail(poId)).toEqual({ error: "Unauthorized" });
});
});

1535
PdfService/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
PdfService/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "pdf-service",
"version": "1.0.0",
"description": "Renders a Pelagia PO export page to a real PDF via headless Chromium (Playwright). Mirrors GstService/EpfoService.",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.49.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

81
PdfService/src/index.ts Normal file
View file

@ -0,0 +1,81 @@
/**
* 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)" }));

12
PdfService/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}