diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 7a40344..8bbc382 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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//.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. diff --git a/App/app/(portal)/po/[id]/email-actions.ts b/App/app/(portal)/po/[id]/email-actions.ts index 64c5c43..bb3844b 100644 --- a/App/app/(portal)/po/[id]/email-actions.ts +++ b/App/app/(portal)/po/[id]/email-actions.ts @@ -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 { 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}` }; diff --git a/App/lib/storage.ts b/App/lib/storage.ts index 0d6e687..e0e5a04 100644 --- a/App/lib/storage.ts +++ b/App/lib/storage.ts @@ -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). */ diff --git a/App/tests/integration/email-vendor.test.ts b/App/tests/integration/email-vendor.test.ts index 62f8634..25d919f 100644 --- a/App/tests/integration/email-vendor.test.ts +++ b/App/tests/integration/email-vendor.test.ts @@ -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);