From 35a296889b3d6bbfe1e120626e65c27cd9c41351 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Thu, 17 Jul 2025 16:44:19 -0700 Subject: [PATCH] Improved resume editing --- frontend/src/components/ResumeChat.tsx | 22 +- .../src/components/layout/BackstoryLayout.tsx | 2 +- frontend/src/components/ui/ResumeEdit.tsx | 792 ++++++++++++++++++ frontend/src/components/ui/ResumeInfo.tsx | 671 +-------------- frontend/src/components/ui/ResumePreview.tsx | 78 +- frontend/src/components/ui/ResumeViewer.tsx | 32 +- frontend/src/config/navigationConfig.tsx | 2 +- frontend/src/hooks/GlobalContext.tsx | 24 +- frontend/src/pages/JobAnalysisPage.tsx | 12 +- .../src/pages/candidate/RegistrationForms.tsx | 5 +- frontend/src/services/api-client.ts | 503 +++++------ frontend/src/types/types.ts | 3 +- src/backend/agents/base.py | 2 +- src/backend/agents/edit_resume.py | 9 +- src/backend/agents/generate_resume.py | 20 +- src/backend/logger.py | 23 +- src/backend/models.py | 1 + src/backend/routes/candidates.py | 37 +- src/backend/routes/resumes.py | 73 +- 19 files changed, 1260 insertions(+), 1051 deletions(-) create mode 100644 frontend/src/components/ui/ResumeEdit.tsx diff --git a/frontend/src/components/ResumeChat.tsx b/frontend/src/components/ResumeChat.tsx index 09511f8..c084eea 100644 --- a/frontend/src/components/ResumeChat.tsx +++ b/frontend/src/components/ResumeChat.tsx @@ -86,7 +86,6 @@ const ResumeChat = forwardRef( const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(false); const messagesEndRef = useRef(null); - const [lastPrompt, setLastPrompt] = useState(''); const onDelete = async (session: ChatSession): Promise => { if (!session.id) { @@ -109,7 +108,6 @@ const ResumeChat = forwardRef( const messageContent = message; setStreaming(true); - setLastPrompt(message); const chatMessage: ChatMessageUser = { sessionId: chatSession.id, @@ -141,7 +139,7 @@ const ResumeChat = forwardRef( isAnswer: true, }; } else { - onResumeChange(lastPrompt, msg.content); + onResumeChange(message, msg.content); } setMessages(prev => { @@ -167,11 +165,21 @@ const ResumeChat = forwardRef( }, onStreaming: (chunk: ChatMessageStreaming): void => { // console.log("onStreaming:", chunk); - if (chunk.content[0] === 'A') { - chunk.content = chunk.content.replace(/A[^:]*:?/, ''); + const prefix = 'ANSWER:'; + let content = chunk.content || ''; + for (let i = prefix.length; i >= 1; i--) { + if (chunk.content.startsWith(prefix.slice(0, i))) { + content = chunk.content.slice(i); + break; + } + } + content = content.trim(); + if (streamingMessage?.content === content) { + return; // Avoid duplicate updates } setStreamingMessage({ ...chunk, + content, role: 'assistant', metadata: emptyMetadata, }); @@ -226,9 +234,10 @@ const ResumeChat = forwardRef( try { const result = await apiClient.getChatMessages(chatSession.id); const chatMessages: ChatMessage[] = result.data; + console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages); chatMessages.forEach(msg => { if (msg.role === 'assistant' && msg.content.match(/^ANSWER:\s*/)) { - msg.content = msg.content.replace(/^ANSWER:\s*/, ''); + msg.content = msg.content.replace(/^ANSWER: */, ''); msg.extraContext = { isAnswer: true, }; @@ -238,7 +247,6 @@ const ResumeChat = forwardRef( setMessages(chatMessages); setProcessingMessage(null); setStreamingMessage(null); - console.log(`getChatMessages returned ${chatMessages.length} messages.`, chatMessages); } catch (error) { console.error('Failed to load messages:', error); } diff --git a/frontend/src/components/layout/BackstoryLayout.tsx b/frontend/src/components/layout/BackstoryLayout.tsx index 3752b19..a7fca31 100644 --- a/frontend/src/components/layout/BackstoryLayout.tsx +++ b/frontend/src/components/layout/BackstoryLayout.tsx @@ -27,7 +27,7 @@ interface BackstoryPageContainerProps { const BackstoryPageContainer = (props: BackstoryPageContainerProps): JSX.Element => { const { children, variant = 'normal' } = props; - console.log({ variant }); + return ( void; + resumeId?: string; + onSave?: (updatedResume: Resume) => void; +} + +const ResumeEdit: React.FC = (props: ResumeEditProps) => { + const { setSnack } = useAppState(); + const { onClose, resumeId, onSave } = props; + const { selectedCandidate, selectedJob } = useAppState(); + const { apiClient } = useAuth(); + const [editContent, setEditContent] = useState(''); + + const [saving, setSaving] = useState(false); + const [tabValue, setTabValue] = useState('markdown'); + const [jobTabValue, setJobTabValue] = useState('chat'); + const [status, setStatus] = useState(''); + const [statusType, setStatusType] = useState(null); + const [error, setError] = useState(null); + const [selectedStyle, setSelectedStyle] = useState('corporate'); + const [resume, setResume] = useState(null); + const [isAIGenerated, setIsAIGenerated] = useState(false); + const [editPrompt, setEditPrompt] = useState(''); + + // Revision-related state + const [revisions, setRevisions] = useState([]); + const [selectedRevision, setSelectedRevision] = useState('current'); + const [loadingRevisions, setLoadingRevisions] = useState(false); + const [loadingRevision, setLoadingRevision] = useState(false); + const [saveDisabled, setSaveDisabled] = useState(true); + const [current, setCurrent] = useState(null); + const [backupContent, setBackupContent] = useState(''); + + const printContentRef = useRef(null); + const reactToPrintFn = useReactToPrint({ + contentRef: printContentRef, + pageStyle: '@page { margin: 8mm !important; }', + }); + + useEffect(() => { + setSaveDisabled( + !resume || + statusType !== null || + saving || + editContent.trim() === '' || + selectedRevision !== 'current' || + (editContent === resume?.resume && isAIGenerated === false) + ); + }, [resume, statusType, saving, editContent, isAIGenerated]); + + // Initialize edit content when dialog opens + useEffect(() => { + if (!selectedCandidate || !selectedJob) { + setSnack('No candidate or job selected for resume generation.', 'error'); + return; + } + + if (!resumeId && !resume) { + apiClient + .getResumesByCandidate(selectedCandidate.id || '', selectedJob.id || '') + .then(resumes => { + if (resumes.length > 0) { + console.log( + `Found ${resumes.length} resumes for candidate ${selectedCandidate.id} and job ${selectedJob.id}` + ); + setResume(resumes[0]); + setCurrent(resumes[0]); + setBackupContent(resumes[0].resume); + } else { + console.log( + `No resumes found for candidate ${selectedCandidate.id} and job ${selectedJob.id}. Generating new resume...` + ); + generateResume(); + } + }) + .catch(error => { + console.log(error); + }); + return; + } + + if (!resume) { + apiClient.getResume(resumeId || '').then(loadedResume => { + console.log(`Loading resume with ID: ${resumeId}`); + setResume(loadedResume); + setCurrent(loadedResume); + setBackupContent(loadedResume.resume); + }); + return; + } + + console.log(`Resume was ${resume.aiGenerated ? 'AI-generated' : 'user-generated'}`, resume); + setEditPrompt(resume.prompt || ''); + setIsAIGenerated(resume.aiGenerated || false); + setEditContent(resume.resume); + loadResumeRevisions(); + }, [resume]); + + const currentStyle = useMemo(() => { + return resumeStyles[selectedStyle]; + }, [selectedStyle]); + + // Load resume revisions + const loadResumeRevisions = async (): Promise => { + if (!resume) return; + + setLoadingRevisions(true); + try { + const response = await apiClient.getResumeRevisions(resume.id || ''); + console.log('Loaded revisions:', response.revisions); + setRevisions(response.revisions || []); + } catch (error) { + if (error instanceof Error) { + console.error('Failed to load revisions:', error); + setSnack(`Failed to load resume revisions: ${error.message || 'Unknown error'}`, 'error'); + } + } finally { + setLoadingRevisions(false); + } + }; + + // Load specific revision content + const loadRevision = async (revisionId: string): Promise => { + if (!resume || revisionId === 'current') { + return; + } + + setLoadingRevision(true); + try { + const response = await apiClient.getResumeRevision(resume.id || '', revisionId); + setResume(response.revision); + setEditPrompt(response.revision.prompt || ''); + setIsAIGenerated(response.revision.aiGenerated || false); + } catch (error) { + console.error('Failed to load revision:', error); + setSnack('Failed to load revision content.', 'error'); + } finally { + setLoadingRevision(false); + } + }; + + // Handle revision selection + const handleRevisionChange = async (event: SelectChangeEvent): Promise => { + const newRevisionId = event.target.value; + if (selectedRevision === 'current') { + setEditPrompt(current?.prompt || ''); + setIsAIGenerated(current?.aiGenerated || false); + setSelectedRevision(newRevisionId); + setBackupContent(editContent); + } else { + setSelectedRevision(newRevisionId); + if (newRevisionId === 'current') { + setEditContent(backupContent); + } + await loadRevision(newRevisionId); + } + }; + + // Restore revision to editor + const restoreRevision = (): void => { + if (!resume) { + setSnack('No revision selected to restore.', 'error'); + return; + } + setResume(resume); + setEditContent(resume.resume); + setSelectedRevision('current'); + setSnack('Revision restored to editor.', 'warning'); + }; + + // Format timestamp for display + const formatRevisionTimestamp = (timestamp: string): string => { + try { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return timestamp; + } + }; + + const handleSave = async (): Promise => { + if (!resume) { + setSnack('No resume to save.', 'error'); + return; + } + setSaving(true); + try { + const resumeUpdate = { + ...resume, + resume: editContent, + prompt: isAIGenerated ? editPrompt : 'Manual edits', + }; + const result = await apiClient.updateResume(resumeUpdate); + console.log('Resume updated:', result); + const updatedResume = { + ...resume, + ...result, + }; + setResume(updatedResume); + setCurrent(updatedResume); + setBackupContent(updatedResume.resume); + onSave && onSave(updatedResume); + setSnack('Resume updated successfully.'); + // Reload revisions to include the new version + await loadResumeRevisions(); + } catch (error) { + setSnack('Failed to update resume.', 'error'); + } finally { + setSaving(false); + } + }; + + const generateResumeHandlers: StreamingOptions = { + onMessage: (_message: Types.ChatMessageResume) => { + setStatus(''); + setStatusType(null); + setSnack('Resume generation completed successfully.'); + }, + onStreaming: (chunk: Types.ChatMessageStreaming) => { + if (status === '') { + setStatus('Generating resume...'); + setStatusType('generating'); + } + setEditContent(chunk.content); + }, + onStatus: (status: Types.ChatMessageStatus) => { + console.log('status:', status.content); + setStatusType(status.activity); + setStatus(status.content); + }, + onError: (error: Types.ChatMessageError) => { + console.log('error:', error); + setStatusType(null); + setStatus(error.content); + setError(error); + }, + }; + + const generateResume = async (): Promise => { + if (!selectedCandidate || !selectedJob) { + setSnack('No candidate or job selected for resume generation.', 'error'); + return Promise.reject('No candidate or job selected'); + } + setEditContent(''); + setStatusType('thinking'); + setStatus('Starting resume generation...'); + setSelectedRevision('current'); + const request = apiClient.generateResume( + selectedCandidate.id || '', + selectedJob.id || '', + generateResumeHandlers + ); + const generatedResume = (await request.promise).resume; + setResume(generatedResume); + setCurrent(generatedResume); + setBackupContent(generatedResume.resume); + setSelectedRevision('current'); + setEditPrompt(generatedResume.prompt || ''); + setIsAIGenerated(true); + await loadResumeRevisions(); + return generatedResume; + }; + + const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => { + if (newValue === 'print') { + console.log('Printing resume...'); + reactToPrintFn(); + return; + } + if (newValue === 'regenerate') { + setSnack('Regenerating resume...'); + setTabValue('markdown'); + generateResume(); + return; + } + if (newValue === 'undo') { + setEditContent(current?.resume || ''); + setEditPrompt(current?.prompt || ''); + setIsAIGenerated(current?.aiGenerated || false); + setTabValue('markdown'); + return; + } + setTabValue(newValue); + }; + + const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => { + setJobTabValue(newValue); + }; + + const handleClose = (): void => { + onClose && onClose(); + }; + + const changeLog = + (selectedRevision !== 'current' && resume?.aiGenerated ? resume?.prompt : '') || + (isAIGenerated ? editPrompt : '') || + 'Manual edits'; + console.log( + `Change log: ${changeLog}, isAIGenerated: ${isAIGenerated}, editPrompt: ${editPrompt}, selectedRevision: ${selectedRevision}, resume?.aiGenerated: ${resume?.aiGenerated}` + ); + + return ( + + + + + + {resume && ( + <> + + Resume for {resume.candidate?.fullName || resume.candidateId},{' '} + {resume.job?.title || 'No Job Title Assigned'},{' '} + {resume.job?.company || 'No Company Assigned'} + + + Resume ID: {resume.id} + + + )} + {!resume && ( + + No resume loaded. Please wait for generation to complete. + + )} + + + {/* Style Selector */} + + Resume Style + + + + + + + + + + Version History + + + + + + + + + + + + {selectedRevision !== 'current' && ( + <> + + + + + )} + + + + + + + + + + + + } label="Markdown" /> + } + label="Changes" + /> + } label="Preview" /> + } + label="Print" + /> + } label="Regenerate" /> + } + label="Revert" + /> + + + + {status && ( + + + {statusType && } + + {status || 'Processing...'} + + + {status && !error && } + + )} + + + {selectedRevision !== 'current' && ( + + You are viewing a previous version. Click "Restore" to load this content + into the editor. + + )} + + {isAIGenerated && ( + + This resume was generated by AI and has not been manually edited. Review and then + selecte 'Save'. + + )} + + + *:not(.Scrollable)': { + flexShrink: 0, + }, + position: 'relative', + }} + > + {tabValue === 'markdown' && ( + <> + {selectedRevision === 'current' ? ( + setEditContent(value)} + style={{ + position: 'relative', + maxHeight: '100%', + height: '100%', + width: '100%', + display: 'flex', + minHeight: '100%', + flexGrow: 1, + flex: 1, + overflowY: 'auto', + fontFamily: 'monospace', + backgroundColor: '#fafafa', + fontSize: '12px', + }} + placeholder="Enter resume content..." + /> + ) : ( + + {loadingRevision ? ( + + + Loading revision... + + ) : ( +
{resume?.resume}
+ )} +
+ )} + + )} + {tabValue === 'diff' && current && ( + + )} + {tabValue === 'preview' && resume && resume.candidate && ( + + + + )} +
+
+ + + + {resume && resume.job !== undefined && ( + } label="Job" /> + )} + } label="AI Edit" /> + + + {resume && resume.job !== undefined && jobTabValue === 'job' && ( + + )} + {jobTabValue === 'chat' && resume && ( + { + console.log('onResumeChange:', prompt); + if (newResume !== editContent) { + setBackupContent(editContent); + setEditPrompt(prompt); + setEditContent(newResume); + setIsAIGenerated(true); + } + }} + sx={{ + m: 1, + p: 1, + flexGrow: 1, + position: 'relative', + maxWidth: 'fit-content', + minWidth: '100%', + }} + /> + )} + + +
+
+ {resume && ( + + {onClose && } + + + Last saved: {resume.updatedAt ? new Date(resume.updatedAt).toLocaleString() : 'N/A'} + + + )} +
+ ); +}; + +export { ResumeEdit }; diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx index ab440ab..885d4c4 100644 --- a/frontend/src/components/ui/ResumeInfo.tsx +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Typography, SxProps, CardHeader, - Button, - LinearProgress, IconButton, Tooltip, Card, @@ -13,63 +11,21 @@ import { Divider, useTheme, useMediaQuery, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Tabs, - Tab, - Paper, - FormControl, - Select, - MenuItem, - InputLabel, - Chip, - Alert, - Stack, - SelectChangeEvent, } from '@mui/material'; -import PrintIcon from '@mui/icons-material/Print'; -// import SaveIcon from '@mui/icons-material/Save'; -// import UndoIcon from '@mui/icons-material/Undo'; -// import PreviewIcon from '@mui/icons-material/Preview'; -import DifferenceIcon from '@mui/icons-material/Difference'; -// import EditDocumentIcon from '@mui/icons-material/EditDocument'; import { Delete as DeleteIcon, Restore as RestoreIcon, - Save as SaveIcon, Edit as EditIcon, Description as DescriptionIcon, - Work as WorkIcon, Person as PersonIcon, Schedule as ScheduleIcon, - ModelTraining, - History as HistoryIcon, - RestoreFromTrash as RestoreFromTrashIcon, - Refresh as RefreshIcon, - PrecisionManufacturing, - // Language as WebsiteIcon, + Work as WorkIcon, } from '@mui/icons-material'; -import TuneIcon from '@mui/icons-material/Tune'; -import PreviewIcon from '@mui/icons-material/Preview'; -import EditDocumentIcon from '@mui/icons-material/EditDocument'; - -import { useReactToPrint } from 'react-to-print'; import { useAuth } from 'hooks/AuthContext'; import { useAppState } from 'hooks/GlobalContext'; import { StyledMarkdown } from 'components/StyledMarkdown'; import { Resume } from 'types/types'; -import { BackstoryTextField } from 'components/BackstoryTextField'; -import { JobInfo } from './JobInfo'; -import { Scrollable } from 'components/Scrollable'; -import * as Types from 'types/types'; -import { StreamingOptions } from 'services/api-client'; -import { StatusBox, StatusIcon } from './StatusIcon'; -import { ResumeChat } from 'components/ResumeChat'; -import { DiffViewer } from 'components/DiffViewer'; -import { ResumePreview, resumeStyles } from './ResumePreview'; import { useNavigate } from 'react-router-dom'; interface ResumeInfoProps { @@ -80,16 +36,6 @@ interface ResumeInfoProps { variant?: 'minimal' | 'small' | 'normal' | 'all' | null; } -// Resume revision interface -interface ResumeRevision { - revisionId: string; - revisionTimestamp: string; - updatedAt: string; - createdAt: string; - candidateId: string; - jobId: string; -} - const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const { setSnack } = useAppState(); const navigate = useNavigate(); @@ -101,31 +47,6 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const isAdmin = user?.isAdmin; const [activeResume, setActiveResume] = useState({ ...resume }); const [deleted, setDeleted] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [editContent, setEditContent] = useState(''); - const [editSystemPrompt, setEditSystemPrompt] = useState(''); - const [editPrompt, setEditPrompt] = useState(''); - const [lastRevision, setLastRevision] = useState(null); - const [saving, setSaving] = useState(false); - const [tabValue, setTabValue] = useState('markdown'); - const [jobTabValue, setJobTabValue] = useState('chat'); - const [status, setStatus] = useState(''); - const [statusType, setStatusType] = useState(null); - const [error, setError] = useState(null); - const [selectedStyle, setSelectedStyle] = useState('corporate'); - - // New revision-related state - const [revisions, setRevisions] = useState([]); - const [selectedRevision, setSelectedRevision] = useState('current'); - const [loadingRevisions, setLoadingRevisions] = useState(false); - const [loadingRevision, setLoadingRevision] = useState(false); - const [revisionContent, setRevisionContent] = useState(''); - - const printContentRef = useRef(null); - const reactToPrintFn = useReactToPrint({ - contentRef: printContentRef, - pageStyle: '@page { margin: 8mm !important; }', - }); useEffect(() => { if (resume && resume.id !== activeResume?.id) { @@ -133,90 +54,6 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { } }, [resume, activeResume]); - // Load revisions when dialog opens - useEffect(() => { - if (editDialogOpen && activeResume.id) { - loadResumeRevisions(); - } - }, [editDialogOpen, activeResume.id]); - - const currentStyle = useMemo(() => { - return resumeStyles[selectedStyle]; - }, [selectedStyle]); - - // Load resume revisions - const loadResumeRevisions = async (): Promise => { - if (!activeResume.id) return; - - setLoadingRevisions(true); - try { - const response = await apiClient.getResumeRevisions(activeResume.id); - console.log('Loaded revisions:', response.revisions); - setRevisions(response.revisions || []); - } catch (error) { - if (error instanceof Error) { - console.error('Failed to load revisions:', error); - setSnack(`Failed to load resume revisions: ${error.message || 'Unknown error'}`); - } - } finally { - setLoadingRevisions(false); - } - }; - - // Load specific revision content - const loadRevisionContent = async (revisionId: string): Promise => { - if (!activeResume.id || revisionId === 'current') { - setRevisionContent(editContent); - return; - } - - setLoadingRevision(true); - try { - const response = await apiClient.getResumeRevision(activeResume.id, revisionId); - setRevisionContent(response.revision.resume || ''); - } catch (error) { - console.error('Failed to load revision:', error); - setSnack('Failed to load revision content.'); - } finally { - setLoadingRevision(false); - } - }; - - // Handle revision selection - const handleRevisionChange = async (event: SelectChangeEvent): Promise => { - const newRevisionId = event.target.value; - if (selectedRevision === 'current') { - setLastRevision(activeResume); - } - setSelectedRevision(newRevisionId); - await loadRevisionContent(newRevisionId); - }; - - // Restore revision to editor - const restoreRevision = (): void => { - setEditContent(revisionContent); - setSelectedRevision('current'); - setLastRevision(null); - setSnack('Revision restored to editor.'); - }; - - // Format timestamp for display - const formatRevisionTimestamp = (timestamp: string): string => { - try { - const date = new Date(timestamp); - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return timestamp; - } - }; - - // Rest of the existing methods... const deleteResume = async (id: string | undefined): Promise => { if (id) { try { @@ -233,40 +70,8 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { setActiveResume({ ...resume }); }; - const handleSave = async (prompt = ''): Promise => { - setSaving(true); - try { - const resumeUpdate = { - ...activeResume, - resume: editContent, - systemPrompt: editSystemPrompt, - prompt: prompt || editPrompt, - }; - const result = await apiClient.updateResume(resumeUpdate); - console.log('Resume updated:', result); - const updatedResume = { - ...activeResume, - ...result, - }; - setActiveResume(updatedResume); - setSnack('Resume updated successfully.'); - // Reload revisions to include the new version - await loadResumeRevisions(); - } catch (error) { - setSnack('Failed to update resume.'); - } finally { - setSaving(false); - } - }; - const handleEditOpen = (): void => { - setEditContent(activeResume.resume); - setEditSystemPrompt(activeResume.systemPrompt || ''); - setEditPrompt(activeResume.prompt || ''); - setSelectedRevision('current'); - setLastRevision(null); - setRevisionContent(activeResume.resume); - setEditDialogOpen(true); + navigate(`/candidate/resumes/${activeResume.id}/edit`); }; if (!resume) { @@ -289,66 +94,6 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { } }; - const generateResumeHandlers: StreamingOptions = { - onMessage: (message: Types.ChatMessageResume) => { - const resume: Resume = message.resume; - setEditSystemPrompt(resume.systemPrompt || ''); - setEditPrompt(resume.prompt || ''); - setEditContent(resume.resume); - setActiveResume({ ...resume }); - setStatus(''); - setSnack('Resume generation completed successfully.'); - }, - onStreaming: (chunk: Types.ChatMessageStreaming) => { - if (status === '') { - setStatus('Generating resume...'); - setStatusType('generating'); - } - setEditContent(chunk.content); - }, - onStatus: (status: Types.ChatMessageStatus) => { - console.log('status:', status.content); - setStatusType(status.activity); - setStatus(status.content); - }, - onError: (error: Types.ChatMessageError) => { - console.log('error:', error); - setStatusType(null); - setStatus(error.content); - setError(error); - }, - }; - - const generateResume = async (): Promise => { - setStatusType('thinking'); - setStatus('Starting resume generation...'); - setActiveResume({ ...activeResume, resume: '' }); - const request = await apiClient.generateResume( - activeResume.candidateId || '', - activeResume.jobId || '', - generateResumeHandlers - ); - await request.promise; - }; - - const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => { - if (newValue === 'print') { - console.log('Printing resume...'); - reactToPrintFn(); - return; - } - if (newValue === 'regenerate') { - setSnack('Regenerating resume...'); - generateResume(); - return; - } - setTabValue(newValue); - }; - - const handleJobTabChange = (event: React.SyntheticEvent, newValue: string): void => { - setJobTabValue(newValue); - }; - return ( = (props: ResumeInfoProps) => { avatar={} sx={{ p: 0, pb: 1 }} action={ - + @@ -533,416 +278,8 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { - - {saving && ( - - - - Saving resume... - - - )} )} - - {/* Edit Dialog */} - { - setEditDialogOpen(false); - }} - maxWidth="lg" - fullWidth - disableEscapeKeyDown={true} - fullScreen={true} - > - - - - Edit Resume Content - - Resume for {activeResume.candidate?.fullName || activeResume.candidateId},{' '} - {activeResume.job?.title || 'No Job Title Assigned'},{' '} - {activeResume.job?.company || 'No Company Assigned'} - - - Resume ID: # {activeResume.id} - - - Last saved:{' '} - {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'} - - - - - {/* Style Selector */} - - Resume Style - - - - - - } label="Markdown" /> - {lastRevision && } label="Changes" />} - } label="Preview" /> - } label="Print" /> - {activeResume.systemPrompt && ( - } label="Context" /> - )} - } label="Regenerate" /> - - - - - - - - {status && ( - - - {statusType && } - - {status || 'Processing...'} - - - {status && !error && } - - )} - - {/* Revision History Dropdown for Markdown Tab */} - {tabValue === 'markdown' && ( - - - - - - - Version History - - - - - - - - - - - - {selectedRevision !== 'current' && ( - <> - - - - - - - - - )} - - - {selectedRevision !== 'current' && ( - - You are viewing a previous version. Click "Restore" to load this - content into the editor. - - )} - - )} - - *:not(.Scrollable)': { - flexShrink: 0, - }, - position: 'relative', - }} - > - {tabValue === 'markdown' && ( - <> - {selectedRevision === 'current' ? ( - setEditContent(value)} - style={{ - position: 'relative', - maxHeight: '100%', - height: '100%', - width: '100%', - display: 'flex', - minHeight: '100%', - flexGrow: 1, - flex: 1, - overflowY: 'auto', - fontFamily: 'monospace', - backgroundColor: '#fafafa', - fontSize: '12px', - }} - placeholder="Enter resume content..." - /> - ) : ( - - {loadingRevision ? ( - - - Loading revision... - - ) : ( -
{revisionContent}
- )} -
- )} - - )} - {tabValue === 'context' && ( - - )} - {tabValue === 'prompt' && ( - setEditPrompt(value)} - style={{ - position: 'relative', - maxHeight: '100%', - height: '100%', - width: '100%', - display: 'flex', - minHeight: '100%', - flexGrow: 1, - flex: 1, - overflowY: 'auto', - }} - placeholder="Edit prompt..." - /> - )} - {tabValue === 'diff' && lastRevision && ( - - )} - {tabValue === 'preview' && activeResume.candidate && ( - - - - )} -
-
- - - - {activeResume.job !== undefined && ( - } label="Job" /> - )} - } label="AI Edit" /> - - - {activeResume.job !== undefined && jobTabValue === 'job' && ( - - )} - {jobTabValue === 'chat' && ( - { - if (newResume !== editContent) { - handleSave(prompt).then(() => { - setEditContent(newResume); - setLastRevision(activeResume); - setActiveResume({ ...activeResume, prompt, resume: newResume }); - }); - } - }} - sx={{ - m: 1, - p: 1, - flexGrow: 1, - position: 'relative', - maxWidth: 'fit-content', - minWidth: '100%', - }} - /> - )} - - -
-
- - - - -
); }; diff --git a/frontend/src/components/ui/ResumePreview.tsx b/frontend/src/components/ui/ResumePreview.tsx index 180f33e..0fd32fa 100644 --- a/frontend/src/components/ui/ResumePreview.tsx +++ b/frontend/src/components/ui/ResumePreview.tsx @@ -153,78 +153,6 @@ const generateResumeStyles = (): Record => { background: '#ffffff', }, }, - creative: { - name: 'Creative', - description: 'Colorful, unique design with personality', - headerStyle: { - display: 'flex', - flexDirection: 'row', - fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif', - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - color: '#ffffff', - padding: 2.5, - borderRadius: 1.5, - marginBottom: 3, - } as SxProps, - footerStyle: { - fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif', - background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', - color: '#ffffff', - paddingTop: 2, - borderRadius: 1.5, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - textTransform: 'uppercase', - alignContent: 'center', - fontSize: '0.8rem', - pb: 2, - mb: 2, - } as SxProps, - contentStyle: { - fontFamily: '"Open Sans", Arial, sans-serif', - lineHeight: 1.6, - color: '#444444', - } as SxProps, - markdownStyle: { - fontFamily: '"Open Sans", Arial, sans-serif', - '& h1, & h2, & h3': { - fontFamily: '"Montserrat", "Helvetica Neue", Arial, sans-serif', - color: '#667eea', - fontWeight: 600, - marginBottom: 2, - }, - '& h1': { - fontSize: '1.5rem', - }, - '& h2': { - fontSize: '1.25rem', - }, - '& h3': { - fontSize: '1.1rem', - }, - '& p, & li': { - lineHeight: 1.6, - marginBottom: 1, - color: '#444444', - }, - '& strong': { - color: '#764ba2', - fontWeight: 600, - }, - '& ul': { - paddingLeft: 3, - }, - } as SxProps, - color: { - primary: '#667eea', - secondary: '#764ba2', - accent: '#f093fb', - text: '#444444', - background: '#ffffff', - }, - }, corporate: { name: 'Corporate', description: 'Formal, structured business format', @@ -444,7 +372,7 @@ const StyledFooter: React.FC = ({ resume, style, isMobile }) sx={{ ...style.footerStyle, color: style.color.secondary, - fontSize: isMobile ? '0.6rem' : '1rem', + fontSize: isMobile ? '0.6rem' : '0.8rem', }} > Dive deeper into my qualifications at Backstory... @@ -455,7 +383,9 @@ const StyledFooter: React.FC = ({ resume, style, isMobile }) className="qr-code" sx={{ display: 'flex', mt: 1, mb: 1 }} /> - {window.location.protocol}://{window.location.host}/chat/{resume.id} + {window.location.protocol} + {'//'} + {window.location.host}/chat/{resume.id}   diff --git a/frontend/src/components/ui/ResumeViewer.tsx b/frontend/src/components/ui/ResumeViewer.tsx index 720dd4d..032b0b3 100644 --- a/frontend/src/components/ui/ResumeViewer.tsx +++ b/frontend/src/components/ui/ResumeViewer.tsx @@ -40,6 +40,7 @@ import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assumin import { useNavigate, useParams } from 'react-router-dom'; import { Resume } from 'types/types'; import { formatDate } from 'utils/formatDate'; +import { ResumeEdit } from './ResumeEdit'; type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId'; type SortOrder = 'asc' | 'desc'; @@ -64,6 +65,7 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + const { resumeId, mode = 'view' } = useParams<{ resumeId?: string; mode?: string }>(); const { apiClient } = useAuth(); const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context @@ -74,7 +76,6 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI const [mobileDialogOpen, setMobileDialogOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filteredResumes, setFilteredResumes] = useState([]); - const { resumeId } = useParams<{ resumeId?: string }>(); useEffect(() => { if (resumeId) { @@ -103,19 +104,18 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI const getResumes = async (): Promise => { try { - let results; + let result: Resume[]; if (candidateId) { - results = await apiClient.getResumesByCandidate(candidateId); + result = await apiClient.getResumesByCandidate(candidateId); } else if (jobId) { - results = await apiClient.getResumesByJob(jobId); + result = await apiClient.getResumesByJob(jobId); } else { - results = await apiClient.getResumes(); + result = await apiClient.getResumes(); } - const resumesData: Resume[] = results.resumes || []; - setResumes(resumesData); - setFilteredResumes(resumesData); + setResumes(result); + setFilteredResumes(result); } catch (err) { console.error('Failed to load resumes:', err); setSnack('Failed to load resumes: ' + err, 'error'); @@ -521,6 +521,22 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI ); + const handleSave = (updatedResume: Resume): void => { + setSelectedResume(updatedResume); + }; + + if (mode === 'edit' && selectedResume) { + return ( + { + navigate(window.location.pathname.replace('/edit', '')); + }} + resumeId={selectedResume.id} + onSave={handleSave} + /> + ); + } + if (isMobile) { return ( , component: , variant: 'fullWidth', diff --git a/frontend/src/hooks/GlobalContext.tsx b/frontend/src/hooks/GlobalContext.tsx index 97e556f..60f2334 100644 --- a/frontend/src/hooks/GlobalContext.tsx +++ b/frontend/src/hooks/GlobalContext.tsx @@ -12,6 +12,7 @@ import * as Types from 'types/types'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from 'hooks/AuthContext'; import { SetSnackType, SeverityType, Snack } from 'components/Snack'; +import { ApiErrorDetails } from 'services/api-client'; // ============================ // Storage Keys @@ -411,8 +412,8 @@ const AppStateContext = createContext(null); export function AppStateProvider({ children }: { children: React.ReactNode }): JSX.Element { const appState = useAppStateLogic(); + const { apiClient } = useAuth(); const snackRef = useRef<{ setSnack: SetSnackType }>(null); - // Global UI components appState.setSnack = useCallback( (message: string, severity?: SeverityType) => { @@ -421,6 +422,27 @@ export function AppStateProvider({ children }: { children: React.ReactNode }): J [snackRef] ); + apiClient.setErrorCallback((error: ApiErrorDetails) => { + console.log('API Error:', error); + + // Handle authorization failures + if (error.status === 401) { + // Token expired or invalid - redirect to login + appState.setSnack('Authentication token expired or invalid. Please login again.', 'error'); + } else if (error.status === 403) { + // Forbidden - show access denied message + appState.setSnack('Access denied', 'error'); + } else if (error.status >= 500) { + // Server error - show generic error message + appState.setSnack('Server error. Please try again later.', 'error'); + } else { + appState.setSnack( + `Error: ${('message' in error && error.message) || 'An unexpected error occurred.'}`, + 'error' + ); + } + }); + return ( {children} diff --git a/frontend/src/pages/JobAnalysisPage.tsx b/frontend/src/pages/JobAnalysisPage.tsx index 40b7c4d..f3219bf 100644 --- a/frontend/src/pages/JobAnalysisPage.tsx +++ b/frontend/src/pages/JobAnalysisPage.tsx @@ -27,9 +27,9 @@ import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext'; import { CandidatePicker } from 'components/ui/CandidatePicker'; import { JobCreator } from 'components/JobCreator'; import { LoginRestricted } from 'components/ui/LoginRestricted'; -import { ResumeGenerator } from 'components/ResumeGenerator'; import { JobsView } from 'components/ui/JobsView'; import { useNavigate, useParams } from 'react-router-dom'; +import { ResumeEdit } from 'components/ui/ResumeEdit'; function WorkAddIcon(): JSX.Element { return ( @@ -403,13 +403,7 @@ const JobAnalysisPage: React.FC = () => { return <>; } - return ( - - ); + return ; }; // Don't render until initialized to avoid flash of incorrect state @@ -652,7 +646,7 @@ const JobAnalysisPage: React.FC = () => { }, }} > - {!isMobile && 'Generate '}Resume + {!isMobile && 'Edit '}Resume diff --git a/frontend/src/pages/candidate/RegistrationForms.tsx b/frontend/src/pages/candidate/RegistrationForms.tsx index 6f784b4..3f93061 100644 --- a/frontend/src/pages/candidate/RegistrationForms.tsx +++ b/frontend/src/pages/candidate/RegistrationForms.tsx @@ -24,7 +24,7 @@ import { InputAdornment, } from '@mui/material'; import { Visibility, VisibilityOff } from '@mui/icons-material'; -import { ApiClient, RegistrationResponse } from 'services/api-client'; +import { RegistrationResponse } from 'services/api-client'; import { RegistrationSuccessDialog } from 'components/EmailVerificationComponents'; import { useAuth } from 'hooks/AuthContext'; import { useNavigate } from 'react-router-dom'; @@ -390,6 +390,7 @@ const CandidateRegistrationForm = (): JSX.Element => { // Employer Registration Form const EmployerRegistrationForm = (): JSX.Element => { + const { apiClient } = useAuth(); const [formData, setFormData] = useState({ email: '', username: '', @@ -412,8 +413,6 @@ const EmployerRegistrationForm = (): JSX.Element => { const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const apiClient = new ApiClient(); - const industryOptions = [ 'Technology', 'Healthcare', diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 17de6cb..eab3a3f 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -36,6 +36,22 @@ const TOKEN_STORAGE = { PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail', } as const; +// ============================ +// Error Handling Types +// ============================ + +export interface ApiErrorDetails { + status: number; + statusText: string; + url: string; + method: string; + errorBody?: any; + headers?: Record; + timestamp: Date; +} + +export type ApiErrorCallback = (error: ApiErrorDetails) => void | Promise; + // ============================ // Streaming Types and Interfaces // ============================ @@ -135,6 +151,7 @@ export interface CandidateSessionsResponse { class ApiClient { private baseUrl: string; private defaultHeaders: Record; + private errorCallback?: ApiErrorCallback; constructor(accessToken?: string) { const loc = window.location; @@ -149,6 +166,124 @@ class ApiClient { }; } + // ============================ + // Error Handling Methods + // ============================ + + /** + * Set the error callback for handling API errors + */ + setErrorCallback(callback: ApiErrorCallback): void { + this.errorCallback = callback; + } + + /** + * Clear the registered error callback + */ + clearErrorCallback(): void { + this.errorCallback = undefined; + } + + /** + * Centralized fetch wrapper with error handling + */ + private async fetchWithErrorHandling(url: string, options: RequestInit = {}): Promise { + const fullUrl = url.startsWith('http') ? url : `${this.baseUrl}${url}`; + const method = options.method || 'GET'; + + try { + const response = await fetch(fullUrl, { + ...options, + headers: { + ...this.defaultHeaders, + ...options.headers, + }, + }); + + // Handle error responses + if (!response.ok) { + const errorDetails: ApiErrorDetails = { + status: response.status, + statusText: response.statusText, + url: fullUrl, + method, + timestamp: new Date(), + }; + + // Try to extract error body + try { + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + errorDetails.errorBody = await response.clone().json(); + } else { + errorDetails.errorBody = await response.clone().text(); + } + } catch { + // Ignore errors when parsing error body + } + + // Extract relevant response headers + errorDetails.headers = {}; + response.headers.forEach((value, key) => { + if (errorDetails.headers) { + errorDetails.headers[key] = value; + } + }); + + // Call the error callback if registered + if (this.errorCallback) { + try { + await this.errorCallback(errorDetails); + } catch (callbackError) { + console.error('Error callback failed:', callbackError); + } + } + + // Handle specific error types + if (response.status === 429) { + const rateLimitData = errorDetails.errorBody; + const retryAfter = response.headers.get('Retry-After'); + + throw new RateLimitError( + rateLimitData?.detail?.message || 'Rate limit exceeded', + parseInt(retryAfter || '60'), + rateLimitData?.detail?.remaining || {} + ); + } + + // Return the response for existing error handling to process + return response; + } + + return response; + } catch (error) { + // Handle network errors or other fetch failures + if (error instanceof RateLimitError) { + throw error; // Re-throw rate limit errors + } + + const errorDetails: ApiErrorDetails = { + status: 0, + statusText: 'Network Error', + url: fullUrl, + method, + errorBody: error instanceof Error ? error.message : 'Unknown error', + timestamp: new Date(), + }; + + // Call the error callback for network errors too + if (this.errorCallback) { + try { + await this.errorCallback(errorDetails); + } catch (callbackError) { + console.error('Error callback failed:', callbackError); + } + } + + throw error; + } + } + // ============================ // Response Handlers with Date Conversion // ============================ @@ -207,9 +342,8 @@ class ApiClient { * Create candidate with email verification */ async createCandidate(candidate: CreateCandidateRequest): Promise { - const response = await fetch(`${this.baseUrl}/candidates`, { + const response = await this.fetchWithErrorHandling('/candidates', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(candidate)), }); @@ -217,9 +351,8 @@ class ApiClient { } async createCandidateAI(userMessage: Types.ChatMessageUser): Promise { - const response = await fetch(`${this.baseUrl}/candidates/ai`, { + const response = await this.fetchWithErrorHandling('/candidates/ai', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(userMessage)), }); @@ -237,9 +370,8 @@ class ApiClient { async createEmployerWithVerification( employer: CreateEmployerRequest ): Promise { - const response = await fetch(`${this.baseUrl}/employers`, { + const response = await this.fetchWithErrorHandling('/employers', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(employer)), }); @@ -250,9 +382,8 @@ class ApiClient { * Verify email address */ async verifyEmail(request: EmailVerificationRequest): Promise { - const response = await fetch(`${this.baseUrl}/auth/verify-email`, { + const response = await this.fetchWithErrorHandling('/auth/verify-email', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -263,9 +394,8 @@ class ApiClient { * Resend verification email */ async resendVerificationEmail(request: ResendVerificationRequest): Promise<{ message: string }> { - const response = await fetch(`${this.baseUrl}/auth/resend-verification`, { + const response = await this.fetchWithErrorHandling('/auth/resend-verification', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -276,9 +406,8 @@ class ApiClient { * Request MFA for new device */ async requestMFA(request: MFARequest): Promise { - const response = await fetch(`${this.baseUrl}/auth/mfa/request`, { + const response = await this.fetchWithErrorHandling('/auth/mfa/request', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -290,9 +419,8 @@ class ApiClient { */ async verifyMFA(request: Types.MFAVerifyRequest): Promise { const formattedRequest = formatApiRequest(request); - const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, { + const response = await this.fetchWithErrorHandling('/auth/mfa/verify', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formattedRequest), }); @@ -303,9 +431,8 @@ class ApiClient { * login with device detection */ async login(auth: Types.LoginRequest): Promise { - const response = await fetch(`${this.baseUrl}/auth/login`, { + const response = await this.fetchWithErrorHandling('/auth/login', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(auth)), }); @@ -319,9 +446,8 @@ class ApiClient { accessToken: string, refreshToken: string ): Promise<{ message: string; tokensRevoked: any }> { - const response = await fetch(`${this.baseUrl}/auth/logout`, { + const response = await this.fetchWithErrorHandling('/auth/logout', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify( formatApiRequest({ accessToken, @@ -337,9 +463,8 @@ class ApiClient { * Logout from all devices */ async logoutAllDevices(): Promise<{ message: string }> { - const response = await fetch(`${this.baseUrl}/auth/logout-all`, { + const response = await this.fetchWithErrorHandling('/auth/logout-all', { method: 'POST', - headers: this.defaultHeaders, }); return handleApiResponse<{ message: string }>(response); @@ -353,9 +478,7 @@ class ApiClient { * Get trusted devices for current user */ async getTrustedDevices(): Promise { - const response = await fetch(`${this.baseUrl}/auth/trusted-devices`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/auth/trusted-devices'); return handleApiResponse(response); } @@ -364,9 +487,8 @@ class ApiClient { * Remove trusted device */ async removeTrustedDevice(deviceId: string): Promise<{ message: string }> { - const response = await fetch(`${this.baseUrl}/auth/trusted-devices/${deviceId}`, { + const response = await this.fetchWithErrorHandling(`/auth/trusted-devices/${deviceId}`, { method: 'DELETE', - headers: this.defaultHeaders, }); return handleApiResponse<{ message: string }>(response); @@ -376,9 +498,7 @@ class ApiClient { * Get security log for current user */ async getSecurityLog(days = 7): Promise { - const response = await fetch(`${this.baseUrl}/auth/security-log?days=${days}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/auth/security-log?days=${days}`); return handleApiResponse(response); } @@ -391,9 +511,7 @@ class ApiClient { * Get pending user verifications (admin only) */ async getPendingVerifications(): Promise { - const response = await fetch(`${this.baseUrl}/admin/pending-verifications`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/admin/pending-verifications'); return handleApiResponse(response); } @@ -402,9 +520,8 @@ class ApiClient { * Manually verify user (admin only) */ async manuallyVerifyUser(userId: string, reason: string): Promise<{ message: string }> { - const response = await fetch(`${this.baseUrl}/admin/verify-user/${userId}`, { + const response = await this.fetchWithErrorHandling(`/admin/verify-user/${userId}`, { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest({ reason })), }); @@ -415,11 +532,8 @@ class ApiClient { * Get user security events (admin only) */ async getUserSecurityEvents(userId: string, days = 30): Promise { - const response = await fetch( - `${this.baseUrl}/admin/users/${userId}/security-events?days=${days}`, - { - headers: this.defaultHeaders, - } + const response = await this.fetchWithErrorHandling( + `/admin/users/${userId}/security-events?days=${days}` ); return handleApiResponse(response); @@ -523,9 +637,8 @@ class ApiClient { // Authentication Methods // ============================ async refreshToken(refreshToken: string): Promise { - const response = await fetch(`${this.baseUrl}/auth/refresh`, { + const response = await this.fetchWithErrorHandling('/auth/refresh', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest({ refreshToken })), }); @@ -538,9 +651,7 @@ class ApiClient { // reference can be candidateId, username, or email async getCandidate(reference: string): Promise { - const response = await fetch(`${this.baseUrl}/candidates/${reference}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/candidates/${reference}`); return this.handleApiResponseWithConversion(response, 'Candidate'); } @@ -552,9 +663,8 @@ class ApiClient { skills: [], }; const request = formatApiRequest(data); - const response = await fetch(`${this.baseUrl}/candidates/job-analysis`, { + const response = await this.fetchWithErrorHandling('/candidates/job-analysis', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(request), }); @@ -563,9 +673,8 @@ class ApiClient { async updateCandidate(id: string, updates: Partial): Promise { const request = formatApiRequest(updates); - const response = await fetch(`${this.baseUrl}/candidates/${id}`, { + const response = await this.fetchWithErrorHandling(`/candidates/${id}`, { method: 'PATCH', - headers: this.defaultHeaders, body: JSON.stringify(request), }); @@ -573,9 +682,8 @@ class ApiClient { } async deleteCandidate(id: string): Promise { - const response = await fetch(`${this.baseUrl}/candidates/${id}`, { + const response = await this.fetchWithErrorHandling(`/candidates/${id}`, { method: 'DELETE', - headers: this.defaultHeaders, body: JSON.stringify({ id }), }); @@ -583,9 +691,8 @@ class ApiClient { } async deleteJob(id: string): Promise { - const response = await fetch(`${this.baseUrl}/jobs/${id}`, { + const response = await this.fetchWithErrorHandling(`/jobs/${id}`, { method: 'DELETE', - headers: this.defaultHeaders, body: JSON.stringify({ id }), }); @@ -610,7 +717,7 @@ class ApiClient { formData.append('file', file); formData.append('filename', file.name); - const response = await fetch(`${this.baseUrl}/candidates/profile/upload`, { + const response = await this.fetchWithErrorHandling('/candidates/profile/upload', { method: 'POST', headers: { // Don't set Content-Type - browser will set it automatically with boundary @@ -630,9 +737,7 @@ class ApiClient { const paginatedRequest = createPaginatedRequest(request); const params = toUrlParams(formatApiRequest(paginatedRequest)); - const response = await fetch(`${this.baseUrl}/candidates?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/candidates?${params}`); return this.handlePaginatedApiResponseWithConversion(response, 'Candidate'); } @@ -649,9 +754,7 @@ class ApiClient { }; const params = toUrlParams(formatApiRequest(searchRequest)); - const response = await fetch(`${this.baseUrl}/candidates/search?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/candidates/search?${params}`); return this.handlePaginatedApiResponseWithConversion(response, 'Candidate'); } @@ -661,9 +764,8 @@ class ApiClient { // ============================ async createEmployer(request: CreateEmployerRequest): Promise { - const response = await fetch(`${this.baseUrl}/employers`, { + const response = await this.fetchWithErrorHandling('/employers', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -671,17 +773,14 @@ class ApiClient { } async getEmployer(id: string): Promise { - const response = await fetch(`${this.baseUrl}/employers/${id}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/employers/${id}`); return this.handleApiResponseWithConversion(response, 'Employer'); } async updateEmployer(id: string, updates: Partial): Promise { - const response = await fetch(`${this.baseUrl}/employers/${id}`, { + const response = await this.fetchWithErrorHandling(`/employers/${id}`, { method: 'PATCH', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(updates)), }); @@ -692,9 +791,8 @@ class ApiClient { // Job Methods with Date Conversion // ============================ async updateJob(id: string, updates: Partial): Promise { - const response = await fetch(`${this.baseUrl}/jobs/${id}`, { + const response = await this.fetchWithErrorHandling(`/jobs/${id}`, { method: 'PATCH', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(updates)), }); @@ -718,9 +816,8 @@ class ApiClient { job: Omit ): Promise { const body = JSON.stringify(formatApiRequest(job)); - const response = await fetch(`${this.baseUrl}/jobs`, { + const response = await this.fetchWithErrorHandling('/jobs', { method: 'POST', - headers: this.defaultHeaders, body: body, }); @@ -736,73 +833,57 @@ class ApiClient { } // Additional API methods for Resume management - async getResumes(): Promise<{ - success: boolean; - resumes: Types.Resume[]; - count: number; - }> { - const response = await fetch(`${this.baseUrl}/resumes`, { - headers: this.defaultHeaders, - }); + async getResumes(): Promise { + const response = await this.fetchWithErrorHandling('/resumes'); - return handleApiResponse<{ + const result = await handleApiResponse<{ success: boolean; resumes: Types.Resume[]; - count: number; }>(response); + + // Convert all history items with proper date handling + return convertArrayFromApi(result.resumes, 'Resume'); } async getResume(resumeId: string): Promise { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/resumes/${resumeId}`); return this.handleApiResponseWithConversion(response, 'Resume'); } async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { + const response = await this.fetchWithErrorHandling(`/resumes/${resumeId}`, { method: 'DELETE', - headers: this.defaultHeaders, }); return handleApiResponse<{ success: boolean; message: string }>(response); } - async getResumesByCandidate(candidateId: string): Promise<{ - success: boolean; - candidateId: string; - resumes: Types.Resume[]; - count: number; - }> { - const response = await fetch(`${this.baseUrl}/resumes/candidate/${candidateId}`, { - headers: this.defaultHeaders, - }); - - return handleApiResponse<{ + async getResumesByCandidate(candidateId: string, jobId?: string): Promise { + let options = candidateId; + if (jobId) { + options += `/${jobId}`; + } + const response = await this.fetchWithErrorHandling(`/resumes/candidate/${options}`); + const result = await handleApiResponse<{ success: boolean; - candidateId: string; resumes: Types.Resume[]; - count: number; }>(response); + + // Convert all history items with proper date handling + return convertArrayFromApi(result.resumes, 'Resume'); } - async getResumesByJob(jobId: string): Promise<{ - success: boolean; - jobId: string; - resumes: Types.Resume[]; - count: number; - }> { - const response = await fetch(`${this.baseUrl}/resumes/job/${jobId}`, { - headers: this.defaultHeaders, - }); + async getResumesByJob(jobId: string): Promise { + const response = await this.fetchWithErrorHandling(`/resumes/job/${jobId}`); - return handleApiResponse<{ + const result = await handleApiResponse<{ success: boolean; - jobId: string; resumes: Types.Resume[]; - count: number; }>(response); + + // Convert all history items with proper date handling + return convertArrayFromApi(result.resumes, 'Resume'); } async searchResumes(query: string): Promise<{ @@ -812,9 +893,7 @@ class ApiClient { count: number; }> { const params = new URLSearchParams({ q: query }); - const response = await fetch(`${this.baseUrl}/resumes/search?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/resumes/search?${params}`); return handleApiResponse<{ success: boolean; @@ -825,18 +904,15 @@ class ApiClient { } async getResumeStatistics(): Promise<{ success: boolean; statistics: any }> { - const response = await fetch(`${this.baseUrl}/resumes/stats`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/resumes/stats'); return handleApiResponse<{ success: boolean; statistics: any }>(response); } async updateResume(resume: Types.Resume): Promise { const body = JSON.stringify(formatApiRequest(resume)); - const response = await fetch(`${this.baseUrl}/resumes`, { + const response = await this.fetchWithErrorHandling('/resumes', { method: 'PATCH', - headers: this.defaultHeaders, body: body, }); @@ -863,9 +939,7 @@ class ApiClient { }>; count: number; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/resumes/${resumeId}/revisions`); return handleApiResponse<{ success: boolean; @@ -894,9 +968,9 @@ class ApiClient { revisionId: string; revision: Types.Resume; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/${revisionId}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/revisions/${revisionId}` + ); const result = await handleApiResponse<{ success: boolean; @@ -925,10 +999,12 @@ class ApiClient { resume: Types.Resume; message: string; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/restore/${revisionId}`, { - method: 'POST', - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/restore/${revisionId}`, + { + method: 'POST', + } + ); const result = await handleApiResponse<{ success: boolean; @@ -957,10 +1033,12 @@ class ApiClient { revisionId: string; message: string; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/${revisionId}`, { - method: 'DELETE', - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/revisions/${revisionId}`, + { + method: 'DELETE', + } + ); return handleApiResponse<{ success: boolean; @@ -991,11 +1069,8 @@ class ApiClient { }; }; }> { - const response = await fetch( - `${this.baseUrl}/resumes/${resumeId}/revisions/compare/${revisionId1}/${revisionId2}`, - { - headers: this.defaultHeaders, - } + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/revisions/compare/${revisionId1}/${revisionId2}` ); const result = await handleApiResponse<{ @@ -1042,11 +1117,13 @@ class ApiClient { failedDeletions: string[]; message: string; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/bulk-delete`, { - method: 'POST', - headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest({ revisionIds: revisionIds })), - }); + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/revisions/bulk-delete`, + { + method: 'POST', + body: JSON.stringify(formatApiRequest({ revisionIds: revisionIds })), + } + ); return handleApiResponse<{ success: boolean; @@ -1076,11 +1153,8 @@ class ApiClient { changeType: 'added' | 'removed' | 'modified'; }[]; }> { - const response = await fetch( - `${this.baseUrl}/resumes/${resumeId}/revisions/diff/${fromRevisionId}/${toRevisionId}`, - { - headers: this.defaultHeaders, - } + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/revisions/diff/${fromRevisionId}/${toRevisionId}` ); return handleApiResponse<{ @@ -1116,9 +1190,9 @@ class ApiClient { count: number; }> { const params = new URLSearchParams({ q: query }); - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/search?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/revisions/search?${params}` + ); return handleApiResponse<{ success: boolean; @@ -1143,9 +1217,7 @@ class ApiClient { history: Types.Resume[]; count: number; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/history`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/resumes/${resumeId}/history`); const result = await handleApiResponse<{ success: boolean; @@ -1176,9 +1248,7 @@ class ApiClient { minorChanges: number; }; }> { - const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/statistics`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/resumes/${resumeId}/revisions/statistics`); return handleApiResponse<{ success: boolean; @@ -1198,11 +1268,8 @@ class ApiClient { * Export resume revision history as JSON or other formats */ async exportResumeHistory(resumeId: string, format: 'json' | 'csv' = 'json'): Promise { - const response = await fetch( - `${this.baseUrl}/resumes/${resumeId}/history/export?format=${format}`, - { - headers: this.defaultHeaders, - } + const response = await this.fetchWithErrorHandling( + `/resumes/${resumeId}/history/export?format=${format}` ); if (!response.ok) { @@ -1288,9 +1355,7 @@ class ApiClient { } async getJob(id: string): Promise { - const response = await fetch(`${this.baseUrl}/jobs/${id}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/jobs/${id}`); return this.handleApiResponseWithConversion(response, 'Job'); } @@ -1299,9 +1364,7 @@ class ApiClient { const paginatedRequest = createPaginatedRequest(request); const params = toUrlParams(formatApiRequest(paginatedRequest)); - const response = await fetch(`${this.baseUrl}/jobs?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/jobs?${params}`); return this.handlePaginatedApiResponseWithConversion(response, 'Job'); } @@ -1313,9 +1376,7 @@ class ApiClient { const paginatedRequest = createPaginatedRequest(request); const params = toUrlParams(formatApiRequest(paginatedRequest)); - const response = await fetch(`${this.baseUrl}/employers/${employerId}/jobs?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/employers/${employerId}/jobs?${params}`); return this.handlePaginatedApiResponseWithConversion(response, 'Job'); } @@ -1332,9 +1393,7 @@ class ApiClient { }; const params = toUrlParams(formatApiRequest(searchRequest)); - const response = await fetch(`${this.baseUrl}/jobs/search?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/jobs/search?${params}`); return this.handlePaginatedApiResponseWithConversion(response, 'Job'); } @@ -1346,9 +1405,8 @@ class ApiClient { async applyToJob( application: Omit ): Promise { - const response = await fetch(`${this.baseUrl}/job-applications`, { + const response = await this.fetchWithErrorHandling('/job-applications', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(application)), }); @@ -1356,9 +1414,7 @@ class ApiClient { } async getJobApplication(id: string): Promise { - const response = await fetch(`${this.baseUrl}/job-applications/${id}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/job-applications/${id}`); return this.handleApiResponseWithConversion(response, 'JobApplication'); } @@ -1369,9 +1425,7 @@ class ApiClient { const paginatedRequest = createPaginatedRequest(request); const params = toUrlParams(formatApiRequest(paginatedRequest)); - const response = await fetch(`${this.baseUrl}/job-applications?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/job-applications?${params}`); return this.handlePaginatedApiResponseWithConversion( response, @@ -1383,9 +1437,8 @@ class ApiClient { id: string, status: Types.ApplicationStatus ): Promise { - const response = await fetch(`${this.baseUrl}/job-applications/${id}/status`, { + const response = await this.fetchWithErrorHandling(`/job-applications/${id}/status`, { method: 'PATCH', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest({ status })), }); @@ -1402,9 +1455,8 @@ class ApiClient { async createChatSessionWithCandidate( request: CreateChatSessionRequest ): Promise { - const response = await fetch(`${this.baseUrl}/chat/sessions`, { + const response = await this.fetchWithErrorHandling('/chat/sessions', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -1421,9 +1473,9 @@ class ApiClient { const paginatedRequest = createPaginatedRequest(request); const params = toUrlParams(formatApiRequest(paginatedRequest)); - const response = await fetch(`${this.baseUrl}/candidates/${username}/chat-sessions?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/candidates/${username}/chat-sessions?${params}` + ); // Handle the nested sessions with date conversion const result = await this.handleApiResponseWithConversion(response); @@ -1454,10 +1506,7 @@ class ApiClient { } async getSystemInfo(): Promise { - const response = await fetch(`${this.baseUrl}/system/info`, { - method: 'GET', - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/system/info'); const result = await handleApiResponse(response); @@ -1465,9 +1514,8 @@ class ApiClient { } async getCandidateSimilarContent(query: string): Promise { - const response = await fetch(`${this.baseUrl}/candidates/rag-search`, { + const response = await this.fetchWithErrorHandling('/candidates/rag-search', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(query), }); @@ -1477,9 +1525,8 @@ class ApiClient { } async getCandidateVectors(dimensions: number): Promise { - const response = await fetch(`${this.baseUrl}/candidates/rag-vectors`, { + const response = await this.fetchWithErrorHandling('/candidates/rag-vectors', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(dimensions), }); @@ -1489,9 +1536,8 @@ class ApiClient { } async getCandidateRAGContent(documentId: string): Promise { - const response = await fetch(`${this.baseUrl}/candidates/rag-content`, { + const response = await this.fetchWithErrorHandling('/candidates/rag-content', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify({ id: documentId }), }); @@ -1613,9 +1659,8 @@ class ApiClient { filename: document.filename, options: document.options, }; - const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}`, { + const response = await this.fetchWithErrorHandling(`/candidates/documents/${document.id}`, { method: 'PATCH', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -1625,9 +1670,8 @@ class ApiClient { } async deleteCandidateDocument(document: Types.Document): Promise { - const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}`, { + const response = await this.fetchWithErrorHandling(`/candidates/documents/${document.id}`, { method: 'DELETE', - headers: this.defaultHeaders, }); const result = await handleApiResponse(response); @@ -1636,9 +1680,7 @@ class ApiClient { } async getCandidateDocuments(): Promise { - const response = await fetch(`${this.baseUrl}/candidates/documents`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/candidates/documents'); const result = await handleApiResponse(response); @@ -1646,9 +1688,9 @@ class ApiClient { } async getCandidateDocumentText(document: Types.Document): Promise { - const response = await fetch(`${this.baseUrl}/candidates/documents/${document.id}/content`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/candidates/documents/${document.id}/content` + ); const result = await handleApiResponse(response); @@ -1676,9 +1718,8 @@ class ApiClient { } async createChatSession(context: Types.ChatContext): Promise { - const response = await fetch(`${this.baseUrl}/chat/sessions`, { + const response = await this.fetchWithErrorHandling('/chat/sessions', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest({ context })), }); @@ -1686,9 +1727,7 @@ class ApiClient { } async getChatSession(id: string): Promise { - const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling(`/chat/sessions/${id}`); return this.handleApiResponseWithConversion(response, 'ChatSession'); } @@ -1700,9 +1739,8 @@ class ApiClient { id: string, updates: UpdateChatSessionRequest ): Promise { - const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, { + const response = await this.fetchWithErrorHandling(`/chat/sessions/${id}`, { method: 'PATCH', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(updates)), }); @@ -1713,9 +1751,8 @@ class ApiClient { * Delete a chat session */ async deleteChatSession(id: string): Promise<{ success: boolean; message: string }> { - const response = await fetch(`${this.baseUrl}/chat/sessions/${id}`, { + const response = await this.fetchWithErrorHandling(`/chat/sessions/${id}`, { method: 'DELETE', - headers: this.defaultHeaders, }); return handleApiResponse<{ success: boolean; message: string }>(response); @@ -1729,9 +1766,8 @@ class ApiClient { * Create a guest session with authentication */ async createGuestSession(): Promise { - const response = await fetch(`${this.baseUrl}/auth/guest`, { + const response = await this.fetchWithErrorHandling('/auth/guest', { method: 'POST', - headers: this.defaultHeaders, }); const result = await handleApiResponse(response); @@ -1754,9 +1790,8 @@ class ApiClient { auth: Types.AuthResponse; conversionType: string; }> { - const response = await fetch(`${this.baseUrl}/auth/guest/convert`, { + const response = await this.fetchWithErrorHandling('/auth/guest/convert', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(registrationData)), }); @@ -1821,9 +1856,7 @@ class ApiClient { reset_times: Record; config: any; }> { - const response = await fetch(`${this.baseUrl}/admin/rate-limits/info`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/admin/rate-limits/info'); return handleApiResponse(response); } @@ -1839,9 +1872,7 @@ class ApiClient { by_ip: Record; creation_timeline: Record; }> { - const response = await fetch(`${this.baseUrl}/admin/guests/statistics`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling('/admin/guests/statistics'); return handleApiResponse(response); } @@ -1853,9 +1884,8 @@ class ApiClient { message: string; cleaned_count: number; }> { - const response = await fetch(`${this.baseUrl}/admin/guests/cleanup`, { + const response = await this.fetchWithErrorHandling('/admin/guests/cleanup', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify({ inactive_hours: inactiveHours }), }); @@ -1888,9 +1918,8 @@ class ApiClient { } async resetChatSession(id: string): Promise<{ success: boolean; message: string }> { - const response = await fetch(`${this.baseUrl}/chat/sessions/${id}/reset`, { + const response = await this.fetchWithErrorHandling(`/chat/sessions/${id}/reset`, { method: 'PATCH', - headers: this.defaultHeaders, }); return handleApiResponse<{ success: boolean; message: string }>(response); @@ -1919,7 +1948,7 @@ class ApiClient { const processStream = async (): Promise => { try { - const response = await fetch(`${this.baseUrl}${api}`, { + const response = await this.fetchWithErrorHandling(api, { method, headers: headers || { ...this.defaultHeaders, @@ -2080,9 +2109,9 @@ class ApiClient { }); const params = toUrlParams(formatApiRequest(paginatedRequest)); - const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`, { - headers: this.defaultHeaders, - }); + const response = await this.fetchWithErrorHandling( + `/chat/sessions/${sessionId}/messages?${params}` + ); return this.handlePaginatedApiResponseWithConversion( response, @@ -2095,9 +2124,8 @@ class ApiClient { // ============================ async requestPasswordReset(request: PasswordResetRequest): Promise<{ message: string }> { - const response = await fetch(`${this.baseUrl}/auth/password-reset/request`, { + const response = await this.fetchWithErrorHandling('/auth/password-reset/request', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); @@ -2105,9 +2133,8 @@ class ApiClient { } async confirmPasswordReset(request: PasswordResetConfirm): Promise<{ message: string }> { - const response = await fetch(`${this.baseUrl}/auth/password-reset/confirm`, { + const response = await this.fetchWithErrorHandling('/auth/password-reset/confirm', { method: 'POST', - headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)), }); diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 45a9f96..fdbcea7 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-07-16T23:28:16.752031 +// Generated on: 2025-07-17T19:28:19.417396 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -1006,6 +1006,7 @@ export interface Resume { updatedAt?: Date; job?: Job; candidate?: Candidate; + aiGenerated?: boolean; } export interface ResumeMessage { diff --git a/src/backend/agents/base.py b/src/backend/agents/base.py index 3906949..20c322f 100644 --- a/src/backend/agents/base.py +++ b/src/backend/agents/base.py @@ -536,7 +536,7 @@ Content: {content} umap_embedding_2d=umap_2d, umap_embedding_3d=umap_3d, ) - if rag_metadata.query_embedding: + if rag_metadata.query_embedding and len(rag_metadata.query_embedding) > 0: logger.info(f"Len of embedding: {len(rag_metadata.query_embedding)}") else: logger.warning(f"No query embedding found in RAG results; {query_embedding}") diff --git a/src/backend/agents/edit_resume.py b/src/backend/agents/edit_resume.py index f4e557f..1a3e331 100644 --- a/src/backend/agents/edit_resume.py +++ b/src/backend/agents/edit_resume.py @@ -126,6 +126,7 @@ class EditResume(Agent): content = "" start_time = time.perf_counter() response = None + async for response in llm.chat_stream( model=model, messages=messages, @@ -209,10 +210,10 @@ You are a professional copy editor. Your task is to edit and enhance the provide - DO NOT estimate, approximate, or infer numerical data **RESPONSE FORMAT RULES:** -- IF the user asks a question (contains question words like "what", "how", "why", "when", "where", "which", "who" OR ends with "?"), then: +- IF the user prompt contains question words like "what", "how", "why", "when", "where", "which", "who" OR ends with "?", then it is a QUESTION: - Start response with exactly "ANSWER: " (including the space) - Provide only the answer, not the full resume -- IF the user requests edits/changes to the resume, then: +- IF the user prompt IS NOT A QUESTION: - Return the complete edited resume without any prefix - Do not include "ANSWER:" anywhere in the response @@ -231,9 +232,9 @@ You are a professional copy editor. Your task is to edit and enhance the provide - Example of UNACCEPTABLE impact: "Streamlined resume analysis processes with a 40% reduction in manual review time" (unless that 40% is stated in the original content) If the user asks a question about the resume, state "ANSWER:" and then provide a brief answer based only on the content of the resume or the context provided. -Start the response with "ANSWER:" if an answer is being rovided and not the full resume. +Start the response with "ANSWER:" if an answer is being provided and not the full resume. -If the user did not ask a question, return the entire resume with the requested edits applied while maintaining the current formatting. +If the user did not ask a question, always return the entire resume with the requested edits applied while maintaining the current formatting. """ logger.info(f"Generating edit_resume response to: {prompt}\ncontext: {extra_context}") diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py index d69390e..747d4f3 100644 --- a/src/backend/agents/generate_resume.py +++ b/src/backend/agents/generate_resume.py @@ -83,38 +83,40 @@ direct duplication from the assessment. Do not provide header information like name, email, or phone number in the resume, as that information will be added later. ## CANDIDATE INFORMATION: -Name: {self.user.full_name} -Email: {self.user.email or "N/A"} -Phone: {self.user.phone or "N/A"} -{f"Location: {json.dumps(self.user.location.model_dump())}" if self.user.location else ""} +* Name: {self.user.full_name} +* Email: {self.user.email or "N/A"} +* Phone: {self.user.phone or "N/A"} +{f"* Location: {json.dumps(self.user.location.model_dump(exclude_none=True, exclude_unset=True))}" if self.user.location else ""} ## SKILL ASSESSMENT RESULTS: + """ if len(skills_by_strength[SkillStrength.STRONG]): system_prompt += f"""\ - ### Strong Skills (prominent in resume): * {".\n* ".join(skills_by_strength[SkillStrength.STRONG])} + """ if len(skills_by_strength[SkillStrength.MODERATE]): system_prompt += f"""\ - ### Moderate Skills (demonstrated in resume): * {".\n* ".join(skills_by_strength[SkillStrength.MODERATE])} + """ if len(skills_by_strength[SkillStrength.WEAK]): system_prompt += f"""\ - ### Weaker Skills (mentioned or implied): * {".\n* ".join(skills_by_strength[SkillStrength.WEAK])} + """ - system_prompt += """\ - + system_prompt += """ + ## EXPERIENCE EVIDENCE: + """ # Add experience evidence by source/position diff --git a/src/backend/logger.py b/src/backend/logger.py index 8d8acee..1d95ec1 100644 --- a/src/backend/logger.py +++ b/src/backend/logger.py @@ -4,6 +4,25 @@ import logging import defines +class RelativePathFormatter(logging.Formatter): + def __init__(self, fmt=None, datefmt=None, remove_prefix=None): + super().__init__(fmt, datefmt) + self.remove_prefix = remove_prefix or os.getcwd() + # Ensure the prefix ends with a separator + if not self.remove_prefix.endswith(os.sep): + self.remove_prefix += os.sep + + def format(self, record): + # Make a copy of the record to avoid modifying the original + record = logging.makeLogRecord(record.__dict__) + + # Remove the prefix from pathname + if record.pathname.startswith(self.remove_prefix): + record.pathname = record.pathname[len(self.remove_prefix) :] + + return super().format(record) + + def _setup_logging(level=defines.logging_level) -> logging.Logger: os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR" warnings.filterwarnings("ignore", message="Overriding a previously registered kernel") @@ -18,8 +37,8 @@ def _setup_logging(level=defines.logging_level) -> logging.Logger: raise ValueError(f"Invalid log level: {level}") # Create a custom formatter - formatter = logging.Formatter( - fmt="%(levelname)s - %(filename)s:%(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" + formatter = RelativePathFormatter( + fmt="%(levelname)s - %(pathname)s:%(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) # Create a handler (e.g., StreamHandler for console output) diff --git a/src/backend/models.py b/src/backend/models.py index 748eeea..0dc988e 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -1209,6 +1209,7 @@ class Resume(BaseModel): updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) job: Optional[Job] = None candidate: Optional[Candidate] = None + ai_generated: Optional[bool] = Field(default=True, alias=str("aiGenerated")) model_config = ConfigDict(populate_by_name=True) class ChatMessageResume(ChatMessageUser): diff --git a/src/backend/routes/candidates.py b/src/backend/routes/candidates.py index 40ccf30..644c379 100644 --- a/src/backend/routes/candidates.py +++ b/src/backend/routes/candidates.py @@ -1985,9 +1985,40 @@ async def generate_resume( yield error_message return - resume: ChatMessageResume = final_message - resume.resume.job_id = job.id - yield resume + resume_message: ChatMessageResume = final_message + resume_message.resume.ai_generated = True + resume_message.resume.job_id = job.id + resume_message.resume.candidate_id = candidate.id + + current_resumes = await database.get_resumes_by_job(current_user.id, job.id) + current_resume = current_resumes[0] if len(current_resumes) != 0 else None + if current_resume: + logger.info(f"💾 Updating resume from candidate {candidate.id} and job {job.id}") + resume_message.resume.id = current_resume.id + current_resume.resume = resume_message.resume.resume + updates = current_resume.model_dump() + if current_resume.resume == resume_message.resume.resume: + logger.info(f"✅ No changes detected in resume for candidate {candidate.id} and job {job.id}") + resume_message.resume.job = job + resume_message.resume.candidate = candidate + yield resume_message + return + updated_resume_data = await database.update_resume(current_user.id, current_resume.id, updates) + if updated_resume_data: + updated_resume = Resume.model_validate(updated_resume_data) if updated_resume_data else None + if updated_resume: + logger.info(f"✅ Resume {updated_resume.id} updated successfully for user {current_user.id}") + resume_message.resume = updated_resume + else: + success = await database.set_resume(current_user.id, resume_message.resume.model_dump()) + if not success: + error_message = ChatMessageError(session_id=MOCK_UUID, content="Failed to save resume to database") + yield error_message + return + + resume_message.resume.job = job + resume_message.resume.candidate = candidate + yield resume_message return try: diff --git a/src/backend/routes/resumes.py b/src/backend/routes/resumes.py index fbf5054..54325ae 100644 --- a/src/backend/routes/resumes.py +++ b/src/backend/routes/resumes.py @@ -3,7 +3,7 @@ Resume Routes """ import json -from typing import List +from typing import List, Optional import uuid from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query @@ -13,7 +13,7 @@ import backstory_traceback as backstory_traceback from database.manager import RedisDatabase from logger import logger from models import MOCK_UUID, ChatMessageError, Job, Candidate, Resume, ResumeMessage -from utils.dependencies import get_database, get_current_user, get_current_user_or_guest +from utils.dependencies import get_database, get_current_user_or_guest from utils.responses import create_success_response # Create router for authentication endpoints @@ -23,7 +23,7 @@ router = APIRouter(prefix="/resumes", tags=["resumes"]) @router.post("") async def create_candidate_resume( resume: Resume = Body(...), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Create a new resume for a candidate/job combination""" @@ -122,7 +122,9 @@ async def create_candidate_resume( @router.get("") -async def get_user_resumes(current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database)): +async def get_user_resumes( + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database) +): """Get all resumes for the current user""" try: resumes_data = await database.get_all_resumes_for_user(current_user.id) @@ -170,7 +172,7 @@ async def get_resume( @router.delete("/{resume_id}") async def delete_resume( resume_id: str = Path(..., description="ID of the resume"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Delete a specific resume""" @@ -179,7 +181,7 @@ async def delete_resume( if not success: raise HTTPException(status_code=404, detail="Resume not found") - return {"success": True, "message": f"Resume {resume_id} deleted successfully"} + return create_success_response({"message": f"Resume {resume_id} deleted successfully"}) except HTTPException: raise except Exception as e: @@ -188,15 +190,28 @@ async def delete_resume( @router.get("/candidate/{candidate_id}") +@router.get("/candidate/{candidate_id}/{job_id}") async def get_resumes_by_candidate( candidate_id: str = Path(..., description="ID of the candidate"), - current_user=Depends(get_current_user), + job_id: Optional[str] = Path(..., description="ID of the job"), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Get all resumes for a specific candidate""" try: resumes = await database.get_resumes_by_candidate(current_user.id, candidate_id) - return {"success": True, "candidate_id": candidate_id, "resumes": resumes, "count": len(resumes)} + if job_id: + for resume in resumes: + logger.info(f"Checking resume {resume.id} for job {job_id}: {resume.job_id}") + resumes = [resume for resume in resumes if resume.job_id == job_id] + for resume in resumes: + job_data = await database.get_job(resume.job_id) + if job_data: + resume.job = Job.model_validate(job_data) + candidate_data = await database.get_candidate(resume.candidate_id) + if candidate_data: + resume.candidate = Candidate.model_validate(candidate_data) + return create_success_response({"resumes": [resume.model_dump(by_alias=True) for resume in resumes]}) except Exception as e: logger.error(f"❌ Error retrieving resumes for candidate {candidate_id}: {e}") raise HTTPException(status_code=500, detail="Failed to retrieve candidate resumes") @@ -205,13 +220,13 @@ async def get_resumes_by_candidate( @router.get("/job/{job_id}") async def get_resumes_by_job( job_id: str = Path(..., description="ID of the job"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Get all resumes for a specific job""" try: resumes = await database.get_resumes_by_job(current_user.id, job_id) - return {"success": True, "job_id": job_id, "resumes": resumes, "count": len(resumes)} + return create_success_response({"job_id": job_id, "resumes": resumes, "count": len(resumes)}) except Exception as e: logger.error(f"❌ Error retrieving resumes for job {job_id}: {e}") raise HTTPException(status_code=500, detail="Failed to retrieve job resumes") @@ -220,13 +235,13 @@ async def get_resumes_by_job( @router.get("/search") async def search_resumes( q: str = Query(..., description="Search query"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Search resumes by content""" try: resumes = await database.search_resumes_for_user(current_user.id, q) - return {"success": True, "query": q, "resumes": resumes, "count": len(resumes)} + return create_success_response({"query": q, "resumes": resumes, "count": len(resumes)}) except Exception as e: logger.error(f"❌ Error searching resumes for user {current_user.id}: {e}") raise HTTPException(status_code=500, detail="Failed to search resumes") @@ -234,12 +249,12 @@ async def search_resumes( @router.get("/stats") async def get_resume_statistics( - current_user=Depends(get_current_user), database: RedisDatabase = Depends(get_database) + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database) ): """Get resume statistics for the current user""" try: stats = await database.get_resume_statistics(current_user.id) - return {"success": True, "statistics": stats} + return create_success_response({"statistics": stats}) except Exception as e: logger.error(f"❌ Error retrieving resume statistics for user {current_user.id}: {e}") raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics") @@ -248,11 +263,19 @@ async def get_resume_statistics( @router.patch("") async def update_resume( resume: Resume = Body(...), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Update the content of a specific resume""" try: + current_resume = await database.get_resume(resume.id) + if not current_resume: + logger.warning(f"⚠️ Resume {resume.id} not found for user {current_user.id}") + raise HTTPException(status_code=404, detail="Resume not found") + if current_resume.resume == resume.resume and resume.ai_generated is False: + logger.info(f"✅ No changes detected in resume {resume.id} for user {current_user.id}") + return create_success_response(resume.model_dump(by_alias=True)) + resume.ai_generated = False # Ensure we mark it as user-generated updates = resume.model_dump() updated_resume_data = await database.update_resume(current_user.id, resume.id, updates) if not updated_resume_data: @@ -279,7 +302,7 @@ async def update_resume( @router.get("/{resume_id}/revisions") async def get_resume_revisions( resume_id: str = Path(..., description="ID of the resume"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Get list of all revisions for a resume with metadata""" @@ -295,7 +318,7 @@ async def get_resume_revisions( async def get_resume_revision( resume_id: str = Path(..., description="ID of the resume"), revision_id: str = Path(..., description="Revision ID of the revision"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Get a specific revision of a resume by revision ID""" @@ -303,7 +326,13 @@ async def get_resume_revision( revision = await database.get_resume_revision(current_user.id, resume_id, revision_id) if not revision: raise HTTPException(status_code=404, detail="Revision not found") - + job_data = await database.get_job(revision.job_id) + if job_data: + revision.job = Job.model_validate(job_data) + candidate_data = await database.get_candidate(revision.candidate_id) + if candidate_data: + revision.candidate = Candidate.model_validate(candidate_data) + logger.info(f"📄 Retrieved revision {revision_id} for resume {resume_id}") return create_success_response( {"resume_id": resume_id, "revision_id": revision_id, "revision": revision.model_dump(by_alias=True)} ) @@ -317,7 +346,7 @@ async def get_resume_revision( @router.get("/{resume_id}/history") async def get_resume_history( resume_id: str = Path(..., description="ID of the resume"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Get all historical versions of a resume with full data""" @@ -339,7 +368,7 @@ async def get_resume_history( async def restore_resume_revision( resume_id: str = Path(..., description="ID of the resume"), revision_id: str = Path(..., description="Revision ID of the revision to restore"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Restore a resume to a specific revision""" @@ -368,7 +397,7 @@ async def restore_resume_revision( async def delete_resume_revision( resume_id: str = Path(..., description="ID of the resume"), revision_id: str = Path(..., description="Revision ID of the revision to delete"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Delete a specific revision from history""" @@ -397,7 +426,7 @@ async def compare_resume_revisions( resume_id: str = Path(..., description="ID of the resume"), revision_id1: str = Path(..., description="First revision ID"), revision_id2: str = Path(..., description="Second revision ID"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Compare two revisions of a resume"""