refactor: route settings and config through backend apis

This commit is contained in:
Saravana Achu Mac 2026-04-04 15:50:44 -07:00
parent 5b59257a4b
commit 44f3171783
7 changed files with 150 additions and 113 deletions

View File

@ -20,6 +20,7 @@ import {
getCurrentUserProfile,
listAllTradeProfiles,
listTradeProfilesForUser,
saveCurrentUserProfile,
saveTradeProfileForUser,
} from './profileRepository.js';
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
@ -1636,6 +1637,29 @@ export class ApiServer {
res.json({ profile });
});
this.app.patch('/api/me/profile', this.requireAuth, async (req, res) => {
const authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean);
try {
const profile = await saveCurrentUserProfile(authUserId, req.body || {}, {
email: authReq.authEmail,
role: authReq.authRole,
first_name: displayNameParts[0] || '',
last_name: displayNameParts.slice(1).join(' '),
trade_enable: true,
}, supabaseService);
res.json({ profile });
} catch (error: any) {
res.status(400).json({ error: `Failed to update profile: ${error.message}` });
}
});
this.app.get('/api/profiles', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
if (!authUserId) {

View File

@ -383,3 +383,56 @@ export async function getCurrentUserProfile(
market_poll_interval_in_seconds: Number(fallback.market_poll_interval_in_seconds ?? 0),
};
}
export async function saveCurrentUserProfile(
userId: string,
input: Partial<TradingUserProfile>,
fallback: Partial<TradingUserProfile> = {},
legacyService?: LegacySupabaseService
): Promise<TradingUserProfile> {
const existing = await getCurrentUserProfile(userId, fallback, legacyService);
const merged: TradingUserProfile = {
...existing,
...input,
user_id: userId,
email: String(input.email ?? existing.email ?? fallback.email ?? ''),
role: String(input.role ?? existing.role ?? fallback.role ?? 'member'),
first_name: String(input.first_name ?? existing.first_name ?? fallback.first_name ?? ''),
last_name: String(input.last_name ?? existing.last_name ?? fallback.last_name ?? ''),
trade_enable: Boolean(input.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true),
drop_threshold_for_buy: Number(input.drop_threshold_for_buy ?? existing.drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0),
gain_threshold_for_sell: Number(input.gain_threshold_for_sell ?? existing.gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0),
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),
};
const client = legacyService?.getClient?.();
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,
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;
}

View File

@ -63,6 +63,14 @@ export async function fetchCurrentUserProfile(): Promise<CurrentUserProfile> {
return response.profile;
}
export async function updateCurrentUserProfile(payload: Partial<CurrentUserProfile>): Promise<CurrentUserProfile> {
const response = await apiRequest<{ profile: CurrentUserProfile }>('/api/me/profile', {
method: 'PATCH',
body: JSON.stringify(payload),
});
return response.profile;
}
export async function fetchTradeProfiles(options?: { ensureDefault?: boolean; scope?: 'user' | 'all' }): Promise<TradeProfilePayload[]> {
const params = new URLSearchParams();
if (options?.ensureDefault) {

View File

@ -3,55 +3,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfigTab } from './ConfigTab';
import { tableNameBotConfig } from '../lib/const';
const {
fromMock,
upsertMock,
selectState
fetchDynamicConfigItemsMock,
upsertDynamicConfigItemsMock
} = vi.hoisted(() => ({
fromMock: vi.fn(),
upsertMock: vi.fn(),
selectState: {
data: [] as any[],
error: null as any
}
fetchDynamicConfigItemsMock: vi.fn(),
upsertDynamicConfigItemsMock: vi.fn()
}));
vi.mock('../lib/supabaseClient', () => ({
supabase: {
from: fromMock
}
vi.mock('../lib/dynamicConfigApi', () => ({
fetchDynamicConfigItems: fetchDynamicConfigItemsMock,
upsertDynamicConfigItems: upsertDynamicConfigItemsMock
}));
const makeBotConfigTable = () => {
const selectBuilder: any = {
order: vi.fn(() => Promise.resolve({ data: selectState.data, error: selectState.error }))
};
return {
select: vi.fn(() => selectBuilder),
upsert: upsertMock
};
};
describe('ConfigTab DOM behavior', () => {
beforeEach(() => {
fromMock.mockReset();
upsertMock.mockReset();
selectState.data = [
fetchDynamicConfigItemsMock.mockReset();
upsertDynamicConfigItemsMock.mockReset();
fetchDynamicConfigItemsMock.mockResolvedValue([
{ key: 'BOT_MODE', value: 'enabled', description: 'Bot mode' },
{ key: 'API_KEY', value: 'masked', description: 'Secret key' }
];
selectState.error = null;
fromMock.mockImplementation((table: string) => {
if (table === tableNameBotConfig) {
return makeBotConfigTable();
}
return { select: vi.fn(), upsert: vi.fn() };
});
upsertMock.mockResolvedValue({ error: null });
]);
upsertDynamicConfigItemsMock.mockResolvedValue(undefined);
});
it('loads configs, supports edit/reset, and saves with success message', async () => {
@ -79,21 +53,20 @@ describe('ConfigTab DOM behavior', () => {
await user.click(screen.getByRole('button', { name: 'Commit Changes' }));
await waitFor(() => {
expect(upsertMock).toHaveBeenCalledTimes(1);
expect(upsertDynamicConfigItemsMock).toHaveBeenCalledTimes(1);
expect(screen.getByText('SYNC COMPLETE')).toBeInTheDocument();
});
expect(upsertMock.mock.calls[0][0]).toEqual(
expect(upsertDynamicConfigItemsMock.mock.calls[0][0]).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'BOT_MODE', value: 'disabled' }),
expect.objectContaining({ key: 'API_KEY', value: 'masked' })
])
);
expect(upsertMock.mock.calls[0][1]).toEqual({ onConflict: 'key' });
}, 20000);
it('renders empty-state table when no config rows exist', async () => {
selectState.data = [];
fetchDynamicConfigItemsMock.mockResolvedValueOnce([]);
render(<ConfigTab />);
await waitFor(() => {
@ -102,7 +75,7 @@ describe('ConfigTab DOM behavior', () => {
});
it('shows save error banner when upsert fails', async () => {
upsertMock.mockResolvedValue({ error: { message: 'write failed' } });
upsertDynamicConfigItemsMock.mockRejectedValueOnce(new Error('write failed'));
const user = userEvent.setup();
render(<ConfigTab />);

View File

@ -1,8 +1,7 @@
import React from 'react';
import { supabase } from '../lib/supabaseClient';
import { tableNameBotConfig } from '../lib/const';
import { AlertCircle, CheckCircle2, LayoutDashboard, Info, Zap, Key, ShieldAlert } from 'lucide-react';
import { clearBacktestRuntimeFlagCache } from '../backtest/flags';
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
interface ConfigItem {
key: string;
@ -35,12 +34,23 @@ export const cloneConfigItems = (configs: ConfigItem[]) =>
export const hasConfigChanges = (configs: ConfigItem[], originalConfigs: ConfigItem[]) =>
JSON.stringify(configs) !== JSON.stringify(originalConfigs);
export const buildConfigUpsertPayload = (configs: ConfigItem[], timestampIso: string) =>
configs.map((config) => ({
key: config.key,
value: config.value,
updated_at: timestampIso
}));
export function buildConfigUpsertPayload(configs: ConfigItem[]): ConfigItem[];
export function buildConfigUpsertPayload(configs: ConfigItem[], timestampIso: string): Array<{ key: string; value: string; updated_at: string }>;
export function buildConfigUpsertPayload(configs: ConfigItem[], timestampIso?: string) {
return configs.map((config) => (
timestampIso
? {
key: config.key,
value: config.value,
updated_at: timestampIso
}
: {
key: config.key,
value: config.value,
description: config.description
}
));
}
export const ConfigTab = () => {
const [configs, setConfigs] = React.useState<ConfigItem[]>([]);
@ -56,23 +66,19 @@ export const ConfigTab = () => {
const fetchConfigs = async () => {
setLoading(true);
const { data, error } = await supabase
.from(tableNameBotConfig)
.select('*')
.order('key');
if (error) {
console.error('Error fetching global config:', error);
} else {
const configData = data || [];
try {
const configData = await fetchDynamicConfigItems();
setConfigs(configData);
setOriginalConfigs(cloneConfigItems(configData));
setBacktestFlags({
enableBacktest: readBooleanConfig(configData, BACKTEST_FLAG_KEYS.ENABLE_BACKTEST, false),
customerEnabled: readBooleanConfig(configData, BACKTEST_FLAG_KEYS.BACKTEST_CUSTOMER_ENABLED, false)
});
} catch (error) {
console.error('Error fetching global config:', error);
} finally {
setLoading(false);
}
setLoading(false);
};
React.useEffect(() => {
@ -90,14 +96,7 @@ export const ConfigTab = () => {
const handleSaveAll = async () => {
setSaving(true);
try {
const { error } = await supabase
.from(tableNameBotConfig)
.upsert(
buildConfigUpsertPayload(configs, new Date().toISOString()),
{ onConflict: 'key' }
);
if (error) throw error;
await upsertDynamicConfigItems(buildConfigUpsertPayload(configs));
clearBacktestRuntimeFlagCache();
setMessage({ type: 'success', text: 'Neural parameters synchronized successfully.' });
@ -113,22 +112,18 @@ export const ConfigTab = () => {
const handleSaveBacktestFlags = async () => {
setSavingBacktestFlags(true);
try {
const payload = [
await upsertDynamicConfigItems([
{
key: BACKTEST_FLAG_KEYS.ENABLE_BACKTEST,
value: String(backtestFlags.enableBacktest),
updated_at: new Date().toISOString()
description: 'Master switch for /api/backtest/run.'
},
{
key: BACKTEST_FLAG_KEYS.BACKTEST_CUSTOMER_ENABLED,
value: String(backtestFlags.customerEnabled),
updated_at: new Date().toISOString()
description: 'Allows non-admin customers to use backtest.'
}
];
const { error } = await supabase
.from(tableNameBotConfig)
.upsert(payload, { onConflict: 'key' });
if (error) throw error;
]);
clearBacktestRuntimeFlagCache();
await fetchConfigs();
setMessage({ type: 'success', text: 'Backtest access flags updated.' });

View File

@ -3,15 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SettingsTab } from './SettingsTab';
// import { tableNameUsers } from '../lib/const';
import type { BotState } from '../hooks/useWebSocket';
const {
authState,
refreshProfileMock,
fromMock,
updateMock,
updateEqMock
updateCurrentUserProfileMock
} = vi.hoisted(() => ({
authState: {
user: { id: 'user-1' } as any,
@ -30,9 +27,7 @@ const {
} as any
},
refreshProfileMock: vi.fn(),
fromMock: vi.fn(),
updateMock: vi.fn(),
updateEqMock: vi.fn()
updateCurrentUserProfileMock: vi.fn()
}));
vi.mock('../components/AuthContext', () => ({
@ -42,10 +37,8 @@ vi.mock('../components/AuthContext', () => ({
})
}));
vi.mock('../lib/supabaseClient', () => ({
supabase: {
from: fromMock
}
vi.mock('../lib/profileApi', () => ({
updateCurrentUserProfile: updateCurrentUserProfileMock
}));
const botState: BotState = {
@ -74,9 +67,7 @@ describe('SettingsTab Master Suite', () => {
market_poll_interval_in_seconds: 60000
};
updateEqMock.mockResolvedValue({ error: null });
updateMock.mockReturnValue({ eq: updateEqMock });
fromMock.mockImplementation(() => ({ update: updateMock }));
updateCurrentUserProfileMock.mockResolvedValue({});
vi.stubGlobal('alert', vi.fn());
});
@ -93,8 +84,7 @@ describe('SettingsTab Master Suite', () => {
await user.click(screen.getByText('Save Changes'));
await waitFor(() => {
expect(updateMock).toHaveBeenCalled();
expect(updateEqMock).toHaveBeenCalledWith('user_id', 'user-1');
expect(updateCurrentUserProfileMock).toHaveBeenCalled();
expect(alert).toHaveBeenCalledWith('Settings saved successfully!');
});
});
@ -122,7 +112,7 @@ describe('SettingsTab Master Suite', () => {
it('covers save exception catch block', async () => {
const user = userEvent.setup();
updateEqMock.mockResolvedValue({ error: { message: 'DB Error' } });
updateCurrentUserProfileMock.mockRejectedValueOnce(new Error('DB Error'));
render(<SettingsTab botState={botState} />);
await user.click(screen.getByText('Edit Configuration'));
await user.click(screen.getByText('Save Changes'));

View File

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabaseClient';
import { tableNameUsers } from '../lib/const';
import { useAuth } from '../components/AuthContext';
import type { BotState } from '../hooks/useWebSocket';
import { updateCurrentUserProfile } from '../lib/profileApi';
interface SettingsTabProps {
botState: BotState;
@ -90,23 +89,18 @@ export const SettingsTab = ({ botState }: SettingsTabProps) => {
try {
const { buyThreshold, sellThreshold, interval } = parseNumericSettings(formData);
const { error } = await supabase
.from(tableNameUsers)
.update({
first_name: formData.first_name,
last_name: formData.last_name,
ALPACA_API_KEY: formData.ALPACA_API_KEY,
ALPACA_SECRET_KEY: formData.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: formData.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: formData.REAL_ALPACA_SECRET_KEY,
trade_enable: formData.trade_enable,
drop_threshold_for_buy: buyThreshold,
gain_threshold_for_sell: sellThreshold,
market_poll_interval_in_seconds: interval,
})
.eq('user_id', user.id);
if (error) throw error;
await updateCurrentUserProfile({
first_name: formData.first_name,
last_name: formData.last_name,
ALPACA_API_KEY: formData.ALPACA_API_KEY,
ALPACA_SECRET_KEY: formData.ALPACA_SECRET_KEY,
REAL_ALPACA_API_KEY: formData.REAL_ALPACA_API_KEY,
REAL_ALPACA_SECRET_KEY: formData.REAL_ALPACA_SECRET_KEY,
trade_enable: formData.trade_enable,
drop_threshold_for_buy: buyThreshold,
gain_threshold_for_sell: sellThreshold,
market_poll_interval_in_seconds: interval,
});
await refreshProfile();
setEditing(false);