package main import ( "fmt" "strings" "time" ) // MigratorConfig holds portable CLI options for MySQL → Postgres ETL. type MigratorConfig struct { MySQLDSN string PostgresURL string DryRun bool MapsDir string IDMapPath string ReportDir string FixturePath string Resume bool CompanyFilter []string // legacy company ids; empty = all Domains domainSet SkipPostImport bool EnsureDemo bool DemoEmail string DemoPassword string DemoName string LocalDemoCo string } type domainSet map[string]bool func parseDomains(raw string) domainSet { raw = strings.TrimSpace(strings.ToLower(raw)) if raw == "" || raw == "all" { return domainSet{"all": true} } out := domainSet{} for _, p := range strings.Split(raw, ",") { p = strings.TrimSpace(p) if p == "" { continue } out[p] = true } if len(out) == 0 { return domainSet{"all": true} } return out } func (d domainSet) has(name string) bool { if d == nil || d["all"] { return true } return d[name] } func (d domainSet) String() string { if d == nil || d["all"] { return "all" } parts := make([]string, 0, len(d)) for k := range d { parts = append(parts, k) } return strings.Join(parts, ",") } func parseCompanyFilter(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } var out []string seen := map[string]bool{} for _, p := range strings.Split(raw, ",") { p = strings.TrimSpace(p) if p == "" || seen[p] { continue } seen[p] = true out = append(out, p) } return out } func companyFilterSet(ids []string) map[string]bool { if len(ids) == 0 { return nil } m := make(map[string]bool, len(ids)) for _, id := range ids { m[id] = true } return m } func filterCompanies(rows []companyRow, allow map[string]bool) []companyRow { if allow == nil { return rows } out := make([]companyRow, 0, len(rows)) for _, c := range rows { if allow[c.LegacyID] { out = append(out, c) } } return out } // mysqlCompanyFilter appends AND company_id IN (...) when a filter is set. // Values are bound as ? placeholders; the column path is validated and quoted. func mysqlCompanyFilter(column string, allow map[string]bool) (clause string, args []any) { if len(allow) == 0 { return "", nil } quotedCol, err := quoteMySQLIdentPath(column) if err != nil { panic(err) } ids := make([]string, 0, len(allow)) for id := range allow { ids = append(ids, id) } placeholders := make([]string, len(ids)) args = make([]any, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } return fmt.Sprintf(" AND %s IN (%s)", quotedCol, strings.Join(placeholders, ",")), args } // MigrationRunReport is the portable JSON artifact written after each run. type MigrationRunReport struct { GeneratedAt string `json:"generated_at"` Mode string `json:"mode"` Domains string `json:"domains"` CompanyFilter []string `json:"company_filter,omitempty"` Resume bool `json:"resume"` Counts map[string]int `json:"counts"` Validation any `json:"validation,omitempty"` Demo *DemoReport `json:"demo,omitempty"` Notes []string `json:"notes,omitempty"` ElapsedMS int64 `json:"elapsed_ms"` } // DemoReport documents the ensure-demo outcome (no password plaintext). type DemoReport struct { Email string `json:"email"` PasswordSet bool `json:"password_set"` UserID string `json:"user_id,omitempty"` PrimaryCompany string `json:"primary_company,omitempty"` PrimaryName string `json:"primary_company_name,omitempty"` Memberships int64 `json:"memberships_admin"` PlatformAdmin bool `json:"platform_admin"` Note string `json:"note,omitempty"` } func newRunReport(cfg MigratorConfig, dryRun bool) *MigrationRunReport { mode := "live" if dryRun { mode = "dry-run" } return &MigrationRunReport{ GeneratedAt: time.Now().UTC().Format(time.RFC3339), Mode: mode, Domains: cfg.Domains.String(), CompanyFilter: append([]string(nil), cfg.CompanyFilter...), Resume: cfg.Resume, Counts: map[string]int{}, Notes: []string{ "Clerk is excluded: users mapped by email only; no Clerk API.", "Legacy password hashes are never imported.", "API key secrets are not migrated; clients must mint new keys (seed-demo / ensure-demo for local).", "File blobs are metadata-only; resync object storage separately.", "Job history: domain jobs migrates processing_jobs (+ best-effort job_products) and tasks; tagged ai_provider_mode=migrated so retention keeps them.", "company_settings: language + merge_products only (domain settings); other legacy settings fields are not imported.", "woocommerce_configs: migrated from wc_* custom_fields when domain woo is enabled.", }, } }