feat: Implement file upload and management system with database integration
This commit is contained in:
79
src/app/api/files/[fileId]/route.js
Normal file
79
src/app/api/files/[fileId]/route.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const fileId = params.fileId;
|
||||
|
||||
// Get file info from database
|
||||
const file = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete physical file
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), "public", file.file_path);
|
||||
await unlink(fullPath);
|
||||
} catch (fileError) {
|
||||
console.warn("Could not delete physical file:", fileError.message);
|
||||
// Continue with database deletion even if file doesn't exist
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
const result = db.prepare(`
|
||||
DELETE FROM file_attachments WHERE file_id = ?
|
||||
`).run(parseInt(fileId));
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const fileId = params.fileId;
|
||||
|
||||
// Get file info from database
|
||||
const file = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(file);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
162
src/app/api/files/route.js
Normal file
162
src/app/api/files/route.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
import { auditLog } from "@/lib/middleware/auditLog";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"text/plain"
|
||||
];
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const entityType = formData.get("entityType");
|
||||
const entityId = formData.get("entityId");
|
||||
const description = formData.get("description") || "";
|
||||
|
||||
if (!file || !entityType || !entityId) {
|
||||
return NextResponse.json(
|
||||
{ error: "File, entityType, and entityId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate entity type
|
||||
if (!["contract", "project", "task"].includes(entityType)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid entity type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "File size too large (max 10MB)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "File type not allowed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create upload directory structure
|
||||
const entityDir = path.join(UPLOAD_DIR, entityType + "s", entityId);
|
||||
if (!existsSync(entityDir)) {
|
||||
await mkdir(entityDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const sanitizedOriginalName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const storedFilename = `${timestamp}_${sanitizedOriginalName}`;
|
||||
const filePath = path.join(entityDir, storedFilename);
|
||||
const relativePath = `/uploads/${entityType}s/${entityId}/${storedFilename}`;
|
||||
|
||||
// Save file
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Save to database
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO file_attachments (
|
||||
entity_type, entity_id, original_filename, stored_filename,
|
||||
file_path, file_size, mime_type, description, uploaded_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
entityType,
|
||||
parseInt(entityId),
|
||||
file.name,
|
||||
storedFilename,
|
||||
relativePath,
|
||||
file.size,
|
||||
file.type,
|
||||
description,
|
||||
null // TODO: Get from session when auth is implemented
|
||||
);
|
||||
|
||||
const newFile = {
|
||||
file_id: result.lastInsertRowid,
|
||||
entity_type: entityType,
|
||||
entity_id: parseInt(entityId),
|
||||
original_filename: file.name,
|
||||
stored_filename: storedFilename,
|
||||
file_path: relativePath,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
description: description,
|
||||
upload_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
return NextResponse.json(newFile, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("File upload error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to upload file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const entityType = searchParams.get("entityType");
|
||||
const entityId = searchParams.get("entityId");
|
||||
|
||||
if (!entityType || !entityId) {
|
||||
return NextResponse.json(
|
||||
{ error: "entityType and entityId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const files = db.prepare(`
|
||||
SELECT
|
||||
file_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
original_filename,
|
||||
stored_filename,
|
||||
file_path,
|
||||
file_size,
|
||||
mime_type,
|
||||
description,
|
||||
upload_date,
|
||||
uploaded_by
|
||||
FROM file_attachments
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY upload_date DESC
|
||||
`).all(entityType, parseInt(entityId));
|
||||
|
||||
return NextResponse.json(files);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching files:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch files" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import FileUploadModal from "@/components/FileUploadModal";
|
||||
import FileAttachmentsList from "@/components/FileAttachmentsList";
|
||||
|
||||
export default function ContractDetailsPage() {
|
||||
const params = useParams();
|
||||
@@ -17,6 +19,8 @@ export default function ContractDetailsPage() {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [attachments, setAttachments] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchContractDetails() {
|
||||
@@ -52,6 +56,14 @@ export default function ContractDetailsPage() {
|
||||
fetchContractDetails();
|
||||
}
|
||||
}, [contractId]);
|
||||
const handleFileUploaded = (newFile) => {
|
||||
setAttachments(prev => [newFile, ...prev]);
|
||||
};
|
||||
|
||||
const handleFilesChange = (files) => {
|
||||
setAttachments(files);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -245,6 +257,44 @@ export default function ContractDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract Documents */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Contract Documents ({attachments.length})
|
||||
</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Upload Document
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileAttachmentsList
|
||||
entityType="contract"
|
||||
entityId={contractId}
|
||||
onFilesChange={handleFilesChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Associated Projects */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -386,6 +436,15 @@ export default function ContractDetailsPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
entityType="contract"
|
||||
entityId={contractId}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user