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:
parent
f1c2a0ef84
commit
233b20efa6
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue