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 ' if [[ ! -e /opt/backstory/block-server ]]; then'; \
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 ' else'; \
echo ' if [[ ${once} -eq 0 ]]; then' ; \

View File

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

View File

@ -45,6 +45,7 @@
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
"react-spinners": "^0.15.0",
"react-to-print": "^3.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
@ -20206,6 +20207,14 @@
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-to-print": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.1.0.tgz",
"integrity": "sha512-hiJZVmJtaRm9EHoUTG2bordyeRxVSGy9oFVV7fSvzOWwctPp6jbz2R6NFkaokaTYBxC7wTM/fMV5eCXsNpEwsA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View File

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

View File

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

View File

@ -502,7 +502,6 @@ const JobCreator = (props: JobCreatorProps) => {
flexShrink: 0, /* Prevent shrinking */
},
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" }}><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>
</>}
{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 &&
<Box sx={{ display: "flex", flexDirection: "column", p: 1 }}>
<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 >
);
};

View File

@ -83,6 +83,7 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
if (job) {
setSelectedJob(job);
onSelect?.(job);
setMobileDialogOpen(true);
return;
}
}
@ -147,11 +148,8 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
if (isMobile) {
setMobileDialogOpen(true);
} else {
navigate(`/candidate/jobs/${job.id}`);
}
setMobileDialogOpen(true);
navigate(`/candidate/jobs/${job.id}`);
};
const handleMobileDialogClose = () => {
@ -181,11 +179,9 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
sx={{
display: 'flex',
flexDirection: 'column',
...(isMobile ? {
width: '100%',
boxShadow: 'none',
backgroundColor: 'transparent'
} : { width: '50%' })
backgroundColor: 'transparent'
}}
>
<Box sx={{
@ -452,87 +448,54 @@ const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
</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 (
<Box sx={{
display: 'flex',
height: '100%',
gap: 0.75,
p: 0.75,
p: 0.5,
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>
<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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -184,13 +184,32 @@ const CandidateProfile: React.FC<BackstoryPageProps> = (props: BackstoryPageProp
// Handle profile image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
if (await apiClient.uploadCandidateProfile(e.target.files[0])) {
if (!e.target.files || !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]));
candidate.profileImage = 'profile.' + e.target.files[0].name.replace(/^.*\./, '');
console.log(`Set profile image to: ${candidate.profileImage}`);
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);
}
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}`, {
method: 'PUT',
headers: this.defaultHeaders,
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> {

View File

@ -50,25 +50,19 @@ emptyUser = {
"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.
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
{
"age": number,
"gender": "male" | "female",
"ethnicity": string,
"full_name": string,
"first_name": string,
"last_name": string,
}
{json.dumps(persona, indent=4)}
```
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
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:
```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)
"description": string, # One to two sentence description of their job
"location": string, # In the location, provide ALL of: City, State/Region, and Country
"phone": string, # Location appropriate phone number with area code
"email": string, # primary email address
"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".
@ -296,7 +293,6 @@ class GeneratePersona(Agent):
_agent_type: ClassVar[str] = agent_type # Add this for registration
agent_persist: bool = False
system_prompt: str = generate_persona_system_prompt
age: int = 0
gender: str = ""
username: str = ""
@ -325,16 +321,18 @@ class GeneratePersona(Agent):
original_prompt = prompt.strip()
prompt = f"""\
```json
{json.dumps({
persona = {
"age": self.age,
"gender": self.gender,
"ethnicity": self.ethnicity,
"full_name": self.full_name,
"first_name": self.first_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
#
logger.info(f"🤖 Generating persona for {self.full_name}")
logger.info(f"🤖 Generating persona...")
generating_message = None
async for generating_message in self.llm_one_shot(
llm=llm, model=model,
session_id=session_id,
prompt=prompt,
system_prompt=generate_persona_system_prompt,
system_prompt=generate_persona_system_prompt(persona=persona),
temperature=temperature,
):
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.
3. Include these sections:
- Professional Summary (highlight strongest skills and experience level)
- Skills (organized by strength)
- Skills (organized by strength, under a single section). When listing skills, rephrase them so they are not identical to the original assessment.
- Professional Experience (focus on achievements and evidence of the skill)
4. Optional sections, to include only if evidence is present:
- Education section
@ -165,7 +165,11 @@ ELSE:
Provide the resume in clean markdown format, ready for the candidate to use.
"""
prompt = "Create a tailored professional resume that highlights candidate's skills and experience most relevant to the job requirements. Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no commentary before or after."
prompt = """\
Create a tailored professional resume that highlights candidate's skills and experience most relevant to the job requirements.
Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no commentary before or after.
"""
return system_prompt, prompt
async def generate_resume(

View File

@ -1,28 +1,30 @@
"""
Background tasks for guest cleanup and system maintenance
Fixed for event loop safety
"""
import asyncio
import schedule # type: ignore
import threading
import time
from datetime import datetime, timedelta, UTC
from typing import Optional
from typing import Optional, List, Dict, Any, Callable
from logger import logger
from database import DatabaseManager
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):
self.database_manager = database_manager
self.running = False
self.tasks = []
self.scheduler_thread: Optional[threading.Thread] = None
self.tasks: List[asyncio.Task] = []
self.main_loop: Optional[asyncio.AbstractEventLoop] = None
async def cleanup_inactive_guests(self, inactive_hours: int = 24):
"""Clean up inactive guest sessions"""
try:
if self.database_manager.is_shutting_down:
logger.info("Skipping guest cleanup - application shutting down")
return 0
database = self.database_manager.get_database()
cleaned_count = await database.cleanup_inactive_guests(inactive_hours)
@ -37,6 +39,10 @@ class BackgroundTaskManager:
async def cleanup_expired_verification_tokens(self):
"""Clean up expired email verification tokens"""
try:
if self.database_manager.is_shutting_down:
logger.info("Skipping token cleanup - application shutting down")
return 0
database = self.database_manager.get_database()
cleaned_count = await database.cleanup_expired_verification_tokens()
@ -51,6 +57,10 @@ class BackgroundTaskManager:
async def update_guest_statistics(self):
"""Update guest usage statistics"""
try:
if self.database_manager.is_shutting_down:
logger.info("Skipping stats update - application shutting down")
return {}
database = self.database_manager.get_database()
stats = await database.get_guest_statistics()
@ -68,8 +78,15 @@ class BackgroundTaskManager:
async def cleanup_old_rate_limit_data(self, days_old: int = 7):
"""Clean up old rate limiting data"""
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()
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
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}")
return 0
def schedule_periodic_tasks(self):
"""Schedule periodic background tasks with safer intervals"""
# 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"""
async def cleanup_orphaned_data(self):
"""Clean up orphaned database records"""
try:
# Create new event loop for this thread if needed
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if self.database_manager.is_shutting_down:
return 0
database = self.database_manager.get_database()
# Run the coroutine
loop.run_until_complete(coro_func(*args, **kwargs))
# Clean up orphaned job requirements
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:
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):
"""Worker thread for running scheduled tasks"""
async def _run_periodic_task(self, name: str, task_func: Callable, interval_seconds: int, *args, **kwargs):
"""Run a periodic task safely in the same event loop"""
logger.info(f"🔄 Starting periodic task: {name} (every {interval_seconds}s)")
while self.running:
try:
schedule.run_pending()
time.sleep(60) # Check every minute
# Verify we're still in the correct event loop
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:
logger.error(f"❌ Error in scheduler worker: {e}")
time.sleep(60)
logger.error(f"❌ Error in periodic task {name}: {e}")
# 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):
"""Start the background task manager"""
async def start(self):
"""Start all background tasks in the current event loop"""
if self.running:
logger.warning("⚠️ Background task manager already running")
return
# Store the current event loop
self.main_loop = asyncio.get_running_loop()
self.running = True
self.schedule_periodic_tasks()
# Start scheduler thread
self.scheduler_thread = threading.Thread(target=self._scheduler_worker, daemon=True)
self.scheduler_thread.start()
# Define periodic tasks with their intervals (in seconds)
periodic_tasks = [
# (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):
"""Stop the background task manager"""
async def _run_initial_cleanup(self):
"""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
if self.scheduler_thread and self.scheduler_thread.is_alive():
self.scheduler_thread.join(timeout=5)
# Cancel all running tasks
for task in self.tasks:
if not task.done():
task.cancel()
# Clear scheduled tasks
schedule.clear()
# Wait for all tasks to complete with timeout
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")
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):
self.redis: Optional[redis.Redis] = None
self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379")
self.redis_db = int(os.getenv("REDIS_DB", "0"))
# Append database to URL if not already present
if not self.redis_url.endswith(f"/{self.redis_db}"):
self.redis_url = f"{self.redis_url}/{self.redis_db}"
self._connection_pool: Optional[redis.ConnectionPool] = None
self._is_connected = False
@ -210,10 +216,10 @@ class RedisDatabase:
"""Save a resume for a user"""
try:
# Generate resume_id if not present
if 'resume_id' not in resume_data:
resume_data['resume_id'] = f"resume_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}"
if 'id' not in resume_data:
raise ValueError("Resume data must include an 'id' field")
resume_id = resume_data['resume_id']
resume_id = resume_data['id']
# Store the resume data
key = f"{self.KEY_PREFIXES['resumes']}{user_id}:{resume_id}"
@ -236,9 +242,9 @@ class RedisDatabase:
data = await self.redis.get(key)
if data:
resume_data = self._deserialize(data)
logger.debug(f"📄 Retrieved resume {resume_id} for user {user_id}")
logger.info(f"📄 Retrieved resume {resume_id} for user {user_id}")
return resume_data
logger.debug(f"📄 Resume {resume_id} not found for user {user_id}")
logger.info(f"📄 Resume {resume_id} not found for user {user_id}")
return None
except Exception as e:
logger.error(f"❌ Error retrieving resume {resume_id} for user {user_id}: {e}")
@ -252,7 +258,7 @@ class RedisDatabase:
resume_ids = await self.redis.lrange(user_resumes_key, 0, -1)
if not resume_ids:
logger.debug(f"📄 No resumes found for user {user_id}")
logger.info(f"📄 No resumes found for user {user_id}")
return []
# Get all resume data
@ -275,7 +281,7 @@ class RedisDatabase:
# Sort by created_at timestamp (most recent first)
resumes.sort(key=lambda x: x.get("created_at", ""), reverse=True)
logger.debug(f"📄 Retrieved {len(resumes)} resumes for user {user_id}")
logger.info(f"📄 Retrieved {len(resumes)} resumes for user {user_id}")
return resumes
except Exception as e:
logger.error(f"❌ Error retrieving resumes for user {user_id}: {e}")
@ -392,7 +398,7 @@ class RedisDatabase:
if query_lower in searchable_text:
matching_resumes.append(resume)
logger.debug(f"📄 Found {len(matching_resumes)} matching resumes for user {user_id}")
logger.info(f"📄 Found {len(matching_resumes)} matching resumes for user {user_id}")
return matching_resumes
except Exception as e:
logger.error(f"❌ Error searching resumes for user {user_id}: {e}")
@ -407,7 +413,7 @@ class RedisDatabase:
if resume.get("candidate_id") == candidate_id
]
logger.debug(f"📄 Found {len(candidate_resumes)} resumes for candidate {candidate_id} by user {user_id}")
logger.info(f"📄 Found {len(candidate_resumes)} resumes for candidate {candidate_id} by user {user_id}")
return candidate_resumes
except Exception as e:
logger.error(f"❌ Error retrieving resumes for candidate {candidate_id} by user {user_id}: {e}")
@ -422,7 +428,7 @@ class RedisDatabase:
if resume.get("job_id") == job_id
]
logger.debug(f"📄 Found {len(job_resumes)} resumes for job {job_id} by user {user_id}")
logger.info(f"📄 Found {len(job_resumes)} resumes for job {job_id} by user {user_id}")
return job_resumes
except Exception as e:
logger.error(f"❌ Error retrieving resumes for job {job_id} by user {user_id}: {e}")
@ -558,7 +564,7 @@ class RedisDatabase:
cache_key,
json.dumps(assessment.model_dump(mode='json', by_alias=True), default=str) # Serialize with datetime handling
)
logger.debug(f"💾 Skill match cached: {cache_key}")
logger.info(f"💾 Skill match cached: {cache_key}")
except Exception as e:
logger.error(f"❌ Error caching skill match: {e}")
@ -734,9 +740,9 @@ class RedisDatabase:
data = await self.redis.get(key)
if data:
requirements_data = self._deserialize(data)
logger.debug(f"📋 Retrieved cached job requirements for document {document_id}")
logger.info(f"📋 Retrieved cached job requirements for document {document_id}")
return requirements_data
logger.debug(f"📋 No cached job requirements found for document {document_id}")
logger.info(f"📋 No cached job requirements found for document {document_id}")
return None
except Exception as e:
logger.error(f"❌ Error retrieving job requirements for document {document_id}: {e}")
@ -759,7 +765,7 @@ class RedisDatabase:
# Optional: Set expiration (e.g., 30 days) to prevent indefinite storage
# await self.redis.expire(key, 30 * 24 * 60 * 60) # 30 days
logger.debug(f"📋 Saved job requirements for document {document_id}")
logger.info(f"📋 Saved job requirements for document {document_id}")
return True
except Exception as e:
logger.error(f"❌ Error saving job requirements for document {document_id}: {e}")
@ -771,7 +777,7 @@ class RedisDatabase:
key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}"
result = await self.redis.delete(key)
if result > 0:
logger.debug(f"📋 Deleted job requirements for document {document_id}")
logger.info(f"📋 Deleted job requirements for document {document_id}")
return True
return False
except Exception as e:
@ -1010,7 +1016,7 @@ class RedisDatabase:
if candidate_email and await self.user_exists_by_email(candidate_email):
await self.delete_user(candidate_email)
user_records_deleted += 1
logger.debug(f"🗑️ Deleted user record by email: {candidate_email}")
logger.info(f"🗑️ Deleted user record by email: {candidate_email}")
# Delete by username (if different from email)
if (candidate_username and
@ -1018,7 +1024,7 @@ class RedisDatabase:
await self.user_exists_by_username(candidate_username)):
await self.delete_user(candidate_username)
user_records_deleted += 1
logger.debug(f"🗑️ Deleted user record by username: {candidate_username}")
logger.info(f"🗑️ Deleted user record by username: {candidate_username}")
# Delete user by ID if exists
user_by_id = await self.get_user_by_id(candidate_id)
@ -1026,7 +1032,7 @@ class RedisDatabase:
key = f"user_by_id:{candidate_id}"
await self.redis.delete(key)
user_records_deleted += 1
logger.debug(f"🗑️ Deleted user record by ID: {candidate_id}")
logger.info(f"🗑️ Deleted user record by ID: {candidate_id}")
deletion_stats["user_records"] = user_records_deleted
logger.info(f"🗑️ Deleted {user_records_deleted} user records for candidate {candidate_id}")
@ -1038,14 +1044,14 @@ class RedisDatabase:
auth_deleted = await self.delete_authentication(candidate_id)
if auth_deleted:
deletion_stats["auth_records"] = 1
logger.debug(f"🗑️ Deleted authentication record for candidate {candidate_id}")
logger.info(f"🗑️ Deleted authentication record for candidate {candidate_id}")
except Exception as e:
logger.error(f"❌ Error deleting authentication records: {e}")
# 7. Revoke all refresh tokens for this user
try:
await self.revoke_all_user_tokens(candidate_id)
logger.debug(f"🗑️ Revoked all refresh tokens for candidate {candidate_id}")
logger.info(f"🗑️ Revoked all refresh tokens for candidate {candidate_id}")
except Exception as e:
logger.error(f"❌ Error revoking refresh tokens: {e}")
@ -1068,7 +1074,7 @@ class RedisDatabase:
deletion_stats["security_logs"] = security_logs_deleted
if security_logs_deleted > 0:
logger.debug(f"🗑️ Deleted {security_logs_deleted} security log entries for candidate {candidate_id}")
logger.info(f"🗑️ Deleted {security_logs_deleted} security log entries for candidate {candidate_id}")
except Exception as e:
logger.error(f"❌ Error deleting security logs: {e}")
@ -1115,7 +1121,7 @@ class RedisDatabase:
break
if tokens_deleted > 0:
logger.debug(f"🗑️ Deleted {tokens_deleted} email verification tokens for candidate {candidate_id}")
logger.info(f"🗑️ Deleted {tokens_deleted} email verification tokens for candidate {candidate_id}")
except Exception as e:
logger.error(f"❌ Error deleting email verification tokens: {e}")
@ -1141,7 +1147,7 @@ class RedisDatabase:
break
if tokens_deleted > 0:
logger.debug(f"🗑️ Deleted {tokens_deleted} password reset tokens for candidate {candidate_id}")
logger.info(f"🗑️ Deleted {tokens_deleted} password reset tokens for candidate {candidate_id}")
except Exception as e:
logger.error(f"❌ Error deleting password reset tokens: {e}")
@ -1163,7 +1169,7 @@ class RedisDatabase:
break
if mfa_codes_deleted > 0:
logger.debug(f"🗑️ Deleted {mfa_codes_deleted} MFA codes for candidate {candidate_id}")
logger.info(f"🗑️ Deleted {mfa_codes_deleted} MFA codes for candidate {candidate_id}")
except Exception as e:
logger.error(f"❌ Error deleting MFA codes: {e}")
@ -1417,7 +1423,7 @@ class RedisDatabase:
if current_time > expires_at:
await self.redis.delete(key)
cleaned_count += 1
logger.debug(f"🧹 Cleaned expired verification token for {verification_info.get('email')}")
logger.info(f"🧹 Cleaned expired verification token for {verification_info.get('email')}")
if cursor == 0:
break
@ -1509,7 +1515,7 @@ class RedisDatabase:
json.dumps(verification_data, default=str)
)
logger.debug(f"📧 Stored email verification token for {email}")
logger.info(f"📧 Stored email verification token for {email}")
return True
except Exception as e:
logger.error(f"❌ Error storing email verification token: {e}")
@ -1568,7 +1574,7 @@ class RedisDatabase:
json.dumps(mfa_data, default=str)
)
logger.debug(f"🔐 Stored MFA code for {email}")
logger.info(f"🔐 Stored MFA code for {email}")
return True
except Exception as e:
logger.error(f"❌ Error storing MFA code: {e}")
@ -2093,7 +2099,7 @@ class RedisDatabase:
key = f"{self.KEY_PREFIXES['job_requirements']}{document_id}"
pipe.delete(key)
orphaned_count += 1
logger.debug(f"📋 Queued orphaned job requirements for deletion: {document_id}")
logger.info(f"📋 Queued orphaned job requirements for deletion: {document_id}")
if orphaned_count > 0:
await pipe.execute()
@ -2128,7 +2134,7 @@ class RedisDatabase:
try:
key = f"auth:{user_id}"
await self.redis.set(key, json.dumps(auth_data, default=str))
logger.debug(f"🔐 Stored authentication record for user {user_id}")
logger.info(f"🔐 Stored authentication record for user {user_id}")
return True
except Exception as e:
logger.error(f"❌ Error storing authentication record for {user_id}: {e}")
@ -2151,7 +2157,7 @@ class RedisDatabase:
try:
key = f"auth:{user_id}"
result = await self.redis.delete(key)
logger.debug(f"🔐 Deleted authentication record for user {user_id}")
logger.info(f"🔐 Deleted authentication record for user {user_id}")
return result > 0
except Exception as e:
logger.error(f"❌ Error deleting authentication record for {user_id}: {e}")
@ -2163,7 +2169,7 @@ class RedisDatabase:
try:
key = f"user_by_id:{user_id}"
await self.redis.set(key, json.dumps(user_data, default=str))
logger.debug(f"👤 Stored user data by ID for {user_id}")
logger.info(f"👤 Stored user data by ID for {user_id}")
return True
except Exception as e:
logger.error(f"❌ Error storing user by ID {user_id}: {e}")
@ -2213,10 +2219,10 @@ class RedisDatabase:
data = await self.redis.get(key)
if data:
user_data = json.loads(data)
logger.debug(f"👤 Retrieved user data for {login}")
logger.info(f"👤 Retrieved user data for {login}")
return user_data
logger.debug(f"👤 No user found for {login}")
logger.info(f"👤 No user found for {login}")
return None
except Exception as e:
logger.error(f"❌ Error retrieving user {login}: {e}")
@ -2233,7 +2239,7 @@ class RedisDatabase:
key = f"users:{login}"
await self.redis.set(key, json.dumps(user_data, default=str))
logger.debug(f"👤 Stored user data for {login}")
logger.info(f"👤 Stored user data for {login}")
return True
except Exception as e:
logger.error(f"❌ Error storing user {login}: {e}")
@ -2257,7 +2263,7 @@ class RedisDatabase:
ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds())
if ttl_seconds > 0:
await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str))
logger.debug(f"🔐 Stored refresh token for user {user_id}")
logger.info(f"🔐 Stored refresh token for user {user_id}")
return True
else:
logger.warning(f"⚠️ Attempted to store expired refresh token for user {user_id}")
@ -2287,7 +2293,7 @@ class RedisDatabase:
token_data["is_revoked"] = True
token_data["revoked_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.set(key, json.dumps(token_data, default=str))
logger.debug(f"🔐 Revoked refresh token")
logger.info(f"🔐 Revoked refresh token")
return True
return False
except Exception as e:
@ -2340,7 +2346,7 @@ class RedisDatabase:
ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds())
if ttl_seconds > 0:
await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str))
logger.debug(f"🔐 Stored password reset token for {email}")
logger.info(f"🔐 Stored password reset token for {email}")
return True
else:
logger.warning(f"⚠️ Attempted to store expired password reset token for {email}")
@ -2370,7 +2376,7 @@ class RedisDatabase:
token_data["used"] = True
token_data["used_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.set(key, json.dumps(token_data, default=str))
logger.debug(f"🔐 Marked password reset token as used")
logger.info(f"🔐 Marked password reset token as used")
return True
return False
except Exception as e:
@ -2398,7 +2404,7 @@ class RedisDatabase:
# Set expiration for 30 days
await self.redis.expire(key, 30 * 24 * 60 * 60)
logger.debug(f"🔒 Logged security event {event_type} for user {user_id}")
logger.info(f"🔒 Logged security event {event_type} for user {user_id}")
return True
except Exception as e:
logger.error(f"❌ Error logging security event for {user_id}: {e}")
@ -2443,7 +2449,7 @@ class RedisDatabase:
json.dumps(guest_data)
)
logger.debug(f"💾 Guest stored with backup: {guest_id}")
logger.info(f"💾 Guest stored with backup: {guest_id}")
except Exception as e:
logger.error(f"❌ Error storing guest {guest_id}: {e}")
raise
@ -2458,7 +2464,7 @@ class RedisDatabase:
# Update last activity when accessed
guest_data["last_activity"] = datetime.now(UTC).isoformat()
await self.set_guest(guest_id, guest_data)
logger.debug(f"🔍 Guest found in primary storage: {guest_id}")
logger.info(f"🔍 Guest found in primary storage: {guest_id}")
return guest_data
# Fallback to backup storage
@ -2534,7 +2540,7 @@ class RedisDatabase:
created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
if current_time - created_at < timedelta(hours=1):
preserved_count += 1
logger.debug(f"🛡️ Preserving new guest: {guest_id}")
logger.info(f"🛡️ Preserving new guest: {guest_id}")
continue
# Check last activity
@ -2760,4 +2766,5 @@ class DatabaseManager:
if self._shutdown_initiated:
raise RuntimeError("Application is shutting down")
return self.db

View File

@ -19,7 +19,7 @@ class EmailService:
self.email_password = os.getenv("EMAIL_PASSWORD")
self.from_name = os.getenv("FROM_NAME", "Backstory")
self.app_name = os.getenv("APP_NAME", "Backstory")
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory.ketrenos.com")
if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None:
raise ValueError("SMTP configuration is not set in the environment variables")

File diff suppressed because it is too large Load Diff

View File