diff --git a/src/app/api/upload/validate/route.ts b/src/app/api/upload/validate/route.ts deleted file mode 100644 index 3b3b0e0..0000000 --- a/src/app/api/upload/validate/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import * as XLSX from "xlsx"; - -export async function POST(req: NextRequest) { - console.log("Validating uploaded file..."); - const formData = await req.formData(); - const file = formData.get("file") as File; - - if (!file) { - return NextResponse.json({ error: "No file provided" }, { status: 400 }); - } - - const filename = file.name.toLowerCase(); - const mimeType = file.type; - - let fileType: "csv" | "xlsx" | "unknown" = "unknown"; - - if ( - filename.endsWith(".csv") || - mimeType === "text/csv" || - mimeType === "application/vnd.ms-excel" - ) { - fileType = "csv"; - } else if ( - filename.endsWith(".xlsx") || - filename.endsWith(".xls") || - mimeType === - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) { - fileType = "xlsx"; - } - - if (fileType === "unknown") { - return NextResponse.json( - { error: "Unsupported file type", file_type: fileType }, - { status: 400 } - ); - } - - let isStandardised = false; - let sheetNames: string[] = []; - - // Handle CSV - if (fileType === "csv") { - const text = await file.text(); - const lines = text.split("\n").map((line) => line.split(",")); - const headers = lines[0].map((h) => h.trim()); - isStandardised = headers.includes("domna_property_id"); - } - - // Handle Excel - if (fileType === "xlsx") { - const arrayBuffer = await file.arrayBuffer(); - - // Only load specific sheet to reduce memory usage - const workbook = XLSX.read(arrayBuffer, { - type: "array", - sheets: ["Standardised Asset List"], - }); - - sheetNames = workbook.SheetNames; - - const worksheet = workbook.Sheets["Standardised Asset List"]; - if (worksheet) { - const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); - const headers = jsonData[0] as string[]; - isStandardised = headers?.includes("domna_property_id"); - } - } - - return NextResponse.json({ - message: isStandardised - ? "Standardised Asset List format detected." - : "Valid file. No standardised format detected.", - isStandardised, - file_type: fileType, - ...(fileType === "xlsx" && isStandardised ? { sheetNames } : {}), - fileFormat: isStandardised ? "domna_asset_list" : null, - }); -} diff --git a/src/app/portfolio/[slug]/components/InputFile.tsx b/src/app/portfolio/[slug]/components/InputFile.tsx index 80ff585..60b0dad 100644 --- a/src/app/portfolio/[slug]/components/InputFile.tsx +++ b/src/app/portfolio/[slug]/components/InputFile.tsx @@ -1,14 +1,15 @@ "use client"; import { Input } from "@/app/shadcn_components/ui/input"; -import { Label } from "@/app/shadcn_components/ui/label"; export function InputFile({ onFileSelect, isValidating, + isValid, }: { onFileSelect: (file: File) => void; isValidating: boolean; + isValid: boolean | null; }) { function handleOnChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -17,11 +18,14 @@ export function InputFile({ const validExtensions = ["csv", "xls", "xlsx"]; const fileExtension = file.name.split(".").pop()?.toLowerCase(); + console.log("Extension: ", fileExtension); + if (!fileExtension || !validExtensions.includes(fileExtension)) { console.error("Unsupported file type"); e.target.value = ""; return; } + console.log("TRIGGER"); onFileSelect(file); } @@ -36,9 +40,16 @@ export function InputFile({ disabled={isValidating} className={`cursor-pointer ${isValidating ? "opacity-50" : ""}`} /> + {isValidating && ( Validating file… )} + + {isValid === false && !isValidating && ( + + File validation failed + + )} ); } diff --git a/src/app/portfolio/[slug]/components/UploadCsvModal.tsx b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx index c7ad608..74e8aa1 100644 --- a/src/app/portfolio/[slug]/components/UploadCsvModal.tsx +++ b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx @@ -28,9 +28,18 @@ import { uploadCsvSchema, UploadCsvFormValues, } from "@/app/portfolio/[slug]/components/FormSchema"; +import * as XLSX from "xlsx"; const NEW_SENTINEL = "__new__"; +const MAX_FILE_SIZE_MB = 20; + +const allowedMimeTypes = [ + "text/csv", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +]; + const selecthousingTypeOptions = [ { label: "Social", value: "Social", disabled: false }, { label: "Private", value: "Private", disabled: false }, @@ -60,6 +69,93 @@ function generateS3Key( return `${userId}/${portfolioId}/${timestamp}/asset_list.${fileType}`; } +export async function validateClientFile(file: File): Promise<{ + isValid: boolean; + error?: string; + file_type?: "csv" | "xlsx"; + isStandardised?: boolean; + sheetNames?: string[]; + fileFormat?: "domna_asset_list" | null; +}> { + const sizeMB = file.size / (1024 * 1024); + if (sizeMB > MAX_FILE_SIZE_MB) { + return { + isValid: false, + error: `File is too large. Max size is ${MAX_FILE_SIZE_MB}MB.`, + }; + } + + const filename = file.name.toLowerCase(); + const mimeType = file.type; + + let fileType: "csv" | "xlsx" | "unknown" = "unknown"; + + if ( + filename.endsWith(".csv") || + mimeType === "text/csv" || + mimeType === "application/vnd.ms-excel" + ) { + fileType = "csv"; + } else if ( + filename.endsWith(".xlsx") || + filename.endsWith(".xls") || + mimeType === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) { + fileType = "xlsx"; + } + + if (fileType === "unknown") { + return { + isValid: false, + error: "Unsupported file type. Only CSV or Excel allowed.", + }; + } + + let isStandardised = false; + let sheetNames: string[] = []; + + if (fileType === "csv") { + const text = await file.text(); + const lines = text.split("\n").map((line) => line.split(",")); + const headers = lines[0].map((h) => h.trim()); + isStandardised = headers.includes("domna_property_id"); + } + + if (fileType === "xlsx") { + const arrayBuffer = await file.arrayBuffer(); + + const workbook = XLSX.read(arrayBuffer, { + type: "array", + sheets: ["Standardised Asset List"], + }); + + sheetNames = workbook.SheetNames; + + const worksheet = workbook.Sheets["Standardised Asset List"]; + if (worksheet) { + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + const headers = jsonData[0] as string[]; + isStandardised = headers?.includes("domna_property_id"); + } + + if (!isStandardised) { + return { + isValid: false, + error: "Excel file is not a valid domna asset list.", + }; + } + } + + return { + isValid: true, + file_type: fileType, + isStandardised, + sheetNames: fileType === "xlsx" && isStandardised ? sheetNames : undefined, + fileFormat: isStandardised ? "domna_asset_list" : null, + }; +} + async function uploadCsvToS3({ presignedUrl, file, @@ -223,6 +319,8 @@ export default function UploadCsvModal({ const [showMeasures, setShowMeasures] = useState(false); const [fileType, setFileType] = useState<"csv" | "xlsx">("csv"); const [fileFormat, setFileFormat] = useState<"domna_asset_list" | null>(null); + const [isValidating, setIsValidating] = useState(false); + const [fileIsValid, setFileIsValid] = useState(null); const scenarioOptions = useMemo( () => @@ -234,36 +332,6 @@ export default function UploadCsvModal({ [scenarios] ); - const { mutate: validateFile, isLoading: isValidating } = useMutation( - async (file: File) => { - const formData = new FormData(); - formData.append("file", file); - const response = await fetch("/api/upload/validate", { - method: "POST", - body: formData, - }); - if (!response.ok) throw new Error("File validation failed"); - return response.json(); - }, - { - onSuccess: (data) => { - if (data.sheetNames) { - setSheetNames(data.sheetNames); - setSelectedSheet(data.sheetNames[0] || ""); - } else { - setSheetNames([]); - setSelectedSheet(""); - } - setFileType(data.file_type); // capture file type - setFileFormat(data.fileFormat || null); - }, - onError: () => { - setSheetNames([]); - setSelectedSheet(""); - }, - } - ); - const onSelectScenario = (opt: { value: string }) => { setSelectedScenario(opt.value); if (opt.value === NEW_SENTINEL) { @@ -355,12 +423,38 @@ export default function UploadCsvModal({ {/* File Upload */}
{ + onFileSelect={async (file) => { + setIsValidating(true); + setFileIsValid(null); + + const result = await validateClientFile(file); + setIsValidating(false); + + if (!result.isValid) { + setFileIsValid(false); + setCsvFile(null); + setSheetNames([]); + setSelectedSheet(""); + return; + } + + setFileIsValid(true); setCsvFile(file); - validateFile(file); + setFileType(result.file_type!); + setFileFormat(result.fileFormat ?? null); + + if (result.sheetNames) { + setSheetNames(result.sheetNames); + setSelectedSheet(result.sheetNames[0] || ""); + } else { + setSheetNames([]); + setSelectedSheet(""); + } }} isValidating={isValidating} + isValid={fileIsValid} /> +