diff --git a/backend/README.md b/backend/README.md index 940937f..9d3d3b8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,5 @@ -Backend startup quickstarat: +Backend startup quickstart: + -----------------------------> BEST ## 1. brew services start postgresql@15 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/App.jsx b/frontend/src/App.jsx index 014f142..a99c4ac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,7 +11,9 @@ import AlertHistory from "./pages/alertHistory/AlertHistory"; import Account from "./pages/user_account/Account"; export default function App() { - const { currentUser, isSubscribed } = useAuth(); + const { currentUser, isSubscribed, loading } = useAuth(); + + if (loading) return null; if (!currentUser) { return ( diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 0413bd3..cb375e6 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,140 +1,94 @@ -/** - * AuthContext - Firebase Authentication State Management - * - * Provides authentication state, tier info, and methods throughout the app - */ - -import { createContext, useContext, useEffect, useState, useCallback } from "react"; +import { createContext, useContext, useState, useEffect, useCallback } from "react"; import { - createUserWithEmailAndPassword, - signInWithEmailAndPassword, - signOut, - onAuthStateChanged, + onAuthStateChanged, + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + signOut, } from "firebase/auth"; import { auth } from "../firebase/config"; import { getAccount } from "../api/account"; -const AuthContext = createContext(); +const AuthContext = createContext(undefined); const DEFAULT_TIER_LIMITS = { - max_addresses: 1, - max_alert_types: 1, - allowed_channels: ["email"], + max_addresses: 1, + max_alert_types: 1, + allowed_channels: ["email"], }; -/** - * Hook to access auth context - * @returns {Object} Auth context value - */ -export function useAuth() { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within AuthProvider"); - } - return context; -} - -/** - * AuthProvider - Wraps app and provides auth state + tier info - */ export function AuthProvider({ children }) { - const [currentUser, setCurrentUser] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [userTier, setUserTier] = useState("free"); + const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS); + const [isSubscribed, setIsSubscribed] = useState(false); + const [loading, setLoading] = useState(true); - const [userTier, setUserTier] = useState("free"); - const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS); - const [addressCount, setAddressCount] = useState(0); - const [subscriptionStatus, setSubscriptionStatus] = useState("none"); - - const refreshAccount = useCallback(async () => { - try { - const data = await getAccount(); - setUserTier(data.subscription_tier || "free"); - setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS); - setAddressCount(data.address_count || 0); - setSubscriptionStatus(data.subscription_status || "none"); - } catch { - // account fetch can fail during onboarding before subscription is active - } - }, []); - - async function signup(email, password) { - try { - setError(null); - const result = await createUserWithEmailAndPassword( - auth, - email, - password, - ); - return result.user; - } catch (err) { - setError(err.message); - throw err; - } + const fetchAccount = useCallback(async () => { + try { + const data = await getAccount(); + setUserTier(data.subscription_tier || "free"); + setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS); + setIsSubscribed( + data.subscription_status === "active" || + data.subscription_status === "trialing", + ); + } catch { + setUserTier("free"); + setTierLimits(DEFAULT_TIER_LIMITS); + setIsSubscribed(false); } + }, []); - async function login(email, password) { - try { - setError(null); - const result = await signInWithEmailAndPassword( - auth, - email, - password, - ); - return result.user; - } catch (err) { - setError(err.message); - throw err; - } - } + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, async (user) => { + setCurrentUser(user); + if (user) { + await fetchAccount(); + } else { + setUserTier("free"); + setTierLimits(DEFAULT_TIER_LIMITS); + setIsSubscribed(false); + } + setLoading(false); + }); + return unsubscribe; + }, [fetchAccount]); - async function logout() { - try { - setError(null); - setUserTier("free"); - setTierLimits(DEFAULT_TIER_LIMITS); - setAddressCount(0); - setSubscriptionStatus("none"); - await signOut(auth); - } catch (err) { - setError(err.message); - throw err; - } - } + async function signup(email, password) { + const cred = await createUserWithEmailAndPassword(auth, email, password); + return cred.user; + } - useEffect(() => { - const unsubscribe = onAuthStateChanged(auth, (user) => { - setCurrentUser(user); - setLoading(false); - if (user) { - refreshAccount(); - } - }); - return unsubscribe; - }, [refreshAccount]); + async function login(email, password) { + const cred = await signInWithEmailAndPassword(auth, email, password); + return cred.user; + } - const isSubscribed = - subscriptionStatus === "active" || subscriptionStatus === "trialing"; + async function logout() { + await signOut(auth); + } - const value = { - currentUser, - signup, - login, - logout, - error, - loading, - userTier, - tierLimits, - addressCount, - subscriptionStatus, - isSubscribed, - refreshAccount, - }; + const value = { + currentUser, + userTier, + tierLimits, + isSubscribed, + loading, + signup, + login, + logout, + refreshAccount: fetchAccount, + }; - return ( - - {!loading && children} - - ); + return {children}; } + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +export default useAuth; diff --git a/frontend/src/contexts/UserPropertiesContext.jsx b/frontend/src/contexts/UserPropertiesContext.jsx new file mode 100644 index 0000000..0bb91a4 --- /dev/null +++ b/frontend/src/contexts/UserPropertiesContext.jsx @@ -0,0 +1,44 @@ +import { createContext, useReducer, useContext } from "react"; + +const initialState = { + email: "", + password: "", +}; + +const ACTION_TYPES = { + SET_USER_PROPERTIES: "SET_USER_PROPERTIES", + CLEAR_USER_PROPERTIES: "CLEAR_USER_PROPERTIES", +}; + +function userPropertiesReducer(state, action) { + switch (action.type) { + case ACTION_TYPES.SET_USER_PROPERTIES: + return { ...state, ...action.payload }; + case ACTION_TYPES.CLEAR_USER_PROPERTIES: + return { ...initialState }; + default: + return state; + } +} + +const UserPropertiesContext = createContext(undefined); + +export function UserPropertiesProvider({ children }) { + const [state, dispatch] = useReducer(userPropertiesReducer, initialState); + + return ( + + {children} + + ); +} + +export function useUserProperties() { + const context = useContext(UserPropertiesContext); + if (context === undefined) { + throw new Error( + "useUserProperties must be used within a UserPropertiesProvider", + ); + } + return context; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 00913df..6c791fd 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,15 +2,18 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./contexts/AuthContext"; +import { UserPropertiesProvider } from "./contexts/UserPropertiesContext"; import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( - - - + + + + + , ); diff --git a/frontend/src/pages/subscribe/Subscribe.jsx b/frontend/src/pages/subscribe/Subscribe.jsx index e776894..8ba7685 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../../contexts/AuthContext"; +import { useUserProperties } from "../../contexts/UserPropertiesContext"; import { createCheckoutSession, activateFreeTier, @@ -21,7 +22,7 @@ export default function Subscribe() { const success = queryParameters?.get("payment") const session_id = queryParameters?.get("session_id") - console.log('success, session_id_________________________------------>', success, session_id) + console.log('success, session_id ------>', success, session_id) function forward(success, session_id) { success && session_id ?