Merge pull request #128 from Hestia-Homes/feature/nick_upload

Feature/nick upload
This commit is contained in:
KhalimCK 2025-11-12 10:16:05 +00:00 committed by GitHub
commit 6b61366fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 13951 additions and 0 deletions

View 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 }
);
}
}

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

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

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

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

View file

@ -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;