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