package handler import ( "crypto/rand" "encoding/hex" "fmt" "net/http" "strconv" "strings" "dealroom/internal/model" "dealroom/templates" ) // requireAdmin wraps requireAuth and checks for owner/admin role func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return h.requireAuth(func(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) if profile.Role != "owner" && profile.Role != "admin" { http.Error(w, "Forbidden", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } func generateID(prefix string) string { b := make([]byte, 8) rand.Read(b) return prefix + "-" + hex.EncodeToString(b) } // --- Admin Dashboard --- func (h *Handler) handleAdmin(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) var stats templates.AdminStats h.db.QueryRow("SELECT COUNT(*) FROM contacts WHERE organization_id = ?", profile.OrganizationID).Scan(&stats.ContactCount) h.db.QueryRow("SELECT COUNT(*) FROM deals WHERE organization_id = ?", profile.OrganizationID).Scan(&stats.DealCount) h.db.QueryRow("SELECT COUNT(*) FROM profiles WHERE organization_id = ?", profile.OrganizationID).Scan(&stats.UserCount) h.db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&stats.OrgCount) templates.AdminDashboard(profile, stats).Render(r.Context(), w) } // --- Contacts CRUD --- func (h *Handler) handleAdminContacts(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) filter := r.URL.Query().Get("type") query := "SELECT id, full_name, email, phone, company, title, contact_type, tags, notes FROM contacts WHERE organization_id = ?" args := []interface{}{profile.OrganizationID} if filter != "" { query += " AND contact_type = ?" args = append(args, filter) } query += " ORDER BY full_name" rows, err := h.db.Query(query, args...) if err != nil { http.Error(w, "Error loading contacts", 500) return } defer rows.Close() var contacts []*model.Contact for rows.Next() { c := &model.Contact{} rows.Scan(&c.ID, &c.FullName, &c.Email, &c.Phone, &c.Company, &c.Title, &c.ContactType, &c.Tags, &c.Notes) contacts = append(contacts, c) } templates.AdminContacts(profile, contacts, filter).Render(r.Context(), w) } func (h *Handler) handleAdminContactForm(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") var contact model.Contact if id != "" { h.db.QueryRow("SELECT id, full_name, email, phone, company, title, contact_type, tags, notes FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan( &contact.ID, &contact.FullName, &contact.Email, &contact.Phone, &contact.Company, &contact.Title, &contact.ContactType, &contact.Tags, &contact.Notes) } templates.AdminContactForm(profile, &contact).Render(r.Context(), w) } func (h *Handler) handleAdminContactSave(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) r.ParseForm() id := r.FormValue("id") fullName := strings.TrimSpace(r.FormValue("full_name")) email := strings.TrimSpace(r.FormValue("email")) phone := strings.TrimSpace(r.FormValue("phone")) company := strings.TrimSpace(r.FormValue("company")) title := strings.TrimSpace(r.FormValue("title")) contactType := r.FormValue("contact_type") tags := strings.TrimSpace(r.FormValue("tags")) notes := strings.TrimSpace(r.FormValue("notes")) if fullName == "" { http.Error(w, "Name is required", 400) return } if id == "" { id = generateID("contact") _, err := h.db.Exec("INSERT INTO contacts (id, organization_id, full_name, email, phone, company, title, contact_type, tags, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", id, profile.OrganizationID, fullName, email, phone, company, title, contactType, tags, notes) if err != nil { http.Error(w, "Error creating contact", 500) return } } else { _, err := h.db.Exec("UPDATE contacts SET full_name=?, email=?, phone=?, company=?, title=?, contact_type=?, tags=?, notes=? WHERE id=? AND organization_id=?", fullName, email, phone, company, title, contactType, tags, notes, id, profile.OrganizationID) if err != nil { http.Error(w, "Error updating contact", 500) return } } http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther) } func (h *Handler) handleAdminContactDelete(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") if id == "" { http.Error(w, "Missing id", 400) return } h.db.Exec("DELETE FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID) http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther) } // --- Deals CRUD --- func (h *Handler) handleAdminDeals(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) rows, err := h.db.Query("SELECT id, name, description, target_company, stage, deal_size, currency, is_archived, created_at FROM deals WHERE organization_id = ? ORDER BY created_at DESC", profile.OrganizationID) if err != nil { http.Error(w, "Error loading deals", 500) return } defer rows.Close() var deals []*model.Deal for rows.Next() { d := &model.Deal{} rows.Scan(&d.ID, &d.Name, &d.Description, &d.TargetCompany, &d.Stage, &d.DealSize, &d.Currency, &d.IsArchived, &d.CreatedAt) deals = append(deals, d) } templates.AdminDeals(profile, deals).Render(r.Context(), w) } func (h *Handler) handleAdminDealForm(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") var deal model.Deal deal.Currency = "USD" if id != "" { h.db.QueryRow("SELECT id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, COALESCE(industry, ''), COALESCE(buyer_can_comment, 1), COALESCE(seller_can_comment, 1), is_archived FROM deals WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan( &deal.ID, &deal.Name, &deal.Description, &deal.TargetCompany, &deal.Stage, &deal.DealSize, &deal.Currency, &deal.IOIDate, &deal.LOIDate, &deal.ExclusivityEnd, &deal.ExpectedCloseDate, &deal.CloseProbability, &deal.Industry, &deal.BuyerCanComment, &deal.SellerCanComment, &deal.IsArchived) } templates.AdminDealForm(profile, &deal).Render(r.Context(), w) } func (h *Handler) handleAdminDealSave(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) r.ParseForm() id := r.FormValue("id") name := strings.TrimSpace(r.FormValue("name")) description := strings.TrimSpace(r.FormValue("description")) 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") expectedClose := r.FormValue("expected_close_date") closeProbability, _ := strconv.Atoi(r.FormValue("close_probability")) industry := strings.TrimSpace(r.FormValue("industry")) buyerCanComment := r.FormValue("buyer_can_comment") == "on" sellerCanComment := r.FormValue("seller_can_comment") == "on" isArchived := r.FormValue("is_archived") == "on" if name == "" { http.Error(w, "Name is required", 400) return } if currency == "" { currency = "USD" } if stage == "" { stage = "pipeline" } if id == "" { 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, expected_close_date, close_probability, industry, buyer_can_comment, seller_can_comment, is_archived, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, profile.OrganizationID, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, industry, buyerCanComment, sellerCanComment, isArchived, profile.ID) if err != nil { http.Error(w, fmt.Sprintf("Error creating deal: %v", err), 500) return } } else { _, err := h.db.Exec(`UPDATE deals SET name=?, description=?, target_company=?, stage=?, deal_size=?, currency=?, ioi_date=?, loi_date=?, exclusivity_end_date=?, expected_close_date=?, close_probability=?, industry=?, buyer_can_comment=?, seller_can_comment=?, is_archived=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?`, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, industry, buyerCanComment, sellerCanComment, isArchived, id, profile.OrganizationID) if err != nil { http.Error(w, fmt.Sprintf("Error updating deal: %v", err), 500) return } } http.Redirect(w, r, "/admin/deals", http.StatusSeeOther) } func (h *Handler) handleAdminDealDelete(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") if id == "" { http.Error(w, "Missing id", 400) return } // Delete related data first h.db.Exec("DELETE FROM deal_activity WHERE deal_id = ?", id) h.db.Exec("DELETE FROM diligence_requests WHERE deal_id = ?", id) h.db.Exec("DELETE FROM files WHERE deal_id = ?", id) h.db.Exec("DELETE FROM folders WHERE deal_id = ?", id) h.db.Exec("DELETE FROM deals WHERE id = ? AND organization_id = ?", id, profile.OrganizationID) http.Redirect(w, r, "/admin/deals", http.StatusSeeOther) } // --- Users/Profiles CRUD --- func (h *Handler) handleAdminUsers(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) rows, err := h.db.Query("SELECT id, email, full_name, role, created_at FROM profiles WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID) if err != nil { http.Error(w, "Error loading users", 500) return } defer rows.Close() var users []*model.Profile for rows.Next() { u := &model.Profile{} rows.Scan(&u.ID, &u.Email, &u.FullName, &u.Role, &u.CreatedAt) users = append(users, u) } templates.AdminUsers(profile, users).Render(r.Context(), w) } func (h *Handler) handleAdminUserForm(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") var user model.Profile if id != "" { h.db.QueryRow("SELECT id, email, full_name, role, COALESCE(buyer_group, '') FROM profiles WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan( &user.ID, &user.Email, &user.FullName, &user.Role, &user.BuyerGroup) } templates.AdminUserForm(profile, &user).Render(r.Context(), w) } func (h *Handler) handleAdminUserSave(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) r.ParseForm() id := r.FormValue("id") email := strings.TrimSpace(r.FormValue("email")) fullName := strings.TrimSpace(r.FormValue("full_name")) role := r.FormValue("role") buyerGroup := strings.TrimSpace(r.FormValue("buyer_group")) if email == "" || fullName == "" { http.Error(w, "Email and name are required", 400) return } password := strings.TrimSpace(r.FormValue("password")) if id == "" { id = generateID("user") var passHash string if password != "" { var err error passHash, err = hashPassword(password) if err != nil { http.Error(w, "Error hashing password", 500) return } } _, err := h.db.Exec("INSERT INTO profiles (id, email, full_name, organization_id, role, password_hash, buyer_group) VALUES (?, ?, ?, ?, ?, ?, ?)", id, email, fullName, profile.OrganizationID, role, passHash, buyerGroup) if err != nil { http.Error(w, "Error creating user", 500) return } } else { _, err := h.db.Exec("UPDATE profiles SET email=?, full_name=?, role=?, buyer_group=? WHERE id=? AND organization_id=?", email, fullName, role, buyerGroup, id, profile.OrganizationID) if err != nil { http.Error(w, "Error updating user", 500) return } } http.Redirect(w, r, "/admin/users", http.StatusSeeOther) } func (h *Handler) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") if id == "" || id == profile.ID { http.Error(w, "Cannot delete yourself", 400) return } h.db.Exec("DELETE FROM sessions WHERE user_id = ?", id) h.db.Exec("DELETE FROM profiles WHERE id = ? AND organization_id = ?", id, profile.OrganizationID) http.Redirect(w, r, "/admin/users", http.StatusSeeOther) } // --- Organizations CRUD --- func (h *Handler) handleAdminOrgs(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) rows, err := h.db.Query("SELECT id, name, slug, created_at FROM organizations ORDER BY name") if err != nil { http.Error(w, "Error loading organizations", 500) return } defer rows.Close() var orgs []*model.Organization for rows.Next() { o := &model.Organization{} rows.Scan(&o.ID, &o.Name, &o.Slug, &o.CreatedAt) orgs = append(orgs, o) } templates.AdminOrgs(profile, orgs).Render(r.Context(), w) } func (h *Handler) handleAdminOrgForm(w http.ResponseWriter, r *http.Request) { profile := getProfile(r.Context()) id := r.URL.Query().Get("id") var org model.Organization if id != "" { h.db.QueryRow("SELECT id, name, slug, COALESCE(org_type, 'company') FROM organizations WHERE id = ?", id).Scan(&org.ID, &org.Name, &org.Slug, &org.OrgType) } templates.AdminOrgForm(profile, &org).Render(r.Context(), w) } func (h *Handler) handleAdminOrgSave(w http.ResponseWriter, r *http.Request) { r.ParseForm() id := r.FormValue("id") name := strings.TrimSpace(r.FormValue("name")) slug := strings.TrimSpace(r.FormValue("slug")) orgType := r.FormValue("org_type") if orgType == "" { orgType = "company" } if name == "" || slug == "" { http.Error(w, "Name and slug are required", 400) return } if id == "" { id = generateID("org") _, err := h.db.Exec("INSERT INTO organizations (id, name, slug, org_type) VALUES (?, ?, ?, ?)", id, name, slug, orgType) if err != nil { http.Error(w, "Error creating organization", 500) return } } else { _, err := h.db.Exec("UPDATE organizations SET name=?, slug=?, org_type=? WHERE id=?", name, slug, orgType, id) if err != nil { http.Error(w, "Error updating organization", 500) return } } http.Redirect(w, r, "/admin/organizations", http.StatusSeeOther) } func (h *Handler) handleAdminOrgDelete(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { http.Error(w, "Missing id", 400) return } // Check if org has users var count int h.db.QueryRow("SELECT COUNT(*) FROM profiles WHERE organization_id = ?", id).Scan(&count) if count > 0 { http.Error(w, "Cannot delete organization with users", 400) return } h.db.Exec("DELETE FROM organizations WHERE id = ?", id) http.Redirect(w, r, "/admin/organizations", http.StatusSeeOther) }