600 lines
16 KiB
Go
600 lines
16 KiB
Go
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 <path> -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
|
||
|
|
}
|