From 7e97120cbee96e1571e388898dc967a15f5b10eb Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 14 Jul 2026 12:01:29 -0500 Subject: [PATCH 1/2] feat: sync interview config with backend account Rename interviewConf.username/jobDescription -> fullName/context to match the backend's account fields, with a migration so existing installs keep their data on disk under the old keys. Add account.service.ts + IPC wiring to pull the account's interview config on login/remembered session and push edits from the configuration dialog to the new backend endpoint, replacing the purely-local electron-store save. Also aligns the dialog's input cap with the backend's real 128k limit (was hardcoded to 60k). Co-Authored-By: Claude Sonnet 5 --- src/main/api/client.ts | 5 ++ src/main/api/users.ts | 24 +++++++ src/main/index.ts | 2 + src/main/ipc/account.ts | 13 ++++ src/main/preload.cts | 5 ++ src/main/services/account.service.ts | 65 +++++++++++++++++++ src/main/services/auth.service.ts | 4 ++ src/main/services/health-check.service.ts | 7 ++ .../services/suggestion-action.service.ts | 2 +- src/main/services/suggestion-live.service.ts | 2 +- src/main/services/tools.service.ts | 2 +- src/main/store/config.store.ts | 33 ++++++++-- src/main/types/account.ts | 27 ++++++++ .../custom/configuration-dialog.tsx | 48 +++++++++----- .../components/custom/control-panel/index.tsx | 2 +- .../custom/control-panel/profile-group.tsx | 2 +- .../custom/panels/transcript-panel.tsx | 2 +- src/renderer/types/config.ts | 4 +- src/renderer/types/electron-api.d.ts | 9 +++ 19 files changed, 230 insertions(+), 28 deletions(-) create mode 100644 src/main/api/users.ts create mode 100644 src/main/ipc/account.ts create mode 100644 src/main/services/account.service.ts create mode 100644 src/main/types/account.ts diff --git a/src/main/api/client.ts b/src/main/api/client.ts index 5665e9a..a23e1a9 100644 --- a/src/main/api/client.ts +++ b/src/main/api/client.ts @@ -118,6 +118,11 @@ export class ApiClient { return this.request('PUT', url, body); } + async patch(path: string, body?: unknown): Promise> { + const url = this.buildUrl(path); + return this.request('PATCH', url, body); + } + async delete(path: string): Promise> { const url = this.buildUrl(path); return this.request('DELETE', url); diff --git a/src/main/api/users.ts b/src/main/api/users.ts new file mode 100644 index 0000000..39333b0 --- /dev/null +++ b/src/main/api/users.ts @@ -0,0 +1,24 @@ +/** + * Users API + * Handles the authenticated user's account and interview configuration + * (full name, profile, context) + */ + +import { UpdateInterviewConfigRequest, UserAccount } from '../types/account.js'; +import { ApiClient, ApiResponse } from './client.js'; + +export class UsersApi extends ApiClient { + /** + * Get the authenticated user's account + */ + async getMe(): Promise> { + return this.get('/api/users/me'); + } + + /** + * Replace the authenticated user's interview configuration + */ + async updateInterviewConfig(data: UpdateInterviewConfigRequest): Promise> { + return this.patch('/api/users/me/interview-config', data); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 04c9fba..b6ea33e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,6 +8,7 @@ const __dirname = path.dirname(__filename); // Import modules import { MIN_HEIGHT, MIN_WIDTH } from './consts.js'; import { registerGlobalHotkeys, unregisterHotkeys } from './hotkeys.js'; +import { registerAccountHandlers } from './ipc/account.js'; import { registerAppStateHandlers } from './ipc/app-state.js'; import { registerAuthHandlers } from './ipc/auth.js'; import { registerAutoUpdaterHandlers } from './ipc/auto-updater.js'; @@ -182,6 +183,7 @@ app.whenReady().then(async () => { registerConfigHandlers(); registerAppStateHandlers(); registerAuthHandlers(); + registerAccountHandlers(); registerPaymentHandlers(); registerLLMHandlers(); registerPermissionHandlers(); diff --git a/src/main/ipc/account.ts b/src/main/ipc/account.ts new file mode 100644 index 0000000..06db466 --- /dev/null +++ b/src/main/ipc/account.ts @@ -0,0 +1,13 @@ +import { ipcMain } from 'electron'; + +import { accountService } from '../services/account.service.js'; + +export function registerAccountHandlers(): void { + // Push local account config changes (full name, profile, context) to the backend + ipcMain.handle( + 'account:update', + async (_event, fullName: string, profileData: string, context: string) => { + return accountService.updateConfig(fullName, profileData, context); + } + ); +} diff --git a/src/main/preload.cts b/src/main/preload.cts index 01a7a47..339e37a 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -53,6 +53,11 @@ const electronApi = { ipcRenderer.invoke('auth:change-password', currentPassword, newPassword), }, + account: { + update: (fullName: string, profileData: string, context: string) => + ipcRenderer.invoke('account:update', fullName, profileData, context), + }, + payment: { getPlans: () => ipcRenderer.invoke('payment:get-plans'), getCurrencies: () => ipcRenderer.invoke('payment:get-currencies'), diff --git a/src/main/services/account.service.ts b/src/main/services/account.service.ts new file mode 100644 index 0000000..137e49f --- /dev/null +++ b/src/main/services/account.service.ts @@ -0,0 +1,65 @@ +import { UsersApi } from '../api/users.js'; +import { configStore } from '../store/config.store.js'; + +/** + * AccountService + * Keeps the local interviewConf (full name, profile, context) in sync with the + * account persisted on the backend, so this config follows the user across devices. + */ +export class AccountService { + private client = new UsersApi(); + + /** + * Pull the authenticated user's persisted account config from the backend + * into the local store. Called after login so a device shows the same + * config the user last saved anywhere. + */ + async pullFromBackend(): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.getMe(); + if (response.error || !response.data) { + return { success: false, error: response.error?.message || 'Failed to fetch account' }; + } + + const interviewConfig = response.data.interview_config; + configStore.updateConfig({ + interviewConf: { + fullName: interviewConfig?.full_name ?? '', + profileData: interviewConfig?.profile_data ?? '', + context: interviewConfig?.context ?? '', + }, + }); + return { success: true }; + } catch { + return { success: false, error: 'Failed to fetch account' }; + } + } + + /** + * Push local interview config changes to the backend, then mirror the + * saved values into the local store. + */ + async updateConfig( + fullName: string, + profileData: string, + context: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.updateInterviewConfig({ + full_name: fullName, + profile_data: profileData, + context, + }); + if (response.error) { + return { success: false, error: response.error.message || 'Failed to update account' }; + } + + configStore.updateConfig({ interviewConf: { fullName, profileData, context } }); + return { success: true }; + } catch { + return { success: false, error: 'Failed to update account' }; + } + } +} + +export const accountService = new AccountService(); diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index 202a6ff..bf752bb 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -1,5 +1,6 @@ import { AuthApi } from '../api/auth.js'; import { configStore } from '../store/config.store.js'; +import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; import { disableStealth } from './window-control.service.js'; @@ -99,6 +100,9 @@ export class AuthService { // update app state to logged in appStateService.updateState({ isLoggedIn: true }); + // pull this account's synced config (full name, profile, context) from the backend + await accountService.pullFromBackend(); + return { success: true }; } catch { return { success: false, error: 'Login failed' }; diff --git a/src/main/services/health-check.service.ts b/src/main/services/health-check.service.ts index 5f1515b..d846ca8 100644 --- a/src/main/services/health-check.service.ts +++ b/src/main/services/health-check.service.ts @@ -5,6 +5,7 @@ import { HealthCheckApi } from '../api/health-check.js'; import { safeSleep } from '../utils/sleep.js'; +import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; import { authService } from './auth.service.js'; import { pushNotificationService } from './push-notification.service.js'; @@ -33,6 +34,12 @@ export class HealthCheckService { userRole: res.data?.user_role, providedLLMModel: res.data?.provided_llm_model, }); + + // Remembered sessions log the user in here without going through authService.login(), + // so this is where a returning device needs to pull its synced account config. + if (res.status === 200) { + await accountService.pullFromBackend(); + } } catch (error) { console.error('[HealthCheckService] Initial client ping error:', error); appStateService.updateState({ isLoggedIn: false }); diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 8846311..c8598e7 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -190,7 +190,7 @@ export class ActionSuggestionService { const payload: GenerateActionSuggestionRequest = { config: conf.llmConf, profile_data: conf.interviewConf.profileData, - context: conf.interviewConf.jobDescription, + context: conf.interviewConf.context, transcripts: transcripts, image_names: [...this.uploadedImageNames], }; diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index d21f347..92245ad 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -56,7 +56,7 @@ class LiveSuggestionService { const requestBody: GenerateLiveSuggestionRequest = { config: conf.llmConf, profile_data: conf.interviewConf.profileData, - context: conf.interviewConf.jobDescription, + context: conf.interviewConf.context, transcripts: transcripts, }; diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 0745c43..4f15a75 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -31,7 +31,7 @@ class ToolsService { async exportTranscript(): Promise { // Prepare request data - const username = configStore.getConfig().interviewConf.username; + const username = configStore.getConfig().interviewConf.fullName; const transcripts = appStateService.getState().transcripts; const suggestions = appStateService.getState().liveSuggestions; diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 0f7d3b7..a6a49f6 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -11,9 +11,9 @@ import { LLMConfig } from '../types/llm.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { interviewConf: { - username: string; + fullName: string; profileData: string; - jobDescription: string; + context: string; }; language: string; sessionToken: string; @@ -33,9 +33,9 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { interviewConf: { - username: '', + fullName: '', profileData: '', - jobDescription: '', + context: '', }, language: 'en', sessionToken: '', @@ -200,3 +200,28 @@ export const configStore = new ConfigStore(); configStore.updateConfig(migration); } })(); // migration block + +// interviewConf.username/jobDescription were renamed to fullName/context to match +// the persisted account fields now synced with the backend. Carry over any value +// already on disk under the old keys so existing installs don't lose their data. +(() => { + // eslint-disable-next-line + const rawInterviewConf = (configStore as any).store.get('runtime.interviewConf') as + | (Partial & { username?: string; jobDescription?: string }) + | undefined; + if (!rawInterviewConf) return; + + const legacyUsername = rawInterviewConf.username; + const legacyJobDescription = rawInterviewConf.jobDescription; + const needsFullName = rawInterviewConf.fullName === undefined && legacyUsername !== undefined; + const needsContext = rawInterviewConf.context === undefined && legacyJobDescription !== undefined; + if (!needsFullName && !needsContext) return; + + configStore.updateConfig({ + interviewConf: { + fullName: needsFullName ? legacyUsername! : (rawInterviewConf.fullName ?? ''), + profileData: rawInterviewConf.profileData ?? '', + context: needsContext ? legacyJobDescription! : (rawInterviewConf.context ?? ''), + }, + }); +})(); // legacy interviewConf field migration diff --git a/src/main/types/account.ts b/src/main/types/account.ts new file mode 100644 index 0000000..fa2abae --- /dev/null +++ b/src/main/types/account.ts @@ -0,0 +1,27 @@ +/** + * Account Types + */ + +export interface InterviewConfig { + full_name: string; + profile_data: string; + context: string; +} + +export interface UserAccount { + _id: string; + username: string; + email: string; + role: string; + status: string; + credits: number; + interview_config: InterviewConfig | null; + created_at: number; + updated_at: number | null; +} + +export interface UpdateInterviewConfigRequest { + full_name: string; + profile_data: string; + context: string; +} diff --git a/src/renderer/components/custom/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx index 00020f5..3f5a041 100644 --- a/src/renderer/components/custom/configuration-dialog.tsx +++ b/src/renderer/components/custom/configuration-dialog.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { useConfigStore } from '@/hooks/use-config-store'; +import { getElectron } from '@/lib/utils'; import { Dialog, @@ -14,17 +16,21 @@ import { DialogTitle, } from '../ui/dialog'; +// Kept in sync with the backend's MAX_PROFILE_DATA_LENGTH / MAX_CONTEXT_LENGTH (app/cfg/llm.py) +const MAX_FIELD_LENGTH = 128_000; + interface ConfigurationDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; } export default function ConfigurationDialog({ isOpen, onOpenChange }: ConfigurationDialogProps) { - const { config, updateConfig } = useConfigStore(); + const { config, loadConfig } = useConfigStore(); const [name, setName] = useState(''); const [profileData, setProfileData] = useState(''); - const [jobDescription, setJobDescription] = useState(''); + const [context, setContext] = useState(''); + const [saving, setSaving] = useState(false); // Initialize form values when dialog opens - runs before paint useEffect(() => { @@ -33,24 +39,34 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat // Queue state updates in a single microtask to avoid cascading renders Promise.resolve().then(() => { if (config?.interviewConf) { - setName(config.interviewConf.username ?? ''); + setName(config.interviewConf.fullName ?? ''); setProfileData(config.interviewConf.profileData ?? ''); - setJobDescription(config.interviewConf.jobDescription ?? ''); + setContext(config.interviewConf.context ?? ''); } }); }, [isOpen, config?.interviewConf]); const handleSave = async () => { - const interviewConf = { - username: name, - profileData: profileData, - jobDescription: jobDescription, - }; + setSaving(true); try { - await updateConfig({ interviewConf: interviewConf }); + const electron = getElectron(); + if (!electron?.account) { + throw new Error('Electron API not available'); + } + + const result = await electron.account.update(name, profileData, context); + if (!result.success) { + throw new Error(result.error || 'Failed to save configuration'); + } + + // Refresh the renderer's cached config from the main-process store + await loadConfig(); onOpenChange(false); } catch (error) { console.error('Failed to save configuration:', error); + toast.error(error instanceof Error ? error.message : 'Failed to save configuration'); + } finally { + setSaving(false); } }; @@ -89,7 +105,7 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat onChange={(e) => setProfileData(e.target.value)} placeholder="Enter your profile information. (e.g. your CV/resume, LinkedIn profile, or a brief bio)" className="text-sm min-h-20 max-h-40 overflow-auto" - maxLength={60000} + maxLength={MAX_FIELD_LENGTH} /> @@ -98,11 +114,11 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat Context (Recommended)