feat: move core web profile flows behind backend api

This commit is contained in:
Saravana Achu Mac 2026-04-04 15:02:15 -07:00
parent 5685cb3449
commit 42420687f9
11 changed files with 689 additions and 187 deletions

View File

@ -14,6 +14,13 @@ import { observabilityService } from './observabilityService.js';
import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js';
import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js';
import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js';
import {
deleteTradeProfileForUser,
ensureDefaultTradeProfileForUser,
getCurrentUserProfile,
listTradeProfilesForUser,
saveTradeProfileForUser,
} from './profileRepository.js';
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
import { OperationalEvent } from '../domain/operationalEvents.js';
import { runBacktest } from '../backtest/index.js';
@ -26,6 +33,9 @@ import {
interface AuthenticatedRequest extends Request {
authUserId?: string;
authRole?: string;
authEmail?: string;
authDisplayName?: string;
authPlan?: string;
}
interface RateLimitBucket {
@ -620,14 +630,17 @@ export class ApiServer {
return;
}
const { userId, role, error } = await verifyTradingAccessToken(token);
if (!userId) {
res.status(401).json({ error: `Unauthorized: ${error || 'invalid token'}` });
const verified = await verifyTradingAccessToken(token);
if (!verified.userId) {
res.status(401).json({ error: `Unauthorized: ${verified.error || 'invalid token'}` });
return;
}
(req as AuthenticatedRequest).authUserId = userId;
(req as AuthenticatedRequest).authRole = role;
(req as AuthenticatedRequest).authUserId = verified.userId;
(req as AuthenticatedRequest).authRole = verified.role;
(req as AuthenticatedRequest).authEmail = verified.email;
(req as AuthenticatedRequest).authDisplayName = verified.displayName;
(req as AuthenticatedRequest).authPlan = verified.plan;
next();
};
@ -1602,6 +1615,118 @@ export class ApiServer {
});
});
this.app.get('/api/me/profile', this.requireAuth, async (req, res) => {
const authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean);
const profile = await getCurrentUserProfile(authUserId, {
email: authReq.authEmail,
role: authReq.authRole,
first_name: displayNameParts[0] || '',
last_name: displayNameParts.slice(1).join(' '),
trade_enable: true,
}, supabaseService);
res.json({ profile });
});
this.app.get('/api/profiles', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
const ensureDefault = String(req.query.ensureDefault || '').toLowerCase() === 'true';
const profiles = ensureDefault
? await ensureDefaultTradeProfileForUser(authUserId, supabaseService)
: await listTradeProfilesForUser(authUserId, supabaseService);
res.json({ profiles });
} catch (error: any) {
res.status(500).json({ error: `Failed to load profiles: ${error.message}` });
}
});
this.app.post('/api/profiles', 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 || {}, authUserId, supabaseService);
res.status(201).json({ profile });
} catch (error: any) {
res.status(400).json({ error: `Failed to save profile: ${error.message}` });
}
});
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;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
const profileId = String(req.params.id || '').trim();
const existing = (await listTradeProfilesForUser(authUserId, supabaseService))
.find((profile) => profile.id === profileId);
if (!existing) {
res.status(404).json({ error: 'Profile not found' });
return;
}
const profile = await saveTradeProfileForUser({
...existing,
is_active: Boolean(req.body?.is_active),
}, authUserId, supabaseService);
res.json({ profile });
} catch (error: any) {
res.status(400).json({ error: `Failed to update profile state: ${error.message}` });
}
});
this.app.delete('/api/profiles/:id', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
await deleteTradeProfileForUser(String(req.params.id || '').trim(), authUserId, supabaseService);
res.json({ success: true });
} catch (error: any) {
res.status(400).json({ error: `Failed to delete profile: ${error.message}` });
}
});
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
try {
const items = await listDynamicConfigEntries(supabaseService);

View File

@ -6,6 +6,9 @@ import { supabaseService } from './SupabaseService.js';
export interface VerifiedTradingAuth {
userId: string | null;
role?: string;
email?: string;
displayName?: string;
plan?: string;
productId?: string;
source: 'platform' | 'supabase' | null;
error?: string;
@ -65,6 +68,13 @@ export async function verifyTradingAccessToken(token: string): Promise<VerifiedT
return {
userId: String(payload.sub),
role: normalizeRole(payload.role),
email: typeof payload.email === 'string' ? payload.email : undefined,
displayName: typeof payload.displayName === 'string'
? payload.displayName
: typeof payload.name === 'string'
? payload.name
: undefined,
plan: typeof payload.plan === 'string' ? payload.plan : undefined,
productId: productId || config.PRODUCT_ID,
source: 'platform',
};

View File

@ -0,0 +1,324 @@
import { getContainer } from '@bytelyst/cosmos';
import { randomUUID } from 'node:crypto';
import { config } from '../config/index.js';
import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js';
type LegacySupabaseService = typeof supabaseService;
export interface TradingUserProfile {
user_id: string;
first_name: string;
last_name: string;
email: string;
role: string;
trade_enable: boolean;
ALPACA_API_KEY?: string;
ALPACA_SECRET_KEY?: string;
REAL_ALPACA_API_KEY?: string;
REAL_ALPACA_SECRET_KEY?: string;
drop_threshold_for_buy?: number;
gain_threshold_for_sell?: number;
market_poll_interval_in_seconds?: number;
}
export interface TradeProfileRecord {
id: string;
user_id: string;
name: string;
allocated_capital: number;
risk_per_trade_percent: number;
symbols: string;
is_active: boolean;
strategy_config: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
interface TradeProfileDocument extends TradeProfileRecord {
productId: string;
type: 'trade_profile';
createdAt: string;
updatedAt: string;
}
const PROFILE_CONTAINER = 'trade_profiles';
function isCosmosConfigured(): boolean {
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
}
function normalizeProfile(row: Partial<TradeProfileRecord> | null | undefined): TradeProfileRecord | null {
const id = String(row?.id || '').trim();
const userId = String(row?.user_id || '').trim();
if (!id || !userId) {
return null;
}
return {
id,
user_id: userId,
name: String(row?.name || 'Untitled Strategy').trim() || 'Untitled Strategy',
allocated_capital: Number(row?.allocated_capital || 0),
risk_per_trade_percent: Number(row?.risk_per_trade_percent || 0),
symbols: String(row?.symbols || 'BTC/USDT'),
is_active: Boolean(row?.is_active),
strategy_config: (row?.strategy_config && typeof row.strategy_config === 'object')
? row.strategy_config as Record<string, unknown>
: {},
created_at: row?.created_at ? String(row.created_at) : undefined,
updated_at: row?.updated_at ? String(row.updated_at) : undefined,
};
}
function buildDefaultTradeProfile(userId: string): TradeProfileRecord {
const timestamp = new Date().toISOString();
return {
id: randomUUID(),
user_id: userId,
name: 'My First Strategy',
allocated_capital: 1000,
risk_per_trade_percent: 1,
symbols: 'BTC/USDT, ETH/USDT',
is_active: false,
strategy_config: {
rules: [
{ ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } },
{ ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14, overbought: 70, oversold: 30 } },
{ ruleId: 'ZoneRule', enabled: true, params: { zonePercent: 1.5 } },
{ ruleId: 'SessionRule', enabled: true, params: { sessions: 'London,NY' } },
{ ruleId: 'EntryTriggerRule', enabled: true, params: { showPatterns: true } },
{ ruleId: 'RiskManagementRule', enabled: true, params: { maxRisk: 2.0 } },
{ ruleId: 'AIAnalysisRule', enabled: false, params: { minConfidence: 0.7 } },
],
riskLimits: { maxDailyLossUsd: 50, maxOpenTrades: 3, maxConsecutiveLosses: 2 },
execution: { orderType: 'market', cooldownMinutes: 30, entryMode: 'both' },
},
created_at: timestamp,
updated_at: timestamp,
};
}
async function listProfilesFromCosmos(userId: string): 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.user_id = @userId AND c.type = @type ORDER BY c.createdAt DESC',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@userId', value: userId },
{ 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) {
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')
.eq('user_id', userId)
.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 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;
try {
const { error } = await client
.from('trade_profiles')
.upsert({
...profile,
created_at: profile.created_at || new Date().toISOString(),
updated_at: profile.updated_at || new Date().toISOString(),
}, { onConflict: 'id' });
if (error) {
logger.warn(`[Profiles] Legacy profile mirror failed: ${error.message}`);
}
} catch (error) {
logger.warn(`[Profiles] Legacy profile mirror failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
async function deleteProfileFromSupabase(profileId: string, userId: string, legacyService?: LegacySupabaseService): Promise<void> {
const client = legacyService?.getClient?.();
if (!client) return;
try {
const { error } = await client
.from('trade_profiles')
.delete()
.eq('id', profileId)
.eq('user_id', userId);
if (error) {
logger.warn(`[Profiles] Legacy profile delete failed: ${error.message}`);
}
} catch (error) {
logger.warn(`[Profiles] Legacy profile delete failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
export async function listTradeProfilesForUser(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
try {
const cosmosProfiles = await listProfilesFromCosmos(userId);
if (cosmosProfiles.length > 0) {
return cosmosProfiles;
}
} catch (error) {
logger.warn(`[Profiles] Cosmos profile read failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`);
}
return listProfilesFromSupabase(userId, legacyService);
}
export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const profiles = await listTradeProfilesForUser(userId, legacyService);
if (profiles.length > 0) {
return profiles;
}
const created = await saveTradeProfileForUser(buildDefaultTradeProfile(userId), userId, legacyService);
return [created];
}
export async function saveTradeProfileForUser(
input: Partial<TradeProfileRecord>,
userId: string,
legacyService?: LegacySupabaseService
): Promise<TradeProfileRecord> {
const now = new Date().toISOString();
const normalized = normalizeProfile({
...input,
id: String(input.id || randomUUID()),
user_id: userId,
created_at: input.created_at || now,
updated_at: now,
});
if (!normalized) {
throw new Error('Invalid trade profile payload');
}
if (isCosmosConfigured()) {
try {
const container = getContainer(PROFILE_CONTAINER);
await container.items.upsert<TradeProfileDocument>({
...normalized,
productId: config.PRODUCT_ID,
type: 'trade_profile',
createdAt: normalized.created_at || now,
updatedAt: normalized.updated_at || now,
});
} catch (error) {
logger.warn(`[Profiles] Cosmos profile upsert failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
await mirrorProfileToSupabase(normalized, legacyService);
return normalized;
}
export async function deleteTradeProfileForUser(
profileId: string,
userId: string,
legacyService?: LegacySupabaseService
): Promise<void> {
if (!profileId || !userId) {
return;
}
if (isCosmosConfigured()) {
try {
const container = getContainer(PROFILE_CONTAINER);
await container.item(profileId, userId).delete();
} catch (error) {
logger.warn(`[Profiles] Cosmos profile delete failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
await deleteProfileFromSupabase(profileId, userId, legacyService);
}
export async function getCurrentUserProfile(
userId: string,
fallback: Partial<TradingUserProfile> = {},
legacyService?: LegacySupabaseService
): Promise<TradingUserProfile> {
const client = legacyService?.getClient?.();
if (client) {
try {
const { data, error } = await client
.from('users')
.select('user_id,first_name,last_name,email,role,trade_enable,ALPACA_API_KEY,ALPACA_SECRET_KEY,REAL_ALPACA_API_KEY,REAL_ALPACA_SECRET_KEY,drop_threshold_for_buy,gain_threshold_for_sell,market_poll_interval_in_seconds')
.eq('user_id', userId)
.maybeSingle();
if (!error && data) {
return {
user_id: String((data as any).user_id || userId),
first_name: String((data as any).first_name || fallback.first_name || ''),
last_name: String((data as any).last_name || fallback.last_name || ''),
email: String((data as any).email || fallback.email || ''),
role: String((data as any).role || fallback.role || 'member'),
trade_enable: Boolean((data as any).trade_enable ?? fallback.trade_enable ?? true),
ALPACA_API_KEY: (data as any).ALPACA_API_KEY || fallback.ALPACA_API_KEY,
ALPACA_SECRET_KEY: (data as any).ALPACA_SECRET_KEY || fallback.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: (data as any).REAL_ALPACA_API_KEY || fallback.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: (data as any).REAL_ALPACA_SECRET_KEY || fallback.REAL_ALPACA_SECRET_KEY,
drop_threshold_for_buy: Number((data as any).drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0),
gain_threshold_for_sell: Number((data as any).gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0),
market_poll_interval_in_seconds: Number((data as any).market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0),
};
}
} catch (error) {
logger.warn(`[Profiles] Legacy user profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
return {
user_id: userId,
first_name: String(fallback.first_name || ''),
last_name: String(fallback.last_name || ''),
email: String(fallback.email || ''),
role: String(fallback.role || 'member'),
trade_enable: Boolean(fallback.trade_enable ?? true),
ALPACA_API_KEY: fallback.ALPACA_API_KEY,
ALPACA_SECRET_KEY: fallback.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: fallback.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: fallback.REAL_ALPACA_SECRET_KEY,
drop_threshold_for_buy: Number(fallback.drop_threshold_for_buy ?? 0),
gain_threshold_for_sell: Number(fallback.gain_threshold_for_sell ?? 0),
market_poll_interval_in_seconds: Number(fallback.market_poll_interval_in_seconds ?? 0),
};
}

View File

@ -1,21 +1,23 @@
// @vitest-environment jsdom
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
// import userEvent from '@testing-library/user-event';
import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App';
const { authMock, socketMock, fromMock } = vi.hoisted(() => ({
authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() },
socketMock: {
botState: {
import { render, screen, fireEvent } from '@testing-library/react';
// import userEvent from '@testing-library/user-event';
import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App';
const { authMock, socketMock, fetchTradeProfilesMock, createTradeProfileMock, updateTradeProfileMock } = vi.hoisted(() => ({
authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() },
socketMock: {
botState: {
settings: { isAlgoEnabled: true },
symbols: {},
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
},
connected: true
},
fromMock: vi.fn()
}));
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
},
connected: true
},
fetchTradeProfilesMock: vi.fn(),
createTradeProfileMock: vi.fn(),
updateTradeProfileMock: vi.fn()
}));
vi.mock('./components/AuthContext', () => ({
useAuth: () => authMock
@ -30,9 +32,11 @@ vi.mock('./hooks/useWebSocket', () => ({
}
}));
vi.mock('./lib/supabaseClient', () => ({
supabase: { from: fromMock }
}));
vi.mock('./lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock,
createTradeProfile: createTradeProfileMock,
updateTradeProfile: updateTradeProfileMock
}));
// Mock components
vi.mock('./components/AlertFeed', () => ({ AlertFeed: () => <div>AlertFeedMock</div> }));
@ -65,16 +69,12 @@ describe('App Component DOM', () => {
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
};
fromMock.mockReturnValue({
select: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: [], error: null }),
insert: vi.fn().mockResolvedValue({ error: null }),
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockResolvedValue({ error: null })
});
vi.stubGlobal('location', { pathname: '/' });
});
fetchTradeProfilesMock.mockResolvedValue([]);
createTradeProfileMock.mockResolvedValue({});
updateTradeProfileMock.mockResolvedValue({});
vi.stubGlobal('location', { pathname: '/' });
});
afterEach(() => {
vi.useRealTimers();

View File

@ -21,10 +21,9 @@ import './App.css';
import { useAuth } from './components/AuthContext';
import { Login } from './components/Login';
import { ResetPassword } from './components/ResetPassword';
import { supabase } from './lib/supabaseClient';
import { tableNameProfiles } from './lib/const';
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
export const resolveProfileNameForAction = (
action: string,
@ -82,7 +81,7 @@ function App() {
const hasCriticalEvents = recentCriticalEvents.length > 0;
const fetchChatProfiles = useCallback(async () => {
const { data } = await supabase.from(tableNameProfiles).select('*').order('created_at', { ascending: false });
const data = await fetchTradeProfiles();
setChatProfiles(data || []);
}, []);
@ -109,8 +108,9 @@ function App() {
console.log('[ChatApply] Action:', action, 'user_id:', currentUserId, 'Payload:', payload);
if (action === 'create_profile') {
const { error } = await supabase.from(tableNameProfiles).insert([payload]);
if (error) {
try {
await createTradeProfile(payload);
} catch (error: any) {
console.error('[ChatApply] Insert error:', error);
return { success: false, error: error.message };
}
@ -119,8 +119,9 @@ function App() {
window.dispatchEvent(new Event('profiles-updated'));
return { success: true };
} else if (action === 'update_profile' && profileData.id) {
const { error } = await supabase.from(tableNameProfiles).update(payload).eq('id', profileData.id);
if (error) {
try {
await updateTradeProfile(profileData.id, payload);
} catch (error: any) {
console.error('[ChatApply] Update error:', error);
return { success: false, error: error.message };
}

View File

@ -3,30 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
// import userEvent from '@testing-library/user-event';
import { AuthProvider, useAuth } from './AuthContext';
import { tableNameProfiles, tableNameUsers } from '../lib/const';
const {
getSessionMock,
signOutMock,
fromMock,
usersSingleMock,
profilesLimitMock,
profilesInsertMock,
unsubscribeMock,
tradingAuthState
tradingAuthState,
fetchCurrentUserProfileMock,
fetchTradeProfilesMock
} = vi.hoisted(() => ({
getSessionMock: vi.fn(),
signOutMock: vi.fn(),
fromMock: vi.fn(),
usersSingleMock: vi.fn(),
profilesLimitMock: vi.fn(),
profilesInsertMock: vi.fn(),
unsubscribeMock: vi.fn(),
tradingAuthState: {
user: { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' } as any,
isLoading: false,
logout: vi.fn()
}
},
fetchCurrentUserProfileMock: vi.fn(),
fetchTradeProfilesMock: vi.fn()
}));
vi.mock('../lib/tradingAuth', () => ({
@ -39,11 +32,15 @@ vi.mock('../lib/supabaseClient', () => ({
auth: {
getSession: getSessionMock,
signOut: signOutMock
},
from: fromMock
}
}
}));
vi.mock('../lib/profileApi', () => ({
fetchCurrentUserProfile: fetchCurrentUserProfileMock,
fetchTradeProfiles: fetchTradeProfilesMock
}));
const Probe = () => {
const auth = useAuth();
return (
@ -61,56 +58,22 @@ describe('AuthContext DOM behavior', () => {
beforeEach(() => {
getSessionMock.mockReset();
signOutMock.mockReset();
fromMock.mockReset();
usersSingleMock.mockReset();
profilesLimitMock.mockReset();
profilesInsertMock.mockReset();
unsubscribeMock.mockReset();
tradingAuthState.user = { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' };
tradingAuthState.isLoading = false;
tradingAuthState.logout.mockReset();
fetchCurrentUserProfileMock.mockReset();
fetchTradeProfilesMock.mockReset();
signOutMock.mockResolvedValue({ error: null });
usersSingleMock.mockResolvedValue({
data: {
user_id: 'user-1',
first_name: 'Sarah',
last_name: 'Algo',
email: 'sarah@example.com',
role: 'admin',
trade_enable: true
},
error: null
});
profilesLimitMock.mockResolvedValue({ data: [], error: null });
profilesInsertMock.mockResolvedValue({ error: null });
fromMock.mockImplementation((table: string) => {
if (table === tableNameUsers) {
const usersBuilder: any = {
select: vi.fn(() => usersBuilder),
eq: vi.fn(() => usersBuilder),
single: usersSingleMock
};
return usersBuilder;
}
if (table === tableNameProfiles) {
const profilesBuilder: any = {
select: vi.fn(() => profilesBuilder),
eq: vi.fn(() => profilesBuilder),
limit: profilesLimitMock,
insert: profilesInsertMock
};
return profilesBuilder;
}
return {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn(),
limit: vi.fn(),
insert: vi.fn()
};
fetchCurrentUserProfileMock.mockResolvedValue({
user_id: 'user-1',
first_name: 'Sarah',
last_name: 'Algo',
email: 'sarah@example.com',
role: 'admin',
trade_enable: true
});
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1' }]);
});
it('loads session/profile, ensures default profile, and cleans up subscription', async () => {
@ -131,10 +94,7 @@ describe('AuthContext DOM behavior', () => {
expect(screen.getByTestId('role')).toHaveTextContent('admin');
});
expect(profilesInsertMock).toHaveBeenCalledTimes(1);
expect(profilesInsertMock.mock.calls[0][0]).toEqual([
expect.objectContaining({ user_id: 'user-1', name: 'My First Strategy' })
]);
expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ ensureDefault: true });
expect(dispatchSpy).toHaveBeenCalled();
expect((dispatchSpy.mock.calls[0][0] as Event).type).toBe('profiles-updated');
}, 20000);
@ -148,7 +108,7 @@ describe('AuthContext DOM behavior', () => {
expect(screen.getByTestId('loading')).toHaveTextContent('false');
expect(screen.getByTestId('user')).toHaveTextContent('none');
});
expect(usersSingleMock).not.toHaveBeenCalled();
expect(fetchCurrentUserProfileMock).not.toHaveBeenCalled();
});
it('handles auth state changes with no session', async () => {
@ -172,12 +132,12 @@ describe('AuthContext DOM behavior', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
usersSingleMock.mockResolvedValue({ data: null, error: { message: 'Profile Not Found' } });
fetchCurrentUserProfileMock.mockRejectedValue({ message: 'Profile Not Found' });
render(<AuthProvider><Probe /></AuthProvider>);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Error fetching user profile:', expect.objectContaining({ message: 'Profile Not Found' }));
expect(consoleSpy).toHaveBeenCalledWith('Unexpected error fetching profile:', expect.objectContaining({ message: 'Profile Not Found' }));
});
consoleSpy.mockRestore();
});
@ -186,7 +146,7 @@ describe('AuthContext DOM behavior', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
usersSingleMock.mockImplementation(() => { throw new Error('Crashed'); });
fetchCurrentUserProfileMock.mockImplementation(() => { throw new Error('Crashed'); });
render(<AuthProvider><Probe /></AuthProvider>);
@ -200,7 +160,7 @@ describe('AuthContext DOM behavior', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
profilesLimitMock.mockImplementation(() => { throw new Error('Limit Error'); });
fetchTradeProfilesMock.mockImplementation(() => { throw new Error('Limit Error'); });
render(<AuthProvider><Probe /></AuthProvider>);

View File

@ -18,25 +18,28 @@ vi.mock('../lib/tradingAuth', () => ({
}));
vi.mock('../lib/supabaseClient', () => {
const query: any = {
select: vi.fn(() => query),
eq: vi.fn(() => query),
single: vi.fn(async () => ({ data: null, error: null })),
limit: vi.fn(async () => ({ data: [], error: null })),
insert: vi.fn(async () => ({ error: null }))
};
return {
supabase: {
auth: {
getSession: vi.fn(async () => ({ data: { session: null } })),
signOut: vi.fn(async () => ({ error: null }))
},
from: vi.fn(() => query)
}
}
};
});
vi.mock('../lib/profileApi', () => ({
fetchCurrentUserProfile: vi.fn(async () => ({
user_id: 'user-1',
first_name: 'User',
last_name: 'One',
email: 'user-1@example.com',
role: 'member',
trade_enable: true,
})),
fetchTradeProfiles: vi.fn(async () => [{ id: 'p1' }]),
}));
describe('AuthContext', () => {
it('provides auth state through AuthProvider', () => {
const Probe = () => {

View File

@ -1,8 +1,8 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { User, Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabaseClient';
import { tableNameUsers, tableNameProfiles } from '../lib/const';
import { TradingAuthProvider, useTradingAuth } from '../lib/tradingAuth';
import { fetchCurrentUserProfile, fetchTradeProfiles } from '../lib/profileApi';
// Define the shape of our extended user profile
export interface UserProfile {
@ -118,23 +118,11 @@ function AuthBridge({ children }: { children: React.ReactNode }) {
};
}, [tradingAuth.user?.id]);
const fetchProfile = async (userId: string, authUserOverride?: User | null) => {
const fetchProfile = async (_userId: string, authUserOverride?: User | null) => {
try {
const { data, error } = await supabase
.from(tableNameUsers)
.select('user_id,first_name,last_name,email,role,ALPACA_API_KEY,ALPACA_SECRET_KEY,REAL_ALPACA_API_KEY,REAL_ALPACA_SECRET_KEY,trade_enable,drop_threshold_for_buy,gain_threshold_for_sell,market_poll_interval_in_seconds')
.eq('user_id', userId)
.single();
if (error) {
console.error('Error fetching user profile:', error);
setProfile(buildFallbackProfile(authUserOverride ?? user));
ensureDefaultProfile(userId);
} else {
setProfile(data as UserProfile);
// Ensure a default trading profile exists for new users
ensureDefaultProfile(userId);
}
const currentProfile = await fetchCurrentUserProfile();
setProfile(currentProfile as UserProfile);
await ensureDefaultProfile();
} catch (err) {
console.error('Unexpected error fetching profile:', err);
setProfile(buildFallbackProfile(authUserOverride ?? user));
@ -143,17 +131,12 @@ function AuthBridge({ children }: { children: React.ReactNode }) {
}
};
const ensureDefaultProfile = async (userId: string) => {
const ensureDefaultProfile = async () => {
try {
const { data } = await supabase
.from(tableNameProfiles)
.select('id')
.eq('user_id', userId)
.limit(1);
if (shouldCreateDefaultProfile(data)) {
console.log('[Auth] No profiles found - creating default profile for new user');
await supabase.from(tableNameProfiles).insert([buildDefaultProfilePayload(userId)]);
const profiles = await fetchTradeProfiles({ ensureDefault: true });
if (shouldCreateDefaultProfile(profiles)) {
console.log('[Auth] No profiles found after bootstrap ensureDefault call');
} else {
window.dispatchEvent(new Event('profiles-updated'));
}
} catch (err) {

View File

@ -14,14 +14,13 @@ import {
Target,
Lock
} from 'lucide-react';
import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates';
import type { RiskStyleTemplate } from '../lib/RiskStyleTemplates';
import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates';
import type { RiskStyleTemplate } from '../lib/RiskStyleTemplates';
import { getUserTier, TIER_POLICIES, isFeatureAllowed } from '../lib/TierPolicy';
import { supabase } from '../lib/supabaseClient';
import { tableNameProfiles } from '../lib/const';
import { useAuth } from './AuthContext';
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
import { createTradeProfile, updateTradeProfile } from '../lib/profileApi';
interface WizardState {
step: number;
@ -132,12 +131,12 @@ export const StrategyWizard: React.FC<{
strategy_config
};
let result;
if (editingProfile) {
result = await supabase.from(tableNameProfiles).update(payload).eq('id', editingProfile.id);
} else {
result = await supabase.from(tableNameProfiles).insert([payload]);
}
let result;
if (editingProfile) {
result = await updateTradeProfile(editingProfile.id, payload).then(() => ({ error: null as any })).catch((error) => ({ error }));
} else {
result = await createTradeProfile(payload).then(() => ({ error: null as any })).catch((error) => ({ error }));
}
setLoading(false);

100
web/src/lib/profileApi.ts Normal file
View File

@ -0,0 +1,100 @@
import { supabase } from './supabaseClient';
import { tradingRuntime } from './runtime';
export interface TradeProfilePayload {
id?: string;
user_id?: string;
name: string;
allocated_capital: number;
risk_per_trade_percent: number;
symbols: string;
is_active: boolean;
strategy_config: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
export interface CurrentUserProfile {
user_id: string;
first_name: string;
last_name: string;
email: string;
role: string;
trade_enable: boolean;
ALPACA_API_KEY?: string;
ALPACA_SECRET_KEY?: string;
REAL_ALPACA_API_KEY?: string;
REAL_ALPACA_SECRET_KEY?: string;
drop_threshold_for_buy?: number;
gain_threshold_for_sell?: number;
market_poll_interval_in_seconds?: number;
}
async function getAccessToken(): Promise<string> {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
if (!accessToken) {
throw new Error('Not authenticated');
}
return accessToken;
}
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const accessToken = await getAccessToken();
const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
...(init?.headers || {}),
},
});
const body = await response.json().catch(() => ({} as any));
if (!response.ok) {
throw new Error(body?.error || `Request failed (${response.status})`);
}
return body as T;
}
export async function fetchCurrentUserProfile(): Promise<CurrentUserProfile> {
const response = await apiRequest<{ profile: CurrentUserProfile }>('/api/me/profile');
return response.profile;
}
export async function fetchTradeProfiles(options?: { ensureDefault?: boolean }): Promise<TradeProfilePayload[]> {
const query = options?.ensureDefault ? '?ensureDefault=true' : '';
const response = await apiRequest<{ profiles: TradeProfilePayload[] }>(`/api/profiles${query}`);
return Array.isArray(response.profiles) ? response.profiles : [];
}
export async function createTradeProfile(payload: TradeProfilePayload): Promise<TradeProfilePayload> {
const response = await apiRequest<{ profile: TradeProfilePayload }>('/api/profiles', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.profile;
}
export async function updateTradeProfile(id: string, payload: Partial<TradeProfilePayload>): Promise<TradeProfilePayload> {
const response = await apiRequest<{ profile: TradeProfilePayload }>(`/api/profiles/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
return response.profile;
}
export async function setTradeProfileActive(id: string, isActive: boolean): Promise<TradeProfilePayload> {
const response = await apiRequest<{ profile: TradeProfilePayload }>(`/api/profiles/${id}/active`, {
method: 'PATCH',
body: JSON.stringify({ is_active: isActive }),
});
return response.profile;
}
export async function deleteTradeProfile(id: string): Promise<void> {
await apiRequest<{ success: boolean }>(`/api/profiles/${id}`, {
method: 'DELETE',
});
}

View File

@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '../lib/supabaseClient';
import { tableNameProfiles } from '../lib/const';
import { useAuth } from '../components/AuthContext';
import { getStrategyExplanation } from '../lib/StrategyExplanationService';
import { getUserTier } from '../lib/TierPolicy';
import React, { useState, useEffect } from 'react';
import { useAuth } from '../components/AuthContext';
import { getStrategyExplanation } from '../lib/StrategyExplanationService';
import { getUserTier } from '../lib/TierPolicy';
import {
Play,
Pause,
@ -28,6 +26,7 @@ import {
import { StrategyWizard } from '../components/StrategyWizard';
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
import { deleteTradeProfile, fetchTradeProfiles, setTradeProfileActive } from '../lib/profileApi';
const ActiveStrategyCard: React.FC<{
profile: any;
@ -260,17 +259,13 @@ export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewA
const tier = getUserTier(userProfile);
const { enabled: backtestEnabled, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
const fetchProfiles = async () => {
if (!user) return;
setIsLoading(true);
const { data } = await supabase
.from(tableNameProfiles)
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
setProfiles(data || []);
setIsLoading(false);
};
const fetchProfiles = async () => {
if (!user) return;
setIsLoading(true);
const data = await fetchTradeProfiles();
setProfiles(data || []);
setIsLoading(false);
};
useEffect(() => {
fetchProfiles();
@ -278,22 +273,24 @@ export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewA
return () => window.removeEventListener('profiles-updated', fetchProfiles);
}, [user]);
const toggleBot = async (profile: any) => {
const { error } = await supabase
.from(tableNameProfiles)
.update({ is_active: !profile.is_active })
.eq('id', profile.id);
if (!error) fetchProfiles();
};
const deleteBot = async (id: string) => {
if (!confirm('Are you sure you want to delete this strategy?')) return;
const { error } = await supabase
.from(tableNameProfiles)
.delete()
.eq('id', id);
if (!error) fetchProfiles();
};
const toggleBot = async (profile: any) => {
try {
await setTradeProfileActive(profile.id, !profile.is_active);
fetchProfiles();
} catch {
// existing UI remains silent on toggle failure
}
};
const deleteBot = async (id: string) => {
if (!confirm('Are you sure you want to delete this strategy?')) return;
try {
await deleteTradeProfile(id);
fetchProfiles();
} catch {
// existing UI remains silent on delete failure
}
};
if (showWizard) {
return (