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 ?