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