From 621bf46a3970f5167d41959a00fe5d3045c45905 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Tue, 15 Jul 2025 12:03:07 -0700 Subject: [PATCH] Revision control --- frontend/src/components/ui/ResumeInfo.tsx | 270 +++++++++++-- frontend/src/services/api-client.ts | 444 ++++++++++++++++++++++ src/backend/database/constants.py | 2 + src/backend/database/mixins/resume.py | 229 ++++++++++- src/backend/routes/resumes.py | 155 ++++++++ 5 files changed, 1049 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx index 22b06a9..c00cbef 100644 --- a/frontend/src/components/ui/ResumeInfo.tsx +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -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 = ({ candidate, job, st fontSize: '0.8rem', pb: 2, mb: 2, - // pt: 2, color: style.color.secondary, }} > @@ -515,26 +531,6 @@ const StyledHeader: React.FC = ({ candidate, style } )} - - {/* {(candidate.website || candidate.linkedin) && ( - - - - - {candidate.website || candidate.linkedin} - - - - )} */} @@ -563,6 +559,13 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const [error, setError] = useState(null); const [selectedStyle, setSelectedStyle] = useState('corporate'); + // New revision-related state + const [revisions, setRevisions] = useState([]); + const [selectedRevision, setSelectedRevision] = useState('current'); + const [loadingRevisions, setLoadingRevisions] = useState(false); + const [loadingRevision, setLoadingRevision] = useState(false); + const [revisionContent, setRevisionContent] = useState(''); + const printContentRef = useRef(null); const reactToPrintFn = useReactToPrint({ contentRef: printContentRef, @@ -575,11 +578,86 @@ const ResumeInfo: React.FC = (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 => { + 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 => { + 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): Promise => { + 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 => { if (id) { try { @@ -613,6 +691,8 @@ const ResumeInfo: React.FC = (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 = (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 = (props: ResumeInfoProps) => { {status && !error && } )} + + {/* Revision History Dropdown for Markdown Tab */} + {tabValue === 'markdown' && ( + + + + + + + Version History + + + + + + + + + + + + {selectedRevision !== 'current' && ( + + + + )} + + + {selectedRevision !== 'current' && ( + + You are viewing a previous version. Click "Restore" to load this + content into the editor. + + )} + + )} + = (props: ResumeInfoProps) => { }} > {tabValue === 'markdown' && ( - 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' ? ( + 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..." + /> + ) : ( + + {loadingRevision ? ( + + + Loading revision... + + ) : ( +
{revisionContent}
+ )} +
+ )} + )} {tabValue === 'systemPrompt' && ( = (props: ResumeInfoProps) => { display: 'flex', flexDirection: 'column', position: 'relative', - // backgroundColor: '#f8f0e0', }} > diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index bff2aa2..6ce6fb8 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -843,6 +843,450 @@ class ApiClient { return this.handleApiResponseWithConversion(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(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(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(result.comparison.revision1.data, 'Resume'), + }, + revision2: { + ...result.comparison.revision2, + data: convertFromApi(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(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 { + 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(response: Response): Promise { + 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(response); + } + async getJob(id: string): Promise { const response = await fetch(`${this.baseUrl}/jobs/${id}`, { headers: this.defaultHeaders, diff --git a/src/backend/database/constants.py b/src/backend/database/constants.py index d2eae49..0e79b1e 100644 --- a/src/backend/database/constants.py +++ b/src/backend/database/constants.py @@ -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:", } diff --git a/src/backend/database/mixins/resume.py b/src/backend/database/mixins/resume.py index ed9647e..a7774d3 100644 --- a/src/backend/database/mixins/resume.py +++ b/src/backend/database/mixins/resume.py @@ -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}") \ No newline at end of file diff --git a/src/backend/routes/resumes.py b/src/backend/routes/resumes.py index b32cc6e..1fd307f 100644 --- a/src/backend/routes/resumes.py +++ b/src/backend/routes/resumes.py @@ -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") \ No newline at end of file