package handler import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "dealroom/internal/model" "dealroom/internal/rbac" "dealroom/templates" ) func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } profile := getProfile(r.Context()) deals := h.getDeals(profile) activities := h.getActivities(profile.OrganizationID, 8) // Count files per deal fileCounts := make(map[string]int) for _, d := range deals { var count int h.db.QueryRow("SELECT COUNT(*) FROM files WHERE deal_id = ?", d.ID).Scan(&count) fileCounts[d.ID] = count d.FileCount = count } // Build deal name map and populate DealName on activities dealNames := make(map[string]string) for _, d := range deals { dealNames[d.ID] = d.Name } for _, act := range activities { if name, ok := dealNames[act.DealID]; ok { act.DealName = name } } // Get last activity time per deal lastActivity := make(map[string]*time.Time) for _, d := range deals { t := h.getLastActivityByDeal(d.ID) if t != nil { lastActivity[d.ID] = t } } templates.Dashboard(profile, deals, activities, fileCounts, lastActivity).Render(r.Context(), w) } func (h *Handler) handleDealRooms(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) deals := h.getDeals(profile) for _, d := range deals { var count int h.db.QueryRow("SELECT COUNT(*) FROM files WHERE deal_id = ?", d.ID).Scan(&count) d.FileCount = count } templates.DealRooms(profile, deals).Render(r.Context(), w) } func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) dealID := strings.TrimPrefix(r.URL.Path, "/deals/") // Handle file status PATCH: /deals/{id}/files/{fileID}/status if strings.Contains(dealID, "/files/") { parts := strings.SplitN(dealID, "/files/", 2) dealID = parts[0] if r.Method == http.MethodPatch { h.handleFileStatusUpdate(w, r, dealID, parts[1]) return } } if dealID == "" { http.Redirect(w, r, "/deals", http.StatusSeeOther) return } var deal model.Deal err := h.db.QueryRow(`SELECT id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by, COALESCE(industry, '') FROM deals WHERE id = ?`, dealID).Scan( &deal.ID, &deal.OrganizationID, &deal.Name, &deal.Description, &deal.TargetCompany, &deal.Stage, &deal.DealSize, &deal.Currency, &deal.IOIDate, &deal.LOIDate, &deal.ExclusivityEnd, &deal.ExpectedCloseDate, &deal.CloseProbability, &deal.CreatedBy, &deal.Industry) if err != nil { http.NotFound(w, r) return } folders := h.getFolders(dealID) folderParam := r.URL.Query().Get("folder") files := h.getFiles(dealID) requests := h.getRequests(dealID, profile) templates.DealRoomDetail(profile, &deal, folders, files, requests, folderParam).Render(r.Context(), w) } func (h *Handler) handleCreateDeal(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } profile := getProfile(r.Context()) r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) if name == "" { http.Error(w, "Project name is required", 400) return } targetCompany := strings.TrimSpace(r.FormValue("target_company")) stage := r.FormValue("stage") if stage == "" { stage = "prospect" } dealSize, _ := strconv.ParseFloat(r.FormValue("deal_size"), 64) currency := r.FormValue("currency") if currency == "" { currency = "USD" } ioiDate := r.FormValue("ioi_date") loiDate := r.FormValue("loi_date") exclusivityEnd := r.FormValue("exclusivity_end") industry := strings.TrimSpace(r.FormValue("industry")) description := strings.TrimSpace(r.FormValue("description")) folderStructure := strings.TrimSpace(r.FormValue("folder_structure")) inviteEmails := strings.TrimSpace(r.FormValue("invite_emails")) id := generateID("deal") _, err := h.db.Exec(`INSERT INTO deals (id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, industry, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, profile.OrganizationID, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, industry, profile.ID) if err != nil { http.Error(w, fmt.Sprintf("Error creating deal: %v", err), 500) return } // Create folder structure from textarea if folderStructure != "" { lines := strings.Split(folderStructure, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, "/") parentID := "" for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } folderID := generateID("folder") h.db.Exec("INSERT INTO folders (id, deal_id, parent_id, name, created_by) VALUES (?, ?, ?, ?, ?)", folderID, id, parentID, part, profile.ID) parentID = folderID } } } // Create invites for initial team if inviteEmails != "" { emails := strings.Split(inviteEmails, "\n") for _, email := range emails { email = strings.TrimSpace(email) if email == "" { continue } token := generateToken() expiresAt := time.Now().Add(7 * 24 * time.Hour) h.db.Exec("INSERT INTO invites (token, org_id, email, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)", token, profile.OrganizationID, email, "member", profile.ID, expiresAt) } } http.Redirect(w, r, "/deals/"+id, http.StatusSeeOther) } func (h *Handler) handleFileStatusUpdate(w http.ResponseWriter, r *http.Request, dealID string, remaining string) { fileID := strings.TrimSuffix(remaining, "/status") if fileID == "" { http.Error(w, "Missing file ID", 400) return } var req struct { Status string `json:"status"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", 400) return } validStatuses := map[string]bool{"uploaded": true, "processing": true, "reviewed": true, "flagged": true, "archived": true} if !validStatuses[req.Status] { http.Error(w, "Invalid status", 400) return } _, err := h.db.Exec("UPDATE files SET status = ? WHERE id = ? AND deal_id = ?", req.Status, fileID, dealID) if err != nil { http.Error(w, "Error updating status", 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": req.Status}) } func (h *Handler) handleFolderCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } profile := getProfile(r.Context()) dealID := r.FormValue("deal_id") name := strings.TrimSpace(r.FormValue("name")) parentID := r.FormValue("parent_id") if dealID == "" || name == "" { http.Error(w, "Deal ID and name are required", 400) return } folderID := generateID("folder") h.db.Exec("INSERT INTO folders (id, deal_id, parent_id, name, created_by) VALUES (?, ?, ?, ?, ?)", folderID, dealID, parentID, name, profile.ID) http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) } func (h *Handler) handleFolderRename(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } folderID := r.FormValue("folder_id") dealID := r.FormValue("deal_id") name := strings.TrimSpace(r.FormValue("name")) if folderID == "" || name == "" { http.Error(w, "Missing fields", 400) return } h.db.Exec("UPDATE folders SET name = ? WHERE id = ?", name, folderID) http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) } func (h *Handler) handleFolderDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } folderID := r.FormValue("folder_id") dealID := r.FormValue("deal_id") if folderID == "" { http.Error(w, "Missing folder ID", 400) return } // Delete child folders h.db.Exec("DELETE FROM folders WHERE parent_id = ?", folderID) // Move files to root h.db.Exec("UPDATE files SET folder_id = '' WHERE folder_id = ?", folderID) h.db.Exec("DELETE FROM folders WHERE id = ?", folderID) http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) } func (h *Handler) handleFolderReorder(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } folderID := r.FormValue("folder_id") dealID := r.FormValue("deal_id") direction := r.FormValue("direction") if folderID == "" { http.Error(w, "Missing folder ID", 400) return } var currentOrder int h.db.QueryRow("SELECT COALESCE(sort_order, 0) FROM folders WHERE id = ?", folderID).Scan(¤tOrder) if direction == "up" { h.db.Exec("UPDATE folders SET sort_order = sort_order + 1 WHERE deal_id = ? AND COALESCE(sort_order, 0) = ?", dealID, currentOrder-1) h.db.Exec("UPDATE folders SET sort_order = ? WHERE id = ?", currentOrder-1, folderID) } else { h.db.Exec("UPDATE folders SET sort_order = sort_order - 1 WHERE deal_id = ? AND COALESCE(sort_order, 0) = ?", dealID, currentOrder+1) h.db.Exec("UPDATE folders SET sort_order = ? WHERE id = ?", currentOrder+1, folderID) } http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) } func (h *Handler) handleDealSearch(w http.ResponseWriter, r *http.Request) { // /deals/search/{dealID}?q=... dealID := strings.TrimPrefix(r.URL.Path, "/deals/search/") q := r.URL.Query().Get("q") if q == "" { w.Write([]byte(`

Type to search files, folders, and requests.

`)) return } pattern := "%" + q + "%" w.Header().Set("Content-Type", "text/html") html := `
` // Search files rows, _ := h.db.Query("SELECT id, name, folder_id FROM files WHERE deal_id = ? AND name LIKE ?", dealID, pattern) if rows != nil { hasFiles := false for rows.Next() { if !hasFiles { html += `

Files

` hasFiles = true } var id, name, folderID string rows.Scan(&id, &name, &folderID) html += fmt.Sprintf(`
%s
`, id, name) } rows.Close() } // Search folders rows2, _ := h.db.Query("SELECT id, name FROM folders WHERE deal_id = ? AND name LIKE ?", dealID, pattern) if rows2 != nil { hasFolders := false for rows2.Next() { if !hasFolders { html += `

Folders

` hasFolders = true } var id, name string rows2.Scan(&id, &name) html += fmt.Sprintf(`
%s
`, dealID, id, name) } rows2.Close() } // Search requests rows3, _ := h.db.Query("SELECT id, item_number, description FROM diligence_requests WHERE deal_id = ? AND description LIKE ?", dealID, pattern) if rows3 != nil { hasReqs := false for rows3.Next() { if !hasReqs { html += `

Request Items

` hasReqs = true } var id, itemNum, desc string rows3.Scan(&id, &itemNum, &desc) html += fmt.Sprintf(`
%s %s
`, itemNum, desc) } rows3.Close() } html += `
` w.Write([]byte(html)) } func (h *Handler) getLastActivityByDeal(dealID string) *time.Time { var t time.Time err := h.db.QueryRow("SELECT MAX(created_at) FROM deal_activity WHERE deal_id = ?", dealID).Scan(&t) if err != nil { return nil } if t.IsZero() { return nil } return &t } func (h *Handler) getDeals(profile *model.Profile) []*model.Deal { rows, err := h.db.Query("SELECT id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by, created_at FROM deals WHERE organization_id = ? AND is_archived = 0 ORDER BY created_at DESC", profile.OrganizationID) if err != nil { return nil } defer rows.Close() var deals []*model.Deal for rows.Next() { d := &model.Deal{} rows.Scan(&d.ID, &d.OrganizationID, &d.Name, &d.Description, &d.TargetCompany, &d.Stage, &d.DealSize, &d.Currency, &d.IOIDate, &d.LOIDate, &d.ExclusivityEnd, &d.ExpectedCloseDate, &d.CloseProbability, &d.CreatedBy, &d.CreatedAt) deals = append(deals, d) } return deals } func (h *Handler) getFolders(dealID string) []*model.Folder { rows, err := h.db.Query("SELECT id, deal_id, parent_id, name, description FROM folders WHERE deal_id = ? ORDER BY COALESCE(sort_order, 0), name", dealID) if err != nil { return nil } defer rows.Close() var folders []*model.Folder for rows.Next() { f := &model.Folder{} rows.Scan(&f.ID, &f.DealID, &f.ParentID, &f.Name, &f.Description) folders = append(folders, f) } return folders } func (h *Handler) getFiles(dealID string) []*model.File { rows, err := h.db.Query("SELECT id, deal_id, folder_id, name, file_size, mime_type, status, uploaded_by, created_at FROM files WHERE deal_id = ? ORDER BY name", dealID) if err != nil { return nil } defer rows.Close() var files []*model.File for rows.Next() { f := &model.File{} rows.Scan(&f.ID, &f.DealID, &f.FolderID, &f.Name, &f.FileSize, &f.MimeType, &f.Status, &f.UploadedBy, &f.CreatedAt) files = append(files, f) } return files } func (h *Handler) getRequests(dealID string, profile *model.Profile) []*model.DiligenceRequest { query := "SELECT id, deal_id, item_number, section, description, priority, atlas_status, atlas_note, confidence, buyer_comment, seller_comment, buyer_group, linked_file_ids, COALESCE(is_buyer_specific, 0), COALESCE(visible_to_buyer_group, '') FROM diligence_requests WHERE deal_id = ?" args := []interface{}{dealID} if rbac.EffectiveIsBuyer(profile) { groups := rbac.BuyerGroups(profile) if len(groups) > 0 { placeholders := make([]string, len(groups)) for i, g := range groups { placeholders[i] = "?" args = append(args, g) } // Show general requests (not buyer-specific) AND buyer-specific ones for this group query += " AND (COALESCE(is_buyer_specific, 0) = 0 OR COALESCE(visible_to_buyer_group, '') IN (" + strings.Join(placeholders, ",") + "))" // Also filter by buyer_group for general requests query += " AND (buyer_group IN (" + strings.Join(placeholders, ",") + ") OR buyer_group = '')" for _, g := range groups { args = append(args, g) } } } query += " ORDER BY item_number" rows, err := h.db.Query(query, args...) if err != nil { return nil } defer rows.Close() var reqs []*model.DiligenceRequest for rows.Next() { r := &model.DiligenceRequest{} rows.Scan(&r.ID, &r.DealID, &r.ItemNumber, &r.Section, &r.Description, &r.Priority, &r.AtlasStatus, &r.AtlasNote, &r.Confidence, &r.BuyerComment, &r.SellerComment, &r.BuyerGroup, &r.LinkedFileIDs, &r.IsBuyerSpecific, &r.VisibleToBuyerGroup) reqs = append(reqs, r) } return reqs } func (h *Handler) getActivities(orgID string, limit int) []*model.DealActivity { rows, err := h.db.Query(` SELECT a.id, a.deal_id, a.user_id, a.activity_type, a.resource_type, a.resource_name, a.created_at, COALESCE(p.full_name, 'Unknown') FROM deal_activity a LEFT JOIN profiles p ON a.user_id = p.id WHERE a.organization_id = ? ORDER BY a.created_at DESC LIMIT ? `, orgID, limit) if err != nil { return nil } defer rows.Close() var acts []*model.DealActivity for rows.Next() { a := &model.DealActivity{} rows.Scan(&a.ID, &a.DealID, &a.UserID, &a.ActivityType, &a.ResourceType, &a.ResourceName, &a.CreatedAt, &a.UserName) acts = append(acts, a) } return acts } func (h *Handler) getActivitiesFiltered(orgID string, dealID string, limit int) []*model.DealActivity { return h.getActivitiesFilteredBuyer(orgID, dealID, "", limit) } func (h *Handler) handleContactSearch(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) q := r.URL.Query().Get("q") if q == "" { w.Header().Set("Content-Type", "application/json") w.Write([]byte("[]")) return } pattern := "%" + q + "%" rows, err := h.db.Query(`SELECT DISTINCT email, full_name FROM profiles WHERE organization_id = ? AND (email LIKE ? OR full_name LIKE ?) LIMIT 10`, profile.OrganizationID, pattern, pattern) if err != nil { w.Header().Set("Content-Type", "application/json") w.Write([]byte("[]")) return } defer rows.Close() type contact struct { Email string `json:"email"` Name string `json:"name"` } var contacts []contact for rows.Next() { var c contact rows.Scan(&c.Email, &c.Name) contacts = append(contacts, c) } // Also search contacts table rows2, _ := h.db.Query(`SELECT DISTINCT email, full_name FROM contacts WHERE organization_id = ? AND contact_type = 'internal' AND (email LIKE ? OR full_name LIKE ?) LIMIT 10`, profile.OrganizationID, pattern, pattern) if rows2 != nil { for rows2.Next() { var c contact rows2.Scan(&c.Email, &c.Name) contacts = append(contacts, c) } rows2.Close() } if contacts == nil { contacts = []contact{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(contacts) } func (h *Handler) handleIndustryList(w http.ResponseWriter, r *http.Request) { // Standard pre-populated industries industries := []string{ "Aerospace & Defense", "Agriculture", "Automotive", "Business Services", "Chemicals", "Consumer Products", "Education", "Energy", "Financial Services", "Food & Beverage", "Healthcare", "Hospitality & Leisure", "Industrial Manufacturing", "Insurance", "Internet & E-Commerce", "Logistics & Transportation", "Media & Entertainment", "Mining & Metals", "Pharmaceuticals", "Real Estate", "Retail", "Software & Technology", "Telecommunications", "Utilities", } // Fetch custom industries from existing deals rows, err := h.db.Query(`SELECT DISTINCT industry FROM deals WHERE organization_id = ? AND industry != '' ORDER BY industry`, getProfile(r.Context()).OrganizationID) if err == nil { defer rows.Close() existing := make(map[string]bool) for _, ind := range industries { existing[ind] = true } for rows.Next() { var ind string rows.Scan(&ind) if !existing[ind] { industries = append(industries, ind) } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(industries) } func (h *Handler) handleDealUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } r.ParseForm() dealID := r.FormValue("deal_id") if dealID == "" { http.Error(w, "Missing deal ID", 400) return } name := strings.TrimSpace(r.FormValue("name")) targetCompany := strings.TrimSpace(r.FormValue("target_company")) stage := r.FormValue("stage") dealSize, _ := strconv.ParseFloat(r.FormValue("deal_size"), 64) currency := r.FormValue("currency") ioiDate := r.FormValue("ioi_date") loiDate := r.FormValue("loi_date") exclusivityEnd := r.FormValue("exclusivity_end") industry := strings.TrimSpace(r.FormValue("industry")) description := strings.TrimSpace(r.FormValue("description")) _, err := h.db.Exec(`UPDATE deals SET name=?, description=?, target_company=?, stage=?, deal_size=?, currency=?, ioi_date=?, loi_date=?, exclusivity_end_date=?, industry=?, updated_at=datetime('now') WHERE id=?`, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, industry, dealID) if err != nil { http.Error(w, fmt.Sprintf("Error updating deal: %v", err), 500) return } http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) } func (h *Handler) getActivitiesFilteredBuyer(orgID string, dealID string, buyerGroup string, limit int) []*model.DealActivity { query := ` SELECT a.id, a.deal_id, a.user_id, a.activity_type, a.resource_type, a.resource_name, a.created_at, COALESCE(p.full_name, 'Unknown') FROM deal_activity a LEFT JOIN profiles p ON a.user_id = p.id WHERE a.organization_id = ?` args := []interface{}{orgID} if dealID != "" { query += " AND a.deal_id = ?" args = append(args, dealID) } if buyerGroup != "" { query += " AND a.buyer_group = ?" args = append(args, buyerGroup) } query += " ORDER BY a.created_at DESC LIMIT ?" args = append(args, limit) rows, err := h.db.Query(query, args...) if err != nil { return nil } defer rows.Close() var acts []*model.DealActivity for rows.Next() { a := &model.DealActivity{} rows.Scan(&a.ID, &a.DealID, &a.UserID, &a.ActivityType, &a.ResourceType, &a.ResourceName, &a.CreatedAt, &a.UserName) acts = append(acts, a) } return acts }