refactor: move profile reads onto backend api

This commit is contained in:
Saravana Achu Mac 2026-04-04 15:36:54 -07:00
parent 42420687f9
commit 535e0a88a9
10 changed files with 163 additions and 124 deletions

View File

@ -18,6 +18,7 @@ import {
deleteTradeProfileForUser,
ensureDefaultTradeProfileForUser,
getCurrentUserProfile,
listAllTradeProfiles,
listTradeProfilesForUser,
saveTradeProfileForUser,
} from './profileRepository.js';
@ -1644,9 +1645,22 @@ export class ApiServer {
try {
const ensureDefault = String(req.query.ensureDefault || '').toLowerCase() === 'true';
const profiles = ensureDefault
? await ensureDefaultTradeProfileForUser(authUserId, supabaseService)
: await listTradeProfilesForUser(authUserId, supabaseService);
const scope = String(req.query.scope || 'user').toLowerCase();
const wantsAll = scope === 'all';
const isAdmin = wantsAll ? await isTradingAdmin(authUserId, (req as AuthenticatedRequest).authRole) : false;
let profiles;
if (ensureDefault && !wantsAll) {
profiles = await ensureDefaultTradeProfileForUser(authUserId, supabaseService);
} else if (wantsAll) {
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin role required' });
return;
}
profiles = await listAllTradeProfiles(supabaseService);
} else {
profiles = await listTradeProfilesForUser(authUserId, supabaseService);
}
res.json({ profiles });
} catch (error: any) {
res.status(500).json({ error: `Failed to load profiles: ${error.message}` });

View File

@ -123,6 +123,29 @@ async function listProfilesFromCosmos(userId: string): Promise<TradeProfileRecor
.filter((profile): profile is TradeProfileRecord => Boolean(profile));
}
async function listAllProfilesFromCosmos(): Promise<TradeProfileRecord[]> {
if (!isCosmosConfigured()) {
return [];
}
const container = getContainer(PROFILE_CONTAINER);
const { resources } = await container.items.query<TradeProfileDocument>({
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.createdAt DESC',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ 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<TradeProfileRecord[]> {
const client = legacyService?.getClient?.();
if (!client) {
@ -149,6 +172,31 @@ async function listProfilesFromSupabase(userId: string, legacyService?: LegacySu
}
}
async function listAllProfilesFromSupabase(legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
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')
.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 global profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return [];
}
}
async function mirrorProfileToSupabase(profile: TradeProfileRecord, legacyService?: LegacySupabaseService): Promise<void> {
const client = legacyService?.getClient?.();
if (!client) return;
@ -202,6 +250,19 @@ export async function listTradeProfilesForUser(userId: string, legacyService?: L
return listProfilesFromSupabase(userId, legacyService);
}
export async function listAllTradeProfiles(legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
try {
const cosmosProfiles = await listAllProfilesFromCosmos();
if (cosmosProfiles.length > 0) {
return cosmosProfiles;
}
} catch (error) {
logger.warn(`[Profiles] Cosmos global profile read failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`);
}
return listAllProfilesFromSupabase(legacyService);
}
export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const profiles = await listTradeProfilesForUser(userId, legacyService);
if (profiles.length > 0) {

View File

@ -63,8 +63,15 @@ export async function fetchCurrentUserProfile(): Promise<CurrentUserProfile> {
return response.profile;
}
export async function fetchTradeProfiles(options?: { ensureDefault?: boolean }): Promise<TradeProfilePayload[]> {
const query = options?.ensureDefault ? '?ensureDefault=true' : '';
export async function fetchTradeProfiles(options?: { ensureDefault?: boolean; scope?: 'user' | 'all' }): Promise<TradeProfilePayload[]> {
const params = new URLSearchParams();
if (options?.ensureDefault) {
params.set('ensureDefault', 'true');
}
if (options?.scope === 'all') {
params.set('scope', 'all');
}
const query = params.toString() ? `?${params.toString()}` : '';
const response = await apiRequest<{ profiles: TradeProfilePayload[] }>(`/api/profiles${query}`);
return Array.isArray(response.profiles) ? response.profiles : [];
}

View File

@ -1,10 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react';
import { supabase } from '../lib/supabaseClient';
import { tableNameProfiles } from '../lib/const';
import { useAuth } from '../components/AuthContext';
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
import { BacktestComparePanel } from '../backtest/components/BacktestComparePanel';
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
import { fetchTradeProfiles } from '../lib/profileApi';
interface BacktestTabProps {
previewAsCustomer?: boolean;
@ -31,20 +30,16 @@ export const BacktestTab: React.FC<BacktestTabProps> = ({ previewAsCustomer = fa
return;
}
setLoadingProfiles(true);
const { data, error } = await supabase
.from(tableNameProfiles)
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('[BacktestTab] Failed to fetch profiles:', error);
setProfiles([]);
} else {
try {
const data = await fetchTradeProfiles();
const nextProfiles = data || [];
setProfiles(nextProfiles);
if (!nextProfiles.find((item: any) => item.id === selectedProfileId)) {
setSelectedProfileId(nextProfiles[0]?.id || '');
}
} catch (error) {
console.error('[BacktestTab] Failed to fetch profiles:', error);
setProfiles([]);
}
setLoadingProfiles(false);
};

View File

@ -3,14 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HistoryTab } from './HistoryTab';
import { tableNameProfiles, tableNameTransactions } from '../lib/const';
import { tableNameTransactions } from '../lib/const';
const { authState } = vi.hoisted(() => ({
const { authState, fetchTradeProfilesMock } = vi.hoisted(() => ({
authState: {
user: { id: 'u1' },
profile: { role: 'user' },
refreshProfile: vi.fn()
}
},
fetchTradeProfilesMock: vi.fn()
}));
vi.mock('../components/AuthContext', () => ({
@ -34,6 +35,10 @@ vi.mock('../lib/supabaseClient', () => ({
supabase: { from: vi.fn() }
}));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
import { supabase } from '../lib/supabaseClient';
describe('HistoryTab Master Suite', () => {
@ -43,9 +48,9 @@ describe('HistoryTab Master Suite', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1', name: 'Alpha' }]);
(supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain(historyData);
if (table === tableNameProfiles) return mockSupabaseChain([{ id: 'p1', name: 'Alpha' }]);
return mockSupabaseChain([]);
});
});
@ -85,7 +90,6 @@ describe('HistoryTab Master Suite', () => {
(supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain(manyRecords);
if (table === tableNameProfiles) return mockSupabaseChain([{ id: 'p1', name: 'Alpha' }]);
return mockSupabaseChain([]);
});

View File

@ -1,12 +1,13 @@
import { useEffect, useState, useMemo } from 'react';
import { supabase } from '../lib/supabaseClient';
import { useAuth } from '../components/AuthContext';
import { tableNameTransactions, tableNameProfiles, tableNameOrders } from '../lib/const';
import { tableNameTransactions, tableNameOrders } from '../lib/const';
import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger';
import {
type LifecycleOrderRow
} from '../lib/orderLifecycleLedger';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
interface TradeRecord {
@ -353,18 +354,16 @@ export const HistoryTab = ({ botState }: HistoryTabProps) => {
historyData = histDataLegacy as TradeRecord[] | null;
}
let profilesQuery = supabase
.from(tableNameProfiles)
.select('id, name, allocated_capital')
.order('name', { ascending: true });
if (profile?.role !== 'admin') {
profilesQuery = profilesQuery.eq('user_id', user.id);
}
const { data: profData, error: profError } = await profilesQuery;
if (profError) {
console.error('[HistoryTab] Failed loading profiles:', profError.message);
let profData: Array<{ id: string; name: string; allocated_capital?: number }> = [];
try {
const profiles = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
profData = profiles.map((item: any) => ({
id: String(item.id),
name: String(item.name || item.id || 'Unnamed Profile'),
allocated_capital: Number(item.allocated_capital || 0)
}));
} catch (error: any) {
console.error('[HistoryTab] Failed loading profiles:', error.message || error);
}
const orderTagColumnsV2 = 'order_id,trade_id,profile_id,sub_tag,timestamp,filled_at,created_at,symbol,side,action,qty,quantity,price,status,source';

View File

@ -3,27 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { OverviewTab, dedupeLivePositions } from './OverviewTab';
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
import { tableNameProfiles } from '../lib/const';
const { authMock, supabaseMock, createMockQuery, canonicalLifecycleHookState } = vi.hoisted(() => {
const createMockQuery = (data: any, error: any = null) => {
const query: any = {
select: vi.fn(), eq: vi.fn(), order: vi.fn(), limit: vi.fn(), then: vi.fn()
};
query.select.mockReturnValue(query);
query.eq.mockReturnValue(query);
query.order.mockReturnValue(query);
query.limit.mockReturnValue(query);
query.then.mockImplementation((cb: any) => Promise.resolve({ data, error }).then(cb));
return query;
};
const { authMock, fetchTradeProfilesMock, canonicalLifecycleHookState } = vi.hoisted(() => {
return {
createMockQuery,
authMock: { user: { id: 'u1' }, profile: { role: 'user' } },
supabaseMock: {
from: vi.fn()
},
fetchTradeProfilesMock: vi.fn(),
canonicalLifecycleHookState: {
snapshot: null as any,
loading: false,
@ -39,8 +23,8 @@ vi.mock('../components/AuthContext', () => ({
})
}));
vi.mock('../lib/supabaseClient', () => ({
supabase: supabaseMock
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
vi.mock('../hooks/useCanonicalLifecycle', () => ({
@ -70,13 +54,9 @@ describe('OverviewTab coverage maximization', () => {
profileSignals: { 'p1': { signal: 'BUY', passed: true } }
}
};
supabaseMock.from.mockImplementation((table: string) => {
if (table === tableNameProfiles) return createMockQuery([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
return createMockQuery([]);
});
fetchTradeProfilesMock.mockResolvedValue([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
});
it('covers all uptime and duration formatting paths', async () => {
@ -99,7 +79,7 @@ describe('OverviewTab coverage maximization', () => {
});
it('covers fallback capital logic', async () => {
supabaseMock.from.mockImplementation(() => createMockQuery([]));
fetchTradeProfilesMock.mockResolvedValue([]);
mockBotState.settings.totalCapital = 25000;
render(<OverviewTab botState={mockBotState} />);
// The label in bot-status-bar is "Allocated:"
@ -107,22 +87,16 @@ describe('OverviewTab coverage maximization', () => {
});
it('covers Signal Active branches', async () => {
supabaseMock.from.mockImplementation((table: string) => {
if (table === tableNameProfiles) return createMockQuery([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 0, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
return createMockQuery([]);
});
fetchTradeProfilesMock.mockResolvedValueOnce([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 0, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
const { unmount } = render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal active, no allocation/i)).toBeInTheDocument());
unmount();
supabaseMock.from.mockImplementation((table: string) => {
if (table === tableNameProfiles) return createMockQuery([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
return createMockQuery([]);
});
fetchTradeProfilesMock.mockResolvedValueOnce([
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal active, waiting entry/i)).toBeInTheDocument());
});
@ -225,12 +199,12 @@ describe('OverviewTab coverage maximization', () => {
it('covers Admin role data fetching', async () => {
authMock.profile.role = 'admin';
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(supabaseMock.from).toHaveBeenCalled());
await waitFor(() => expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ scope: 'all' }));
});
it('covers error handling in fetchData', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
supabaseMock.from.mockImplementation(() => { throw new Error('Query Fail'); });
fetchTradeProfilesMock.mockRejectedValueOnce(new Error('Query Fail'));
render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(consoleSpy).toHaveBeenCalled());
consoleSpy.mockRestore();

View File

@ -1,10 +1,9 @@
import { useState, useEffect, useMemo } from 'react';
import type { BotState } from '../hooks/useWebSocket';
import { supabase } from '../lib/supabaseClient';
import { tableNameProfiles } from '../lib/const';
import { useAuth } from '../components/AuthContext';
import { aggregateCanonicalLifecycleTrades } from '../lib/orderLifecycleLedger';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
interface OverviewTabProps {
botState: BotState;
@ -133,19 +132,7 @@ export const OverviewTab = ({ botState, previewAsCustomer = false, connected = t
if (cancelled) return;
try {
let profilesQuery = supabase
.from(tableNameProfiles)
.select('id, name, allocated_capital, is_active, strategy_config');
if (profile?.role !== 'admin') {
profilesQuery = profilesQuery.eq('user_id', user.id);
}
const { data: profilesData, error: profilesError } = await profilesQuery;
if (profilesError) {
console.error('[OverviewTab] Failed loading profiles:', profilesError.message);
}
const profilesData = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
const activeProfileRows: ActiveProfileCapital[] = ((profilesData as any[]) || [])
.filter((p: any) => Boolean(p.is_active))

View File

@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PositionsTab } from './PositionsTab';
import type { BotState } from '../hooks/useWebSocket';
import { tableNameOrders, tableNameProfiles, tableNameStocks } from '../lib/const';
import { tableNameOrders, tableNameStocks } from '../lib/const';
const {
authState,
@ -13,7 +13,7 @@ const {
entriesEqMock,
ordersEqMock,
historyEqMock,
profilesEqMock
fetchTradeProfilesMock
} = vi.hoisted(() => ({
authState: {
user: { id: 'user-1' } as any,
@ -24,7 +24,7 @@ const {
entriesEqMock: vi.fn(),
ordersEqMock: vi.fn(),
historyEqMock: vi.fn(),
profilesEqMock: vi.fn()
fetchTradeProfilesMock: vi.fn()
}));
vi.mock('../components/AuthContext', () => ({
@ -40,12 +40,16 @@ vi.mock('../lib/supabaseClient', () => ({
}
}));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
interface QueryResult {
data: any;
error: any;
}
type TableKey = 'entries' | 'orders' | 'trade_history' | 'trade_profiles';
type TableKey = 'entries' | 'orders' | 'trade_history';
const makeBuilder = (result: QueryResult, eqSpy: (field: string, value: any) => void) => {
const builder: any = {
@ -65,8 +69,7 @@ const configureQueries = (plan: Record<TableKey, QueryResult[]>) => {
const queues = {
entries: [...plan.entries],
orders: [...plan.orders],
trade_history: [...plan.trade_history],
trade_profiles: [...plan.trade_profiles]
trade_history: [...plan.trade_history]
};
fromMock.mockImplementation((table: string) => {
@ -79,9 +82,6 @@ const configureQueries = (plan: Record<TableKey, QueryResult[]>) => {
if (table === 'trade_history') {
return makeBuilder(queues.trade_history.shift() || { data: [], error: null }, historyEqMock);
}
if (table === tableNameProfiles) {
return makeBuilder(queues.trade_profiles.shift() || { data: [], error: null }, profilesEqMock);
}
return makeBuilder({ data: [], error: null }, vi.fn());
});
};
@ -262,7 +262,11 @@ describe('PositionsTab DOM behavior', () => {
entriesEqMock.mockReset();
ordersEqMock.mockReset();
historyEqMock.mockReset();
profilesEqMock.mockReset();
fetchTradeProfilesMock.mockReset();
fetchTradeProfilesMock.mockResolvedValue([
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
]);
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal('alert', vi.fn());
@ -361,13 +365,6 @@ describe('PositionsTab DOM behavior', () => {
trade_history: [{
data: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }, { trade_id: '', profile_id: 'p1' }],
error: null
}],
trade_profiles: [{
data: [
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
],
error: null
}]
});
@ -449,7 +446,6 @@ describe('PositionsTab DOM behavior', () => {
error: null
}],
trade_history: [{ data: [], error: null }],
trade_profiles: [{ data: [{ id: 'p1', name: 'High Risk Scalper' }], error: null }]
});
const user = userEvent.setup();
@ -507,9 +503,9 @@ describe('PositionsTab DOM behavior', () => {
{ data: null, error: { message: 'v2 fallback failed' } },
{ data: null, error: { message: 'legacy failed' } }
],
trade_history: [{ data: null, error: { message: 'history failed' } }],
trade_profiles: [{ data: null, error: { message: 'profiles failed' } }]
trade_history: [{ data: null, error: { message: 'history failed' } }]
});
fetchTradeProfilesMock.mockRejectedValueOnce(new Error('profiles failed'));
render(<PositionsTab botState={{
symbols: {},
@ -536,7 +532,7 @@ describe('PositionsTab DOM behavior', () => {
expect(entriesEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(ordersEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(historyEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(profilesEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ scope: 'user' });
expect(warnSpy).toHaveBeenCalledWith(
'[PositionsTab] V3 order query failed, falling back to v2 columns:',

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { BotState } from '../hooks/useWebSocket';
import type { BotState } from '../hooks/useWebSocket';
import { supabase } from '../lib/supabaseClient';
import { tradingRuntime } from '../lib/runtime';
import { useAuth } from '../components/AuthContext';
import { tableNameStocks, tableNameOrders, tableNameProfiles } from '../lib/const';
import { tableNameStocks, tableNameOrders } from '../lib/const';
import { Layers, ListFilter, Link2, GitBranch, AlertTriangle, Lock, RefreshCw, CheckCircle, XCircle } from 'lucide-react';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
interface PositionsTabProps {
botState: BotState;
@ -523,16 +524,17 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
console.warn('[PositionsTab] History trade lookup failed:', histError.message);
}
let profilesQuery = supabase
.from(tableNameProfiles)
.select('id, name')
.order('name', { ascending: true });
if (profile?.role !== 'admin') {
profilesQuery = profilesQuery.eq('user_id', user.id);
}
const { data: profData, error: profError } = await profilesQuery;
let profData: Array<{ id: string; name: string }> = [];
let profError: Error | null = null;
try {
const profiles = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
profData = profiles.map((item: any) => ({
id: String(item.id),
name: String(item.name || item.id || 'Unnamed Profile')
}));
} catch (error) {
profError = error as Error;
}
if (posError) {
console.error('[PositionsTab] Failed loading manual positions:', posError.message);