refactor: route settings and config through backend apis
This commit is contained in:
parent
5b59257a4b
commit
44f3171783
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 />);
|
||||
|
||||
@ -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.' });
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user