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..59341ca --- /dev/null +++ b/src/main/services/account.service.ts @@ -0,0 +1,67 @@ +import { UsersApi } from '../api/users.js'; +import { appStateService } from './app-state.service.js'; + +/** + * AccountService + * Keeps the in-memory interview config (full name, profile, context) in sync with + * the account persisted on the backend. Not written to local disk - the backend is + * the only durable store, so this always reflects whatever was last fetched/saved + * this session. + */ +export class AccountService { + private client = new UsersApi(); + + /** + * Pull the authenticated user's persisted interview config from the backend + * into app state. 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; + appStateService.updateState({ + interviewConfig: { + 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 app state. + */ + 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' }; + } + + appStateService.updateState({ interviewConfig: { 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/app-state.service.ts b/src/main/services/app-state.service.ts index 70f4095..24c8d1c 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -24,6 +24,7 @@ const DEFAULT_STATE: AppState = { credits: undefined, userRole: undefined, providedLLMModel: undefined, + interviewConfig: { fullName: '', profileData: '', context: '' }, }; export class AppStateService { 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..75ed88d 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -186,11 +186,12 @@ export class ActionSuggestionService { private async generateSuggestion(taskId: string, transcripts: Transcript[]): Promise { const timestamp = DateTimeUtil.now(); const conf = configStore.getConfig(); + const interviewConfig = appStateService.getState().interviewConfig; const payload: GenerateActionSuggestionRequest = { config: conf.llmConf, - profile_data: conf.interviewConf.profileData, - context: conf.interviewConf.jobDescription, + profile_data: interviewConfig.profileData, + context: interviewConfig.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..9d3c403 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -53,10 +53,11 @@ class LiveSuggestionService { try { const conf = configStore.getConfig(); + const interviewConfig = appStateService.getState().interviewConfig; const requestBody: GenerateLiveSuggestionRequest = { config: conf.llmConf, - profile_data: conf.interviewConf.profileData, - context: conf.interviewConf.jobDescription, + profile_data: interviewConfig.profileData, + context: interviewConfig.context, transcripts: transcripts, }; diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 0745c43..59f8787 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 = appStateService.getState().interviewConfig.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..a0b5ebd 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -10,11 +10,6 @@ import { LLMConfig } from '../types/llm.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { - interviewConf: { - username: string; - profileData: string; - jobDescription: string; - }; language: string; sessionToken: string; rememberMe: boolean; @@ -32,11 +27,6 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { - interviewConf: { - username: '', - profileData: '', - jobDescription: '', - }, language: 'en', sessionToken: '', rememberMe: true, @@ -89,14 +79,6 @@ class ConfigStore { const current = this.getConfig(); const updated = { ...current, ...updates }; - // Deep merge interview_conf if it's being partially updated - if (updates.interviewConf) { - updated.interviewConf = { - ...current.interviewConf, - ...updates.interviewConf, - }; - } - this.store.set('runtime', updated); return updated; } @@ -200,3 +182,18 @@ export const configStore = new ConfigStore(); configStore.updateConfig(migration); } })(); // migration block + +// interviewConf (full name, profile, context) used to be cached here, but it's now +// backend-persisted and lives only in-memory (see AccountService/AppStateService). +// Drop any leftover local copy so it doesn't linger in the store file. +(() => { + // eslint-disable-next-line + const raw = (configStore as any).store.get('runtime') as + | (Partial & { interviewConf?: unknown }) + | undefined; + if (raw && 'interviewConf' in raw) { + delete raw.interviewConf; + // eslint-disable-next-line + (configStore as any).store.set('runtime', raw); + } +})(); // drop legacy local interviewConf 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/main/types/app-state.ts b/src/main/types/app-state.ts index 52b39cb..9e8e898 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -51,6 +51,12 @@ export interface ActionSuggestion { error: string; } +export interface InterviewConfig { + fullName: string; + profileData: string; + context: string; +} + export interface AppState { isStealth: boolean; isBackendLive: boolean | null; @@ -62,4 +68,5 @@ export interface AppState { credits?: number; userRole?: UserRole; providedLLMModel?: string; + interviewConfig: InterviewConfig; } diff --git a/src/renderer/components/custom/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx index 00020f5..2351ada 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 { useAppState } from '@/hooks/use-app-state'; +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 { appState } = useAppState(); 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(() => { @@ -32,25 +38,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 ?? ''); - setProfileData(config.interviewConf.profileData ?? ''); - setJobDescription(config.interviewConf.jobDescription ?? ''); + if (appState?.interviewConfig) { + setName(appState.interviewConfig.fullName ?? ''); + setProfileData(appState.interviewConfig.profileData ?? ''); + setContext(appState.interviewConfig.context ?? ''); } }); - }, [isOpen, config?.interviewConf]); + }, [isOpen, appState?.interviewConfig]); 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'); + } + + // Account update pushes a fresh app-state broadcast, so no manual refresh needed here 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 +104,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 +113,11 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat Context (Recommended)