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,
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 &quot;Restore&quot; 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}>

View File

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

View File

@ -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:",
}

View File

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

View File

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