From 8dcc1c0336c33cb0d82f40ca19b865e09f2342aa Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Thu, 12 Jun 2025 09:22:06 -0700 Subject: [PATCH] Added Resume viewing --- frontend/src/components/ResumeGenerator.tsx | 26 + frontend/src/components/ui/ResumeInfo.tsx | 381 +++++++++++++ frontend/src/components/ui/ResumeViewer.tsx | 601 ++++++++++++++++++++ frontend/src/config/navigationConfig.tsx | 11 + frontend/src/hooks/GlobalContext.tsx | 10 + frontend/src/services/api-client.ts | 74 +++ frontend/src/types/types.ts | 67 ++- src/backend/database.py | 315 +++++++++- src/backend/get_requirements_list.py | 62 ++ src/backend/main.py | 267 ++++++++- src/backend/models.py | 23 + 11 files changed, 1830 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ui/ResumeInfo.tsx create mode 100644 frontend/src/components/ui/ResumeViewer.tsx create mode 100644 src/backend/get_requirements_list.py diff --git a/frontend/src/components/ResumeGenerator.tsx b/frontend/src/components/ResumeGenerator.tsx index 90aa2a1..4300f6a 100644 --- a/frontend/src/components/ResumeGenerator.tsx +++ b/frontend/src/components/ResumeGenerator.tsx @@ -99,6 +99,28 @@ const ResumeGenerator: React.FC = (props: ResumeGeneratorP generateResume(); }, [job, candidate, apiClient, resume, skills, generated, setSystemPrompt, setPrompt, setResume]); + const handleSave = useCallback(async () => { + if (!resume) { + setSnack('No resume to save!'); + return; + } + + try { + if (!candidate.id || !job.id) { + setSnack('Candidate or job ID is missing.'); + return; + } + const controller = apiClient.saveResume(candidate.id, job.id, resume); + const result = await controller.promise; + if (result.resume.id) { + setSnack('Resume saved successfully!'); + } + } catch (error) { + console.error('Error saving resume:', error); + setSnack('Error saving resume.'); + } + }, [apiClient, candidate.id, job.id, resume, setSnack]); + return ( = (props: ResumeGeneratorP {tabValue === 'prompt' &&
{prompt}
} {tabValue === 'resume' && <> { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} />} + + {resume && !status && !error && }
) diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx new file mode 100644 index 0000000..e72c82b --- /dev/null +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -0,0 +1,381 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + Link, + Typography, + Avatar, + Grid, + SxProps, + CardActions, + Chip, + Stack, + CardHeader, + Button, + LinearProgress, + IconButton, + Tooltip, + Card, + CardContent, + Divider, + useTheme, + useMediaQuery, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions +} from '@mui/material'; +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, + Visibility as VisibilityIcon, + VisibilityOff as VisibilityOffIcon +} from '@mui/icons-material'; +import { useAuth } from 'hooks/AuthContext'; +import { useAppState } from 'hooks/GlobalContext'; +import { StyledMarkdown } from 'components/StyledMarkdown'; +import { Resume } from 'types/types'; + +interface ResumeInfoProps { + resume: Resume; + sx?: SxProps; + action?: string; + elevation?: number; + variant?: "minimal" | "small" | "normal" | "all" | null; +} + +const ResumeInfo: React.FC = (props: ResumeInfoProps) => { + const { setSnack } = useAppState(); + const { resume } = props; + const { user, apiClient } = useAuth(); + const { + sx, + action = '', + elevation = 1, + variant = "normal" + } = props; + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')) || variant === "minimal"; + const isAdmin = user?.isAdmin; + const [activeResume, setActiveResume] = useState({ ...resume }); + const [isContentExpanded, setIsContentExpanded] = useState(false); + const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); + const [deleted, setDeleted] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editContent, setEditContent] = useState(''); + const [saving, setSaving] = useState(false); + const contentRef = useRef(null); + + useEffect(() => { + if (resume && resume.id !== activeResume?.id) { + setActiveResume(resume); + } + }, [resume, activeResume]); + + // Check if content needs truncation + useEffect(() => { + if (contentRef.current && resume.resume) { + const element = contentRef.current; + setShouldShowMoreButton(element.scrollHeight > element.clientHeight); + } + }, [resume.resume]); + + const deleteResume = async (resumeId: string | undefined) => { + if (resumeId) { + try { + await apiClient.deleteResume(resumeId); + setDeleted(true); + setSnack('Resume deleted successfully.'); + } catch (error) { + setSnack('Failed to delete resume.'); + } + } + }; + + const handleReset = async () => { + setActiveResume({ ...resume }); + }; + + const handleSave = async () => { + setSaving(true); + try { + const result = await apiClient.updateResume(activeResume.id || '', editContent); + const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() }; + setActiveResume(updatedResume); + setEditDialogOpen(false); + setSnack('Resume updated successfully.'); + } catch (error) { + setSnack('Failed to update resume.'); + } finally { + setSaving(false); + } + }; + + const handleEditOpen = () => { + setEditContent(activeResume.resume); + setEditDialogOpen(true); + }; + + if (!resume) { + return No resume provided.; + } + + const formatDate = (date: Date | undefined) => { + if (!date) return 'N/A'; + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); + }; + + return ( + + + + {/* Header Information */} + + + + + {activeResume.candidate && ( + + + + Candidate + + + )} + + {activeResume.candidate?.fullName || activeResume.candidateId} + + + {activeResume.job && ( + <> + + + + Job + + + + {activeResume.job.title} at {activeResume.job.company} + + + )} + + + + + + + + + Timeline + + + + Created: {formatDate(activeResume.createdAt)} + + + Updated: {formatDate(activeResume.updatedAt)} + + + Resume ID: {activeResume.resumeId} + + + + + + + + + {/* Resume Content */} + {activeResume.resume && ( + + } + sx={{ p: 0, pb: 1 }} + action={ + isAdmin && ( + + + + + + ) + } + /> + + + + {activeResume.resume} + + + {shouldShowMoreButton && variant !== "all" && ( + + + + )} + + + + )} + + {variant === 'all' && activeResume.resume && ( + + + + )} + + + + {/* Admin Controls */} + {isAdmin && ( + + + + { e.stopPropagation(); handleEditOpen(); }} + > + + + + + + { e.stopPropagation(); deleteResume(activeResume.id); }} + > + + + + + + { e.stopPropagation(); handleReset(); }} + > + + + + + + {saving && ( + + + + Saving resume... + + + )} + + )} + + {/* Edit Dialog */} + setEditDialogOpen(false)} + maxWidth="lg" + fullWidth + fullScreen={isMobile} + > + + Edit Resume Content + + Resume for {activeResume.candidate?.fullName || activeResume.candidateId} + + + + setEditContent(e.target.value)} + variant="outlined" + sx={{ + mt: 1, + '& .MuiInputBase-input': { + fontFamily: 'monospace', + fontSize: '0.875rem' + } + }} + placeholder="Enter resume content..." + /> + + + + + + + + ); +}; + +export { ResumeInfo }; \ No newline at end of file diff --git a/frontend/src/components/ui/ResumeViewer.tsx b/frontend/src/components/ui/ResumeViewer.tsx new file mode 100644 index 0000000..2f93a1f --- /dev/null +++ b/frontend/src/components/ui/ResumeViewer.tsx @@ -0,0 +1,601 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + FormControl, + Select, + MenuItem, + InputLabel, + Chip, + IconButton, + Dialog, + AppBar, + Toolbar, + useMediaQuery, + useTheme, + Slide, + TextField, + InputAdornment +} from '@mui/material'; +import { + KeyboardArrowUp as ArrowUpIcon, + KeyboardArrowDown as ArrowDownIcon, + Description as DescriptionIcon, + Work as WorkIcon, + Person as PersonIcon, + Schedule as ScheduleIcon, + Close as CloseIcon, + ArrowBack as ArrowBackIcon, + Search as SearchIcon, + Clear as ClearIcon +} from '@mui/icons-material'; +import { TransitionProps } from '@mui/material/transitions'; +import { ResumeInfo } from 'components/ui/ResumeInfo'; +import { useAuth } from 'hooks/AuthContext'; +import { useAppState, useSelectedResume } from 'hooks/GlobalContext'; // Assuming similar context exists +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { Resume } from 'types/types'; + +type SortField = 'updatedAt' | 'createdAt' | 'candidateId' | 'jobId'; +type SortOrder = 'asc' | 'desc'; + +interface ResumeViewerProps { + onSelect?: (resume: Resume) => void; + candidateId?: string; // Optional filter by candidate + jobId?: string; // Optional filter by job +} + +const Transition = React.forwardRef(function Transition( + props: TransitionProps & { + children: React.ReactElement; + }, + ref: React.Ref, +) { + return ; +}); + +const ResumeViewer: React.FC = ({ onSelect, candidateId, jobId }) => { + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + + const { apiClient } = useAuth(); + const { selectedResume, setSelectedResume } = useSelectedResume(); // Assuming similar context + const { setSnack } = useAppState(); + const [resumes, setResumes] = useState([]); + const [loading, setLoading] = useState(false); + const [sortField, setSortField] = useState('updatedAt'); + const [sortOrder, setSortOrder] = useState('desc'); + const [mobileDialogOpen, setMobileDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [filteredResumes, setFilteredResumes] = useState([]); + const { resumeId } = useParams<{ resumeId?: string }>(); + + useEffect(() => { + const getResumes = async () => { + setLoading(true); + try { + let results; + + if (candidateId) { + results = await apiClient.getResumesByCandidate(candidateId); + } else if (jobId) { + results = await apiClient.getResumesByJob(jobId); + } else { + results = await apiClient.getResumes(); + } + + const resumesData: Resume[] = results.resumes || []; + setResumes(resumesData); + setFilteredResumes(resumesData); + + if (resumeId) { + const resume = resumesData.find(r => r.id === resumeId); + if (resume) { + setSelectedResume(resume); + onSelect?.(resume); + return; + } + } + + // Auto-select first resume if none selected + if (resumesData.length > 0 && !selectedResume) { + const firstResume = sortResumes(resumesData, sortField, sortOrder)[0]; + setSelectedResume(firstResume); + onSelect?.(firstResume); + } + } catch (err) { + console.error("Failed to load resumes:", err); + setSnack("Failed to load resumes: " + err, 'error'); + } finally { + setLoading(false); + } + }; + + getResumes(); + }, [apiClient, setSnack, candidateId, jobId]); + + // Filter resumes based on search query + useEffect(() => { + if (!searchQuery.trim()) { + setFilteredResumes(resumes); + } else { + const filtered = resumes.filter(resume => + resume.candidate?.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) || + resume.job?.title?.toLowerCase().includes(searchQuery.toLowerCase()) || + resume.job?.company?.toLowerCase().includes(searchQuery.toLowerCase()) || + resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) || + resume.resumeId?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + setFilteredResumes(filtered); + } + }, [searchQuery, resumes]); + + const sortResumes = (resumesList: Resume[], field: SortField, order: SortOrder): Resume[] => { + return [...resumesList].sort((a, b) => { + let aValue: any; + let bValue: any; + + switch (field) { + case 'updatedAt': + aValue = a.updatedAt?.getTime() || 0; + bValue = b.updatedAt?.getTime() || 0; + break; + case 'createdAt': + aValue = a.createdAt?.getTime() || 0; + bValue = b.createdAt?.getTime() || 0; + break; + case 'candidateId': + aValue = a.candidate?.fullName?.toLowerCase() || a.candidateId?.toLowerCase() || ''; + bValue = b.candidate?.fullName?.toLowerCase() || b.candidateId?.toLowerCase() || ''; + break; + case 'jobId': + aValue = a.job?.title?.toLowerCase() || a.jobId?.toLowerCase() || ''; + bValue = b.job?.title?.toLowerCase() || b.jobId?.toLowerCase() || ''; + break; + default: + return 0; + } + + if (aValue < bValue) return order === 'asc' ? -1 : 1; + if (aValue > bValue) return order === 'asc' ? 1 : -1; + return 0; + }); + }; + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('desc'); + } + }; + + const handleResumeSelect = (resume: Resume) => { + setSelectedResume(resume); + onSelect?.(resume); + if (isMobile) { + setMobileDialogOpen(true); + } else { + navigate(`/candidate/resumes/${resume.id}`); + } + }; + + const handleMobileDialogClose = () => { + setMobileDialogOpen(false); + }; + + const handleSearchClear = () => { + setSearchQuery(''); + }; + + const sortedResumes = sortResumes(filteredResumes, sortField, sortOrder); + + const formatDate = (date: Date | undefined) => { + if (!date) return 'N/A'; + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + ...(isMobile ? {} : { year: 'numeric' }), + ...(isSmall ? {} : { hour: '2-digit', minute: '2-digit' }) + }).format(date); + }; + + const getSortIcon = (field: SortField) => { + if (sortField !== field) return null; + return sortOrder === 'asc' ? : ; + }; + + const getDisplayTitle = () => { + if (candidateId) return `Resumes for Candidate`; + if (jobId) return `Resumes for Job`; + return `All Resumes`; + }; + + const ResumeList = () => ( + + + + {getDisplayTitle()} ({sortedResumes.length}) + + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchQuery && ( + + + + + + ) + }} + sx={{ flexGrow: 1, minWidth: isSmall ? '100%' : 200 }} + /> + + + Sort by + + + + + + + + + + handleSort('candidateId')} + > + + + + {isSmall ? 'Candidate' : 'Candidate'} + + {getSortIcon('candidateId')} + + + handleSort('jobId')} + > + + + Job + {getSortIcon('jobId')} + + + {!isMobile && ( + handleSort('updatedAt')} + > + + + Updated + {getSortIcon('updatedAt')} + + + )} + + + ID + + + + + + {sortedResumes.map((resume) => ( + handleResumeSelect(resume)} + sx={{ + cursor: 'pointer', + height: isMobile ? 48 : 'auto', + '&.Mui-selected': { + backgroundColor: 'action.selected', + }, + '&:hover': { + backgroundColor: 'action.hover', + } + }} + > + + + {resume.candidate?.fullName || resume.candidateId} + + {isMobile && ( + + {formatDate(resume.updatedAt)} + + )} + + + + {resume.job?.title || 'Unknown Job'} + + {!isMobile && resume.job?.company && ( + + {resume.job.company} + + )} + + {!isMobile && ( + + + {formatDate(resume.updatedAt)} + + {resume.createdAt && ( + + Created: {formatDate(resume.createdAt)} + + )} + + )} + + + {resume.resumeId} + + + + ))} + +
+
+
+ ); + + const ResumeDetails = ({ inDialog = false }: { inDialog?: boolean }) => ( + + {selectedResume ? ( + + ) : ( + + + Select a resume to view details + + + )} + + ); + + if (isMobile) { + return ( + + + + + + + + + + + + Resume Details + + + {selectedResume?.candidate?.fullName || selectedResume?.candidateId} + + + + + + + + ); + } + + return ( + + + + + + + Resume Details + + + + + + ); +}; + +export { ResumeViewer }; \ No newline at end of file diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index d313d58..ba775e8 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -45,6 +45,7 @@ import { useAuth } from "hooks/AuthContext"; import { useNavigate } from "react-router-dom"; import { JobViewer } from "components/ui/JobViewer"; import { CandidatePicker } from "components/ui/CandidatePicker"; +import { ResumeViewer } from "components/ui/ResumeViewer"; // Beta page components for placeholder routes const BackstoryPage = () => ( @@ -156,6 +157,16 @@ export const navigationConfig: NavigationConfig = { ), userTypes: ["candidate", "guest", "employer"], }, + { + id: "explore-resumes", + label: "Resumes", + path: "/candidate/resumes/:resumeId?", + icon: , + component: ( + + ), + userTypes: ["candidate", "guest", "employer"], + }, ], showInNavigation: true, }, diff --git a/frontend/src/hooks/GlobalContext.tsx b/frontend/src/hooks/GlobalContext.tsx index 344f2a3..b54fff6 100644 --- a/frontend/src/hooks/GlobalContext.tsx +++ b/frontend/src/hooks/GlobalContext.tsx @@ -39,6 +39,8 @@ export interface AppState { selectedCandidate: Types.Candidate | null; selectedJob: Types.Job | null; selectedEmployer: Types.Employer | null; + selectedResume: Types.Resume | null; + setSelectedResume: (resume: Types.Resume | null) => void; routeState: RouteState; isInitializing: boolean; } @@ -140,6 +142,7 @@ export function useAppStateLogic(): AppStateContextType { const [selectedCandidate, setSelectedCandidateState] = useState(null); const [selectedJob, setSelectedJobState] = useState(null); const [selectedEmployer, setSelectedEmployerState] = useState(null); + const [selectedResume, setSelectedResume] = useState(null); const [isInitializing, setIsInitializing] = useState(true); // Route state @@ -373,6 +376,8 @@ export function useAppStateLogic(): AppStateContextType { selectedJob, selectedEmployer, routeState, + selectedResume, + setSelectedResume, isInitializing, setSelectedCandidate, setSelectedJob, @@ -437,6 +442,11 @@ export function useSelectedEmployer() { return { selectedEmployer, setSelectedEmployer }; } +export const useSelectedResume = () => { + const { selectedResume, setSelectedResume } = useAppState(); + return { selectedResume, setSelectedResume }; +}; + export function useRouteState() { const { routeState, diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 603dba1..08e703a 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -674,6 +674,80 @@ class ApiClient { return this.handleApiResponseWithConversion(response, 'Job'); } + saveResume(candidate_id: string, job_id: string, resume: string, streamingOptions?: StreamingOptions): StreamingResponse { + const body = JSON.stringify(resume); + return this.streamify(`/resumes/${candidate_id}/${job_id}`, body, streamingOptions, "Resume"); + } + + // 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 + }); + + return handleApiResponse<{ success: boolean; resumes: Types.Resume[]; count: number }>(response); + } + + async getResume(resumeId: string): Promise<{ success: boolean; resume: Types.Resume }> { + const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { + headers: this.defaultHeaders + }); + + return handleApiResponse<{ success: boolean; resume: Types.Resume }>(response); + } + + async deleteResume(resumeId: string): Promise<{ success: boolean; message: string }> { + const response = await fetch(`${this.baseUrl}/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<{ success: boolean; candidateId: string; resumes: Types.Resume[]; count: number }>(response); + } + + 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 + }); + + return handleApiResponse<{ success: boolean; jobId: string; resumes: Types.Resume[]; count: number }>(response); + } + + async searchResumes(query: string): Promise<{ success: boolean; query: string; resumes: Types.Resume[]; count: number }> { + const params = new URLSearchParams({ q: query }); + const response = await fetch(`${this.baseUrl}/resumes/search?${params}`, { + headers: this.defaultHeaders + }); + + return handleApiResponse<{ success: boolean; query: string; resumes: Types.Resume[]; count: number }>(response); + } + + async getResumeStatistics(): Promise<{ success: boolean; statistics: any }> { + const response = await fetch(`${this.baseUrl}/resumes/stats`, { + headers: this.defaultHeaders + }); + + return handleApiResponse<{ success: boolean; statistics: any }>(response); + } + + async updateResume(resumeId: string, content: string): Promise<{ success: boolean; message: string; resume: Types.Resume }> { + const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { + method: 'PUT', + headers: this.defaultHeaders, + body: JSON.stringify(content) + }); + + return handleApiResponse<{ success: boolean; message: string; resume: Types.Resume }>(response); + } + async getJob(id: string): Promise { const response = await fetch(`${this.baseUrl}/jobs/${id}`, { headers: this.defaultHeaders diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 553cffb..5b9b6d5 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-06-11T22:14:30.373041 +// Generated on: 2025-06-12T15:58:49.974420 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -965,6 +965,31 @@ export interface ResendVerificationRequest { email: string; } +export interface Resume { + id?: string; + resumeId: string; + jobId: string; + candidateId: string; + resume: string; + createdAt?: Date; + updatedAt?: Date; + job?: Job; + candidate?: Candidate; +} + +export interface ResumeMessage { + id?: string; + sessionId: string; + senderId?: string; + status: "streaming" | "status" | "done" | "error"; + type: "binary" | "text" | "json"; + timestamp?: Date; + role: "user" | "assistant" | "system" | "information" | "warning" | "error"; + content: string; + tunables?: Tunables; + resume: Resume; +} + export interface RetrievalParameters { searchType: "similarity" | "mmr" | "hybrid" | "keyword"; topK: number; @@ -1770,6 +1795,42 @@ export function convertRefreshTokenFromApi(data: any): RefreshToken { expiresAt: new Date(data.expiresAt), }; } +/** + * Convert Resume from API response + * Date fields: createdAt, updatedAt + * Nested models: job (Job), candidate (Candidate) + */ +export function convertResumeFromApi(data: any): Resume { + if (!data) return data; + + return { + ...data, + // Convert createdAt from ISO string to Date + createdAt: data.createdAt ? new Date(data.createdAt) : undefined, + // Convert updatedAt from ISO string to Date + updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined, + // Convert nested Job model + job: data.job ? convertJobFromApi(data.job) : undefined, + // Convert nested Candidate model + candidate: data.candidate ? convertCandidateFromApi(data.candidate) : undefined, + }; +} +/** + * Convert ResumeMessage from API response + * Date fields: timestamp + * Nested models: resume (Resume) + */ +export function convertResumeMessageFromApi(data: any): ResumeMessage { + if (!data) return data; + + return { + ...data, + // Convert timestamp from ISO string to Date + timestamp: data.timestamp ? new Date(data.timestamp) : undefined, + // Convert nested Resume model + resume: convertResumeFromApi(data.resume), + }; +} /** * Convert SkillAssessment from API response * Date fields: createdAt, updatedAt @@ -1912,6 +1973,10 @@ export function convertFromApi(data: any, modelType: string): T { return convertRateLimitStatusFromApi(data) as T; case 'RefreshToken': return convertRefreshTokenFromApi(data) as T; + case 'Resume': + return convertResumeFromApi(data) as T; + case 'ResumeMessage': + return convertResumeMessageFromApi(data) as T; case 'SkillAssessment': return convertSkillAssessmentFromApi(data) as T; case 'UserActivity': diff --git a/src/backend/database.py b/src/backend/database.py index 9cac753..cec7fb0 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -183,7 +183,9 @@ class RedisDatabase: 'ai_parameters': 'ai_parameters:', 'users': 'user:', 'candidate_documents': 'candidate_documents:', - 'job_requirements': 'job_requirements:', # Add this line + 'job_requirements': 'job_requirements:', + 'resumes': 'resume:', + 'user_resumes': 'user_resumes:', } def _serialize(self, data: Any) -> str: @@ -203,6 +205,286 @@ class RedisDatabase: logger.error(f"Failed to deserialize data: {data}") return None + # Resume operations + async def set_resume(self, user_id: str, resume_data: Dict) -> bool: + """Save a resume for a user""" + try: + # Generate resume_id if not present + if 'resume_id' not in resume_data: + resume_data['resume_id'] = f"resume_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}" + + resume_id = resume_data['resume_id'] + + # Store the resume data + key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}" + await self.redis.set(key, self._serialize(resume_data)) + + # Add resume_id to user's resume list + user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" + await self.redis.rpush(user_resumes_key, resume_id) + + logger.info(f"📄 Saved resume {resume_id} for user {user_id}") + return True + except Exception as e: + logger.error(f"❌ Error saving resume for user {user_id}: {e}") + return False + + async def get_resume(self, user_id: str, resume_id: str) -> Optional[Dict]: + """Get a specific resume for a user""" + try: + key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}" + data = await self.redis.get(key) + if data: + resume_data = self._deserialize(data) + logger.debug(f"📄 Retrieved resume {resume_id} for user {user_id}") + return resume_data + logger.debug(f"📄 Resume {resume_id} not found for user {user_id}") + return None + except Exception as e: + logger.error(f"❌ Error retrieving resume {resume_id} for user {user_id}: {e}") + return None + + async def get_all_resumes_for_user(self, user_id: str) -> List[Dict]: + """Get all resumes for a specific user""" + try: + # Get all resume IDs for this user + user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" + resume_ids = await self.redis.lrange(user_resumes_key, 0, -1) + + if not resume_ids: + logger.debug(f"📄 No resumes found for user {user_id}") + return [] + + # Get all resume data + resumes = [] + pipe = self.redis.pipeline() + for resume_id in resume_ids: + pipe.get(f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}") + values = await pipe.execute() + + for resume_id, value in zip(resume_ids, values): + if value: + resume_data = self._deserialize(value) + if resume_data: + resumes.append(resume_data) + else: + # Clean up orphaned resume ID + await self.redis.lrem(user_resumes_key, 0, resume_id) + logger.warning(f"Removed orphaned resume ID {resume_id} for user {user_id}") + + # Sort by created_at timestamp (most recent first) + resumes.sort(key=lambda x: x.get("created_at", ""), reverse=True) + + logger.debug(f"📄 Retrieved {len(resumes)} resumes for user {user_id}") + return resumes + except Exception as e: + logger.error(f"❌ Error retrieving resumes for user {user_id}: {e}") + return [] + + async def delete_resume(self, user_id: str, resume_id: str) -> bool: + """Delete a specific resume for a user""" + try: + # Delete the resume data + key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}" + result = await self.redis.delete(key) + + # Remove from user's resume list + user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" + await self.redis.lrem(user_resumes_key, 0, resume_id) + + if result > 0: + logger.info(f"🗑️ Deleted resume {resume_id} for user {user_id}") + return True + else: + logger.warning(f"⚠️ Resume {resume_id} not found for user {user_id}") + return False + except Exception as e: + logger.error(f"❌ Error deleting resume {resume_id} for user {user_id}: {e}") + return False + + async def delete_all_resumes_for_user(self, user_id: str) -> int: + """Delete all resumes for a specific user and return count of deleted resumes""" + try: + # Get all resume IDs for this user + user_resumes_key = f"{self.KEY_PREFIXES['user_resumes']}{user_id}" + resume_ids = await self.redis.lrange(user_resumes_key, 0, -1) + + if not resume_ids: + logger.info(f"📄 No resumes found for user {user_id}") + return 0 + + deleted_count = 0 + + # Use pipeline for efficient batch operations + pipe = self.redis.pipeline() + + # Delete each resume + for resume_id in resume_ids: + pipe.delete(f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}") + deleted_count += 1 + + # Delete the user's resume list + pipe.delete(user_resumes_key) + + # Execute all operations + await pipe.execute() + + logger.info(f"🗑️ Successfully deleted {deleted_count} resumes for user {user_id}") + return deleted_count + + except Exception as e: + logger.error(f"❌ Error deleting all resumes for user {user_id}: {e}") + raise + + async def get_all_resumes(self) -> Dict[str, List[Dict]]: + """Get all resumes grouped by user (admin function)""" + try: + pattern = f"{self.KEY_PREFIXES['resumes']}*" + keys = await self.redis.keys(pattern) + + if not keys: + return {} + + # Group by user_id + user_resumes = {} + pipe = self.redis.pipeline() + for key in keys: + pipe.get(key) + values = await pipe.execute() + + for key, value in zip(keys, values): + if value: + # Extract user_id from key format: resume:{user_id}:{resume_id} + key_parts = key.replace(self.KEY_PREFIXES['resumes'], '').split(':', 1) + if len(key_parts) >= 1: + user_id = key_parts[0] + resume_data = self._deserialize(value) + if resume_data: + if user_id not in user_resumes: + user_resumes[user_id] = [] + user_resumes[user_id].append(resume_data) + + # Sort each user's resumes by created_at + for user_id in user_resumes: + user_resumes[user_id].sort(key=lambda x: x.get("created_at", ""), reverse=True) + + return user_resumes + except Exception as e: + logger.error(f"❌ Error retrieving all resumes: {e}") + return {} + + async def search_resumes_for_user(self, user_id: str, query: str) -> List[Dict]: + """Search resumes for a user by content, job title, or candidate name""" + try: + all_resumes = await self.get_all_resumes_for_user(user_id) + query_lower = query.lower() + + matching_resumes = [] + for resume in all_resumes: + # Search in resume content, job_id, candidate_id, etc. + searchable_text = " ".join([ + resume.get("resume", ""), + resume.get("job_id", ""), + resume.get("candidate_id", ""), + str(resume.get("created_at", "")) + ]).lower() + + if query_lower in searchable_text: + matching_resumes.append(resume) + + logger.debug(f"📄 Found {len(matching_resumes)} matching resumes for user {user_id}") + return matching_resumes + except Exception as e: + logger.error(f"❌ Error searching resumes for user {user_id}: {e}") + return [] + + async def get_resumes_by_candidate(self, user_id: str, candidate_id: str) -> List[Dict]: + """Get all resumes for a specific candidate created by a user""" + try: + all_resumes = await self.get_all_resumes_for_user(user_id) + candidate_resumes = [ + resume for resume in all_resumes + if resume.get("candidate_id") == candidate_id + ] + + logger.debug(f"📄 Found {len(candidate_resumes)} resumes for candidate {candidate_id} by user {user_id}") + return candidate_resumes + except Exception as e: + logger.error(f"❌ Error retrieving resumes for candidate {candidate_id} by user {user_id}: {e}") + return [] + + async def get_resumes_by_job(self, user_id: str, job_id: str) -> List[Dict]: + """Get all resumes for a specific job created by a user""" + try: + all_resumes = await self.get_all_resumes_for_user(user_id) + job_resumes = [ + resume for resume in all_resumes + if resume.get("job_id") == job_id + ] + + logger.debug(f"📄 Found {len(job_resumes)} resumes for job {job_id} by user {user_id}") + return job_resumes + except Exception as e: + logger.error(f"❌ Error retrieving resumes for job {job_id} by user {user_id}: {e}") + return [] + + async def get_resume_statistics(self, user_id: str) -> Dict[str, Any]: + """Get resume statistics for a user""" + try: + all_resumes = await self.get_all_resumes_for_user(user_id) + + stats = { + "total_resumes": len(all_resumes), + "resumes_by_candidate": {}, + "resumes_by_job": {}, + "creation_timeline": {}, + "recent_resumes": [] + } + + for resume in all_resumes: + # Count by candidate + candidate_id = resume.get("candidate_id", "unknown") + stats["resumes_by_candidate"][candidate_id] = stats["resumes_by_candidate"].get(candidate_id, 0) + 1 + + # Count by job + job_id = resume.get("job_id", "unknown") + stats["resumes_by_job"][job_id] = stats["resumes_by_job"].get(job_id, 0) + 1 + + # Timeline by date + created_at = resume.get("created_at") + if created_at: + try: + date_key = created_at[:10] # Extract date part + stats["creation_timeline"][date_key] = stats["creation_timeline"].get(date_key, 0) + 1 + except (IndexError, TypeError): + pass + + # Get recent resumes (last 5) + stats["recent_resumes"] = all_resumes[:5] + + return stats + except Exception as e: + logger.error(f"❌ Error getting resume statistics for user {user_id}: {e}") + return {"total_resumes": 0, "resumes_by_candidate": {}, "resumes_by_job": {}, "creation_timeline": {}, "recent_resumes": []} + + async def update_resume(self, user_id: str, resume_id: str, updates: Dict) -> Optional[Dict]: + """Update specific fields of a resume""" + try: + resume_data = await self.get_resume(user_id, resume_id) + if resume_data: + resume_data.update(updates) + resume_data["updated_at"] = datetime.now(UTC).isoformat() + + key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}" + await self.redis.set(key, self._serialize(resume_data)) + + logger.info(f"📄 Updated resume {resume_id} for user {user_id}") + return resume_data + return None + except Exception as e: + logger.error(f"❌ Error updating resume {resume_id} for user {user_id}: {e}") + return None + # Document operations async def get_document(self, document_id: str) -> Optional[Dict]: """Get document metadata by ID""" @@ -658,7 +940,8 @@ class RedisDatabase: "auth_records": 0, "security_logs": 0, "ai_parameters": 0, - "candidate_record": 0 + "candidate_record": 0, + "resumes": 0 } logger.info(f"🗑️ Starting cascading delete for candidate {candidate_id}") @@ -893,7 +1176,30 @@ class RedisDatabase: except Exception as e: logger.error(f"❌ Error deleting candidate record: {e}") - # 14. Log the deletion as a security event (if we have admin/system user context) + # 14. Delete resumes associated with this candidate across all users + try: + all_resumes = await self.get_all_resumes() + candidate_resumes_deleted = 0 + + for user_id, user_resumes in all_resumes.items(): + resumes_to_delete = [] + for resume in user_resumes: + if resume.get("candidate_id") == candidate_id: + resumes_to_delete.append(resume.get("resume_id")) + + # Delete each resume for this candidate + for resume_id in resumes_to_delete: + if resume_id: + await self.delete_resume(user_id, resume_id) + candidate_resumes_deleted += 1 + + deletion_stats["resumes"] = candidate_resumes_deleted + if candidate_resumes_deleted > 0: + logger.info(f"🗑️ Deleted {candidate_resumes_deleted} resumes for candidate {candidate_id}") + except Exception as e: + logger.error(f"❌ Error deleting resumes for candidate {candidate_id}: {e}") + + # 15. Log the deletion as a security event (if we have admin/system user context) try: total_items_deleted = sum(deletion_stats.values()) logger.info(f"✅ Completed cascading delete for candidate {candidate_id}. " @@ -924,7 +1230,8 @@ class RedisDatabase: "auth_records": 0, "security_logs": 0, "ai_parameters": 0, - "candidate_record": 0 + "candidate_record": 0, + "resumes": 0, } logger.info(f"🗑️ Starting batch deletion for {len(candidate_ids)} candidates") diff --git a/src/backend/get_requirements_list.py b/src/backend/get_requirements_list.py new file mode 100644 index 0000000..e858fe3 --- /dev/null +++ b/src/backend/get_requirements_list.py @@ -0,0 +1,62 @@ +from typing import List, Dict +from models import (Job) + +def get_requirements_list(job: Job) -> List[Dict[str, str]]: + requirements: List[Dict[str, str]] = [] + + if job.requirements: + if job.requirements.technical_skills: + if job.requirements.technical_skills.required: + requirements.extend([ + {"requirement": req, "domain": "Technical Skills (required)"} + for req in job.requirements.technical_skills.required + ]) + if job.requirements.technical_skills.preferred: + requirements.extend([ + {"requirement": req, "domain": "Technical Skills (preferred)"} + for req in job.requirements.technical_skills.preferred + ]) + + if job.requirements.experience_requirements: + if job.requirements.experience_requirements.required: + requirements.extend([ + {"requirement": req, "domain": "Experience (required)"} + for req in job.requirements.experience_requirements.required + ]) + if job.requirements.experience_requirements.preferred: + requirements.extend([ + {"requirement": req, "domain": "Experience (preferred)"} + for req in job.requirements.experience_requirements.preferred + ]) + + if job.requirements.soft_skills: + requirements.extend([ + {"requirement": req, "domain": "Soft Skills"} + for req in job.requirements.soft_skills + ]) + + if job.requirements.experience: + requirements.extend([ + {"requirement": req, "domain": "Experience"} + for req in job.requirements.experience + ]) + + if job.requirements.education: + requirements.extend([ + {"requirement": req, "domain": "Education"} + for req in job.requirements.education + ]) + + if job.requirements.certifications: + requirements.extend([ + {"requirement": req, "domain": "Certifications"} + for req in job.requirements.certifications + ]) + + if job.requirements.preferred_attributes: + requirements.extend([ + {"requirement": req, "domain": "Preferred Attributes"} + for req in job.requirements.preferred_attributes + ]) + + return requirements \ No newline at end of file diff --git a/src/backend/main.py b/src/backend/main.py index 96ca28c..3ed0be5 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -94,7 +94,7 @@ from models import ( Document, DocumentType, DocumentListResponse, DocumentUpdateRequest, DocumentContentResponse, # Supporting models - Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, Skill, SkillAssessment, SystemInfo, WorkExperience, Education, + Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, RagContentMetadata, RagContentResponse, ResendVerificationRequest, Resume, ResumeMessage, Skill, SkillAssessment, SystemInfo, WorkExperience, Education, # Email EmailVerificationRequest @@ -3280,7 +3280,270 @@ async def confirm_password_reset( status_code=500, content=create_error_response("RESET_ERROR", "An error occurred resetting the password") ) - + +# ============================ +# Resume Endpoints +# ============================ + +@api_router.post("/resumes/{candidate_id}/{job_id}") +async def create_candidate_resume( + candidate_id: str = Path(..., description="ID of the candidate"), + job_id: str = Path(..., description="ID of the job"), + resume_content: str = Body(...), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Create a new resume for a candidate/job combination""" + async def message_stream_generator(): + logger.info(f"🔍 Looking up candidate and job details for {candidate_id}/{job_id}") + + candidate_data = await database.get_candidate(candidate_id) + if not candidate_data: + logger.error(f"❌ Candidate with ID '{candidate_id}' not found") + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"Candidate with ID '{candidate_id}' not found" + ) + yield error_message + return + candidate = Candidate.model_validate(candidate_data) + + job_data = await database.get_job(job_id) + if not job_data: + logger.error(f"❌ Job with ID '{job_id}' not found") + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"Job with ID '{job_id}' not found" + ) + yield error_message + return + job = Job.model_validate(job_data) + + logger.info(f"📄 Saving resume for candidate {candidate.first_name} {candidate.last_name} for job '{job.title}'") + + # Job and Candidate are valid. Save the resume + resume = Resume( + job_id=job_id, + candidate_id=candidate_id, + resume=resume_content, + ) + resume_message: ResumeMessage = ResumeMessage( + sessionId=MOCK_UUID, # No session ID for document uploads + resume=resume + ) + + # Save to database + success = await database.set_resume(current_user.id, resume.model_dump()) + if not success: + error_message = ChatMessageError( + sessionId=MOCK_UUID, + content="Failed to save resume to database" + ) + yield error_message + return + + logger.info(f"✅ Successfully saved resume {resume_message.resume.id} for user {current_user.id}") + yield resume_message + return + + try: + async def to_json(method): + try: + async for message in method: + json_data = message.model_dump(mode='json', by_alias=True) + json_str = json.dumps(json_data) + yield f"data: {json_str}\n\n".encode("utf-8") + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"Error in to_json conversion: {e}") + return + + return StreamingResponse( + to_json(message_stream_generator()), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Nginx + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*", # Adjust for your CORS needs + "Transfer-Encoding": "chunked", + }, + ) + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"❌ Resume creation error: {e}") + return StreamingResponse( + iter([json.dumps(ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content="Failed to create resume" + ).model_dump(mode='json', by_alias=True))]), + media_type="text/event-stream" + ) + +@api_router.get("/resumes") +async def get_user_resumes( + current_user = Depends(get_current_user), + 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) + resumes : List[Resume] = [Resume.model_validate(data) for data in resumes_data] + return create_success_response({ + "resumes": resumes, + "count": len(resumes) + }) + except Exception as e: + logger.error(f"❌ Error retrieving resumes for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve resumes") + +@api_router.get("/resumes/{resume_id}") +async def get_resume( + resume_id: str = Path(..., description="ID of the resume"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Get a specific resume by ID""" + try: + resume = await database.get_resume(current_user.id, resume_id) + if not resume: + raise HTTPException(status_code=404, detail="Resume not found") + + return { + "success": True, + "resume": resume + } + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error retrieving resume {resume_id} for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to retrieve resume") + +@api_router.delete("/resumes/{resume_id}") +async def delete_resume( + resume_id: str = Path(..., description="ID of the resume"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Delete a specific resume""" + try: + success = await database.delete_resume(current_user.id, resume_id) + if not success: + raise HTTPException(status_code=404, detail="Resume not found") + + return { + "success": True, + "message": f"Resume {resume_id} deleted successfully" + } + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error deleting resume {resume_id} for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to delete resume") + +@api_router.get("/resumes/candidate/{candidate_id}") +async def get_resumes_by_candidate( + candidate_id: str = Path(..., description="ID of the candidate"), + current_user = Depends(get_current_user), + 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) + } + 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") + +@api_router.get("/resumes/job/{job_id}") +async def get_resumes_by_job( + job_id: str = Path(..., description="ID of the job"), + current_user = Depends(get_current_user), + 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) + } + 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") + +@api_router.get("/resumes/search") +async def search_resumes( + q: str = Query(..., description="Search query"), + current_user = Depends(get_current_user), + 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) + } + 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") + +@api_router.get("/resumes/stats") +async def get_resume_statistics( + current_user = Depends(get_current_user), + 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 + } + 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") + +@api_router.put("/resumes/{resume_id}") +async def update_resume( + resume_id: str = Path(..., description="ID of the resume"), + resume: str = Body(..., description="Updated resume content"), + current_user = Depends(get_current_user), + database: RedisDatabase = Depends(get_database) +): + """Update the content of a specific resume""" + try: + updates = { + "resume": resume, + "updated_at": datetime.now(UTC).isoformat() + } + + updated_resume = await database.update_resume(current_user.id, resume_id, updates) + if not updated_resume: + raise HTTPException(status_code=404, detail="Resume not found") + + return { + "success": True, + "message": f"Resume {resume_id} updated successfully", + "resume": updated_resume + } + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error updating resume {resume_id} for user {current_user.id}: {e}") + raise HTTPException(status_code=500, detail="Failed to update resume") + # ============================ # Job Endpoints # ============================ diff --git a/src/backend/models.py b/src/backend/models.py index a44dd35..ad11061 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -1019,6 +1019,29 @@ class ChatMessageResume(ChatMessageUser): resume: str = Field(..., alias="resume") system_prompt: Optional[str] = Field(None, alias="systemPrompt") prompt: Optional[str] = Field(None, alias="prompt") + model_config = { + "populate_by_name": True, # Allow both field names and aliases + } + +class Resume(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + job_id: str = Field(..., alias="jobId") + candidate_id: str = Field(..., alias="candidateId") + resume: str = Field(..., alias="resume") + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt") + updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt") + job: Optional[Job] = None + candidate: Optional[Candidate] = None + model_config = { + "populate_by_name": True, # Allow both field names and aliases + } + +class ResumeMessage(ChatMessageUser): + role: ChatSenderType = ChatSenderType.ASSISTANT + resume: Resume = Field(..., alias="resume") + model_config = { + "populate_by_name": True, # Allow both field names and aliases + } class GPUInfo(BaseModel): name: str