dealroom/internal/handler/admin.go

424 lines
14 KiB
Go

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, 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.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"))
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, is_archived, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, profile.OrganizationID, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, 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=?, is_archived=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?`,
name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, 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 FROM profiles WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan(
&user.ID, &user.Email, &user.FullName, &user.Role)
}
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")
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) VALUES (?, ?, ?, ?, ?, ?)",
id, email, fullName, profile.OrganizationID, role, passHash)
if err != nil {
http.Error(w, "Error creating user", 500)
return
}
} else {
_, err := h.db.Exec("UPDATE profiles SET email=?, full_name=?, role=? WHERE id=? AND organization_id=?",
email, fullName, role, 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)
}