Resolve merge conflicts

This commit is contained in:
KS Jannette
2026-05-11 12:52:33 -04:00
8 changed files with 227 additions and 130 deletions

View File

@@ -1,4 +1,5 @@
Backend startup quickstarat: Backend startup quickstart:
-----------------------------> BEST -----------------------------> BEST
## 1. brew services start postgresql@15 ## 1. brew services start postgresql@15

View 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;

View File

@@ -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) {

View File

@@ -11,7 +11,9 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account"; import Account from "./pages/user_account/Account";
export default function App() { export default function App() {
const { currentUser, isSubscribed } = useAuth(); const { currentUser, isSubscribed, loading } = useAuth();
if (loading) return null;
if (!currentUser) { if (!currentUser) {
return ( return (

View File

@@ -1,140 +1,94 @@
/** import { createContext, useContext, useState, useEffect, useCallback } from "react";
* AuthContext - Firebase Authentication State Management
*
* Provides authentication state, tier info, and methods throughout the app
*/
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { import {
createUserWithEmailAndPassword, onAuthStateChanged,
signInWithEmailAndPassword, signInWithEmailAndPassword,
signOut, createUserWithEmailAndPassword,
onAuthStateChanged, signOut,
} from "firebase/auth"; } from "firebase/auth";
import { auth } from "../firebase/config"; import { auth } from "../firebase/config";
import { getAccount } from "../api/account"; import { getAccount } from "../api/account";
const AuthContext = createContext(); const AuthContext = createContext(undefined);
const DEFAULT_TIER_LIMITS = { const DEFAULT_TIER_LIMITS = {
max_addresses: 1, max_addresses: 1,
max_alert_types: 1, max_alert_types: 1,
allowed_channels: ["email"], 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 }) { export function AuthProvider({ children }) {
const [currentUser, setCurrentUser] = useState(null); const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true); const [userTier, setUserTier] = useState("free");
const [error, setError] = useState(null); const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS);
const [isSubscribed, setIsSubscribed] = useState(false);
const [loading, setLoading] = useState(true);
const [userTier, setUserTier] = useState("free"); const fetchAccount = useCallback(async () => {
const [tierLimits, setTierLimits] = useState(DEFAULT_TIER_LIMITS); try {
const [addressCount, setAddressCount] = useState(0); const data = await getAccount();
const [subscriptionStatus, setSubscriptionStatus] = useState("none"); setUserTier(data.subscription_tier || "free");
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS);
const refreshAccount = useCallback(async () => { setIsSubscribed(
try { data.subscription_status === "active" ||
const data = await getAccount(); data.subscription_status === "trialing",
setUserTier(data.subscription_tier || "free"); );
setTierLimits(data.tier_limits || DEFAULT_TIER_LIMITS); } catch {
setAddressCount(data.address_count || 0); setUserTier("free");
setSubscriptionStatus(data.subscription_status || "none"); setTierLimits(DEFAULT_TIER_LIMITS);
} catch { setIsSubscribed(false);
// 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) { useEffect(() => {
try { const unsubscribe = onAuthStateChanged(auth, async (user) => {
setError(null); setCurrentUser(user);
const result = await signInWithEmailAndPassword( if (user) {
auth, await fetchAccount();
email, } else {
password, setUserTier("free");
); setTierLimits(DEFAULT_TIER_LIMITS);
return result.user; setIsSubscribed(false);
} catch (err) { }
setError(err.message); setLoading(false);
throw err; });
} return unsubscribe;
} }, [fetchAccount]);
async function logout() { async function signup(email, password) {
try { const cred = await createUserWithEmailAndPassword(auth, email, password);
setError(null); return cred.user;
setUserTier("free"); }
setTierLimits(DEFAULT_TIER_LIMITS);
setAddressCount(0);
setSubscriptionStatus("none");
await signOut(auth);
} catch (err) {
setError(err.message);
throw err;
}
}
useEffect(() => { async function login(email, password) {
const unsubscribe = onAuthStateChanged(auth, (user) => { const cred = await signInWithEmailAndPassword(auth, email, password);
setCurrentUser(user); return cred.user;
setLoading(false); }
if (user) {
refreshAccount();
}
});
return unsubscribe;
}, [refreshAccount]);
const isSubscribed = async function logout() {
subscriptionStatus === "active" || subscriptionStatus === "trialing"; await signOut(auth);
}
const value = { const value = {
currentUser, currentUser,
signup, userTier,
login, tierLimits,
logout, isSubscribed,
error, loading,
loading, signup,
userTier, login,
tierLimits, logout,
addressCount, refreshAccount: fetchAccount,
subscriptionStatus, };
isSubscribed,
refreshAccount,
};
return ( return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
} }
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;

View File

@@ -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 (
<UserPropertiesContext.Provider value={{ state, dispatch, ACTION_TYPES }}>
{children}
</UserPropertiesContext.Provider>
);
}
export function useUserProperties() {
const context = useContext(UserPropertiesContext);
if (context === undefined) {
throw new Error(
"useUserProperties must be used within a UserPropertiesProvider",
);
}
return context;
}

View File

@@ -2,15 +2,18 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";
import { UserPropertiesProvider } from "./contexts/UserPropertiesContext";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <UserPropertiesProvider>
<App /> <AuthProvider>
</AuthProvider> <App />
</AuthProvider>
</UserPropertiesProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { useUserProperties } from "../../contexts/UserPropertiesContext";
import { import {
createCheckoutSession, createCheckoutSession,
activateFreeTier, activateFreeTier,
@@ -21,7 +22,7 @@ export default function Subscribe() {
const success = queryParameters?.get("payment") const success = queryParameters?.get("payment")
const session_id = queryParameters?.get("session_id") 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) { function forward(success, session_id) {
success && session_id ? success && session_id ?