From 233b20efa6189ffc3dca404acfe10132ec3d1200 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 23 Feb 2026 02:49:57 -0500 Subject: [PATCH] feat: permission controls on request lists Add buyer_can_comment/seller_can_comment to deals, buyer_group to profiles. Add buyer_groups and folder_access tables for granular access control. Add permission checkboxes to admin deal form, buyer_group to admin user form. Update RBAC to use profile buyer_group field. Co-Authored-By: Claude Opus 4.6 --- internal/db/migrate.go | 21 +++++++++++++++++++++ internal/handler/admin.go | 27 +++++++++++++++------------ internal/model/models.go | 3 +++ internal/rbac/rbac.go | 5 ++++- templates/admin.templ | 3 +++ 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 89398d1..4f833ce 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -19,6 +19,8 @@ func Migrate(db *sql.DB) error { createSessions, createIndexes, createInvites, + createBuyerGroups, + createFolderAccess, } for i, m := range migrations { @@ -202,12 +204,31 @@ CREATE TABLE IF NOT EXISTS invites ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP );` +const createBuyerGroups = ` +CREATE TABLE IF NOT EXISTS buyer_groups ( + id TEXT PRIMARY KEY, + deal_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +);` + +const createFolderAccess = ` +CREATE TABLE IF NOT EXISTS folder_access ( + folder_id TEXT NOT NULL, + buyer_group TEXT NOT NULL, + PRIMARY KEY (folder_id, buyer_group) +);` + // Additive migrations - each statement is run individually, errors ignored (for already-existing columns) var additiveMigrationStmts = []string{ // Section 1: org_type `ALTER TABLE organizations ADD COLUMN org_type TEXT DEFAULT 'company'`, // Section 4: industry `ALTER TABLE deals ADD COLUMN industry TEXT DEFAULT ''`, + // Section 5: permission controls + `ALTER TABLE deals ADD COLUMN buyer_can_comment INTEGER DEFAULT 1`, + `ALTER TABLE deals ADD COLUMN seller_can_comment INTEGER DEFAULT 1`, + `ALTER TABLE profiles ADD COLUMN buyer_group TEXT DEFAULT ''`, } func seed(db *sql.DB) error { diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 650e361..82a3118 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -168,8 +168,8 @@ func (h *Handler) handleAdminDealForm(w http.ResponseWriter, r *http.Request) { 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, ''), 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.IsArchived) + 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) @@ -192,6 +192,8 @@ func (h *Handler) handleAdminDealSave(w http.ResponseWriter, r *http.Request) { 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 == "" { @@ -207,15 +209,15 @@ func (h *Handler) handleAdminDealSave(w http.ResponseWriter, r *http.Request) { 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, is_archived, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - id, profile.OrganizationID, name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, industry, isArchived, profile.ID) + _, 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=?, is_archived=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?`, - name, description, targetCompany, stage, dealSize, currency, ioiDate, loiDate, exclusivityEnd, expectedClose, closeProbability, industry, isArchived, id, profile.OrganizationID) + _, 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 @@ -271,8 +273,8 @@ func (h *Handler) handleAdminUserForm(w http.ResponseWriter, r *http.Request) { 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) + 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) @@ -286,6 +288,7 @@ func (h *Handler) handleAdminUserSave(w http.ResponseWriter, r *http.Request) { 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) @@ -305,15 +308,15 @@ func (h *Handler) handleAdminUserSave(w http.ResponseWriter, r *http.Request) { 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) + _, 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=? WHERE id=? AND organization_id=?", - email, fullName, role, id, profile.OrganizationID) + _, 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 diff --git a/internal/model/models.go b/internal/model/models.go index e575d5e..d23403f 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -32,6 +32,7 @@ type Profile struct { PasswordHash string CreatedAt time.Time LastLogin *time.Time + BuyerGroup string ViewAsBuyer bool // Not persisted, set per-request from cookie } @@ -50,6 +51,8 @@ type Deal struct { ExpectedCloseDate string CloseProbability int Industry string + BuyerCanComment bool + SellerCanComment bool IsArchived bool CreatedBy string CreatedAt time.Time diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go index 7aff7dc..d14092b 100644 --- a/internal/rbac/rbac.go +++ b/internal/rbac/rbac.go @@ -17,8 +17,11 @@ func EffectiveIsBuyer(profile *model.Profile) bool { return profile.ViewAsBuyer || IsBuyer(profile.Role) } -// BuyerGroups returns the buyer groups for the demo buyer +// BuyerGroups returns the buyer groups for the user func BuyerGroups(profile *model.Profile) []string { + if profile.BuyerGroup != "" { + return []string{profile.BuyerGroup} + } if IsBuyer(profile.Role) { return []string{"Meridian Capital", "Summit Health Equity"} } diff --git a/templates/admin.templ b/templates/admin.templ index 058ab37..50df42b 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -261,6 +261,8 @@ templ AdminDealForm(profile *model.Profile, deal *model.Deal) { @formField("exclusivity_end", "Exclusivity End", "date", deal.ExclusivityEnd, false) @formField("expected_close_date", "Expected Close", "date", deal.ExpectedCloseDate, false) + @formCheckbox("buyer_can_comment", "Allow buyer comments", deal.BuyerCanComment) + @formCheckbox("seller_can_comment", "Allow seller comments", deal.SellerCanComment) @formCheckbox("is_archived", "Archived", deal.IsArchived) @formActions("/admin/deals") @@ -353,6 +355,7 @@ templ AdminUserForm(profile *model.Profile, user *model.Profile) { {Value: "member", Label: "Member"}, {Value: "viewer", Label: "Viewer"}, }) + @formField("buyer_group", "Buyer Group", "text", user.BuyerGroup, false) @formActions("/admin/users")