diff --git a/backend/src/services/profileRepository.ts b/backend/src/services/profileRepository.ts index 0fb6ecb..c5029ab 100644 --- a/backend/src/services/profileRepository.ts +++ b/backend/src/services/profileRepository.ts @@ -49,6 +49,15 @@ interface TradeProfileDocument extends TradeProfileRecord { } const PROFILE_CONTAINER = 'trade_profiles'; +const USER_PROFILE_CONTAINER = 'trading_users'; + +interface TradingUserProfileDocument extends TradingUserProfile { + id: string; + productId: string; + type: 'trading_user'; + createdAt: string; + updatedAt: string; +} function isCosmosConfigured(): boolean { return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); @@ -105,6 +114,68 @@ function buildDefaultTradeProfile(userId: string): TradeProfileRecord { }; } +function normalizeTradingUserProfile( + row: Partial | null | undefined, + fallbackUserId?: string +): TradingUserProfile | null { + const userId = String(row?.user_id || fallbackUserId || '').trim(); + if (!userId) { + return null; + } + + return { + user_id: userId, + first_name: String(row?.first_name || ''), + last_name: String(row?.last_name || ''), + email: String(row?.email || ''), + role: String(row?.role || 'member'), + trade_enable: Boolean(row?.trade_enable ?? true), + ALPACA_API_KEY: row?.ALPACA_API_KEY, + ALPACA_SECRET_KEY: row?.ALPACA_SECRET_KEY, + REAL_ALPACA_API_KEY: row?.REAL_ALPACA_API_KEY, + REAL_ALPACA_SECRET_KEY: row?.REAL_ALPACA_SECRET_KEY, + drop_threshold_for_buy: Number(row?.drop_threshold_for_buy ?? 0), + gain_threshold_for_sell: Number(row?.gain_threshold_for_sell ?? 0), + market_poll_interval_in_seconds: Number(row?.market_poll_interval_in_seconds ?? 0), + }; +} + +function toTradingUserProfileDocument(profile: TradingUserProfile): TradingUserProfileDocument { + const now = new Date().toISOString(); + return { + ...profile, + id: profile.user_id, + productId: config.PRODUCT_ID, + type: 'trading_user', + createdAt: now, + updatedAt: now, + }; +} + +async function getTradingUserProfileFromCosmos(userId: string): Promise { + if (!isCosmosConfigured() || !userId) { + return null; + } + + const container = getContainer(USER_PROFILE_CONTAINER); + const { resources } = await container.items.query({ + query: 'SELECT TOP 1 * FROM c WHERE c.productId = @productId AND c.type = @type AND c.user_id = @userId', + parameters: [ + { name: '@productId', value: config.PRODUCT_ID }, + { name: '@type', value: 'trading_user' }, + { name: '@userId', value: userId }, + ], + }).fetchAll(); + + return normalizeTradingUserProfile(resources[0], userId); +} + +async function upsertTradingUserProfileToCosmos(profile: TradingUserProfile): Promise { + if (!isCosmosConfigured()) return; + const container = getContainer(USER_PROFILE_CONTAINER); + await container.items.upsert(toTradingUserProfileDocument(profile)); +} + async function listProfilesFromCosmos(userId: string): Promise { if (!isCosmosConfigured()) { return []; @@ -479,6 +550,17 @@ export async function getCurrentUserProfile( fallback: Partial = {}, legacyService?: LegacySupabaseService ): Promise { + if (isCosmosConfigured()) { + try { + const cosmosProfile = await getTradingUserProfileFromCosmos(userId); + if (cosmosProfile) { + return cosmosProfile; + } + } catch (error) { + logger.warn(`[Profiles] Cosmos user profile read failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + const client = legacyService?.getClient?.(); if (client) { try { @@ -489,7 +571,7 @@ export async function getCurrentUserProfile( .maybeSingle(); if (!error && data) { - return { + const normalized = { 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 || ''), @@ -504,6 +586,8 @@ export async function getCurrentUserProfile( 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), }; + await upsertTradingUserProfileToCosmos(normalized); + return normalized; } } catch (error) { logger.warn(`[Profiles] Legacy user profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`); @@ -548,6 +632,12 @@ export async function saveCurrentUserProfile( market_poll_interval_in_seconds: Number(input.market_poll_interval_in_seconds ?? existing.market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0), }; + try { + await upsertTradingUserProfileToCosmos(merged); + } catch (error) { + logger.warn(`[Profiles] Cosmos user profile save failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } + const client = legacyService?.getClient?.(); if (client) { try { diff --git a/backend/src/services/strategyPresetRepository.ts b/backend/src/services/strategyPresetRepository.ts index 594d745..4214695 100644 --- a/backend/src/services/strategyPresetRepository.ts +++ b/backend/src/services/strategyPresetRepository.ts @@ -1,13 +1,48 @@ +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; +const PRESET_CONTAINER = 'strategy_presets'; + +interface StrategyPresetDocument { + id: string; + productId: string; + type: 'strategy_preset'; + created_at?: string; + [key: string]: unknown; +} + +function isCosmosConfigured(): boolean { + return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); +} function getClient(legacyService?: LegacySupabaseService) { return legacyService?.getClient?.() ?? null; } export async function listStrategyPresets(legacyService?: LegacySupabaseService): Promise { + if (isCosmosConfigured()) { + try { + const container = getContainer(PRESET_CONTAINER); + const { resources } = await container.items.query({ + query: 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.created_at DESC', + parameters: [ + { name: '@productId', value: config.PRODUCT_ID }, + { name: '@type', value: 'strategy_preset' }, + ], + }).fetchAll(); + if (resources.length > 0) { + return resources; + } + } catch (error: any) { + logger.error(`[StrategyPresetRepo] Cosmos preset lookup failed: ${error.message}`); + return []; + } + } + const client = getClient(legacyService); if (!client) return []; @@ -22,7 +57,21 @@ export async function listStrategyPresets(legacyService?: LegacySupabaseService) return []; } - return data || []; + const rows = data || []; + if (isCosmosConfigured() && rows.length > 0) { + try { + const container = getContainer(PRESET_CONTAINER); + await Promise.all(rows.map((row: any) => container.items.upsert({ + id: String(row.id || row.original_profile_id || randomUUID()), + productId: config.PRODUCT_ID, + type: 'strategy_preset', + ...row, + }))); + } catch (seedError: any) { + logger.warn(`[StrategyPresetRepo] Cosmos preset seed failed: ${seedError.message}`); + } + } + return rows; } catch (error: any) { logger.error(`[StrategyPresetRepo] Preset lookup unexpected error: ${error.message}`); return []; @@ -30,9 +79,20 @@ export async function listStrategyPresets(legacyService?: LegacySupabaseService) } export async function createStrategyPreset(payload: Record, legacyService?: LegacySupabaseService): Promise { + if (isCosmosConfigured()) { + const container = getContainer(PRESET_CONTAINER); + await container.items.upsert({ + id: String(payload.id || payload['original_profile_id'] || randomUUID()), + productId: config.PRODUCT_ID, + type: 'strategy_preset', + created_at: String(payload['created_at'] || new Date().toISOString()), + ...payload, + }); + } + const client = getClient(legacyService); if (!client) { - throw new Error('Strategy preset store is unavailable'); + return; } const { error } = await client.from('strategy_presets').insert([payload]); diff --git a/backend/src/services/userRepository.ts b/backend/src/services/userRepository.ts index 98a3ac9..8fbeecc 100644 --- a/backend/src/services/userRepository.ts +++ b/backend/src/services/userRepository.ts @@ -1,7 +1,20 @@ +import { getContainer } from '@bytelyst/cosmos'; +import { config } from '../config/index.js'; import logger from '../utils/logger.js'; import type { UserConfig, supabaseService } from './SupabaseService.js'; type LegacySupabaseService = typeof supabaseService; +const USER_PROFILE_CONTAINER = 'trading_users'; + +interface TradingUserDocument extends UserConfig { + id: string; + productId: string; + type: 'trading_user'; +} + +function isCosmosConfigured(): boolean { + return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); +} function normalizeUser(row: Partial | null | undefined): UserConfig | null { const userId = String(row?.user_id || '').trim(); @@ -27,6 +40,29 @@ function normalizeUser(row: Partial | null | undefined): UserConfig } export async function listActiveTradingUsers(legacyService?: LegacySupabaseService): Promise { + if (isCosmosConfigured()) { + try { + const container = getContainer(USER_PROFILE_CONTAINER); + const { resources } = await container.items.query({ + query: 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type AND c.trade_enable = true', + parameters: [ + { name: '@productId', value: config.PRODUCT_ID }, + { name: '@type', value: 'trading_user' }, + ], + }).fetchAll(); + + const normalized = resources + .map((row) => normalizeUser(row as UserConfig)) + .filter((user): user is UserConfig => Boolean(user)); + if (normalized.length > 0) { + return normalized; + } + } catch (error) { + logger.warn(`[Users] Cosmos active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`); + return []; + } + } + const client = legacyService?.getClient?.(); if (!client) { return []; @@ -42,9 +78,23 @@ export async function listActiveTradingUsers(legacyService?: LegacySupabaseServi return []; } - return data + const normalized = data .map((row) => normalizeUser(row as UserConfig)) .filter((user): user is UserConfig => Boolean(user)); + if (isCosmosConfigured() && normalized.length > 0) { + try { + const container = getContainer(USER_PROFILE_CONTAINER); + await Promise.all(normalized.map((user) => container.items.upsert({ + ...user, + id: user.user_id, + productId: config.PRODUCT_ID, + type: 'trading_user', + } as TradingUserDocument))); + } catch (error) { + logger.warn(`[Users] Cosmos user seed failed: ${error instanceof Error ? error.message : 'unknown error'}`); + } + } + return normalized; } catch (error) { logger.warn(`[Users] Active trading user lookup failed: ${error instanceof Error ? error.message : 'unknown error'}`); return []; diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 7cce14d..1f7f773 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -81,6 +81,7 @@ Rule: - platform-service and Cosmos are the target system - Supabase remains transitional only where trading persistence has not yet been migrated +- trading user profiles, dynamic config, trading controls, snapshots, capital ledgers, and strategy presets now have Cosmos-backed authority paths ## Verification Standard diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce9d1a0..a0a8d7d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -41,6 +41,7 @@ It assumes: - [x] Release smoke coverage now exists for web auth and product accessibility flows, with a tracked mobile release smoke checklist in operations - [x] Request ID propagation is now standardized across the main web/mobile API paths and echoed by backend HTTP responses - [x] Backtest feature access now reads from an explicit backend feature-flags contract instead of scraping generic runtime config +- [x] Trading user profiles and marketplace presets now have Cosmos-backed authority paths with legacy seeding/mirroring during migration - [x] Root verification and lint flows now run successfully without sandbox-hostile script harness behavior - [-] DRY cleanup completed for runtime/config/bootstrap concerns, shared websocket auth helpers, platform-session handling, and request tracing, but not yet for all persistence and feature-flag concerns - [!] Full common-platform data-plane replacement remains a follow-up where selected trading-record repositories still depend on legacy Supabase storage because Cosmos-native equivalents are not finished yet