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 <noreply@anthropic.com>
This commit is contained in:
parent
ae79ad12c3
commit
6484145581
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -108,12 +108,10 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
<!-- Toolbar -->
|
||||
<div class="p-3 border-b border-gray-800 flex gap-3">
|
||||
<input type="text" id="fileSearch" placeholder="Search files..." oninput="filterFiles()" class="flex-1 px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
|
||||
if profile.Role != "buyer" {
|
||||
<button class="px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||
Upload Document
|
||||
</button>
|
||||
}
|
||||
<button onclick="document.getElementById('uploadModal').classList.remove('hidden')" class="px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||
Upload Document
|
||||
</button>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
|
|
@ -151,6 +149,22 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/deals/files/download/%s", file.ID)) } class="text-xs text-gray-500 hover:text-teal-400 transition" title="Download">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||
</a>
|
||||
if profile.Role == "owner" || profile.Role == "admin" {
|
||||
<form action="/deals/files/delete" method="POST" style="display:inline" onsubmit="return confirm('Delete this file?')">
|
||||
<input type="hidden" name="file_id" value={ file.ID }/>
|
||||
<input type="hidden" name="deal_id" value={ deal.ID }/>
|
||||
<button type="submit" class="text-xs text-gray-500 hover:text-red-400 transition" title="Delete">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
|
@ -207,6 +221,43 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="uploadModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('uploadModal').classList.add('hidden')"></div>
|
||||
<div class="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Upload Document</h2>
|
||||
<form action="/deals/files/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
|
||||
<input type="hidden" name="deal_id" value={ deal.ID }/>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">File</label>
|
||||
<input type="file" name="file" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">Folder</label>
|
||||
<select name="folder_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
|
||||
<option value="">Root (No folder)</option>
|
||||
for _, f := range folders {
|
||||
<option value={ f.ID }>{ f.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">Link to Request Item <span class="text-gray-600">(optional)</span></label>
|
||||
<select name="request_item_id" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
|
||||
<option value="">None</option>
|
||||
for _, req := range requests {
|
||||
<option value={ req.ID }>{ req.ItemNumber } - { req.Description }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="document.getElementById('uploadModal').classList.add('hidden')" class="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition">Cancel</button>
|
||||
<button type="submit" class="px-4 py-2 bg-teal-500 text-white text-sm font-medium rounded-lg hover:bg-teal-600 transition">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Folder Modal -->
|
||||
<div id="newFolderModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('newFolderModal').classList.add('hidden')"></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue