From 44f31717836974de1456a98e0775dd397ae28232 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 15:50:44 -0700 Subject: [PATCH] refactor: route settings and config through backend apis --- backend/src/services/apiServer.ts | 24 +++++++++ backend/src/services/profileRepository.ts | 53 +++++++++++++++++++ web/src/lib/profileApi.ts | 8 +++ web/src/tabs/ConfigTab.dom.test.tsx | 59 ++++++--------------- web/src/tabs/ConfigTab.tsx | 63 +++++++++++------------ web/src/tabs/SettingsTab.dom.test.tsx | 24 +++------ web/src/tabs/SettingsTab.tsx | 32 +++++------- 7 files changed, 150 insertions(+), 113 deletions(-) diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index d333198..adea444 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -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) { diff --git a/backend/src/services/profileRepository.ts b/backend/src/services/profileRepository.ts index cba4d68..9fc6460 100644 --- a/backend/src/services/profileRepository.ts +++ b/backend/src/services/profileRepository.ts @@ -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, + fallback: Partial = {}, + legacyService?: LegacySupabaseService +): Promise { + 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; +} diff --git a/web/src/lib/profileApi.ts b/web/src/lib/profileApi.ts index 8350211..a5db9c4 100644 --- a/web/src/lib/profileApi.ts +++ b/web/src/lib/profileApi.ts @@ -63,6 +63,14 @@ export async function fetchCurrentUserProfile(): Promise { return response.profile; } +export async function updateCurrentUserProfile(payload: Partial): Promise { + 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 { const params = new URLSearchParams(); if (options?.ensureDefault) { diff --git a/web/src/tabs/ConfigTab.dom.test.tsx b/web/src/tabs/ConfigTab.dom.test.tsx index 6249b1f..8d3fd4d 100644 --- a/web/src/tabs/ConfigTab.dom.test.tsx +++ b/web/src/tabs/ConfigTab.dom.test.tsx @@ -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(); 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(); diff --git a/web/src/tabs/ConfigTab.tsx b/web/src/tabs/ConfigTab.tsx index 0f49b6a..bdcfd51 100644 --- a/web/src/tabs/ConfigTab.tsx +++ b/web/src/tabs/ConfigTab.tsx @@ -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([]); @@ -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.' }); diff --git a/web/src/tabs/SettingsTab.dom.test.tsx b/web/src/tabs/SettingsTab.dom.test.tsx index 4c2e486..901ad91 100644 --- a/web/src/tabs/SettingsTab.dom.test.tsx +++ b/web/src/tabs/SettingsTab.dom.test.tsx @@ -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(); await user.click(screen.getByText('Edit Configuration')); await user.click(screen.getByText('Save Changes')); diff --git a/web/src/tabs/SettingsTab.tsx b/web/src/tabs/SettingsTab.tsx index d65b64c..4111d7c 100644 --- a/web/src/tabs/SettingsTab.tsx +++ b/web/src/tabs/SettingsTab.tsx @@ -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);