diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 8483b88..d333198 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -1668,14 +1668,18 @@ export class ApiServer { }); this.app.post('/api/profiles', this.requireAuth, async (req, res) => { - const authUserId = (req as AuthenticatedRequest).authUserId; + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { - const profile = await saveTradeProfileForUser(req.body || {}, authUserId, supabaseService); + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); + const requestedUserId = String(req.body?.user_id || '').trim(); + const targetUserId = isAdmin && requestedUserId ? requestedUserId : authUserId; + const profile = await saveTradeProfileForUser(req.body || {}, targetUserId, supabaseService); res.status(201).json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to save profile: ${error.message}` }); @@ -1683,25 +1687,8 @@ export class ApiServer { }); 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; + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; @@ -1709,7 +1696,42 @@ export class ApiServer { try { const profileId = String(req.params.id || '').trim(); - const existing = (await listTradeProfilesForUser(authUserId, supabaseService)) + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); + const existingProfiles = isAdmin + ? await listAllTradeProfiles(supabaseService) + : await listTradeProfilesForUser(authUserId, supabaseService); + const existing = existingProfiles.find((profile) => profile.id === profileId); + if (!existing) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + const profile = await saveTradeProfileForUser({ + ...existing, + ...(req.body || {}), + id: profileId, + }, existing.user_id, 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 authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const profileId = String(req.params.id || '').trim(); + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); + const existingProfiles = isAdmin + ? await listAllTradeProfiles(supabaseService) + : await listTradeProfilesForUser(authUserId, supabaseService); + const existing = existingProfiles .find((profile) => profile.id === profileId); if (!existing) { res.status(404).json({ error: 'Profile not found' }); @@ -1719,7 +1741,7 @@ export class ApiServer { const profile = await saveTradeProfileForUser({ ...existing, is_active: Boolean(req.body?.is_active), - }, authUserId, supabaseService); + }, existing.user_id, supabaseService); res.json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to update profile state: ${error.message}` }); @@ -1727,14 +1749,26 @@ export class ApiServer { }); this.app.delete('/api/profiles/:id', this.requireAuth, async (req, res) => { - const authUserId = (req as AuthenticatedRequest).authUserId; + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { - await deleteTradeProfileForUser(String(req.params.id || '').trim(), authUserId, supabaseService); + const profileId = String(req.params.id || '').trim(); + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); + const existingProfiles = isAdmin + ? await listAllTradeProfiles(supabaseService) + : await listTradeProfilesForUser(authUserId, supabaseService); + const existing = existingProfiles.find((profile) => profile.id === profileId); + if (!existing) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + await deleteTradeProfileForUser(profileId, existing.user_id, supabaseService); res.json({ success: true }); } catch (error: any) { res.status(400).json({ error: `Failed to delete profile: ${error.message}` }); diff --git a/web/src/components/TradeProfileManager.dom.test.tsx b/web/src/components/TradeProfileManager.dom.test.tsx index a91ee62..862dd38 100644 --- a/web/src/components/TradeProfileManager.dom.test.tsx +++ b/web/src/components/TradeProfileManager.dom.test.tsx @@ -3,35 +3,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TradeProfileManager } from './TradeProfileManager'; -import { tableNameProfiles, tableNameTransactions, tableNameUsers } from '../lib/const'; +import { tableNameTransactions } from '../lib/const'; const { authState, fromMock, - profilesSelectMock, - profilesOrderMock, - profilesInsertMock, - profilesUpdateMock, - profilesUpdateEqMock, - profilesDeleteMock, - profilesDeleteEqMock, - usersSelectMock, - historySelectMock + historySelectMock, + fetchTradeProfilesMock, + fetchCurrentUserProfileMock, + createTradeProfileMock, + updateTradeProfileMock, + deleteTradeProfileMock, + setTradeProfileActiveMock } = vi.hoisted(() => ({ authState: { user: { id: 'user-1', email: 'owner@demo.test' } as any, profile: { role: 'admin' } as any }, fromMock: vi.fn(), - profilesSelectMock: vi.fn(), - profilesOrderMock: vi.fn(), - profilesInsertMock: vi.fn(), - profilesUpdateMock: vi.fn(), - profilesUpdateEqMock: vi.fn(), - profilesDeleteMock: vi.fn(), - profilesDeleteEqMock: vi.fn(), - usersSelectMock: vi.fn(), - historySelectMock: vi.fn() + historySelectMock: vi.fn(), + fetchTradeProfilesMock: vi.fn(), + fetchCurrentUserProfileMock: vi.fn(), + createTradeProfileMock: vi.fn(), + updateTradeProfileMock: vi.fn(), + deleteTradeProfileMock: vi.fn(), + setTradeProfileActiveMock: vi.fn() })); vi.mock('./AuthContext', () => ({ @@ -44,57 +40,60 @@ vi.mock('../lib/supabaseClient', () => ({ } })); +vi.mock('../lib/profileApi', () => ({ + fetchTradeProfiles: fetchTradeProfilesMock, + fetchCurrentUserProfile: fetchCurrentUserProfileMock, + createTradeProfile: createTradeProfileMock, + updateTradeProfile: updateTradeProfileMock, + deleteTradeProfile: deleteTradeProfileMock, + setTradeProfileActive: setTradeProfileActiveMock +})); + describe('TradeProfileManager DOM flow', () => { beforeEach(() => { authState.user = { id: 'user-1', email: 'owner@demo.test' }; authState.profile = { role: 'admin' }; fromMock.mockReset(); - profilesSelectMock.mockReset(); - profilesOrderMock.mockReset(); - profilesInsertMock.mockReset(); - profilesUpdateMock.mockReset(); - profilesUpdateEqMock.mockReset(); - profilesDeleteMock.mockReset(); - profilesDeleteEqMock.mockReset(); - usersSelectMock.mockReset(); historySelectMock.mockReset(); + fetchTradeProfilesMock.mockReset(); + fetchCurrentUserProfileMock.mockReset(); + createTradeProfileMock.mockReset(); + updateTradeProfileMock.mockReset(); + deleteTradeProfileMock.mockReset(); + setTradeProfileActiveMock.mockReset(); - profilesOrderMock.mockResolvedValue({ - data: [ - { - id: 'profile-1', - user_id: 'user-1', - name: 'High Risk Scalper', - allocated_capital: 3000, - risk_per_trade_percent: 1.5, - symbols: 'BTC/USDT, ETH/USDT', - is_active: true, - strategy_config: { - rules: [ - { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, - { ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14, overbought: 70, oversold: 30 } } - ], - riskLimits: { - maxDailyLossUsd: 100, - maxOpenTrades: 3, - maxConsecutiveLosses: 2 - }, - execution: { - orderType: 'market', - cooldownMinutes: 30, - entryMode: 'both' - } + fetchTradeProfilesMock.mockResolvedValue([ + { + id: 'profile-1', + user_id: 'user-1', + name: 'High Risk Scalper', + allocated_capital: 3000, + risk_per_trade_percent: 1.5, + symbols: 'BTC/USDT, ETH/USDT', + is_active: true, + strategy_config: { + rules: [ + { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, + { ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14, overbought: 70, oversold: 30 } } + ], + riskLimits: { + maxDailyLossUsd: 100, + maxOpenTrades: 3, + maxConsecutiveLosses: 2 }, - created_at: '2026-02-15T10:00:00.000Z' - } - ], - error: null - }); - - usersSelectMock.mockResolvedValue({ - data: [{ user_id: 'user-1', email: 'owner@demo.test' }], - error: null + execution: { + orderType: 'market', + cooldownMinutes: 30, + entryMode: 'both' + } + }, + created_at: '2026-02-15T10:00:00.000Z' + } + ]); + fetchCurrentUserProfileMock.mockResolvedValue({ + user_id: 'user-1', + email: 'owner@demo.test' }); historySelectMock.mockResolvedValue({ @@ -119,27 +118,12 @@ describe('TradeProfileManager DOM flow', () => { error: null }); - profilesInsertMock.mockResolvedValue({ error: null }); - profilesUpdateEqMock.mockResolvedValue({ error: null }); - profilesUpdateMock.mockReturnValue({ eq: profilesUpdateEqMock }); - profilesDeleteEqMock.mockResolvedValue({ error: null }); - profilesDeleteMock.mockReturnValue({ eq: profilesDeleteEqMock }); - profilesSelectMock.mockReturnValue({ order: profilesOrderMock }); + createTradeProfileMock.mockResolvedValue({ id: 'profile-2' }); + updateTradeProfileMock.mockResolvedValue({ id: 'profile-1' }); + deleteTradeProfileMock.mockResolvedValue(undefined); + setTradeProfileActiveMock.mockResolvedValue({ id: 'profile-1', is_active: false }); fromMock.mockImplementation((table: string) => { - if (table === tableNameProfiles) { - return { - select: profilesSelectMock, - insert: profilesInsertMock, - update: profilesUpdateMock, - delete: profilesDeleteMock - }; - } - if (table === tableNameUsers) { - return { - select: usersSelectMock - }; - } if (table === tableNameTransactions) { return { select: historySelectMock @@ -172,7 +156,7 @@ describe('TradeProfileManager DOM flow', () => { await user.click(screen.getByTitle('Refresh')); await waitFor(() => { - expect(profilesOrderMock.mock.calls.length).toBeGreaterThan(1); + expect(fetchTradeProfilesMock.mock.calls.length).toBeGreaterThan(1); }); }, 20000); @@ -205,15 +189,13 @@ describe('TradeProfileManager DOM flow', () => { await user.click(screen.getAllByRole('button', { name: 'Create Profile' }).at(-1)!); await waitFor(() => { - expect(profilesInsertMock).toHaveBeenCalledTimes(1); - expect(profilesInsertMock).toHaveBeenCalledWith([ - expect.objectContaining({ - name: 'Night Session Guard', - user_id: 'user-1', - symbols: 'ETH/USDT,SOL/USDT' - }) - ]); - const insertedPayload = profilesInsertMock.mock.calls[0][0][0]; + expect(createTradeProfileMock).toHaveBeenCalledTimes(1); + expect(createTradeProfileMock).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Night Session Guard', + user_id: 'user-1', + symbols: 'ETH/USDT,SOL/USDT' + })); + const insertedPayload = createTradeProfileMock.mock.calls[0][0]; const sessionRule = insertedPayload?.strategy_config?.rules?.find((rule: any) => rule.ruleId === 'SessionRule'); expect(sessionRule?.params?.sessions).toBe('24/7'); expect(screen.getByText('Profile created successfully')).toBeInTheDocument(); @@ -230,7 +212,7 @@ describe('TradeProfileManager DOM flow', () => { await user.click(screen.getByTitle('Pause profile')); await waitFor(() => { - expect(profilesUpdateEqMock).toHaveBeenCalledWith('id', 'profile-1'); + expect(setTradeProfileActiveMock).toHaveBeenCalledWith('profile-1', false); expect(screen.getByText('Profile suspended')).toBeInTheDocument(); }); @@ -241,7 +223,9 @@ describe('TradeProfileManager DOM flow', () => { await user.click(screen.getByRole('button', { name: 'Save Changes' })); await waitFor(() => { - expect(profilesUpdateEqMock).toHaveBeenCalledWith('id', 'profile-1'); + expect(updateTradeProfileMock).toHaveBeenCalledWith('profile-1', expect.objectContaining({ + name: 'High Risk Scalper v2' + })); expect(screen.getByText('Profile updated')).toBeInTheDocument(); }); @@ -250,14 +234,14 @@ describe('TradeProfileManager DOM flow', () => { await user.click(screen.getAllByRole('button', { name: 'Delete' }).at(-1)!); await waitFor(() => { - expect(profilesDeleteEqMock).toHaveBeenCalledWith('id', 'profile-1'); + expect(deleteTradeProfileMock).toHaveBeenCalledWith('profile-1'); expect(screen.getByText('Profile deleted')).toBeInTheDocument(); }); }, 20000); it('surfaces update and delete failures in toast feedback', async () => { - profilesUpdateEqMock.mockResolvedValueOnce({ error: { message: 'permission denied' } }); - profilesDeleteEqMock.mockResolvedValueOnce({ error: { message: 'foreign key violation' } }); + setTradeProfileActiveMock.mockRejectedValueOnce(new Error('permission denied')); + deleteTradeProfileMock.mockRejectedValueOnce(new Error('foreign key violation')); const user = userEvent.setup(); render(); diff --git a/web/src/components/TradeProfileManager.tsx b/web/src/components/TradeProfileManager.tsx index abe6d18..bb395ea 100644 --- a/web/src/components/TradeProfileManager.tsx +++ b/web/src/components/TradeProfileManager.tsx @@ -1,8 +1,17 @@ import { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { supabase } from '../lib/supabaseClient'; -import { tableNameTransactions, tableNameProfiles, tableNameUsers } from '../lib/const'; +import { tableNameTransactions } from '../lib/const'; import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger'; +import { + createTradeProfile, + deleteTradeProfile, + fetchCurrentUserProfile, + fetchTradeProfiles, + setTradeProfileActive, + type TradeProfilePayload, + updateTradeProfile +} from '../lib/profileApi'; import { Trash2, Edit3, X, Check, Zap, Play, @@ -290,10 +299,10 @@ interface TradeProfileManagerProps { export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfileManagerProps) => { const { user: authUser, profile } = useAuth(); const [profiles, setProfiles] = useState([]); - const [users, setUsers] = useState([]); const [tradeStats, setTradeStats] = useState>({}); const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [currentUserProfile, setCurrentUserProfile] = useState(null); // Drawer state const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -315,34 +324,31 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi const fetchData = async () => { setLoading(true); try { - let profilesQuery = supabase - .from(tableNameProfiles) - .select('id,user_id,name,allocated_capital,risk_per_trade_percent,symbols,is_active,strategy_config,created_at') - .order('created_at', { ascending: false }); - - let usersQuery = supabase.from(tableNameUsers).select('user_id, email'); - let historyQuery = supabase .from(tableNameTransactions) .select('id,timestamp,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,profile_id,created_at,trade_id,source'); if (profile?.role !== 'admin' && authUser?.id) { - profilesQuery = profilesQuery.eq('user_id', authUser.id); - usersQuery = usersQuery.eq('user_id', authUser.id); historyQuery = historyQuery.eq('user_id', authUser.id); } - const [pRes, uRes, hRes] = await Promise.all([ - profilesQuery, - usersQuery, + const [profilesData, meProfile, hRes] = await Promise.all([ + fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' }), + fetchCurrentUserProfile().catch(() => null), historyQuery ]); - const normalizedProfiles = (pRes.data || []).map((profile: any) => ({ + const normalizedProfiles = (profilesData || []).map((profile: any) => ({ ...profile, strategy_config: normalizeStrategyConfig(profile.strategy_config as StrategyConfig) })); setProfiles(normalizedProfiles); - setUsers(uRes.data || []); + setCurrentUserProfile(meProfile ? { + user_id: String(meProfile.user_id || authUser?.id || ''), + email: String(meProfile.email || authUser?.email || '') + } : (authUser?.id || authUser?.email ? { + user_id: String(authUser?.id || ''), + email: String(authUser?.email || '') + } : null)); const historyLedger = buildHistoryLedger({ dbRows: hRes.data || [], @@ -393,7 +399,7 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi const handleOpenAdd = () => { setEditingProfile({ name: '', - user_id: authUser?.id || users[0]?.user_id || '', + user_id: authUser?.id || currentUserProfile?.user_id || '', allocated_capital: 1000, risk_per_trade_percent: 1, is_active: true, @@ -413,37 +419,38 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi if (!editingProfile.name) return addToast('Profile name is required', 'error'); setLoading(true); const id = isAddingMode ? null : editingProfile.id; - const payload = { + const payload: TradeProfilePayload = { name: editingProfile.name, - user_id: editingProfile.user_id || authUser?.id || users[0]?.user_id, + user_id: editingProfile.user_id || authUser?.id || currentUserProfile?.user_id, allocated_capital: Number(editingProfile.allocated_capital), risk_per_trade_percent: Number(editingProfile.risk_per_trade_percent), - symbols: editingProfile.symbols, + symbols: editingProfile.symbols || '', is_active: editingProfile.is_active ?? true, - strategy_config: normalizeStrategyConfig(editingProfile.strategy_config as StrategyConfig) + strategy_config: normalizeStrategyConfig(editingProfile.strategy_config as StrategyConfig) as unknown as Record }; - const { error } = id - ? await supabase.from(tableNameProfiles).update(payload).eq('id', id) - : await supabase.from(tableNameProfiles).insert([payload]); - - if (error) { - addToast(error.message, 'error'); - } else { + try { + if (id) { + await updateTradeProfile(id, payload); + } else { + await createTradeProfile(payload); + } addToast(isAddingMode ? 'Profile created successfully' : 'Profile updated', 'success'); setIsDrawerOpen(false); - fetchData(); + await fetchData(); + } catch (error: any) { + addToast(error?.message || 'Failed to save profile', 'error'); } setLoading(false); }; const handleDelete = async (profileId: string) => { - const { error } = await supabase.from(tableNameProfiles).delete().eq('id', profileId); - if (error) { - addToast(`Delete failed: ${error.message}`, 'error'); - } else { + try { + await deleteTradeProfile(profileId); addToast('Profile deleted', 'success'); - fetchData(); + await fetchData(); + } catch (error: any) { + addToast(`Delete failed: ${error?.message || 'Unknown error'}`, 'error'); } setDeleteConfirm(null); }; @@ -471,12 +478,12 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi const toggleProfileActive = async (profile: Profile) => { const newState = !profile.is_active; - const { error } = await supabase.from(tableNameProfiles).update({ is_active: newState }).eq('id', profile.id); - if (error) { - addToast(error.message, 'error'); - } else { + try { + await setTradeProfileActive(profile.id, newState); addToast(newState ? 'Profile activated' : 'Profile suspended', 'success'); - fetchData(); + await fetchData(); + } catch (error: any) { + addToast(error?.message || 'Failed to update profile state', 'error'); } }; @@ -516,8 +523,13 @@ export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfi const { activeCount, totalCapital, totalPnl, totalTrades } = summarizePortfolioStats(profiles, tradeStats); const getUserEmail = (userId: string) => { - const u = users.find(u => u.user_id === userId); - return u ? u.email : ''; + if (userId && userId === currentUserProfile?.user_id) { + return currentUserProfile.email; + } + if (userId && userId === authUser?.id) { + return authUser?.email || ''; + } + return ''; }; // --- RENDER ---