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 { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js';
|
||||||
import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js';
|
import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js';
|
||||||
import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js';
|
import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js';
|
||||||
|
import {
|
||||||
|
deleteTradeProfileForUser,
|
||||||
|
ensureDefaultTradeProfileForUser,
|
||||||
|
getCurrentUserProfile,
|
||||||
|
listTradeProfilesForUser,
|
||||||
|
saveTradeProfileForUser,
|
||||||
|
} from './profileRepository.js';
|
||||||
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
||||||
import { OperationalEvent } from '../domain/operationalEvents.js';
|
import { OperationalEvent } from '../domain/operationalEvents.js';
|
||||||
import { runBacktest } from '../backtest/index.js';
|
import { runBacktest } from '../backtest/index.js';
|
||||||
@ -26,6 +33,9 @@ import {
|
|||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
authUserId?: string;
|
authUserId?: string;
|
||||||
authRole?: string;
|
authRole?: string;
|
||||||
|
authEmail?: string;
|
||||||
|
authDisplayName?: string;
|
||||||
|
authPlan?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RateLimitBucket {
|
interface RateLimitBucket {
|
||||||
@ -620,14 +630,17 @@ export class ApiServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userId, role, error } = await verifyTradingAccessToken(token);
|
const verified = await verifyTradingAccessToken(token);
|
||||||
if (!userId) {
|
if (!verified.userId) {
|
||||||
res.status(401).json({ error: `Unauthorized: ${error || 'invalid token'}` });
|
res.status(401).json({ error: `Unauthorized: ${verified.error || 'invalid token'}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(req as AuthenticatedRequest).authUserId = userId;
|
(req as AuthenticatedRequest).authUserId = verified.userId;
|
||||||
(req as AuthenticatedRequest).authRole = role;
|
(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();
|
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) => {
|
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const items = await listDynamicConfigEntries(supabaseService);
|
const items = await listDynamicConfigEntries(supabaseService);
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import { supabaseService } from './SupabaseService.js';
|
|||||||
export interface VerifiedTradingAuth {
|
export interface VerifiedTradingAuth {
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
email?: string;
|
||||||
|
displayName?: string;
|
||||||
|
plan?: string;
|
||||||
productId?: string;
|
productId?: string;
|
||||||
source: 'platform' | 'supabase' | null;
|
source: 'platform' | 'supabase' | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
@ -65,6 +68,13 @@ export async function verifyTradingAccessToken(token: string): Promise<VerifiedT
|
|||||||
return {
|
return {
|
||||||
userId: String(payload.sub),
|
userId: String(payload.sub),
|
||||||
role: normalizeRole(payload.role),
|
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,
|
productId: productId || config.PRODUCT_ID,
|
||||||
source: 'platform',
|
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
|
// @vitest-environment jsdom
|
||||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
// import userEvent from '@testing-library/user-event';
|
// import userEvent from '@testing-library/user-event';
|
||||||
import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App';
|
import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App';
|
||||||
|
|
||||||
const { authMock, socketMock, fromMock } = vi.hoisted(() => ({
|
const { authMock, socketMock, fetchTradeProfilesMock, createTradeProfileMock, updateTradeProfileMock } = vi.hoisted(() => ({
|
||||||
authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() },
|
authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() },
|
||||||
socketMock: {
|
socketMock: {
|
||||||
botState: {
|
botState: {
|
||||||
settings: { isAlgoEnabled: true },
|
settings: { isAlgoEnabled: true },
|
||||||
symbols: {},
|
symbols: {},
|
||||||
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
|
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
|
||||||
},
|
},
|
||||||
connected: true
|
connected: true
|
||||||
},
|
},
|
||||||
fromMock: vi.fn()
|
fetchTradeProfilesMock: vi.fn(),
|
||||||
}));
|
createTradeProfileMock: vi.fn(),
|
||||||
|
updateTradeProfileMock: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./components/AuthContext', () => ({
|
vi.mock('./components/AuthContext', () => ({
|
||||||
useAuth: () => authMock
|
useAuth: () => authMock
|
||||||
@ -30,9 +32,11 @@ vi.mock('./hooks/useWebSocket', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./lib/supabaseClient', () => ({
|
vi.mock('./lib/profileApi', () => ({
|
||||||
supabase: { from: fromMock }
|
fetchTradeProfiles: fetchTradeProfilesMock,
|
||||||
}));
|
createTradeProfile: createTradeProfileMock,
|
||||||
|
updateTradeProfile: updateTradeProfileMock
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock('./components/AlertFeed', () => ({ AlertFeed: () => <div>AlertFeedMock</div> }));
|
vi.mock('./components/AlertFeed', () => ({ AlertFeed: () => <div>AlertFeedMock</div> }));
|
||||||
@ -65,16 +69,12 @@ describe('App Component DOM', () => {
|
|||||||
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
|
health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
|
||||||
};
|
};
|
||||||
|
|
||||||
fromMock.mockReturnValue({
|
fetchTradeProfilesMock.mockResolvedValue([]);
|
||||||
select: vi.fn().mockReturnThis(),
|
createTradeProfileMock.mockResolvedValue({});
|
||||||
order: vi.fn().mockResolvedValue({ data: [], error: null }),
|
updateTradeProfileMock.mockResolvedValue({});
|
||||||
insert: vi.fn().mockResolvedValue({ error: null }),
|
|
||||||
update: vi.fn().mockReturnThis(),
|
vi.stubGlobal('location', { pathname: '/' });
|
||||||
eq: vi.fn().mockResolvedValue({ error: null })
|
});
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('location', { pathname: '/' });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@ -21,10 +21,9 @@ import './App.css';
|
|||||||
import { useAuth } from './components/AuthContext';
|
import { useAuth } from './components/AuthContext';
|
||||||
import { Login } from './components/Login';
|
import { Login } from './components/Login';
|
||||||
import { ResetPassword } from './components/ResetPassword';
|
import { ResetPassword } from './components/ResetPassword';
|
||||||
import { supabase } from './lib/supabaseClient';
|
|
||||||
import { tableNameProfiles } from './lib/const';
|
|
||||||
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
|
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
|
||||||
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
|
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
|
||||||
|
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
|
||||||
|
|
||||||
export const resolveProfileNameForAction = (
|
export const resolveProfileNameForAction = (
|
||||||
action: string,
|
action: string,
|
||||||
@ -82,7 +81,7 @@ function App() {
|
|||||||
const hasCriticalEvents = recentCriticalEvents.length > 0;
|
const hasCriticalEvents = recentCriticalEvents.length > 0;
|
||||||
|
|
||||||
const fetchChatProfiles = useCallback(async () => {
|
const fetchChatProfiles = useCallback(async () => {
|
||||||
const { data } = await supabase.from(tableNameProfiles).select('*').order('created_at', { ascending: false });
|
const data = await fetchTradeProfiles();
|
||||||
setChatProfiles(data || []);
|
setChatProfiles(data || []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -109,8 +108,9 @@ function App() {
|
|||||||
console.log('[ChatApply] Action:', action, 'user_id:', currentUserId, 'Payload:', payload);
|
console.log('[ChatApply] Action:', action, 'user_id:', currentUserId, 'Payload:', payload);
|
||||||
|
|
||||||
if (action === 'create_profile') {
|
if (action === 'create_profile') {
|
||||||
const { error } = await supabase.from(tableNameProfiles).insert([payload]);
|
try {
|
||||||
if (error) {
|
await createTradeProfile(payload);
|
||||||
|
} catch (error: any) {
|
||||||
console.error('[ChatApply] Insert error:', error);
|
console.error('[ChatApply] Insert error:', error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
@ -119,8 +119,9 @@ function App() {
|
|||||||
window.dispatchEvent(new Event('profiles-updated'));
|
window.dispatchEvent(new Event('profiles-updated'));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} else if (action === 'update_profile' && profileData.id) {
|
} else if (action === 'update_profile' && profileData.id) {
|
||||||
const { error } = await supabase.from(tableNameProfiles).update(payload).eq('id', profileData.id);
|
try {
|
||||||
if (error) {
|
await updateTradeProfile(profileData.id, payload);
|
||||||
|
} catch (error: any) {
|
||||||
console.error('[ChatApply] Update error:', error);
|
console.error('[ChatApply] Update error:', error);
|
||||||
return { success: false, error: error.message };
|
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 { render, screen, waitFor } from '@testing-library/react';
|
||||||
// import userEvent from '@testing-library/user-event';
|
// import userEvent from '@testing-library/user-event';
|
||||||
import { AuthProvider, useAuth } from './AuthContext';
|
import { AuthProvider, useAuth } from './AuthContext';
|
||||||
import { tableNameProfiles, tableNameUsers } from '../lib/const';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getSessionMock,
|
getSessionMock,
|
||||||
signOutMock,
|
signOutMock,
|
||||||
fromMock,
|
tradingAuthState,
|
||||||
usersSingleMock,
|
fetchCurrentUserProfileMock,
|
||||||
profilesLimitMock,
|
fetchTradeProfilesMock
|
||||||
profilesInsertMock,
|
|
||||||
unsubscribeMock,
|
|
||||||
tradingAuthState
|
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
getSessionMock: vi.fn(),
|
getSessionMock: vi.fn(),
|
||||||
signOutMock: vi.fn(),
|
signOutMock: vi.fn(),
|
||||||
fromMock: vi.fn(),
|
|
||||||
usersSingleMock: vi.fn(),
|
|
||||||
profilesLimitMock: vi.fn(),
|
|
||||||
profilesInsertMock: vi.fn(),
|
|
||||||
unsubscribeMock: vi.fn(),
|
|
||||||
tradingAuthState: {
|
tradingAuthState: {
|
||||||
user: { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' } as any,
|
user: { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' } as any,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
logout: vi.fn()
|
logout: vi.fn()
|
||||||
}
|
},
|
||||||
|
fetchCurrentUserProfileMock: vi.fn(),
|
||||||
|
fetchTradeProfilesMock: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/tradingAuth', () => ({
|
vi.mock('../lib/tradingAuth', () => ({
|
||||||
@ -39,11 +32,15 @@ vi.mock('../lib/supabaseClient', () => ({
|
|||||||
auth: {
|
auth: {
|
||||||
getSession: getSessionMock,
|
getSession: getSessionMock,
|
||||||
signOut: signOutMock
|
signOut: signOutMock
|
||||||
},
|
}
|
||||||
from: fromMock
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/profileApi', () => ({
|
||||||
|
fetchCurrentUserProfile: fetchCurrentUserProfileMock,
|
||||||
|
fetchTradeProfiles: fetchTradeProfilesMock
|
||||||
|
}));
|
||||||
|
|
||||||
const Probe = () => {
|
const Probe = () => {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
return (
|
return (
|
||||||
@ -61,56 +58,22 @@ describe('AuthContext DOM behavior', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getSessionMock.mockReset();
|
getSessionMock.mockReset();
|
||||||
signOutMock.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.user = { id: 'user-1', email: 'sarah@example.com', role: 'admin', name: 'Sarah Algo' };
|
||||||
tradingAuthState.isLoading = false;
|
tradingAuthState.isLoading = false;
|
||||||
tradingAuthState.logout.mockReset();
|
tradingAuthState.logout.mockReset();
|
||||||
|
fetchCurrentUserProfileMock.mockReset();
|
||||||
|
fetchTradeProfilesMock.mockReset();
|
||||||
|
|
||||||
signOutMock.mockResolvedValue({ error: null });
|
signOutMock.mockResolvedValue({ error: null });
|
||||||
usersSingleMock.mockResolvedValue({
|
fetchCurrentUserProfileMock.mockResolvedValue({
|
||||||
data: {
|
user_id: 'user-1',
|
||||||
user_id: 'user-1',
|
first_name: 'Sarah',
|
||||||
first_name: 'Sarah',
|
last_name: 'Algo',
|
||||||
last_name: 'Algo',
|
email: 'sarah@example.com',
|
||||||
email: 'sarah@example.com',
|
role: 'admin',
|
||||||
role: 'admin',
|
trade_enable: true
|
||||||
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()
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads session/profile, ensures default profile, and cleans up subscription', async () => {
|
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(screen.getByTestId('role')).toHaveTextContent('admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(profilesInsertMock).toHaveBeenCalledTimes(1);
|
expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ ensureDefault: true });
|
||||||
expect(profilesInsertMock.mock.calls[0][0]).toEqual([
|
|
||||||
expect.objectContaining({ user_id: 'user-1', name: 'My First Strategy' })
|
|
||||||
]);
|
|
||||||
expect(dispatchSpy).toHaveBeenCalled();
|
expect(dispatchSpy).toHaveBeenCalled();
|
||||||
expect((dispatchSpy.mock.calls[0][0] as Event).type).toBe('profiles-updated');
|
expect((dispatchSpy.mock.calls[0][0] as Event).type).toBe('profiles-updated');
|
||||||
}, 20000);
|
}, 20000);
|
||||||
@ -148,7 +108,7 @@ describe('AuthContext DOM behavior', () => {
|
|||||||
expect(screen.getByTestId('loading')).toHaveTextContent('false');
|
expect(screen.getByTestId('loading')).toHaveTextContent('false');
|
||||||
expect(screen.getByTestId('user')).toHaveTextContent('none');
|
expect(screen.getByTestId('user')).toHaveTextContent('none');
|
||||||
});
|
});
|
||||||
expect(usersSingleMock).not.toHaveBeenCalled();
|
expect(fetchCurrentUserProfileMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles auth state changes with no session', async () => {
|
it('handles auth state changes with no session', async () => {
|
||||||
@ -172,12 +132,12 @@ describe('AuthContext DOM behavior', () => {
|
|||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||||
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
|
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
|
||||||
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
|
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>);
|
render(<AuthProvider><Probe /></AuthProvider>);
|
||||||
|
|
||||||
await waitFor(() => {
|
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();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@ -186,7 +146,7 @@ describe('AuthContext DOM behavior', () => {
|
|||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||||
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
|
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
|
||||||
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
|
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
|
||||||
usersSingleMock.mockImplementation(() => { throw new Error('Crashed'); });
|
fetchCurrentUserProfileMock.mockImplementation(() => { throw new Error('Crashed'); });
|
||||||
|
|
||||||
render(<AuthProvider><Probe /></AuthProvider>);
|
render(<AuthProvider><Probe /></AuthProvider>);
|
||||||
|
|
||||||
@ -200,7 +160,7 @@ describe('AuthContext DOM behavior', () => {
|
|||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
||||||
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
|
tradingAuthState.user = { id: 'u1', email: 'u1@example.com', role: 'member', name: 'U One' };
|
||||||
getSessionMock.mockResolvedValue({ data: { session: { user: { id: 'u1' } } } });
|
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>);
|
render(<AuthProvider><Probe /></AuthProvider>);
|
||||||
|
|
||||||
|
|||||||
@ -18,25 +18,28 @@ vi.mock('../lib/tradingAuth', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/supabaseClient', () => {
|
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 {
|
return {
|
||||||
supabase: {
|
supabase: {
|
||||||
auth: {
|
auth: {
|
||||||
getSession: vi.fn(async () => ({ data: { session: null } })),
|
getSession: vi.fn(async () => ({ data: { session: null } })),
|
||||||
signOut: vi.fn(async () => ({ error: 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', () => {
|
describe('AuthContext', () => {
|
||||||
it('provides auth state through AuthProvider', () => {
|
it('provides auth state through AuthProvider', () => {
|
||||||
const Probe = () => {
|
const Probe = () => {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
import type { User, Session } from '@supabase/supabase-js';
|
||||||
import { supabase } from '../lib/supabaseClient';
|
import { supabase } from '../lib/supabaseClient';
|
||||||
import { tableNameUsers, tableNameProfiles } from '../lib/const';
|
|
||||||
import { TradingAuthProvider, useTradingAuth } from '../lib/tradingAuth';
|
import { TradingAuthProvider, useTradingAuth } from '../lib/tradingAuth';
|
||||||
|
import { fetchCurrentUserProfile, fetchTradeProfiles } from '../lib/profileApi';
|
||||||
|
|
||||||
// Define the shape of our extended user profile
|
// Define the shape of our extended user profile
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
@ -118,23 +118,11 @@ function AuthBridge({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [tradingAuth.user?.id]);
|
}, [tradingAuth.user?.id]);
|
||||||
|
|
||||||
const fetchProfile = async (userId: string, authUserOverride?: User | null) => {
|
const fetchProfile = async (_userId: string, authUserOverride?: User | null) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const currentProfile = await fetchCurrentUserProfile();
|
||||||
.from(tableNameUsers)
|
setProfile(currentProfile as UserProfile);
|
||||||
.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')
|
await ensureDefaultProfile();
|
||||||
.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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Unexpected error fetching profile:', err);
|
console.error('Unexpected error fetching profile:', err);
|
||||||
setProfile(buildFallbackProfile(authUserOverride ?? user));
|
setProfile(buildFallbackProfile(authUserOverride ?? user));
|
||||||
@ -143,17 +131,12 @@ function AuthBridge({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureDefaultProfile = async (userId: string) => {
|
const ensureDefaultProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await supabase
|
const profiles = await fetchTradeProfiles({ ensureDefault: true });
|
||||||
.from(tableNameProfiles)
|
if (shouldCreateDefaultProfile(profiles)) {
|
||||||
.select('id')
|
console.log('[Auth] No profiles found after bootstrap ensureDefault call');
|
||||||
.eq('user_id', userId)
|
} else {
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (shouldCreateDefaultProfile(data)) {
|
|
||||||
console.log('[Auth] No profiles found - creating default profile for new user');
|
|
||||||
await supabase.from(tableNameProfiles).insert([buildDefaultProfilePayload(userId)]);
|
|
||||||
window.dispatchEvent(new Event('profiles-updated'));
|
window.dispatchEvent(new Event('profiles-updated'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -14,14 +14,13 @@ import {
|
|||||||
Target,
|
Target,
|
||||||
Lock
|
Lock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates';
|
import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates';
|
||||||
import type { RiskStyleTemplate } from '../lib/RiskStyleTemplates';
|
import type { RiskStyleTemplate } from '../lib/RiskStyleTemplates';
|
||||||
import { getUserTier, TIER_POLICIES, isFeatureAllowed } from '../lib/TierPolicy';
|
import { getUserTier, TIER_POLICIES, isFeatureAllowed } from '../lib/TierPolicy';
|
||||||
import { supabase } from '../lib/supabaseClient';
|
|
||||||
import { tableNameProfiles } from '../lib/const';
|
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
|
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
|
||||||
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
|
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
|
||||||
|
import { createTradeProfile, updateTradeProfile } from '../lib/profileApi';
|
||||||
|
|
||||||
interface WizardState {
|
interface WizardState {
|
||||||
step: number;
|
step: number;
|
||||||
@ -132,12 +131,12 @@ export const StrategyWizard: React.FC<{
|
|||||||
strategy_config
|
strategy_config
|
||||||
};
|
};
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
if (editingProfile) {
|
if (editingProfile) {
|
||||||
result = await supabase.from(tableNameProfiles).update(payload).eq('id', editingProfile.id);
|
result = await updateTradeProfile(editingProfile.id, payload).then(() => ({ error: null as any })).catch((error) => ({ error }));
|
||||||
} else {
|
} else {
|
||||||
result = await supabase.from(tableNameProfiles).insert([payload]);
|
result = await createTradeProfile(payload).then(() => ({ error: null as any })).catch((error) => ({ error }));
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { supabase } from '../lib/supabaseClient';
|
import { useAuth } from '../components/AuthContext';
|
||||||
import { tableNameProfiles } from '../lib/const';
|
import { getStrategyExplanation } from '../lib/StrategyExplanationService';
|
||||||
import { useAuth } from '../components/AuthContext';
|
import { getUserTier } from '../lib/TierPolicy';
|
||||||
import { getStrategyExplanation } from '../lib/StrategyExplanationService';
|
|
||||||
import { getUserTier } from '../lib/TierPolicy';
|
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
@ -28,6 +26,7 @@ import {
|
|||||||
import { StrategyWizard } from '../components/StrategyWizard';
|
import { StrategyWizard } from '../components/StrategyWizard';
|
||||||
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
|
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
|
||||||
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
|
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
|
||||||
|
import { deleteTradeProfile, fetchTradeProfiles, setTradeProfileActive } from '../lib/profileApi';
|
||||||
|
|
||||||
const ActiveStrategyCard: React.FC<{
|
const ActiveStrategyCard: React.FC<{
|
||||||
profile: any;
|
profile: any;
|
||||||
@ -260,17 +259,13 @@ export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewA
|
|||||||
const tier = getUserTier(userProfile);
|
const tier = getUserTier(userProfile);
|
||||||
const { enabled: backtestEnabled, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
|
const { enabled: backtestEnabled, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
|
||||||
|
|
||||||
const fetchProfiles = async () => {
|
const fetchProfiles = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const { data } = await supabase
|
const data = await fetchTradeProfiles();
|
||||||
.from(tableNameProfiles)
|
setProfiles(data || []);
|
||||||
.select('*')
|
setIsLoading(false);
|
||||||
.eq('user_id', user.id)
|
};
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
setProfiles(data || []);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProfiles();
|
fetchProfiles();
|
||||||
@ -278,22 +273,24 @@ export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewA
|
|||||||
return () => window.removeEventListener('profiles-updated', fetchProfiles);
|
return () => window.removeEventListener('profiles-updated', fetchProfiles);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const toggleBot = async (profile: any) => {
|
const toggleBot = async (profile: any) => {
|
||||||
const { error } = await supabase
|
try {
|
||||||
.from(tableNameProfiles)
|
await setTradeProfileActive(profile.id, !profile.is_active);
|
||||||
.update({ is_active: !profile.is_active })
|
fetchProfiles();
|
||||||
.eq('id', profile.id);
|
} catch {
|
||||||
if (!error) fetchProfiles();
|
// existing UI remains silent on toggle failure
|
||||||
};
|
}
|
||||||
|
};
|
||||||
const deleteBot = async (id: string) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this strategy?')) return;
|
const deleteBot = async (id: string) => {
|
||||||
const { error } = await supabase
|
if (!confirm('Are you sure you want to delete this strategy?')) return;
|
||||||
.from(tableNameProfiles)
|
try {
|
||||||
.delete()
|
await deleteTradeProfile(id);
|
||||||
.eq('id', id);
|
fetchProfiles();
|
||||||
if (!error) fetchProfiles();
|
} catch {
|
||||||
};
|
// existing UI remains silent on delete failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (showWizard) {
|
if (showWizard) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user