diff --git a/api/handlers.go b/api/handlers.go index 5a0604e..d7a1231 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -3,9 +3,10 @@ package api import ( "bufio" "bytes" - "encoding/csv" - "crypto/subtle" + "context" "crypto/rand" + "crypto/subtle" + "encoding/csv" "encoding/hex" "encoding/json" "fmt" @@ -14,6 +15,7 @@ import ( "log" "net/http" "os" + "os/exec" "path/filepath" "strings" "time" @@ -21,6 +23,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/mish/dealspace/lib" + pdfcpuapi "github.com/pdfcpu/pdfcpu/pkg/api" + pdfcpumodel "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" + pdfcputypes "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" "github.com/xuri/excelize/v2" ) @@ -838,6 +843,123 @@ func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) { w.Write(data) } +// PreviewObject handles GET /api/projects/{projectID}/objects/{objectID}/preview +// Converts files to watermarked PDF for inline viewing. Videos served directly. +func (h *Handlers) PreviewObject(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + objectID := chi.URLParam(r, "objectID") + + if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + data, err := h.Store.Read(projectID, objectID) + if err != nil { + ErrorResponse(w, http.StatusNotFound, "not_found", "Object not found") + return + } + + user, _ := lib.UserByID(h.DB, actorID) + userEmail := "unknown" + if user != nil { + userEmail = user.Email + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + filename = "document" + } + + ext := strings.ToLower(filepath.Ext(filename)) + wmText := fmt.Sprintf("%s · %s · CONFIDENTIAL", userEmail, time.Now().Format("2006-01-02 15:04")) + + // Videos: serve directly + videoExts := map[string]string{".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska", ".webm": "video/webm"} + if ct, ok := videoExts[ext]; ok { + w.Header().Set("Content-Type", ct) + w.Header().Set("Content-Disposition", "inline") + w.Write(data) + return + } + + var pdfBytes []byte + if ext == ".pdf" { + pdfBytes, err = watermarkPDF(data, wmText) + } else { + pdfBytes, err = convertToPDFAndWatermark(data, filename, wmText) + } + + if err != nil { + log.Printf("preview conversion failed: %v", err) + // Fallback: serve original inline + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) + w.Write(data) + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", strings.TrimSuffix(filename, ext)+".pdf")) + w.Header().Set("Cache-Control", "no-store, no-cache, private") + w.Write(pdfBytes) +} + +// watermarkPDF applies a text watermark to existing PDF bytes using pdfcpu. +func watermarkPDF(pdfData []byte, wmText string) ([]byte, error) { + wm, err := pdfcpuapi.TextWatermark(wmText, + "font:Helvetica, points:10, pos:bc, rot:0, opacity:0.35, scale:1 abs, color: 0.5 0.5 0.5", + true, false, pdfcputypes.POINTS) + if err != nil { + return nil, fmt.Errorf("create watermark: %w", err) + } + + rs := bytes.NewReader(pdfData) + var out bytes.Buffer + conf := pdfcpumodel.NewDefaultConfiguration() + if err := pdfcpuapi.AddWatermarks(rs, &out, nil, wm, conf); err != nil { + // If watermarking fails, return original PDF + return pdfData, nil + } + return out.Bytes(), nil +} + +// convertToPDFAndWatermark uses LibreOffice headless to convert a file to PDF, then watermarks it. +func convertToPDFAndWatermark(data []byte, filename, wmText string) ([]byte, error) { + tmpDir, err := os.MkdirTemp("", "ds-preview-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + inputPath := filepath.Join(tmpDir, filename) + if err := os.WriteFile(inputPath, data, 0600); err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "libreoffice", + "--headless", + "--convert-to", "pdf", + "--outdir", tmpDir, + inputPath, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("libreoffice convert: %v: %s", err, out) + } + + ext := filepath.Ext(filename) + pdfPath := filepath.Join(tmpDir, strings.TrimSuffix(filename, ext)+".pdf") + pdfData, err := os.ReadFile(pdfPath) + if err != nil { + return nil, fmt.Errorf("read pdf output: %w", err) + } + + return watermarkPDF(pdfData, wmText) +} + // --------------------------------------------------------------------------- // Template serving handlers // --------------------------------------------------------------------------- @@ -2058,7 +2180,8 @@ func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) { // 1. Create request_list entry (depth=1, parent=project) requestListID := uuid.New().String() - rlData := lib.RequestListData{Name: listName} + visibilityOrgID := r.FormValue("visibility_org_id") + rlData := lib.RequestListData{Name: listName, VisibilityOrgID: visibilityOrgID} rlDataJSON, _ := json.Marshal(rlData) rlSummaryPacked, _ := lib.Pack(projectKey, listName) rlDataPacked, _ := lib.Pack(projectKey, string(rlDataJSON)) @@ -2192,6 +2315,63 @@ func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) { }) } +// UpdateRequestListVisibility handles PATCH /api/projects/{projectID}/entries/{entryID}/visibility +// Sets or clears the visibility_org_id on a request_list entry. IB roles only. +func (h *Handlers) UpdateRequestListVisibility(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + entryID := chi.URLParam(r, "entryID") + + // Only IB roles can change visibility + role, err := lib.GetUserHighestRole(h.DB, actorID, projectID) + if err != nil || (role != lib.RoleIBAdmin && role != lib.RoleIBMember) { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Only IB roles can change list visibility") + return + } + + var req struct { + VisibilityOrgID *string `json:"visibility_org_id"` // null to clear + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + + // Read the entry and update its data + entry, err := lib.EntryByID(h.DB, h.Cfg, entryID) + if err != nil || entry == nil || entry.Type != lib.TypeRequestList || entry.ProjectID != projectID { + ErrorResponse(w, http.StatusNotFound, "not_found", "Request list not found") + return + } + + var rlData lib.RequestListData + if entry.DataText != "" { + json.Unmarshal([]byte(entry.DataText), &rlData) + } + + if req.VisibilityOrgID != nil { + rlData.VisibilityOrgID = *req.VisibilityOrgID + } else { + rlData.VisibilityOrgID = "" + } + + dataJSON, _ := json.Marshal(rlData) + projectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) + dataPacked, _ := lib.Pack(projectKey, string(dataJSON)) + + now := time.Now().UnixMilli() + _, err = h.DB.Conn.Exec( + `UPDATE entries SET data = ?, updated_at = ?, version = version + 1 WHERE entry_id = ?`, + dataPacked, now, entryID, + ) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update visibility") + return + } + + JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"}) +} + // --------------------------------------------------------------------------- // Tree API endpoints // --------------------------------------------------------------------------- @@ -2207,6 +2387,15 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) { return } + // Get actor's role and org for visibility filtering + actorRole, _ := lib.GetUserHighestRole(h.DB, actorID, projectID) + actorUser, _ := lib.UserByID(h.DB, actorID) + actorOrgID := "" + if actorUser != nil { + actorOrgID = actorUser.OrgID + } + isIBRole := actorRole == lib.RoleIBAdmin || actorRole == lib.RoleIBMember + rows, err := h.DB.Conn.Query( `SELECT entry_id, project_id, parent_id, type, depth, sort_order, search_key, search_key2, summary, data, stage, @@ -2280,6 +2469,22 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) { childrenOf[te.ParentID] = append(childrenOf[te.ParentID], te.EntryID) } + // Build set of hidden request_list IDs based on visibility + hiddenLists := map[string]bool{} + if !isIBRole { + for _, te := range all { + if te.Type == lib.TypeRequestList { + if dataMap, ok := te.Data.(map[string]any); ok { + if visOrgID, ok := dataMap["visibility_org_id"].(string); ok && visOrgID != "" { + if visOrgID != actorOrgID { + hiddenLists[te.EntryID] = true + } + } + } + } + } + } + // Get answer link counts linkCounts, _ := lib.AnswerLinkCountsByProject(h.DB, projectID) @@ -2306,6 +2511,10 @@ func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) { if te == nil { continue } + // Skip hidden request_lists and their subtrees + if hiddenLists[te.EntryID] { + continue + } item := treeItem{ EntryID: te.EntryID, ParentID: te.ParentID, diff --git a/api/routes.go b/api/routes.go index 22b7beb..a8022e7 100644 --- a/api/routes.go +++ b/api/routes.go @@ -85,9 +85,13 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. // Answers (picker) r.Get("/projects/{projectID}/answers", h.ListAnswers) - // File upload/download + // File upload/download/preview r.Post("/projects/{projectID}/objects", h.UploadObject) r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject) + r.Get("/projects/{projectID}/objects/{objectID}/preview", h.PreviewObject) + + // Request list visibility + r.Patch("/projects/{projectID}/entries/{entryID}/visibility", h.UpdateRequestListVisibility) // Super admin endpoints // Organizations (platform level) diff --git a/lib/types.go b/lib/types.go index f21cf4f..fe14e2a 100644 --- a/lib/types.go +++ b/lib/types.go @@ -261,8 +261,9 @@ type SectionData struct { // RequestListData is the JSON structure packed into a request_list entry's Data field. type RequestListData struct { - Name string `json:"name"` - OrgID string `json:"org_id,omitempty"` // PE firm org entry_id + Name string `json:"name"` + OrgID string `json:"org_id,omitempty"` // PE firm org entry_id + VisibilityOrgID string `json:"visibility_org_id,omitempty"` // restrict visibility to this org (+ IB) } // WorkstreamData is the JSON structure packed into a workstream entry's Data field. diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index 9645a83..a0539b8 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -103,6 +103,12 @@ +
+ + +
@@ -277,12 +283,18 @@ if (item.type === 'request_list') { const name = d.name || 'Request List'; const childCount = item.children_count || 0; + const visOrgId = d.visibility_org_id || ''; + const visBadge = visOrgId + ? `Restricted` + : `All`; html += ` ${chevron} ${escHtml(name)} + ${visBadge} ${childCount} items + `; } else if (item.type === 'section') { @@ -580,9 +592,14 @@ list.innerHTML = answers.map(a => { const d = a.data || {}; const name = d.filename || d.name || d.title || 'Document'; + const objectID = d.object_id || ''; + const previewUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '/preview?filename=' + encodeURIComponent(name) : ''; return `
${escHtml(name)} - +
+ ${previewUrl ? `Preview` : ''} + +
`; }).join(''); } catch(e) { @@ -719,11 +736,26 @@ } // ---- Import ---- - function openImportModal() { + async function openImportModal() { document.getElementById('importModal').style.display='flex'; document.getElementById('importFile').value=''; document.getElementById('importListName').value=''; document.getElementById('importError').classList.add('hidden'); + // Populate visibility org options + try { + const res = await fetchAPI('/api/projects/' + projectID + '/orgs'); + const orgs = await res.json(); + const sel = document.getElementById('importVisibility'); + sel.innerHTML = ''; + if (orgs && orgs.length > 0) { + for (const o of orgs) { + const d = parseData(o.data_text); + const orgName = d.org_name || d.name || 'Unknown'; + const orgId = d.org_id || o.entry_id || ''; + sel.innerHTML += ``; + } + } + } catch(e) {} } function closeImportModal() { document.getElementById('importModal').style.display='none'; } @@ -737,6 +769,7 @@ fd.append('file', file); fd.append('mode', document.getElementById('importMode').value); fd.append('list_name', document.getElementById('importListName').value); + fd.append('visibility_org_id', document.getElementById('importVisibility').value); try { const res = await fetch('/api/projects/' + projectID + '/requests/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: fd }); const data = await res.json(); @@ -747,6 +780,62 @@ finally { btn.disabled = false; btn.textContent = 'Import'; } } + // ---- Visibility edit ---- + async function editVisibility(entryId) { + const item = treeData.find(t => t.entry_id === entryId); + if (!item) return; + const d = item.data || {}; + const currentVis = d.visibility_org_id || ''; + + // Fetch orgs for the selector + let orgs = []; + try { + const res = await fetchAPI('/api/projects/' + projectID + '/orgs'); + orgs = await res.json() || []; + } catch(e) {} + + let opts = ''; + for (const o of orgs) { + const od = parseData(o.data_text); + const orgName = od.org_name || od.name || 'Unknown'; + const orgId = od.org_id || o.entry_id || ''; + opts += ``; + } + + const row = document.querySelector(`tr[data-id="${entryId}"]`); + if (!row) return; + const td = row.querySelector('td:nth-child(2)'); + if (!td) return; + td.dataset.origHtml = td.innerHTML; + td.innerHTML += `
+ + + +
`; + } + + function cancelVisibility(entryId) { + const row = document.querySelector(`tr[data-id="${entryId}"]`); + if (!row) return; + const td = row.querySelector('td:nth-child(2)'); + if (td && td.dataset.origHtml) td.innerHTML = td.dataset.origHtml; + } + + async function saveVisibility(entryId) { + const sel = document.getElementById('vis-sel-' + entryId); + if (!sel) return; + const val = sel.value || null; + try { + await fetchAPI('/api/projects/' + projectID + '/entries/' + entryId + '/visibility', { + method: 'PATCH', + body: JSON.stringify({ visibility_org_id: val }) + }); + loadRequestTree(); + } catch(e) { + console.error('Visibility update failed:', e); + } + } + // ---- Init ---- loadProject(); loadRequestTree(); diff --git a/portal/templates/app/request.html b/portal/templates/app/request.html index c40288b..889f13c 100644 --- a/portal/templates/app/request.html +++ b/portal/templates/app/request.html @@ -245,6 +245,8 @@ const icon = name.endsWith('.pdf') ? '\ud83d\udcc4' : name.match(/\.(jpg|jpeg|png|gif)$/i) ? '\ud83d\uddbc\ufe0f' : name.match(/\.(xlsx?|csv)$/i) ? '\ud83d\udcca' : '\ud83d\udcce'; const objectID = d.object_id || ''; const downloadUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '?filename=' + encodeURIComponent(name) : '#'; + const previewUrl = objectID ? '/api/projects/' + projectID + '/objects/' + objectID + '/preview?filename=' + encodeURIComponent(name) : ''; + const isVideo = /\.(mp4|mov|avi|mkv|webm)$/i.test(name); return `
${icon}
@@ -256,7 +258,10 @@
${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}
- ${objectID ? `Download` : ''} +
+ ${objectID && previewUrl ? `${isVideo ? 'Play' : 'Preview'}` : ''} + ${objectID ? `Download` : ''} +
`; }).join(''); }