152 lines
4.5 KiB
Go
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
|
|
}
|