refactor: move user metadata and presets onto cosmos paths
This commit is contained in:
parent
0baf32bfcf
commit
e043f3c79d
@ -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<TradingUserProfile> | 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<TradingUserProfile | null> {
|
||||
if (!isCosmosConfigured() || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = getContainer(USER_PROFILE_CONTAINER);
|
||||
const { resources } = await container.items.query<TradingUserProfileDocument>({
|
||||
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<void> {
|
||||
if (!isCosmosConfigured()) return;
|
||||
const container = getContainer(USER_PROFILE_CONTAINER);
|
||||
await container.items.upsert<TradingUserProfileDocument>(toTradingUserProfileDocument(profile));
|
||||
}
|
||||
|
||||
async function listProfilesFromCosmos(userId: string): Promise<TradeProfileRecord[]> {
|
||||
if (!isCosmosConfigured()) {
|
||||
return [];
|
||||
@ -479,6 +550,17 @@ export async function getCurrentUserProfile(
|
||||
fallback: Partial<TradingUserProfile> = {},
|
||||
legacyService?: LegacySupabaseService
|
||||
): Promise<TradingUserProfile> {
|
||||
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 {
|
||||
|
||||
@ -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<any[]> {
|
||||
if (isCosmosConfigured()) {
|
||||
try {
|
||||
const container = getContainer(PRESET_CONTAINER);
|
||||
const { resources } = await container.items.query<StrategyPresetDocument>({
|
||||
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<StrategyPresetDocument>({
|
||||
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<string, unknown>, legacyService?: LegacySupabaseService): Promise<void> {
|
||||
if (isCosmosConfigured()) {
|
||||
const container = getContainer(PRESET_CONTAINER);
|
||||
await container.items.upsert<StrategyPresetDocument>({
|
||||
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]);
|
||||
|
||||
@ -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<UserConfig> | null | undefined): UserConfig | null {
|
||||
const userId = String(row?.user_id || '').trim();
|
||||
@ -27,6 +40,29 @@ function normalizeUser(row: Partial<UserConfig> | null | undefined): UserConfig
|
||||
}
|
||||
|
||||
export async function listActiveTradingUsers(legacyService?: LegacySupabaseService): Promise<UserConfig[]> {
|
||||
if (isCosmosConfigured()) {
|
||||
try {
|
||||
const container = getContainer(USER_PROFILE_CONTAINER);
|
||||
const { resources } = await container.items.query<TradingUserDocument>({
|
||||
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 [];
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user