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: /id-map.json)") reportDir := flag.String("report-dir", "", "JSON report directory (default: ; 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: /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 }