mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Adding file validation to client side instead of server
This commit is contained in:
parent
f9d72e7ad5
commit
ba69dd4ad5
3 changed files with 138 additions and 113 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue