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 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-23 02:49:57 -05:00
parent f1c2a0ef84
commit 233b20efa6
5 changed files with 46 additions and 13 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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"}
}

View File

@ -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)
</div>
@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")
</form>
@ -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")
</form>
</div>