Add printing and lots of fixes

This commit is contained in:
James Ketr 2025-06-12 16:27:08 -07:00
parent 6845ed7c62
commit 5750577eaf
14 changed files with 278 additions and 270 deletions

View File

@ -45,6 +45,7 @@
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"react-to-print": "^3.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
@ -20206,6 +20207,14 @@
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View File

@ -40,6 +40,7 @@
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"react-to-print": "^3.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",

View File

@ -314,11 +314,10 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
} }
<Typography variant="caption">Job ID: {job.id}</Typography> <Typography variant="caption">Job ID: {job.id}</Typography>
</>} </>}
{variant === 'all' && <StyledMarkdown content={activeJob.description} />} {variant === 'all' && <StyledMarkdown sx={{ display: "flex" }} content={activeJob.description} />}
{(variant !== 'small' && variant !== 'minimal') && <><Divider />{renderJobRequirements()}</>} {(variant !== 'small' && variant !== 'minimal') && <Box><Divider />{renderJobRequirements()}</Box>}
</Box >
{isAdmin && {isAdmin &&
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}> <Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
@ -371,6 +370,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
</Box> </Box>
} }
</Box > </Box >
</Box >
); );
}; };

View File

@ -83,6 +83,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
if (job) { if (job) {
setSelectedJob(job); setSelectedJob(job);
onSelect?.(job); onSelect?.(job);
setMobileDialogOpen(true);
return; return;
} }
} }
@ -147,11 +148,8 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const handleJobSelect = (job: Job) => { const handleJobSelect = (job: Job) => {
setSelectedJob(job); setSelectedJob(job);
onSelect?.(job); onSelect?.(job);
if (isMobile) {
setMobileDialogOpen(true); setMobileDialogOpen(true);
} else {
navigate(`/candidate/jobs/${job.id}`); navigate(`/candidate/jobs/${job.id}`);
}
}; };
const handleMobileDialogClose = () => { const handleMobileDialogClose = () => {
@ -181,11 +179,9 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
...(isMobile ? {
width: '100%', width: '100%',
boxShadow: 'none', boxShadow: 'none',
backgroundColor: 'transparent' backgroundColor: 'transparent'
} : { width: '50%' })
}} }}
> >
<Box sx={{ <Box sx={{
@ -452,7 +448,6 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</Box> </Box>
); );
if (isMobile) {
return ( return (
<Box sx={{ <Box sx={{
height: '100%', height: '100%',
@ -503,38 +498,6 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</Dialog> </Dialog>
</Box> </Box>
); );
}
return (
<Box sx={{
display: 'flex',
height: '100%',
gap: 0.75,
p: 0.75,
backgroundColor: 'background.default'
}}>
<JobList />
<Paper sx={{
width: '50%',
display: 'flex',
flexDirection: 'column',
elevation: 1
}}>
<Box sx={{
p: 0.75,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.paper'
}}>
<Typography variant="h6" sx={{ fontSize: '1.1rem', fontWeight: 600 }}>
Job Details
</Typography>
</Box>
<JobDetails />
</Paper>
</Box>
);
}; };
export { JobViewer }; export { JobViewer };

View File

@ -27,6 +27,7 @@ import {
Tabs, Tabs,
Tab Tab
} from '@mui/material'; } from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import { import {
Delete as DeleteIcon, Delete as DeleteIcon,
Restore as RestoreIcon, Restore as RestoreIcon,
@ -41,11 +42,15 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import PreviewIcon from '@mui/icons-material/Preview'; import PreviewIcon from '@mui/icons-material/Preview';
import EditDocumentIcon from '@mui/icons-material/EditDocument'; import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { useReactToPrint } from "react-to-print";
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { useAppState } from 'hooks/GlobalContext'; import { useAppState } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown'; import { StyledMarkdown } from 'components/StyledMarkdown';
import { Resume } from 'types/types'; import { Resume } from 'types/types';
import { BackstoryTextField } from 'components/BackstoryTextField'; import { BackstoryTextField } from 'components/BackstoryTextField';
import { JobInfo } from './JobInfo';
interface ResumeInfoProps { interface ResumeInfoProps {
resume: Resume; resume: Resume;
@ -73,10 +78,13 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false); const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
const [deleted, setDeleted] = useState<boolean>(false); const [deleted, setDeleted] = useState<boolean>(false);
const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false); const [editDialogOpen, setEditDialogOpen] = useState<boolean>(false);
const [printDialogOpen, setPrintDialogOpen] = useState<boolean>(false);
const [editContent, setEditContent] = useState<string>(''); const [editContent, setEditContent] = useState<string>('');
const [saving, setSaving] = useState<boolean>(false); const [saving, setSaving] = useState<boolean>(false);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const [tabValue, setTabValue] = useState("markdown"); const [tabValue, setTabValue] = useState("markdown");
const printContentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ contentRef: printContentRef, pageStyle: '@page { margin: 10px; }' });
useEffect(() => { useEffect(() => {
if (resume && resume.id !== activeResume?.id) { if (resume && resume.id !== activeResume?.id) {
@ -92,10 +100,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
} }
}, [resume.resume]); }, [resume.resume]);
const deleteResume = async (resumeId: string | undefined) => { const deleteResume = async (id: string | undefined) => {
if (resumeId) { if (id) {
try { try {
await apiClient.deleteResume(resumeId); await apiClient.deleteResume(id);
setDeleted(true); setDeleted(true);
setSnack('Resume deleted successfully.'); setSnack('Resume deleted successfully.');
} catch (error) { } catch (error) {
@ -114,7 +122,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
const result = await apiClient.updateResume(activeResume.id || '', editContent); const result = await apiClient.updateResume(activeResume.id || '', editContent);
const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() }; const updatedResume = { ...activeResume, resume: editContent, updatedAt: new Date() };
setActiveResume(updatedResume); setActiveResume(updatedResume);
setEditDialogOpen(false);
setSnack('Resume updated successfully.'); setSnack('Resume updated successfully.');
} catch (error) { } catch (error) {
setSnack('Failed to update resume.'); setSnack('Failed to update resume.');
@ -144,6 +151,10 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
}; };
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
if (newValue === "print") {
reactToPrintFn();
return;
}
setTabValue(newValue); setTabValue(newValue);
}; };
@ -218,7 +229,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
Updated: {formatDate(activeResume.updatedAt)} Updated: {formatDate(activeResume.updatedAt)}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Resume ID: {activeResume.resumeId} Resume ID: {activeResume.id}
</Typography> </Typography>
</Stack> </Stack>
</Grid> </Grid>
@ -338,26 +349,57 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
</Box> </Box>
)} )}
{/* Print Dialog */}
<Dialog
open={printDialogOpen}
onClose={() => { }}//setPrintDialogOpen(false)}
maxWidth="lg"
fullWidth
fullScreen={true}
>
<StyledMarkdown
content={activeResume.resume}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}} />
</Dialog>
{/* Edit Dialog */} {/* Edit Dialog */}
<Dialog <Dialog
open={editDialogOpen} open={editDialogOpen}
onClose={() => setEditDialogOpen(false)} onClose={() => setEditDialogOpen(false)}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
disableEscapeKeyDown={true}
fullScreen={true} fullScreen={true}
> >
<DialogTitle> <DialogTitle>
Edit Resume Content Edit Resume Content
<Typography variant="caption" display="block" color="text.secondary"> <Typography variant="caption" display="block" color="text.secondary">
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'}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Resume ID: # {activeResume.id}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
Last saved: {activeResume.updatedAt ? new Date(activeResume.updatedAt).toLocaleString() : 'N/A'}
</Typography> </Typography>
</DialogTitle> </DialogTitle>
<DialogContent sx={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}> <DialogContent sx={{ position: "relative", display: "flex", flexDirection: "column", height: "100%" }}>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" /> <Tab value="markdown" icon={<EditDocumentIcon />} label="Markdown" />
<Tab value="preview" icon={<PreviewIcon />} label="Preview" /> <Tab value="preview" icon={<PreviewIcon />} label="Preview" />
<Tab value="job" icon={<WorkIcon />} label="Job" />
<Tab value="print" icon={<PrintIcon />} label="Print" />
</Tabs> </Tabs>
<Box sx={{ <Box ref={printContentRef} sx={{
display: "flex", flexDirection: "column", display: "flex", flexDirection: "column",
height: "100%", /* Restrict to main-container's height */ height: "100%", /* Restrict to main-container's height */
width: "100%", width: "100%",
@ -367,7 +409,6 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
flexShrink: 0, /* Prevent shrinking */ flexShrink: 0, /* Prevent shrinking */
}, },
position: "relative", position: "relative",
border: "2px solid purple",
}}> }}>
{tabValue === "markdown" && {tabValue === "markdown" &&
@ -405,6 +446,20 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
content={editContent} /> content={editContent} />
<Box sx={{ pb: 2 }}></Box></> <Box sx={{ pb: 2 }}></Box></>
} }
{tabValue === "job" && activeResume.job && <JobInfo
variant="all"
job={activeResume.job}
sx={{
p: 2,
position: "relative",
maxHeight: "100%",
width: "100%",
display: "flex",
flexGrow: 1,
flex: 1, /* Take remaining space in some-container */
overflowY: "auto", /* Scroll if content overflows */
}}
/>}
</Box> </Box>
</DialogContent> </DialogContent>

View File

@ -133,7 +133,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
resume.job?.title?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.job?.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
resume.job?.company?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.job?.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) || resume.resume?.toLowerCase().includes(searchQuery.toLowerCase()) ||
resume.resumeId?.toLowerCase().includes(searchQuery.toLowerCase()) resume.id?.toLowerCase().includes(searchQuery.toLowerCase())
); );
setFilteredResumes(filtered); setFilteredResumes(filtered);
} }
@ -464,7 +464,7 @@ const ResumeViewer: React.FC<ResumeViewerProps> = ({ onSelect, candidateId, jobI
noWrap noWrap
sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }} sx={{ fontSize: isMobile ? '0.65rem' : '0.7rem' }}
> >
{resume.resumeId} {resume.id}
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -19,6 +19,7 @@ import {
BubbleChart, BubbleChart,
AutoFixHigh, AutoFixHigh,
} from "@mui/icons-material"; } from "@mui/icons-material";
import EditDocumentIcon from '@mui/icons-material/EditDocument';
import { BackstoryLogo } from "components/ui/BackstoryLogo"; import { BackstoryLogo } from "components/ui/BackstoryLogo";
import { HomePage } from "pages/HomePage"; import { HomePage } from "pages/HomePage";
@ -131,12 +132,12 @@ export const navigationConfig: NavigationConfig = {
component: <CandidateChatPage />, component: <CandidateChatPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ["guest", "candidate", "employer"],
}, },
{ // {
id: "explore", // id: "explore",
label: "Explore", // label: "Explore",
icon: <SearchIcon />, // icon: <SearchIcon />,
userTypes: ["candidate", "guest", "employer"], // userTypes: ["candidate", "guest", "employer"],
children: [ // children: [
// { // {
// id: "explore-candidates", // id: "explore-candidates",
// label: "Candidates", // label: "Candidates",
@ -147,29 +148,9 @@ export const navigationConfig: NavigationConfig = {
// ), // ),
// userTypes: ["candidate", "guest", "employer"], // userTypes: ["candidate", "guest", "employer"],
// }, // },
{ // ],
id: "explore-jobs", // showInNavigation: true,
label: "Jobs", // },
path: "/candidate/jobs/:jobId?",
icon: <SearchIcon />,
component: (
<JobViewer />
),
userTypes: ["candidate", "guest", "employer"],
},
{
id: "explore-resumes",
label: "Resumes",
path: "/candidate/resumes/:resumeId?",
icon: <SearchIcon />,
component: (
<ResumeViewer />
),
userTypes: ["candidate", "guest", "employer"],
},
],
showInNavigation: true,
},
{ {
id: "generate-candidate", id: "generate-candidate",
@ -205,6 +186,32 @@ export const navigationConfig: NavigationConfig = {
showInNavigation: false, showInNavigation: false,
showInUserMenu: true, showInUserMenu: true,
}, },
{
id: "explore-jobs",
label: "Jobs",
path: "/candidate/jobs/:jobId?",
icon: <WorkIcon />,
component: (
<JobViewer />
),
userTypes: ["candidate", "guest", "employer"],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "profile",
},
{
id: "explore-resumes",
label: "Resumes",
path: "/candidate/resumes/:resumeId?",
icon: <EditDocumentIcon />,
component: (
<ResumeViewer />
),
userTypes: ["candidate", "guest", "employer"],
showInNavigation: false,
showInUserMenu: true,
userMenuGroup: "profile",
},
{ {
id: "candidate-docs", id: "candidate-docs",
label: "Content", label: "Content",
@ -291,33 +298,21 @@ export const navigationConfig: NavigationConfig = {
showInNavigation: false, showInNavigation: false,
children: [ children: [
{ {
id: "register", id: "verify-email",
label: "Register", label: "Verify Email",
path: "/login/register", path: "/login/verify-email",
component: ( component: <EmailVerificationPage />,
<BetaPage> userTypes: ["guest", "candidate", "employer"],
<CreateProfilePage />
</BetaPage>
),
userTypes: ["guest"],
showInNavigation: false, showInNavigation: false,
}, },
{ {
id: "login", id: "login",
label: "Login", label: "Login",
path: "/login/*", path: "/login/:tab?",
component: <LoginPage />, component: <LoginPage />,
userTypes: ["guest", "candidate", "employer"], userTypes: ["guest", "candidate", "employer"],
showInNavigation: false, showInNavigation: false,
}, },
{
id: "verify-email",
label: "Verify Email",
path: "/login/verify-email",
component: <EmailVerificationPage />,
userTypes: ["guest", "candidate", "employer"],
showInNavigation: false,
},
{ {
id: "logout-page", id: "logout-page",
label: "Logout", label: "Logout",

View File

@ -26,14 +26,14 @@ import { BackstoryPageProps } from 'components/BackstoryTab';
import { LoginForm } from "components/EmailVerificationComponents"; import { LoginForm } from "components/EmailVerificationComponents";
import { CandidateRegistrationForm } from "pages/candidate/RegistrationForms"; 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 { useAppState } from 'hooks/GlobalContext';
import * as Types from 'types/types'; import * as Types from 'types/types';
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => { const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setSnack } = useAppState(); const { setSnack } = useAppState();
const [tabValue, setTabValue] = useState(0); const [tabValue, setTabValue] = useState<string>('login');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const { guest, user, login, isLoading, error } = useAuth(); const { guest, user, login, isLoading, error } = useAuth();
@ -42,6 +42,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const showGuest: boolean = false; const showGuest: boolean = false;
const { tab } = useParams();
useEffect(() => { useEffect(() => {
if (!loading || !error) { if (!loading || !error) {
@ -60,7 +61,13 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
} }
}, [error, loading]); }, [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); setTabValue(newValue);
setSuccess(null); setSuccess(null);
}; };
@ -93,8 +100,8 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}> <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab icon={<Person />} label="Login" /> <Tab value="login" icon={<Person />} label="Login" />
<Tab icon={<PersonAdd />} label="Register" /> <Tab value="register" icon={<PersonAdd />} label="Register" />
</Tabs> </Tabs>
</Box> </Box>
@ -110,11 +117,11 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
</Alert> </Alert>
)} )}
{tabValue === 0 && ( {tabValue === "login" && (
<LoginForm /> <LoginForm />
)} )}
{tabValue === 1 && ( {tabValue === "register" && (
<CandidateRegistrationForm /> <CandidateRegistrationForm />
)} )}
</Paper> </Paper>

View File

@ -738,14 +738,14 @@ class ApiClient {
return handleApiResponse<{ success: boolean; statistics: any }>(response); 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<Types.Resume> {
const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, { const response = await fetch(`${this.baseUrl}/resumes/${resumeId}`, {
method: 'PUT', method: 'PUT',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(content) body: JSON.stringify(content)
}); });
return handleApiResponse<{ success: boolean; message: string; resume: Types.Resume }>(response); return this.handleApiResponseWithConversion<Types.Resume>(response, 'Resume');
} }
async getJob(id: string): Promise<Types.Job> { async getJob(id: string): Promise<Types.Job> {

View File

@ -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. 2. Format the resume in a clean, concise, and modern style that will pass ATS systems.
3. Include these sections: 3. Include these sections:
- Professional Summary (highlight strongest skills and experience level) - 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) - Professional Experience (focus on achievements and evidence of the skill)
4. Optional sections, to include only if evidence is present: 4. Optional sections, to include only if evidence is present:
- Education section - Education section
@ -165,7 +165,11 @@ ELSE:
Provide the resume in clean markdown format, ready for the candidate to use. 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 return system_prompt, prompt
async def generate_resume( async def generate_resume(

View File

@ -17,6 +17,12 @@ class _RedisManager:
def __init__(self): def __init__(self):
self.redis: Optional[redis.Redis] = None self.redis: Optional[redis.Redis] = None
self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379") 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._connection_pool: Optional[redis.ConnectionPool] = None
self._is_connected = False self._is_connected = False
@ -210,10 +216,10 @@ class RedisDatabase:
"""Save a resume for a user""" """Save a resume for a user"""
try: try:
# Generate resume_id if not present # Generate resume_id if not present
if 'resume_id' not in resume_data: if 'id' not in resume_data:
resume_data['resume_id'] = f"resume_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}" raise ValueError("Resume data must include an 'id' field")
resume_id = resume_data['resume_id'] resume_id = resume_data['id']
# Store the resume data # Store the resume data
key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}" key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
@ -236,9 +242,9 @@ class RedisDatabase:
data = await self.redis.get(key) data = await self.redis.get(key)
if data: if data:
resume_data = self._deserialize(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 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 return None
except Exception as e: except Exception as e:
logger.error(f"❌ Error retrieving resume {resume_id} for user {user_id}: {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) resume_ids = await self.redis.lrange(user_resumes_key, 0, -1)
if not resume_ids: 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 [] return []
# Get all resume data # Get all resume data
@ -275,7 +281,7 @@ class RedisDatabase:
# Sort by created_at timestamp (most recent first) # Sort by created_at timestamp (most recent first)
resumes.sort(key=lambda x: x.get("created_at", ""), reverse=True) 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 return resumes
except Exception as e: except Exception as e:
logger.error(f"❌ Error retrieving resumes for user {user_id}: {e}") logger.error(f"❌ Error retrieving resumes for user {user_id}: {e}")
@ -392,7 +398,7 @@ class RedisDatabase:
if query_lower in searchable_text: if query_lower in searchable_text:
matching_resumes.append(resume) 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 return matching_resumes
except Exception as e: except Exception as e:
logger.error(f"❌ Error searching resumes for user {user_id}: {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 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 return candidate_resumes
except Exception as e: except Exception as e:
logger.error(f"❌ Error retrieving resumes for candidate {candidate_id} by user {user_id}: {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 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 return job_resumes
except Exception as e: except Exception as e:
logger.error(f"❌ Error retrieving resumes for job {job_id} by user {user_id}: {e}") logger.error(f"❌ Error retrieving resumes for job {job_id} by user {user_id}: {e}")
@ -558,7 +564,7 @@ class RedisDatabase:
cache_key, cache_key,
json.dumps(assessment.model_dump(mode='json', by_alias=True), default=str) # Serialize with datetime handling 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: except Exception as e:
logger.error(f"❌ Error caching skill match: {e}") logger.error(f"❌ Error caching skill match: {e}")
@ -734,9 +740,9 @@ class RedisDatabase:
data = await self.redis.get(key) data = await self.redis.get(key)
if data: if data:
requirements_data = self._deserialize(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 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 return None
except Exception as e: except Exception as e:
logger.error(f"❌ Error retrieving job requirements for document {document_id}: {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 # Optional: Set expiration (e.g., 30 days) to prevent indefinite storage
# await self.redis.expire(key, 30 * 24 * 60 * 60) # 30 days # 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 return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error saving job requirements for document {document_id}: {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}" key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}"
result = await self.redis.delete(key) result = await self.redis.delete(key)
if result > 0: 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 True
return False return False
except Exception as e: except Exception as e:
@ -1010,7 +1016,7 @@ class RedisDatabase:
if candidate_email and await self.user_exists_by_email(candidate_email): if candidate_email and await self.user_exists_by_email(candidate_email):
await self.delete_user(candidate_email) await self.delete_user(candidate_email)
user_records_deleted += 1 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) # Delete by username (if different from email)
if (candidate_username and if (candidate_username and
@ -1018,7 +1024,7 @@ class RedisDatabase:
await self.user_exists_by_username(candidate_username)): await self.user_exists_by_username(candidate_username)):
await self.delete_user(candidate_username) await self.delete_user(candidate_username)
user_records_deleted += 1 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 # Delete user by ID if exists
user_by_id = await self.get_user_by_id(candidate_id) user_by_id = await self.get_user_by_id(candidate_id)
@ -1026,7 +1032,7 @@ class RedisDatabase:
key = f"user_by_id:{candidate_id}" key = f"user_by_id:{candidate_id}"
await self.redis.delete(key) await self.redis.delete(key)
user_records_deleted += 1 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 deletion_stats["user_records"] = user_records_deleted
logger.info(f"🗑️ Deleted {user_records_deleted} user records for candidate {candidate_id}") 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) auth_deleted = await self.delete_authentication(candidate_id)
if auth_deleted: if auth_deleted:
deletion_stats["auth_records"] = 1 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: except Exception as e:
logger.error(f"❌ Error deleting authentication records: {e}") logger.error(f"❌ Error deleting authentication records: {e}")
# 7. Revoke all refresh tokens for this user # 7. Revoke all refresh tokens for this user
try: try:
await self.revoke_all_user_tokens(candidate_id) 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: except Exception as e:
logger.error(f"❌ Error revoking refresh tokens: {e}") logger.error(f"❌ Error revoking refresh tokens: {e}")
@ -1068,7 +1074,7 @@ class RedisDatabase:
deletion_stats["security_logs"] = security_logs_deleted deletion_stats["security_logs"] = security_logs_deleted
if security_logs_deleted > 0: 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: except Exception as e:
logger.error(f"❌ Error deleting security logs: {e}") logger.error(f"❌ Error deleting security logs: {e}")
@ -1115,7 +1121,7 @@ class RedisDatabase:
break break
if tokens_deleted > 0: 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: except Exception as e:
logger.error(f"❌ Error deleting email verification tokens: {e}") logger.error(f"❌ Error deleting email verification tokens: {e}")
@ -1141,7 +1147,7 @@ class RedisDatabase:
break break
if tokens_deleted > 0: 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: except Exception as e:
logger.error(f"❌ Error deleting password reset tokens: {e}") logger.error(f"❌ Error deleting password reset tokens: {e}")
@ -1163,7 +1169,7 @@ class RedisDatabase:
break break
if mfa_codes_deleted > 0: 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: except Exception as e:
logger.error(f"❌ Error deleting MFA codes: {e}") logger.error(f"❌ Error deleting MFA codes: {e}")
@ -1417,7 +1423,7 @@ class RedisDatabase:
if current_time > expires_at: if current_time > expires_at:
await self.redis.delete(key) await self.redis.delete(key)
cleaned_count += 1 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: if cursor == 0:
break break
@ -1509,7 +1515,7 @@ class RedisDatabase:
json.dumps(verification_data, default=str) 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 return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error storing email verification token: {e}") logger.error(f"❌ Error storing email verification token: {e}")
@ -1568,7 +1574,7 @@ class RedisDatabase:
json.dumps(mfa_data, default=str) json.dumps(mfa_data, default=str)
) )
logger.debug(f"🔐 Stored MFA code for {email}") logger.info(f"🔐 Stored MFA code for {email}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error storing MFA code: {e}") logger.error(f"❌ Error storing MFA code: {e}")
@ -2093,7 +2099,7 @@ class RedisDatabase:
key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}" key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}"
pipe.delete(key) pipe.delete(key)
orphaned_count += 1 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: if orphaned_count > 0:
await pipe.execute() await pipe.execute()
@ -2128,7 +2134,7 @@ class RedisDatabase:
try: try:
key = f"auth:{user_id}" key = f"auth:{user_id}"
await self.redis.set(key, json.dumps(auth_data, default=str)) 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 return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error storing authentication record for {user_id}: {e}") logger.error(f"❌ Error storing authentication record for {user_id}: {e}")
@ -2151,7 +2157,7 @@ class RedisDatabase:
try: try:
key = f"auth:{user_id}" key = f"auth:{user_id}"
result = await self.redis.delete(key) 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 return result > 0
except Exception as e: except Exception as e:
logger.error(f"❌ Error deleting authentication record for {user_id}: {e}") logger.error(f"❌ Error deleting authentication record for {user_id}: {e}")
@ -2163,7 +2169,7 @@ class RedisDatabase:
try: try:
key = f"user_by_id:{user_id}" key = f"user_by_id:{user_id}"
await self.redis.set(key, json.dumps(user_data, default=str)) 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 return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error storing user by ID {user_id}: {e}") logger.error(f"❌ Error storing user by ID {user_id}: {e}")
@ -2213,10 +2219,10 @@ class RedisDatabase:
data = await self.redis.get(key) data = await self.redis.get(key)
if data: if data:
user_data = json.loads(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 return user_data
logger.debug(f"👤 No user found for {login}") logger.info(f"👤 No user found for {login}")
return None return None
except Exception as e: except Exception as e:
logger.error(f"❌ Error retrieving user {login}: {e}") logger.error(f"❌ Error retrieving user {login}: {e}")
@ -2233,7 +2239,7 @@ class RedisDatabase:
key = f"users:{login}" key = f"users:{login}"
await self.redis.set(key, json.dumps(user_data, default=str)) 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 return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error storing user {login}: {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()) ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds())
if ttl_seconds > 0: if ttl_seconds > 0:
await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str)) 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 return True
else: else:
logger.warning(f"⚠️ Attempted to store expired refresh token for user {user_id}") 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["is_revoked"] = True
token_data["revoked_at"] = datetime.now(timezone.utc).isoformat() token_data["revoked_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.set(key, json.dumps(token_data, default=str)) 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 True
return False return False
except Exception as e: except Exception as e:
@ -2340,7 +2346,7 @@ class RedisDatabase:
ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds()) ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds())
if ttl_seconds > 0: if ttl_seconds > 0:
await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str)) 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 return True
else: else:
logger.warning(f"⚠️ Attempted to store expired password reset token for {email}") 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"] = True
token_data["used_at"] = datetime.now(timezone.utc).isoformat() token_data["used_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.set(key, json.dumps(token_data, default=str)) 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 True
return False return False
except Exception as e: except Exception as e:
@ -2398,7 +2404,7 @@ class RedisDatabase:
# Set expiration for 30 days # Set expiration for 30 days
await self.redis.expire(key, 30 * 24 * 60 * 60) 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 return True
except Exception as e: except Exception as e:
logger.error(f"❌ Error logging security event for {user_id}: {e}") logger.error(f"❌ Error logging security event for {user_id}: {e}")
@ -2443,7 +2449,7 @@ class RedisDatabase:
json.dumps(guest_data) 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: except Exception as e:
logger.error(f"❌ Error storing guest {guest_id}: {e}") logger.error(f"❌ Error storing guest {guest_id}: {e}")
raise raise
@ -2458,7 +2464,7 @@ class RedisDatabase:
# Update last activity when accessed # Update last activity when accessed
guest_data["last_activity"] = datetime.now(UTC).isoformat() guest_data["last_activity"] = datetime.now(UTC).isoformat()
await self.set_guest(guest_id, guest_data) 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 return guest_data
# Fallback to backup storage # Fallback to backup storage
@ -2534,7 +2540,7 @@ class RedisDatabase:
created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
if current_time - created_at < timedelta(hours=1): if current_time - created_at < timedelta(hours=1):
preserved_count += 1 preserved_count += 1
logger.debug(f"🛡️ Preserving new guest: {guest_id}") logger.info(f"🛡️ Preserving new guest: {guest_id}")
continue continue
# Check last activity # Check last activity

View File

@ -19,7 +19,7 @@ class EmailService:
self.email_password = os.getenv("EMAIL_PASSWORD") self.email_password = os.getenv("EMAIL_PASSWORD")
self.from_name = os.getenv("FROM_NAME", "Backstory") self.from_name = os.getenv("FROM_NAME", "Backstory")
self.app_name = os.getenv("APP_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: 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") raise ValueError("SMTP configuration is not set in the environment variables")

View File

@ -118,24 +118,30 @@ def signal_handler(signum, frame):
elif signum == signal.SIGTERM and callable(prev_term): elif signum == signal.SIGTERM and callable(prev_term):
prev_term(signum, frame) prev_term(signum, frame)
# Global background task manager
background_task_manager: Optional[BackgroundTaskManager] = None
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # 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(f"📝 API Documentation available at: http://{defines.host}:{defines.port}{defines.api_prefix}/docs")
logger.info("🔗 API endpoints prefixed with: /api/1.0") 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: try:
# Initialize database # Initialize database
await db_manager.initialize() await db_manager.initialize()
entities.entity_manager.initialize(prometheus_collector, database=db_manager.get_database()) 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.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, 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 yield # Application is running
@ -146,10 +152,12 @@ async def lifespan(app: FastAPI):
finally: finally:
# Shutdown # Shutdown
logger.info("Application shutdown requested") logger.info("Application shutdown requested")
await db_manager.graceful_shutdown()
# Global background task manager # Stop background tasks first
background_task_manager: Optional[BackgroundTaskManager] = None if background_task_manager:
background_task_manager.stop()
await db_manager.graceful_shutdown()
app = FastAPI( app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
@ -162,6 +170,7 @@ app = FastAPI(
) )
ssl_enabled = os.getenv("SSL_ENABLED", "true").lower() == "true" ssl_enabled = os.getenv("SSL_ENABLED", "true").lower() == "true"
if ssl_enabled: if ssl_enabled:
allow_origins = ["https://battle-linux.ketrenos.com:3000", allow_origins = ["https://battle-linux.ketrenos.com:3000",
"https://backstory-beta.ketrenos.com"] "https://backstory-beta.ketrenos.com"]
@ -199,7 +208,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
content=json.dumps({"detail": str(exc)}), content=json.dumps({"detail": str(exc)}),
) )
# ============================ # ============================
# Authentication Utilities # Authentication Utilities
# ============================ # ============================
@ -3390,6 +3398,14 @@ async def get_user_resumes(
try: try:
resumes_data = await database.get_all_resumes_for_user(current_user.id) resumes_data = await database.get_all_resumes_for_user(current_user.id)
resumes : List[Resume] = [Resume.model_validate(data) for data in resumes_data] 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({ return create_success_response({
"resumes": resumes, "resumes": resumes,
"count": len(resumes) "count": len(resumes)
@ -3529,15 +3545,17 @@ async def update_resume(
"updated_at": datetime.now(UTC).isoformat() "updated_at": datetime.now(UTC).isoformat()
} }
updated_resume = await database.update_resume(current_user.id, resume_id, updates) updated_resume_data = await database.update_resume(current_user.id, resume_id, updates)
if not updated_resume: 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") 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, "success": True,
"message": f"Resume {resume_id} updated successfully", "message": f"Resume {resume_id} updated successfully",
"resume": updated_resume "resume": updated_resume
} })
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -5783,58 +5801,6 @@ async def api_info():
"health": f"{defines.api_prefix}/health" "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 # Task Monitoring and Metrics
# ============================ # ============================
@ -6113,12 +6079,14 @@ instrumentator.expose(app, endpoint=f"{defines.api_prefix}/metrics")
# Static File Serving # Static File Serving
# ============================ # ============================
# Serve static files (for frontend build) @app.get("/{path:path}")
# This should be last to not interfere with API routes async def serve_static(path: str, request: Request):
if os.path.exists(defines.static_content): full_path = os.path.join(defines.static_content, path)
app.mount("/", StaticFiles(directory=defines.static_content, html=True), name="static")
else: if os.path.exists(full_path) and os.path.isfile(full_path):
logger.info(f"⚠️ Static directory '{defines.static_content}' not found. Static file serving disabled.") return FileResponse(full_path)
return FileResponse(os.path.join(defines.static_content, "index.html"))
# Root endpoint when no static files # Root endpoint when no static files
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)

View File