Files

1066 lines
36 KiB
Go
Raw Permalink Normal View History

package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
_ "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
mysqlDSN := flag.String("mysql", os.Getenv("MIGRATE_MYSQL_DSN"), "MySQL DSN (required unless -fixture); also accepts mysql:// URLs")
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL")
dryRun := flag.Bool("dry-run", false, "Count/remap without writing to Postgres")
mapsDir := flag.String("maps-dir", "maps", "Directory for id-map / validation artifacts (maps/*.json; do not commit)")
idMapPath := flag.String("id-map", "", "Unified id-map.json path (default: <maps-dir>/id-map.json)")
reportDir := flag.String("report-dir", "", "JSON report directory (default: <maps-dir>; also copies to docs/migration-reports when present)")
fixturePath := flag.String("fixture", "", "JSON fixture path for dry-run without MySQL (see testdata/fixture.json)")
resume := flag.Bool("resume", true, "Reuse existing id-map.json UUIDs for idempotent remaps")
companyFilter := flag.String("company", "", "Comma-separated legacy company_id filter (empty = all)")
domains := flag.String("domains", "all", "Comma domains: identity,billing,catalog,feeds,products,files,settings,formulas,tags,woo,usage,jobs (or all)")
skipPostImport := flag.Bool("skip-post-import", false, "Skip set-password invite hook generation")
issueSetPassword := flag.Bool("issue-set-password-invites", false, "Create set-password invites for must_set_password users; write password_invites.json and print URLs (works alone with -postgres)")
setPassword := flag.String("set-password", "", "Local bootstrap: set password for one user as email:password (requires -postgres)")
ensureDemo := flag.Bool("ensure-demo", false, "Upsert demo user (admin of all companies; rename richest → A1 Slovenija)")
demoEmail := flag.String("demo-email", "demo@descrybe.local", "Demo user email for -ensure-demo")
demoPassword := flag.String("demo-password", "DemoPass123!", "Demo user password for -ensure-demo (local only; do not commit)")
demoName := flag.String("demo-name", "Demo User", "Demo display name")
localDemoCo := flag.String("local-demo-name", "Platform Demo", "Standalone demo sandbox company when -ensure-demo (never A1)")
listWithoutPlans := flag.Bool("list-companies-without-plans", false, "List Postgres companies with no active company_plans row (works alone with -postgres)")
assignMissingPlans := flag.Bool("assign-missing-plans", false, "Assign -plan-name to companies without an active plan (never overwrites existing active plans; works alone with -postgres; requires -dry-run or -confirm)")
planName := flag.String("plan-name", "Free", "Plan name for -assign-missing-plans (case-insensitive)")
listMemberMemberships := flag.Bool("list-member-memberships", false, "List active memberships with role=member (Postgres-only; optional -email/-user-id/-company-id filters)")
promoteCompanyAdmins := flag.Bool("promote-company-admins", false, "Promote matching active member memberships to company admin (requires -email, -user-id, or -company-id; never promotes A1 a1=true; requires -dry-run or -confirm)")
promoteEmail := flag.String("email", "", "Filter/target user email for -list-member-memberships / -promote-company-admins")
promoteUserID := flag.String("user-id", "", "Filter/target Postgres user UUID for membership role tooling")
promoteCompanyID := flag.String("company-id", "", "Filter/target Postgres company UUID for membership role tooling (alone is enough to scope -promote-company-admins; A1 still skipped)")
confirmAssign := flag.Bool("confirm", false, "Required for live -assign-missing-plans / -promote-company-admins / -patch-emails writes (omit with -dry-run to preview only; no blind live writes)")
fallbackPlanName := flag.String("fallback-plan-name", "", "During ETL: when legacy company_plans plan_id is missing from plans, assign this Postgres plan name instead of skipping (e.g. Free)")
listLegacyEmails := flag.Bool("list-legacy-emails", false, "List Postgres users with synthetic @legacy.local emails (works alone with -postgres; read-only)")
exportLegacyEmails := flag.Bool("export-legacy-emails", false, "Export @legacy.local inventory + emails stub map to -emails-out / maps-dir (works alone with -postgres; read-only)")
patchEmails := flag.Bool("patch-emails", false, "Patch users.email from -emails-file for rows that still end with @legacy.local (never overwrites real emails; requires -dry-run or -confirm)")
emailsFile := flag.String("emails-file", "", "Clerk export or emails map (JSON/CSV) for -patch-emails")
emailsOut := flag.String("emails-out", "", "Output path for -export-legacy-emails (default: <maps-dir>/legacy-emails.json)")
flag.Parse()
if *setPassword != "" {
runSetPasswordOnly(*postgresURL, *setPassword)
return
}
if *issueSetPassword && *fixturePath == "" && *mysqlDSN == "" {
runIssueSetPasswordInvites(*postgresURL, *mapsDir, *dryRun)
return
}
if *listWithoutPlans || *assignMissingPlans {
runCompaniesWithoutPlansRepair(*postgresURL, *planName, *listWithoutPlans, *assignMissingPlans, *dryRun, *confirmAssign)
return
}
if *listMemberMemberships || *promoteCompanyAdmins {
runMembershipRoleRepair(*postgresURL, *promoteEmail, *promoteUserID, *promoteCompanyID, *listMemberMemberships, *promoteCompanyAdmins, *dryRun, *confirmAssign)
return
}
if *listLegacyEmails || *exportLegacyEmails || *patchEmails {
runLegacyEmailTools(*postgresURL, *mapsDir, *emailsFile, *emailsOut, *listLegacyEmails, *exportLegacyEmails, *patchEmails, *dryRun, *confirmAssign)
return
}
if *fixturePath == "" && *mysqlDSN == "" {
log.Fatal("BLOCKER: set -mysql / MIGRATE_MYSQL_DSN for real cutover validation, or pass -fixture testdata/fixture.json for offline dry-run; or use -issue-set-password-invites / -set-password / -list-companies-without-plans / -assign-missing-plans / -list-member-memberships / -promote-company-admins / -list-legacy-emails / -export-legacy-emails / -patch-emails with -postgres")
}
if *postgresURL == "" && !*dryRun {
log.Fatal("-postgres / DATABASE_URL is required for live loads (omit only with -dry-run + -fixture)")
}
if *dryRun && *fixturePath != "" && *postgresURL == "" {
runFixtureDryRun(*fixturePath, *mapsDir, *idMapPath)
return
}
if *postgresURL == "" {
log.Fatal("-postgres / DATABASE_URL is required")
}
ctx := context.Background()
var mysqlDB *sql.DB
if *fixturePath != "" {
log.Fatal("fixture mode only supports offline -dry-run without -postgres (omit DATABASE_URL); for live loads use real -mysql")
}
normalizedMySQL, err := normalizeMySQLDSN(*mysqlDSN)
if err != nil {
log.Fatalf("mysql DSN: %v\n%s", err, mysqlDSNHelp())
}
db, err := sql.Open("mysql", normalizedMySQL)
if err != nil {
log.Fatalf("mysql open (%s): %v\n%s", maskMySQLDSN(normalizedMySQL), err, mysqlDSNHelp())
}
mysqlDB = db
defer mysqlDB.Close()
mysqlDB.SetConnMaxLifetime(time.Minute)
if err := mysqlDB.PingContext(ctx); err != nil {
log.Fatalf("mysql ping (%s): %v\n%s", maskMySQLDSN(normalizedMySQL), err, mysqlDSNHelp())
}
pg, err := pgxpool.New(ctx, *postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
if err := os.MkdirAll(*mapsDir, 0o755); err != nil {
log.Fatalf("maps dir: %v", err)
}
outIDMap := *idMapPath
if outIDMap == "" {
outIDMap = filepath.Join(*mapsDir, "id-map.json")
}
cfg := MigratorConfig{
MySQLDSN: *mysqlDSN,
PostgresURL: *postgresURL,
DryRun: *dryRun,
MapsDir: *mapsDir,
IDMapPath: outIDMap,
ReportDir: *reportDir,
Resume: *resume,
CompanyFilter: parseCompanyFilter(*companyFilter),
Domains: parseDomains(*domains),
SkipPostImport: *skipPostImport,
EnsureDemo: *ensureDemo,
DemoEmail: *demoEmail,
DemoPassword: *demoPassword,
DemoName: *demoName,
LocalDemoCo: *localDemoCo,
}
if cfg.ReportDir == "" {
cfg.ReportDir = *mapsDir
}
runReport := newRunReport(cfg, *dryRun)
started := time.Now()
log.Printf("migrator domains=%s company_filter=%v resume=%v dry_run=%v", cfg.Domains, cfg.CompanyFilter, cfg.Resume, *dryRun)
report := map[string]int{}
companyMap := map[string]string{}
userMap := map[string]string{}
resumeCategories := map[string]string{}
resumeAttributes := map[string]string{}
resumeFeeds := map[string]string{}
resumeRaw := map[string]string{}
resumeFiles := map[string]string{}
if cfg.Resume {
if _, err := os.Stat(outIDMap); err == nil {
if loaded, err := ReadIDMap(outIDMap); err == nil {
for k, v := range loaded.Companies {
companyMap[k] = v
}
for k, v := range loaded.Users {
userMap[k] = v
}
for k, v := range loaded.Categories {
resumeCategories[k] = v
}
for k, v := range loaded.Attributes {
resumeAttributes[k] = v
}
for k, v := range loaded.Feeds {
resumeFeeds[k] = v
}
for k, v := range loaded.RawProducts {
resumeRaw[k] = v
}
for k, v := range loaded.Files {
resumeFiles[k] = v
}
log.Printf("reusing id map %s for idempotent remaps (companies=%d feeds=%d)", outIDMap, len(companyMap), len(resumeFeeds))
}
}
} else {
log.Printf("resume disabled: generating fresh UUIDs (still upserts by natural keys)")
}
allowCompanies := companyFilterSet(cfg.CompanyFilter)
companies, err := loadCompanies(ctx, mysqlDB)
if err != nil {
log.Fatalf("load companies: %v", err)
}
companies = filterCompanies(companies, allowCompanies)
if len(cfg.CompanyFilter) > 0 && len(companies) == 0 {
log.Fatalf("company filter matched 0 companies: %v", cfg.CompanyFilter)
}
for _, c := range companies {
newID := uuid.New()
if existing, ok := companyMap[c.LegacyID]; ok {
if parsed, err := uuid.Parse(existing); err == nil {
newID = parsed
}
}
// Always prefer the live Postgres row when present so gap-only domains
// and drifted id-maps still resolve FKs correctly.
if !*dryRun {
var existing uuid.UUID
if err := pg.QueryRow(ctx, `SELECT id FROM companies WHERE legacy_company_id = $1`, c.LegacyID).Scan(&existing); err == nil && existing != uuid.Nil {
newID = existing
}
}
companyMap[c.LegacyID] = newID.String()
if !*dryRun && cfg.Domains.has("identity") {
_, err = pg.Exec(ctx, `
INSERT INTO companies (id, name, language, legacy_company_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (legacy_company_id) DO UPDATE SET name = EXCLUDED.name
RETURNING id`, newID, c.Name, c.Language, c.LegacyID)
if err != nil {
log.Printf("company insert %s: %v", c.LegacyID, err)
}
var existing uuid.UUID
if err2 := pg.QueryRow(ctx, `SELECT id FROM companies WHERE legacy_company_id = $1`, c.LegacyID).Scan(&existing); err2 == nil && existing != uuid.Nil {
companyMap[c.LegacyID] = existing.String()
}
}
report["companies"]++
}
users, err := loadUsers(ctx, mysqlDB)
if err != nil {
log.Fatalf("load users: %v", err)
}
for _, u := range users {
newID := uuid.New()
if existing, ok := userMap[u.LegacyID]; ok {
if parsed, err := uuid.Parse(existing); err == nil {
newID = parsed
}
}
userMap[u.LegacyID] = newID.String()
if !*dryRun && cfg.Domains.has("identity") {
// SECURITY: never import legacy password hashes; force must_set_password.
_, err = pg.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, must_set_password, is_active, legacy_user_id, created_at, updated_at)
VALUES ($1, $2, $3, NULL, true, $4, $5, now(), now())
ON CONFLICT (email) DO UPDATE SET
legacy_user_id = COALESCE(users.legacy_user_id, EXCLUDED.legacy_user_id),
password_hash = NULL,
must_set_password = true`,
newID, strings.ToLower(u.Email), nullStr(u.Name), u.Active, u.LegacyID)
if err != nil {
log.Printf("user insert %s: %v", u.Email, err)
}
var existing uuid.UUID
if err := pg.QueryRow(ctx, `SELECT id FROM users WHERE legacy_user_id = $1 OR email = $2`, u.LegacyID, strings.ToLower(u.Email)).Scan(&existing); err == nil {
userMap[u.LegacyID] = existing.String()
}
}
report["users"]++
}
if cfg.Domains.has("identity") {
applyPlatformAdmins(ctx, mysqlDB, pg, userMap, report, *dryRun)
}
memberships, err := loadMemberships(ctx, mysqlDB)
if err != nil {
log.Fatalf("load memberships: %v", err)
}
for _, m := range memberships {
if allowCompanies != nil && !allowCompanies[m.CompanyLegacy] {
continue
}
cid, okC := companyMap[m.CompanyLegacy]
uid, okU := userMap[m.UserLegacy]
if !okC || !okU {
report["memberships_skipped"]++
continue
}
if !*dryRun && cfg.Domains.has("identity") {
_, err = pg.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, $3, $4)
ON CONFLICT (company_id, user_id) DO UPDATE SET role = EXCLUDED.role, status = EXCLUDED.status`,
cid, uid, m.Role, m.Status)
if err != nil {
log.Printf("membership: %v", err)
}
}
report["memberships"]++
}
if !cfg.Domains.has("billing") {
log.Printf("billing domain skipped")
} else {
plans, err := loadPlans(ctx, mysqlDB)
if err != nil {
log.Printf("plans skipped: %v", err)
} else {
planMap := map[int64]int64{}
for _, pl := range plans {
if !*dryRun {
var newID int64
err = pg.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`, pl.Name, pl.Description, pl.MonthlyCredits, pl.YearlyCredits, pl.MaxProducts, pl.IsCustom, pl.Term).Scan(&newID)
if err != nil {
log.Printf("plan: %v", err)
} else {
planMap[pl.LegacyID] = newID
}
} else {
// Dry-run: keep legacy ids so company_plans linkage can be counted.
planMap[pl.LegacyID] = pl.LegacyID
}
report["plans"]++
}
var fallbackPlanID int64
if name := strings.TrimSpace(*fallbackPlanName); name != "" {
if *dryRun {
// Dry-run: treat fallback as available so linkage can be counted.
fallbackPlanID = -1
log.Printf("company_plans: dry-run fallback plan name %q (would resolve on live load)", name)
} else {
id, err := resolveFallbackPlanID(ctx, pg, name)
if err != nil {
log.Fatalf("fallback-plan-name %q: %v", name, err)
}
fallbackPlanID = id
log.Printf("company_plans: fallback plan %q -> id=%d", name, fallbackPlanID)
}
}
cps, err := loadCompanyPlans(ctx, mysqlDB)
if err == nil {
for _, cp := range cps {
if billing.IsA1CohortCompany(cp.CompanyLegacy, "") {
report["company_plans_skipped_a1"]++
log.Printf("company_plans: skip A1 cohort company_legacy=%s (preserve PAYG/Legacy; use EnsureLegacyDefaults)", cp.CompanyLegacy)
continue
}
cid, ok := companyMap[cp.CompanyLegacy]
pid, okP := planMap[cp.PlanLegacy]
if !ok {
report["company_plans_skipped"]++
log.Printf("company_plans: skip company_legacy=%s plan_id=%d (company not remapped)", cp.CompanyLegacy, cp.PlanLegacy)
continue
}
if !okP {
if fallbackPlanID != 0 {
if fallbackPlanID > 0 {
pid = fallbackPlanID
}
okP = true
report["company_plans_fallback"]++
log.Printf("company_plans: fallback for company_legacy=%s missing plan_id=%d -> plan %q", cp.CompanyLegacy, cp.PlanLegacy, strings.TrimSpace(*fallbackPlanName))
} else {
report["company_plans_skipped"]++
log.Printf("company_plans: skip company_legacy=%s plan_id=%d (not in plans map; use -fallback-plan-name or -assign-missing-plans)", cp.CompanyLegacy, cp.PlanLegacy)
continue
}
}
if !*dryRun {
_, err = pg.Exec(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date, is_trial, trial_credits)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
cid, pid, cp.IsActive, cp.BillingStart, cp.NextBilling, cp.IsTrial, cp.TrialCredits)
if err != nil {
log.Printf("company_plan: %v", err)
}
}
report["company_plans"]++
}
}
}
balances, err := loadCreditBalances(ctx, mysqlDB)
if err != nil {
log.Printf("credit_balances skipped: %v", err)
} else {
for _, b := range balances {
cid, ok := companyMap[b.CompanyLegacy]
if !ok {
report["credit_balances_skipped"]++
continue
}
if !*dryRun {
_, err = pg.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (company_id) DO UPDATE SET
total_credits = EXCLUDED.total_credits,
used_credits = EXCLUDED.used_credits,
updated_at = now()`, cid, b.Total, b.Used)
if err != nil {
log.Printf("credit_balance: %v", err)
}
}
report["credit_balances"]++
}
}
} // end billing domain
categoryMap, attributeMap, feedMap, rawMap, fileMap := migrateCatalogAndFeeds(ctx, mysqlDB, pg, companyMap, userMap, allowCompanies, cfg.Domains, report, *dryRun)
for k, v := range resumeCategories {
if _, ok := categoryMap[k]; !ok {
categoryMap[k] = v
}
}
for k, v := range resumeAttributes {
if _, ok := attributeMap[k]; !ok {
attributeMap[k] = v
}
}
for k, v := range resumeFeeds {
if _, ok := feedMap[k]; !ok {
feedMap[k] = v
}
}
for k, v := range resumeRaw {
if _, ok := rawMap[k]; !ok {
rawMap[k] = v
}
}
for k, v := range resumeFiles {
if _, ok := fileMap[k]; !ok {
fileMap[k] = v
}
}
_ = attributeMap
migrateGapDomains(ctx, mysqlDB, pg, companyMap, feedMap, allowCompanies, cfg.Domains, report, *dryRun)
migrateJobsDomain(ctx, mysqlDB, pg, companyMap, userMap, rawMap, allowCompanies, cfg.Domains, report, *dryRun)
if !*skipPostImport || *issueSetPassword {
hooks, err := prepareSetPasswordHooks(ctx, pg, 7*24*time.Hour, *dryRun, report)
if err != nil {
log.Printf("set-password hooks: %v", err)
} else if err := writeSetPasswordArtifacts(*mapsDir, hooks); err != nil {
log.Printf("write set-password hooks: %v", err)
}
}
validation := runValidation(ctx, mysqlDB, pg, *dryRun)
printValidation(validation)
if err := writeJSON(filepath.Join(*mapsDir, "validation-report.json"), validation); err != nil {
log.Printf("validation report: %v", err)
}
idDoc := NewIDMapDocument(userMap, companyMap, *dryRun)
idDoc.AttachEntityMaps(categoryMap, attributeMap, feedMap, rawMap, fileMap, report)
if err := WriteIDMap(outIDMap, idDoc); err != nil {
log.Fatalf("id-map: %v", err)
}
writeEntityMapFiles(*mapsDir, companyMap, userMap, categoryMap, attributeMap, feedMap, rawMap, fileMap)
if cfg.EnsureDemo {
demoRep, err := ensureDemoUser(ctx, pg, cfg.DemoEmail, cfg.DemoPassword, cfg.DemoName, cfg.LocalDemoCo, *dryRun, report)
if err != nil {
log.Printf("ensure-demo: %v", err)
} else {
runReport.Demo = demoRep
fmt.Printf("demo user: %s (password set locally; see docs/portable-mysql-pg-migration.md)\n", cfg.DemoEmail)
if demoRep != nil && demoRep.PrimaryName != "" {
fmt.Printf("demo primary company: %s (%s)\n", demoRep.PrimaryName, demoRep.PrimaryCompany)
}
}
}
runReport.Counts = report
runReport.Validation = validation
runReport.ElapsedMS = time.Since(started).Milliseconds()
if err := writeMigrationReports(cfg.ReportDir, runReport); err != nil {
log.Printf("migration report: %v", err)
}
printMigrationReport(report, *dryRun)
fmt.Printf("wrote maps under %s (unified: %s)\n", *mapsDir, outIDMap)
fmt.Printf("wrote migration report under %s\n", cfg.ReportDir)
if *fixturePath != "" {
fmt.Println("BLOCKER: fixture mode is not a substitute for dry-run against production MySQL — set MIGRATE_MYSQL_DSN and re-run before cutover.")
}
}
func writeMigrationReports(reportDir string, rep *MigrationRunReport) error {
if reportDir == "" || rep == nil {
return nil
}
if err := os.MkdirAll(reportDir, 0o755); err != nil {
return err
}
stamp := time.Now().UTC().Format("20060102T150405Z")
primary := filepath.Join(reportDir, "migration-report.json")
stamped := filepath.Join(reportDir, "migration-report-"+stamp+".json")
if err := writeJSON(primary, rep); err != nil {
return err
}
_ = writeJSON(stamped, rep)
// Optional copy under docs/migration-reports (repo-relative from apps/api).
docsDir := filepath.Clean(filepath.Join("..", "..", "docs", "migration-reports"))
if st, err := os.Stat(docsDir); err == nil && st.IsDir() {
_ = writeJSON(filepath.Join(docsDir, "migration-report-latest.json"), rep)
_ = writeJSON(filepath.Join(docsDir, "migration-report-"+stamp+".json"), rep)
}
return nil
}
func writeEntityMapFiles(
mapsDir string,
companyMap, userMap, categoryMap, attributeMap, feedMap, rawMap, fileMap map[string]string,
) {
if companyMap == nil {
companyMap = map[string]string{}
}
if userMap == nil {
userMap = map[string]string{}
}
if categoryMap == nil {
categoryMap = map[string]string{}
}
if attributeMap == nil {
attributeMap = map[string]string{}
}
if feedMap == nil {
feedMap = map[string]string{}
}
if rawMap == nil {
rawMap = map[string]string{}
}
if fileMap == nil {
fileMap = map[string]string{}
}
_ = writeJSON(filepath.Join(mapsDir, "company_map.json"), companyMap)
_ = writeJSON(filepath.Join(mapsDir, "user_map.json"), userMap)
_ = writeJSON(filepath.Join(mapsDir, "category_map.json"), categoryMap)
_ = writeJSON(filepath.Join(mapsDir, "attribute_map.json"), attributeMap)
_ = writeJSON(filepath.Join(mapsDir, "feed_map.json"), feedMap)
_ = writeJSON(filepath.Join(mapsDir, "raw_product_map.json"), rawMap)
_ = writeJSON(filepath.Join(mapsDir, "file_map.json"), fileMap)
}
func printMigrationReport(report map[string]int, dryRun bool) {
fmt.Println("=== Migration report ===")
if dryRun {
fmt.Println("mode: dry-run")
}
keys := make([]string, 0, len(report))
for k := range report {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, report[k])
}
}
func runSetPasswordOnly(postgresURL, emailPass string) {
if postgresURL == "" {
log.Fatal("-postgres / DATABASE_URL is required for -set-password")
}
ctx := context.Background()
pg, err := pgxpool.New(ctx, postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
if err := setPasswordByEmail(ctx, pg, emailPass); err != nil {
log.Fatal(err)
}
}
func runIssueSetPasswordInvites(postgresURL, mapsDir string, dryRun bool) {
if postgresURL == "" {
log.Fatal("-postgres / DATABASE_URL is required for -issue-set-password-invites")
}
ctx := context.Background()
pg, err := pgxpool.New(ctx, postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
if err := os.MkdirAll(mapsDir, 0o755); err != nil {
log.Fatalf("maps dir: %v", err)
}
report := map[string]int{}
hooks, err := prepareSetPasswordHooks(ctx, pg, 7*24*time.Hour, dryRun, report)
if err != nil {
log.Fatalf("set-password invites: %v", err)
}
if dryRun {
fmt.Println("mode: dry-run (no invites written)")
return
}
if len(hooks) == 0 {
fmt.Println("no active users with must_set_password=true (and a membership)")
return
}
if err := writeSetPasswordArtifacts(mapsDir, hooks); err != nil {
log.Fatal(err)
}
fmt.Printf("set_password_hooks: %d\n", report["set_password_hooks"])
if skipped := report["set_password_hooks_skipped"]; skipped > 0 {
fmt.Printf("set_password_hooks_skipped: %d\n", skipped)
}
}
type companyRow struct {
LegacyID, Name, Language string
}
type userRow struct {
LegacyID, Email, Name string
Active bool
}
type membershipRow struct {
CompanyLegacy, UserLegacy, Role, Status string
}
type planRow struct {
LegacyID int64
Name, Description, Term string
MonthlyCredits, YearlyCredits int
MaxProducts *int
IsCustom bool
}
type companyPlanRow struct {
CompanyLegacy string
PlanLegacy int64
IsActive bool
BillingStart time.Time
NextBilling time.Time
IsTrial bool
TrialCredits int
}
type balanceRow struct {
CompanyLegacy string
Total, Used int
}
func loadCompanies(ctx context.Context, db *sql.DB) ([]companyRow, error) {
// Legacy companies has no language column (lives on company_settings when present).
var queries []string
if mysqlTableExists(ctx, db, "companies") {
nameExpr := mysqlCoalesce(ctx, db, "companies", "name", "id")
if mysqlTableExists(ctx, db, "company_settings") && mysqlColumnExists(ctx, db, "company_settings", "language") {
queries = append(queries, fmt.Sprintf(`
SELECT c.id, %s, COALESCE(cs.language, 'en')
FROM companies c
LEFT JOIN company_settings cs ON cs.company_id = c.id`, nameExpr))
}
queries = append(queries, fmt.Sprintf(`SELECT id, %s, 'en' FROM companies`, nameExpr))
if mysqlColumnExists(ctx, db, "companies", "language") {
queries = append([]string{fmt.Sprintf(
`SELECT id, %s, %s FROM companies`,
nameExpr, mysqlCoalesce(ctx, db, "companies", "language", "'en'"),
)}, queries...)
}
}
queries = append(queries,
`SELECT DISTINCT company_id, COALESCE(MAX(company_name), company_id), 'en'
FROM profiles WHERE company_id IS NOT NULL AND company_id <> ''
GROUP BY company_id`,
`SELECT DISTINCT company_id, company_id, 'en' FROM profiles
WHERE company_id IS NOT NULL AND company_id <> ''`,
)
rows, err := queryFirstOK(ctx, db, queries...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []companyRow
for rows.Next() {
var c companyRow
if err := rows.Scan(&c.LegacyID, &c.Name, &c.Language); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func loadUsers(ctx context.Context, db *sql.DB) ([]userRow, error) {
// Prefer users.email (joined with profiles for Clerk legacy id); fall back to profiles.
// Many legacy dumps have no users table and profiles without email/name (Clerk-only identity).
if mysqlTableExists(ctx, db, "users") && mysqlColumnExists(ctx, db, "users", "email") {
q := `
SELECT
COALESCE(NULLIF(p.user_id, ''), u.id) AS legacy_id,
u.email,
COALESCE(u.name, ''),
CASE WHEN COALESCE(u.is_active, 1) = 0 THEN 0 ELSE 1 END
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.email IS NOT NULL AND TRIM(u.email) <> ''`
rows, err := db.QueryContext(ctx, q)
if err != nil {
log.Printf("loadUsers users+profiles failed (%v); trying users alone", err)
rows, err = db.QueryContext(ctx, `
SELECT id, email, COALESCE(name, ''),
CASE WHEN COALESCE(is_active, 1) = 0 THEN 0 ELSE 1 END
FROM users
WHERE email IS NOT NULL AND TRIM(email) <> ''`)
}
if err == nil {
out, err2 := scanUserRows(rows)
rows.Close()
if err2 != nil {
return nil, err2
}
if len(out) > 0 {
return enrichUserEmailsFromAdminUsers(ctx, db, out), nil
}
} else {
log.Printf("loadUsers users table failed (%v); falling back to profiles", err)
}
}
if !mysqlTableExists(ctx, db, "profiles") {
return nil, fmt.Errorf("neither users nor profiles table found")
}
emailExpr := `CONCAT(user_id, '@legacy.local')`
if mysqlColumnExists(ctx, db, "profiles", "email") {
emailExpr = `COALESCE(NULLIF(TRIM(email), ''), CONCAT(user_id, '@legacy.local'))`
} else {
log.Printf("profiles.email missing; using synthetic @legacy.local addresses (enrich from admin_users when present)")
}
nameExpr := `''`
if mysqlColumnExists(ctx, db, "profiles", "name") {
nameExpr = `COALESCE(name, '')`
}
activeExpr := `1`
if mysqlColumnExists(ctx, db, "profiles", "status") {
activeExpr = `CASE WHEN COALESCE(status, 'active') = 'inactive' THEN 0 ELSE 1 END`
}
q := fmt.Sprintf(`
SELECT user_id, %s, %s, %s
FROM profiles
WHERE user_id IS NOT NULL AND user_id <> ''`, emailExpr, nameExpr, activeExpr)
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out, err := scanUserRows(rows)
if err != nil {
return nil, err
}
return enrichUserEmailsFromAdminUsers(ctx, db, out), nil
}
// enrichUserEmailsFromAdminUsers overlays real emails from admin_users onto Clerk-id users.
func enrichUserEmailsFromAdminUsers(ctx context.Context, db *sql.DB, users []userRow) []userRow {
if len(users) == 0 || !mysqlTableExists(ctx, db, "admin_users") {
return users
}
hasUserID := mysqlColumnExists(ctx, db, "admin_users", "user_id")
hasEmail := mysqlColumnExists(ctx, db, "admin_users", "email")
if !hasUserID || !hasEmail {
return users
}
rows, err := db.QueryContext(ctx, `
SELECT user_id, email FROM admin_users
WHERE user_id IS NOT NULL AND user_id <> ''
AND email IS NOT NULL AND TRIM(email) <> ''`)
if err != nil {
log.Printf("admin_users email enrich skipped: %v", err)
return users
}
defer rows.Close()
byLegacy := map[string]string{}
for rows.Next() {
var id, email string
if err := rows.Scan(&id, &email); err != nil {
continue
}
byLegacy[id] = strings.ToLower(strings.TrimSpace(email))
}
if len(byLegacy) == 0 {
return users
}
for i := range users {
if email, ok := byLegacy[users[i].LegacyID]; ok {
users[i].Email = email
}
}
return users
}
func scanUserRows(rows *sql.Rows) ([]userRow, error) {
seen := map[string]bool{}
var out []userRow
for rows.Next() {
var u userRow
var active int
if err := rows.Scan(&u.LegacyID, &u.Email, &u.Name, &active); err != nil {
return nil, err
}
if seen[u.LegacyID] {
continue
}
seen[u.LegacyID] = true
u.Active = active == 1
out = append(out, u)
}
return out, rows.Err()
}
func loadMemberships(ctx context.Context, db *sql.DB) ([]membershipRow, error) {
if !mysqlTableExists(ctx, db, "profiles") {
return nil, fmt.Errorf("profiles table missing")
}
// Legacy profiles often omit role (Clerk held org roles). Default to member.
roleExpr := `'member'`
if mysqlColumnExists(ctx, db, "profiles", "role") {
roleExpr = `CASE WHEN COALESCE(role, 'member') IN ('admin', 'org:admin') THEN 'admin' ELSE 'member' END`
} else {
log.Printf("profiles.role missing; defaulting all memberships to role=member (promote admins after cutover)")
}
statusExpr := `'active'`
if mysqlColumnExists(ctx, db, "profiles", "status") {
statusExpr = `CASE WHEN COALESCE(status, 'active') = 'inactive' THEN 'inactive' ELSE 'active' END`
}
q := fmt.Sprintf(`
SELECT company_id, user_id, %s, %s
FROM profiles
WHERE company_id IS NOT NULL AND company_id <> ''
AND user_id IS NOT NULL AND user_id <> ''`, roleExpr, statusExpr)
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []membershipRow
for rows.Next() {
var m membershipRow
if err := rows.Scan(&m.CompanyLegacy, &m.UserLegacy, &m.Role, &m.Status); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func normalizeMySQLDSN(dsn string) (string, error) {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return "", fmt.Errorf("empty DSN")
}
if !strings.Contains(dsn, "://") {
return ensureParseTime(dsn), nil
}
u, err := url.Parse(dsn)
if err != nil {
return "", fmt.Errorf("parse URL DSN: %w", err)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "mysql" && scheme != "mariadb" {
return "", fmt.Errorf("unsupported scheme %q (want mysql:// or user:pass@tcp(...)/db)", u.Scheme)
}
user := ""
pass := ""
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
}
host := u.Hostname()
if host == "" {
host = "127.0.0.1"
}
port := u.Port()
if port == "" {
port = "3306"
}
dbName := strings.TrimPrefix(u.Path, "/")
if dbName == "" {
return "", fmt.Errorf("database name missing in DSN path")
}
out := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", user, pass, host, port, dbName)
q := u.RawQuery
if q == "" {
q = "parseTime=true&charset=utf8mb4"
} else if !strings.Contains(q, "parseTime=") {
q += "&parseTime=true"
}
return out + "?" + q, nil
}
func ensureParseTime(dsn string) string {
if strings.Contains(dsn, "parseTime=") {
return dsn
}
if strings.Contains(dsn, "?") {
return dsn + "&parseTime=true"
}
return dsn + "?parseTime=true"
}
func maskMySQLDSN(dsn string) string {
// user:pass@tcp(...) → user:****@tcp(...)
if at := strings.Index(dsn, "@"); at > 0 {
cred := dsn[:at]
if colon := strings.Index(cred, ":"); colon >= 0 {
return cred[:colon+1] + "****" + dsn[at:]
}
}
// URL form fallback
if u, err := url.Parse(dsn); err == nil && u.User != nil {
u.User = url.UserPassword(u.User.Username(), "****")
return u.String()
}
return dsn
}
func mysqlDSNHelp() string {
return strings.TrimSpace(`
Hints:
- Start Laragon MySQL (often root@127.0.0.1:3306) and confirm the DB exists.
- Preferred DSN: user:pass@tcp(127.0.0.1:3306)/dbname?parseTime=true
- mysql://user:pass@host:3306/dbname URLs are accepted and converted.
- Set MIGRATE_MYSQL_DSN or pass -mysql; do not commit credentials.`)
}
func loadPlans(ctx context.Context, db *sql.DB) ([]planRow, error) {
if !mysqlTableExists(ctx, db, "plans") {
return nil, fmt.Errorf("plans table missing")
}
q := mysqlSelectList(
"id",
"name",
mysqlCoalesce(ctx, db, "plans", "description", "NULL"),
mysqlCoalesce(ctx, db, "plans", "monthly_credits", "0"),
mysqlCoalesce(ctx, db, "plans", "yearly_credits", "0"),
mysqlCol(ctx, db, "plans", "max_products", "NULL"),
mysqlCoalesce(ctx, db, "plans", "is_custom", "0"),
mysqlCoalesce(ctx, db, "plans", "term", "'monthly'"),
) + " FROM plans"
rows, err := db.QueryContext(ctx, q)
if err != nil {
// Minimal shape fallback.
rows, err = db.QueryContext(ctx, `SELECT id, name, NULL, monthly_credits, 0, NULL, 0, 'monthly' FROM plans`)
if err != nil {
return nil, err
}
}
defer rows.Close()
var out []planRow
for rows.Next() {
var p planRow
var custom int
var desc sql.NullString
if err := rows.Scan(&p.LegacyID, &p.Name, &desc, &p.MonthlyCredits, &p.YearlyCredits, &p.MaxProducts, &custom, &p.Term); err != nil {
return nil, err
}
if desc.Valid {
p.Description = desc.String
}
p.IsCustom = custom == 1
if p.Term == "" {
p.Term = "monthly"
}
out = append(out, p)
}
return out, rows.Err()
}
func loadCompanyPlans(ctx context.Context, db *sql.DB) ([]companyPlanRow, error) {
if !mysqlTableExists(ctx, db, "company_plans") {
return nil, fmt.Errorf("company_plans table missing")
}
// Legacy plan_id is TEXT storing numeric plan ids — scan as string then parse.
q := mysqlSelectList(
"company_id",
"CAST(plan_id AS CHAR)",
mysqlCoalesce(ctx, db, "company_plans", "is_active", "1"),
mysqlCol(ctx, db, "company_plans", "billing_cycle_start", "NOW()"),
mysqlCol(ctx, db, "company_plans", "next_billing_date", "NOW()"),
mysqlCoalesce(ctx, db, "company_plans", "is_trial", "0"),
mysqlCoalesce(ctx, db, "company_plans", "trial_credits", "0"),
) + " FROM company_plans"
rows, err := db.QueryContext(ctx, q)
if err != nil {
rows, err = db.QueryContext(ctx, `
SELECT company_id, CAST(plan_id AS CHAR), 1, NOW(), NOW(), 0, 0 FROM company_plans`)
if err != nil {
return nil, err
}
}
defer rows.Close()
var out []companyPlanRow
for rows.Next() {
var cp companyPlanRow
var planIDStr string
var active, trial int
if err := rows.Scan(&cp.CompanyLegacy, &planIDStr, &active, &cp.BillingStart, &cp.NextBilling, &trial, &cp.TrialCredits); err != nil {
return nil, err
}
pid, err := strconv.ParseInt(strings.TrimSpace(planIDStr), 10, 64)
if err != nil {
log.Printf("company_plans: skip non-numeric plan_id %q", planIDStr)
continue
}
cp.PlanLegacy = pid
cp.IsActive = active == 1
cp.IsTrial = trial == 1
out = append(out, cp)
}
return out, rows.Err()
}
func loadCreditBalances(ctx context.Context, db *sql.DB) ([]balanceRow, error) {
if !mysqlTableExists(ctx, db, "credit_balances") {
return nil, fmt.Errorf("credit_balances table missing")
}
// total/used may be DECIMAL — pull as strings then parse.
q := mysqlSelectList(
"company_id",
mysqlCoalesce(ctx, db, "credit_balances", "total_credits", "0"),
mysqlCoalesce(ctx, db, "credit_balances", "used_credits", "0"),
) + " FROM credit_balances"
rows, err := db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []balanceRow
for rows.Next() {
var b balanceRow
var totalRaw, usedRaw any
if err := rows.Scan(&b.CompanyLegacy, &totalRaw, &usedRaw); err != nil {
return nil, err
}
b.Total = scanIntish(totalRaw)
b.Used = scanIntish(usedRaw)
out = append(out, b)
}
return out, rows.Err()
}
func writeJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}