From 3a52e7afb31442be496a2d3124977090c62d5ccd Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 00:02:38 -0400 Subject: [PATCH 1/7] more --- backend/README.md | 3 +- .../src/contexts/UserPropertiesContext.jsx | 48 +++++++++++++++++++ .../src/contexts/userPropertiesReducer.js | 20 ++++++++ frontend/src/main.jsx | 9 ++-- frontend/src/pages/subscribe/Subscribe.jsx | 19 ++++---- 5 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 frontend/src/contexts/UserPropertiesContext.jsx create mode 100644 frontend/src/contexts/userPropertiesReducer.js 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/frontend/src/contexts/UserPropertiesContext.jsx b/frontend/src/contexts/UserPropertiesContext.jsx new file mode 100644 index 0000000..e2bd3e5 --- /dev/null +++ b/frontend/src/contexts/UserPropertiesContext.jsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useReducer, useEffect } from "react"; +import { + userPropertiesReducer, + initialState, + ACTION_TYPES, +} from "./userPropertiesReducer"; + +const STORAGE_KEY = "kp_onboard_user"; + +function hydrateState() { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (raw) return { ...initialState, ...JSON.parse(raw) }; + } catch { + /* corrupt data — fall through */ + } + return initialState; +} + +const UserPropertiesContext = createContext(initialState); + +export function useUserProperties() { + const ctx = useContext(UserPropertiesContext); + if (!ctx) { + throw new Error( + "useUserProperties must be used within UserPropertiesProvider", + ); + } + return ctx; +} + +export function UserPropertiesProvider({ children }) { + const [state, dispatch] = useReducer(userPropertiesReducer, null, hydrateState); + + useEffect(() => { + if (state.email || state.password) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } else { + sessionStorage.removeItem(STORAGE_KEY); + } + }, [state]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/contexts/userPropertiesReducer.js b/frontend/src/contexts/userPropertiesReducer.js new file mode 100644 index 0000000..c693ccb --- /dev/null +++ b/frontend/src/contexts/userPropertiesReducer.js @@ -0,0 +1,20 @@ +export const initialState = { + email: "", + password: "", +}; + +export const ACTION_TYPES = { + SET_USER_PROPERTIES: "SET_USER_PROPERTIES", + CLEAR_USER_PROPERTIES: "CLEAR_USER_PROPERTIES", +}; + +export 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; + } +} 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 4031120..4a342a2 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../../contexts/AuthContext"; +import { useUserProperties } from "../../contexts/UserPropertiesContext"; import { createOnboardingCheckout, verifyCheckoutSession, @@ -15,6 +16,7 @@ const STEPS = ["Create Account", "Choose Plan"]; export default function Subscribe() { const { currentUser, signup } = useAuth(); + const { state: userProps, dispatch, ACTION_TYPES } = useUserProperties(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -51,9 +53,7 @@ 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) { - const savedEmail = sessionStorage.getItem("kp_onboard_email"); - const savedPw = sessionStorage.getItem("kp_onboard_pw"); - if (!savedEmail || !savedPw) { + if (!userProps.email || !userProps.password) { setSearchParams({}, { replace: true }); setError("Session expired. Please start the signup process again."); setStep(1); @@ -61,7 +61,7 @@ export default function Subscribe() { return; } setLoading(true); - signup(savedEmail, savedPw).catch((err) => { + signup(userProps.email, userProps.password).catch((err) => { setSearchParams({}, { replace: true }); setError("Account creation failed: " + err.message); setStep(1); @@ -73,8 +73,7 @@ export default function Subscribe() { // Phase 2: authenticated — verify the checkout and go to the dashboard. setSearchParams({}, { replace: true }); setLoading(true); - sessionStorage.removeItem("kp_onboard_email"); - sessionStorage.removeItem("kp_onboard_pw"); + dispatch({ type: ACTION_TYPES.CLEAR_USER_PROPERTIES }); verifyCheckoutSession(sessionId) .then(() => { @@ -85,7 +84,7 @@ export default function Subscribe() { setStep(2); }) .finally(() => setLoading(false)); - }, [currentUser, searchParams, setSearchParams, signup, navigate]); + }, [currentUser, searchParams, setSearchParams, signup, navigate, userProps, dispatch, ACTION_TYPES]); // ── Step handlers ───────────────────────────────────────────────────────── @@ -124,8 +123,10 @@ export default function Subscribe() { return; } - sessionStorage.setItem("kp_onboard_email", data.email); - sessionStorage.setItem("kp_onboard_pw", data.password); + dispatch({ + type: ACTION_TYPES.SET_USER_PROPERTIES, + payload: { email: data.email, password: data.password }, + }); const { url } = await createOnboardingCheckout( data.email, data.selectedTier, From c0aaaedaf13c47b3f0148d24f7ab93797f206690 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 01:31:20 -0400 Subject: [PATCH 2/7] more --- frontend/src/contexts/AuthContext.jsx | 176 +++++------------- .../src/contexts/UserPropertiesContext.jsx | 48 ----- .../src/contexts/userPropertiesReducer.js | 20 -- frontend/src/reducers/authReducer.jsx | 25 +++ 4 files changed, 69 insertions(+), 200 deletions(-) delete mode 100644 frontend/src/contexts/UserPropertiesContext.jsx delete mode 100644 frontend/src/contexts/userPropertiesReducer.js create mode 100644 frontend/src/reducers/authReducer.jsx diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 0413bd3..81169ef 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,140 +1,52 @@ -/** - * AuthContext - Firebase Authentication State Management - * - * Provides authentication state, tier info, and methods throughout the app - */ +import { createContext, useReducer, useContext } from "react"; +import shopReducer, { initialState } from "./shopReducer"; -import { createContext, useContext, useEffect, useState, useCallback } from "react"; -import { - createUserWithEmailAndPassword, - signInWithEmailAndPassword, - signOut, - onAuthStateChanged, -} from "firebase/auth"; -import { auth } from "../firebase/config"; -import { getAccount } from "../api/account"; +const AuthContext = createContext(initialState); -const AuthContext = createContext(); +export const AuthProvider = ({ children }) => { + const [state, dispatch] = useReducer(shopReducer, initialState); -const DEFAULT_TIER_LIMITS = { - max_addresses: 1, - max_alert_types: 1, - allowed_channels: ["email"], + const addAuthUser = (args) => { + const updatedUser = state.user.concat(product); + updatePrice(updatedCart); + dispatch({ + type: "ADD_AUTH_USER", + payload: { + args: updatedCart + } + }); + }; + + const logoutAuthUser = (args) => { + const updatedUser = state.user.filter( + (currentUser) => currentUser.name !== args.name + ); + updateUser(updatedCart); + + dispatch({ + type: "REMOVE_AUTH_USER", + payload: { + args: updatedCart + } + }); + }; + + + const value = { + user: state.name, + user: state.tier + }; + return {children}; }; -/** - * 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; -} +const useAuthContext = () => { + const context = useContext(ShopContext); -/** - * 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); + if (context === undefined) { + throw new Error("authUser must be used within AuthContext"); + } - const [userTier, setUserTier] = useState("free"); - const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS); - const [addressCount, setAddressCount] = useState(0); - const [subscriptionStatus, setSubscriptionStatus] = useState("none"); + return context; +}; - 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; - } - } - - 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; - } - } - - 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; - } - } - - useEffect(() => { - const unsubscribe = onAuthStateChanged(auth, (user) => { - setCurrentUser(user); - setLoading(false); - if (user) { - refreshAccount(); - } - }); - return unsubscribe; - }, [refreshAccount]); - - const isSubscribed = - subscriptionStatus === "active" || subscriptionStatus === "trialing"; - - const value = { - currentUser, - signup, - login, - logout, - error, - loading, - userTier, - tierLimits, - addressCount, - subscriptionStatus, - isSubscribed, - refreshAccount, - }; - - return ( - - {!loading && children} - - ); -} +export default useAuthContext; diff --git a/frontend/src/contexts/UserPropertiesContext.jsx b/frontend/src/contexts/UserPropertiesContext.jsx deleted file mode 100644 index e2bd3e5..0000000 --- a/frontend/src/contexts/UserPropertiesContext.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { createContext, useContext, useReducer, useEffect } from "react"; -import { - userPropertiesReducer, - initialState, - ACTION_TYPES, -} from "./userPropertiesReducer"; - -const STORAGE_KEY = "kp_onboard_user"; - -function hydrateState() { - try { - const raw = sessionStorage.getItem(STORAGE_KEY); - if (raw) return { ...initialState, ...JSON.parse(raw) }; - } catch { - /* corrupt data — fall through */ - } - return initialState; -} - -const UserPropertiesContext = createContext(initialState); - -export function useUserProperties() { - const ctx = useContext(UserPropertiesContext); - if (!ctx) { - throw new Error( - "useUserProperties must be used within UserPropertiesProvider", - ); - } - return ctx; -} - -export function UserPropertiesProvider({ children }) { - const [state, dispatch] = useReducer(userPropertiesReducer, null, hydrateState); - - useEffect(() => { - if (state.email || state.password) { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } else { - sessionStorage.removeItem(STORAGE_KEY); - } - }, [state]); - - return ( - - {children} - - ); -} diff --git a/frontend/src/contexts/userPropertiesReducer.js b/frontend/src/contexts/userPropertiesReducer.js deleted file mode 100644 index c693ccb..0000000 --- a/frontend/src/contexts/userPropertiesReducer.js +++ /dev/null @@ -1,20 +0,0 @@ -export const initialState = { - email: "", - password: "", -}; - -export const ACTION_TYPES = { - SET_USER_PROPERTIES: "SET_USER_PROPERTIES", - CLEAR_USER_PROPERTIES: "CLEAR_USER_PROPERTIES", -}; - -export 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; - } -} diff --git a/frontend/src/reducers/authReducer.jsx b/frontend/src/reducers/authReducer.jsx new file mode 100644 index 0000000..d227086 --- /dev/null +++ b/frontend/src/reducers/authReducer.jsx @@ -0,0 +1,25 @@ +import { useReducer } from "react" + +const UserAuthContext = createContext(); + +export const initialState = { + authUser: null, + AuthTier: null +}; + +const authReducer = (state, action) => { + const { type, payload = "FREE_TIER" } = action + + switch (type) { + case "ADD_AUTH_USER": + console.log("ADD_AUTH_USER", payload); + return { + ...state, + auth: payload + }; + default: + throw new Error(`No case for type ${type} found.`); + } +} + +export default shopReducer \ No newline at end of file From 1418e7f97ca07eb7f6b7e36ac7c24a3099b0b2e2 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 01:48:55 -0400 Subject: [PATCH 3/7] more --- frontend/src/App.jsx | 5 +++- frontend/src/contexts/AuthContext.jsx | 10 +++++--- frontend/src/reducers/authReducer.jsx | 37 ++++++++++++++++++--------- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 47fb0d3..52afc9c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,6 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./contexts/AuthContext"; +import { useContext } from 'react' import Navbar from "./components/Navbar"; import Login from "./pages/login/Login"; import Signup from "./pages/Signup"; @@ -8,9 +9,11 @@ import Addresses from "./pages/addresses/Addresses"; import Alerts from "./pages/alerts/Alerts"; import AlertHistory from "./pages/alertHistory/AlertHistory"; import Account from "./pages/user_account/Account"; +import { AuthProvider } from "./ShopContext"; +import useAuth from "./ShopContext"; export default function App() { - const { currentUser, isSubscribed } = useAuth(); + const { context } = useContext(ShopContext) if (!currentUser) { return ( diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 81169ef..ce70e46 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -33,13 +33,15 @@ export const AuthProvider = ({ children }) => { const value = { - user: state.name, - user: state.tier + authUser: state.name, + authUserTier: state.tier, + addAuthUser, + logoutAuthUser }; return {children}; }; -const useAuthContext = () => { +const useAuth = () => { const context = useContext(ShopContext); if (context === undefined) { @@ -49,4 +51,4 @@ const useAuthContext = () => { return context; }; -export default useAuthContext; +export default useAuth; diff --git a/frontend/src/reducers/authReducer.jsx b/frontend/src/reducers/authReducer.jsx index d227086..f29f16f 100644 --- a/frontend/src/reducers/authReducer.jsx +++ b/frontend/src/reducers/authReducer.jsx @@ -1,25 +1,38 @@ import { useReducer } from "react" -const UserAuthContext = createContext(); - export const initialState = { - authUser: null, - AuthTier: null + user: null, + tier: null, }; -const authReducer = (state, action) => { - const { type, payload = "FREE_TIER" } = action +const shopReducer = (state, action) => { + const { name, payload } = action; switch (type) { - case "ADD_AUTH_USER": - console.log("ADD_AUTH_USER", payload); + case "ADD_TO_CART": + console.log("ADD_TO_CART", payload); + return { ...state, - auth: payload + products: payload.products + }; + case "REMOVE_FROM_CART": + console.log("REMOVE_FROM_CART", payload); + + return { + ...state, + products: payload.products + }; + case "UPDATE_PRICE": + console.log("UPDATE_PRICE", payload); + + return { + ...state, + total: payload.total }; default: - throw new Error(`No case for type ${type} found.`); + throw new Error(`No case for type ${type} found in shopReducer.`); } -} +}; -export default shopReducer \ No newline at end of file +export default shopReducer; \ No newline at end of file From 9d2a0678be616ffba31149b6ed8e8244d0061c49 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 07:02:20 -0400 Subject: [PATCH 4/7] more --- frontend/src/reducers/authReducer.jsx | 44 ++++++++++++--------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/frontend/src/reducers/authReducer.jsx b/frontend/src/reducers/authReducer.jsx index f29f16f..dd1c505 100644 --- a/frontend/src/reducers/authReducer.jsx +++ b/frontend/src/reducers/authReducer.jsx @@ -1,38 +1,32 @@ import { useReducer } from "react" +const UserAuthContext = createContext(); + export const initialState = { - user: null, - tier: null, + authUser: null, + AuthTier: null }; -const shopReducer = (state, action) => { - const { name, payload } = action; +const authReducer = (state, action) => { + const { type, payload = "FREE_TIER" } = action switch (type) { - case "ADD_TO_CART": - console.log("ADD_TO_CART", payload); - + case "ADD_AUTH_USER": + console.log("ADD_AUTH_USER", payload); return { ...state, - products: payload.products - }; - case "REMOVE_FROM_CART": - console.log("REMOVE_FROM_CART", payload); - - return { - ...state, - products: payload.products - }; - case "UPDATE_PRICE": - console.log("UPDATE_PRICE", payload); - - return { - ...state, - total: payload.total + auth: payload }; default: - throw new Error(`No case for type ${type} found in shopReducer.`); + throw new Error(`No case for type ${type} found.`); } -}; + switch (type) { + case "DELETE_AUTH_USER": + return { + ...state, + users: state.users.filter(user => user.id !== action.userIdToDelete) + }; + } +} -export default shopReducer; \ No newline at end of file +export default shopReducer \ No newline at end of file From 0920985a74dc76bff78fbbdccbc3815eeece686d Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 07:04:25 -0400 Subject: [PATCH 5/7] more --- frontend/src/contexts/AuthContext.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index ce70e46..47cc435 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -12,7 +12,7 @@ export const AuthProvider = ({ children }) => { dispatch({ type: "ADD_AUTH_USER", payload: { - args: updatedCart + args: newUser } }); }; From cde9f52f525670ceffad1502bc8641ae6a3bf44d Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 15:39:20 -0400 Subject: [PATCH 6/7] final steps --- .../migrations/010_add_unique_email_index.sql | 20 +++++ backend/internal/models/user.go | 78 ++++++++++++++++++- frontend/src/pages/subscribe/Subscribe.jsx | 17 ++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 backend/infra/migrations/010_add_unique_email_index.sql 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 4031120..fc3c3ca 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -38,8 +38,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."); @@ -54,14 +60,24 @@ export default function Subscribe() { 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); + 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); @@ -73,6 +89,7 @@ export default function Subscribe() { // Phase 2: authenticated — verify the checkout and go to the dashboard. setSearchParams({}, { replace: true }); setLoading(true); + if (signupLockKey) sessionStorage.removeItem(signupLockKey); sessionStorage.removeItem("kp_onboard_email"); sessionStorage.removeItem("kp_onboard_pw"); From 0f770742ef75ab983f87727d8084155d5f4335ed Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 29 Mar 2026 16:21:48 -0400 Subject: [PATCH 7/7] hottie --- frontend/src/App.jsx | 7 +- frontend/src/contexts/AuthContext.jsx | 128 ++++++++++++------ .../src/contexts/UserPropertiesContext.jsx | 44 ++++++ frontend/src/pages/subscribe/Subscribe.jsx | 2 + frontend/src/reducers/authReducer.jsx | 32 ----- 5 files changed, 133 insertions(+), 80 deletions(-) create mode 100644 frontend/src/contexts/UserPropertiesContext.jsx delete mode 100644 frontend/src/reducers/authReducer.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 52afc9c..d2c25e7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,5 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./contexts/AuthContext"; -import { useContext } from 'react' import Navbar from "./components/Navbar"; import Login from "./pages/login/Login"; import Signup from "./pages/Signup"; @@ -9,11 +8,11 @@ import Addresses from "./pages/addresses/Addresses"; import Alerts from "./pages/alerts/Alerts"; import AlertHistory from "./pages/alertHistory/AlertHistory"; import Account from "./pages/user_account/Account"; -import { AuthProvider } from "./ShopContext"; -import useAuth from "./ShopContext"; export default function App() { - const { context } = useContext(ShopContext) + 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 47cc435..cb375e6 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,54 +1,94 @@ -import { createContext, useReducer, useContext } from "react"; -import shopReducer, { initialState } from "./shopReducer"; +import { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { + onAuthStateChanged, + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + signOut, +} from "firebase/auth"; +import { auth } from "../firebase/config"; +import { getAccount } from "../api/account"; -const AuthContext = createContext(initialState); +const AuthContext = createContext(undefined); -export const AuthProvider = ({ children }) => { - const [state, dispatch] = useReducer(shopReducer, initialState); - - const addAuthUser = (args) => { - const updatedUser = state.user.concat(product); - updatePrice(updatedCart); - dispatch({ - type: "ADD_AUTH_USER", - payload: { - args: newUser - } - }); - }; - - const logoutAuthUser = (args) => { - const updatedUser = state.user.filter( - (currentUser) => currentUser.name !== args.name - ); - updateUser(updatedCart); - - dispatch({ - type: "REMOVE_AUTH_USER", - payload: { - args: updatedCart - } - }); - }; - - - const value = { - authUser: state.name, - authUserTier: state.tier, - addAuthUser, - logoutAuthUser - }; - return {children}; +const DEFAULT_TIER_LIMITS = { + max_addresses: 1, + max_alert_types: 1, + allowed_channels: ["email"], }; -const useAuth = () => { - const context = useContext(ShopContext); +export function AuthProvider({ children }) { + 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); - if (context === undefined) { - throw new Error("authUser must be used within AuthContext"); + 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); + } + }, []); + + 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 signup(email, password) { + const cred = await createUserWithEmailAndPassword(auth, email, password); + return cred.user; } + async function login(email, password) { + const cred = await signInWithEmailAndPassword(auth, email, password); + return cred.user; + } + + async function logout() { + await signOut(auth); + } + + const value = { + currentUser, + userTier, + tierLimits, + isSubscribed, + loading, + signup, + login, + logout, + refreshAccount: fetchAccount, + }; + + 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/pages/subscribe/Subscribe.jsx b/frontend/src/pages/subscribe/Subscribe.jsx index 98e44e7..7a9560a 100644 --- a/frontend/src/pages/subscribe/Subscribe.jsx +++ b/frontend/src/pages/subscribe/Subscribe.jsx @@ -141,6 +141,8 @@ export default function Subscribe() { return; } + sessionStorage.setItem("kp_onboard_email", data.email); + sessionStorage.setItem("kp_onboard_pw", data.password); dispatch({ type: ACTION_TYPES.SET_USER_PROPERTIES, payload: { email: data.email, password: data.password }, diff --git a/frontend/src/reducers/authReducer.jsx b/frontend/src/reducers/authReducer.jsx deleted file mode 100644 index dd1c505..0000000 --- a/frontend/src/reducers/authReducer.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useReducer } from "react" - -const UserAuthContext = createContext(); - -export const initialState = { - authUser: null, - AuthTier: null -}; - -const authReducer = (state, action) => { - const { type, payload = "FREE_TIER" } = action - - switch (type) { - case "ADD_AUTH_USER": - console.log("ADD_AUTH_USER", payload); - return { - ...state, - auth: payload - }; - default: - throw new Error(`No case for type ${type} found.`); - } - switch (type) { - case "DELETE_AUTH_USER": - return { - ...state, - users: state.users.filter(user => user.id !== action.userIdToDelete) - }; - } -} - -export default shopReducer \ No newline at end of file