package main import ( "context" "encoding/csv" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/jackc/pgx/v5/pgxpool" ) const legacyEmailSuffix = "@legacy.local" // legacyEmailRow is one Postgres user still on a synthetic Clerk-missing address. type legacyEmailRow struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name,omitempty"` LegacyUserID string `json:"legacy_user_id,omitempty"` Companies []string `json:"companies,omitempty"` A1Member bool `json:"a1_member"` LegacyCompanyIDs []string `json:"legacy_company_ids,omitempty"` } type legacyEmailInventory struct { Version int `json:"version"` GeneratedAt string `json:"generated_at"` Count int `json:"count"` A1Members int `json:"a1_members"` Note string `json:"note"` Users []legacyEmailRow `json:"users"` // Emails is a Clerk-id → email stub map for operators to fill (or overwrite from a Clerk export). Emails map[string]string `json:"emails"` } type emailPatchSkip struct { LegacyID string `json:"legacy_id,omitempty"` UserID string `json:"user_id,omitempty"` Email string `json:"email,omitempty"` Reason string `json:"reason"` } type emailPatchAction struct { UserID string `json:"user_id"` LegacyID string `json:"legacy_id"` FromEmail string `json:"from_email"` ToEmail string `json:"to_email"` A1Member bool `json:"a1_member"` Name string `json:"name,omitempty"` } type emailPatchPlan struct { Apply []emailPatchAction `json:"apply"` Skips []emailPatchSkip `json:"skips"` } func runLegacyEmailTools(postgresURL, mapsDir, emailsFile, emailsOut string, listOnly, exportOnly, patch bool, dryRun, confirm bool) { if postgresURL == "" { log.Fatal("-postgres / DATABASE_URL is required for legacy-email tooling") } if !listOnly && !exportOnly && !patch { log.Fatal("pass -list-legacy-emails and/or -export-legacy-emails and/or -patch-emails") } if patch && strings.TrimSpace(emailsFile) == "" { log.Fatal("-emails-file is required with -patch-emails (Clerk export or emails map JSON/CSV)") } if err := guardLiveMutation(patch, dryRun, confirm, "-patch-emails"); 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 := listSyntheticLegacyEmails(ctx, pg) if err != nil { log.Fatalf("list @legacy.local users: %v", err) } fmt.Printf("legacy_local_users: %d\n", len(rows)) a1 := 0 for _, r := range rows { if r.A1Member { a1++ } } fmt.Printf("a1_members_among_them: %d\n", a1) if listOnly || (!exportOnly && !patch) { for _, r := range rows { a1Flag := "" if r.A1Member { a1Flag = "\ta1" } co := strings.Join(r.Companies, ",") fmt.Printf(" %s\t%s\t%s\t%s%s\n", r.ID, r.LegacyUserID, r.Email, co, a1Flag) } fmt.Printf("listed=%d\n", len(rows)) } if exportOnly { outPath := strings.TrimSpace(emailsOut) if outPath == "" { if strings.TrimSpace(mapsDir) == "" { mapsDir = "maps" } outPath = filepath.Join(mapsDir, "legacy-emails.json") } if err := writeLegacyEmailInventory(outPath, rows); err != nil { log.Fatalf("export legacy emails: %v", err) } fmt.Printf("exported=%d path=%s\n", len(rows), outPath) } if !patch { return } byLegacy, err := loadEmailPatchMap(emailsFile) if err != nil { log.Fatalf("load -emails-file: %v", err) } occupied, err := loadOccupiedEmails(ctx, pg) if err != nil { log.Fatalf("load occupied emails: %v", err) } plan := planEmailPatches(rows, byLegacy, occupied) fmt.Printf("patch_candidates: %d skips: %d dry_run=%v\n", len(plan.Apply), len(plan.Skips), dryRun) for _, s := range plan.Skips { fmt.Printf("skip\t%s\t%s\t%s\t%s\n", s.UserID, s.LegacyID, s.Email, s.Reason) } applied := 0 for _, a := range plan.Apply { if a.A1Member { fmt.Printf("skip\t%s\t%s\t%s\ta1_member\n", a.UserID, a.LegacyID, a.FromEmail) continue } if dryRun { fmt.Printf("dry-run: would patch %s (%s) %s -> %s\n", a.UserID, a.LegacyID, a.FromEmail, a.ToEmail) applied++ continue } ok, err := applyEmailPatch(ctx, pg, a) if err != nil { log.Printf("patch %s: %v", a.UserID, err) continue } if !ok { fmt.Printf("skip\t%s\t%s\t%s\tcurrent_email_no_longer_synthetic\n", a.UserID, a.LegacyID, a.FromEmail) continue } fmt.Printf("patched\t%s\t%s\t%s -> %s\n", a.UserID, a.LegacyID, a.FromEmail, a.ToEmail) applied++ } fmt.Printf("applied=%d skipped=%d dry_run=%v\n", applied, len(plan.Skips), dryRun) } func listSyntheticLegacyEmails(ctx context.Context, pg *pgxpool.Pool) ([]legacyEmailRow, error) { q := ` SELECT u.id::text, u.email, COALESCE(u.name, ''), COALESCE(u.legacy_user_id, ''), COALESCE(string_agg(DISTINCT c.name, ', ' ORDER BY c.name), ''), COALESCE(string_agg(DISTINCT COALESCE(c.legacy_company_id, ''), ','), '') FROM users u LEFT JOIN memberships m ON m.user_id = u.id AND m.status = 'active' LEFT JOIN companies c ON c.id = m.company_id WHERE lower(u.email) LIKE '%@legacy.local' GROUP BY u.id ORDER BY u.email` rows, err := pg.Query(ctx, q) if err != nil { return nil, err } defer rows.Close() var out []legacyEmailRow for rows.Next() { var r legacyEmailRow var companiesCSV, legacyIDsCSV string if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.LegacyUserID, &companiesCSV, &legacyIDsCSV); err != nil { return nil, err } r.Companies = splitCSVNonEmpty(companiesCSV) r.LegacyCompanyIDs = splitCSVNonEmpty(legacyIDsCSV) r.A1Member = rowIsA1Member(r) if r.LegacyUserID == "" { r.LegacyUserID = legacyIDFromSyntheticEmail(r.Email) } out = append(out, r) } return out, rows.Err() } func rowIsA1Member(r legacyEmailRow) bool { for _, id := range r.LegacyCompanyIDs { if billing.IsA1CohortCompany(id, "") { return true } } for _, name := range r.Companies { if strings.EqualFold(strings.TrimSpace(name), "A1 Slovenija") || strings.EqualFold(strings.TrimSpace(name), "A1") { return true } } return false } func splitCSVNonEmpty(s string) []string { s = strings.TrimSpace(s) if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func writeLegacyEmailInventory(path string, rows []legacyEmailRow) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } a1 := 0 emails := map[string]string{} for _, r := range rows { if r.A1Member { a1++ } key := strings.TrimSpace(r.LegacyUserID) if key == "" { key = legacyIDFromSyntheticEmail(r.Email) } if key != "" { emails[key] = "" } } inv := legacyEmailInventory{ Version: 1, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Count: len(rows), A1Members: a1, Note: "Fill emails{} from a Clerk user export (id → primary email), then: go run ./cmd/migrator -patch-emails -emails-file -postgres $DATABASE_URL -dry-run (live apply needs -confirm). Never commit secrets. Patch only updates rows that still end with @legacy.local — A1 members and real live emails are never overwritten.", Users: rows, Emails: emails, } raw, err := json.MarshalIndent(inv, "", " ") if err != nil { return err } return os.WriteFile(path, append(raw, '\n'), 0o600) } func loadOccupiedEmails(ctx context.Context, pg *pgxpool.Pool) (map[string]string, error) { rows, err := pg.Query(ctx, `SELECT id::text, lower(email) FROM users WHERE email IS NOT NULL AND trim(email) <> ''`) if err != nil { return nil, err } defer rows.Close() out := map[string]string{} for rows.Next() { var id, email string if err := rows.Scan(&id, &email); err != nil { return nil, err } out[strings.ToLower(strings.TrimSpace(email))] = id } return out, rows.Err() } func loadEmailPatchMap(path string) (map[string]string, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } ext := strings.ToLower(filepath.Ext(path)) if ext == ".csv" { return parseEmailPatchCSV(raw) } return parseEmailPatchJSON(raw) } func parseEmailPatchJSON(raw []byte) (map[string]string, error) { trimmed := strings.TrimSpace(string(raw)) if trimmed == "" { return nil, fmt.Errorf("empty emails file") } // Object map: {"user_xxx":"a@b.com"} or inventory {"emails":{...},"users":[...]} var obj map[string]json.RawMessage if err := json.Unmarshal(raw, &obj); err == nil { if emailsRaw, ok := obj["emails"]; ok { var emails map[string]string if err := json.Unmarshal(emailsRaw, &emails); err != nil { return nil, fmt.Errorf("emails object: %w", err) } return normalizeEmailPatchMap(emails), nil } if usersRaw, ok := obj["users"]; ok { m, err := parseEmailPatchArray(usersRaw) if err == nil && len(m) > 0 { return m, nil } } // Flat string map (all values JSON strings). var flat map[string]string if err := json.Unmarshal(raw, &flat); err == nil { // Reject inventory-shaped objects that decoded poorly (version etc.). if _, hasVersion := flat["version"]; !hasVersion && len(flat) > 0 { return normalizeEmailPatchMap(flat), nil } } } var arr []json.RawMessage if err := json.Unmarshal(raw, &arr); err == nil { return parseEmailPatchArray(raw) } return nil, fmt.Errorf("unsupported emails JSON (want map, {emails:{}}, {users:[]}, or array)") } func parseEmailPatchArray(raw []byte) (map[string]string, error) { var rows []map[string]any if err := json.Unmarshal(raw, &rows); err != nil { return nil, err } out := map[string]string{} for _, row := range rows { id := firstString(row, "id", "legacy_user_id", "user_id", "clerk_id") email := firstString(row, "email", "primary_email_address", "primary_email", "real_email") if email == "" { if addrs, ok := row["email_addresses"].([]any); ok { email = primaryFromClerkEmailAddresses(addrs) } } id = strings.TrimSpace(id) email = strings.ToLower(strings.TrimSpace(email)) if id == "" || email == "" { continue } out[id] = email } return normalizeEmailPatchMap(out), nil } func primaryFromClerkEmailAddresses(addrs []any) string { for _, a := range addrs { m, ok := a.(map[string]any) if !ok { continue } email := firstString(m, "email_address", "email") if email == "" { continue } if primary, _ := m["primary"].(bool); primary { return email } } for _, a := range addrs { m, ok := a.(map[string]any) if !ok { continue } if email := firstString(m, "email_address", "email"); email != "" { return email } } return "" } func firstString(m map[string]any, keys ...string) string { for _, k := range keys { if v, ok := m[k]; ok { switch t := v.(type) { case string: if strings.TrimSpace(t) != "" { return t } } } } return "" } func parseEmailPatchCSV(raw []byte) (map[string]string, error) { r := csv.NewReader(strings.NewReader(string(raw))) r.TrimLeadingSpace = true records, err := r.ReadAll() if err != nil { return nil, err } if len(records) == 0 { return nil, fmt.Errorf("empty CSV") } header := records[0] idIdx, emailIdx := -1, -1 for i, h := range header { switch strings.ToLower(strings.TrimSpace(h)) { case "id", "legacy_user_id", "user_id", "clerk_id": if idIdx < 0 { idIdx = i } case "email", "primary_email_address", "primary_email", "real_email": if emailIdx < 0 { emailIdx = i } } } if idIdx < 0 || emailIdx < 0 { return nil, fmt.Errorf("CSV needs id/legacy_user_id and email/primary_email_address columns") } out := map[string]string{} for _, rec := range records[1:] { if idIdx >= len(rec) || emailIdx >= len(rec) { continue } id := strings.TrimSpace(rec[idIdx]) email := strings.ToLower(strings.TrimSpace(rec[emailIdx])) if id == "" || email == "" { continue } out[id] = email } return normalizeEmailPatchMap(out), nil } func normalizeEmailPatchMap(in map[string]string) map[string]string { out := map[string]string{} for k, v := range in { k = strings.TrimSpace(k) v = strings.ToLower(strings.TrimSpace(v)) if k == "" || v == "" { continue } out[k] = v } return out } func legacyIDFromSyntheticEmail(email string) string { email = strings.ToLower(strings.TrimSpace(email)) if !strings.HasSuffix(email, legacyEmailSuffix) { return "" } return strings.TrimSuffix(email, legacyEmailSuffix) } // planEmailPatches builds apply/skip lists. Safety: only synthetic current emails; // never overwrite a real (non-@legacy.local) address; never mutate A1 members // (even with -confirm / dry-run apply lists). func planEmailPatches(rows []legacyEmailRow, byLegacy map[string]string, occupied map[string]string) emailPatchPlan { plan := emailPatchPlan{} if len(byLegacy) == 0 { plan.Skips = append(plan.Skips, emailPatchSkip{Reason: "empty_patch_map"}) return plan } matchedLegacy := map[string]bool{} for _, row := range rows { if row.A1Member { legacyID := strings.TrimSpace(row.LegacyUserID) if legacyID == "" { legacyID = legacyIDFromSyntheticEmail(row.Email) } if legacyID != "" { matchedLegacy[legacyID] = true } plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "a1_member", }) continue } if !auth.IsSyntheticLegacyEmail(row.Email) { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: row.LegacyUserID, Email: row.Email, Reason: "current_email_not_synthetic", }) continue } legacyID := strings.TrimSpace(row.LegacyUserID) if legacyID == "" { legacyID = legacyIDFromSyntheticEmail(row.Email) } to, ok := byLegacy[legacyID] if !ok { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "no_mapping_in_emails_file", }) continue } matchedLegacy[legacyID] = true to = strings.ToLower(strings.TrimSpace(to)) if to == "" || strings.EqualFold(to, "replace_me@example.com") { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "empty_or_placeholder_target", }) continue } if auth.IsSyntheticLegacyEmail(to) { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "target_still_synthetic", }) continue } if !strings.Contains(to, "@") { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "target_invalid_email", }) continue } if strings.EqualFold(to, row.Email) { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "unchanged", }) continue } if owner, taken := occupied[to]; taken && owner != row.ID { plan.Skips = append(plan.Skips, emailPatchSkip{ UserID: row.ID, LegacyID: legacyID, Email: row.Email, Reason: "target_email_owned_by_" + owner, }) continue } plan.Apply = append(plan.Apply, emailPatchAction{ UserID: row.ID, LegacyID: legacyID, FromEmail: row.Email, ToEmail: to, A1Member: row.A1Member, Name: row.Name, }) } for legacyID, email := range byLegacy { if matchedLegacy[legacyID] { continue } plan.Skips = append(plan.Skips, emailPatchSkip{ LegacyID: legacyID, Email: email, Reason: "no_synthetic_user_for_legacy_id", }) } return plan } func applyEmailPatch(ctx context.Context, pg *pgxpool.Pool, a emailPatchAction) (bool, error) { // Defense in depth: SQL only updates rows that are still @legacy.local. tag, err := pg.Exec(ctx, ` UPDATE users SET email = $2, updated_at = now() WHERE id = $1::uuid AND lower(email) LIKE '%@legacy.local' AND lower(email) = lower($3)`, a.UserID, a.ToEmail, a.FromEmail) if err != nil { return false, err } return tag.RowsAffected() > 0, nil }