learning_ai_invt_trdg/backend/src/services/profileRepository.ts
Devin 4fc53703c6 feat(backtest): runtime + per-user feature flags (Option C)
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>
2026-05-10 19:04:12 +00:00

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;
}