diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 8b7d4b3..2eda25d 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -14,6 +14,13 @@ import { observabilityService } from './observabilityService.js'; import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js'; import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js'; import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js'; +import { + deleteTradeProfileForUser, + ensureDefaultTradeProfileForUser, + getCurrentUserProfile, + listTradeProfilesForUser, + saveTradeProfileForUser, +} from './profileRepository.js'; import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { OperationalEvent } from '../domain/operationalEvents.js'; import { runBacktest } from '../backtest/index.js'; @@ -26,6 +33,9 @@ import { interface AuthenticatedRequest extends Request { authUserId?: string; authRole?: string; + authEmail?: string; + authDisplayName?: string; + authPlan?: string; } interface RateLimitBucket { @@ -620,14 +630,17 @@ export class ApiServer { return; } - const { userId, role, error } = await verifyTradingAccessToken(token); - if (!userId) { - res.status(401).json({ error: `Unauthorized: ${error || 'invalid token'}` }); + const verified = await verifyTradingAccessToken(token); + if (!verified.userId) { + res.status(401).json({ error: `Unauthorized: ${verified.error || 'invalid token'}` }); return; } - (req as AuthenticatedRequest).authUserId = userId; - (req as AuthenticatedRequest).authRole = role; + (req as AuthenticatedRequest).authUserId = verified.userId; + (req as AuthenticatedRequest).authRole = verified.role; + (req as AuthenticatedRequest).authEmail = verified.email; + (req as AuthenticatedRequest).authDisplayName = verified.displayName; + (req as AuthenticatedRequest).authPlan = verified.plan; next(); }; @@ -1602,6 +1615,118 @@ export class ApiServer { }); }); + this.app.get('/api/me/profile', this.requireAuth, async (req, res) => { + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean); + const profile = await getCurrentUserProfile(authUserId, { + email: authReq.authEmail, + role: authReq.authRole, + first_name: displayNameParts[0] || '', + last_name: displayNameParts.slice(1).join(' '), + trade_enable: true, + }, supabaseService); + + res.json({ profile }); + }); + + this.app.get('/api/profiles', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const ensureDefault = String(req.query.ensureDefault || '').toLowerCase() === 'true'; + const profiles = ensureDefault + ? await ensureDefaultTradeProfileForUser(authUserId, supabaseService) + : await listTradeProfilesForUser(authUserId, supabaseService); + res.json({ profiles }); + } catch (error: any) { + res.status(500).json({ error: `Failed to load profiles: ${error.message}` }); + } + }); + + this.app.post('/api/profiles', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const profile = await saveTradeProfileForUser(req.body || {}, authUserId, supabaseService); + res.status(201).json({ profile }); + } catch (error: any) { + res.status(400).json({ error: `Failed to save profile: ${error.message}` }); + } + }); + + this.app.put('/api/profiles/:id', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const profile = await saveTradeProfileForUser({ + ...(req.body || {}), + id: String(req.params.id || '').trim(), + }, authUserId, supabaseService); + res.json({ profile }); + } catch (error: any) { + res.status(400).json({ error: `Failed to update profile: ${error.message}` }); + } + }); + + this.app.patch('/api/profiles/:id/active', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const profileId = String(req.params.id || '').trim(); + const existing = (await listTradeProfilesForUser(authUserId, supabaseService)) + .find((profile) => profile.id === profileId); + if (!existing) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + const profile = await saveTradeProfileForUser({ + ...existing, + is_active: Boolean(req.body?.is_active), + }, authUserId, supabaseService); + res.json({ profile }); + } catch (error: any) { + res.status(400).json({ error: `Failed to update profile state: ${error.message}` }); + } + }); + + this.app.delete('/api/profiles/:id', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + await deleteTradeProfileForUser(String(req.params.id || '').trim(), authUserId, supabaseService); + res.json({ success: true }); + } catch (error: any) { + res.status(400).json({ error: `Failed to delete profile: ${error.message}` }); + } + }); + this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => { try { const items = await listDynamicConfigEntries(supabaseService); diff --git a/backend/src/services/platformAuthService.ts b/backend/src/services/platformAuthService.ts index 6e36421..e323225 100644 --- a/backend/src/services/platformAuthService.ts +++ b/backend/src/services/platformAuthService.ts @@ -6,6 +6,9 @@ import { supabaseService } from './SupabaseService.js'; export interface VerifiedTradingAuth { userId: string | null; role?: string; + email?: string; + displayName?: string; + plan?: string; productId?: string; source: 'platform' | 'supabase' | null; error?: string; @@ -65,6 +68,13 @@ export async function verifyTradingAccessToken(token: string): Promise; + created_at?: string; + updated_at?: string; +} + +interface TradeProfileDocument extends TradeProfileRecord { + productId: string; + type: 'trade_profile'; + createdAt: string; + updatedAt: string; +} + +const PROFILE_CONTAINER = 'trade_profiles'; + +function isCosmosConfigured(): boolean { + return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); +} + +function normalizeProfile(row: Partial | null | undefined): TradeProfileRecord | null { + const id = String(row?.id || '').trim(); + const userId = String(row?.user_id || '').trim(); + if (!id || !userId) { + return null; + } + + return { + id, + user_id: userId, + name: String(row?.name || 'Untitled Strategy').trim() || 'Untitled Strategy', + allocated_capital: Number(row?.allocated_capital || 0), + risk_per_trade_percent: Number(row?.risk_per_trade_percent || 0), + symbols: String(row?.symbols || 'BTC/USDT'), + is_active: Boolean(row?.is_active), + strategy_config: (row?.strategy_config && typeof row.strategy_config === 'object') + ? row.strategy_config as Record + : {}, + created_at: row?.created_at ? String(row.created_at) : undefined, + updated_at: row?.updated_at ? String(row.updated_at) : undefined, + }; +} + +function buildDefaultTradeProfile(userId: string): TradeProfileRecord { + const timestamp = new Date().toISOString(); + return { + id: randomUUID(), + user_id: userId, + name: 'My First Strategy', + allocated_capital: 1000, + risk_per_trade_percent: 1, + symbols: 'BTC/USDT, ETH/USDT', + is_active: false, + strategy_config: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, + { ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14, overbought: 70, oversold: 30 } }, + { ruleId: 'ZoneRule', enabled: true, params: { zonePercent: 1.5 } }, + { ruleId: 'SessionRule', enabled: true, params: { sessions: 'London,NY' } }, + { ruleId: 'EntryTriggerRule', enabled: true, params: { showPatterns: true } }, + { ruleId: 'RiskManagementRule', enabled: true, params: { maxRisk: 2.0 } }, + { ruleId: 'AIAnalysisRule', enabled: false, params: { minConfidence: 0.7 } }, + ], + riskLimits: { maxDailyLossUsd: 50, maxOpenTrades: 3, maxConsecutiveLosses: 2 }, + execution: { orderType: 'market', cooldownMinutes: 30, entryMode: 'both' }, + }, + created_at: timestamp, + updated_at: timestamp, + }; +} + +async function listProfilesFromCosmos(userId: string): Promise { + if (!isCosmosConfigured()) { + return []; + } + + const container = getContainer(PROFILE_CONTAINER); + const { resources } = await container.items.query({ + query: 'SELECT * FROM c WHERE c.productId = @productId AND c.user_id = @userId AND c.type = @type ORDER BY c.createdAt DESC', + parameters: [ + { name: '@productId', value: config.PRODUCT_ID }, + { name: '@userId', value: userId }, + { name: '@type', value: 'trade_profile' }, + ], + }).fetchAll(); + + return resources + .map((resource) => normalizeProfile({ + ...resource, + created_at: resource.createdAt, + updated_at: resource.updatedAt, + })) + .filter((profile): profile is TradeProfileRecord => Boolean(profile)); +} + +async function listProfilesFromSupabase(userId: string, legacyService?: LegacySupabaseService): Promise { + const client = legacyService?.getClient?.(); + if (!client) { + return []; + } + + try { + const { data, error } = await client + .from('trade_profiles') + .select('id,user_id,name,allocated_capital,risk_per_trade_percent,symbols,is_active,strategy_config,created_at,updated_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error || !Array.isArray(data)) { + return []; + } + + return data + .map((row) => normalizeProfile(row as TradeProfileRecord)) + .filter((profile): profile is TradeProfileRecord => Boolean(profile)); + } catch (error) { + logger.warn(`[Profiles] Legacy profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`); + return []; + } +} + +async function mirrorProfileToSupabase(profile: TradeProfileRecord, legacyService?: LegacySupabaseService): Promise { + const client = legacyService?.getClient?.(); + if (!client) return; + + try { + const { error } = await client + .from('trade_profiles') + .upsert({ + ...profile, + created_at: profile.created_at || new Date().toISOString(), + updated_at: profile.updated_at || new Date().toISOString(), + }, { onConflict: 'id' }); + + if (error) { + logger.warn(`[Profiles] Legacy profile mirror failed: ${error.message}`); + } + } catch (error) { + logger.warn(`[Profiles] Legacy profile mirror failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } +} + +async function deleteProfileFromSupabase(profileId: string, userId: string, legacyService?: LegacySupabaseService): Promise { + const client = legacyService?.getClient?.(); + if (!client) return; + + try { + const { error } = await client + .from('trade_profiles') + .delete() + .eq('id', profileId) + .eq('user_id', userId); + + if (error) { + logger.warn(`[Profiles] Legacy profile delete failed: ${error.message}`); + } + } catch (error) { + logger.warn(`[Profiles] Legacy profile delete failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } +} + +export async function listTradeProfilesForUser(userId: string, legacyService?: LegacySupabaseService): Promise { + try { + const cosmosProfiles = await listProfilesFromCosmos(userId); + if (cosmosProfiles.length > 0) { + return cosmosProfiles; + } + } catch (error) { + logger.warn(`[Profiles] Cosmos profile read failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`); + } + + return listProfilesFromSupabase(userId, legacyService); +} + +export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise { + const profiles = await listTradeProfilesForUser(userId, legacyService); + if (profiles.length > 0) { + return profiles; + } + + const created = await saveTradeProfileForUser(buildDefaultTradeProfile(userId), userId, legacyService); + return [created]; +} + +export async function saveTradeProfileForUser( + input: Partial, + userId: string, + legacyService?: LegacySupabaseService +): Promise { + const now = new Date().toISOString(); + const normalized = normalizeProfile({ + ...input, + id: String(input.id || randomUUID()), + user_id: userId, + created_at: input.created_at || now, + updated_at: now, + }); + + if (!normalized) { + throw new Error('Invalid trade profile payload'); + } + + if (isCosmosConfigured()) { + try { + const container = getContainer(PROFILE_CONTAINER); + await container.items.upsert({ + ...normalized, + productId: config.PRODUCT_ID, + type: 'trade_profile', + createdAt: normalized.created_at || now, + updatedAt: normalized.updated_at || now, + }); + } catch (error) { + logger.warn(`[Profiles] Cosmos profile upsert failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + + await mirrorProfileToSupabase(normalized, legacyService); + return normalized; +} + +export async function deleteTradeProfileForUser( + profileId: string, + userId: string, + legacyService?: LegacySupabaseService +): Promise { + if (!profileId || !userId) { + return; + } + + if (isCosmosConfigured()) { + try { + const container = getContainer(PROFILE_CONTAINER); + await container.item(profileId, userId).delete(); + } catch (error) { + logger.warn(`[Profiles] Cosmos profile delete failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + + await deleteProfileFromSupabase(profileId, userId, legacyService); +} + +export async function getCurrentUserProfile( + userId: string, + fallback: Partial = {}, + legacyService?: LegacySupabaseService +): Promise { + const client = legacyService?.getClient?.(); + if (client) { + try { + const { data, error } = await client + .from('users') + .select('user_id,first_name,last_name,email,role,trade_enable,ALPACA_API_KEY,ALPACA_SECRET_KEY,REAL_ALPACA_API_KEY,REAL_ALPACA_SECRET_KEY,drop_threshold_for_buy,gain_threshold_for_sell,market_poll_interval_in_seconds') + .eq('user_id', userId) + .maybeSingle(); + + if (!error && data) { + return { + user_id: String((data as any).user_id || userId), + first_name: String((data as any).first_name || fallback.first_name || ''), + last_name: String((data as any).last_name || fallback.last_name || ''), + email: String((data as any).email || fallback.email || ''), + role: String((data as any).role || fallback.role || 'member'), + trade_enable: Boolean((data as any).trade_enable ?? fallback.trade_enable ?? true), + ALPACA_API_KEY: (data as any).ALPACA_API_KEY || fallback.ALPACA_API_KEY, + ALPACA_SECRET_KEY: (data as any).ALPACA_SECRET_KEY || fallback.ALPACA_SECRET_KEY, + REAL_ALPACA_API_KEY: (data as any).REAL_ALPACA_API_KEY || fallback.REAL_ALPACA_API_KEY, + REAL_ALPACA_SECRET_KEY: (data as any).REAL_ALPACA_SECRET_KEY || fallback.REAL_ALPACA_SECRET_KEY, + drop_threshold_for_buy: Number((data as any).drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0), + gain_threshold_for_sell: Number((data as any).gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0), + market_poll_interval_in_seconds: Number((data as any).market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0), + }; + } + } catch (error) { + logger.warn(`[Profiles] Legacy user profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + + return { + user_id: userId, + first_name: String(fallback.first_name || ''), + last_name: String(fallback.last_name || ''), + email: String(fallback.email || ''), + role: String(fallback.role || 'member'), + trade_enable: Boolean(fallback.trade_enable ?? true), + ALPACA_API_KEY: fallback.ALPACA_API_KEY, + ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY, + REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY, + REAL_ALPACA_SECRET_KEY: fallback.REAL_ALPACA_SECRET_KEY, + drop_threshold_for_buy: Number(fallback.drop_threshold_for_buy ?? 0), + gain_threshold_for_sell: Number(fallback.gain_threshold_for_sell ?? 0), + market_poll_interval_in_seconds: Number(fallback.market_poll_interval_in_seconds ?? 0), + }; +} diff --git a/web/src/App.dom.test.tsx b/web/src/App.dom.test.tsx index 93c49d5..e5c32e7 100644 --- a/web/src/App.dom.test.tsx +++ b/web/src/App.dom.test.tsx @@ -1,21 +1,23 @@ // @vitest-environment jsdom import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -// import userEvent from '@testing-library/user-event'; -import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App'; - -const { authMock, socketMock, fromMock } = vi.hoisted(() => ({ - authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() }, - socketMock: { - botState: { +import { render, screen, fireEvent } from '@testing-library/react'; +// import userEvent from '@testing-library/user-event'; +import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App'; + +const { authMock, socketMock, fetchTradeProfilesMock, createTradeProfileMock, updateTradeProfileMock } = vi.hoisted(() => ({ + authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() }, + socketMock: { + botState: { settings: { isAlgoEnabled: true }, symbols: {}, - health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 } - }, - connected: true - }, - fromMock: vi.fn() -})); + health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 } + }, + connected: true + }, + fetchTradeProfilesMock: vi.fn(), + createTradeProfileMock: vi.fn(), + updateTradeProfileMock: vi.fn() +})); vi.mock('./components/AuthContext', () => ({ useAuth: () => authMock @@ -30,9 +32,11 @@ vi.mock('./hooks/useWebSocket', () => ({ } })); -vi.mock('./lib/supabaseClient', () => ({ - supabase: { from: fromMock } -})); +vi.mock('./lib/profileApi', () => ({ + fetchTradeProfiles: fetchTradeProfilesMock, + createTradeProfile: createTradeProfileMock, + updateTradeProfile: updateTradeProfileMock +})); // Mock components vi.mock('./components/AlertFeed', () => ({ AlertFeed: () =>
AlertFeedMock
})); @@ -65,16 +69,12 @@ describe('App Component DOM', () => { health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 } }; - fromMock.mockReturnValue({ - select: vi.fn().mockReturnThis(), - order: vi.fn().mockResolvedValue({ data: [], error: null }), - insert: vi.fn().mockResolvedValue({ error: null }), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockResolvedValue({ error: null }) - }); - - vi.stubGlobal('location', { pathname: '/' }); - }); + fetchTradeProfilesMock.mockResolvedValue([]); + createTradeProfileMock.mockResolvedValue({}); + updateTradeProfileMock.mockResolvedValue({}); + + vi.stubGlobal('location', { pathname: '/' }); + }); afterEach(() => { vi.useRealTimers(); diff --git a/web/src/App.tsx b/web/src/App.tsx index f36e031..445edc2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,10 +21,9 @@ import './App.css'; import { useAuth } from './components/AuthContext'; import { Login } from './components/Login'; import { ResetPassword } from './components/ResetPassword'; -import { supabase } from './lib/supabaseClient'; -import { tableNameProfiles } from './lib/const'; import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate'; import { tradingRuntime, tradingTelemetry } from './lib/runtime'; +import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi'; export const resolveProfileNameForAction = ( action: string, @@ -82,7 +81,7 @@ function App() { const hasCriticalEvents = recentCriticalEvents.length > 0; const fetchChatProfiles = useCallback(async () => { - const { data } = await supabase.from(tableNameProfiles).select('*').order('created_at', { ascending: false }); + const data = await fetchTradeProfiles(); setChatProfiles(data || []); }, []); @@ -109,8 +108,9 @@ function App() { console.log('[ChatApply] Action:', action, 'user_id:', currentUserId, 'Payload:', payload); if (action === 'create_profile') { - const { error } = await supabase.from(tableNameProfiles).insert([payload]); - if (error) { + try { + await createTradeProfile(payload); + } catch (error: any) { console.error('[ChatApply] Insert error:', error); return { success: false, error: error.message }; } @@ -119,8 +119,9 @@ function App() { window.dispatchEvent(new Event('profiles-updated')); return { success: true }; } else if (action === 'update_profile' && profileData.id) { - const { error } = await supabase.from(tableNameProfiles).update(payload).eq('id', profileData.id); - if (error) { + try { + await updateTradeProfile(profileData.id, payload); + } catch (error: any) { console.error('[ChatApply] Update error:', error); return { success: false, error: error.message }; } diff --git a/web/src/components/AuthContext.dom.test.tsx b/web/src/components/AuthContext.dom.test.tsx index 6d2270d..894f7f3 100644 --- a/web/src/components/AuthContext.dom.test.tsx +++ b/web/src/components/AuthContext.dom.test.tsx @@ -3,30 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; // import userEvent from '@testing-library/user-event'; import { AuthProvider, useAuth } from './AuthContext'; -import { tableNameProfiles, tableNameUsers } from '../lib/const'; const { getSessionMock, signOutMock, - fromMock, - usersSingleMock, - profilesLimitMock, - profilesInsertMock, - unsubscribeMock, - tradingAuthState + tradingAuthState, + fetchCurrentUserProfileMock, + fetchTradeProfilesMock } = vi.hoisted(() => ({ getSessionMock: vi.fn(), signOutMock: vi.fn(), - fromMock: vi.fn(), - usersSingleMock: vi.fn(), - profilesLimitMock: vi.fn(), - profilesInsertMock: vi.fn(), - unsubscribeMock: vi.fn(), tradingAuthState: { user: { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' } as any, isLoading: false, logout: vi.fn() - } + }, + fetchCurrentUserProfileMock: vi.fn(), + fetchTradeProfilesMock: vi.fn() })); vi.mock('../lib/tradingAuth', () => ({ @@ -39,11 +32,15 @@ vi.mock('../lib/supabaseClient', () => ({ auth: { getSession: getSessionMock, signOut: signOutMock - }, - from: fromMock + } } })); +vi.mock('../lib/profileApi', () => ({ + fetchCurrentUserProfile: fetchCurrentUserProfileMock, + fetchTradeProfiles: fetchTradeProfilesMock +})); + const Probe = () => { const auth = useAuth(); return ( @@ -61,56 +58,22 @@ describe('AuthContext DOM behavior', () => { beforeEach(() => { getSessionMock.mockReset(); signOutMock.mockReset(); - fromMock.mockReset(); - usersSingleMock.mockReset(); - profilesLimitMock.mockReset(); - profilesInsertMock.mockReset(); - unsubscribeMock.mockReset(); tradingAuthState.user = { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' }; tradingAuthState.isLoading = false; tradingAuthState.logout.mockReset(); + fetchCurrentUserProfileMock.mockReset(); + fetchTradeProfilesMock.mockReset(); signOutMock.mockResolvedValue({ error: null }); - usersSingleMock.mockResolvedValue({ - data: { - user_id: 'user-1', - first_name: 'Sarah', - last_name: 'Algo', - email: 'sarah@example.com', - role: 'admin', - trade_enable: true - }, - error: null - }); - profilesLimitMock.mockResolvedValue({ data: [], error: null }); - profilesInsertMock.mockResolvedValue({ error: null }); - - fromMock.mockImplementation((table: string) => { - if (table === tableNameUsers) { - const usersBuilder: any = { - select: vi.fn(() => usersBuilder), - eq: vi.fn(() => usersBuilder), - single: usersSingleMock - }; - return usersBuilder; - } - if (table === tableNameProfiles) { - const profilesBuilder: any = { - select: vi.fn(() => profilesBuilder), - eq: vi.fn(() => profilesBuilder), - limit: profilesLimitMock, - insert: profilesInsertMock - }; - return profilesBuilder; - } - return { - select: vi.fn(), - eq: vi.fn(), - single: vi.fn(), - limit: vi.fn(), - insert: vi.fn() - }; + fetchCurrentUserProfileMock.mockResolvedValue({ + user_id: 'user-1', + first_name: 'Sarah', + last_name: 'Algo', + email: 'sarah@example.com', + role: 'admin', + trade_enable: true }); + fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1' }]); }); it('loads session/profile, ensures default profile, and cleans up subscription', async () => { @@ -131,10 +94,7 @@ describe('AuthContext DOM behavior', () => { expect(screen.getByTestId('role')).toHaveTextContent('admin'); }); - expect(profilesInsertMock).toHaveBeenCalledTimes(1); - expect(profilesInsertMock.mock.calls[0][0]).toEqual([ - expect.objectContaining({ user_id: 'user-1', name: 'My First Strategy' }) - ]); + expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ ensureDefault: true }); expect(dispatchSpy).toHaveBeenCalled(); expect((dispatchSpy.mock.calls[0][0] as Event).type).toBe('profiles-updated'); }, 20000); @@ -148,7 +108,7 @@ describe('AuthContext DOM behavior', () => { expect(screen.getByTestId('loading')).toHaveTextContent('false'); expect(screen.getByTestId('user')).toHaveTextContent('none'); }); - expect(usersSingleMock).not.toHaveBeenCalled(); + expect(fetchCurrentUserProfileMock).not.toHaveBeenCalled(); }); it('handles auth state changes with no session', async () => { @@ -172,12 +132,12 @@ describe('AuthContext DOM behavior', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' }; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); - usersSingleMock.mockResolvedValue({ data: null, error: { message: 'Profile Not Found' } }); + fetchCurrentUserProfileMock.mockRejectedValue({ message: 'Profile Not Found' }); render(); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith('Error fetching user profile:', expect.objectContaining({ message: 'Profile Not Found' })); + expect(consoleSpy).toHaveBeenCalledWith('Unexpected error fetching profile:', expect.objectContaining({ message: 'Profile Not Found' })); }); consoleSpy.mockRestore(); }); @@ -186,7 +146,7 @@ describe('AuthContext DOM behavior', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' }; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); - usersSingleMock.mockImplementation(() => { throw new Error('Crashed'); }); + fetchCurrentUserProfileMock.mockImplementation(() => { throw new Error('Crashed'); }); render(); @@ -200,7 +160,7 @@ describe('AuthContext DOM behavior', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' }; getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } }); - profilesLimitMock.mockImplementation(() => { throw new Error('Limit Error'); }); + fetchTradeProfilesMock.mockImplementation(() => { throw new Error('Limit Error'); }); render(); diff --git a/web/src/components/AuthContext.test.ts b/web/src/components/AuthContext.test.ts index 61888b7..9a0c7c6 100644 --- a/web/src/components/AuthContext.test.ts +++ b/web/src/components/AuthContext.test.ts @@ -18,25 +18,28 @@ vi.mock('../lib/tradingAuth', () => ({ })); vi.mock('../lib/supabaseClient', () => { - const query: any = { - select: vi.fn(() => query), - eq: vi.fn(() => query), - single: vi.fn(async () => ({ data: null, error: null })), - limit: vi.fn(async () => ({ data: [], error: null })), - insert: vi.fn(async () => ({ error: null })) - }; - return { supabase: { auth: { getSession: vi.fn(async () => ({ data: { session: null } })), signOut: vi.fn(async () => ({ error: null })) - }, - from: vi.fn(() => query) + } } }; }); +vi.mock('../lib/profileApi', () => ({ + fetchCurrentUserProfile: vi.fn(async () => ({ + user_id: 'user-1', + first_name: 'User', + last_name: 'One', + email: 'user-1@example.com', + role: 'member', + trade_enable: true, + })), + fetchTradeProfiles: vi.fn(async () => [{ id: 'p1' }]), +})); + describe('AuthContext', () => { it('provides auth state through AuthProvider', () => { const Probe = () => { diff --git a/web/src/components/AuthContext.tsx b/web/src/components/AuthContext.tsx index 5c145de..f2b4f82 100644 --- a/web/src/components/AuthContext.tsx +++ b/web/src/components/AuthContext.tsx @@ -1,8 +1,8 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import type { User, Session } from '@supabase/supabase-js'; import { supabase } from '../lib/supabaseClient'; -import { tableNameUsers, tableNameProfiles } from '../lib/const'; import { TradingAuthProvider, useTradingAuth } from '../lib/tradingAuth'; +import { fetchCurrentUserProfile, fetchTradeProfiles } from '../lib/profileApi'; // Define the shape of our extended user profile export interface UserProfile { @@ -118,23 +118,11 @@ function AuthBridge({ children }: { children: React.ReactNode }) { }; }, [tradingAuth.user?.id]); - const fetchProfile = async (userId: string, authUserOverride?: User | null) => { + const fetchProfile = async (_userId: string, authUserOverride?: User | null) => { try { - const { data, error } = await supabase - .from(tableNameUsers) - .select('user_id,first_name,last_name,email,role,ALPACA_API_KEY,ALPACA_SECRET_KEY,REAL_ALPACA_API_KEY,REAL_ALPACA_SECRET_KEY,trade_enable,drop_threshold_for_buy,gain_threshold_for_sell,market_poll_interval_in_seconds') - .eq('user_id', userId) - .single(); - - if (error) { - console.error('Error fetching user profile:', error); - setProfile(buildFallbackProfile(authUserOverride ?? user)); - ensureDefaultProfile(userId); - } else { - setProfile(data as UserProfile); - // Ensure a default trading profile exists for new users - ensureDefaultProfile(userId); - } + const currentProfile = await fetchCurrentUserProfile(); + setProfile(currentProfile as UserProfile); + await ensureDefaultProfile(); } catch (err) { console.error('Unexpected error fetching profile:', err); setProfile(buildFallbackProfile(authUserOverride ?? user)); @@ -143,17 +131,12 @@ function AuthBridge({ children }: { children: React.ReactNode }) { } }; - const ensureDefaultProfile = async (userId: string) => { + const ensureDefaultProfile = async () => { try { - const { data } = await supabase - .from(tableNameProfiles) - .select('id') - .eq('user_id', userId) - .limit(1); - - if (shouldCreateDefaultProfile(data)) { - console.log('[Auth] No profiles found - creating default profile for new user'); - await supabase.from(tableNameProfiles).insert([buildDefaultProfilePayload(userId)]); + const profiles = await fetchTradeProfiles({ ensureDefault: true }); + if (shouldCreateDefaultProfile(profiles)) { + console.log('[Auth] No profiles found after bootstrap ensureDefault call'); + } else { window.dispatchEvent(new Event('profiles-updated')); } } catch (err) { diff --git a/web/src/components/StrategyWizard.tsx b/web/src/components/StrategyWizard.tsx index 0fad717..90cdfb2 100644 --- a/web/src/components/StrategyWizard.tsx +++ b/web/src/components/StrategyWizard.tsx @@ -14,14 +14,13 @@ import { Target, Lock } from 'lucide-react'; -import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates'; -import type { RiskStyleTemplate } from '../lib/RiskStyleTemplates'; +import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates'; +import type { RiskStyleTemplate } from '../lib/RiskStyleTemplates'; import { getUserTier, TIER_POLICIES, isFeatureAllowed } from '../lib/TierPolicy'; -import { supabase } from '../lib/supabaseClient'; -import { tableNameProfiles } from '../lib/const'; import { useAuth } from './AuthContext'; import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel'; import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate'; +import { createTradeProfile, updateTradeProfile } from '../lib/profileApi'; interface WizardState { step: number; @@ -132,12 +131,12 @@ export const StrategyWizard: React.FC<{ strategy_config }; - let result; - if (editingProfile) { - result = await supabase.from(tableNameProfiles).update(payload).eq('id', editingProfile.id); - } else { - result = await supabase.from(tableNameProfiles).insert([payload]); - } + let result; + if (editingProfile) { + result = await updateTradeProfile(editingProfile.id, payload).then(() => ({ error: null as any })).catch((error) => ({ error })); + } else { + result = await createTradeProfile(payload).then(() => ({ error: null as any })).catch((error) => ({ error })); + } setLoading(false); diff --git a/web/src/lib/profileApi.ts b/web/src/lib/profileApi.ts new file mode 100644 index 0000000..392972c --- /dev/null +++ b/web/src/lib/profileApi.ts @@ -0,0 +1,100 @@ +import { supabase } from './supabaseClient'; +import { tradingRuntime } from './runtime'; + +export interface TradeProfilePayload { + id?: string; + user_id?: string; + name: string; + allocated_capital: number; + risk_per_trade_percent: number; + symbols: string; + is_active: boolean; + strategy_config: Record; + created_at?: string; + updated_at?: string; +} + +export interface CurrentUserProfile { + user_id: string; + first_name: string; + last_name: string; + email: string; + role: string; + trade_enable: boolean; + ALPACA_API_KEY?: string; + ALPACA_SECRET_KEY?: string; + REAL_ALPACA_API_KEY?: string; + REAL_ALPACA_SECRET_KEY?: string; + drop_threshold_for_buy?: number; + gain_threshold_for_sell?: number; + market_poll_interval_in_seconds?: number; +} + +async function getAccessToken(): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) { + throw new Error('Not authenticated'); + } + return accessToken; +} + +async function apiRequest(path: string, init?: RequestInit): Promise { + const accessToken = await getAccessToken(); + const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(init?.headers || {}), + }, + }); + + const body = await response.json().catch(() => ({} as any)); + if (!response.ok) { + throw new Error(body?.error || `Request failed (${response.status})`); + } + + return body as T; +} + +export async function fetchCurrentUserProfile(): Promise { + const response = await apiRequest<{ profile: CurrentUserProfile }>('/api/me/profile'); + return response.profile; +} + +export async function fetchTradeProfiles(options?: { ensureDefault?: boolean }): Promise { + const query = options?.ensureDefault ? '?ensureDefault=true' : ''; + const response = await apiRequest<{ profiles: TradeProfilePayload[] }>(`/api/profiles${query}`); + return Array.isArray(response.profiles) ? response.profiles : []; +} + +export async function createTradeProfile(payload: TradeProfilePayload): Promise { + const response = await apiRequest<{ profile: TradeProfilePayload }>('/api/profiles', { + method: 'POST', + body: JSON.stringify(payload), + }); + return response.profile; +} + +export async function updateTradeProfile(id: string, payload: Partial): Promise { + const response = await apiRequest<{ profile: TradeProfilePayload }>(`/api/profiles/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + return response.profile; +} + +export async function setTradeProfileActive(id: string, isActive: boolean): Promise { + const response = await apiRequest<{ profile: TradeProfilePayload }>(`/api/profiles/${id}/active`, { + method: 'PATCH', + body: JSON.stringify({ is_active: isActive }), + }); + return response.profile; +} + +export async function deleteTradeProfile(id: string): Promise { + await apiRequest<{ success: boolean }>(`/api/profiles/${id}`, { + method: 'DELETE', + }); +} diff --git a/web/src/tabs/MyStrategiesTab.tsx b/web/src/tabs/MyStrategiesTab.tsx index 370e43d..90e066d 100644 --- a/web/src/tabs/MyStrategiesTab.tsx +++ b/web/src/tabs/MyStrategiesTab.tsx @@ -1,9 +1,7 @@ -import React, { useState, useEffect } from 'react'; -import { supabase } from '../lib/supabaseClient'; -import { tableNameProfiles } from '../lib/const'; -import { useAuth } from '../components/AuthContext'; -import { getStrategyExplanation } from '../lib/StrategyExplanationService'; -import { getUserTier } from '../lib/TierPolicy'; +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../components/AuthContext'; +import { getStrategyExplanation } from '../lib/StrategyExplanationService'; +import { getUserTier } from '../lib/TierPolicy'; import { Play, Pause, @@ -28,6 +26,7 @@ import { import { StrategyWizard } from '../components/StrategyWizard'; import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel'; import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate'; +import { deleteTradeProfile, fetchTradeProfiles, setTradeProfileActive } from '../lib/profileApi'; const ActiveStrategyCard: React.FC<{ profile: any; @@ -260,17 +259,13 @@ export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewA const tier = getUserTier(userProfile); const { enabled: backtestEnabled, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer }); - const fetchProfiles = async () => { - if (!user) return; - setIsLoading(true); - const { data } = await supabase - .from(tableNameProfiles) - .select('*') - .eq('user_id', user.id) - .order('created_at', { ascending: false }); - setProfiles(data || []); - setIsLoading(false); - }; + const fetchProfiles = async () => { + if (!user) return; + setIsLoading(true); + const data = await fetchTradeProfiles(); + setProfiles(data || []); + setIsLoading(false); + }; useEffect(() => { fetchProfiles(); @@ -278,22 +273,24 @@ export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewA return () => window.removeEventListener('profiles-updated', fetchProfiles); }, [user]); - const toggleBot = async (profile: any) => { - const { error } = await supabase - .from(tableNameProfiles) - .update({ is_active: !profile.is_active }) - .eq('id', profile.id); - if (!error) fetchProfiles(); - }; - - const deleteBot = async (id: string) => { - if (!confirm('Are you sure you want to delete this strategy?')) return; - const { error } = await supabase - .from(tableNameProfiles) - .delete() - .eq('id', id); - if (!error) fetchProfiles(); - }; + const toggleBot = async (profile: any) => { + try { + await setTradeProfileActive(profile.id, !profile.is_active); + fetchProfiles(); + } catch { + // existing UI remains silent on toggle failure + } + }; + + const deleteBot = async (id: string) => { + if (!confirm('Are you sure you want to delete this strategy?')) return; + try { + await deleteTradeProfile(id); + fetchProfiles(); + } catch { + // existing UI remains silent on delete failure + } + }; if (showWizard) { return (