Revision control
This commit is contained in:
parent
b32f0948c4
commit
621bf46a39
@ -25,6 +25,10 @@ import {
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Theme,
|
||||
Chip,
|
||||
Alert,
|
||||
Stack,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import PrintIcon from '@mui/icons-material/Print';
|
||||
import {
|
||||
@ -40,6 +44,9 @@ import {
|
||||
Email as EmailIcon,
|
||||
Phone as PhoneIcon,
|
||||
LocationOn as LocationIcon,
|
||||
History as HistoryIcon,
|
||||
RestoreFromTrash as RestoreFromTrashIcon,
|
||||
Refresh as RefreshIcon,
|
||||
// Language as WebsiteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import InputIcon from '@mui/icons-material/Input';
|
||||
@ -71,6 +78,16 @@ interface ResumeInfoProps {
|
||||
variant?: 'minimal' | 'small' | 'normal' | 'all' | null;
|
||||
}
|
||||
|
||||
// Resume revision interface
|
||||
interface ResumeRevision {
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
candidateId: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
// Resume Style Definitions
|
||||
interface ResumeStyle {
|
||||
name: string;
|
||||
@ -384,7 +401,6 @@ const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, st
|
||||
fontSize: '0.8rem',
|
||||
pb: 2,
|
||||
mb: 2,
|
||||
// pt: 2,
|
||||
color: style.color.secondary,
|
||||
}}
|
||||
>
|
||||
@ -515,26 +531,6 @@ const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* {(candidate.website || candidate.linkedin) && (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WebsiteIcon
|
||||
fontSize="small"
|
||||
sx={{ color: style.name === 'creative' ? '#ffffff' : style.color.accent }}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: style.name === 'creative' ? '#ffffff' : style.color.text,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{candidate.website || candidate.linkedin}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)} */}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@ -563,6 +559,13 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
const [error, setError] = useState<Types.ChatMessageError | null>(null);
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>('corporate');
|
||||
|
||||
// New revision-related state
|
||||
const [revisions, setRevisions] = useState<ResumeRevision[]>([]);
|
||||
const [selectedRevision, setSelectedRevision] = useState<string>('current');
|
||||
const [loadingRevisions, setLoadingRevisions] = useState<boolean>(false);
|
||||
const [loadingRevision, setLoadingRevision] = useState<boolean>(false);
|
||||
const [revisionContent, setRevisionContent] = useState<string>('');
|
||||
|
||||
const printContentRef = useRef<HTMLDivElement>(null);
|
||||
const reactToPrintFn = useReactToPrint({
|
||||
contentRef: printContentRef,
|
||||
@ -575,11 +578,86 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
}
|
||||
}, [resume, activeResume]);
|
||||
|
||||
// Load revisions when dialog opens
|
||||
useEffect(() => {
|
||||
if (editDialogOpen && activeResume.id) {
|
||||
loadResumeRevisions();
|
||||
}
|
||||
}, [editDialogOpen, activeResume.id]);
|
||||
|
||||
const currentStyle = useMemo(() => {
|
||||
return resumeStyles[selectedStyle];
|
||||
}, [selectedStyle]);
|
||||
|
||||
// Rest of the component remains the same...
|
||||
// Load resume revisions
|
||||
const loadResumeRevisions = async (): Promise<void> => {
|
||||
if (!activeResume.id) return;
|
||||
|
||||
setLoadingRevisions(true);
|
||||
try {
|
||||
const response = await apiClient.getResumeRevisions(activeResume.id);
|
||||
console.log('Loaded revisions:', response.revisions);
|
||||
setRevisions(response.revisions || []);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error('Failed to load revisions:', error);
|
||||
setSnack(`Failed to load resume revisions: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
} finally {
|
||||
setLoadingRevisions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load specific revision content
|
||||
const loadRevisionContent = async (revisionId: string): Promise<void> => {
|
||||
if (!activeResume.id || revisionId === 'current') {
|
||||
setRevisionContent(editContent);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRevision(true);
|
||||
try {
|
||||
const response = await apiClient.getResumeRevision(activeResume.id, revisionId);
|
||||
setRevisionContent(response.revision.resume || '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load revision:', error);
|
||||
setSnack('Failed to load revision content.');
|
||||
} finally {
|
||||
setLoadingRevision(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle revision selection
|
||||
const handleRevisionChange = async (event: SelectChangeEvent<string>): Promise<void> => {
|
||||
const newRevisionId = event.target.value;
|
||||
setSelectedRevision(newRevisionId);
|
||||
await loadRevisionContent(newRevisionId);
|
||||
};
|
||||
|
||||
// Restore revision to editor
|
||||
const restoreRevision = (): void => {
|
||||
setEditContent(revisionContent);
|
||||
setSelectedRevision('current');
|
||||
setSnack('Revision restored to editor.');
|
||||
};
|
||||
|
||||
// Format timestamp for display
|
||||
const formatRevisionTimestamp = (timestamp: string): string => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
// Rest of the existing methods...
|
||||
const deleteResume = async (id: string | undefined): Promise<void> => {
|
||||
if (id) {
|
||||
try {
|
||||
@ -613,6 +691,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
};
|
||||
setActiveResume(updatedResume);
|
||||
setSnack('Resume updated successfully.');
|
||||
// Reload revisions to include the new version
|
||||
await loadResumeRevisions();
|
||||
} catch (error) {
|
||||
setSnack('Failed to update resume.');
|
||||
} finally {
|
||||
@ -624,6 +704,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
setEditContent(activeResume.resume);
|
||||
setEditSystemPrompt(activeResume.systemPrompt || '');
|
||||
setEditPrompt(activeResume.prompt || '');
|
||||
setSelectedRevision('current');
|
||||
setRevisionContent(activeResume.resume);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
@ -1016,6 +1098,92 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Revision History Dropdown for Markdown Tab */}
|
||||
{tabValue === 'markdown' && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="revision-select-label">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HistoryIcon fontSize="small" />
|
||||
Version History
|
||||
</Box>
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="revision-select-label"
|
||||
value={selectedRevision}
|
||||
onChange={handleRevisionChange}
|
||||
label="Version History"
|
||||
disabled={loadingRevisions}
|
||||
>
|
||||
<MenuItem value="current">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="CURRENT" size="small" color="primary" variant="outlined" />
|
||||
<Typography variant="body2">Current Version</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
{revisions.map(revision => (
|
||||
<MenuItem key={revision.revisionId} value={revision.revisionId}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{formatRevisionTimestamp(revision.revisionTimestamp)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Updated: {formatRevisionTimestamp(revision.updatedAt)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: '0.65rem' }}
|
||||
>
|
||||
ID: {revision.revisionId.substring(0, 8)}...
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Tooltip title="Refresh Revisions">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={loadResumeRevisions}
|
||||
disabled={loadingRevisions}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{selectedRevision !== 'current' && (
|
||||
<Tooltip title="Restore this revision to editor">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RestoreFromTrashIcon />}
|
||||
onClick={restoreRevision}
|
||||
disabled={loadingRevision}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{selectedRevision !== 'current' && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
You are viewing a previous version. Click "Restore" to load this
|
||||
content into the editor.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Scrollable
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@ -1030,22 +1198,47 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
}}
|
||||
>
|
||||
{tabValue === 'markdown' && (
|
||||
<BackstoryTextField
|
||||
value={editContent}
|
||||
onChange={(value): void => setEditContent(value)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
minHeight: '100%',
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
placeholder="Enter resume content..."
|
||||
/>
|
||||
<>
|
||||
{selectedRevision === 'current' ? (
|
||||
<BackstoryTextField
|
||||
value={editContent}
|
||||
onChange={(value): void => setEditContent(value)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxHeight: '100%',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
minHeight: '100%',
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
placeholder="Enter resume content..."
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'grey.50',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{loadingRevision ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinearProgress sx={{ flexGrow: 1 }} />
|
||||
<Typography variant="body2">Loading revision...</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<pre style={{ border: 0 }}>{revisionContent}</pre>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tabValue === 'systemPrompt' && (
|
||||
<BackstoryTextField
|
||||
@ -1146,7 +1339,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
// backgroundColor: '#f8f0e0',
|
||||
}}
|
||||
>
|
||||
<Tabs value={jobTabValue} onChange={handleJobTabChange}>
|
||||
|
@ -843,6 +843,450 @@ class ApiClient {
|
||||
return this.handleApiResponseWithConversion<Types.Resume>(response, 'Resume');
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Resume Revision History Methods
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Get list of all revisions for a resume with metadata
|
||||
*/
|
||||
async getResumeRevisions(resumeId: string): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
revisions: Array<{
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
candidateId: string;
|
||||
jobId: string;
|
||||
}>;
|
||||
count: number;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
return handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
revisions: Array<{
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
candidateId: string;
|
||||
jobId: string;
|
||||
}>;
|
||||
count: number;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific revision of a resume by revision ID
|
||||
*/
|
||||
async getResumeRevision(
|
||||
resumeId: string,
|
||||
revisionId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
revisionId: string;
|
||||
revision: Types.Resume;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/${revisionId}`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
revisionId: string;
|
||||
revision: any;
|
||||
}>(response);
|
||||
|
||||
// Convert the revision data with proper date handling
|
||||
return {
|
||||
...result,
|
||||
revision: convertFromApi<Types.Resume>(result.revision, 'Resume'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a resume to a specific revision
|
||||
*/
|
||||
async restoreResumeRevision(
|
||||
resumeId: string,
|
||||
revisionId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
restoredFrom: string;
|
||||
resume: Types.Resume;
|
||||
message: string;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/restore/${revisionId}`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
restoredFrom: string;
|
||||
resume: any;
|
||||
message: string;
|
||||
}>(response);
|
||||
|
||||
// Convert the restored resume data with proper date handling
|
||||
return {
|
||||
...result,
|
||||
resume: convertFromApi<Types.Resume>(result.resume, 'Resume'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific revision from history
|
||||
*/
|
||||
async deleteResumeRevision(
|
||||
resumeId: string,
|
||||
revisionId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
revisionId: string;
|
||||
message: string;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/${revisionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
return handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
revisionId: string;
|
||||
message: string;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two revisions of a resume
|
||||
*/
|
||||
async compareResumeRevisions(
|
||||
resumeId: string,
|
||||
revisionId1: string,
|
||||
revisionId2: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
comparison: {
|
||||
revision1: {
|
||||
revisionId: string;
|
||||
data: Types.Resume;
|
||||
};
|
||||
revision2: {
|
||||
revisionId: string;
|
||||
data: Types.Resume;
|
||||
};
|
||||
};
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/resumes/${resumeId}/revisions/compare/${revisionId1}/${revisionId2}`,
|
||||
{
|
||||
headers: this.defaultHeaders,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
comparison: {
|
||||
revision1: {
|
||||
revisionId: string;
|
||||
data: any;
|
||||
};
|
||||
revision2: {
|
||||
revisionId: string;
|
||||
data: any;
|
||||
};
|
||||
};
|
||||
}>(response);
|
||||
|
||||
// Convert both revision data with proper date handling
|
||||
return {
|
||||
...result,
|
||||
comparison: {
|
||||
revision1: {
|
||||
...result.comparison.revision1,
|
||||
data: convertFromApi<Types.Resume>(result.comparison.revision1.data, 'Resume'),
|
||||
},
|
||||
revision2: {
|
||||
...result.comparison.revision2,
|
||||
data: convertFromApi<Types.Resume>(result.comparison.revision2.data, 'Resume'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk operations for resume revisions
|
||||
*/
|
||||
async bulkDeleteResumeRevisions(
|
||||
resumeId: string,
|
||||
revisionIds: string[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
deletedCount: number;
|
||||
failedDeletions: string[];
|
||||
message: string;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/bulk-delete`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(formatApiRequest({ revisionIds: revisionIds })),
|
||||
});
|
||||
|
||||
return handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
deletedCount: number;
|
||||
failedDeletions: string[];
|
||||
message: string;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get revision differences between two revision IDs
|
||||
*/
|
||||
async getResumeRevisionDiff(
|
||||
resumeId: string,
|
||||
fromRevisionId: string,
|
||||
toRevisionId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
fromRevisionId: string;
|
||||
toRevisionId: string;
|
||||
differences: {
|
||||
field: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
changeType: 'added' | 'removed' | 'modified';
|
||||
}[];
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/resumes/${resumeId}/revisions/diff/${fromRevisionId}/${toRevisionId}`,
|
||||
{
|
||||
headers: this.defaultHeaders,
|
||||
}
|
||||
);
|
||||
|
||||
return handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
fromRevisionId: string;
|
||||
toRevisionId: string;
|
||||
differences: {
|
||||
field: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
changeType: 'added' | 'removed' | 'modified';
|
||||
}[];
|
||||
}>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search through resume revision history
|
||||
*/
|
||||
async searchResumeRevisions(
|
||||
resumeId: string,
|
||||
query: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
query: string;
|
||||
matchingRevisions: Array<{
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
matchedFields: string[];
|
||||
preview: string;
|
||||
}>;
|
||||
count: number;
|
||||
}> {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/search?${params}`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
return handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
query: string;
|
||||
matchingRevisions: Array<{
|
||||
revisionId: string;
|
||||
revisionTimestamp: string;
|
||||
matchedFields: string[];
|
||||
preview: string;
|
||||
}>;
|
||||
count: number;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all historical versions of a resume with full data
|
||||
*/
|
||||
async getResumeHistory(resumeId: string): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
history: Types.Resume[];
|
||||
count: number;
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/history`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
history: any[];
|
||||
count: number;
|
||||
}>(response);
|
||||
|
||||
// Convert all history items with proper date handling
|
||||
return {
|
||||
...result,
|
||||
history: convertArrayFromApi<Types.Resume>(result.history, 'Resume'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume revision statistics and metadata
|
||||
*/
|
||||
async getResumeRevisionStatistics(resumeId: string): Promise<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
statistics: {
|
||||
totalRevisions: number;
|
||||
oldestRevision: string;
|
||||
newestRevision: string;
|
||||
averageUpdateFrequency: string;
|
||||
majorChanges: number;
|
||||
minorChanges: number;
|
||||
};
|
||||
}> {
|
||||
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}/revisions/statistics`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
|
||||
return handleApiResponse<{
|
||||
success: boolean;
|
||||
resumeId: string;
|
||||
statistics: {
|
||||
totalRevisions: number;
|
||||
oldestRevision: string;
|
||||
newestRevision: string;
|
||||
averageUpdateFrequency: string;
|
||||
majorChanges: number;
|
||||
minorChanges: number;
|
||||
};
|
||||
}>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export resume revision history as JSON or other formats
|
||||
*/
|
||||
async exportResumeHistory(resumeId: string, format: 'json' | 'csv' = 'json'): Promise<Blob> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/resumes/${resumeId}/history/export?format=${format}`,
|
||||
{
|
||||
headers: this.defaultHeaders,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Resume Revision Utility Methods
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Helper method to format revision timestamp for display
|
||||
*/
|
||||
formatRevisionTimestamp(timestamp: string): string {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to calculate time between revisions
|
||||
*/
|
||||
calculateTimeBetweenRevisions(timestamp1: string, timestamp2: string): string {
|
||||
try {
|
||||
const date1 = new Date(timestamp1);
|
||||
const date2 = new Date(timestamp2);
|
||||
const diffMs = Math.abs(date2.getTime() - date1.getTime());
|
||||
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d ${diffHours}h`;
|
||||
if (diffHours > 0) return `${diffHours}h ${diffMinutes}m`;
|
||||
return `${diffMinutes}m`;
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error handling with revision-specific errors
|
||||
*/
|
||||
private async handleResumeRevisionResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
// Handle specific revision errors
|
||||
switch (response.status) {
|
||||
case 404:
|
||||
if (errorText.includes('revision')) {
|
||||
throw new Error(
|
||||
'Revision not found - it may have been deleted or the timestamp is invalid'
|
||||
);
|
||||
}
|
||||
throw new Error('Resume not found');
|
||||
case 409:
|
||||
throw new Error(
|
||||
'Revision conflict - the resume may have been updated since this revision'
|
||||
);
|
||||
case 422:
|
||||
throw new Error('Invalid revision data - unable to process the revision');
|
||||
default:
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
return handleApiResponse<T>(response);
|
||||
}
|
||||
|
||||
async getJob(id: string): Promise<Types.Job> {
|
||||
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
|
||||
headers: this.defaultHeaders,
|
||||
|
@ -11,5 +11,7 @@ KEY_PREFIXES = {
|
||||
"candidate_documents": "candidate_documents:",
|
||||
"job_requirements": "job_requirements:",
|
||||
"resumes": "resume:",
|
||||
"resume_history": "resume_history:",
|
||||
"resume_revisions": "resume_revisions:",
|
||||
"user_resumes": "user_resumes:",
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from models import Resume
|
||||
@ -95,6 +96,9 @@ class ResumeMixin(DatabaseProtocol):
|
||||
user_resumes_key = f"{KEY_PREFIXES['user_resumes']}{user_id}"
|
||||
await self.redis.lrem(user_resumes_key, 0, resume_id) # type: ignore
|
||||
|
||||
# Clean up revision history
|
||||
await self._delete_resume_history(user_id, resume_id)
|
||||
|
||||
if result > 0:
|
||||
logger.info(f"🗑️ Deleted resume {resume_id} for user {user_id}")
|
||||
return True
|
||||
@ -121,11 +125,14 @@ class ResumeMixin(DatabaseProtocol):
|
||||
# Use pipeline for efficient batch operations
|
||||
pipe = self.redis.pipeline()
|
||||
|
||||
# Delete each resume
|
||||
# Delete each resume and its history
|
||||
for resume_id in resume_ids:
|
||||
pipe.delete(f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}")
|
||||
deleted_count += 1
|
||||
|
||||
# Delete revision history for this resume
|
||||
await self._delete_resume_history(user_id, resume_id)
|
||||
|
||||
# Delete the user's resume list
|
||||
pipe.delete(user_resumes_key)
|
||||
|
||||
@ -273,20 +280,218 @@ class ResumeMixin(DatabaseProtocol):
|
||||
}
|
||||
|
||||
async def update_resume(self, user_id: str, resume_id: str, updates: Dict) -> Optional[Resume]:
|
||||
"""Update specific fields of a resume"""
|
||||
"""Update specific fields of a resume and save the previous version to history"""
|
||||
try:
|
||||
resume_data = await self.get_resume(user_id, resume_id)
|
||||
if resume_data:
|
||||
resume_dict = resume_data.model_dump()
|
||||
resume_dict.update(updates)
|
||||
resume_dict["updated_at"] = datetime.now(UTC).isoformat()
|
||||
# Get current resume data
|
||||
current_resume = await self.get_resume(user_id, resume_id)
|
||||
if not current_resume:
|
||||
logger.warning(f"📄 Resume {resume_id} not found for user {user_id}")
|
||||
return None
|
||||
|
||||
key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||
await self.redis.set(key, self._serialize(resume_dict))
|
||||
# Save current version to history before updating
|
||||
await self._save_resume_revision(user_id, resume_id, current_resume.model_dump())
|
||||
|
||||
logger.info(f"📄 Updated resume {resume_id} for user {user_id}")
|
||||
return Resume.model_validate(resume_dict)
|
||||
return None
|
||||
# Update the resume
|
||||
resume_dict = current_resume.model_dump()
|
||||
resume_dict.update(updates)
|
||||
resume_dict["updated_at"] = datetime.now(UTC).isoformat()
|
||||
|
||||
# Save updated resume
|
||||
key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||
await self.redis.set(key, self._serialize(resume_dict))
|
||||
|
||||
logger.info(f"📄 Updated resume {resume_id} for user {user_id}")
|
||||
return Resume.model_validate(resume_dict)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating resume {resume_id} for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_resume_revisions(self, user_id: str, resume_id: str) -> List[Dict[str, str]]:
|
||||
"""Get list of all revisions for a resume with their revision IDs and metadata"""
|
||||
try:
|
||||
revisions_key = f"{KEY_PREFIXES['resume_revisions']}{user_id}:{resume_id}"
|
||||
revision_ids = await self.redis.lrange(revisions_key, 0, -1) # type: ignore
|
||||
|
||||
if not revision_ids:
|
||||
logger.info(f"📄 No revisions found for resume {resume_id} by user {user_id}")
|
||||
return []
|
||||
|
||||
# Get metadata for each revision
|
||||
revisions = []
|
||||
pipe = self.redis.pipeline()
|
||||
|
||||
for revision_id in revision_ids:
|
||||
history_key = f"{KEY_PREFIXES['resume_history']}{user_id}:{resume_id}:{revision_id}"
|
||||
pipe.get(history_key)
|
||||
|
||||
values = await pipe.execute()
|
||||
|
||||
for revision_id, value in zip(revision_ids, values):
|
||||
if value:
|
||||
revision_data = self._deserialize(value)
|
||||
if revision_data:
|
||||
revisions.append(
|
||||
{
|
||||
"revision_id": revision_id,
|
||||
"revision_timestamp": revision_data.get("revision_timestamp", ""),
|
||||
"updated_at": revision_data.get("updated_at", ""),
|
||||
"created_at": revision_data.get("created_at", ""),
|
||||
"candidate_id": revision_data.get("candidate_id", ""),
|
||||
"job_id": revision_data.get("job_id", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by revision_timestamp (most recent first)
|
||||
revisions.sort(key=lambda x: x["revision_timestamp"], reverse=True)
|
||||
|
||||
logger.info(f"📄 Found {len(revisions)} revisions for resume {resume_id} by user {user_id}")
|
||||
return revisions
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving revisions for resume {resume_id} by user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
async def get_resume_revision(self, user_id: str, resume_id: str, revision_id: str) -> Optional[Resume]:
|
||||
"""Get a specific revision of a resume by revision ID"""
|
||||
try:
|
||||
history_key = f"{KEY_PREFIXES['resume_history']}{user_id}:{resume_id}:{revision_id}"
|
||||
data = await self.redis.get(history_key)
|
||||
|
||||
if data:
|
||||
revision_data = self._deserialize(data)
|
||||
logger.info(f"📄 Retrieved revision {revision_id} for resume {resume_id} by user {user_id}")
|
||||
return Resume.model_validate(revision_data)
|
||||
|
||||
logger.warning(f"📄 Revision {revision_id} not found for resume {resume_id} by user {user_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving revision {revision_id} for resume {resume_id} by user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_resume_history(self, user_id: str, resume_id: str) -> List[Resume]:
|
||||
"""Get all historical versions of a resume (full data)"""
|
||||
try:
|
||||
revisions_key = f"{KEY_PREFIXES['resume_revisions']}{user_id}:{resume_id}"
|
||||
revision_ids = await self.redis.lrange(revisions_key, 0, -1) # type: ignore
|
||||
|
||||
if not revision_ids:
|
||||
logger.info(f"📄 No history found for resume {resume_id} by user {user_id}")
|
||||
return []
|
||||
|
||||
# Get all revision data
|
||||
history = []
|
||||
pipe = self.redis.pipeline()
|
||||
|
||||
for revision_id in revision_ids:
|
||||
history_key = f"{KEY_PREFIXES['resume_history']}{user_id}:{resume_id}:{revision_id}"
|
||||
pipe.get(history_key)
|
||||
|
||||
values = await pipe.execute()
|
||||
|
||||
for revision_id, value in zip(revision_ids, values):
|
||||
if value:
|
||||
revision_data = self._deserialize(value)
|
||||
if revision_data:
|
||||
history.append(Resume.model_validate(revision_data))
|
||||
|
||||
# Sort by revision_timestamp or updated_at (most recent first)
|
||||
history.sort(key=lambda x: x.updated_at or x.created_at, reverse=True)
|
||||
|
||||
logger.info(f"📄 Retrieved {len(history)} historical versions for resume {resume_id} by user {user_id}")
|
||||
return history
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving history for resume {resume_id} by user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
async def restore_resume_revision(self, user_id: str, resume_id: str, revision_id: str) -> Optional[Resume]:
|
||||
"""Restore a resume to a specific revision"""
|
||||
try:
|
||||
# Get the revision data
|
||||
revision = await self.get_resume_revision(user_id, resume_id, revision_id)
|
||||
if not revision:
|
||||
logger.warning(f"📄 Revision {revision_id} not found for resume {resume_id} by user {user_id}")
|
||||
return None
|
||||
|
||||
# Save current version to history before restoring
|
||||
current_resume = await self.get_resume(user_id, resume_id)
|
||||
if current_resume:
|
||||
await self._save_resume_revision(user_id, resume_id, current_resume.model_dump())
|
||||
|
||||
# Update the resume with revision data
|
||||
revision_dict = revision.model_dump()
|
||||
revision_dict["updated_at"] = datetime.now(UTC).isoformat()
|
||||
revision_dict["restored_from"] = revision_id
|
||||
|
||||
# Save restored resume
|
||||
key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
|
||||
await self.redis.set(key, self._serialize(revision_dict))
|
||||
|
||||
logger.info(f"📄 Restored resume {resume_id} to revision {revision_id} for user {user_id}")
|
||||
return Resume.model_validate(revision_dict)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error restoring resume {resume_id} to revision {revision_id} for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_resume_revision(self, user_id: str, resume_id: str, revision_id: str) -> bool:
|
||||
"""Delete a specific revision from history"""
|
||||
try:
|
||||
# Delete the revision data
|
||||
history_key = f"{KEY_PREFIXES['resume_history']}{user_id}:{resume_id}:{revision_id}"
|
||||
result = await self.redis.delete(history_key)
|
||||
|
||||
# Remove revision ID from revisions list
|
||||
revisions_key = f"{KEY_PREFIXES['resume_revisions']}{user_id}:{resume_id}"
|
||||
await self.redis.lrem(revisions_key, 0, revision_id) # type: ignore
|
||||
|
||||
if result > 0:
|
||||
logger.info(f"🗑️ Deleted revision {revision_id} for resume {resume_id} by user {user_id}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ Revision {revision_id} not found for resume {resume_id} by user {user_id}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error deleting revision {revision_id} for resume {resume_id} by user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
# Private helper methods for revision management
|
||||
|
||||
async def _save_resume_revision(self, user_id: str, resume_id: str, resume_data: Dict) -> str:
|
||||
"""Save a resume revision to history"""
|
||||
revision_id = str(uuid.uuid4())
|
||||
revision_timestamp = datetime.now(UTC).isoformat()
|
||||
|
||||
# Add revision metadata to the data
|
||||
revision_data = resume_data.copy()
|
||||
revision_data["revision_timestamp"] = revision_timestamp
|
||||
revision_data["revision_id"] = revision_id
|
||||
|
||||
# Store the revision
|
||||
history_key = f"{KEY_PREFIXES['resume_history']}{user_id}:{resume_id}:{revision_id}"
|
||||
await self.redis.set(history_key, self._serialize(revision_data))
|
||||
|
||||
# Add revision ID to revisions list
|
||||
revisions_key = f"{KEY_PREFIXES['resume_revisions']}{user_id}:{resume_id}"
|
||||
await self.redis.rpush(revisions_key, revision_id) # type: ignore
|
||||
|
||||
logger.info(f"📄 Saved revision {revision_id} for resume {resume_id} by user {user_id}")
|
||||
return revision_id
|
||||
|
||||
async def _delete_resume_history(self, user_id: str, resume_id: str) -> None:
|
||||
"""Delete all revision history for a resume"""
|
||||
try:
|
||||
# Get all revision IDs
|
||||
revisions_key = f"{KEY_PREFIXES['resume_revisions']}{user_id}:{resume_id}"
|
||||
revision_ids = await self.redis.lrange(revisions_key, 0, -1) # type: ignore
|
||||
|
||||
# Delete all revision data
|
||||
pipe = self.redis.pipeline()
|
||||
for revision_id in revision_ids:
|
||||
history_key = f"{KEY_PREFIXES['resume_history']}{user_id}:{resume_id}:{revision_id}"
|
||||
pipe.delete(history_key)
|
||||
|
||||
# Delete the revisions list
|
||||
pipe.delete(revisions_key)
|
||||
await pipe.execute()
|
||||
|
||||
logger.info(f"🗑️ Deleted all revision history for resume {resume_id} by user {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error deleting revision history for resume {resume_id} by user {user_id}: {e}")
|
@ -260,3 +260,158 @@ async def update_resume(
|
||||
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")
|
||||
|
||||
|
||||
#
|
||||
# REVISION HISTORY ENDPOINTS
|
||||
#
|
||||
|
||||
|
||||
@router.get("/{resume_id}/revisions")
|
||||
async def get_resume_revisions(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
current_user=Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get list of all revisions for a resume with metadata"""
|
||||
try:
|
||||
revisions = await database.get_resume_revisions(current_user.id, resume_id)
|
||||
return create_success_response({"resume_id": resume_id, "revisions": revisions, "count": len(revisions)})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving revisions for resume {resume_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve resume revisions")
|
||||
|
||||
|
||||
@router.get("/{resume_id}/revisions/{revision_id}")
|
||||
async def get_resume_revision(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id: str = Path(..., description="Revision ID of the revision"),
|
||||
current_user=Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get a specific revision of a resume by revision ID"""
|
||||
try:
|
||||
revision = await database.get_resume_revision(current_user.id, resume_id, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
return create_success_response(
|
||||
{"resume_id": resume_id, "revision_id": revision_id, "revision": revision.model_dump(by_alias=True)}
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving revision {revision_id} for resume {resume_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve resume revision")
|
||||
|
||||
|
||||
@router.get("/{resume_id}/history")
|
||||
async def get_resume_history(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
current_user=Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Get all historical versions of a resume with full data"""
|
||||
try:
|
||||
history = await database.get_resume_history(current_user.id, resume_id)
|
||||
return create_success_response(
|
||||
{
|
||||
"resume_id": resume_id,
|
||||
"history": [resume.model_dump(by_alias=True) for resume in history],
|
||||
"count": len(history),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error retrieving history for resume {resume_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve resume history")
|
||||
|
||||
|
||||
@router.post("/{resume_id}/restore/{revision_id}")
|
||||
async def restore_resume_revision(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id: str = Path(..., description="Revision ID of the revision to restore"),
|
||||
current_user=Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Restore a resume to a specific revision"""
|
||||
try:
|
||||
restored_resume = await database.restore_resume_revision(current_user.id, resume_id, revision_id)
|
||||
if not restored_resume:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
logger.info(f"📄 User {current_user.id} restored resume {resume_id} to revision {revision_id}")
|
||||
return create_success_response(
|
||||
{
|
||||
"resume_id": resume_id,
|
||||
"restored_from": revision_id,
|
||||
"resume": restored_resume.model_dump(by_alias=True),
|
||||
"message": f"Resume restored to revision {revision_id}",
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error restoring resume {resume_id} to revision {revision_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to restore resume revision")
|
||||
|
||||
|
||||
@router.delete("/{resume_id}/revisions/{revision_id}")
|
||||
async def delete_resume_revision(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id: str = Path(..., description="Revision ID of the revision to delete"),
|
||||
current_user=Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Delete a specific revision from history"""
|
||||
try:
|
||||
success = await database.delete_resume_revision(current_user.id, resume_id, revision_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Revision not found")
|
||||
|
||||
logger.info(f"🗑️ User {current_user.id} deleted revision {revision_id} for resume {resume_id}")
|
||||
return create_success_response(
|
||||
{
|
||||
"resume_id": resume_id,
|
||||
"revision_id": revision_id,
|
||||
"message": f"Revision {revision_id} deleted successfully",
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error deleting revision {revision_id} for resume {resume_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to delete resume revision")
|
||||
|
||||
|
||||
@router.get("/{resume_id}/revisions/compare/{revision_id1}/{revision_id2}")
|
||||
async def compare_resume_revisions(
|
||||
resume_id: str = Path(..., description="ID of the resume"),
|
||||
revision_id1: str = Path(..., description="First revision ID"),
|
||||
revision_id2: str = Path(..., description="Second revision ID"),
|
||||
current_user=Depends(get_current_user),
|
||||
database: RedisDatabase = Depends(get_database),
|
||||
):
|
||||
"""Compare two revisions of a resume"""
|
||||
try:
|
||||
revision1 = await database.get_resume_revision(current_user.id, resume_id, revision_id1)
|
||||
revision2 = await database.get_resume_revision(current_user.id, resume_id, revision_id2)
|
||||
|
||||
if not revision1:
|
||||
raise HTTPException(status_code=404, detail=f"Revision {revision_id1} not found")
|
||||
if not revision2:
|
||||
raise HTTPException(status_code=404, detail=f"Revision {revision_id2} not found")
|
||||
|
||||
return create_success_response(
|
||||
{
|
||||
"resume_id": resume_id,
|
||||
"comparison": {
|
||||
"revision1": {"revision_id": revision_id1, "data": revision1.model_dump(by_alias=True)},
|
||||
"revision2": {"revision_id": revision_id2, "data": revision2.model_dump(by_alias=True)},
|
||||
},
|
||||
}
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error comparing revisions {revision_id1} and {revision_id2} for resume {resume_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to compare resume revisions")
|
Loading…
x
Reference in New Issue
Block a user