Files
descrybe/apps/api/cmd/migrator/membership_role_repair.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

240 lines
6.7 KiB
Go

package main
import (
"context"
"fmt"
"log"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/jackc/pgx/v5/pgxpool"
)
// memberMembershipRow is one active membership with role=member (cutover promote candidate).
type memberMembershipRow struct {
UserID string
Email string
CompanyID string
CompanyName string
LegacyCompanyID string
Role string
Status string
IsPlatformAdmin bool
MustSetPassword bool
}
// runMembershipRoleRepair lists and/or promotes active memberships from role=member
// to company admin (role=admin). Postgres-only post-load operator tooling.
// Live writes require confirm=true (no blind promotes). Prefer -dry-run first.
// Unscoped promote (no email/user-id/company-id) is refused.
// NEVER promotes A1 cohort memberships (a1=true) — dry-run and live both skip them.
func runMembershipRoleRepair(
postgresURL, email, userID, companyID string,
listOnly, promote bool,
dryRun, confirm bool,
) {
if postgresURL == "" {
log.Fatal("-postgres / DATABASE_URL is required for membership role tooling")
}
if !listOnly && !promote {
log.Fatal("pass -list-member-memberships and/or -promote-company-admins")
}
if err := validatePromoteTargets(promote, email, userID, companyID); err != nil {
log.Fatal(err)
}
if err := guardLiveMutation(promote, dryRun, confirm, "-promote-company-admins"); err != nil {
log.Fatal(err)
}
ctx := context.Background()
pg, err := pgxpool.New(ctx, postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
rows, err := listMemberMemberships(ctx, pg, email, userID, companyID)
if err != nil {
log.Fatalf("list member memberships: %v", err)
}
fmt.Printf("active_member_memberships: %d\n", len(rows))
listed := 0
promoted := 0
skipped := 0
a1Candidates := 0
a1Skipped := 0
for _, m := range rows {
listed++
a1 := billing.IsA1CohortCompany(m.LegacyCompanyID, m.CompanyName)
if a1 {
a1Candidates++
}
if listOnly || !promote {
fmt.Printf(" %s\t%s\t%s\t%s\ta1=%v\tplatform_admin=%v\tmust_set_password=%v\n",
m.UserID, m.Email, m.CompanyID, m.CompanyName, a1, m.IsPlatformAdmin, m.MustSetPassword)
}
mutate, skipReason := decideMembershipPromote(promote, a1)
if !mutate {
if promote && skipReason != "" {
fmt.Printf("skip\t%s\t%s\t%s\t%s\t%s\n",
m.UserID, m.Email, m.CompanyID, m.CompanyName, skipReason)
skipped++
if skipReason == "a1_cohort" {
a1Skipped++
}
}
continue
}
if dryRun {
fmt.Printf("dry-run: would promote user %s (%s) on company %s (%s) member→admin a1=false\n",
m.UserID, m.Email, m.CompanyID, m.CompanyName)
promoted++
continue
}
ok, err := promoteMembershipToAdmin(ctx, pg, m.CompanyID, m.UserID)
if err != nil {
log.Printf("promote user %s company %s: %v", m.UserID, m.CompanyID, err)
skipped++
continue
}
if !ok {
skipped++
continue
}
fmt.Printf("promoted user %s (%s) on company %s (%s) member→admin a1=false\n",
m.UserID, m.Email, m.CompanyID, m.CompanyName)
promoted++
}
if promote {
fmt.Printf("listed=%d promoted=%d skipped=%d a1_candidates=%d a1_skipped=%d dry_run=%v\n",
listed, promoted, skipped, a1Candidates, a1Skipped, dryRun)
} else {
fmt.Printf("listed=%d a1_candidates=%d\n", listed, a1Candidates)
}
}
// decideMembershipPromote is the promote gate used by dry-run and -confirm.
// A1 cohort rows always skip (never member→admin), even when confirm=true.
func decideMembershipPromote(promote, a1 bool) (mutate bool, skipReason string) {
if !promote {
return false, ""
}
if a1 {
return false, "a1_cohort"
}
return true, ""
}
// validatePromoteTargets refuses unscoped live/dry promote of every member membership.
// At least one of -email, -user-id, or -company-id is required. A1 rows are always skipped at promote time.
func validatePromoteTargets(promote bool, email, userID, companyID string) error {
if !promote {
return nil
}
if strings.TrimSpace(email) == "" && strings.TrimSpace(userID) == "" && strings.TrimSpace(companyID) == "" {
return fmt.Errorf("-promote-company-admins requires -email, -user-id, or -company-id (refusing unscoped promote; A1 rows are always skipped)")
}
return nil
}
// guardLiveMutation refuses mutating ops unless -confirm is set.
// -dry-run always previews without writes (confirm is ignored).
func guardLiveMutation(mutate, dryRun, confirm bool, flagHint string) error {
if !mutate || dryRun {
return nil
}
if !confirm {
if strings.TrimSpace(flagHint) == "" {
flagHint = "the mutating flag"
}
return fmt.Errorf("refusing live write: pass -dry-run to preview, or -confirm with %s (no blind live writes)", flagHint)
}
return nil
}
func listMemberMemberships(
ctx context.Context,
pg *pgxpool.Pool,
email, userID, companyID string,
) ([]memberMembershipRow, error) {
q := `
SELECT u.id::text,
u.email,
c.id::text,
c.name,
COALESCE(c.legacy_company_id, ''),
m.role,
m.status,
u.is_platform_admin,
u.must_set_password
FROM memberships m
JOIN users u ON u.id = m.user_id
JOIN companies c ON c.id = m.company_id
WHERE m.status = 'active'
AND m.role = 'member'`
args := make([]any, 0, 3)
argN := 1
if e := strings.TrimSpace(email); e != "" {
q += fmt.Sprintf(" AND lower(u.email) = lower($%d)", argN)
args = append(args, e)
argN++
}
if uid := strings.TrimSpace(userID); uid != "" {
q += fmt.Sprintf(" AND m.user_id = $%d::uuid", argN)
args = append(args, uid)
argN++
}
if cid := strings.TrimSpace(companyID); cid != "" {
q += fmt.Sprintf(" AND m.company_id = $%d::uuid", argN)
args = append(args, cid)
argN++
}
q += `
ORDER BY c.name, u.email`
rows, err := pg.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []memberMembershipRow
for rows.Next() {
var m memberMembershipRow
if err := rows.Scan(
&m.UserID,
&m.Email,
&m.CompanyID,
&m.CompanyName,
&m.LegacyCompanyID,
&m.Role,
&m.Status,
&m.IsPlatformAdmin,
&m.MustSetPassword,
); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// promoteMembershipToAdmin sets an active member membership to admin.
// Returns ok=false when no matching row was updated (already admin, inactive, or missing).
func promoteMembershipToAdmin(ctx context.Context, pg *pgxpool.Pool, companyID, userID string) (bool, error) {
tag, err := pg.Exec(ctx, `
UPDATE memberships
SET role = 'admin', updated_at = now()
WHERE company_id = $1::uuid
AND user_id = $2::uuid
AND status = 'active'
AND role = 'member'`, companyID, userID)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}