Added Resume viewing

This commit is contained in:
James Ketr 2025-06-12 09:22:06 -07:00
parent 0bc9f74c7f
commit 8dcc1c0336
11 changed files with 1830 additions and 7 deletions

View File

@ -99,6 +99,28 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (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 (
<Box
className="ResumeGenerator"
@ -129,6 +151,10 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
{tabValue === 'prompt' && <pre>{prompt}</pre>}
{tabValue === 'resume' && <><CopyBubble onClick={() => { setSnack('Resume copied to clipboard!'); }} sx={{ position: "absolute", top: 0, right: 0 }} content={resume} /><StyledMarkdown content={resume} /></>}
</Scrollable></Paper>
{resume && !status && !error && <Button onClick={handleSave} variant="contained" color="primary" sx={{ mt: 2 }}>
Save Resume
</Button>}
</Box>
)

View File

@ -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<ResumeInfoProps> = (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>({ ...resume });
const [isContentExpanded, setIsContentExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
const [deleted, setDeleted] = useState<boolean>(false);
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
const [editContent, setEditContent] = useState<string>('');
const [saving, setSaving] = useState<boolean>(false);
const contentRef = useRef<HTMLDivElement>(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 <Box>No resume provided.</Box>;
}
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 (
<Box
sx={{
display: "flex",
borderColor: 'transparent',
borderWidth: 2,
borderStyle: 'solid',
transition: 'all 0.3s ease',
flexDirection: "column",
minWidth: 0,
opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto",
...sx,
}}
>
<Box sx={{ display: "flex", flexGrow: 1, p: 1, pb: 0, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
{/* Header Information */}
<Box sx={{
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 2,
mb: 2
}}>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}>
{activeResume.candidate && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PersonIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Candidate
</Typography>
</Box>
)}
<Typography variant="body2" color="text.secondary">
{activeResume.candidate?.fullName || activeResume.candidateId}
</Typography>
{activeResume.job && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<WorkIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Job
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{activeResume.job.title} at {activeResume.job.company}
</Typography>
</>
)}
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Stack spacing={1}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon color="primary" fontSize="small" />
<Typography variant="subtitle2" fontWeight="bold">
Timeline
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
Created: {formatDate(activeResume.createdAt)}
</Typography>
<Typography variant="caption" color="text.secondary">
Updated: {formatDate(activeResume.updatedAt)}
</Typography>
<Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.resumeId}
</Typography>
</Stack>
</Grid>
</Grid>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Resume Content */}
{activeResume.resume && (
<Card elevation={0} sx={{ m: 0, p: 0, background: "transparent !important" }}>
<CardHeader
title="Resume Content"
avatar={<DescriptionIcon color="success" />}
sx={{ p: 0, pb: 1 }}
action={
isAdmin && (
<Tooltip title="Edit Resume Content">
<IconButton size="small" onClick={handleEditOpen}>
<EditIcon />
</IconButton>
</Tooltip>
)
}
/>
<CardContent sx={{ p: 0 }}>
<Box sx={{ position: 'relative' }}>
<Typography
ref={contentRef}
variant="body2"
component="div"
sx={{
display: '-webkit-box',
WebkitLineClamp: isContentExpanded ? 'unset' : (variant === "small" ? 5 : variant === "minimal" ? 3 : 10),
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.6,
fontSize: "0.875rem !important",
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
backgroundColor: theme.palette.action.hover,
p: 2,
borderRadius: 1,
border: `1px solid ${theme.palette.divider}`,
}}
>
{activeResume.resume}
</Typography>
{shouldShowMoreButton && variant !== "all" && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1 }}>
<Button
variant="text"
size="small"
onClick={() => setIsContentExpanded(!isContentExpanded)}
startIcon={isContentExpanded ? <VisibilityOffIcon /> : <VisibilityIcon />}
sx={{ fontSize: '0.75rem' }}
>
{isContentExpanded ? "Show Less" : "Show More"}
</Button>
</Box>
)}
</Box>
</CardContent>
</Card>
)}
{variant === 'all' && activeResume.resume && (
<Box sx={{ mt: 2 }}>
<StyledMarkdown content={activeResume.resume} />
</Box>
)}
</Box>
{/* Admin Controls */}
{isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
<Tooltip title="Edit Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleEditOpen(); }}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteResume(activeResume.id); }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reset Resume">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
>
<RestoreIcon />
</IconButton>
</Tooltip>
</Box>
{saving && (
<Box sx={{ mt: 1 }}>
<LinearProgress />
<Typography variant="caption" sx={{ mt: 0.5 }}>
Saving resume...
</Typography>
</Box>
)}
</Box>
)}
{/* Edit Dialog */}
<Dialog
open={editDialogOpen}
onClose={() => setEditDialogOpen(false)}
maxWidth="lg"
fullWidth
fullScreen={isMobile}
>
<DialogTitle>
Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary">
Resume for {activeResume.candidate?.fullName || activeResume.candidateId}
</Typography>
</DialogTitle>
<DialogContent>
<TextField
multiline
fullWidth
rows={20}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
variant="outlined"
sx={{
mt: 1,
'& .MuiInputBase-input': {
fontFamily: 'monospace',
fontSize: '0.875rem'
}
}}
placeholder="Enter resume content..."
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
startIcon={<SaveIcon />}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export { ResumeInfo };

View File

@ -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<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const ResumeViewer: React.FC<ResumeViewerProps> = ({ 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<Resume[]>([]);
const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [mobileDialogOpen, setMobileDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filteredResumes, setFilteredResumes] = useState<Resume[]>([]);
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' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
};
const getDisplayTitle = () => {
if (candidateId) return `Resumes for Candidate`;
if (jobId) return `Resumes for Job`;
return `All Resumes`;
};
const ResumeList = () => (
<Paper
elevation={isMobile ? 0 : 1}
sx={{
display: 'flex',
flexDirection: 'column',
...(isMobile ? {
width: '100%',
boxShadow: 'none',
backgroundColor: 'transparent'
} : { width: '50%' })
}}
>
<Box sx={{
p: isMobile ? 0.5 : 1,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: isMobile ? 'background.paper' : 'inherit'
}}>
<Typography
variant={isSmall ? "subtitle2" : isMobile ? "subtitle1" : "h6"}
gutterBottom
sx={{ mb: isMobile ? 0.5 : 1, fontWeight: 600 }}
>
{getDisplayTitle()} ({sortedResumes.length})
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexDirection: isSmall ? 'column' : 'row', alignItems: isSmall ? 'stretch' : 'center' }}>
<TextField
size="small"
placeholder="Search resumes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: searchQuery && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleSearchClear}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
sx={{ flexGrow: 1, minWidth: isSmall ? '100%' : 200 }}
/>
<FormControl size="small" sx={{ minWidth: isSmall ? '100%' : 180 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={(e) => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
<MenuItem value="candidateId-asc">Candidate (A-Z)</MenuItem>
<MenuItem value="candidateId-desc">Candidate (Z-A)</MenuItem>
<MenuItem value="jobId-asc">Job (A-Z)</MenuItem>
<MenuItem value="jobId-desc">Job (Z-A)</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
<TableContainer sx={{
flex: 1,
overflow: 'auto',
'& .MuiTable-root': {
tableLayout: isMobile ? 'fixed' : 'auto'
}
}}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '35%' : 'auto',
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('candidateId')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PersonIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap>
{isSmall ? 'Candidate' : 'Candidate'}
</Typography>
{getSortIcon('candidateId')}
</Box>
</TableCell>
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '35%' : 'auto',
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('jobId')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<WorkIcon fontSize={isMobile ? "small" : "medium"} />
<Typography variant="caption" fontWeight="bold" noWrap>Job</Typography>
{getSortIcon('jobId')}
</Box>
</TableCell>
{!isMobile && (
<TableCell
sx={{
cursor: 'pointer',
userSelect: 'none',
py: 0.5,
px: 1,
backgroundColor: 'background.paper'
}}
onClick={() => handleSort('updatedAt')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ScheduleIcon fontSize="medium" />
<Typography variant="caption" fontWeight="bold">Updated</Typography>
{getSortIcon('updatedAt')}
</Box>
</TableCell>
)}
<TableCell sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
width: isMobile ? '30%' : 'auto',
backgroundColor: 'background.paper'
}}>
<Typography variant="caption" fontWeight="bold" noWrap>
ID
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedResumes.map((resume) => (
<TableRow
key={resume.id}
hover
selected={selectedResume?.id === resume.id}
onClick={() => handleResumeSelect(resume)}
sx={{
cursor: 'pointer',
height: isMobile ? 48 : 'auto',
'&.Mui-selected': {
backgroundColor: 'action.selected',
},
'&:hover': {
backgroundColor: 'action.hover',
}
}}
>
<TableCell sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden'
}}>
<Typography
variant={isMobile ? "caption" : "body2"}
fontWeight="medium"
noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
>
{resume.candidate?.fullName || resume.candidateId}
</Typography>
{isMobile && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: 'block', fontSize: '0.7rem' }}
>
{formatDate(resume.updatedAt)}
</Typography>
)}
</TableCell>
<TableCell sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden'
}}>
<Typography
variant={isMobile ? "caption" : "body2"}
fontWeight="medium"
noWrap
sx={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
>
{resume.job?.title || 'Unknown Job'}
</Typography>
{!isMobile && resume.job?.company && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: 'block', fontSize: '0.7rem' }}
>
{resume.job.company}
</Typography>
)}
</TableCell>
{!isMobile && (
<TableCell sx={{ py: 0.5, px: 1 }}>
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{formatDate(resume.updatedAt)}
</Typography>
{resume.createdAt && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', fontSize: '0.7rem' }}
>
Created: {formatDate(resume.createdAt)}
</Typography>
)}
</TableCell>
)}
<TableCell sx={{
py: isMobile ? 0.25 : 0.5,
px: isMobile ? 0.5 : 1,
overflow: 'hidden'
}}>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }}
>
{resume.resumeId}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
const ResumeDetails = ({ inDialog = false }: { inDialog?: boolean }) => (
<Box sx={{
flex: 1,
overflow: 'auto',
p: inDialog ? 1.5 : 0.75,
height: inDialog ? '100%' : 'auto'
}}>
{selectedResume ? (
<ResumeInfo
resume={selectedResume}
variant="all"
sx={{
border: 'none',
boxShadow: 'none',
backgroundColor: 'transparent',
'& .MuiTypography-h6': {
fontSize: inDialog ? '1.25rem' : '1.1rem'
}
}}
/>
) : (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'text.secondary',
textAlign: 'center',
p: 2
}}>
<Typography variant="body2">
Select a resume to view details
</Typography>
</Box>
)}
</Box>
);
if (isMobile) {
return (
<Box sx={{
height: '100%',
p: 0.5,
backgroundColor: 'background.default'
}}>
<ResumeList />
<Dialog
fullScreen
open={mobileDialogOpen}
onClose={handleMobileDialogClose}
TransitionComponent={Transition}
TransitionProps={{ timeout: 300 }}
>
<AppBar sx={{ position: 'relative', elevation: 1 }}>
<Toolbar variant="dense" sx={{ minHeight: 48 }}>
<IconButton
edge="start"
color="inherit"
onClick={handleMobileDialogClose}
aria-label="back"
size="small"
>
<ArrowBackIcon />
</IconButton>
<Box sx={{ ml: 1, flex: 1, minWidth: 0 }}>
<Typography
variant="h6"
component="div"
noWrap
sx={{ fontSize: '1rem' }}
>
Resume Details
</Typography>
<Typography
variant="caption"
component="div"
sx={{ color: 'rgba(255, 255, 255, 0.7)' }}
noWrap
>
{selectedResume?.candidate?.fullName || selectedResume?.candidateId}
</Typography>
</Box>
</Toolbar>
</AppBar>
<ResumeDetails inDialog />
</Dialog>
</Box>
);
}
return (
<Box sx={{
display: 'flex',
height: '100%',
gap: 0.75,
p: 0.75,
backgroundColor: 'background.default'
}}>
<ResumeList />
<Paper sx={{
width: '50%',
display: 'flex',
flexDirection: 'column',
elevation: 1
}}>
<Box sx={{
p: 0.75,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.paper'
}}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', fontWeight: 600 }}>
Resume Details
</Typography>
</Box>
<ResumeDetails />
</Paper>
</Box>
);
};
export { ResumeViewer };

View File

@ -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: <SearchIcon />,
component: (
<ResumeViewer />
),
userTypes: ["candidate", "guest", "employer"],
},
],
showInNavigation: true,
},

View File

@ -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<Types.Candidate | null>(null);
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null);
const [selectedResume, setSelectedResume] = useState<Types.Resume | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(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,

View File

@ -674,6 +674,80 @@ class ApiClient {
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
}
saveResume(candidate_id: string, job_id: string, resume: string, streamingOptions?: StreamingOptions<Types.ResumeMessage>): StreamingResponse<Types.ResumeMessage> {
const body = JSON.stringify(resume);
return this.streamify<Types.ResumeMessage>(`/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<Types.Job> {
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
headers: this.defaultHeaders

View File

@ -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<T>(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':

View File

@ -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")

View File

@ -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

View File

@ -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
# ============================

View File

@ -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