From 5750577eafcc15ea2fd9a62d6a387638449ddec1 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Thu, 12 Jun 2025 16:27:08 -0700 Subject: [PATCH] Add printing and lots of fixes --- frontend/package-lock.json | 9 ++ frontend/package.json | 1 + frontend/src/components/ui/JobInfo.tsx | 6 +- frontend/src/components/ui/JobViewer.tsx | 127 +++++++------------- frontend/src/components/ui/ResumeInfo.tsx | 77 ++++++++++-- frontend/src/components/ui/ResumeViewer.tsx | 4 +- frontend/src/config/navigationConfig.tsx | 89 +++++++------- frontend/src/pages/LoginPage.tsx | 21 ++-- frontend/src/services/api-client.ts | 4 +- src/backend/agents/generate_resume.py | 8 +- src/backend/database.py | 90 +++++++------- src/backend/email_service.py | 2 +- src/backend/main.py | 110 ++++++----------- users-prod/.keep | 0 14 files changed, 278 insertions(+), 270 deletions(-) delete mode 100644 users-prod/.keep diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 013894f..8d21b6f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -45,6 +45,7 @@ "react-router-dom": "^7.6.0", "react-scripts": "5.0.1", "react-spinners": "^0.15.0", + "react-to-print": "^3.1.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", @@ -20206,6 +20207,14 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-to-print": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.1.0.tgz", + "integrity": "sha512-hiJZVmJtaRm9EHoUTG2bordyeRxVSGy9oFVV7fSvzOWwctPp6jbz2R6NFkaokaTYBxC7wTM/fMV5eCXsNpEwsA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f7aea4..fe9c4dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "react-router-dom": "^7.6.0", "react-scripts": "5.0.1", "react-spinners": "^0.15.0", + "react-to-print": "^3.1.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", diff --git a/frontend/src/components/ui/JobInfo.tsx b/frontend/src/components/ui/JobInfo.tsx index f97387b..deeebe0 100644 --- a/frontend/src/components/ui/JobInfo.tsx +++ b/frontend/src/components/ui/JobInfo.tsx @@ -314,11 +314,10 @@ const JobInfo: React.FC = (props: JobInfoProps) => { } Job ID: {job.id} } - {variant === 'all' && } + {variant === 'all' && } - {(variant !== 'small' && variant !== 'minimal') && <>{renderJobRequirements()}} + {(variant !== 'small' && variant !== 'minimal') && {renderJobRequirements()}} - {isAdmin && @@ -371,6 +370,7 @@ const JobInfo: React.FC = (props: JobInfoProps) => { } + ); }; diff --git a/frontend/src/components/ui/JobViewer.tsx b/frontend/src/components/ui/JobViewer.tsx index c0c54de..3857b7c 100644 --- a/frontend/src/components/ui/JobViewer.tsx +++ b/frontend/src/components/ui/JobViewer.tsx @@ -83,6 +83,7 @@ const JobViewer: React.FC = ({ onSelect }) => { if (job) { setSelectedJob(job); onSelect?.(job); + setMobileDialogOpen(true); return; } } @@ -147,11 +148,8 @@ const JobViewer: React.FC = ({ onSelect }) => { const handleJobSelect = (job: Job) => { setSelectedJob(job); onSelect?.(job); - if (isMobile) { - setMobileDialogOpen(true); - } else { - navigate(`/candidate/jobs/${job.id}`); - } + setMobileDialogOpen(true); + navigate(`/candidate/jobs/${job.id}`); }; const handleMobileDialogClose = () => { @@ -181,11 +179,9 @@ const JobViewer: React.FC = ({ onSelect }) => { sx={{ display: 'flex', flexDirection: 'column', - ...(isMobile ? { width: '100%', boxShadow: 'none', - backgroundColor: 'transparent' - } : { width: '50%' }) + backgroundColor: 'transparent' }} > = ({ onSelect }) => { ); - if (isMobile) { - return ( - - - - - - - - - - - - {selectedJob?.title} - - - {selectedJob?.company} - - - - - - - - ); - } - return ( - - - - Job Details - - - - + + + + + + + + + {selectedJob?.title} + + + {selectedJob?.company} + + + + + + ); }; diff --git a/frontend/src/components/ui/ResumeInfo.tsx b/frontend/src/components/ui/ResumeInfo.tsx index 0bb7ea4..d29d8a1 100644 --- a/frontend/src/components/ui/ResumeInfo.tsx +++ b/frontend/src/components/ui/ResumeInfo.tsx @@ -27,6 +27,7 @@ import { Tabs, Tab } from '@mui/material'; +import PrintIcon from '@mui/icons-material/Print'; import { Delete as DeleteIcon, Restore as RestoreIcon, @@ -41,11 +42,15 @@ import { } from '@mui/icons-material'; import PreviewIcon from '@mui/icons-material/Preview'; import EditDocumentIcon from '@mui/icons-material/EditDocument'; + +import { useReactToPrint } from "react-to-print"; + import { useAuth } from 'hooks/AuthContext'; import { useAppState } from 'hooks/GlobalContext'; import { StyledMarkdown } from 'components/StyledMarkdown'; import { Resume } from 'types/types'; import { BackstoryTextField } from 'components/BackstoryTextField'; +import { JobInfo } from './JobInfo'; interface ResumeInfoProps { resume: Resume; @@ -73,10 +78,13 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); const [deleted, setDeleted] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); + const [printDialogOpen, setPrintDialogOpen] = useState(false); const [editContent, setEditContent] = useState(''); const [saving, setSaving] = useState(false); const contentRef = useRef(null); const [tabValue, setTabValue] = useState("markdown"); + const printContentRef = useRef(null); + const reactToPrintFn = useReactToPrint({ contentRef: printContentRef, pageStyle: '@page { margin: 10px; }' }); useEffect(() => { if (resume && resume.id !== activeResume?.id) { @@ -92,10 +100,10 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { } }, [resume.resume]); - const deleteResume = async (resumeId: string | undefined) => { - if (resumeId) { + const deleteResume = async (id: string | undefined) => { + if (id) { try { - await apiClient.deleteResume(resumeId); + await apiClient.deleteResume(id); setDeleted(true); setSnack('Resume deleted successfully.'); } catch (error) { @@ -113,8 +121,7 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { try { const result = await apiClient.updateResume(activeResume.id || '', editContent); const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() }; - setActiveResume(updatedResume); - setEditDialogOpen(false); + setActiveResume(updatedResume); setSnack('Resume updated successfully.'); } catch (error) { setSnack('Failed to update resume.'); @@ -144,6 +151,10 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { }; const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { + if (newValue === "print") { + reactToPrintFn(); + return; + } setTabValue(newValue); }; @@ -218,7 +229,7 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { Updated: {formatDate(activeResume.updatedAt)} - Resume ID: {activeResume.resumeId} + Resume ID: {activeResume.id} @@ -338,26 +349,57 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { )} + {/* Print Dialog */} + { }}//setPrintDialogOpen(false)} + maxWidth="lg" + fullWidth + fullScreen={true} + > + + + {/* Edit Dialog */} setEditDialogOpen(false)} maxWidth="lg" fullWidth + disableEscapeKeyDown={true} fullScreen={true} > Edit Resume Content - - Resume for {activeResume.candidate?.fullName || activeResume.candidateId} - + + Resume for {activeResume.candidate?.fullName || activeResume.candidateId}, {activeResume.job?.title || 'No Job Title Assigned'}, {activeResume.job?.company || 'No Company Assigned'} + + + Resume ID: # {activeResume.id} + + + Last saved: {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'} + } label="Markdown" /> } label="Preview" /> + } label="Job" /> + } label="Print" /> - = (props: ResumeInfoProps) => { flexShrink: 0, /* Prevent shrinking */ }, position: "relative", - border: "2px solid purple", }}> {tabValue === "markdown" && @@ -405,6 +446,20 @@ const ResumeInfo: React.FC = (props: ResumeInfoProps) => { content={editContent} /> } + {tabValue === "job" && activeResume.job && } diff --git a/frontend/src/components/ui/ResumeViewer.tsx b/frontend/src/components/ui/ResumeViewer.tsx index 2f93a1f..2328cf3 100644 --- a/frontend/src/components/ui/ResumeViewer.tsx +++ b/frontend/src/components/ui/ResumeViewer.tsx @@ -133,7 +133,7 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI resume.job?.title?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.job?.company?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) || - resume.resumeId?.toLowerCase().includes(searchQuery.toLowerCase()) + resume.id?.toLowerCase().includes(searchQuery.toLowerCase()) ); setFilteredResumes(filtered); } @@ -464,7 +464,7 @@ const ResumeViewer: React.FC = ({ onSelect, candidateId, jobI noWrap sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }} > - {resume.resumeId} + {resume.id} diff --git a/frontend/src/config/navigationConfig.tsx b/frontend/src/config/navigationConfig.tsx index ba775e8..d8d7daf 100644 --- a/frontend/src/config/navigationConfig.tsx +++ b/frontend/src/config/navigationConfig.tsx @@ -19,6 +19,7 @@ import { BubbleChart, AutoFixHigh, } from "@mui/icons-material"; +import EditDocumentIcon from '@mui/icons-material/EditDocument'; import { BackstoryLogo } from "components/ui/BackstoryLogo"; import { HomePage } from "pages/HomePage"; @@ -131,12 +132,12 @@ export const navigationConfig: NavigationConfig = { component: , userTypes: ["guest", "candidate", "employer"], }, - { - id: "explore", - label: "Explore", - icon: , - userTypes: ["candidate", "guest", "employer"], - children: [ + // { + // id: "explore", + // label: "Explore", + // icon: , + // userTypes: ["candidate", "guest", "employer"], + // children: [ // { // id: "explore-candidates", // label: "Candidates", @@ -147,29 +148,9 @@ export const navigationConfig: NavigationConfig = { // ), // userTypes: ["candidate", "guest", "employer"], // }, - { - id: "explore-jobs", - label: "Jobs", - path: "/candidate/jobs/:jobId?", - icon: , - component: ( - - ), - userTypes: ["candidate", "guest", "employer"], - }, - { - id: "explore-resumes", - label: "Resumes", - path: "/candidate/resumes/:resumeId?", - icon: , - component: ( - - ), - userTypes: ["candidate", "guest", "employer"], - }, - ], - showInNavigation: true, - }, + // ], + // showInNavigation: true, + // }, { id: "generate-candidate", @@ -205,6 +186,32 @@ export const navigationConfig: NavigationConfig = { showInNavigation: false, showInUserMenu: true, }, + { + id: "explore-jobs", + label: "Jobs", + path: "/candidate/jobs/:jobId?", + icon: , + component: ( + + ), + userTypes: ["candidate", "guest", "employer"], + showInNavigation: false, + showInUserMenu: true, + userMenuGroup: "profile", + }, + { + id: "explore-resumes", + label: "Resumes", + path: "/candidate/resumes/:resumeId?", + icon: , + component: ( + + ), + userTypes: ["candidate", "guest", "employer"], + showInNavigation: false, + showInUserMenu: true, + userMenuGroup: "profile", + }, { id: "candidate-docs", label: "Content", @@ -291,33 +298,21 @@ export const navigationConfig: NavigationConfig = { showInNavigation: false, children: [ { - id: "register", - label: "Register", - path: "/login/register", - component: ( - - - - ), - userTypes: ["guest"], + id: "verify-email", + label: "Verify Email", + path: "/login/verify-email", + component: , + userTypes: ["guest", "candidate", "employer"], showInNavigation: false, }, { id: "login", label: "Login", - path: "/login/*", + path: "/login/:tab?", component: , userTypes: ["guest", "candidate", "employer"], showInNavigation: false, }, - { - id: "verify-email", - label: "Verify Email", - path: "/login/verify-email", - component: , - userTypes: ["guest", "candidate", "employer"], - showInNavigation: false, - }, { id: "logout-page", label: "Logout", diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index eb883e7..3fe1ecd 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -26,14 +26,14 @@ import { BackstoryPageProps } from 'components/BackstoryTab'; import { LoginForm } from "components/EmailVerificationComponents"; import { CandidateRegistrationForm } from "pages/candidate/RegistrationForms"; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useAppState } from 'hooks/GlobalContext'; import * as Types from 'types/types'; const LoginPage: React.FC = (props: BackstoryPageProps) => { const navigate = useNavigate(); const { setSnack } = useAppState(); - const [tabValue, setTabValue] = useState(0); + const [tabValue, setTabValue] = useState('login'); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(null); const { guest, user, login, isLoading, error } = useAuth(); @@ -42,6 +42,7 @@ const LoginPage: React.FC = (props: BackstoryPageProps) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const showGuest: boolean = false; + const { tab } = useParams(); useEffect(() => { if (!loading || !error) { @@ -60,7 +61,13 @@ const LoginPage: React.FC = (props: BackstoryPageProps) => { } }, [error, loading]); - const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + useEffect(() => { + if (tab === 'register') { + setTabValue(tab); + } + }, [tab, setTabValue]); + + const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { setTabValue(newValue); setSuccess(null); }; @@ -93,8 +100,8 @@ const LoginPage: React.FC = (props: BackstoryPageProps) => { - } label="Login" /> - } label="Register" /> + } label="Login" /> + } label="Register" /> @@ -110,11 +117,11 @@ const LoginPage: React.FC = (props: BackstoryPageProps) => { )} - {tabValue === 0 && ( + {tabValue === "login" && ( )} - {tabValue === 1 && ( + {tabValue === "register" && ( )} diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 08e703a..fa04317 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -738,14 +738,14 @@ class ApiClient { return handleApiResponse<{ success: boolean; statistics: any }>(response); } - async updateResume(resumeId: string, content: string): Promise<{ success: boolean; message: string; resume: Types.Resume }> { + async updateResume(resumeId: string, content: string): Promise { const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { method: 'PUT', headers: this.defaultHeaders, body: JSON.stringify(content) }); - return handleApiResponse<{ success: boolean; message: string; resume: Types.Resume }>(response); + return this.handleApiResponseWithConversion(response, 'Resume'); } async getJob(id: string): Promise { diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py index 417f0b0..617e617 100644 --- a/src/backend/agents/generate_resume.py +++ b/src/backend/agents/generate_resume.py @@ -138,7 +138,7 @@ When sections lack data, output "Information not provided" or use placeholder te 2. Format the resume in a clean, concise, and modern style that will pass ATS systems. 3. Include these sections: - Professional Summary (highlight strongest skills and experience level) - - Skills (organized by strength) + - Skills (organized by strength, under a single section). When listing skills, rephrase them so they are not identical to the original assessment. - Professional Experience (focus on achievements and evidence of the skill) 4. Optional sections, to include only if evidence is present: - Education section @@ -165,7 +165,11 @@ ELSE: Provide the resume in clean markdown format, ready for the candidate to use. """ - prompt = "Create a tailored professional resume that highlights candidate's skills and experience most relevant to the job requirements. Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no commentary before or after." + prompt = """\ +Create a tailored professional resume that highlights candidate's skills and experience most relevant to the job requirements. +Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no commentary before or after. +""" + return system_prompt, prompt async def generate_resume( diff --git a/src/backend/database.py b/src/backend/database.py index cec7fb0..7728f7e 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -17,6 +17,12 @@ class _RedisManager: def __init__(self): self.redis: Optional[redis.Redis] = None self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379") + self.redis_db = int(os.getenv("REDIS_DB", "0")) + + # Append database to URL if not already present + if not self.redis_url.endswith(f"/{self.redis_db}"): + self.redis_url = f"{self.redis_url}/{self.redis_db}" + self._connection_pool: Optional[redis.ConnectionPool] = None self._is_connected = False @@ -210,10 +216,10 @@ class RedisDatabase: """Save a resume for a user""" try: # Generate resume_id if not present - if 'resume_id' not in resume_data: - resume_data['resume_id'] = f"resume_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}" + if 'id' not in resume_data: + raise ValueError("Resume data must include an 'id' field") - resume_id = resume_data['resume_id'] + resume_id = resume_data['id'] # Store the resume data key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}" @@ -236,9 +242,9 @@ class RedisDatabase: data = await self.redis.get(key) if data: resume_data = self._deserialize(data) - logger.debug(f"๐Ÿ“„ Retrieved resume {resume_id} for user {user_id}") + logger.info(f"๐Ÿ“„ Retrieved resume {resume_id} for user {user_id}") return resume_data - logger.debug(f"๐Ÿ“„ Resume {resume_id} not found for user {user_id}") + logger.info(f"๐Ÿ“„ Resume {resume_id} not found for user {user_id}") return None except Exception as e: logger.error(f"โŒ Error retrieving resume {resume_id} for user {user_id}: {e}") @@ -252,7 +258,7 @@ class RedisDatabase: resume_ids = await self.redis.lrange(user_resumes_key, 0, -1) if not resume_ids: - logger.debug(f"๐Ÿ“„ No resumes found for user {user_id}") + logger.info(f"๐Ÿ“„ No resumes found for user {user_id}") return [] # Get all resume data @@ -275,7 +281,7 @@ class RedisDatabase: # Sort by created_at timestamp (most recent first) resumes.sort(key=lambda x: x.get("created_at", ""), reverse=True) - logger.debug(f"๐Ÿ“„ Retrieved {len(resumes)} resumes for user {user_id}") + logger.info(f"๐Ÿ“„ Retrieved {len(resumes)} resumes for user {user_id}") return resumes except Exception as e: logger.error(f"โŒ Error retrieving resumes for user {user_id}: {e}") @@ -392,7 +398,7 @@ class RedisDatabase: if query_lower in searchable_text: matching_resumes.append(resume) - logger.debug(f"๐Ÿ“„ Found {len(matching_resumes)} matching resumes for user {user_id}") + logger.info(f"๐Ÿ“„ Found {len(matching_resumes)} matching resumes for user {user_id}") return matching_resumes except Exception as e: logger.error(f"โŒ Error searching resumes for user {user_id}: {e}") @@ -407,7 +413,7 @@ class RedisDatabase: if resume.get("candidate_id") == candidate_id ] - logger.debug(f"๐Ÿ“„ Found {len(candidate_resumes)} resumes for candidate {candidate_id} by user {user_id}") + logger.info(f"๐Ÿ“„ Found {len(candidate_resumes)} resumes for candidate {candidate_id} by user {user_id}") return candidate_resumes except Exception as e: logger.error(f"โŒ Error retrieving resumes for candidate {candidate_id} by user {user_id}: {e}") @@ -422,7 +428,7 @@ class RedisDatabase: if resume.get("job_id") == job_id ] - logger.debug(f"๐Ÿ“„ Found {len(job_resumes)} resumes for job {job_id} by user {user_id}") + logger.info(f"๐Ÿ“„ Found {len(job_resumes)} resumes for job {job_id} by user {user_id}") return job_resumes except Exception as e: logger.error(f"โŒ Error retrieving resumes for job {job_id} by user {user_id}: {e}") @@ -558,7 +564,7 @@ class RedisDatabase: cache_key, json.dumps(assessment.model_dump(mode='json', by_alias=True), default=str) # Serialize with datetime handling ) - logger.debug(f"๐Ÿ’พ Skill match cached: {cache_key}") + logger.info(f"๐Ÿ’พ Skill match cached: {cache_key}") except Exception as e: logger.error(f"โŒ Error caching skill match: {e}") @@ -734,9 +740,9 @@ class RedisDatabase: data = await self.redis.get(key) if data: requirements_data = self._deserialize(data) - logger.debug(f"๐Ÿ“‹ Retrieved cached job requirements for document {document_id}") + logger.info(f"๐Ÿ“‹ Retrieved cached job requirements for document {document_id}") return requirements_data - logger.debug(f"๐Ÿ“‹ No cached job requirements found for document {document_id}") + logger.info(f"๐Ÿ“‹ No cached job requirements found for document {document_id}") return None except Exception as e: logger.error(f"โŒ Error retrieving job requirements for document {document_id}: {e}") @@ -759,7 +765,7 @@ class RedisDatabase: # Optional: Set expiration (e.g., 30 days) to prevent indefinite storage # await self.redis.expire(key, 30 * 24 * 60 * 60) # 30 days - logger.debug(f"๐Ÿ“‹ Saved job requirements for document {document_id}") + logger.info(f"๐Ÿ“‹ Saved job requirements for document {document_id}") return True except Exception as e: logger.error(f"โŒ Error saving job requirements for document {document_id}: {e}") @@ -771,7 +777,7 @@ class RedisDatabase: key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}" result = await self.redis.delete(key) if result > 0: - logger.debug(f"๐Ÿ“‹ Deleted job requirements for document {document_id}") + logger.info(f"๐Ÿ“‹ Deleted job requirements for document {document_id}") return True return False except Exception as e: @@ -1010,7 +1016,7 @@ class RedisDatabase: if candidate_email and await self.user_exists_by_email(candidate_email): await self.delete_user(candidate_email) user_records_deleted += 1 - logger.debug(f"๐Ÿ—‘๏ธ Deleted user record by email: {candidate_email}") + logger.info(f"๐Ÿ—‘๏ธ Deleted user record by email: {candidate_email}") # Delete by username (if different from email) if (candidate_username and @@ -1018,7 +1024,7 @@ class RedisDatabase: await self.user_exists_by_username(candidate_username)): await self.delete_user(candidate_username) user_records_deleted += 1 - logger.debug(f"๐Ÿ—‘๏ธ Deleted user record by username: {candidate_username}") + logger.info(f"๐Ÿ—‘๏ธ Deleted user record by username: {candidate_username}") # Delete user by ID if exists user_by_id = await self.get_user_by_id(candidate_id) @@ -1026,7 +1032,7 @@ class RedisDatabase: key = f"user_by_id:{candidate_id}" await self.redis.delete(key) user_records_deleted += 1 - logger.debug(f"๐Ÿ—‘๏ธ Deleted user record by ID: {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Deleted user record by ID: {candidate_id}") deletion_stats["user_records"] = user_records_deleted logger.info(f"๐Ÿ—‘๏ธ Deleted {user_records_deleted} user records for candidate {candidate_id}") @@ -1038,14 +1044,14 @@ class RedisDatabase: auth_deleted = await self.delete_authentication(candidate_id) if auth_deleted: deletion_stats["auth_records"] = 1 - logger.debug(f"๐Ÿ—‘๏ธ Deleted authentication record for candidate {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Deleted authentication record for candidate {candidate_id}") except Exception as e: logger.error(f"โŒ Error deleting authentication records: {e}") # 7. Revoke all refresh tokens for this user try: await self.revoke_all_user_tokens(candidate_id) - logger.debug(f"๐Ÿ—‘๏ธ Revoked all refresh tokens for candidate {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Revoked all refresh tokens for candidate {candidate_id}") except Exception as e: logger.error(f"โŒ Error revoking refresh tokens: {e}") @@ -1068,7 +1074,7 @@ class RedisDatabase: deletion_stats["security_logs"] = security_logs_deleted if security_logs_deleted > 0: - logger.debug(f"๐Ÿ—‘๏ธ Deleted {security_logs_deleted} security log entries for candidate {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Deleted {security_logs_deleted} security log entries for candidate {candidate_id}") except Exception as e: logger.error(f"โŒ Error deleting security logs: {e}") @@ -1115,7 +1121,7 @@ class RedisDatabase: break if tokens_deleted > 0: - logger.debug(f"๐Ÿ—‘๏ธ Deleted {tokens_deleted} email verification tokens for candidate {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Deleted {tokens_deleted} email verification tokens for candidate {candidate_id}") except Exception as e: logger.error(f"โŒ Error deleting email verification tokens: {e}") @@ -1141,7 +1147,7 @@ class RedisDatabase: break if tokens_deleted > 0: - logger.debug(f"๐Ÿ—‘๏ธ Deleted {tokens_deleted} password reset tokens for candidate {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Deleted {tokens_deleted} password reset tokens for candidate {candidate_id}") except Exception as e: logger.error(f"โŒ Error deleting password reset tokens: {e}") @@ -1163,7 +1169,7 @@ class RedisDatabase: break if mfa_codes_deleted > 0: - logger.debug(f"๐Ÿ—‘๏ธ Deleted {mfa_codes_deleted} MFA codes for candidate {candidate_id}") + logger.info(f"๐Ÿ—‘๏ธ Deleted {mfa_codes_deleted} MFA codes for candidate {candidate_id}") except Exception as e: logger.error(f"โŒ Error deleting MFA codes: {e}") @@ -1417,7 +1423,7 @@ class RedisDatabase: if current_time > expires_at: await self.redis.delete(key) cleaned_count += 1 - logger.debug(f"๐Ÿงน Cleaned expired verification token for {verification_info.get('email')}") + logger.info(f"๐Ÿงน Cleaned expired verification token for {verification_info.get('email')}") if cursor == 0: break @@ -1509,7 +1515,7 @@ class RedisDatabase: json.dumps(verification_data, default=str) ) - logger.debug(f"๐Ÿ“ง Stored email verification token for {email}") + logger.info(f"๐Ÿ“ง Stored email verification token for {email}") return True except Exception as e: logger.error(f"โŒ Error storing email verification token: {e}") @@ -1568,7 +1574,7 @@ class RedisDatabase: json.dumps(mfa_data, default=str) ) - logger.debug(f"๐Ÿ” Stored MFA code for {email}") + logger.info(f"๐Ÿ” Stored MFA code for {email}") return True except Exception as e: logger.error(f"โŒ Error storing MFA code: {e}") @@ -2093,7 +2099,7 @@ class RedisDatabase: key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}" pipe.delete(key) orphaned_count += 1 - logger.debug(f"๐Ÿ“‹ Queued orphaned job requirements for deletion: {document_id}") + logger.info(f"๐Ÿ“‹ Queued orphaned job requirements for deletion: {document_id}") if orphaned_count > 0: await pipe.execute() @@ -2128,7 +2134,7 @@ class RedisDatabase: try: key = f"auth:{user_id}" await self.redis.set(key, json.dumps(auth_data, default=str)) - logger.debug(f"๐Ÿ” Stored authentication record for user {user_id}") + logger.info(f"๐Ÿ” Stored authentication record for user {user_id}") return True except Exception as e: logger.error(f"โŒ Error storing authentication record for {user_id}: {e}") @@ -2151,7 +2157,7 @@ class RedisDatabase: try: key = f"auth:{user_id}" result = await self.redis.delete(key) - logger.debug(f"๐Ÿ” Deleted authentication record for user {user_id}") + logger.info(f"๐Ÿ” Deleted authentication record for user {user_id}") return result > 0 except Exception as e: logger.error(f"โŒ Error deleting authentication record for {user_id}: {e}") @@ -2163,7 +2169,7 @@ class RedisDatabase: try: key = f"user_by_id:{user_id}" await self.redis.set(key, json.dumps(user_data, default=str)) - logger.debug(f"๐Ÿ‘ค Stored user data by ID for {user_id}") + logger.info(f"๐Ÿ‘ค Stored user data by ID for {user_id}") return True except Exception as e: logger.error(f"โŒ Error storing user by ID {user_id}: {e}") @@ -2213,10 +2219,10 @@ class RedisDatabase: data = await self.redis.get(key) if data: user_data = json.loads(data) - logger.debug(f"๐Ÿ‘ค Retrieved user data for {login}") + logger.info(f"๐Ÿ‘ค Retrieved user data for {login}") return user_data - logger.debug(f"๐Ÿ‘ค No user found for {login}") + logger.info(f"๐Ÿ‘ค No user found for {login}") return None except Exception as e: logger.error(f"โŒ Error retrieving user {login}: {e}") @@ -2233,7 +2239,7 @@ class RedisDatabase: key = f"users:{login}" await self.redis.set(key, json.dumps(user_data, default=str)) - logger.debug(f"๐Ÿ‘ค Stored user data for {login}") + logger.info(f"๐Ÿ‘ค Stored user data for {login}") return True except Exception as e: logger.error(f"โŒ Error storing user {login}: {e}") @@ -2257,7 +2263,7 @@ class RedisDatabase: ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds()) if ttl_seconds > 0: await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str)) - logger.debug(f"๐Ÿ” Stored refresh token for user {user_id}") + logger.info(f"๐Ÿ” Stored refresh token for user {user_id}") return True else: logger.warning(f"โš ๏ธ Attempted to store expired refresh token for user {user_id}") @@ -2287,7 +2293,7 @@ class RedisDatabase: token_data["is_revoked"] = True token_data["revoked_at"] = datetime.now(timezone.utc).isoformat() await self.redis.set(key, json.dumps(token_data, default=str)) - logger.debug(f"๐Ÿ” Revoked refresh token") + logger.info(f"๐Ÿ” Revoked refresh token") return True return False except Exception as e: @@ -2340,7 +2346,7 @@ class RedisDatabase: ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds()) if ttl_seconds > 0: await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str)) - logger.debug(f"๐Ÿ” Stored password reset token for {email}") + logger.info(f"๐Ÿ” Stored password reset token for {email}") return True else: logger.warning(f"โš ๏ธ Attempted to store expired password reset token for {email}") @@ -2370,7 +2376,7 @@ class RedisDatabase: token_data["used"] = True token_data["used_at"] = datetime.now(timezone.utc).isoformat() await self.redis.set(key, json.dumps(token_data, default=str)) - logger.debug(f"๐Ÿ” Marked password reset token as used") + logger.info(f"๐Ÿ” Marked password reset token as used") return True return False except Exception as e: @@ -2398,7 +2404,7 @@ class RedisDatabase: # Set expiration for 30 days await self.redis.expire(key, 30 * 24 * 60 * 60) - logger.debug(f"๐Ÿ”’ Logged security event {event_type} for user {user_id}") + logger.info(f"๐Ÿ”’ Logged security event {event_type} for user {user_id}") return True except Exception as e: logger.error(f"โŒ Error logging security event for {user_id}: {e}") @@ -2443,7 +2449,7 @@ class RedisDatabase: json.dumps(guest_data) ) - logger.debug(f"๐Ÿ’พ Guest stored with backup: {guest_id}") + logger.info(f"๐Ÿ’พ Guest stored with backup: {guest_id}") except Exception as e: logger.error(f"โŒ Error storing guest {guest_id}: {e}") raise @@ -2458,7 +2464,7 @@ class RedisDatabase: # Update last activity when accessed guest_data["last_activity"] = datetime.now(UTC).isoformat() await self.set_guest(guest_id, guest_data) - logger.debug(f"๐Ÿ” Guest found in primary storage: {guest_id}") + logger.info(f"๐Ÿ” Guest found in primary storage: {guest_id}") return guest_data # Fallback to backup storage @@ -2534,7 +2540,7 @@ class RedisDatabase: created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) if current_time - created_at < timedelta(hours=1): preserved_count += 1 - logger.debug(f"๐Ÿ›ก๏ธ Preserving new guest: {guest_id}") + logger.info(f"๐Ÿ›ก๏ธ Preserving new guest: {guest_id}") continue # Check last activity diff --git a/src/backend/email_service.py b/src/backend/email_service.py index dce48a6..76450eb 100644 --- a/src/backend/email_service.py +++ b/src/backend/email_service.py @@ -19,7 +19,7 @@ class EmailService: self.email_password = os.getenv("EMAIL_PASSWORD") self.from_name = os.getenv("FROM_NAME", "Backstory") self.app_name = os.getenv("APP_NAME", "Backstory") - self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com") + self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory.ketrenos.com") if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None: raise ValueError("SMTP configuration is not set in the environment variables") diff --git a/src/backend/main.py b/src/backend/main.py index 3ed0be5..fe71afc 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -118,24 +118,30 @@ def signal_handler(signum, frame): elif signum == signal.SIGTERM and callable(prev_term): prev_term(signum, frame) +# Global background task manager +background_task_manager: Optional[BackgroundTaskManager] = None + @asynccontextmanager async def lifespan(app: FastAPI): # Startup - logger.info("๐Ÿš€ Starting Backstory API") + global background_task_manager + + logger.info("๐Ÿš€ Starting Backstory API with enhanced background tasks") logger.info(f"๐Ÿ“ API Documentation available at: http://{defines.host}:{defines.port}{defines.api_prefix}/docs") logger.info("๐Ÿ”— API endpoints prefixed with: /api/1.0") - if os.path.exists(defines.static_content): - logger.info(f"๐Ÿ“ Serving static files from: {defines.static_content}") - try: # Initialize database await db_manager.initialize() entities.entity_manager.initialize(prometheus_collector, database=db_manager.get_database()) + # Initialize background task manager + background_task_manager = BackgroundTaskManager(db_manager) + background_task_manager.start() + signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) - logger.info("๐Ÿš€ Application startup completed") + logger.info("๐Ÿš€ Application startup completed with background tasks") yield # Application is running @@ -146,11 +152,13 @@ async def lifespan(app: FastAPI): finally: # Shutdown logger.info("Application shutdown requested") + + # Stop background tasks first + if background_task_manager: + background_task_manager.stop() + await db_manager.graceful_shutdown() -# Global background task manager -background_task_manager: Optional[BackgroundTaskManager] = None - app = FastAPI( lifespan=lifespan, title="Backstory API", @@ -162,6 +170,7 @@ app = FastAPI( ) ssl_enabled = os.getenv("SSL_ENABLED", "true").lower() == "true" + if ssl_enabled: allow_origins = ["https://battle-linux.ketrenos.com:3000", "https://backstory-beta.ketrenos.com"] @@ -199,7 +208,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE content=json.dumps({"detail": str(exc)}), ) - # ============================ # Authentication Utilities # ============================ @@ -3390,6 +3398,14 @@ async def get_user_resumes( try: resumes_data = await database.get_all_resumes_for_user(current_user.id) resumes : List[Resume] = [Resume.model_validate(data) for data in resumes_data] + for resume in resumes: + job_data = await database.get_job(resume.job_id) + if job_data: + resume.job = Job.model_validate(job_data) + candidate_data = await database.get_candidate(resume.candidate_id) + if candidate_data: + resume.candidate = Candidate.model_validate(candidate_data) + resumes.sort(key=lambda x: x.updated_at, reverse=True) # Sort by creation date return create_success_response({ "resumes": resumes, "count": len(resumes) @@ -3529,15 +3545,17 @@ async def update_resume( "updated_at": datetime.now(UTC).isoformat() } - updated_resume = await database.update_resume(current_user.id, resume_id, updates) - if not updated_resume: + updated_resume_data = await database.update_resume(current_user.id, resume_id, updates) + if not updated_resume_data: + logger.warning(f"โš ๏ธ Resume {resume_id} not found for user {current_user.id}") raise HTTPException(status_code=404, detail="Resume not found") + updated_resume = Resume.model_validate(updated_resume_data) if updated_resume_data else None - return { + return create_success_response({ "success": True, "message": f"Resume {resume_id} updated successfully", "resume": updated_resume - } + }) except HTTPException: raise except Exception as e: @@ -5783,58 +5801,6 @@ async def api_info(): "health": f"{defines.api_prefix}/health" } -# ============================ -# Manual Task Execution Endpoints (Admin Only) -# ============================ -# Global background task manager -background_task_manager: Optional[BackgroundTaskManager] = None - -@asynccontextmanager -async def enhanced_lifespan(app: FastAPI): - # Startup - global background_task_manager - - logger.info("๐Ÿš€ Starting Backstory API with enhanced background tasks") - logger.info(f"๐Ÿ“ API Documentation available at: http://{defines.host}:{defines.port}{defines.api_prefix}/docs") - logger.info("๐Ÿ”— API endpoints prefixed with: /api/1.0") - if os.path.exists(defines.static_content): - logger.info(f"๐Ÿ“ Serving static files from: {defines.static_content}") - - try: - # Initialize database - await db_manager.initialize() - entities.entity_manager.initialize(prometheus_collector, database=db_manager.get_database()) - - # Initialize background task manager - background_task_manager = BackgroundTaskManager(db_manager) - background_task_manager.start() - - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGINT, signal_handler) - - logger.info("๐Ÿš€ Application startup completed with background tasks") - - yield # Application is running - - except Exception as e: - logger.error(f"โŒ Failed to start application: {e}") - raise - - finally: - # Shutdown - logger.info("Application shutdown requested") - - # Stop background tasks first - if background_task_manager: - background_task_manager.stop() - - await db_manager.graceful_shutdown() - -# ============================ -# Manual Task Execution Endpoints (Admin Only) -# ============================ - - # ============================ # Task Monitoring and Metrics # ============================ @@ -6113,13 +6079,15 @@ instrumentator.expose(app, endpoint=f"{defines.api_prefix}/metrics") # Static File Serving # ============================ -# Serve static files (for frontend build) -# This should be last to not interfere with API routes -if os.path.exists(defines.static_content): - app.mount("/", StaticFiles(directory=defines.static_content, html=True), name="static") -else: - logger.info(f"โš ๏ธ Static directory '{defines.static_content}' not found. Static file serving disabled.") +@app.get("/{path:path}") +async def serve_static(path: str, request: Request): + full_path = os.path.join(defines.static_content, path) + + if os.path.exists(full_path) and os.path.isfile(full_path): + return FileResponse(full_path) + return FileResponse(os.path.join(defines.static_content, "index.html")) + # Root endpoint when no static files @app.get("/", include_in_schema=False) async def root(): diff --git a/users-prod/.keep b/users-prod/.keep deleted file mode 100644 index e69de29..0000000