refactor: move profile manager onto control plane

This commit is contained in:
Saravana Achu Mac 2026-04-04 15:41:15 -07:00
parent 535e0a88a9
commit 5b59257a4b
3 changed files with 191 additions and 161 deletions

View File

@ -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}` });

View File

@ -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(<TradeProfileManager />);

View File

@ -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<Profile[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [tradeStats, setTradeStats] = useState<Record<string, ProfileTradeStats>>({});
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentUserProfile, setCurrentUserProfile] = useState<User | null>(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<string, unknown>
};
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 ---