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 }