Files
zeekayandHanzo Dev 2c4319f61e storage: one object store, ours — and the gate that shut it off is gone
`getS3ClientAndConfig` threw "Invalid upload transport" unless
NEXT_PUBLIC_UPLOAD_TRANSPORT was "s3". The deployment does not set that variable
— it configures storage through NEXT_PRIVATE_UPLOAD_* (transport s3, bucket
dataroom, endpoint http://s3:9000, credentials) — so every call through that
helper threw in production while a correctly configured bucket sat beside it.
The two put paths keyed on the same unset variable and fell to `.otherwise()`,
returning { type: null, data: null }: a server-side upload reported success and
stored nothing.

There is one object store now, so nothing chooses a transport. The VERCEL_BLOB
arms are gone from get/put/copy/delete, CSV exports write through putFileServer,
and branding + expired-export deletion go through deleteFile.

Safe to remove rather than migrate: the database has no rows to orphan —
Document, DocumentVersion, Team and User are all 0, so nothing is stored under a
VERCEL_BLOB path.

The cleanup queue now carries teamId, which deleteFile needs to resolve the
storage region, and its item is a `path` rather than a `blobUrl` — the scheduler
was testing `startsWith("https://")` to decide whether to enqueue, which a
storage path never matches.

Verified with npx tsc --noEmit: 100 -> 99. The one fixed is get-file.ts, whose
ts-pattern match is no longer there to be uncallable. Nothing introduced: the
two duplicate.ts files that looked new carry identical error counts before and
after and were never touched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 11:23:32 -07:00

312 lines
7.8 KiB
TypeScript

import { DocumentStorageType } from "@prisma/client";
import { match } from "ts-pattern";
import { newId } from "@/lib/id-helper";
import { getPagesCount } from "@/lib/utils/get-page-number-count";
import type {
MultipartCompleteRequest,
MultipartGetPartUrlsRequest,
MultipartInitiateRequest,
} from "@/lib/zod/schemas/multipart";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "../constants";
/**
* Uploads a file to our object storage.
*
* For S3 uploads:
* - Files larger than 10MB automatically use multipart upload with pre-signed URLs
* - Files are uploaded in 10MB chunks with parallel processing (batches of 3)
* - Provides better performance and reliability for large files
* - Falls back to single upload for smaller files or on multipart failure
*
* @param file - The file to upload
* @param teamId - The team ID for storage configuration
* @param docId - Optional document ID (generated if not provided)
* @returns Upload result with storage type, key/URL, page count, and file size
*/
export const putFile = async ({
file,
teamId,
docId,
}: {
file: File;
teamId: string;
docId?: string;
}): Promise<{
type: DocumentStorageType | null;
data: string | null;
numPages: number | undefined;
fileSize: number | undefined;
}> => {
// One object store — see putFileServer for what the transport match cost.
const { type, data, numPages, fileSize } = await putFileInS3({
file,
teamId,
docId,
});
return { type, data, numPages, fileSize };
};
// Multipart upload threshold: 10MB
const MULTIPART_THRESHOLD = 10 * 1024 * 1024;
const PART_SIZE = 10 * 1024 * 1024; // 10MB chunks
const putFileInS3 = async ({
file,
teamId,
docId,
}: {
file: File;
teamId: string;
docId?: string;
}) => {
if (!docId) {
docId = newId("doc");
}
if (
!SUPPORTED_DOCUMENT_MIME_TYPES.includes(file.type) &&
!file.name.endsWith(".dwg") &&
!file.name.endsWith(".dxf") &&
!file.name.endsWith(".xlsm")
) {
throw new Error(
"Only PDF, Powerpoint, Word, and Excel, ZIP files are supported",
);
}
// Check if file should use multipart upload
if (file.size > MULTIPART_THRESHOLD) {
return await putFileMultipart({ file, teamId, docId });
} else {
return await putFileSingle({ file, teamId, docId });
}
};
// Single file upload (existing logic)
const putFileSingle = async ({
file,
teamId,
docId,
}: {
file: File;
teamId: string;
docId: string;
}) => {
const presignedResponse = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/get-presigned-post-url`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
teamId: teamId,
docId: docId,
}),
},
);
if (!presignedResponse.ok) {
throw new Error(
`Failed to get presigned post url, failed with status code ${presignedResponse.status}`,
);
}
const { url, key, fileName, contentDisposition } =
(await presignedResponse.json()) as {
url: string;
key: string;
fileName: string;
contentDisposition: string;
};
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": file.type,
"Content-Disposition": contentDisposition,
},
body: file,
});
if (!response.ok) {
throw new Error(
`Failed to upload file "${file.name}", failed with status code ${response.status}`,
);
}
let numPages: number = 1;
// get page count for pdf files
if (file.type === "application/pdf") {
const body = await file.arrayBuffer();
numPages = await getPagesCount(body);
}
return {
type: DocumentStorageType.S3_PATH,
data: key,
numPages: numPages,
fileSize: file.size,
};
};
// Multipart file upload for large files
const putFileMultipart = async ({
file,
teamId,
docId,
}: {
file: File;
teamId: string;
docId: string;
}) => {
try {
// Step 1: Initiate multipart upload
const initiateResponse = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "initiate",
fileName: file.name,
contentType: file.type,
teamId: teamId,
docId: docId,
} satisfies MultipartInitiateRequest),
},
);
if (!initiateResponse.ok) {
throw new Error(
`Failed to initiate multipart upload, status: ${initiateResponse.status}`,
);
}
const { uploadId, key, fileName } = await initiateResponse.json();
// Step 2: Get pre-signed URLs for parts
const partUrlsResponse = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "get-part-urls",
fileName: file.name,
contentType: file.type,
teamId: teamId,
docId: docId,
uploadId: uploadId,
fileSize: file.size,
partSize: PART_SIZE,
} satisfies MultipartGetPartUrlsRequest),
},
);
if (!partUrlsResponse.ok) {
throw new Error(
`Failed to get part URLs, status: ${partUrlsResponse.status}`,
);
}
const { urls } = await partUrlsResponse.json();
// Step 3: Upload parts in parallel (batches of 3)
const uploadPart = async ({
partNumber,
url,
}: {
partNumber: number;
url: string;
}) => {
const start = (partNumber - 1) * PART_SIZE;
const end = Math.min(start + PART_SIZE, file.size);
const chunk = file.slice(start, end);
const response = await fetch(url, {
method: "PUT",
body: chunk,
});
if (!response.ok) {
throw new Error(
`Failed to upload part ${partNumber}, status: ${response.status}`,
);
}
const etag = response.headers.get("ETag");
if (!etag) {
throw new Error(`Missing ETag in response for part ${partNumber}`);
}
return { PartNumber: partNumber, ETag: etag };
};
// Upload parts in batches to avoid overwhelming the connection
const batchSize = 5;
const parts: Array<{ PartNumber: number; ETag: string }> = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(uploadPart));
parts.push(...batchResults);
}
// Step 4: Complete multipart upload
const completeResponse = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "complete",
fileName: file.name,
contentType: file.type,
teamId: teamId,
docId: docId,
uploadId: uploadId,
parts: parts,
} satisfies MultipartCompleteRequest),
},
);
if (!completeResponse.ok) {
throw new Error(
`Failed to complete multipart upload, status: ${completeResponse.status}`,
);
}
let numPages: number = 1;
// get page count for pdf files
if (file.type === "application/pdf") {
const body = await file.arrayBuffer();
numPages = await getPagesCount(body);
}
return {
type: DocumentStorageType.S3_PATH,
data: key,
numPages: numPages,
fileSize: file.size,
};
} catch (error) {
console.error("Multipart upload failed:", error);
// Fallback to single upload on error
console.log("Falling back to single upload...");
return await putFileSingle({ file, teamId, docId });
}
};