mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #128 from Hestia-Homes/feature/nick_upload
Feature/nick upload
This commit is contained in:
commit
6b61366fa2
12 changed files with 13951 additions and 0 deletions
157
src/app/api/upload/retrofit-data/route.ts
Normal file
157
src/app/api/upload/retrofit-data/route.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { S3Client, PutObjectCommand, 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)
|
||||
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!,
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Helper: safely convert BigInt fields to string
|
||||
function serializeBigInt<T extends Record<string, any>>(obj: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === "bigint" ? value.toString() : value,
|
||||
])
|
||||
) 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
|
||||
// -------------------
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const portfolioIdStr = searchParams.get("portfolioId");
|
||||
const propertyIdStr = searchParams.get("propertyId");
|
||||
|
||||
if (!portfolioIdStr || !propertyIdStr) {
|
||||
return NextResponse.json(
|
||||
{ msg: "Missing portfolioId or propertyId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const portfolioId = BigInt(portfolioIdStr);
|
||||
const propertyId = BigInt(propertyIdStr);
|
||||
|
||||
// ✅ Correct Drizzle where syntax
|
||||
const files = await db
|
||||
.select()
|
||||
.from(filesFromSurveyor)
|
||||
.where(
|
||||
and(
|
||||
eq(filesFromSurveyor.portfolioId, portfolioId),
|
||||
eq(filesFromSurveyor.propertyId, propertyId)
|
||||
)
|
||||
);
|
||||
|
||||
// ✅ Add 30-min presigned URLs
|
||||
const withSignedUrls = 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
|
||||
);
|
||||
|
||||
return serializeBigInt({
|
||||
...file,
|
||||
presignedUrl,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ files: withSignedUrls }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("❌ Fetch error:", error);
|
||||
return NextResponse.json(
|
||||
{ msg: "Error fetching files", error: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/db/migrations/0124_mute_alice.sql
Normal file
23
src/app/db/migrations/0124_mute_alice.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
CREATE TABLE "sub_tasks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"task_id" uuid NOT NULL,
|
||||
"job_started" timestamp (6) with time zone,
|
||||
"job_completed" timestamp (6) with time zone,
|
||||
"status" text DEFAULT 'In Progress' NOT NULL,
|
||||
"inputs" text,
|
||||
"outputs" text,
|
||||
"cloud_logs_url" text,
|
||||
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tasks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"task_source" text NOT NULL,
|
||||
"job_started" timestamp (6) with time zone,
|
||||
"job_completed" timestamp (6) with time zone,
|
||||
"status" text DEFAULT 'In Progress' NOT NULL,
|
||||
"service" text,
|
||||
"updated_at" timestamp (6) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sub_tasks" ADD CONSTRAINT "sub_tasks_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;
|
||||
4
src/app/db/migrations/0125_steady_thunderbolts.sql
Normal file
4
src/app/db/migrations/0125_steady_thunderbolts.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "sub_tasks" RENAME TO "sub_task";--> statement-breakpoint
|
||||
ALTER TABLE "sub_task" DROP CONSTRAINT "sub_tasks_task_id_tasks_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sub_task" ADD CONSTRAINT "sub_task_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;
|
||||
10
src/app/db/migrations/0126_third_hawkeye.sql
Normal file
10
src/app/db/migrations/0126_third_hawkeye.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE "files_from_surveyor" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"portfolio_id" bigint NOT NULL,
|
||||
"property_id" bigint NOT NULL,
|
||||
"s3_json_url" text NOT NULL,
|
||||
"uploaded_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "files_from_surveyor" ADD CONSTRAINT "files_from_surveyor_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "files_from_surveyor" ADD CONSTRAINT "files_from_surveyor_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."property"("id") ON DELETE no action ON UPDATE no action;
|
||||
4490
src/app/db/migrations/meta/0124_snapshot.json
Normal file
4490
src/app/db/migrations/meta/0124_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4490
src/app/db/migrations/meta/0125_snapshot.json
Normal file
4490
src/app/db/migrations/meta/0125_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4562
src/app/db/migrations/meta/0126_snapshot.json
Normal file
4562
src/app/db/migrations/meta/0126_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -869,6 +869,27 @@
|
|||
"when": 1761660543815,
|
||||
"tag": "0123_cloudy_gambit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 124,
|
||||
"version": "7",
|
||||
"when": 1762793610067,
|
||||
"tag": "0124_mute_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 125,
|
||||
"version": "7",
|
||||
"when": 1762793952263,
|
||||
"tag": "0125_steady_thunderbolts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 126,
|
||||
"version": "7",
|
||||
"when": 1762892339561,
|
||||
"tag": "0126_third_hawkeye",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
15
src/app/db/schema/files_from_surveyor.ts
Normal file
15
src/app/db/schema/files_from_surveyor.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { pgTable, uuid, bigint, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { portfolio } from "./portfolio";
|
||||
import { property } from "./property";
|
||||
|
||||
export const filesFromSurveyor = pgTable("files_from_surveyor", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => property.id),
|
||||
s3JsonUrl: text("s3_json_url").notNull(),
|
||||
uploadedAt: timestamp("uploaded_at").notNull().defaultNow(),
|
||||
});
|
||||
25
src/app/db/schema/tasks/subtask.ts
Normal file
25
src/app/db/schema/tasks/subtask.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { tasks } from "./tasks";
|
||||
|
||||
export const subTasks = pgTable("sub_task", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
// foreign key to parent task
|
||||
taskId: uuid("task_id")
|
||||
.notNull()
|
||||
.references(() => tasks.id, { onDelete: "cascade" }),
|
||||
|
||||
jobStarted: timestamp("job_started", { precision: 6, withTimezone: true }),
|
||||
jobCompleted: timestamp("job_completed", { precision: 6, withTimezone: true }),
|
||||
|
||||
status: text("status").notNull().default("In Progress"),
|
||||
|
||||
inputs: text("inputs"), // could later change to JSONB if desired
|
||||
outputs: text("outputs"),
|
||||
cloudLogsURL: text("cloud_logs_url"),
|
||||
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
19
src/app/db/schema/tasks/tasks.ts
Normal file
19
src/app/db/schema/tasks/tasks.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const tasks = pgTable("tasks", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
taskSource: text("task_source").notNull(),
|
||||
|
||||
jobStarted: timestamp("job_started", { precision: 6, withTimezone: true }),
|
||||
jobCompleted: timestamp("job_completed", { precision: 6, withTimezone: true }),
|
||||
|
||||
status: text("status").notNull().default("In Progress"), // default status
|
||||
|
||||
service: text("service"), // e.g. plan, wchg etc
|
||||
|
||||
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
interface FileRecord {
|
||||
id: number;
|
||||
s3JsonUrl: string;
|
||||
portfolioId: string;
|
||||
propertyId: string;
|
||||
presignedUrl?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
const UploadPage: React.FC = () => {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||
const params = useParams();
|
||||
|
||||
const portfolioId = params?.slug as string;
|
||||
const propertyId = params?.propertyId as string;
|
||||
|
||||
const fetchFiles = async () => {
|
||||
const res = await fetch(
|
||||
`/api/upload/retrofit-data?portfolioId=${portfolioId}&propertyId=${propertyId}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log(data, "hello");
|
||||
setFiles(data.files);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (portfolioId && propertyId) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [portfolioId, propertyId]);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("portfolioId", portfolioId);
|
||||
formData.append("propertyId", propertyId);
|
||||
Array.from(files).forEach((file) => formData.append("files", file));
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const res = await fetch("/api/upload/retrofit-data", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error("Upload failed");
|
||||
await fetchFiles();
|
||||
alert("✅ Files uploaded successfully!");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("❌ Upload failed");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start min-h-screen gap-6 bg-gray-50 p-8">
|
||||
<h1 className="text-2xl font-semibold">Upload Retrofit Data Files</h1>
|
||||
|
||||
<div className="text-gray-700 text-center">
|
||||
<p>
|
||||
<strong>Portfolio ID:</strong> {portfolioId}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Property ID:</strong> {propertyId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`cursor-pointer px-5 py-3 rounded-lg transition ${
|
||||
isUploading
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||
}`}
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Choose Files"}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-3xl mt-10">
|
||||
<h2 className="text-lg font-medium mb-4">Uploaded Files</h2>
|
||||
{files.length === 0 ? (
|
||||
<p className="text-gray-500">No files uploaded yet.</p>
|
||||
) : (
|
||||
<table className="min-w-full border border-gray-200 bg-white rounded-lg shadow-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="p-3 text-left">File URL</th>
|
||||
<th className="p-3 text-left">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id} className="border-t">
|
||||
<td className="p-3 text-blue-700 break-all">
|
||||
{file.s3JsonUrl}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<button
|
||||
onClick={() => window.open(file.presignedUrl, "_blank")}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
||||
Loading…
Add table
Reference in a new issue