owpengram-server/internal/store/postgres/phone_identity_migration.go
2026-09-09 02:49:30 +03:00

152 lines
4.5 KiB
Go

package postgres
import (
"context"
"fmt"
"math"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
const phoneIdentityMigrationBatchSize = 1000
type phoneIdentityCandidate struct {
userID int64
canonicalPhone string
needsUpdate bool
}
// canonicalizeStoredPhoneIdentities is the data half of migration 0182. SQL
// alone cannot correctly distinguish a removable national trunk prefix from a
// significant leading zero, so the application numbering-plan metadata builds
// a complete plan first. Legacy values that do not describe any valid phone
// identity are left untouched and therefore remain unreachable through the
// canonical login path. No users row is changed until no two parseable rows
// converge on the same E.164 identity.
func canonicalizeStoredPhoneIdentities(ctx context.Context, conn *pgx.Conn) error {
tx, err := conn.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return fmt.Errorf("begin phone identity audit: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
if err := canonicalizeStoredPhoneIdentitiesTx(ctx, tx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit phone identity migration: %w", err)
}
committed = true
return nil
}
func canonicalizeStoredPhoneIdentitiesTx(ctx context.Context, tx pgx.Tx) error {
return canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"public", "users"})
}
func canonicalizeStoredPhoneIdentitiesInTableTx(ctx context.Context, tx pgx.Tx, usersTable pgx.Identifier) error {
if _, err := tx.Exec(ctx, `
CREATE TEMP TABLE phone_identity_0182 (
user_id bigint PRIMARY KEY,
canonical_phone text NOT NULL,
needs_update boolean NOT NULL
) ON COMMIT DROP`); err != nil {
return fmt.Errorf("create phone identity plan: %w", err)
}
lastID := int64(math.MinInt64)
for {
rows, err := tx.Query(ctx, fmt.Sprintf(`
SELECT id, phone
FROM %s
WHERE id > $1 AND phone <> ''
ORDER BY id
LIMIT $2`, usersTable.Sanitize()), lastID, phoneIdentityMigrationBatchSize)
if err != nil {
return fmt.Errorf("scan stored phone identities: %w", err)
}
rowsRead := 0
candidates := make([]phoneIdentityCandidate, 0, phoneIdentityMigrationBatchSize)
for rows.Next() {
rowsRead++
var userID int64
var rawPhone string
if err := rows.Scan(&userID, &rawPhone); err != nil {
rows.Close()
return fmt.Errorf("read stored phone identity: %w", err)
}
lastID = userID
canonical := rawPhone
if !domain.IsSystemUserID(userID) {
canonical = domain.NormalizePhone(rawPhone)
if canonical == "" {
continue
}
}
candidates = append(candidates, phoneIdentityCandidate{
userID: userID,
canonicalPhone: canonical,
needsUpdate: canonical != rawPhone,
})
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("iterate stored phone identities: %w", err)
}
rows.Close()
if len(candidates) > 0 {
copyRows := make([][]any, 0, len(candidates))
for _, candidate := range candidates {
copyRows = append(copyRows, []any{candidate.userID, candidate.canonicalPhone, candidate.needsUpdate})
}
if _, err := tx.CopyFrom(ctx,
pgx.Identifier{"phone_identity_0182"},
[]string{"user_id", "canonical_phone", "needs_update"},
pgx.CopyFromRows(copyRows),
); err != nil {
return fmt.Errorf("stage phone identity plan: %w", err)
}
}
if rowsRead < phoneIdentityMigrationBatchSize {
break
}
}
if _, err := tx.Exec(ctx, `
CREATE INDEX phone_identity_0182_canonical_idx
ON phone_identity_0182(canonical_phone, user_id)`); err != nil {
return fmt.Errorf("index phone identity plan: %w", err)
}
var firstUserID, secondUserID int64
err := tx.QueryRow(ctx, `
SELECT first.user_id, second.user_id
FROM phone_identity_0182 AS first
JOIN phone_identity_0182 AS second
ON second.canonical_phone = first.canonical_phone
AND second.user_id > first.user_id
ORDER BY first.user_id, second.user_id
LIMIT 1`).Scan(&firstUserID, &secondUserID)
switch {
case err == nil:
return fmt.Errorf("users %d and %d resolve to the same canonical phone", firstUserID, secondUserID)
case err != pgx.ErrNoRows:
return fmt.Errorf("audit canonical phone uniqueness: %w", err)
}
if _, err := tx.Exec(ctx, fmt.Sprintf(`
UPDATE %s AS target
SET phone = planned.canonical_phone
FROM phone_identity_0182 AS planned
WHERE planned.user_id = target.id
AND planned.needs_update`, usersTable.Sanitize())); err != nil {
return fmt.Errorf("apply canonical phone identities: %w", err)
}
return nil
}