Adds an "Email to vendor" button on the PO detail (available once approved,
through CLOSED, and again after payment) that opens an Outlook draft addressed
to the vendor's primary contact with a time-limited PDF download link.
Since mailto: can't attach files, the PDF is rendered and stored, and the draft
carries a link (the approach chosen for this issue):
- PdfService/: new standalone Express + Playwright microservice (GstService/
EpfoService pattern) — POST /pdf { url } renders a page to a real PDF via
headless Chromium. SSRF-guarded (shared token + optional origin allowlist).
- export route: accepts a server-only `svc` token (PDF_SERVICE_TOKEN) so
PdfService can fetch /api/po/[id]/export?format=pdf without a user session;
`pdf=1` drops the print button + window.print() auto-trigger.
- lib/pdf-service.ts renderPoPdf(); prepareVendorEmail() server action renders →
uploads to R2 (po-pdf/…) → presigns a 7-day link → returns a mailto draft.
- po-detail: EmailVendorButton, shown when approved + vendor has a contact email.
- Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN; friendly error if unconfigured.
- No DB model/migration. Tests: prepareVendorEmail (6, PdfService/storage mocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
|
|
function getR2Client() {
|
|
return 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 DEV_BASE_URL = process.env.NEXTAUTH_URL ?? "http://localhost:3000";
|
|
|
|
export async function generateUploadUrl(
|
|
key: string,
|
|
contentType: string,
|
|
expiresIn = 300
|
|
): Promise<string> {
|
|
if (isDev) {
|
|
return `${DEV_BASE_URL}/api/files/dev/${key}`;
|
|
}
|
|
const command = new PutObjectCommand({
|
|
Bucket: process.env.R2_BUCKET_NAME!,
|
|
Key: key,
|
|
ContentType: contentType,
|
|
});
|
|
return getSignedUrl(getR2Client(), command, { expiresIn });
|
|
}
|
|
|
|
export async function generateDownloadUrl(
|
|
key: string,
|
|
expiresIn = 3600
|
|
): Promise<string> {
|
|
if (isDev) {
|
|
return `${DEV_BASE_URL}/api/files/dev/${key}`;
|
|
}
|
|
const command = new GetObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key });
|
|
return getSignedUrl(getR2Client(), command, { expiresIn });
|
|
}
|
|
|
|
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" | "po-pdf",
|
|
ownerId: string,
|
|
fileName: string
|
|
): string {
|
|
const timestamp = Date.now();
|
|
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
return `${type}/${ownerId}/${timestamp}-${safe}`;
|
|
}
|
|
|
|
export function buildSignatureKey(userId: string, ext: string): string {
|
|
return `signatures/${userId}.${ext}`;
|
|
}
|
|
|
|
/**
|
|
* Storage key for a company branding asset (logo or stamp/seal).
|
|
* Deterministic per company+type so a re-upload overwrites the previous file.
|
|
*/
|
|
export function buildCompanyAssetKey(
|
|
companyId: string,
|
|
type: "logo" | "stamp",
|
|
ext: string
|
|
): string {
|
|
return `company-assets/${companyId}/${type}.${ext}`;
|
|
}
|
|
|
|
/**
|
|
* Upload a file buffer directly to storage (server-side).
|
|
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
|
*/
|
|
export async function uploadBuffer(
|
|
key: string,
|
|
buffer: Buffer,
|
|
contentType: string
|
|
): Promise<void> {
|
|
if (isDev) {
|
|
const fs = await import("fs/promises");
|
|
const path = await import("path");
|
|
const dir = path.join(process.cwd(), ".dev-uploads", ...key.split("/").slice(0, -1));
|
|
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(filePath, buffer);
|
|
} else {
|
|
const { S3Client, PutObjectCommand } = 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!,
|
|
},
|
|
});
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: process.env.R2_BUCKET_NAME!,
|
|
Key: key,
|
|
Body: buffer,
|
|
ContentType: contentType,
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a stored file as a Buffer (server-side).
|
|
*/
|
|
export async function downloadBuffer(key: string): Promise<Buffer | 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("/"));
|
|
return await fs.readFile(filePath) as Buffer;
|
|
} else {
|
|
const url = await generateDownloadUrl(key, 60);
|
|
const res = await fetch(url);
|
|
if (!res.ok) return null;
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|