Replaces the build-time VITE_BACKTEST_ENABLED gate with a fully runtime
flow: a global Cosmos-backed default (already shipped in the existing
dynamicConfig system) plus a new per-user override layer. An admin can
now enable backtest for specific users without flipping the global
switch — useful for staged rollout and beta testers.
Resolution order: per-user override > global config > env fallback.
Both /api/feature-flags (FE display) and /api/backtest/run (server
guard) consult the same merge logic.
Backend (backend/src/...):
~ services/profileRepository.ts
+ TradingUserFeatureFlags interface
+ featureFlags?: TradingUserFeatureFlags on TradingUserProfile
+ setUserFeatureFlags(userId, { backtestEnabled, ... })
~ saveCurrentUserProfile() — strip role + featureFlags from input
so non-admins can't elevate via PATCH /api/me/profile
~ mergeTradingUserProfiles() — preserves explicit flag values only
~ services/apiServer.ts
~ /api/feature-flags merges per-user override into the response
+ /api/admin/users/:userId/feature-flags (GET — overrides + effective)
+ /api/admin/users/:userId/feature-flags (PATCH — admin-only writer)
~ /api/backtest/run resolves effective flags before guarding
~ backtest/index.ts
+ RunBacktestOptions.skipGlobalFeatureFlagCheck
~ runBacktest() honors the override (route already gated stricter)
Frontend (web/src/...):
~ backtest/flags.ts — isBacktestBuildEnabled() now returns true.
Kept as a no-op function so existing callers don't break.
+ lib/userFeatureFlagsApi.ts — typed admin client
+ components/admin/UserFeatureFlagsPanel.tsx
Tri-state picker per flag (Default / On / Off), Look up by user id,
Save/Reset, shows the merged "effective" value.
~ tabs/ConfigTab.tsx — mounts <UserFeatureFlagsPanel /> below the
existing global Backtest Access Control section.
~ layout-fixes.css §27 — styles for the per-user panel.
Tests:
+ testBacktestEngine: skipGlobalFeatureFlagCheck enables per-user
override semantics. 12/12 regression checks pass.
Security note: featureFlags + role are explicitly stripped from
saveCurrentUserProfile input. Only the admin-only PATCH endpoint can
set per-user overrides.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
843 lines
32 KiB
TypeScript
843 lines
32 KiB
TypeScript
import { getContainer } from '@bytelyst/cosmos';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { config } from '../config/index.js';
|
|
import logger from '../utils/logger.js';
|
|
import { getLegacySupabaseClient } from './legacySupabaseClient.js';
|
|
|
|
/**
|
|
* Per-user runtime feature flags. Each field overrides the corresponding
|
|
* global default in `config` when defined; `undefined` means "use the global".
|
|
*
|
|
* Stored on the trading_users Cosmos doc; admin-only writes via
|
|
* PATCH /api/admin/users/:id/feature-flags.
|
|
*/
|
|
export interface TradingUserFeatureFlags {
|
|
/** Override the global ENABLE_BACKTEST gate for this user. */
|
|
backtestEnabled?: boolean;
|
|
/** Override the global BACKTEST_CUSTOMER_ENABLED gate for this user. */
|
|
backtestCustomerEnabled?: boolean;
|
|
}
|
|
|
|
export interface TradingUserProfile {
|
|
user_id: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
email: string;
|
|
role: string;
|
|
trade_enable: boolean;
|
|
FMP_API_KEY?: string;
|
|
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;
|
|
featureFlags?: TradingUserFeatureFlags;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
|
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
|
|
|
export interface TradeProfileCapitalSummary {
|
|
allocatedCapital: number;
|
|
isActive: boolean;
|
|
userId?: string;
|
|
}
|
|
|
|
interface TradeProfileDocument extends TradeProfileRecord {
|
|
productId: string;
|
|
type: 'trade_profile';
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function getLegacyClient() {
|
|
return getLegacySupabaseClient();
|
|
}
|
|
|
|
function coalesceString(...values: Array<unknown>): string {
|
|
for (const value of values) {
|
|
const normalized = String(value || '').trim();
|
|
if (normalized) return normalized;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function coalesceNumber(...values: Array<unknown>): number {
|
|
for (const value of values) {
|
|
if (value === null || value === undefined || value === '') continue;
|
|
const normalized = Number(value);
|
|
if (Number.isFinite(normalized)) return normalized;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function mergeTradingUserProfiles(
|
|
primary: Partial<TradingUserProfile> | null | undefined,
|
|
fallback: Partial<TradingUserProfile> | null | undefined,
|
|
explicitUserId?: string
|
|
): TradingUserProfile | null {
|
|
const userId = coalesceString(primary?.user_id, fallback?.user_id, explicitUserId);
|
|
if (!userId) {
|
|
return null;
|
|
}
|
|
|
|
// Per-user featureFlags merge: primary wins per-key over fallback.
|
|
// We only include defined values so an `undefined` flag doesn't clobber
|
|
// the global default at the API merge layer.
|
|
const mergedFlags: TradingUserFeatureFlags = {};
|
|
const primaryFlags = primary?.featureFlags || {};
|
|
const fallbackFlags = fallback?.featureFlags || {};
|
|
if (typeof primaryFlags.backtestEnabled === 'boolean') mergedFlags.backtestEnabled = primaryFlags.backtestEnabled;
|
|
else if (typeof fallbackFlags.backtestEnabled === 'boolean') mergedFlags.backtestEnabled = fallbackFlags.backtestEnabled;
|
|
if (typeof primaryFlags.backtestCustomerEnabled === 'boolean') mergedFlags.backtestCustomerEnabled = primaryFlags.backtestCustomerEnabled;
|
|
else if (typeof fallbackFlags.backtestCustomerEnabled === 'boolean') mergedFlags.backtestCustomerEnabled = fallbackFlags.backtestCustomerEnabled;
|
|
|
|
return {
|
|
user_id: userId,
|
|
first_name: coalesceString(primary?.first_name, fallback?.first_name),
|
|
last_name: coalesceString(primary?.last_name, fallback?.last_name),
|
|
email: coalesceString(primary?.email, fallback?.email),
|
|
role: coalesceString(primary?.role, fallback?.role, 'member'),
|
|
trade_enable: Boolean(primary?.trade_enable ?? fallback?.trade_enable ?? true),
|
|
FMP_API_KEY: coalesceString(primary?.FMP_API_KEY, fallback?.FMP_API_KEY),
|
|
ALPACA_API_KEY: coalesceString(primary?.ALPACA_API_KEY, fallback?.ALPACA_API_KEY),
|
|
ALPACA_SECRET_KEY: coalesceString(primary?.ALPACA_SECRET_KEY, fallback?.ALPACA_SECRET_KEY),
|
|
REAL_ALPACA_API_KEY: coalesceString(primary?.REAL_ALPACA_API_KEY, fallback?.REAL_ALPACA_API_KEY),
|
|
REAL_ALPACA_SECRET_KEY: coalesceString(primary?.REAL_ALPACA_SECRET_KEY, fallback?.REAL_ALPACA_SECRET_KEY),
|
|
drop_threshold_for_buy: coalesceNumber(primary?.drop_threshold_for_buy, fallback?.drop_threshold_for_buy, 0),
|
|
gain_threshold_for_sell: coalesceNumber(primary?.gain_threshold_for_sell, fallback?.gain_threshold_for_sell, 0),
|
|
market_poll_interval_in_seconds: coalesceNumber(primary?.market_poll_interval_in_seconds, fallback?.market_poll_interval_in_seconds, 0),
|
|
...(Object.keys(mergedFlags).length > 0 ? { featureFlags: mergedFlags } : {}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function parseProfileSymbols(symbolsRaw?: string | null): string[] {
|
|
return String(symbolsRaw || '')
|
|
.split(',')
|
|
.map((value) => value.trim().toUpperCase())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildSimpleAutoProfile(userId: string, symbol?: string): TradeProfileRecord {
|
|
const normalizedSymbol = String(symbol || '').trim().toUpperCase();
|
|
const timestamp = new Date().toISOString();
|
|
return {
|
|
id: randomUUID(),
|
|
user_id: userId,
|
|
name: SIMPLE_AUTO_PROFILE_NAME,
|
|
allocated_capital: 1000,
|
|
risk_per_trade_percent: 1,
|
|
symbols: normalizedSymbol || 'AAPL',
|
|
is_active: true,
|
|
strategy_config: {
|
|
mode: 'simple-auto',
|
|
source: 'simple-tab',
|
|
execution: {
|
|
orderType: 'limit',
|
|
entryMode: 'manual',
|
|
},
|
|
},
|
|
created_at: timestamp,
|
|
updated_at: timestamp,
|
|
};
|
|
}
|
|
|
|
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),
|
|
FMP_API_KEY: row?.FMP_API_KEY,
|
|
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 [];
|
|
}
|
|
|
|
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 listAllProfilesFromCosmos(): 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.type = @type ORDER BY c.createdAt DESC',
|
|
parameters: [
|
|
{ name: '@productId', value: config.PRODUCT_ID },
|
|
{ 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): Promise<TradeProfileRecord[]> {
|
|
const client = getLegacyClient();
|
|
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 seedProfilesToCosmos(profiles: TradeProfileRecord[]): Promise<TradeProfileRecord[]> {
|
|
if (!isCosmosConfigured() || profiles.length === 0) {
|
|
return profiles;
|
|
}
|
|
|
|
const container = getContainer(PROFILE_CONTAINER);
|
|
await Promise.all(profiles.map((profile) => container.items.upsert<TradeProfileDocument>({
|
|
...profile,
|
|
productId: config.PRODUCT_ID,
|
|
type: 'trade_profile',
|
|
createdAt: profile.created_at || new Date().toISOString(),
|
|
updatedAt: profile.updated_at || new Date().toISOString(),
|
|
})));
|
|
return profiles;
|
|
}
|
|
|
|
async function listAllProfilesFromSupabase(): Promise<TradeProfileRecord[]> {
|
|
const client = getLegacyClient();
|
|
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')
|
|
.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 global profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getProfileFromCosmos(profileId: string): Promise<TradeProfileRecord | null> {
|
|
if (!isCosmosConfigured() || !profileId) {
|
|
return null;
|
|
}
|
|
|
|
const container = getContainer(PROFILE_CONTAINER);
|
|
const { resources } = await container.items.query<TradeProfileDocument>({
|
|
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): Promise<TradeProfileRecord | null> {
|
|
const client = getLegacyClient();
|
|
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): Promise<void> {
|
|
const client = getLegacyClient();
|
|
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): Promise<void> {
|
|
const client = getLegacyClient();
|
|
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): Promise<TradeProfileRecord[]> {
|
|
if (!isCosmosConfigured()) {
|
|
return listProfilesFromSupabase(userId);
|
|
}
|
|
|
|
try {
|
|
const cosmosProfiles = await listProfilesFromCosmos(userId);
|
|
if (cosmosProfiles.length > 0) {
|
|
return cosmosProfiles;
|
|
}
|
|
const seededProfiles = await seedProfilesToCosmos(await listProfilesFromSupabase(userId));
|
|
if (seededProfiles.length > 0) {
|
|
logger.info(`[Profiles] Seeded ${seededProfiles.length} user profiles from legacy store into Cosmos for user ${userId}.`);
|
|
}
|
|
return seededProfiles;
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Cosmos profile read/seed failed for user ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function listAllTradeProfiles(): Promise<TradeProfileRecord[]> {
|
|
if (!isCosmosConfigured()) {
|
|
return listAllProfilesFromSupabase();
|
|
}
|
|
|
|
try {
|
|
const cosmosProfiles = await listAllProfilesFromCosmos();
|
|
if (cosmosProfiles.length > 0) {
|
|
return cosmosProfiles;
|
|
}
|
|
const seededProfiles = await seedProfilesToCosmos(await listAllProfilesFromSupabase());
|
|
if (seededProfiles.length > 0) {
|
|
logger.info(`[Profiles] Seeded ${seededProfiles.length} profiles from legacy store into Cosmos.`);
|
|
}
|
|
return seededProfiles;
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Cosmos global profile read/seed failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function listActiveTradeProfiles(): Promise<TradeProfileRecord[]> {
|
|
const profiles = await listAllTradeProfiles();
|
|
return profiles.filter((profile) => Boolean(profile.is_active));
|
|
}
|
|
|
|
export async function getTradeProfileById(profileId: string): Promise<TradeProfileRecord | null> {
|
|
const normalizedId = String(profileId || '').trim();
|
|
if (!normalizedId) {
|
|
return null;
|
|
}
|
|
|
|
if (!isCosmosConfigured()) {
|
|
return getProfileFromSupabase(normalizedId);
|
|
}
|
|
|
|
try {
|
|
const cosmosProfile = await getProfileFromCosmos(normalizedId);
|
|
if (cosmosProfile) {
|
|
return cosmosProfile;
|
|
}
|
|
const legacyProfile = await getProfileFromSupabase(normalizedId);
|
|
if (!legacyProfile) {
|
|
return null;
|
|
}
|
|
await seedProfilesToCosmos([legacyProfile]);
|
|
logger.info(`[Profiles] Seeded profile ${normalizedId} from legacy store into Cosmos.`);
|
|
return legacyProfile;
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Cosmos profile lookup/seed failed for ${normalizedId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getTradeProfileCapital(profileId: string): Promise<TradeProfileCapitalSummary | null> {
|
|
const profile = await getTradeProfileById(profileId);
|
|
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): Promise<TradeProfileRecord | null> {
|
|
const profile = await getTradeProfileById(profileId);
|
|
if (!profile || String(profile.user_id || '').trim() !== String(userId || '').trim()) {
|
|
return null;
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
export async function ensureDefaultTradeProfileForUser(userId: string): Promise<TradeProfileRecord[]> {
|
|
const profiles = await listTradeProfilesForUser(userId);
|
|
if (profiles.length > 0) {
|
|
return profiles;
|
|
}
|
|
|
|
const created = await saveTradeProfileForUser(buildDefaultTradeProfile(userId), userId);
|
|
return [created];
|
|
}
|
|
|
|
export async function ensureSimpleAutoProfileForUser(userId: string, symbol?: string): Promise<TradeProfileRecord> {
|
|
const normalizedSymbol = String(symbol || '').trim().toUpperCase();
|
|
const profiles = await listTradeProfilesForUser(userId);
|
|
const existing = profiles.find((profile) => String(profile.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY);
|
|
|
|
if (!existing) {
|
|
return saveTradeProfileForUser(buildSimpleAutoProfile(userId, normalizedSymbol), userId);
|
|
}
|
|
|
|
const symbolSet = new Set(parseProfileSymbols(existing.symbols));
|
|
if (normalizedSymbol) {
|
|
symbolSet.add(normalizedSymbol);
|
|
}
|
|
const nextSymbols = Array.from(symbolSet).join(', ');
|
|
const nextIsActive = true;
|
|
const didChange = existing.symbols !== nextSymbols || !existing.is_active;
|
|
if (!didChange) {
|
|
return existing;
|
|
}
|
|
|
|
return saveTradeProfileForUser({
|
|
...existing,
|
|
symbols: nextSymbols,
|
|
is_active: nextIsActive,
|
|
}, userId);
|
|
}
|
|
|
|
export async function saveTradeProfileForUser(
|
|
input: Partial<TradeProfileRecord>,
|
|
userId: string
|
|
): 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);
|
|
return normalized;
|
|
}
|
|
|
|
export async function deleteTradeProfileForUser(
|
|
profileId: string,
|
|
userId: string
|
|
): 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);
|
|
}
|
|
|
|
export async function getCurrentUserProfile(
|
|
userId: string,
|
|
fallback: Partial<TradingUserProfile> = {}
|
|
): Promise<TradingUserProfile> {
|
|
if (isCosmosConfigured()) {
|
|
try {
|
|
const cosmosProfile = await getTradingUserProfileFromCosmos(userId);
|
|
if (cosmosProfile) {
|
|
return mergeTradingUserProfiles(cosmosProfile, fallback, userId) || cosmosProfile;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Cosmos user profile read failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
}
|
|
}
|
|
|
|
const client = getLegacyClient();
|
|
if (client) {
|
|
try {
|
|
const { data, error } = await client
|
|
.from('users')
|
|
.select('user_id,first_name,last_name,email,role,trade_enable,FMP_API_KEY,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) {
|
|
const normalized = mergeTradingUserProfiles(data as any, fallback, userId);
|
|
if (!normalized) {
|
|
throw new Error(`Invalid user profile for ${userId}`);
|
|
}
|
|
await upsertTradingUserProfileToCosmos(normalized);
|
|
return normalized;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Legacy user profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
}
|
|
}
|
|
|
|
return mergeTradingUserProfiles({}, fallback, userId) || {
|
|
user_id: userId,
|
|
first_name: '',
|
|
last_name: '',
|
|
email: '',
|
|
role: 'member',
|
|
trade_enable: Boolean(fallback.trade_enable ?? true),
|
|
FMP_API_KEY: '',
|
|
ALPACA_API_KEY: '',
|
|
ALPACA_SECRET_KEY: '',
|
|
REAL_ALPACA_API_KEY: '',
|
|
REAL_ALPACA_SECRET_KEY: '',
|
|
drop_threshold_for_buy: 0,
|
|
gain_threshold_for_sell: 0,
|
|
market_poll_interval_in_seconds: 0,
|
|
};
|
|
}
|
|
|
|
export async function saveCurrentUserProfile(
|
|
userId: string,
|
|
input: Partial<TradingUserProfile>,
|
|
fallback: Partial<TradingUserProfile> = {}
|
|
): Promise<TradingUserProfile> {
|
|
const existing = await getCurrentUserProfile(userId, fallback);
|
|
// SECURITY: featureFlags + role are NEVER persisted from end-user input.
|
|
// - role is authoritative from JWT (see comment below)
|
|
// - featureFlags are admin-only via setUserFeatureFlags() / the
|
|
// /api/admin/users/:id/feature-flags endpoint
|
|
// Strip them from input here so a non-admin can't elevate their own
|
|
// feature gates by PATCHing /api/me/profile.
|
|
const { role: _ignoredRole, featureFlags: _ignoredFlags, ...safeInput } = input;
|
|
void _ignoredRole; void _ignoredFlags;
|
|
const merged: TradingUserProfile = {
|
|
...existing,
|
|
...safeInput,
|
|
user_id: userId,
|
|
email: String(safeInput.email ?? existing.email ?? fallback.email ?? ''),
|
|
// Role is intentionally NOT persisted from client input — JWT role is the
|
|
// authoritative source. We keep the existing stored role for backward
|
|
// compatibility but it's overridden on read at the API layer.
|
|
role: String(existing.role ?? fallback.role ?? 'member'),
|
|
first_name: String(safeInput.first_name ?? existing.first_name ?? fallback.first_name ?? ''),
|
|
last_name: String(safeInput.last_name ?? existing.last_name ?? fallback.last_name ?? ''),
|
|
trade_enable: Boolean(safeInput.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true),
|
|
drop_threshold_for_buy: Number(safeInput.drop_threshold_for_buy ?? existing.drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0),
|
|
gain_threshold_for_sell: Number(safeInput.gain_threshold_for_sell ?? existing.gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0),
|
|
market_poll_interval_in_seconds: Number(safeInput.market_poll_interval_in_seconds ?? existing.market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0),
|
|
// Preserve any existing featureFlags — admin can edit via setUserFeatureFlags
|
|
...(existing.featureFlags ? { featureFlags: existing.featureFlags } : {}),
|
|
};
|
|
|
|
try {
|
|
await upsertTradingUserProfileToCosmos(merged);
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Cosmos user profile save failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
}
|
|
|
|
const client = getLegacyClient();
|
|
if (client) {
|
|
try {
|
|
const { error } = await client
|
|
.from('users')
|
|
.upsert({
|
|
user_id: userId,
|
|
first_name: merged.first_name,
|
|
last_name: merged.last_name,
|
|
email: merged.email,
|
|
role: merged.role,
|
|
trade_enable: merged.trade_enable,
|
|
FMP_API_KEY: merged.FMP_API_KEY ?? null,
|
|
ALPACA_API_KEY: merged.ALPACA_API_KEY ?? null,
|
|
ALPACA_SECRET_KEY: merged.ALPACA_SECRET_KEY ?? null,
|
|
REAL_ALPACA_API_KEY: merged.REAL_ALPACA_API_KEY ?? null,
|
|
REAL_ALPACA_SECRET_KEY: merged.REAL_ALPACA_SECRET_KEY ?? null,
|
|
drop_threshold_for_buy: merged.drop_threshold_for_buy,
|
|
gain_threshold_for_sell: merged.gain_threshold_for_sell,
|
|
market_poll_interval_in_seconds: merged.market_poll_interval_in_seconds,
|
|
}, { onConflict: 'user_id' });
|
|
|
|
if (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Legacy user profile save failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Admin-only: update the per-user featureFlags overrides on a user profile.
|
|
* Used by PATCH /api/admin/users/:id/feature-flags. Loads, merges, persists.
|
|
*
|
|
* `flags` keys with explicit `null` clear the override (returning to global
|
|
* default); `undefined` keys are left untouched.
|
|
*/
|
|
export async function setUserFeatureFlags(
|
|
userId: string,
|
|
flags: { [K in keyof TradingUserFeatureFlags]?: TradingUserFeatureFlags[K] | null }
|
|
): Promise<TradingUserProfile> {
|
|
const existing = await getCurrentUserProfile(userId);
|
|
const next: TradingUserFeatureFlags = { ...(existing.featureFlags || {}) };
|
|
for (const key of Object.keys(flags) as (keyof TradingUserFeatureFlags)[]) {
|
|
const value = flags[key];
|
|
if (value === null) {
|
|
delete next[key];
|
|
} else if (typeof value === 'boolean') {
|
|
next[key] = value;
|
|
}
|
|
}
|
|
const updated: TradingUserProfile = {
|
|
...existing,
|
|
...(Object.keys(next).length > 0 ? { featureFlags: next } : { featureFlags: undefined }),
|
|
};
|
|
try {
|
|
await upsertTradingUserProfileToCosmos(updated);
|
|
} catch (error) {
|
|
logger.warn(`[Profiles] Cosmos featureFlags save failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
throw error;
|
|
}
|
|
return updated;
|
|
}
|