feat(pdf): cache the PO PDF per vendor email (reuse copy, refresh 7-day link) #128

Merged
shad0w merged 1 commit from fix/po-pdf-cache into master 2026-06-24 09:47:59 +00:00
4 changed files with 85 additions and 6 deletions

View file

@ -141,6 +141,8 @@ An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once
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.**
**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey``po-pdf/<poId>/<slug>.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
### 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.

View file

@ -2,7 +2,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage";
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
type Result = { ok: true; mailto: string; to: string } | { error: string };
@ -47,13 +47,20 @@ export async function prepareVendorEmail(poId: string): Promise<Result> {
return { error: "PDF emailing is not configured on this environment." };
}
// Render → store → presigned link.
// Render → store → presigned link. The PDF is cached at a deterministic
// per-PO key: if a copy already exists and is at least as new as the PO's last
// change, reuse it and only mint a fresh presigned URL (refreshing the 7-day
// timer). Re-render only when there's no copy yet or the PO changed since.
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");
const key = buildPoPdfKey(poId, `${slug}.pdf`);
const cached = await statObject(key);
const isFresh = cached !== null && cached.lastModified >= po.updatedAt;
if (!isFresh) {
const pdf = await renderPoPdf(poId);
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}` };

View file

@ -59,6 +59,16 @@ export function buildSignatureKey(userId: string, ext: string): string {
return `signatures/${userId}.${ext}`;
}
/**
* Deterministic key for a PO's rendered PDF (one object per PO, no timestamp) so
* "Email to vendor" can reuse a previously rendered copy instead of re-rendering
* and re-uploading on every send (see `prepareVendorEmail`).
*/
export function buildPoPdfKey(poId: string, fileName: string): string {
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `po-pdf/${poId}/${safe}`;
}
/**
* Storage key for a company branding asset (logo or stamp/seal).
* Deterministic per company+type so a re-upload overwrites the previous file.
@ -106,6 +116,36 @@ export async function uploadBuffer(
}
}
/**
* Lightweight existence/metadata check for a stored object (no body transfer).
* Returns `{ lastModified }` when the object exists, or `null` when it doesn't.
* Used to reuse a cached PO PDF when it's still current.
*/
export async function statObject(key: string): Promise<{ lastModified: Date } | null> {
try {
if (isDev) {
const fs = await import("fs/promises");
const path = await import("path");
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
const s = await fs.stat(filePath);
return { lastModified: s.mtime };
}
const { S3Client, HeadObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const r = await s3.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key }));
return { lastModified: r.LastModified ?? new Date(0) };
} catch {
return null; // missing object (404/NotFound) or any access error → treat as absent
}
}
/**
* Fetch a stored file as a Buffer (server-side).
*/

View file

@ -17,13 +17,15 @@ vi.mock("@/lib/storage", async (importOriginal) => {
...actual,
uploadBuffer: vi.fn(async () => {}),
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
statObject: vi.fn(async () => null), // default: no cached object → render
};
});
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 { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service";
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
const PREFIX = "INTTEST_EMAILVENDOR_";
@ -98,6 +100,34 @@ describe("prepareVendorEmail", () => {
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
});
it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
// 1st send: no cached object → render + upload once.
vi.mocked(statObject).mockResolvedValueOnce(null);
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
// 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link.
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) });
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused
expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send
});
it("re-renders when the PO changed since the cached copy", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
// Cached object older than the PO's updatedAt → stale → re-render.
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) });
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
});
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);