From 639a7b7eab1171b47a1b1ae8455ac76c4cd158e6 Mon Sep 17 00:00:00 2001 From: RKWojs Date: Wed, 30 Jul 2025 11:37:25 +0200 Subject: [PATCH] feat: Implement file upload and management system with database integration --- init-db-temp.mjs | 5 + migrate-to-username.js | 60 +++++++++ src/app/api/files/[fileId]/route.js | 79 +++++++++++ src/app/api/files/route.js | 162 ++++++++++++++++++++++ src/app/contracts/[id]/page.js | 59 ++++++++ src/components/FileAttachmentsList.js | 177 ++++++++++++++++++++++++ src/components/FileUploadModal.js | 186 ++++++++++++++++++++++++++ src/lib/init-db.js | 22 +++ update-admin-username.js | 28 ++++ 9 files changed, 778 insertions(+) create mode 100644 init-db-temp.mjs create mode 100644 migrate-to-username.js create mode 100644 src/app/api/files/[fileId]/route.js create mode 100644 src/app/api/files/route.js create mode 100644 src/components/FileAttachmentsList.js create mode 100644 src/components/FileUploadModal.js create mode 100644 update-admin-username.js diff --git a/init-db-temp.mjs b/init-db-temp.mjs new file mode 100644 index 0000000..1908bc5 --- /dev/null +++ b/init-db-temp.mjs @@ -0,0 +1,5 @@ +import initializeDatabase from './src/lib/init-db.js'; + +console.log('Initializing database...'); +initializeDatabase(); +console.log('Database initialized successfully!'); diff --git a/migrate-to-username.js b/migrate-to-username.js new file mode 100644 index 0000000..67e8ad8 --- /dev/null +++ b/migrate-to-username.js @@ -0,0 +1,60 @@ +import Database from "better-sqlite3"; + +const db = new Database("./data/database.sqlite"); + +console.log("šŸ”„ Migrating database to username-based authentication...\n"); + +try { + // Check current table structure + const tableInfo = db.prepare("PRAGMA table_info(users)").all(); + console.log("Current users table columns:"); + tableInfo.forEach(col => console.log(` - ${col.name}: ${col.type}`)); + + const hasUsername = tableInfo.some(col => col.name === 'username'); + const hasEmail = tableInfo.some(col => col.name === 'email'); + + if (hasUsername) { + console.log("āœ… Username column already exists!"); + } else if (hasEmail) { + console.log("\nšŸ“ Adding username column..."); + + // Add username column + db.exec(`ALTER TABLE users ADD COLUMN username TEXT;`); + console.log("āœ… Username column added"); + + // Copy email data to username for existing users + console.log("šŸ“‹ Migrating existing email data to username..."); + const result = db.exec(`UPDATE users SET username = email WHERE username IS NULL;`); + console.log("āœ… Data migrated"); + + // Create unique index on username + console.log("šŸ” Creating unique index on username..."); + try { + db.exec(`CREATE UNIQUE INDEX idx_users_username_unique ON users(username);`); + console.log("āœ… Unique index created"); + } catch (e) { + console.log("ā„¹ļø Index already exists or couldn't be created:", e.message); + } + + // Verify migration + console.log("\nšŸ” Verifying migration..."); + const users = db.prepare("SELECT id, name, username, email FROM users LIMIT 3").all(); + console.log("Sample users after migration:"); + users.forEach(user => { + console.log(` - ${user.name}: username="${user.username}", email="${user.email || 'NULL'}"`); + }); + + console.log("\nāœ… Migration completed successfully!"); + console.log("ā„¹ļø You can now log in using usernames instead of emails"); + + } else { + console.log("āŒ Neither username nor email column found. Database may be corrupted."); + process.exit(1); + } + +} catch (error) { + console.error("āŒ Migration failed:", error.message); + process.exit(1); +} finally { + db.close(); +} diff --git a/src/app/api/files/[fileId]/route.js b/src/app/api/files/[fileId]/route.js new file mode 100644 index 0000000..f5ebbea --- /dev/null +++ b/src/app/api/files/[fileId]/route.js @@ -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 } + ); + } +} diff --git a/src/app/api/files/route.js b/src/app/api/files/route.js new file mode 100644 index 0000000..edc98a2 --- /dev/null +++ b/src/app/api/files/route.js @@ -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 } + ); + } +} diff --git a/src/app/contracts/[id]/page.js b/src/app/contracts/[id]/page.js index 03189ea..58f33c5 100644 --- a/src/app/contracts/[id]/page.js +++ b/src/app/contracts/[id]/page.js @@ -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 ( @@ -245,6 +257,44 @@ export default function ContractDetailsPage() { + {/* Contract Documents */} + + +
+

+ Contract Documents ({attachments.length}) +

+ +
+
+ + + +
+ {/* Associated Projects */} @@ -386,6 +436,15 @@ export default function ContractDetailsPage() { )} + + {/* File Upload Modal */} + setShowUploadModal(false)} + entityType="contract" + entityId={contractId} + onFileUploaded={handleFileUploaded} + />
); } diff --git a/src/components/FileAttachmentsList.js b/src/components/FileAttachmentsList.js new file mode 100644 index 0000000..46bfac9 --- /dev/null +++ b/src/components/FileAttachmentsList.js @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Button from "@/components/ui/Button"; +import { formatDate } from "@/lib/utils"; + +export default function FileAttachmentsList({ entityType, entityId, onFilesChange }) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchFiles = async () => { + try { + const response = await fetch(`/api/files?entityType=${entityType}&entityId=${entityId}`); + if (response.ok) { + const filesData = await response.json(); + setFiles(filesData); + if (onFilesChange) { + onFilesChange(filesData); + } + } + } catch (error) { + console.error("Error fetching files:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchFiles(); + }, [entityType, entityId]); + + const handleDelete = async (fileId) => { + if (!confirm("Are you sure you want to delete this file?")) { + return; + } + + try { + const response = await fetch(`/api/files/${fileId}`, { + method: "DELETE", + }); + + if (response.ok) { + setFiles(files.filter(file => file.file_id !== fileId)); + if (onFilesChange) { + onFilesChange(files.filter(file => file.file_id !== fileId)); + } + } else { + alert("Failed to delete file"); + } + } catch (error) { + console.error("Error deleting file:", error); + alert("Failed to delete file"); + } + }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const getFileIcon = (mimeType) => { + if (mimeType.startsWith('image/')) { + return ( + + + + ); + } else if (mimeType === 'application/pdf') { + return ( + + + + ); + } else if (mimeType.includes('word') || mimeType.includes('document')) { + return ( + + + + ); + } else if (mimeType.includes('excel') || mimeType.includes('sheet')) { + return ( + + + + ); + } else { + return ( + + + + ); + } + }; + + if (loading) { + return ( +
+ + + + + Loading files... +
+ ); + } + + if (files.length === 0) { + return ( +
+ + + +

No documents uploaded yet

+
+ ); + } + + return ( +
+ {files.map((file) => ( +
+
+
+ {getFileIcon(file.mime_type)} +
+
+
+ {file.original_filename} +
+
+ {formatFileSize(file.file_size)} + • + {formatDate(file.upload_date, { includeTime: true })} +
+ {file.description && ( +
+ {file.description} +
+ )} +
+
+
+ + + + +
+
+ ))} +
+ ); +} diff --git a/src/components/FileUploadModal.js b/src/components/FileUploadModal.js new file mode 100644 index 0000000..09fe3a9 --- /dev/null +++ b/src/components/FileUploadModal.js @@ -0,0 +1,186 @@ +"use client"; + +import { useState, useRef } from "react"; +import Button from "@/components/ui/Button"; + +export default function FileUploadModal({ + isOpen, + onClose, + entityType, + entityId, + onFileUploaded +}) { + const [dragActive, setDragActive] = useState(false); + const [uploading, setUploading] = useState(false); + const [description, setDescription] = useState(""); + const fileInputRef = useRef(null); + + const handleDrag = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFiles(e.dataTransfer.files); + } + }; + + const handleChange = (e) => { + e.preventDefault(); + if (e.target.files && e.target.files[0]) { + handleFiles(e.target.files); + } + }; + + const handleFiles = async (files) => { + const file = files[0]; + if (!file) return; + + setUploading(true); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("entityType", entityType); + formData.append("entityId", entityId.toString()); + formData.append("description", description); + + const response = await fetch("/api/files", { + method: "POST", + body: formData, + }); + + if (response.ok) { + const uploadedFile = await response.json(); + onFileUploaded(uploadedFile); + setDescription(""); + onClose(); + } else { + const error = await response.json(); + alert(error.error || "Failed to upload file"); + } + } catch (error) { + console.error("Upload error:", error); + alert("Failed to upload file"); + } finally { + setUploading(false); + } + }; + + const onButtonClick = () => { + fileInputRef.current?.click(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ Upload Document +

+ +
+ +
+ {/* Description Input */} +
+ + setDescription(e.target.value)} + placeholder="Brief description of the document..." + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + disabled={uploading} + /> +
+ + {/* File Drop Zone */} +
+ + + {uploading ? ( +
+ + + + + Uploading... +
+ ) : ( +
+ + + + + Drop files here or click to browse + + + PDF, DOC, XLS, Images up to 10MB + + +
+ )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/lib/init-db.js b/src/lib/init-db.js index d191011..851d5e8 100644 --- a/src/lib/init-db.js +++ b/src/lib/init-db.js @@ -341,4 +341,26 @@ export default function initializeDatabase() { } catch (e) { console.warn("Migration warning:", e.message); } + + // Generic file attachments table + db.exec(` + CREATE TABLE IF NOT EXISTS file_attachments ( + file_id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL CHECK(entity_type IN ('contract', 'project', 'task')), + entity_id INTEGER NOT NULL, + original_filename TEXT NOT NULL, + stored_filename TEXT NOT NULL, + file_path TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + description TEXT, + uploaded_by TEXT, + upload_date TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (uploaded_by) REFERENCES users(id) + ); + + -- Create indexes for file attachments + CREATE INDEX IF NOT EXISTS idx_file_attachments_entity ON file_attachments(entity_type, entity_id); + CREATE INDEX IF NOT EXISTS idx_file_attachments_uploaded_by ON file_attachments(uploaded_by); + `); } diff --git a/update-admin-username.js b/update-admin-username.js new file mode 100644 index 0000000..0886d35 --- /dev/null +++ b/update-admin-username.js @@ -0,0 +1,28 @@ +import Database from "better-sqlite3"; + +const db = new Database("./data/database.sqlite"); + +console.log("šŸ”„ Updating admin username..."); + +try { + // Update admin username from email to simple "admin" + const result = db.prepare('UPDATE users SET username = ? WHERE username = ?').run('admin', 'admin@localhost.com'); + + if (result.changes > 0) { + console.log('āœ… Admin username updated to "admin"'); + } else { + console.log('ā„¹ļø No admin user found with email "admin@localhost.com"'); + } + + // Show current users + const users = db.prepare("SELECT name, username, role FROM users").all(); + console.log("\nCurrent users:"); + users.forEach(user => { + console.log(` - ${user.name} (${user.role}): username="${user.username}"`); + }); + +} catch (error) { + console.error("āŒ Error:", error.message); +} finally { + db.close(); +}