feat: move core web profile flows behind backend api
This commit is contained in:
parent
5685cb3449
commit
42420687f9
@ -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);
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
324
backend/src/services/profileRepository.ts
Normal file
324
backend/src/services/profileRepository.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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>);
|
||||
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
100
web/src/lib/profileApi.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user