From 50defe18908236d95638a793b4adc1901b090396 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 16:12:41 -0700 Subject: [PATCH] refactor: centralize backend profile metadata lookups --- backend/src/services/CapitalLedger.ts | 3 +- backend/src/services/ManualTrader.ts | 3 +- backend/src/services/apiServer.ts | 23 ++-- backend/src/services/profileRepository.ts | 101 ++++++++++++++++++ .../reconciliationParityHeartbeatService.ts | 3 +- 5 files changed, 121 insertions(+), 12 deletions(-) diff --git a/backend/src/services/CapitalLedger.ts b/backend/src/services/CapitalLedger.ts index ba36190..b713545 100644 --- a/backend/src/services/CapitalLedger.ts +++ b/backend/src/services/CapitalLedger.ts @@ -1,6 +1,7 @@ import logger from '../utils/logger.js'; import { config } from '../config/index.js'; import { supabaseService } from './SupabaseService.js'; +import { getTradeProfileCapital } from './profileRepository.js'; export interface CapitalLedgerRecord { profile_id: string; @@ -39,7 +40,7 @@ export class CapitalLedger { logger.error(`[CapitalLedger] ensureLedger aborted for ${profileId}: Supabase client unavailable (fail-closed).`); return null; } - const profileCapital = await supabaseService.getProfileCapital(profileId); + const profileCapital = await getTradeProfileCapital(profileId, supabaseService); const allocation = toNumeric(allocatedCapital ?? profileCapital?.allocatedCapital ?? config.TOTAL_CAPITAL); const { data, error } = await client diff --git a/backend/src/services/ManualTrader.ts b/backend/src/services/ManualTrader.ts index fac190a..af7eff4 100644 --- a/backend/src/services/ManualTrader.ts +++ b/backend/src/services/ManualTrader.ts @@ -3,6 +3,7 @@ import { SignalDirection } from '../strategies/rules/types.js'; import logger from '../utils/logger.js'; import { supabaseService } from './SupabaseService.js'; import { config } from '../config/index.js'; +import { getTradeProfileCapital } from './profileRepository.js'; const CAPITAL_WAIT_TIMEOUT_MS = 60_000; const CAPITAL_WAIT_POLL_MS = 3_000; @@ -33,7 +34,7 @@ export class ManualTrader { return Number(config.TOTAL_CAPITAL || 0); } - const profileCapital = await supabaseService.getProfileCapital(profileId); + const profileCapital = await getTradeProfileCapital(profileId, supabaseService); if (profileCapital?.allocatedCapital && profileCapital.allocatedCapital > 0) { return profileCapital.allocatedCapital; } diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 4632b0a..33e895e 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -18,7 +18,9 @@ import { deleteTradeProfileForUser, ensureDefaultTradeProfileForUser, getCurrentUserProfile, + getTradeProfileForUser, listAllTradeProfiles, + listActiveTradeProfiles, listTradeProfilesForUser, saveCurrentUserProfile, saveTradeProfileForUser, @@ -1252,13 +1254,14 @@ export class ApiServer { }); this.app.get('/api/state', this.requireAuth, async (req, res) => { - const authUserId = (req as AuthenticatedRequest).authUserId; + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } this.state.uptime = Date.now() - this.startTime; - const isAdmin = await supabaseService.isAdmin(authUserId); + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const scopedState = this.getScopedState(authUserId, isAdmin); res.json({ ...scopedState, @@ -1267,14 +1270,15 @@ export class ApiServer { }); this.app.get('/api/lifecycle/canonical', this.requireAuth, async (req, res) => { - const authUserId = (req as AuthenticatedRequest).authUserId; + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { - const isAdmin = await supabaseService.isAdmin(authUserId); + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const requestedProfileId = String(req.query.profileId || '').trim(); const splitByProfileQuery = String(req.query.splitByProfile || '').trim().toLowerCase(); const splitByProfile = splitByProfileQuery @@ -1288,8 +1292,8 @@ export class ApiServer { ); const profileRows = isAdmin - ? await supabaseService.getAllProfiles() - : await supabaseService.getProfilesForUser(authUserId); + ? await listAllTradeProfiles(supabaseService) + : await listTradeProfilesForUser(authUserId, supabaseService); if (requestedProfileId && !profileRows.some((row) => row.id === requestedProfileId)) { res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to scoped user context' }); @@ -1889,7 +1893,8 @@ export class ApiServer { }); this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => { - const authUserId = (req as AuthenticatedRequest).authUserId; + const authReq = req as AuthenticatedRequest; + const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; @@ -1902,7 +1907,7 @@ export class ApiServer { return; } - const isAdmin = await supabaseService.isAdmin(authUserId); + const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); if (!isAdmin && !config.BACKTEST_CUSTOMER_ENABLED) { res.status(403).json({ success: false, @@ -1916,7 +1921,7 @@ export class ApiServer { const profileId = String(body.profileId || '').trim(); let profileSettings: any = undefined; if (profileId) { - profileSettings = await supabaseService.getProfileForBacktest(profileId, authUserId); + profileSettings = await getTradeProfileForUser(profileId, authUserId, supabaseService); if (!profileSettings) { res.status(404).json({ success: false, error: 'Backtest profile not found for current user' }); return; diff --git a/backend/src/services/profileRepository.ts b/backend/src/services/profileRepository.ts index 9fc6460..f1cea74 100644 --- a/backend/src/services/profileRepository.ts +++ b/backend/src/services/profileRepository.ts @@ -35,6 +35,12 @@ export interface TradeProfileRecord { updated_at?: string; } +export interface TradeProfileCapitalSummary { + allocatedCapital: number; + isActive: boolean; + userId?: string; +} + interface TradeProfileDocument extends TradeProfileRecord { productId: string; type: 'trade_profile'; @@ -197,6 +203,57 @@ async function listAllProfilesFromSupabase(legacyService?: LegacySupabaseService } } +async function getProfileFromCosmos(profileId: string): Promise { + if (!isCosmosConfigured() || !profileId) { + return null; + } + + const container = getContainer(PROFILE_CONTAINER); + const { resources } = await container.items.query({ + query: 'SELECT TOP 1 * FROM c WHERE c.productId = @productId AND c.type = @type AND c.id = @id', + parameters: [ + { name: '@productId', value: config.PRODUCT_ID }, + { name: '@type', value: 'trade_profile' }, + { name: '@id', value: profileId }, + ], + }).fetchAll(); + + const resource = resources[0]; + if (!resource) { + return null; + } + + return normalizeProfile({ + ...resource, + created_at: resource.createdAt, + updated_at: resource.updatedAt, + }); +} + +async function getProfileFromSupabase(profileId: string, legacyService?: LegacySupabaseService): Promise { + const client = legacyService?.getClient?.(); + if (!client || !profileId) { + return null; + } + + 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('id', profileId) + .maybeSingle(); + + if (error || !data) { + return null; + } + + return normalizeProfile(data as TradeProfileRecord); + } catch (error) { + logger.warn(`[Profiles] Legacy profile lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`); + return null; + } +} + async function mirrorProfileToSupabase(profile: TradeProfileRecord, legacyService?: LegacySupabaseService): Promise { const client = legacyService?.getClient?.(); if (!client) return; @@ -263,6 +320,50 @@ export async function listAllTradeProfiles(legacyService?: LegacySupabaseService return listAllProfilesFromSupabase(legacyService); } +export async function listActiveTradeProfiles(legacyService?: LegacySupabaseService): Promise { + const profiles = await listAllTradeProfiles(legacyService); + return profiles.filter((profile) => Boolean(profile.is_active)); +} + +export async function getTradeProfileById(profileId: string, legacyService?: LegacySupabaseService): Promise { + const normalizedId = String(profileId || '').trim(); + if (!normalizedId) { + return null; + } + + try { + const cosmosProfile = await getProfileFromCosmos(normalizedId); + if (cosmosProfile) { + return cosmosProfile; + } + } catch (error) { + logger.warn(`[Profiles] Cosmos profile lookup failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`); + } + + return getProfileFromSupabase(normalizedId, legacyService); +} + +export async function getTradeProfileCapital(profileId: string, legacyService?: LegacySupabaseService): Promise { + const profile = await getTradeProfileById(profileId, legacyService); + if (!profile) { + return null; + } + + return { + allocatedCapital: Number(profile.allocated_capital || 0), + isActive: Boolean(profile.is_active), + userId: profile.user_id || undefined, + }; +} + +export async function getTradeProfileForUser(profileId: string, userId: string, legacyService?: LegacySupabaseService): Promise { + const profile = await getTradeProfileById(profileId, legacyService); + if (!profile || String(profile.user_id || '').trim() !== String(userId || '').trim()) { + return null; + } + return profile; +} + export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise { const profiles = await listTradeProfilesForUser(userId, legacyService); if (profiles.length > 0) { diff --git a/backend/src/services/reconciliationParityHeartbeatService.ts b/backend/src/services/reconciliationParityHeartbeatService.ts index c872dce..9790b1d 100644 --- a/backend/src/services/reconciliationParityHeartbeatService.ts +++ b/backend/src/services/reconciliationParityHeartbeatService.ts @@ -4,6 +4,7 @@ import logger from '../utils/logger.js'; import { healthTracker } from './healthTracker.js'; import { observabilityService } from './observabilityService.js'; import { supabaseService } from './SupabaseService.js'; +import { getTradeProfileCapital } from './profileRepository.js'; import type { TradeExecutor } from './TradeExecutor.js'; import { buildAlpacaSubTag } from '../utils/alpacaSubTag.js'; import { normalizeBotSymbolToken } from '../utils/botSymbolScope.js'; @@ -275,7 +276,7 @@ export class ReconciliationParityHeartbeatService { const throttleMs = Math.max(0, toNumber(config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS || 600_000)); const requireSubTagAttribution = Boolean(config.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION); const allowLegacyEntryAttribution = Boolean(config.RECON_POSITION_PARITY_ALLOW_LEGACY_ENTRY_ATTRIBUTION); - const profileCapital = await supabaseService.getProfileCapital(profileId); + const profileCapital = await getTradeProfileCapital(profileId, supabaseService); const allocatedCapitalUsd = Math.max(0, toNumber(profileCapital?.allocatedCapital)); let mismatchTrades = 0;