diff --git a/backend/infra/migrations/010_add_unique_email_index.sql b/backend/infra/migrations/010_add_unique_email_index.sql new file mode 100644 index 0000000..a51149e --- /dev/null +++ b/backend/infra/migrations/010_add_unique_email_index.sql @@ -0,0 +1,20 @@ +-- Migration 010: enforce unique emails in users +-- Matches runtime expectation for idx_users_email_unique. + +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM users + GROUP BY email + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'Cannot create unique index idx_users_email_unique: duplicate emails exist in users'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email); + +COMMIT; diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 1481c68..9f5b4af 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/kjannette/koin-ping/backend/internal/domain" ) @@ -37,10 +38,55 @@ func scanUser(row pgx.Row) (*domain.User, error) { return &u, nil } +func (m *UserModel) getByFirebaseUID(ctx context.Context, firebaseUID string) (*domain.User, error) { + row := m.pool.QueryRow(ctx, + `SELECT `+userColumns+` FROM users WHERE firebase_uid = $1`, + firebaseUID, + ) + return scanUser(row) +} + +func (m *UserModel) getByEmail(ctx context.Context, email string) (*domain.User, error) { + row := m.pool.QueryRow(ctx, + `SELECT `+userColumns+` FROM users WHERE email = $1`, + email, + ) + return scanUser(row) +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} + // FindOrCreateByFirebaseUID returns the local user for a Firebase UID, -// creating one if it doesn't exist yet. On conflict (returning user) the -// updated_at timestamp is refreshed. +// creating one if it doesn't exist yet. +// +// For onboarding races or legacy duplicate-identity states, this method +// gracefully falls back to an existing row by email instead of failing +// with a unique-email violation. func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) { + existingByUID, err := m.getByFirebaseUID(ctx, firebaseUID) + if err != nil { + return nil, err + } + if existingByUID != nil { + return existingByUID, nil + } + + if email != "" { + existingByEmail, err := m.getByEmail(ctx, email) + if err != nil { + return nil, err + } + if existingByEmail != nil { + return existingByEmail, nil + } + } + row := m.pool.QueryRow(ctx, `INSERT INTO users (firebase_uid, email) VALUES ($1, $2) @@ -48,7 +94,33 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, RETURNING `+userColumns, firebaseUID, email, ) - return scanUser(row) + user, err := scanUser(row) + if err == nil { + return user, nil + } + + // If a concurrent request inserted by email or firebase_uid first, + // read the existing record and continue without surfacing a 500. + if isUniqueViolation(err) { + existingByUID, readErr := m.getByFirebaseUID(ctx, firebaseUID) + if readErr != nil { + return nil, readErr + } + if existingByUID != nil { + return existingByUID, nil + } + if email != "" { + existingByEmail, readErr := m.getByEmail(ctx, email) + if readErr != nil { + return nil, readErr + } + if existingByEmail != nil { + return existingByEmail, nil + } + } + } + + return nil, err } func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) { diff --git a/frontend/src/pages/subscribe/Subscribe.jsx b/frontend/src/pages/subscribe/Subscribe.jsx index 4a342a2..98e44e7 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -40,8 +40,14 @@ export default function Subscribe() { useEffect(() => { const payment = searchParams.get("payment"); const sessionId = searchParams.get("session_id"); + const signupLockKey = sessionId + ? `kp_onboard_signup_started_${sessionId}` + : null; if (payment === "cancelled") { + if (signupLockKey) sessionStorage.removeItem(signupLockKey); + sessionStorage.removeItem("kp_onboard_email"); + sessionStorage.removeItem("kp_onboard_pw"); setSearchParams({}, { replace: true }); setStep(2); setError("Payment was cancelled. Please try again."); @@ -53,15 +59,27 @@ export default function Subscribe() { // Phase 1: no account yet — create it. Auth state change remounts the // component (App.jsx swaps route trees) and Phase 2 runs on the next mount. if (!currentUser) { - if (!userProps.email || !userProps.password) { + const savedEmail = sessionStorage.getItem("kp_onboard_email"); + const savedPw = sessionStorage.getItem("kp_onboard_pw"); + if (!savedEmail || !savedPw) { + if (signupLockKey) sessionStorage.removeItem(signupLockKey); setSearchParams({}, { replace: true }); setError("Session expired. Please start the signup process again."); setStep(1); setLoading(false); return; } + + // Guard against duplicate signup attempts caused by repeated effects. + if (signupLockKey && sessionStorage.getItem(signupLockKey) === "1") { + setLoading(true); + return; + } + setLoading(true); - signup(userProps.email, userProps.password).catch((err) => { + if (signupLockKey) sessionStorage.setItem(signupLockKey, "1"); + signup(savedEmail, savedPw).catch((err) => { + if (signupLockKey) sessionStorage.removeItem(signupLockKey); setSearchParams({}, { replace: true }); setError("Account creation failed: " + err.message); setStep(1);