Merge pull request 'feat(po): email PO to vendor � PDF link in an Outlook draft (#14)' (#101) from feat/email-po-to-vendor into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #101
This commit is contained in:
commit
7fe46c2448
14 changed files with 2002 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
83
App/app/(portal)/po/[id]/email-actions.ts
Normal file
83
App/app/(portal)/po/[id]/email-actions.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
||||
|
|
|
|||
40
App/components/po/email-vendor-button.tsx
Normal file
40
App/components/po/email-vendor-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
44
App/lib/pdf-service.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
134
App/tests/integration/email-vendor.test.ts
Normal file
134
App/tests/integration/email-vendor.test.ts
Normal 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
1535
PdfService/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
PdfService/package.json
Normal file
21
PdfService/package.json
Normal 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
81
PdfService/src/index.ts
Normal 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
12
PdfService/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue