Files
descrybe/apps/api/cmd/migrator/legacy_emails.go
T

600 lines
16 KiB
Go
Raw Normal View History

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
}