Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
240 lines
6.7 KiB
Go
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
|
|
}
|