Revision control

This commit is contained in:
James Ketr 2025-07-15 12:03:07 -07:00
parent b32f0948c4
commit 621bf46a39
5 changed files with 1049 additions and 51 deletions

View File

@ -25,6 +25,10 @@ import {
MenuItem, MenuItem,
InputLabel, InputLabel,
Theme, Theme,
Chip,
Alert,
Stack,
SelectChangeEvent,
} from '@mui/material'; } from '@mui/material';
import PrintIcon from '@mui/icons-material/Print'; import PrintIcon from '@mui/icons-material/Print';
import { import {
@ -40,6 +44,9 @@ import {
Email as EmailIcon, Email as EmailIcon,
Phone as PhoneIcon, Phone as PhoneIcon,
LocationOn as LocationIcon, LocationOn as LocationIcon,
History as HistoryIcon,
RestoreFromTrash as RestoreFromTrashIcon,
Refresh as RefreshIcon,
// Language as WebsiteIcon, // Language as WebsiteIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import InputIcon from '@mui/icons-material/Input'; import InputIcon from '@mui/icons-material/Input';
@ -71,6 +78,16 @@ interface ResumeInfoProps {
variant?: 'minimal' | 'small' | 'normal' | 'all' | null; 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 // Resume Style Definitions
interface ResumeStyle { interface ResumeStyle {
name: string; name: string;
@ -384,7 +401,6 @@ const StyledFooter: React.FC<BackstoryStyledResumeProps> = ({ candidate, job, st
fontSize: '0.8rem', fontSize: '0.8rem',
pb: 2, pb: 2,
mb: 2, mb: 2,
// pt: 2,
color: style.color.secondary, color: style.color.secondary,
}} }}
> >
@ -515,26 +531,6 @@ const StyledHeader: React.FC<BackstoryStyledResumeProps> = ({ candidate, style }
</Box> </Box>
)} )}
</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> </Box>
</Box> </Box>
@ -563,6 +559,13 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [error, setError] = useState<Types.ChatMessageError | null>(null); const [error, setError] = useState<Types.ChatMessageError | null>(null);
const [selectedStyle, setSelectedStyle] = useState<string>('corporate'); 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 printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ const reactToPrintFn = useReactToPrint({
contentRef: printContentRef, contentRef: printContentRef,
@ -575,11 +578,86 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
} }
}, [resume, activeResume]); }, [resume, activeResume]);
// Load revisions when dialog opens
useEffect(() => {
if (editDialogOpen && activeResume.id) {
loadResumeRevisions();
}
}, [editDialogOpen, activeResume.id]);
const currentStyle = useMemo(() => { const currentStyle = useMemo(() => {
return resumeStyles[selectedStyle]; return resumeStyles[selectedStyle];
}, [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> => { const deleteResume = async (id: string | undefined): Promise<void> => {
if (id) { if (id) {
try { try {
@ -613,6 +691,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}; };
setActiveResume(updatedResume); setActiveResume(updatedResume);
setSnack('Resume updated successfully.'); setSnack('Resume updated successfully.');
// Reload revisions to include the new version
await loadResumeRevisions();
} catch (error) { } catch (error) {
setSnack('Failed to update resume.'); setSnack('Failed to update resume.');
} finally { } finally {
@ -624,6 +704,8 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
setEditContent(activeResume.resume); setEditContent(activeResume.resume);
setEditSystemPrompt(activeResume.systemPrompt || ''); setEditSystemPrompt(activeResume.systemPrompt || '');
setEditPrompt(activeResume.prompt || ''); setEditPrompt(activeResume.prompt || '');
setSelectedRevision('current');
setRevisionContent(activeResume.resume);
setEditDialogOpen(true); setEditDialogOpen(true);
}; };
@ -1016,6 +1098,92 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
{status && !error && <LinearProgress sx={{ mt: 1 }} />} {status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box> </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 &quot;Restore&quot; to load this
content into the editor.
</Alert>
)}
</Box>
)}
<Scrollable <Scrollable
sx={{ sx={{
display: 'flex', display: 'flex',
@ -1030,22 +1198,47 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}} }}
> >
{tabValue === 'markdown' && ( {tabValue === 'markdown' && (
<BackstoryTextField <>
value={editContent} {selectedRevision === 'current' ? (
onChange={(value): void => setEditContent(value)} <BackstoryTextField
style={{ value={editContent}
position: 'relative', onChange={(value): void => setEditContent(value)}
maxHeight: '100%', style={{
height: '100%', position: 'relative',
width: '100%', maxHeight: '100%',
display: 'flex', height: '100%',
minHeight: '100%', width: '100%',
flexGrow: 1, display: 'flex',
flex: 1, minHeight: '100%',
overflowY: 'auto', flexGrow: 1,
}} flex: 1,
placeholder="Enter resume content..." 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' && ( {tabValue === 'systemPrompt' && (
<BackstoryTextField <BackstoryTextField
@ -1146,7 +1339,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative', position: 'relative',
// backgroundColor: '#f8f0e0',
}} }}
> >
<Tabs value={jobTabValue} onChange={handleJobTabChange}> <Tabs value={jobTabValue} onChange={handleJobTabChange}>

View File

@ -843,6 +843,450 @@ class ApiClient {
return this.handleApiResponseWithConversion<Types.Resume>(response, 'Resume'); 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> { async getJob(id: string): Promise<Types.Job> {
const response = await fetch(`${this.baseUrl}/jobs/${id}`, { const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
headers: this.defaultHeaders, headers: this.defaultHeaders,

View File

@ -11,5 +11,7 @@ KEY_PREFIXES = {
"candidate_documents": "candidate_documents:", "candidate_documents": "candidate_documents:",
"job_requirements": "job_requirements:", "job_requirements": "job_requirements:",
"resumes": "resume:", "resumes": "resume:",
"resume_history": "resume_history:",
"resume_revisions": "resume_revisions:",
"user_resumes": "user_resumes:", "user_resumes": "user_resumes:",
} }

View File

@ -1,5 +1,6 @@
from datetime import UTC, datetime from datetime import UTC, datetime
import logging import logging
import uuid
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from models import Resume from models import Resume
@ -95,6 +96,9 @@ class ResumeMixin(DatabaseProtocol):
user_resumes_key = f"{KEY_PREFIXES['user_resumes']}{user_id}" user_resumes_key = f"{KEY_PREFIXES['user_resumes']}{user_id}"
await self.redis.lrem(user_resumes_key, 0, resume_id) # type: ignore 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: if result > 0:
logger.info(f"🗑️ Deleted resume {resume_id} for user {user_id}") logger.info(f"🗑️ Deleted resume {resume_id} for user {user_id}")
return True return True
@ -121,11 +125,14 @@ class ResumeMixin(DatabaseProtocol):
# Use pipeline for efficient batch operations # Use pipeline for efficient batch operations
pipe = self.redis.pipeline() pipe = self.redis.pipeline()
# Delete each resume # Delete each resume and its history
for resume_id in resume_ids: for resume_id in resume_ids:
pipe.delete(f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}") pipe.delete(f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}")
deleted_count += 1 deleted_count += 1
# Delete revision history for this resume
await self._delete_resume_history(user_id, resume_id)
# Delete the user's resume list # Delete the user's resume list
pipe.delete(user_resumes_key) 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]: 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: try:
resume_data = await self.get_resume(user_id, resume_id) # Get current resume data
if resume_data: current_resume = await self.get_resume(user_id, resume_id)
resume_dict = resume_data.model_dump() if not current_resume:
resume_dict.update(updates) logger.warning(f"📄 Resume {resume_id} not found for user {user_id}")
resume_dict["updated_at"] = datetime.now(UTC).isoformat() return None
key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}" # Save current version to history before updating
await self.redis.set(key, self._serialize(resume_dict)) await self._save_resume_revision(user_id, resume_id, current_resume.model_dump())
logger.info(f"📄 Updated resume {resume_id} for user {user_id}") # Update the resume
return Resume.model_validate(resume_dict) resume_dict = current_resume.model_dump()
return None 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: except Exception as e:
logger.error(f"❌ Error updating resume {resume_id} for user {user_id}: {e}") logger.error(f"❌ Error updating resume {resume_id} for user {user_id}: {e}")
return None 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}")

View File

@ -260,3 +260,158 @@ async def update_resume(
except Exception as e: except Exception as e:
logger.error(f"❌ Error updating resume {resume.id} for user {current_user.id}: {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") 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")