ZVVLMBL6LEPGTL7CYJ2UDBB7BMEN263CPJAQBPGVO46TVFAQVGPQC /*** Response interface for file import*/interface FileImportResponse {message: string;data: {importedCount: number;failedCount: number;fileIds: string[];errors?: Array<{fileName: string;error: string;}>;};}
* POST /api/file-import* Bulk import files into the database* Creates records in file, file_dataset, and optionally moth_metadata tables
* POST /api/file-import/chunk* Import a single chunk of files - used by frontend for chunked processing* This endpoint handles authentication refresh and provides better error granularity
if (!body.datasetId) {return c.json({error: "Bad Request",message: "datasetId is required",},400,);}// Validate that dataset exists and user has accessconst datasetResult = await db.select({ id: dataset.id }).from(dataset).where(eq(dataset.id, body.datasetId)).limit(1);if (datasetResult.length === 0) {return c.json({error: "Not Found",message: "Dataset not found",},404,);}// Validate locations and clusters existconst locationIds = [...new Set(body.files.map((f) => f.locationId))];const clusterIds = [...new Set(body.files.map((f) => f.clusterId).filter(Boolean)),];const locationResults = await db.select({ id: location.id }).from(location).where(eq(location.datasetId, body.datasetId));const validLocationIds = new Set(locationResults.map((l) => l.id));if (clusterIds.length > 0) {const clusterResults = await db.select({ id: cluster.id }).from(cluster).where(eq(cluster.datasetId, body.datasetId));const validClusterIds = new Set(clusterResults.map((c) => c.id));// Check if all cluster IDs are validfor (const clusterId of clusterIds) {if (clusterId && !validClusterIds.has(clusterId)) {return c.json({error: "Bad Request",message: `Invalid clusterId: ${clusterId}`,},400,);}}}
// For first chunk only: validate dataset, locations, and clusters existif (body.chunkIndex === 0) {// Validate that dataset exists and user has accessconst datasetResult = await db.select({ id: dataset.id }).from(dataset).where(eq(dataset.id, body.datasetId)).limit(1);
}// Use chunked processing for better fault toleranceconst chunkSize = body.chunkSize || 10; // Default to 10 files per chunkconst createdFiles: string[] = [];const failedFiles: Array<{ fileName: string; error: string }> = [];const processChunk = async (chunk: typeof body.files,chunkIndex: number,) => {const chunkFiles: string[] = [];const now = new Date();
console.log(`Processing chunk ${chunkIndex + 1} with ${chunk.length} files`,);
// Validate locations and clusters existconst locationIds = [...new Set(body.files.map((f) => f.locationId))];const clusterIds = [...new Set(body.files.map((f) => f.clusterId).filter(Boolean)),];
// Process files in chunk sequentially for database consistencyfor (const fileData of chunk) {try {const fileId = nanoid(21);// Validate required fieldsif (!fileData.fileName ||!fileData.xxh64Hash ||!fileData.locationId ||!fileData.timestampLocal ||fileData.duration === undefined ||fileData.sampleRate === undefined) {throw new Error("Missing required file fields: fileName, xxh64Hash, locationId, timestampLocal, duration, sampleRate",);}// Insert into file tableawait db.insert(file).values({id: fileId,fileName: fileData.fileName,path: fileData.path || null,xxh64Hash: fileData.xxh64Hash,locationId: fileData.locationId,timestampLocal: new Date(fileData.timestampLocal),clusterId: fileData.clusterId || null,duration: fileData.duration.toString(),sampleRate: fileData.sampleRate,description: fileData.description || null,upload: fileData.upload || false,maybeSolarNight: fileData.maybeSolarNight ?? null,maybeCivilNight: fileData.maybeCivilNight ?? null,moonPhase: fileData.moonPhase?.toString() || null,createdBy: userId,createdAt: now,lastModified: now,modifiedBy: userId,active: true,});// Insert into file_dataset tableawait db.insert(fileDataset).values({fileId: fileId,datasetId: body.datasetId,createdAt: now,createdBy: userId,lastModified: now,modifiedBy: userId,});
const locationResults = await db.select({ id: location.id }).from(location).where(eq(location.datasetId, body.datasetId));
// Insert moth metadata if providedif (fileData.mothMetadata) {await db.insert(mothMetadata).values({fileId: fileId,timestamp: new Date(fileData.mothMetadata.timestamp),recorderId: fileData.mothMetadata.recorderId || null,gain: fileData.mothMetadata.gain || null,batteryV: fileData.mothMetadata.batteryV?.toString() || null,tempC: fileData.mothMetadata.tempC?.toString() || null,createdAt: now,createdBy: userId,lastModified: now,modifiedBy: userId,active: true,});}
const validLocationIds = new Set(locationResults.map((l) => l.id));
chunkFiles.push(fileId);console.log(`Successfully processed file: ${fileData.fileName}`);} catch (error) {const errorMessage =error instanceof Error ? error.message : String(error);console.error(`Error processing file ${fileData.fileName}:`,errorMessage,);
if (clusterIds.length > 0) {const clusterResults = await db.select({ id: cluster.id }).from(cluster).where(eq(cluster.datasetId, body.datasetId));
// For validation errors, don't retry the entire chunkif (errorMessage.includes("Missing required file fields")) {continue;
// Check if all cluster IDs are validfor (const clusterId of clusterIds) {if (clusterId && !validClusterIds.has(clusterId)) {return c.json({error: "Bad Request",message: `Invalid clusterId: ${clusterId}`,},400,);
return { fileIds: chunkFiles };};// Process files in chunks with retry logicconst result = await processInChunks<(typeof body.files)[0],{ fileIds: string[] }>(body.files,async (chunk: typeof body.files, chunkIndex: number) => {const chunkResult = await processChunk(chunk, chunkIndex);return [chunkResult]; // Return array to match expected type},{chunkSize,maxRetries: 3,baseDelayMs: 1000,maxDelayMs: 10000,exponentialBackoff: true,},(progress: ChunkProgress) => {console.log(`Import progress: ${progress.processedItems}/${progress.totalItems} files processed`,);},);// Collect all successful file IDsresult.results.forEach((chunkResult: { fileIds: string[] }) => {if (chunkResult &&typeof chunkResult === "object" &&"fileIds" in chunkResult) {createdFiles.push(...chunkResult.fileIds);}});// Add failed files from chunk processing errorsresult.errors.forEach((chunkError) => {const fileName =(chunkError.item as { fileName?: string })?.fileName || "unknown";failedFiles.push({fileName,error: chunkError.error.message,});});const response: FileImportResponse = {message: result.success? "Files imported successfully": `Import completed with ${failedFiles.length} failures`,data: {importedCount: createdFiles.length,failedCount: failedFiles.length,fileIds: createdFiles,...(failedFiles.length > 0 && { errors: failedFiles }),},};return c.json(response, result.success ? 200 : 207); // 207 Multi-Status for partial success} catch (error) {console.error("Error importing files:", error);
// Check for auth errorsif (error && typeof error === "object" && "message" in error) {const errorMessage = error.message as string;if (errorMessage.includes("401") ||errorMessage.includes("Unauthorized")) {const errorResponse = handleAuthError("file import");return c.json(errorResponse, 401);
// Check if all location IDs are validfor (const locationId of locationIds) {if (!validLocationIds.has(locationId)) {return c.json({error: "Bad Request",message: `Invalid locationId: ${locationId}`,},400,);}
const errorResponse = standardErrorResponse(error,"Failed to import files",);return c.json(errorResponse, 500);}});/*** POST /api/file-import/chunk* Import a single chunk of files - used by frontend for chunked processing* This endpoint handles authentication refresh and provides better error granularity*/fileImport.post("/chunk", authenticate, async (c) => {try {const jwtPayload = (c as unknown as { jwtPayload: JWTPayload }).jwtPayload;const userId = jwtPayload.sub;const db = createDatabase(c.env);const body = (await c.req.json()) as ChunkedFileImportRequest;// Check user permissionconst hasPermission = await checkUserPermission(db,userId,body.datasetId,"EDIT",);if (!hasPermission) {return c.json({error: "Forbidden",message: "You do not have permission to upload files to this dataset",},403,);}// Validate requestif (!body.files || !Array.isArray(body.files) || body.files.length === 0) {return c.json({error: "Bad Request",message: "Files array is required and must not be empty",},400,);}