Previously every "Email to vendor" click re-rendered the PO via PdfService and re-uploaded to R2 under a timestamped key — wasteful, and it orphaned a new object each time. Now the PDF is stored at a deterministic per-PO key (buildPoPdfKey → po-pdf/<poId>/<slug>.pdf). On each send, statObject() checks for an existing copy: if it exists and is at least as new as the PO's updatedAt, it's reused (no re-render, no re-upload) and only a fresh presigned URL is minted — refreshing the 7-day download timer. It re-renders only when there's no copy yet or the PO changed since the cached one (so an edited PO never emails a stale PDF). - lib/storage.ts: buildPoPdfKey (deterministic) + statObject (HEAD/stat, no body transfer; null when absent). - email-actions.ts: reuse-or-render decision keyed on updatedAt; always re-presign. - Tests: +2 (reuse-on-second-send-only-refreshes-link, re-render-when-changed). email-vendor suite 8 green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
5.4 KiB
TypeScript
168 lines
5.4 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}`;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
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;
|
|
}
|
|
}
|