Files

182 lines
4.8 KiB
Go
Raw Permalink Normal View History

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.",
},
}
}