diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..93aec71 --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,408 @@ +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 + } + + if id == "" { + id = generateID("user") + _, err := h.db.Exec("INSERT INTO profiles (id, email, full_name, organization_id, role, password_hash) VALUES (?, ?, ?, ?, ?, 'demo')", + id, email, fullName, profile.OrganizationID, role) + 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 FROM organizations WHERE id = ?", id).Scan(&org.ID, &org.Name, &org.Slug) + } + + 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")) + + 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) VALUES (?, ?, ?)", id, name, slug) + if err != nil { + http.Error(w, "Error creating organization", 500) + return + } + } else { + _, err := h.db.Exec("UPDATE organizations SET name=?, slug=? WHERE id=?", name, slug, 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) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index ba52950..aa0ea05 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -48,6 +48,25 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/audit", h.requireAuth(h.handleAuditLog)) mux.HandleFunc("/analytics", h.requireAuth(h.handleAnalytics)) + // Admin CRUD + mux.HandleFunc("/admin", h.requireAdmin(h.handleAdmin)) + mux.HandleFunc("/admin/contacts", h.requireAdmin(h.handleAdminContacts)) + mux.HandleFunc("/admin/contacts/edit", h.requireAdmin(h.handleAdminContactForm)) + mux.HandleFunc("/admin/contacts/save", h.requireAdmin(h.handleAdminContactSave)) + mux.HandleFunc("/admin/contacts/delete", h.requireAdmin(h.handleAdminContactDelete)) + mux.HandleFunc("/admin/deals", h.requireAdmin(h.handleAdminDeals)) + mux.HandleFunc("/admin/deals/edit", h.requireAdmin(h.handleAdminDealForm)) + mux.HandleFunc("/admin/deals/save", h.requireAdmin(h.handleAdminDealSave)) + mux.HandleFunc("/admin/deals/delete", h.requireAdmin(h.handleAdminDealDelete)) + mux.HandleFunc("/admin/users", h.requireAdmin(h.handleAdminUsers)) + mux.HandleFunc("/admin/users/edit", h.requireAdmin(h.handleAdminUserForm)) + mux.HandleFunc("/admin/users/save", h.requireAdmin(h.handleAdminUserSave)) + mux.HandleFunc("/admin/users/delete", h.requireAdmin(h.handleAdminUserDelete)) + mux.HandleFunc("/admin/organizations", h.requireAdmin(h.handleAdminOrgs)) + mux.HandleFunc("/admin/organizations/edit", h.requireAdmin(h.handleAdminOrgForm)) + mux.HandleFunc("/admin/organizations/save", h.requireAdmin(h.handleAdminOrgSave)) + mux.HandleFunc("/admin/organizations/delete", h.requireAdmin(h.handleAdminOrgDelete)) + // HTMX partials mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment)) } diff --git a/templates/admin.templ b/templates/admin.templ new file mode 100644 index 0000000..b5c345b --- /dev/null +++ b/templates/admin.templ @@ -0,0 +1,524 @@ +package templates + +import "dealroom/internal/model" +import "fmt" +import "strings" + +func capitalizeFirst(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +type AdminStats struct { + ContactCount int + DealCount int + UserCount int + OrgCount int +} + +// --- Admin Dashboard --- + +templ AdminDashboard(profile *model.Profile, stats AdminStats) { + @Layout(profile, "admin") { +
Manage your organization's data.
+{ desc }
+ +} + +// --- Contacts List --- + +templ AdminContacts(profile *model.Profile, contacts []*model.Contact, filter string) { + @Layout(profile, "admin") { +| Name | +Company | +Type | +Tags | +Actions | +|
|---|---|---|---|---|---|
| No contacts found. | |||||
|
+
+
+
+ { contactInitials(c.FullName) }
+
+
+ { c.FullName }
+
+ { c.Title } + |
+ { c.Company } | +{ c.Email } | +@ContactTypeBadge(c.ContactType) | +
+
+ for _, tag := range splitTags(c.Tags) {
+ if tag != "" {
+ { tag }
+ }
+ }
+
+ |
+ + @editDeleteActions("/admin/contacts", c.ID) + | +
| Name | +Target Company | +Stage | +Size | +Status | +Actions | +
|---|---|---|---|---|---|
| No deals found. | |||||
| { d.Name } | +{ d.TargetCompany } | +@StageBadge(d.Stage) | +{ formatDealSizeCurrency(d.DealSize, d.Currency) } | ++ if d.IsArchived { + Archived + } else { + Active + } + | ++ @editDeleteActions("/admin/deals", d.ID) + | +
| Name | +Role | +Actions | +|
|---|---|---|---|
| No users found. | |||
|
+
+
+
+ { initials(u.FullName) }
+
+ { u.FullName }
+ |
+ { u.Email } | +@roleBadge(u.Role) | ++ if u.ID != profile.ID { + @editDeleteActions("/admin/users", u.ID) + } else { + Edit + } + | +
| Name | +Slug | +Actions | +
|---|---|---|
| No organizations found. | ||
| { o.Name } | +{ o.Slug } | ++ @editDeleteActions("/admin/organizations", o.ID) + | +