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.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user