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).
|
# validated against a real session first). Aadhaar is NOT handled here (manual).
|
||||||
EPFO_SERVICE_URL=http://localhost:3004
|
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) ─────────────
|
# ── Forgejo issue reporting (Report Issue button) ─────────────
|
||||||
# Token needs write:issue scope on the repo below.
|
# Token needs write:issue scope on the repo below.
|
||||||
FORGEJO_URL=https://git.pelagiamarine.com
|
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).
|
`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 (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.
|
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)
|
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
|
||||||
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
|
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_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_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)
|
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,
|
submitter: true,
|
||||||
vessel: true,
|
vessel: true,
|
||||||
account: true,
|
account: true,
|
||||||
vendor: true,
|
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
documents: { orderBy: { uploadedAt: "desc" } },
|
documents: { orderBy: { uploadedAt: "desc" } },
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
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" } })
|
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const vendorEmail = po.vendor?.contacts?.[0]?.email ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl space-y-6">
|
<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} />}
|
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,14 @@ async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage
|
||||||
interface Props { params: Promise<{ id: string }> }
|
interface Props { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: Props) {
|
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();
|
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 { id } = await params;
|
||||||
const po = await db.purchaseOrder.findUnique({
|
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 });
|
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
|
if (!isService) {
|
||||||
// any PO; everyone else only their own.
|
// view_all_pos holders, or submitters when the view-all feature flag is on, may export
|
||||||
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
|
// any PO; everyone else only their own. (PdfService bypasses this — read-only, PDF only.)
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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
|
// 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";
|
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) ──────────
|
// ── Company data (from linked company, or fallback to constants) ──────────
|
||||||
const co = po.company;
|
const co = po.company;
|
||||||
|
|
@ -737,11 +748,11 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
|
|
||||||
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
${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">
|
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
|
||||||
🖨 Print / Save as PDF
|
🖨 Print / Save as PDF
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>`}
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||||
<div class="header-band">
|
<div class="header-band">
|
||||||
|
|
@ -890,7 +901,7 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
||||||
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||||
<div class="brand-bar"></div>
|
<div class="brand-bar"></div>
|
||||||
|
|
||||||
<script>window.onload = function() { window.print(); };</script>
|
${cleanPdf ? "" : `<script>window.onload = function() { window.print(); };</script>`}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</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 { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||||
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
|
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 { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { generateDownloadUrl } from "@/lib/storage";
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { groupAttachments } from "@/lib/attachments";
|
import { groupAttachments } from "@/lib/attachments";
|
||||||
|
|
@ -80,6 +81,8 @@ interface Props {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
currentRole: Role;
|
currentRole: Role;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
// Vendor's primary contact email — enables the "Email to vendor" action (issue #14).
|
||||||
|
vendorEmail?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
|
@ -102,7 +105,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||||
SUPERSEDED: "Superseded",
|
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) => ({
|
const lineItemsForEditor = po.lineItems.map((li) => ({
|
||||||
name: li.name,
|
name: li.name,
|
||||||
description: li.description ?? undefined,
|
description: li.description ?? undefined,
|
||||||
|
|
@ -228,6 +231,11 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
Export XLSX
|
Export XLSX
|
||||||
</a>
|
</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 */}
|
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
|
||||||
{po.status !== "CANCELLED" &&
|
{po.status !== "CANCELLED" &&
|
||||||
["MANAGER", "SUPERUSER"].includes(currentRole) &&
|
["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(
|
export function buildStorageKey(
|
||||||
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
||||||
// phases — see Crewing-Implementation-Spec §4.5.
|
// 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,
|
ownerId: string,
|
||||||
fileName: string
|
fileName: string
|
||||||
): 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