From 6484145581e28e413a721e937f602cf317a1ff69 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 23 Feb 2026 02:52:30 -0500 Subject: [PATCH] feat: file upload and management Add file upload with multipart form, local disk storage at data/uploads/. Add file download with original filename and activity logging. Add file delete (owner/admin only) with disk cleanup. Add upload modal with folder selector and request item linking. Add download and delete actions to file list. Co-Authored-By: Claude Opus 4.6 --- internal/db/migrate.go | 2 + internal/handler/files.go | 164 ++++++++++++++++++++++++++++++++++++ internal/handler/handler.go | 5 ++ internal/model/models.go | 19 +++-- templates/dealroom.templ | 63 ++++++++++++-- 5 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 internal/handler/files.go diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 9953519..385ac74 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -231,6 +231,8 @@ var additiveMigrationStmts = []string{ `ALTER TABLE profiles ADD COLUMN buyer_group TEXT DEFAULT ''`, // Section 6: folder sort order `ALTER TABLE folders ADD COLUMN sort_order INTEGER DEFAULT 0`, + // Section 7: file storage path + `ALTER TABLE files ADD COLUMN storage_path TEXT DEFAULT ''`, } func seed(db *sql.DB) error { diff --git a/internal/handler/files.go b/internal/handler/files.go new file mode 100644 index 0000000..85c85f9 --- /dev/null +++ b/internal/handler/files.go @@ -0,0 +1,164 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const uploadsDir = "data/uploads" + +func (h *Handler) handleFileUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + profile := getProfile(r.Context()) + + err := r.ParseMultipartForm(32 << 20) // 32MB max + if err != nil { + http.Error(w, "Error parsing form", 400) + return + } + + dealID := r.FormValue("deal_id") + folderID := r.FormValue("folder_id") + requestItemID := r.FormValue("request_item_id") + + if dealID == "" { + http.Error(w, "Deal ID is required", 400) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "File is required", 400) + return + } + defer file.Close() + + // Create upload directory + dealDir := filepath.Join(uploadsDir, dealID) + os.MkdirAll(dealDir, 0755) + + // Generate unique filename + fileID := generateID("file") + ext := filepath.Ext(header.Filename) + storageName := fileID + ext + storagePath := filepath.Join(dealDir, storageName) + + // Save file to disk + dst, err := os.Create(storagePath) + if err != nil { + http.Error(w, "Error saving file", 500) + return + } + defer dst.Close() + io.Copy(dst, file) + + // Detect MIME type + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "application/octet-stream" + } + + // Insert file record + _, err = h.db.Exec(`INSERT INTO files (id, deal_id, folder_id, name, file_size, mime_type, status, storage_path, uploaded_by) VALUES (?, ?, ?, ?, ?, ?, 'uploaded', ?, ?)`, + fileID, dealID, folderID, header.Filename, header.Size, mimeType, storagePath, profile.ID) + if err != nil { + os.Remove(storagePath) + http.Error(w, "Error saving file record", 500) + return + } + + // Link to request item if specified + if requestItemID != "" { + var existing string + h.db.QueryRow("SELECT linked_file_ids FROM diligence_requests WHERE id = ?", requestItemID).Scan(&existing) + if existing == "" { + existing = fileID + } else { + existing = existing + "," + fileID + } + h.db.Exec("UPDATE diligence_requests SET linked_file_ids = ? WHERE id = ?", existing, requestItemID) + } + + // Log activity + h.logActivity(dealID, profile.ID, profile.OrganizationID, "upload", "file", header.Filename, fileID) + + http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) +} + +func (h *Handler) handleFileDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + profile := getProfile(r.Context()) + if profile.Role != "owner" && profile.Role != "admin" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + fileID := r.FormValue("file_id") + dealID := r.FormValue("deal_id") + + if fileID == "" { + http.Error(w, "Missing file ID", 400) + return + } + + // Get storage path and delete from disk + var storagePath string + h.db.QueryRow("SELECT COALESCE(storage_path, '') FROM files WHERE id = ?", fileID).Scan(&storagePath) + if storagePath != "" { + os.Remove(storagePath) + } + + h.db.Exec("DELETE FROM files WHERE id = ? AND deal_id = ?", fileID, dealID) + + http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) +} + +func (h *Handler) handleFileDownload(w http.ResponseWriter, r *http.Request) { + profile := getProfile(r.Context()) + + // Parse: /deals/files/download/{fileID} + fileID := strings.TrimPrefix(r.URL.Path, "/deals/files/download/") + if fileID == "" { + http.Error(w, "Missing file ID", 400) + return + } + + var name, storagePath, dealID string + err := h.db.QueryRow("SELECT name, COALESCE(storage_path, ''), deal_id FROM files WHERE id = ?", fileID).Scan(&name, &storagePath, &dealID) + if err != nil { + http.NotFound(w, r) + return + } + + if storagePath == "" || !fileExists(storagePath) { + http.Error(w, "File not found on disk", 404) + return + } + + // Log download activity + h.logActivity(dealID, profile.ID, profile.OrganizationID, "download", "file", name, fileID) + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) + http.ServeFile(w, r, storagePath) +} + +func (h *Handler) logActivity(dealID, userID, orgID, actType, resourceType, resourceName, resourceID string) { + id := generateID("act") + h.db.Exec(`INSERT INTO deal_activity (id, organization_id, deal_id, user_id, activity_type, resource_type, resource_name, resource_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, orgID, dealID, userID, actType, resourceType, resourceName, resourceID) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index f99eab8..105ac8b 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -82,6 +82,11 @@ mux.HandleFunc("/auth/logout", h.handleLogout) mux.HandleFunc("/deals/folders/delete", h.requireAuth(h.handleFolderDelete)) mux.HandleFunc("/deals/folders/reorder", h.requireAuth(h.handleFolderReorder)) + // File management + mux.HandleFunc("/deals/files/upload", h.requireAuth(h.handleFileUpload)) + mux.HandleFunc("/deals/files/delete", h.requireAuth(h.handleFileDelete)) + mux.HandleFunc("/deals/files/download/", h.requireAuth(h.handleFileDownload)) + // HTMX partials mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment)) } diff --git a/internal/model/models.go b/internal/model/models.go index ac8e386..50f8b1f 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -73,15 +73,16 @@ type Folder struct { } type File struct { - ID string - DealID string - FolderID string - Name string - FileSize int64 - MimeType string - Status string // uploaded, processing, reviewed, flagged, archived - UploadedBy string - CreatedAt time.Time + ID string + DealID string + FolderID string + Name string + FileSize int64 + MimeType string + Status string // uploaded, processing, reviewed, flagged, archived + StoragePath string + UploadedBy string + CreatedAt time.Time } type DiligenceRequest struct { diff --git a/templates/dealroom.templ b/templates/dealroom.templ index 3f0e06b..659f0ce 100644 --- a/templates/dealroom.templ +++ b/templates/dealroom.templ @@ -108,12 +108,10 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
- if profile.Role != "buyer" { - - } +
@@ -151,6 +149,22 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model. + } } @@ -207,6 +221,43 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model. + + +
+
+ + + + if profile.Role == "owner" || profile.Role == "admin" { +
+ + + +
+ } +
+