diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b6b65093..5c911ec2 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -7,8 +7,6 @@ services: context: .. dockerfile: .devcontainer/Dockerfile command: sleep infinity - ports: - - "3000:3000" volumes: - ..:/workspaces/assessment-model networks: diff --git a/run_local.sh b/run_local.sh index ef232ddd..dc67388e 100644 --- a/run_local.sh +++ b/run_local.sh @@ -1 +1 @@ -npm run dev +npm run dev -- -p 3000 diff --git a/src/app/api/upload/retrofit-data/route.ts b/src/app/api/upload/retrofit-data/route.ts index 03ea2137..a0b0afd0 100644 --- a/src/app/api/upload/retrofit-data/route.ts +++ b/src/app/api/upload/retrofit-data/route.ts @@ -1,11 +1,13 @@ +export const runtime = "nodejs"; + import { NextRequest, NextResponse } from "next/server"; -import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { db } from "@/app/db/db"; import { filesFromSurveyor } from "@/app/db/schema/files_from_surveyor"; import { and, eq } from "drizzle-orm"; -// ✅ Initialize S3 Client (AWS SDK v3) +// ✅ Initialize S3 Client const s3 = new S3Client({ region: process.env.RETROFIT_DATA_DEV_REGION, credentials: { @@ -14,90 +16,18 @@ const s3 = new S3Client({ }, }); -// ✅ Helper: safely convert BigInt fields to string +// ✅ Helper to safely convert BigInts function serializeBigInt>(obj: T): T { return Object.fromEntries( - Object.entries(obj).map(([key, value]) => [ - key, - typeof value === "bigint" ? value.toString() : value, + Object.entries(obj).map(([k, v]) => [ + k, + typeof v === "bigint" ? v.toString() : v, ]) ) as T; } // ------------------- -// 📤 POST — Upload files to S3 + Save to DB -// ------------------- -export async function POST(request: NextRequest) { - try { - const formData = await request.formData(); - const files = formData.getAll("files") as File[]; - - const portfolioIdStr = formData.get("portfolioId") as string; - const propertyIdStr = formData.get("propertyId") as string; - - if (!portfolioIdStr || !propertyIdStr) { - return NextResponse.json( - { msg: "Missing portfolioId or propertyId" }, - { status: 400 } - ); - } - - const portfolioId = BigInt(portfolioIdStr); - const propertyId = BigInt(propertyIdStr); - - if (!files || files.length === 0) { - return NextResponse.json({ msg: "No files uploaded" }, { status: 400 }); - } - - const uploadedFiles = await Promise.all( - files.map(async (file) => { - const timestamp = Date.now(); - const safeName = file.name.replace(/\s+/g, "_"); - const fileKey = `files_from_surveyor/${portfolioId}/${propertyId}/${timestamp}-${safeName}`; - - const buffer = Buffer.from(await file.arrayBuffer()); - - await s3.send( - new PutObjectCommand({ - Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!, - Key: fileKey, - Body: buffer, - ContentType: file.type, - }) - ); - - const fileUrl = `https://${process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME}.s3.${process.env.RETROFIT_DATA_DEV_REGION}.amazonaws.com/${fileKey}`; - - return { fileKey, fileUrl }; - }) - ); - - const inserted = await db - .insert(filesFromSurveyor) - .values( - uploadedFiles.map((file) => ({ - portfolioId, - propertyId, - s3JsonUrl: file.fileUrl, - })) - ) - .returning(); - - // ✅ Serialize BigInts before sending JSON - const safeInserted = inserted.map(serializeBigInt); - - return NextResponse.json({ files: safeInserted }, { status: 200 }); - } catch (error) { - console.error("❌ S3 or DB Error:", error); - return NextResponse.json( - { msg: "File upload failed", error: String(error) }, - { status: 500 } - ); - } -} - -// ------------------- -// 📥 GET — Fetch uploaded files + Presigned URLs +// 📥 GET — Fetch files from DB + temporary S3 view URLs // ------------------- export async function GET(request: NextRequest) { try { @@ -115,7 +45,6 @@ export async function GET(request: NextRequest) { const portfolioId = BigInt(portfolioIdStr); const propertyId = BigInt(propertyIdStr); - // ✅ Correct Drizzle where syntax const files = await db .select() .from(filesFromSurveyor) @@ -126,18 +55,23 @@ export async function GET(request: NextRequest) { ) ); - // ✅ Add 30-min presigned URLs - const withSignedUrls = await Promise.all( + // ✅ Add temporary presigned GET URLs (30-min expiry) + const filesWithSignedUrls = await Promise.all( files.map(async (file) => { - const key = file.s3JsonUrl.split(".amazonaws.com/")[1]; - const presignedUrl = await getSignedUrl( - s3, - new GetObjectCommand({ - Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!, - Key: key, - }), - { expiresIn: 60 * 30 } // 30 minutes - ); + let presignedUrl: string | null = null; + try { + const key = file.s3JsonUrl.split(".amazonaws.com/")[1]; + presignedUrl = await getSignedUrl( + s3, + new GetObjectCommand({ + Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!, + Key: key, + }), + { expiresIn: 60 * 30 } // 30 minutes + ); + } catch (err) { + console.warn("⚠️ Failed to create presigned URL:", err); + } return serializeBigInt({ ...file, @@ -146,11 +80,11 @@ export async function GET(request: NextRequest) { }) ); - return NextResponse.json({ files: withSignedUrls }, { status: 200 }); - } catch (error) { - console.error("❌ Fetch error:", error); + return NextResponse.json({ files: filesWithSignedUrls }, { status: 200 }); + } catch (err) { + console.error("❌ retrofit-data GET error:", err); return NextResponse.json( - { msg: "Error fetching files", error: String(error) }, + { msg: "Error fetching files", error: String(err) }, { status: 500 } ); } diff --git a/src/app/api/upload/save-to-db/route.ts b/src/app/api/upload/save-to-db/route.ts new file mode 100644 index 00000000..af5e9fa0 --- /dev/null +++ b/src/app/api/upload/save-to-db/route.ts @@ -0,0 +1,57 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/app/db/db"; +import { filesFromSurveyor } from "@/app/db/schema/files_from_surveyor"; + +// ✅ Helper: safely convert BigInt fields to string +function serializeBigInt>(obj: T): T { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key, + typeof value === "bigint" ? value.toString() : value, + ]) + ) as T; +} + +// ------------------- +// 📤 POST — Save uploaded file URLs (from S3) to DB +// ------------------- +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { portfolioId, propertyId, s3JsonUrl } = body; + + if (!portfolioId || !propertyId || !s3JsonUrl) { + return NextResponse.json( + { msg: "Missing portfolioId, propertyId, or s3JsonUrl" }, + { status: 400 } + ); + } + + const portfolioIdBigInt = BigInt(portfolioId); + const propertyIdBigInt = BigInt(propertyId); + + const inserted = await db + .insert(filesFromSurveyor) + .values({ + portfolioId: portfolioIdBigInt, + propertyId: propertyIdBigInt, + s3JsonUrl, + }) + .returning(); + + const safeInserted = inserted.map(serializeBigInt); + + return NextResponse.json( + { msg: "✅ File record saved", files: safeInserted }, + { status: 200 } + ); + } catch (error) { + console.error("❌ DB insert error:", error); + return NextResponse.json( + { msg: "Failed to save file record", error: String(error) }, + { status: 500 } + ); + } +} diff --git a/src/app/api/upload/sign-url/route.ts b/src/app/api/upload/sign-url/route.ts new file mode 100644 index 00000000..92d6fda2 --- /dev/null +++ b/src/app/api/upload/sign-url/route.ts @@ -0,0 +1,52 @@ +// ✅ Force Node.js runtime (not Edge) +export const runtime = "nodejs"; + +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { NextResponse } from "next/server"; + +// ✅ Initialize S3 client with credentials +const s3 = new S3Client({ + region: process.env.RETROFIT_DATA_DEV_REGION, + credentials: { + accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY!, + secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY!, + }, +}); + +export async function POST(req: Request) { + try { + const { fileName, fileType, portfolioId, propertyId } = await req.json(); + + if (!fileName || !fileType || !portfolioId || !propertyId) { + return NextResponse.json( + { msg: "Missing required fields" }, + { status: 400 } + ); + } + + const safeName = fileName.replace(/\s+/g, "_"); + const key = `files_from_surveyor/${portfolioId}/${propertyId}/${Date.now()}-${safeName}`; + + // ✅ Create S3 PutObjectCommand (no upload yet — just sign it) + const command = new PutObjectCommand({ + Bucket: process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME!, + Key: key, + ContentType: fileType, + }); + + // ✅ Generate presigned upload URL (valid 5 minutes) + const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 60 * 5 }); + + // ✅ Public file URL once uploaded + const publicUrl = `https://${process.env.RETROFIT_DATA_DEV_S3_BUCKET_NAME}.s3.${process.env.RETROFIT_DATA_DEV_REGION}.amazonaws.com/${key}`; + + return NextResponse.json({ uploadUrl, publicUrl }, { status: 200 }); + } catch (err) { + console.error("❌ Error generating presigned URL:", err); + return NextResponse.json( + { msg: "Failed to generate presigned URL", error: String(err) }, + { status: 500 } + ); + } +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx index 5d74ef0c..3b9e62a8 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/upload/page.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from "react"; import { useParams } from "next/navigation"; interface FileRecord { - id: number; + id?: number; s3JsonUrl: string; portfolioId: string; propertyId: string; @@ -13,120 +13,229 @@ interface FileRecord { } const UploadPage: React.FC = () => { - const [isUploading, setIsUploading] = useState(false); const [files, setFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); const params = useParams(); const portfolioId = params?.slug as string; const propertyId = params?.propertyId as string; + // ------------------- + // Fetch files from DB + // ------------------- const fetchFiles = async () => { - const res = await fetch( - `/api/upload/retrofit-data?portfolioId=${portfolioId}&propertyId=${propertyId}` - ); - if (res.ok) { + try { + const res = await fetch( + `/api/upload/retrofit-data?portfolioId=${portfolioId}&propertyId=${propertyId}` + ); + if (!res.ok) throw new Error("Failed to fetch files"); const data = await res.json(); - console.log(data, "hello"); - setFiles(data.files); + setFiles(data.files || []); + } catch (err) { + console.error("❌ Fetch files error:", err); } }; useEffect(() => { - if (portfolioId && propertyId) { - fetchFiles(); - } + if (portfolioId && propertyId) fetchFiles(); }, [portfolioId, propertyId]); + // ------------------- + // Handle file upload + // ------------------- const handleFileChange = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; + const selectedFiles = e.target.files; + if (!selectedFiles || selectedFiles.length === 0) return; - const formData = new FormData(); - formData.append("portfolioId", portfolioId); - formData.append("propertyId", propertyId); - Array.from(files).forEach((file) => formData.append("files", file)); + setIsUploading(true); + setUploadProgress(0); try { - setIsUploading(true); - const res = await fetch("/api/upload/retrofit-data", { - method: "POST", - body: formData, - }); - if (!res.ok) throw new Error("Upload failed"); + const filesArray = Array.from(selectedFiles); + + for (let i = 0; i < filesArray.length; i++) { + const file = filesArray[i]; + + // 1️⃣ Get presigned URL + const signRes = await fetch("/api/upload/sign-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileName: file.name, + fileType: file.type, + portfolioId, + propertyId, + }), + }); + + if (!signRes.ok) throw new Error("Failed to get signed URL"); + const { uploadUrl, publicUrl } = await signRes.json(); + + // 2️⃣ Upload directly to S3 (with progress) + await new Promise((resolve, reject) => { + const uploadReq = new XMLHttpRequest(); + + uploadReq.open("PUT", uploadUrl, true); + uploadReq.setRequestHeader("Content-Type", file.type); + + uploadReq.upload.onprogress = (event: ProgressEvent) => { + if (event.lengthComputable) { + const percent = Math.round((event.loaded / event.total) * 100); + setUploadProgress(percent); + } + }; + + uploadReq.onload = () => { + if (uploadReq.status === 200) resolve(); + else reject(new Error(`S3 upload failed for ${file.name}`)); + }; + + uploadReq.onerror = (err: ProgressEvent) => { + console.error("❌ Upload network error:", err); + reject(new Error(`Network error while uploading ${file.name}`)); + }; + + uploadReq.send(file); + }); + + // 3️⃣ Save file record in DB + const saveRes = await fetch("/api/upload/save-to-db", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + portfolioId, + propertyId, + s3JsonUrl: publicUrl, + }), + }); + + if (!saveRes.ok) throw new Error("Failed to save file record"); + + // Update overall progress + setUploadProgress(Math.round(((i + 1) / filesArray.length) * 100)); + } + await fetchFiles(); - alert("✅ Files uploaded successfully!"); + alert("✅ All files uploaded successfully!"); } catch (err) { - console.error(err); - alert("❌ Upload failed"); + console.error("❌ Upload error:", err); + alert("❌ Upload failed. Please try again."); } finally { setIsUploading(false); + setUploadProgress(0); } }; + // ------------------- + // UI + // ------------------- return ( -
-

Upload Retrofit Data Files

- -
-

- Portfolio ID: {portfolioId} +

+
+

+ Upload Retrofit Data +

+

+ Upload files securely to our cloud storage. Each upload will + automatically link to the selected portfolio and property.

-

- Property ID: {propertyId} -

-
- +
+

+ Portfolio ID: {portfolioId} +

+

+ Property ID: {propertyId} +

+
- +
+ -
-

Uploaded Files

- {files.length === 0 ? ( -

No files uploaded yet.

- ) : ( - - - - - - - - - {files.map((file) => ( - - - - - ))} - -
File URLAction
- {file.s3JsonUrl} - - -
+ +
+ + {isUploading && ( +
+
+
)} + +
+

+ Uploaded Files +

+ + {files.length === 0 ? ( +
+ No files uploaded yet. +
+ ) : ( +
+ + + + + + + + + + {files.map((file) => ( + + + + + + ))} + +
File NameUploadedAction
+ {decodeURIComponent( + file.s3JsonUrl.split("/").pop() || "" + )} + + {file.createdAt + ? new Date(file.createdAt).toLocaleString() + : "—"} + + +
+
+ )} +
);