Added Resume viewing
This commit is contained in:
parent
0bc9f74c7f
commit
8dcc1c0336
@ -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>
|
||||
)
|
||||
|
||||
|
381
frontend/src/components/ui/ResumeInfo.tsx
Normal file
381
frontend/src/components/ui/ResumeInfo.tsx
Normal 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 };
|
601
frontend/src/components/ui/ResumeViewer.tsx
Normal file
601
frontend/src/components/ui/ResumeViewer.tsx
Normal 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 };
|
@ -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,
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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':
|
||||
|
@ -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")
|
||||
|
62
src/backend/get_requirements_list.py
Normal file
62
src/backend/get_requirements_list.py
Normal 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
|
@ -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
|
||||
# ============================
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user