Adding file validation to client side instead of server

This commit is contained in:
Khalim Conn-Kowlessar 2025-07-21 16:59:23 +01:00
parent f9d72e7ad5
commit ba69dd4ad5
3 changed files with 138 additions and 113 deletions

View file

@ -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,
});
}

View file

@ -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<HTMLInputElement>) {
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 && (
<span className="text-sm text-gray-500 mt-1">Validating file</span>
)}
{isValid === false && !isValidating && (
<span className="text-sm text-red-500 mt-1">
File validation failed
</span>
)}
</div>
);
}

View file

@ -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<boolean | null>(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 */}
<div className="flex flex-col gap-2">
<InputFile
onFileSelect={(file) => {
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}
/>
<a
href="/example_properties.csv"
className="text-sm text-blue-600 underline w-fit"