Merge pull request #30 from kjannette/finishing-signup-stages
final steps
This commit is contained in:
20
backend/infra/migrations/010_add_unique_email_index.sql
Normal file
20
backend/infra/migrations/010_add_unique_email_index.sql
Normal file
@@ -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;
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -37,10 +38,55 @@ func scanUser(row pgx.Row) (*domain.User, error) {
|
|||||||
return &u, nil
|
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,
|
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||||
// creating one if it doesn't exist yet. On conflict (returning user) the
|
// creating one if it doesn't exist yet.
|
||||||
// updated_at timestamp is refreshed.
|
//
|
||||||
|
// 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) {
|
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,
|
row := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO users (firebase_uid, email)
|
`INSERT INTO users (firebase_uid, email)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
@@ -48,7 +94,33 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID,
|
|||||||
RETURNING `+userColumns,
|
RETURNING `+userColumns,
|
||||||
firebaseUID, email,
|
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) {
|
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ export default function Subscribe() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const payment = searchParams.get("payment");
|
const payment = searchParams.get("payment");
|
||||||
const sessionId = searchParams.get("session_id");
|
const sessionId = searchParams.get("session_id");
|
||||||
|
const signupLockKey = sessionId
|
||||||
|
? `kp_onboard_signup_started_${sessionId}`
|
||||||
|
: null;
|
||||||
|
|
||||||
if (payment === "cancelled") {
|
if (payment === "cancelled") {
|
||||||
|
if (signupLockKey) sessionStorage.removeItem(signupLockKey);
|
||||||
|
sessionStorage.removeItem("kp_onboard_email");
|
||||||
|
sessionStorage.removeItem("kp_onboard_pw");
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
setStep(2);
|
setStep(2);
|
||||||
setError("Payment was cancelled. Please try again.");
|
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
|
// 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.
|
// component (App.jsx swaps route trees) and Phase 2 runs on the next mount.
|
||||||
if (!currentUser) {
|
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 });
|
setSearchParams({}, { replace: true });
|
||||||
setError("Session expired. Please start the signup process again.");
|
setError("Session expired. Please start the signup process again.");
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard against duplicate signup attempts caused by repeated effects.
|
||||||
|
if (signupLockKey && sessionStorage.getItem(signupLockKey) === "1") {
|
||||||
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
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 });
|
setSearchParams({}, { replace: true });
|
||||||
setError("Account creation failed: " + err.message);
|
setError("Account creation failed: " + err.message);
|
||||||
setStep(1);
|
setStep(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user