Compare commits

...

10 Commits

22 changed files with 1161 additions and 587 deletions

View File

@ -231,7 +231,7 @@ RUN { \
echo ' while true; do'; \ echo ' while true; do'; \
echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \ echo ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
echo ' echo "Launching Backstory server..."'; \ echo ' echo "Launching Backstory server..."'; \
echo ' python src/server.py "${@}" || echo "Backstory server died."'; \ echo ' python src/backend/main.py "${@}" || echo "Backstory server died."'; \
echo ' echo "Sleeping for 3 seconds."'; \ echo ' echo "Sleeping for 3 seconds."'; \
echo ' else'; \ echo ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \ echo ' if [[ ${once} -eq 0 ]]; then' ; \

View File

@ -11,6 +11,7 @@ services:
- .env - .env
environment: environment:
- PRODUCTION=0 - PRODUCTION=0
- FRONTEND_URL=https://backstory-beta.ketrenos.com
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b} - MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- REDIS_DB=0 - REDIS_DB=0
@ -50,6 +51,7 @@ services:
- MODEL_NAME=${MODEL_NAME:-qwen2.5:7b} - MODEL_NAME=${MODEL_NAME:-qwen2.5:7b}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- REDIS_DB=1 - REDIS_DB=1
- SSL_ENABLED=false
devices: devices:
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
depends_on: depends_on:

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

@ -82,9 +82,12 @@ const BackstoryTextField = React.forwardRef<BackstoryTextFieldRef, BackstoryText
})); }));
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!onEnter) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); // Prevent newline event.preventDefault(); // Prevent newline
onEnter && onEnter(editValue); onEnter(editValue);
setEditValue(''); // Clear textarea setEditValue(''); // Clear textarea
} }
}; };

View File

@ -502,7 +502,6 @@ const JobCreator = (props: JobCreatorProps) => {
flexShrink: 0, /* Prevent shrinking */ flexShrink: 0, /* Prevent shrinking */
}, },
position: "relative", position: "relative",
border: "3px solid purple",
}}> }}>
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><JobInfo job={job} /></Scrollable> <Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><JobInfo job={job} /></Scrollable>
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><StyledMarkdown content={job.description} /></Scrollable> <Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><StyledMarkdown content={job.description} /></Scrollable>

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); navigate(`/candidate/jobs/${job.id}`);
} else {
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,87 +448,54 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</Box> </Box>
); );
if (isMobile) {
return (
<Box sx={{
height: '100%',
p: 0.5,
backgroundColor: 'background.default'
}}>
<JobList />
<Dialog
fullScreen
open={mobileDialogOpen}
onClose={handleMobileDialogClose}
TransitionComponent={Transition}
TransitionProps={{ timeout: 300 }}
>
<AppBar sx={{ position: 'relative', elevation: 1 }}>
<Toolbar variant="dense" sx={{ minHeight: 48 }}>
<IconButton
edge="start"
color="inherit"
onClick={handleMobileDialogClose}
aria-label="back"
size="small"
>
<ArrowBackIcon />
</IconButton>
<Box sx={{ ml: 1, flex: 1, minWidth: 0 }}>
<Typography
variant="h6"
component="div"
noWrap
sx={{ fontSize: '1rem' }}
>
{selectedJob?.title}
</Typography>
<Typography
variant="caption"
component="div"
sx={{ color: 'rgba(255, 255, 255, 0.7)' }}
noWrap
>
{selectedJob?.company}
</Typography>
</Box>
</Toolbar>
</AppBar>
<JobDetails inDialog />
</Dialog>
</Box>
);
}
return ( return (
<Box sx={{ <Box sx={{
display: 'flex',
height: '100%', height: '100%',
gap: 0.75, p: 0.5,
p: 0.75,
backgroundColor: 'background.default' backgroundColor: 'background.default'
}}> }}>
<JobList /> <JobList />
<Paper sx={{ <Dialog
width: '50%', fullScreen
display: 'flex', open={mobileDialogOpen}
flexDirection: 'column', onClose={handleMobileDialogClose}
elevation: 1 TransitionComponent={Transition}
}}> TransitionProps={{ timeout: 300 }}
<Box sx={{ >
p: 0.75, <AppBar sx={{ position: 'relative', elevation: 1 }}>
borderBottom: 1, <Toolbar variant="dense" sx={{ minHeight: 48 }}>
borderColor: 'divider', <IconButton
backgroundColor: 'background.paper' edge="start"
}}> color="inherit"
<Typography variant="h6" sx={{ fontSize: '1.1rem', fontWeight: 600 }}> onClick={handleMobileDialogClose}
Job Details aria-label="back"
</Typography> size="small"
</Box> >
<JobDetails /> <ArrowBackIcon />
</Paper> </IconButton>
<Box sx={{ ml: 1, flex: 1, minWidth: 0 }}>
<Typography
variant="h6"
component="div"
noWrap
sx={{ fontSize: '1rem' }}
>
{selectedJob?.title}
</Typography>
<Typography
variant="caption"
component="div"
sx={{ color: 'rgba(255, 255, 255, 0.7)' }}
noWrap
>
{selectedJob?.company}
</Typography>
</Box>
</Toolbar>
</AppBar>
<JobDetails inDialog />
</Dialog>
</Box> </Box>
); );
}; };

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) {
@ -113,8 +121,7 @@ const ResumeInfo: React.FC<ResumeInfoProps> = (props: ResumeInfoProps) => {
try { try {
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>
<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>
</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

@ -15,6 +15,8 @@ import {
Stepper, Stepper,
Stack, Stack,
ButtonProps, ButtonProps,
useMediaQuery,
useTheme,
} from '@mui/material'; } from '@mui/material';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@ -215,6 +217,8 @@ const HeroButton = (props: HeroButtonProps) => {
const HowItWorks: React.FC = () => { const HowItWorks: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const handleGetStarted = () => { const handleGetStarted = () => {
navigate('/job-analysis'); navigate('/job-analysis');
@ -289,7 +293,7 @@ const HowItWorks: React.FC = () => {
</Container> </Container>
</HeroSection> </HeroSection>
<HeroSection sx={{ display: "flex", position: "relative", overflow: "hidden", border: "2px solid orange" }}> <HeroSection sx={{ display: "flex", position: "relative", overflow: "hidden", border: "2px solid orange" }}>
<Beta sx={{ left: "-90px" }} /> <Beta adaptive={false} sx={{ left: "-90px" }} />
<Container sx={{ display: "flex", position: "relative" }}> <Container sx={{ display: "flex", position: "relative" }}>
<Box sx={{ display: "flex", flexDirection: "column", textAlign: 'center', maxWidth: 800, mx: 'auto', position: "relative" }}> <Box sx={{ display: "flex", flexDirection: "column", textAlign: 'center', maxWidth: 800, mx: 'auto', position: "relative" }}>
<Typography <Typography

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

@ -184,13 +184,32 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (props: BackstoryPageProp
// Handle profile image upload // Handle profile image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (!e.target.files || !e.target.files[0]) {
if (await apiClient.uploadCandidateProfile(e.target.files[0])) { return;
}
try {
console.log('Uploading profile image:', e.target.files[0]);
const success = await apiClient.uploadCandidateProfile(e.target.files[0]);
if (success) {
setProfileImage(URL.createObjectURL(e.target.files[0])); setProfileImage(URL.createObjectURL(e.target.files[0]));
candidate.profileImage = 'profile.' + e.target.files[0].name.replace(/^.*\./, ''); candidate.profileImage = 'profile.' + e.target.files[0].name.replace(/^.*\./, '');
console.log(`Set profile image to: ${candidate.profileImage}`); console.log(`Set profile image to: ${candidate.profileImage}`);
updateUserData(candidate); updateUserData(candidate);
} else {
setSnackbar({
open: true,
message: 'Failed to upload profile image. Please try again.',
severity: 'error'
});
} }
} catch (error) {
console.error('Error uploading profile image:', error);
setSnackbar({
open: true,
message: 'Failed to upload profile image. Please try again.',
severity: 'error'
});
} }
}; };

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

@ -50,25 +50,19 @@ emptyUser = {
"questions": [], "questions": [],
} }
generate_persona_system_prompt = """\ def generate_persona_system_prompt(persona: Dict[str, Any]) -> str:
return f"""\
You are a casting director for a movie. Your job is to provide information on ficticious personas for use in a screen play. You are a casting director for a movie. Your job is to provide information on ficticious personas for use in a screen play.
All response field MUST BE IN ENGLISH, regardless of ethnicity. All response field MUST BE IN ENGLISH, regardless of ethnicity.
You will be provided with defaults to use if not specified by the user: Use the following defaults, unless the user indicates they should be something else:
```json ```json
{ {json.dumps(persona, indent=4)}
"age": number,
"gender": "male" | "female",
"ethnicity": string,
"full_name": string,
"first_name": string,
"last_name": string,
}
``` ```
Additional information provided in the user message can override those defaults. Additional information provided in the user message can override those defaults. For example, if the user provides a name, you should use that name instead of the defaults.
You need to randomly assign an English username (can include numbers), a first name, last name, and a two English sentence description of You need to randomly assign an English username (can include numbers), a first name, last name, and a two English sentence description of
that individual's work given the demographics provided. that individual's work given the demographics provided.
@ -78,14 +72,17 @@ Provide only the JSON response, and match the field names EXACTLY.
Provide all information in English ONLY, with no other commentary: Provide all information in English ONLY, with no other commentary:
```json ```json
{ {{
"first_name": string, # First name of the person. If the user , use the first name provided or generated
"full_name": string, # Full name of the person, use the first and last name provided or generated
"last_name": string, # Last name of the person, use the last name provided or generated
"username": string, # A likely-to-be unique username, no more than 15 characters (can include numbers and letters but no special characters) "username": string, # A likely-to-be unique username, no more than 15 characters (can include numbers and letters but no special characters)
"description": string, # One to two sentence description of their job "description": string, # One to two sentence description of their job
"location": string, # In the location, provide ALL of: City, State/Region, and Country "location": string, # In the location, provide ALL of: City, State/Region, and Country
"phone": string, # Location appropriate phone number with area code "phone": string, # Location appropriate phone number with area code
"email": string, # primary email address "email": string, # primary email address
"title": string, # Job title of their current job "title": string, # Job title of their current job
} }}
``` ```
Make sure to provide a username and that the field name for the job description is "description". Make sure to provide a username and that the field name for the job description is "description".
@ -296,7 +293,6 @@ class GeneratePersona(Agent):
_agent_type: ClassVar[str] = agent_type # Add this for registration _agent_type: ClassVar[str] = agent_type # Add this for registration
agent_persist: bool = False agent_persist: bool = False
system_prompt: str = generate_persona_system_prompt
age: int = 0 age: int = 0
gender: str = "" gender: str = ""
username: str = "" username: str = ""
@ -325,16 +321,18 @@ class GeneratePersona(Agent):
original_prompt = prompt.strip() original_prompt = prompt.strip()
prompt = f"""\ persona = {
```json
{json.dumps({
"age": self.age, "age": self.age,
"gender": self.gender, "gender": self.gender,
"ethnicity": self.ethnicity, "ethnicity": self.ethnicity,
"full_name": self.full_name, "full_name": self.full_name,
"first_name": self.first_name, "first_name": self.first_name,
"last_name": self.last_name, "last_name": self.last_name,
})} }
prompt = f"""\
```json
{json.dumps(persona)}
``` ```
""" """
@ -346,13 +344,13 @@ Incorporate the following into the job description: {original_prompt}
# #
# Generate the persona # Generate the persona
# #
logger.info(f"🤖 Generating persona for {self.full_name}") logger.info(f"🤖 Generating persona...")
generating_message = None generating_message = None
async for generating_message in self.llm_one_shot( async for generating_message in self.llm_one_shot(
llm=llm, model=model, llm=llm, model=model,
session_id=session_id, session_id=session_id,
prompt=prompt, prompt=prompt,
system_prompt=generate_persona_system_prompt, system_prompt=generate_persona_system_prompt(persona=persona),
temperature=temperature, temperature=temperature,
): ):
if generating_message.status == ApiStatusType.ERROR: if generating_message.status == ApiStatusType.ERROR:

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

@ -1,28 +1,30 @@
""" """
Background tasks for guest cleanup and system maintenance Background tasks for guest cleanup and system maintenance
Fixed for event loop safety
""" """
import asyncio import asyncio
import schedule # type: ignore
import threading
import time
from datetime import datetime, timedelta, UTC from datetime import datetime, timedelta, UTC
from typing import Optional from typing import Optional, List, Dict, Any, Callable
from logger import logger from logger import logger
from database import DatabaseManager from database import DatabaseManager
class BackgroundTaskManager: class BackgroundTaskManager:
"""Manages background tasks for the application""" """Manages background tasks for the application using asyncio instead of threading"""
def __init__(self, database_manager: DatabaseManager): def __init__(self, database_manager: DatabaseManager):
self.database_manager = database_manager self.database_manager = database_manager
self.running = False self.running = False
self.tasks = [] self.tasks: List[asyncio.Task] = []
self.scheduler_thread: Optional[threading.Thread] = None self.main_loop: Optional[asyncio.AbstractEventLoop] = None
async def cleanup_inactive_guests(self, inactive_hours: int = 24): async def cleanup_inactive_guests(self, inactive_hours: int = 24):
"""Clean up inactive guest sessions""" """Clean up inactive guest sessions"""
try: try:
if self.database_manager.is_shutting_down:
logger.info("Skipping guest cleanup - application shutting down")
return 0
database = self.database_manager.get_database() database = self.database_manager.get_database()
cleaned_count = await database.cleanup_inactive_guests(inactive_hours) cleaned_count = await database.cleanup_inactive_guests(inactive_hours)
@ -37,6 +39,10 @@ class BackgroundTaskManager:
async def cleanup_expired_verification_tokens(self): async def cleanup_expired_verification_tokens(self):
"""Clean up expired email verification tokens""" """Clean up expired email verification tokens"""
try: try:
if self.database_manager.is_shutting_down:
logger.info("Skipping token cleanup - application shutting down")
return 0
database = self.database_manager.get_database() database = self.database_manager.get_database()
cleaned_count = await database.cleanup_expired_verification_tokens() cleaned_count = await database.cleanup_expired_verification_tokens()
@ -51,6 +57,10 @@ class BackgroundTaskManager:
async def update_guest_statistics(self): async def update_guest_statistics(self):
"""Update guest usage statistics""" """Update guest usage statistics"""
try: try:
if self.database_manager.is_shutting_down:
logger.info("Skipping stats update - application shutting down")
return {}
database = self.database_manager.get_database() database = self.database_manager.get_database()
stats = await database.get_guest_statistics() stats = await database.get_guest_statistics()
@ -68,8 +78,15 @@ class BackgroundTaskManager:
async def cleanup_old_rate_limit_data(self, days_old: int = 7): async def cleanup_old_rate_limit_data(self, days_old: int = 7):
"""Clean up old rate limiting data""" """Clean up old rate limiting data"""
try: try:
if self.database_manager.is_shutting_down:
logger.info("Skipping rate limit cleanup - application shutting down")
return 0
database = self.database_manager.get_database() database = self.database_manager.get_database()
redis = database.redis
# Get Redis client safely (using the event loop safe method)
from database import redis_manager
redis = await redis_manager.get_client()
# Clean up rate limit keys older than specified days # Clean up rate limit keys older than specified days
cutoff_time = datetime.now(UTC) - timedelta(days=days_old) cutoff_time = datetime.now(UTC) - timedelta(days=days_old)
@ -103,73 +120,206 @@ class BackgroundTaskManager:
logger.error(f"❌ Error cleaning up rate limit data: {e}") logger.error(f"❌ Error cleaning up rate limit data: {e}")
return 0 return 0
def schedule_periodic_tasks(self): async def cleanup_orphaned_data(self):
"""Schedule periodic background tasks with safer intervals""" """Clean up orphaned database records"""
# Guest cleanup - every 6 hours instead of every hour (less aggressive)
schedule.every(6).hours.do(self._run_async_task, self.cleanup_inactive_guests, 48) # 48 hours instead of 24
# Verification token cleanup - every 12 hours
schedule.every(12).hours.do(self._run_async_task, self.cleanup_expired_verification_tokens)
# Guest statistics update - every hour
schedule.every().hour.do(self._run_async_task, self.update_guest_statistics)
# Rate limit data cleanup - daily at 3 AM
schedule.every().day.at("03:00").do(self._run_async_task, self.cleanup_old_rate_limit_data, 7)
logger.info("📅 Background tasks scheduled with safer intervals")
def _run_async_task(self, coro_func, *args, **kwargs):
"""Run an async task in the background"""
try: try:
# Create new event loop for this thread if needed if self.database_manager.is_shutting_down:
try: return 0
loop = asyncio.get_event_loop()
except RuntimeError: database = self.database_manager.get_database()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Run the coroutine # Clean up orphaned job requirements
loop.run_until_complete(coro_func(*args, **kwargs)) orphaned_count = await database.cleanup_orphaned_job_requirements()
if orphaned_count > 0:
logger.info(f"🧹 Cleaned up {orphaned_count} orphaned job requirements")
return orphaned_count
except Exception as e: except Exception as e:
logger.error(f"❌ Error running background task {coro_func.__name__}: {e}") logger.error(f"❌ Error cleaning up orphaned data: {e}")
return 0
def _scheduler_worker(self): async def _run_periodic_task(self, name: str, task_func: Callable, interval_seconds: int, *args, **kwargs):
"""Worker thread for running scheduled tasks""" """Run a periodic task safely in the same event loop"""
logger.info(f"🔄 Starting periodic task: {name} (every {interval_seconds}s)")
while self.running: while self.running:
try: try:
schedule.run_pending() # Verify we're still in the correct event loop
time.sleep(60) # Check every minute current_loop = asyncio.get_running_loop()
if current_loop != self.main_loop:
logger.error(f"Task {name} detected event loop change! Stopping.")
break
# Run the task
await task_func(*args, **kwargs)
except asyncio.CancelledError:
logger.info(f"Periodic task {name} was cancelled")
break
except Exception as e: except Exception as e:
logger.error(f"❌ Error in scheduler worker: {e}") logger.error(f"❌ Error in periodic task {name}: {e}")
time.sleep(60) # Continue running despite errors
# Sleep with cancellation support
try:
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
logger.info(f"Periodic task {name} cancelled during sleep")
break
def start(self): async def start(self):
"""Start the background task manager""" """Start all background tasks in the current event loop"""
if self.running: if self.running:
logger.warning("⚠️ Background task manager already running") logger.warning("⚠️ Background task manager already running")
return return
# Store the current event loop
self.main_loop = asyncio.get_running_loop()
self.running = True self.running = True
self.schedule_periodic_tasks()
# Start scheduler thread # Define periodic tasks with their intervals (in seconds)
self.scheduler_thread = threading.Thread(target=self._scheduler_worker, daemon=True) periodic_tasks = [
self.scheduler_thread.start() # (name, function, interval_seconds, *args)
("guest_cleanup", self.cleanup_inactive_guests, 6 * 3600, 48), # Every 6 hours, cleanup 48h old
("token_cleanup", self.cleanup_expired_verification_tokens, 12 * 3600), # Every 12 hours
("guest_stats", self.update_guest_statistics, 3600), # Every hour
("rate_limit_cleanup", self.cleanup_old_rate_limit_data, 24 * 3600, 7), # Daily, cleanup 7 days old
("orphaned_cleanup", self.cleanup_orphaned_data, 6 * 3600), # Every 6 hours
]
logger.info("🚀 Background task manager started") # Create asyncio tasks for each periodic task
for name, func, interval, *args in periodic_tasks:
task = asyncio.create_task(
self._run_periodic_task(name, func, interval, *args),
name=f"background_{name}"
)
self.tasks.append(task)
logger.info(f"📅 Scheduled background task: {name}")
# Run initial cleanup tasks immediately (but don't wait for them)
asyncio.create_task(self._run_initial_cleanup(), name="initial_cleanup")
logger.info("🚀 Background task manager started with asyncio tasks")
def stop(self): async def _run_initial_cleanup(self):
"""Stop the background task manager""" """Run some cleanup tasks immediately on startup"""
try:
logger.info("🧹 Running initial cleanup tasks...")
# Clean up expired tokens immediately
await asyncio.sleep(5) # Give the app time to fully start
await self.cleanup_expired_verification_tokens()
# Clean up very old inactive guests (7 days old)
await self.cleanup_inactive_guests(inactive_hours=7 * 24)
# Update statistics
await self.update_guest_statistics()
logger.info("✅ Initial cleanup tasks completed")
except Exception as e:
logger.error(f"❌ Error in initial cleanup: {e}")
async def stop(self):
"""Stop all background tasks gracefully"""
logger.info("🛑 Stopping background task manager...")
self.running = False self.running = False
if self.scheduler_thread and self.scheduler_thread.is_alive(): # Cancel all running tasks
self.scheduler_thread.join(timeout=5) for task in self.tasks:
if not task.done():
task.cancel()
# Clear scheduled tasks # Wait for all tasks to complete with timeout
schedule.clear() if self.tasks:
try:
await asyncio.wait_for(
asyncio.gather(*self.tasks, return_exceptions=True),
timeout=30.0
)
logger.info("✅ All background tasks stopped gracefully")
except asyncio.TimeoutError:
logger.warning("⚠️ Some background tasks did not stop within timeout")
self.tasks.clear()
self.main_loop = None
logger.info("🛑 Background task manager stopped") logger.info("🛑 Background task manager stopped")
async def get_task_status(self) -> Dict[str, Any]:
"""Get status of all background tasks"""
status = {
"running": self.running,
"main_loop_id": id(self.main_loop) if self.main_loop else None,
"current_loop_id": None,
"task_count": len(self.tasks),
"tasks": []
}
try:
current_loop = asyncio.get_running_loop()
status["current_loop_id"] = id(current_loop)
status["loop_matches"] = (id(current_loop) == id(self.main_loop)) if self.main_loop else False
except RuntimeError:
status["current_loop_id"] = "no_running_loop"
for task in self.tasks:
task_info = {
"name": task.get_name(),
"done": task.done(),
"cancelled": task.cancelled(),
}
if task.done() and not task.cancelled():
try:
task.result() # This will raise an exception if the task failed
task_info["status"] = "completed"
except Exception as e:
task_info["status"] = "failed"
task_info["error"] = str(e)
elif task.cancelled():
task_info["status"] = "cancelled"
else:
task_info["status"] = "running"
status["tasks"].append(task_info)
return status
async def force_run_task(self, task_name: str) -> Any:
"""Manually trigger a specific background task"""
task_map = {
"guest_cleanup": self.cleanup_inactive_guests,
"token_cleanup": self.cleanup_expired_verification_tokens,
"guest_stats": self.update_guest_statistics,
"rate_limit_cleanup": self.cleanup_old_rate_limit_data,
"orphaned_cleanup": self.cleanup_orphaned_data,
}
if task_name not in task_map:
raise ValueError(f"Unknown task: {task_name}. Available: {list(task_map.keys())}")
logger.info(f"🔧 Manually running task: {task_name}")
result = await task_map[task_name]()
logger.info(f"✅ Manual task {task_name} completed")
return result
# Usage in your main application
async def setup_background_tasks(database_manager: DatabaseManager) -> BackgroundTaskManager:
"""Setup and start background tasks"""
task_manager = BackgroundTaskManager(database_manager)
await task_manager.start()
return task_manager
# For integration with your existing app startup
async def initialize_with_background_tasks(database_manager: DatabaseManager):
"""Initialize database and background tasks together"""
# Start background tasks
background_tasks = await setup_background_tasks(database_manager)
# Return both for your app to manage
return database_manager, background_tasks

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
@ -2760,4 +2766,5 @@ class DatabaseManager:
if self._shutdown_initiated: if self._shutdown_initiated:
raise RuntimeError("Application is shutting down") raise RuntimeError("Application is shutting down")
return self.db return self.db

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

File diff suppressed because it is too large Load Diff

View File