Compare commits

..

2 Commits

23 changed files with 4464 additions and 924 deletions

View File

@ -27,6 +27,7 @@
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@uiw/react-json-view": "^2.0.0-alpha.31", "@uiw/react-json-view": "^2.0.0-alpha.31",
"@uiw/react-markdown-editor": "^6.1.4", "@uiw/react-markdown-editor": "^6.1.4",
"country-state-city": "^3.2.1",
"jsonrepair": "^3.12.0", "jsonrepair": "^3.12.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
@ -8516,6 +8517,11 @@
"integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==",
"peer": true "peer": true
}, },
"node_modules/country-state-city": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz",
"integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA=="
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",

View File

@ -22,6 +22,7 @@
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@uiw/react-json-view": "^2.0.0-alpha.31", "@uiw/react-json-view": "^2.0.0-alpha.31",
"@uiw/react-markdown-editor": "^6.1.4", "@uiw/react-markdown-editor": "^6.1.4",
"country-state-city": "^3.2.1",
"jsonrepair": "^3.12.0", "jsonrepair": "^3.12.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",

View File

@ -110,12 +110,16 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
{ candidate.location && <Typography variant="body2" sx={{ mb: 1 }}> {candidate.location &&
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country} <Typography variant="body2" sx={{ mb: 1 }}>
</Typography> } <strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
{ candidate.email && <Typography variant="body2" sx={{ mb: 1 }}> </Typography>
<strong>Email:</strong> {candidate.email} }
</Typography> } {candidate.email &&
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
}
{ candidate.phone && <Typography variant="body2"> { candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone} <strong>Phone:</strong> {candidate.phone}
</Typography> } </Typography> }

View File

@ -5,6 +5,7 @@ import { Quote } from 'components/Quote';
import { BackstoryElementProps } from 'components/BackstoryTab'; import { BackstoryElementProps } from 'components/BackstoryTab';
import { useUser } from 'hooks/useUser'; import { useUser } from 'hooks/useUser';
import { Candidate, ChatSession } from 'types/types'; import { Candidate, ChatSession } from 'types/types';
import { useSecureAuth } from 'hooks/useSecureAuth';
interface GenerateImageProps extends BackstoryElementProps { interface GenerateImageProps extends BackstoryElementProps {
prompt: string; prompt: string;
@ -12,7 +13,7 @@ interface GenerateImageProps extends BackstoryElementProps {
}; };
const GenerateImage = (props: GenerateImageProps) => { const GenerateImage = (props: GenerateImageProps) => {
const { user } = useUser(); const { user } = useSecureAuth();
const { setSnack, chatSession, prompt } = props; const { setSnack, chatSession, prompt } = props;
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');

View File

@ -0,0 +1,361 @@
import React, { useState, useEffect } from 'react';
import {
Box,
TextField,
Autocomplete,
Typography,
Grid,
Chip,
FormControlLabel,
Checkbox
} from '@mui/material';
import { LocationOn, Public, Home } from '@mui/icons-material';
import { Country, State, City } from 'country-state-city';
import type { ICountry, IState, ICity } from 'country-state-city';
// Import from your types file - adjust path as needed
import type { Location } from 'types/types';
interface LocationInputProps {
value?: Partial<Location>;
onChange: (location: Partial<Location>) => void;
error?: boolean;
helperText?: string;
required?: boolean;
disabled?: boolean;
showCity?: boolean;
}
const LocationInput: React.FC<LocationInputProps> = ({
value = {},
onChange,
error = false,
helperText,
required = false,
disabled = false,
showCity = false
}) => {
// Get all countries from the library
const allCountries = Country.getAllCountries();
const [selectedCountry, setSelectedCountry] = useState<ICountry | null>(
value.country ? allCountries.find(c => c.name === value.country) || null : null
);
const [selectedState, setSelectedState] = useState<IState | null>(null);
const [selectedCity, setSelectedCity] = useState<ICity | null>(null);
const [isRemote, setIsRemote] = useState<boolean>(value.remote || false);
// Get states for selected country
const availableStates = selectedCountry ? State.getStatesOfCountry(selectedCountry.isoCode) : [];
// Get cities for selected state
const availableCities = selectedCountry && selectedState
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
: [];
// Initialize state and city from value prop
useEffect(() => {
if (selectedCountry && value.state) {
const stateMatch = availableStates.find(s => s.name === value.state);
setSelectedState(stateMatch || null);
}
}, [selectedCountry, value.state, availableStates]);
useEffect(() => {
if (selectedCountry && selectedState && value.city && showCity) {
const cityMatch = availableCities.find(c => c.name === value.city);
setSelectedCity(cityMatch || null);
}
}, [selectedCountry, selectedState, value.city, availableCities, showCity]);
// Update parent component when values change
useEffect(() => {
const newLocation: Partial<Location> = {};
if (selectedCountry) {
newLocation.country = selectedCountry.name;
}
if (selectedState) {
newLocation.state = selectedState.name;
}
if (selectedCity && showCity) {
newLocation.city = selectedCity.name;
}
if (isRemote) {
newLocation.remote = isRemote;
}
// Only call onChange if there's actual data or if clearing
if (Object.keys(newLocation).length > 0 || (value.country || value.state || value.city)) {
onChange(newLocation);
}
}, [selectedCountry, selectedState, selectedCity, isRemote, onChange, value.country, value.state, value.city, showCity]);
const handleCountryChange = (event: any, newValue: ICountry | null) => {
setSelectedCountry(newValue);
// Clear state and city when country changes
setSelectedState(null);
setSelectedCity(null);
};
const handleStateChange = (event: any, newValue: IState | null) => {
setSelectedState(newValue);
// Clear city when state changes
setSelectedCity(null);
};
const handleCityChange = (event: any, newValue: ICity | null) => {
setSelectedCity(newValue);
};
const handleRemoteToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsRemote(event.target.checked);
};
return (
<Box>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LocationOn color="primary" />
Location {required && <span style={{ color: 'red' }}>*</span>}
</Typography>
<Grid container spacing={2}>
{/* Country Selection */}
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
<Autocomplete
value={selectedCountry}
onChange={handleCountryChange}
options={allCountries}
getOptionLabel={(option) => option.name}
disabled={disabled}
renderInput={(params) => (
<TextField
{...params}
label="Country"
variant="outlined"
required={required}
error={error && required && !selectedCountry}
helperText={error && required && !selectedCountry ? 'Country is required' : helperText}
InputProps={{
...params.InputProps,
startAdornment: <Public sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
)}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.isoCode}>
<img
loading="lazy"
width="20"
src={`https://flagcdn.com/w20/${option.isoCode.toLowerCase()}.png`}
srcSet={`https://flagcdn.com/w40/${option.isoCode.toLowerCase()}.png 2x`}
alt=""
style={{ marginRight: 8 }}
/>
{option.name}
</Box>
)}
/>
</Grid>
{/* State/Region Selection */}
{selectedCountry && (
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
<Autocomplete
value={selectedState}
onChange={handleStateChange}
options={availableStates}
getOptionLabel={(option) => option.name}
disabled={disabled || availableStates.length === 0}
renderInput={(params) => (
<TextField
{...params}
label="State/Region"
variant="outlined"
placeholder={availableStates.length > 0 ? "Select state/region" : "No states available"}
/>
)}
/>
</Grid>
)}
{/* City Selection */}
{showCity && selectedCountry && selectedState && (
<Grid size={{ xs: 12, sm: 4 }}>
<Autocomplete
value={selectedCity}
onChange={handleCityChange}
options={availableCities}
getOptionLabel={(option) => option.name}
disabled={disabled || availableCities.length === 0}
renderInput={(params) => (
<TextField
{...params}
label="City"
variant="outlined"
placeholder={availableCities.length > 0 ? "Select city" : "No cities available"}
InputProps={{
...params.InputProps,
startAdornment: <Home sx={{ mr: 1, color: 'text.secondary' }} />
}}
/>
)}
/>
</Grid>
)}
{/* Remote Work Option */}
<Grid size={{ xs: 12 }}>
<FormControlLabel
control={
<Checkbox
checked={isRemote}
onChange={handleRemoteToggle}
disabled={disabled}
color="primary"
/>
}
label="Open to remote work"
/>
</Grid>
{/* Location Summary Chips */}
{(selectedCountry || selectedState || selectedCity || isRemote) && (
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{selectedCountry && (
<Chip
icon={<Public />}
label={selectedCountry.name}
variant="outlined"
color="primary"
size="small"
/>
)}
{selectedState && (
<Chip
label={selectedState.name}
variant="outlined"
color="secondary"
size="small"
/>
)}
{selectedCity && showCity && (
<Chip
icon={<Home />}
label={selectedCity.name}
variant="outlined"
color="default"
size="small"
/>
)}
{isRemote && (
<Chip
label="Remote"
variant="filled"
color="success"
size="small"
/>
)}
</Box>
</Grid>
)}
</Grid>
</Box>
);
};
// Demo component to show usage with real data
const LocationInputDemo: React.FC = () => {
const [location, setLocation] = useState<Partial<Location>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const handleLocationChange = (newLocation: Partial<Location>) => {
setLocation(newLocation);
console.log('Location updated:', newLocation);
};
// Show some stats about the data
const totalCountries = Country.getAllCountries().length;
const usStates = State.getStatesOfCountry('US').length;
const canadaProvinces = State.getStatesOfCountry('CA').length;
return (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto' }}>
<Typography variant="h4" gutterBottom align="center" color="primary">
Location Input with Real Data
</Typography>
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 3 }}>
Using country-state-city library with {totalCountries} countries,
{usStates} US states, {canadaProvinces} Canadian provinces, and thousands of cities
</Typography>
<Grid container spacing={4}>
<Grid size={{ xs: 12 }}>
<Typography variant="h5" gutterBottom>
Basic Location Input
</Typography>
<LocationInput
value={location}
onChange={handleLocationChange}
required
/>
</Grid>
<Grid size={{ xs: 12 }}>
<FormControlLabel
control={
<Checkbox
checked={showAdvanced}
onChange={(e) => setShowAdvanced(e.target.checked)}
color="primary"
/>
}
label="Show city field"
/>
</Grid>
{showAdvanced && (
<Grid size={{ xs: 12 }}>
<Typography variant="h5" gutterBottom>
Advanced Location Input (with City)
</Typography>
<LocationInput
value={location}
onChange={handleLocationChange}
showCity
helperText="Include your city for more specific job matches"
/>
</Grid>
)}
<Grid size={{ xs: 12 }}>
<Typography variant="h6" gutterBottom>
Current Location Data:
</Typography>
<Box component="pre" sx={{
bgcolor: 'grey.100',
p: 2,
borderRadius: 1,
overflow: 'auto',
fontSize: '0.875rem'
}}>
{JSON.stringify(location, null, 2)}
</Box>
</Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="body2" color="text.secondary">
💡 This component uses the country-state-city library which is regularly updated
and includes ISO codes, flags, and comprehensive location data.
</Typography>
</Grid>
</Grid>
</Box>
);
};
export { LocationInput };

View File

@ -3,6 +3,7 @@ import { Outlet, useLocation, Routes } from "react-router-dom";
import { Box, Container, Paper } from '@mui/material'; import { Box, Container, Paper } from '@mui/material';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import ChatIcon from '@mui/icons-material/Chat'; import ChatIcon from '@mui/icons-material/Chat';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from '@mui/icons-material/Description';
import BarChartIcon from '@mui/icons-material/BarChart'; import BarChartIcon from '@mui/icons-material/BarChart';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
@ -19,6 +20,7 @@ import { useUser } from 'hooks/useUser';
import { User } from 'types/types'; import { User } from 'types/types';
import { getBackstoryDynamicRoutes } from 'components/layout/BackstoryRoutes'; import { getBackstoryDynamicRoutes } from 'components/layout/BackstoryRoutes';
import { LoadingComponent } from "components/LoadingComponent"; import { LoadingComponent } from "components/LoadingComponent";
import { useSecureAuth } from 'hooks/useSecureAuth';
type NavigationLinkType = { type NavigationLinkType = {
name: string; name: string;
@ -29,41 +31,44 @@ type NavigationLinkType = {
const DefaultNavItems: NavigationLinkType[] = [ const DefaultNavItems: NavigationLinkType[] = [
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> }, { name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
{ name: 'Docs', path: '/docs', icon: <InfoIcon /> }, { name: 'Docs', path: '/docs', icon: <InfoIcon /> },
// { name: 'How It Works', path: '/how-it-works', icon: <InfoIcon/> }, // { name: 'How It Works', path: '/how-it-works', icon: <InfoIcon/> },
// { name: 'For Candidates', path: '/for-candidates', icon: <PersonIcon/> }, // { name: 'For Candidates', path: '/for-candidates', icon: <PersonIcon/> },
// { name: 'For Employers', path: '/for-employers', icon: <BusinessIcon/> }, // { name: 'For Employers', path: '/for-employers', icon: <BusinessIcon/> },
// { name: 'Pricing', path: '/pricing', icon: <AttachMoneyIcon/> }, // { name: 'Pricing', path: '/pricing', icon: <AttachMoneyIcon/> },
];
const ViewerNavItems: NavigationLinkType[] = [
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
]; ];
const CandidateNavItems : NavigationLinkType[]= [ const CandidateNavItems : NavigationLinkType[]= [
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> }, { name: 'Chat', path: '/chat', icon: <ChatIcon /> },
// { name: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon /> }, // { name: 'Job Analysis', path: '/candidate/job-analysis', icon: <WorkIcon /> },
{ name: 'Resume Builder', path: '/resume-builder', icon: <WorkIcon /> }, { name: 'Resume Builder', path: '/candidate/resume-builder', icon: <WorkIcon /> },
// { name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: <WorkIcon /> }, // { name: 'Knowledge Explorer', path: '/candidate/knowledge-explorer', icon: <WorkIcon /> },
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> }, { name: 'Dashboard', icon: <DashboardIcon />, path: '/candidate/dashboard' },
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' }, // { name: 'Profile', icon: <PersonIcon />, path: '/candidate/profile' },
// { name: 'Profile', icon: <PersonIcon />, path: '/profile' }, // { name: 'Backstory', icon: <HistoryIcon />, path: '/candidate/backstory' },
// { name: 'Backstory', icon: <HistoryIcon />, path: '/backstory' }, // { name: 'Resumes', icon: <DescriptionIcon />, path: '/candidate/resumes' },
// { name: 'Resumes', icon: <DescriptionIcon />, path: '/resumes' }, // { name: 'Q&A Setup', icon: <QuestionAnswerIcon />, path: '/candidate/qa-setup' },
// { name: 'Q&A Setup', icon: <QuestionAnswerIcon />, path: '/qa-setup' }, // { name: 'Analytics', icon: <BarChartIcon />, path: '/candidate/analytics' },
// { name: 'Analytics', icon: <BarChartIcon />, path: '/analytics' }, // { name: 'Settings', icon: <SettingsIcon />, path: '/candidate/settings' },
// { name: 'Settings', icon: <SettingsIcon />, path: '/settings' },
]; ];
const EmployerNavItems: NavigationLinkType[] = [ const EmployerNavItems: NavigationLinkType[] = [
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> }, { name: 'Chat', path: '/chat', icon: <ChatIcon /> },
{ name: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon /> }, { name: 'Job Analysis', path: '/employer/job-analysis', icon: <WorkIcon /> },
{ name: 'Resume Builder', path: '/resume-builder', icon: <WorkIcon /> }, { name: 'Resume Builder', path: '/employer/resume-builder', icon: <WorkIcon /> },
{ name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: <WorkIcon /> }, { name: 'Knowledge Explorer', path: '/employer/knowledge-explorer', icon: <WorkIcon /> },
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> }, { name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' }, // { name: 'Dashboard', icon: <DashboardIcon />, path: '/employer/dashboard' },
// { name: 'Search', icon: <SearchIcon />, path: '/search' }, // { name: 'Search', icon: <SearchIcon />, path: '/employer/search' },
// { name: 'Saved', icon: <BookmarkIcon />, path: '/saved' }, // { name: 'Saved', icon: <BookmarkIcon />, path: '/employer/saved' },
// { name: 'Jobs', icon: <WorkIcon />, path: '/jobs' }, // { name: 'Jobs', icon: <WorkIcon />, path: '/employer/jobs' },
// { name: 'Company', icon: <BusinessIcon />, path: '/company' }, // { name: 'Company', icon: <BusinessIcon />, path: '/employer/company' },
// { name: 'Analytics', icon: <BarChartIcon />, path: '/analytics' }, // { name: 'Analytics', icon: <BarChartIcon />, path: '/employer/analytics' },
// { name: 'Settings', icon: <SettingsIcon />, path: '/settings' }, // { name: 'Settings', icon: <SettingsIcon />, path: '/employer/settings' },
]; ];
// Navigation links based on user type // Navigation links based on user type
@ -73,10 +78,12 @@ const getNavigationLinks = (user: User | null): NavigationLinkType[] => {
} }
switch (user.userType) { switch (user.userType) {
case 'viewer':
return DefaultNavItems.concat(ViewerNavItems);
case 'candidate': case 'candidate':
return CandidateNavItems; return DefaultNavItems.concat(CandidateNavItems);
case 'employer': case 'employer':
return EmployerNavItems; return DefaultNavItems.concat(EmployerNavItems);
default: default:
return DefaultNavItems; return DefaultNavItems;
} }
@ -130,7 +137,8 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
const { setSnack, page, chatRef, snackRef, submitQuery } = props; const { setSnack, page, chatRef, snackRef, submitQuery } = props;
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, guest, candidate } = useUser(); const { guest, candidate } = useUser();
const { user } = useSecureAuth();
const [navigationLinks, setNavigationLinks] = useState<NavigationLinkType[]>([]); const [navigationLinks, setNavigationLinks] = useState<NavigationLinkType[]>([]);
useEffect(() => { useEffect(() => {

View File

@ -18,6 +18,7 @@ import { JobAnalysisPage } from 'pages/JobAnalysisPage';
import { GenerateCandidate } from "pages/GenerateCandidate"; import { GenerateCandidate } from "pages/GenerateCandidate";
import { ControlsPage } from 'pages/ControlsPage'; import { ControlsPage } from 'pages/ControlsPage';
import { LoginPage } from "pages/LoginPage"; import { LoginPage } from "pages/LoginPage";
import { CandidateDashboardPage } from "pages/CandidateDashboardPage"
const ProfilePage = () => (<BetaPage><Typography variant="h4">Profile</Typography></BetaPage>); const ProfilePage = () => (<BetaPage><Typography variant="h4">Profile</Typography></BetaPage>);
const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>); const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>);
@ -31,41 +32,44 @@ const LogoutPage = () => (<BetaPage><Typography variant="h4">Logout page...</Typ
// const DashboardPage = () => (<BetaPage><Typography variant="h4">Dashboard</Typography></BetaPage>); // const DashboardPage = () => (<BetaPage><Typography variant="h4">Dashboard</Typography></BetaPage>);
// const AnalyticsPage = () => (<BetaPage><Typography variant="h4">Analytics</Typography></BetaPage>); // const AnalyticsPage = () => (<BetaPage><Typography variant="h4">Analytics</Typography></BetaPage>);
// const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typography></BetaPage>); // const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typography></BetaPage>);
interface BackstoryDynamicRoutesProps extends BackstoryPageProps { interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
chatRef: Ref<ConversationHandle>; chatRef: Ref<ConversationHandle>;
user?: User | null; user?: User | null;
} }
const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => { const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => {
const { user, setSnack, submitQuery, chatRef } = props; const { user, setSnack, submitQuery, chatRef } = props;
const backstoryProps = {
setSnack, submitQuery
};
let index=0 let index=0
const routes = [ const routes = [
<Route key={`${index++}`} path="/" element={<HomePage/>} />, <Route key={`${index++}`} path="/" element={<HomePage/>} />,
<Route key={`${index++}`} path="/chat" element={<CandidateChatPage ref={chatRef} setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/chat" element={<CandidateChatPage ref={chatRef} {...backstoryProps} />} />,
<Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/docs" element={<DocsPage {...backstoryProps} />} />,
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage {...backstoryProps} />} />,
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage {...backstoryProps} />} />,
<Route key={`${index++}`} path="/knowledge-explorer" element={<VectorVisualizerPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/knowledge-explorer" element={<VectorVisualizerPage {...backstoryProps} />} />,
<Route key={`${index++}`} path="/find-a-candidate" element={<CandidateListingPage {...{ setSnack, submitQuery }} />} />, <Route key={`${index++}`} path="/find-a-candidate" element={<CandidateListingPage {...backstoryProps} />} />,
<Route key={`${index++}`} path="/job-analysis" element={<JobAnalysisPage />} />, <Route key={`${index++}`} path="/job-analysis" element={<JobAnalysisPage {...backstoryProps} />} />,
<Route key={`${index++}`} path="/generate-candidate" element={<GenerateCandidate {...{ setSnack, submitQuery }} />} />, <Route key={`${index++}`} path="/generate-candidate" element={<GenerateCandidate {...backstoryProps} />} />,
<Route key={`${index++}`} path="/settings" element={<ControlsPage {...{ setSnack, submitQuery }} />} />, <Route key={`${index++}`} path="/settings" element={<ControlsPage {...backstoryProps} />} />,
]; ];
if (!user) { if (!user) {
routes.push(<Route key={`${index++}`} path="/register" element={(<BetaPage><CreateProfilePage /></BetaPage>)} />); routes.push(<Route key={`${index++}`} path="/register" element={(<BetaPage><CreateProfilePage /></BetaPage>)} />);
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage />} />); routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage {...backstoryProps} />} />);
routes.push(<Route key={`${index++}`} path="*" element={<BetaPage />} />); routes.push(<Route key={`${index++}`} path="*" element={<BetaPage />} />);
} else { } else {
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage />} />); routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage {...backstoryProps} />} />);
routes.push(<Route key={`${index++}`} path="/logout" element={<LogoutPage />} />); routes.push(<Route key={`${index++}`} path="/logout" element={<LogoutPage />} />);
if (user.userType === 'candidate') { if (user.userType === 'candidate') {
routes.splice(-1, 0, ...[ routes.splice(-1, 0, ...[
<Route key={`${index++}`} path="/profile" element={<ProfilePage />} />, <Route key={`${index++}`} path="/candidate/dashboard" element={<BetaPage><CandidateDashboardPage {...backstoryProps} /></BetaPage>} />,
<Route key={`${index++}`} path="/backstory" element={<BackstoryPage />} />, <Route key={`${index++}`} path="/candidate/profile" element={<ProfilePage />} />,
<Route key={`${index++}`} path="/resumes" element={<ResumesPage />} />, <Route key={`${index++}`} path="/candidate/backstory" element={<BackstoryPage />} />,
<Route key={`${index++}`} path="/qa-setup" element={<QASetupPage />} />, <Route key={`${index++}`} path="/candidate/resumes" element={<ResumesPage />} />,
<Route key={`${index++}`} path="/candidate/qa-setup" element={<QASetupPage />} />,
]); ]);
} }

View File

@ -33,11 +33,12 @@ import {
import { NavigationLinkType } from 'components/layout/BackstoryLayout'; import { NavigationLinkType } from 'components/layout/BackstoryLayout';
import { Beta } from 'components/Beta'; import { Beta } from 'components/Beta';
import { useUser } from 'hooks/useUser'; import { useUser } from 'hooks/useUser';
import { Candidate, Employer } from 'types/types'; import { Candidate, Employer, Viewer } from 'types/types';
import { SetSnackType } from 'components/Snack'; import { SetSnackType } from 'components/Snack';
import { CopyBubble } from 'components/CopyBubble'; import { CopyBubble } from 'components/CopyBubble';
import 'components/layout/Header.css'; import 'components/layout/Header.css';
import { useSecureAuth } from 'hooks/useSecureAuth';
// Styled components // Styled components
const StyledAppBar = styled(AppBar, { const StyledAppBar = styled(AppBar, {
@ -97,9 +98,10 @@ interface HeaderProps {
} }
const Header: React.FC<HeaderProps> = (props: HeaderProps) => { const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const { user, setUser } = useUser(); const { user, logout } = useSecureAuth();
const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null; const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null;
const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null; const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null;
const viewer: Viewer | null = (user && user.userType === "viewer") ? user as Viewer : null;
const { const {
transparent = false, transparent = false,
className, className,
@ -112,7 +114,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const theme = useTheme(); const theme = useTheme();
const location = useLocation(); const location = useLocation();
const name = (candidate ? candidate.username : user?.email) || ''; const name = (user?.firstName || user?.email || '');
const BackstoryLogo = () => { const BackstoryLogo = () => {
return <Typography return <Typography
@ -175,7 +177,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const handleLogout = () => { const handleLogout = () => {
handleUserMenuClose(); handleUserMenuClose();
setUser(null); logout();
navigate('/');
}; };
const handleDrawerToggle = () => { const handleDrawerToggle = () => {
@ -325,31 +328,28 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
vertical: 'top', vertical: 'top',
horizontal: 'right', horizontal: 'right',
}} }}
sx={{
"& .MuiList-root": { gap: 0 },
"& .MuiMenuItem-root": { gap: 1, display: "flex", flexDirection: "row", width: "100%" },
"& .MuiSvgIcon-root": { color: "#D4A017" }
}}
> >
<MenuItem onClick={handleUserMenuClose} component="a" href="/profile"> <MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}/profile`) }}>
<ListItemIcon> <Person fontSize="small" />
<Person fontSize="small" /> <Box>Profile</Box>
</ListItemIcon>
<ListItemText>Profile</ListItemText>
</MenuItem> </MenuItem>
<MenuItem onClick={handleUserMenuClose} component="a" href="/dashboard"> <MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}/dashboard`) }}>
<ListItemIcon> <Dashboard fontSize="small" />
<Dashboard fontSize="small" /> <Box>Dashboard</Box>
</ListItemIcon>
<ListItemText>Dashboard</ListItemText>
</MenuItem> </MenuItem>
<MenuItem onClick={handleUserMenuClose} component="a" href="/settings"> <MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}settings`) }}>
<ListItemIcon> <Settings fontSize="small" />
<Settings fontSize="small" /> <Box>Settings</Box>
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem> </MenuItem>
<Divider /> <Divider />
<MenuItem onClick={handleLogout}> <MenuItem onClick={handleLogout}>
<ListItemIcon> <Logout fontSize="small" />
<Logout fontSize="small" /> <Box>Logout</Box>
</ListItemIcon>
<ListItemText>Logout</ListItemText>
</MenuItem> </MenuItem>
</Menu> </Menu>
</> </>

View File

@ -0,0 +1,786 @@
// Persistent Authentication Hook with localStorage Integration and Date Conversion
// Automatically restoring login state on page refresh with proper date handling
import React, { createContext, useContext,useState, useCallback, useEffect, useRef } from 'react';
import * as Types from 'types/types';
import { useUser } from 'hooks/useUser';
import { CreateCandidateRequest, CreateEmployerRequest, CreateViewerRequest, LoginRequest } from 'services/api-client';
import { formatApiRequest } from 'types/conversion';
// Import date conversion functions
import {
convertCandidateFromApi,
convertEmployerFromApi,
convertViewerFromApi,
convertFromApi,
} from 'types/types';
export interface AuthState {
user: Types.User | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitializing: boolean;
error: string | null;
}
// Token storage utilities
const TOKEN_STORAGE = {
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
USER_DATA: 'userData',
TOKEN_EXPIRY: 'tokenExpiry'
} as const;
// JWT token utilities
function parseJwtPayload(token: string): any {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error('Failed to parse JWT token:', error);
return null;
}
}
function isTokenExpired(token: string): boolean {
const payload = parseJwtPayload(token);
if (!payload || !payload.exp) {
return true;
}
// Check if token expires within the next 5 minutes (buffer time)
const expiryTime = payload.exp * 1000; // Convert to milliseconds
const bufferTime = 5 * 60 * 1000; // 5 minutes
const currentTime = Date.now();
return currentTime >= (expiryTime - bufferTime);
}
function clearStoredAuth(): void {
localStorage.removeItem(TOKEN_STORAGE.ACCESS_TOKEN);
localStorage.removeItem(TOKEN_STORAGE.REFRESH_TOKEN);
localStorage.removeItem(TOKEN_STORAGE.USER_DATA);
localStorage.removeItem(TOKEN_STORAGE.TOKEN_EXPIRY);
}
/**
* Convert user data to storage format (dates to ISO strings)
*/
function prepareUserDataForStorage(user: Types.User): string {
try {
// Convert dates to ISO strings for storage
const userForStorage = formatApiRequest(user);
return JSON.stringify(userForStorage);
} catch (error) {
console.error('Failed to prepare user data for storage:', error);
return JSON.stringify(user); // Fallback to direct serialization
}
}
/**
* Convert stored user data back to proper format (ISO strings to dates)
*/
function parseStoredUserData(userDataStr: string): Types.User | null {
try {
const rawUserData = JSON.parse(userDataStr);
// Determine user type and apply appropriate conversion
const userType = rawUserData.userType ||
(rawUserData.companyName ? 'employer' :
rawUserData.firstName ? 'candidate' : 'viewer');
switch (userType) {
case 'candidate':
return convertCandidateFromApi(rawUserData) as Types.Candidate;
case 'employer':
return convertEmployerFromApi(rawUserData) as Types.Employer;
case 'viewer':
return convertViewerFromApi(rawUserData) as Types.Viewer;
default:
// Fallback: try to determine by fields present
if (rawUserData.companyName) {
return convertEmployerFromApi(rawUserData) as Types.Employer;
} else if (rawUserData.skills || rawUserData.experience) {
return convertCandidateFromApi(rawUserData) as Types.Candidate;
} else {
return convertViewerFromApi(rawUserData) as Types.Viewer;
}
}
} catch (error) {
console.error('Failed to parse stored user data:', error);
return null;
}
}
function storeAuthData(authResponse: Types.AuthResponse): void {
localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken);
localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken);
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
localStorage.setItem(TOKEN_STORAGE.TOKEN_EXPIRY, authResponse.expiresAt.toString());
}
function getStoredAuthData(): {
accessToken: string | null;
refreshToken: string | null;
userData: Types.User | null;
expiresAt: number | null;
} {
const accessToken = localStorage.getItem(TOKEN_STORAGE.ACCESS_TOKEN);
const refreshToken = localStorage.getItem(TOKEN_STORAGE.REFRESH_TOKEN);
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
let userData: Types.User | null = null;
let expiresAt: number | null = null;
try {
if (userDataStr) {
userData = parseStoredUserData(userDataStr);
}
if (expiryStr) {
expiresAt = parseInt(expiryStr, 10);
}
} catch (error) {
console.error('Failed to parse stored auth data:', error);
// Clear corrupted data
clearStoredAuth();
}
return { accessToken, refreshToken, userData, expiresAt };
}
/**
* Update stored user data (useful when user profile is updated)
*/
function updateStoredUserData(user: Types.User): void {
try {
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(user));
} catch (error) {
console.error('Failed to update stored user data:', error);
}
}
export function useSecureAuth() {
const [authState, setAuthState] = useState<AuthState>({
user: null,
isAuthenticated: false,
isLoading: false,
isInitializing: true, // Start as true, will be set to false after checking tokens
error: null
});
const {apiClient} = useUser();
const initializationCompleted = useRef(false);
// Token refresh function with date conversion
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => {
try {
const response = await apiClient.refreshToken(refreshToken);
// Ensure user data has proper date conversion
if (response.user) {
const userType = response.user.userType ||
(response.user.companyName ? 'employer' :
response.user.firstName ? 'candidate' : 'viewer');
response.user = convertFromApi<Types.User>(response.user, userType);
}
return response;
} catch (error) {
console.error('Token refresh failed:', error);
return null;
}
}, [apiClient]);
// Initialize authentication state from stored tokens
const initializeAuth = useCallback(async () => {
if (initializationCompleted.current) {
return;
}
try {
const stored = getStoredAuthData();
// If no stored tokens, user is not authenticated
if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
setAuthState(prev => ({
...prev,
isInitializing: false,
isAuthenticated: false,
user: null
}));
return;
}
// Check if access token is expired
if (isTokenExpired(stored.accessToken)) {
console.log('Access token expired, attempting refresh...');
// Try to refresh the token
const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) {
// Successfully refreshed - store with proper date conversion
storeAuthData(refreshResult);
apiClient.setAuthToken(refreshResult.accessToken);
console.log("User (refreshed) =>", refreshResult.user);
setAuthState({
user: refreshResult.user,
isAuthenticated: true,
isLoading: false,
isInitializing: false,
error: null
});
console.log('Token refreshed successfully with date conversion');
} else {
// Refresh failed, clear stored data
console.log('Token refresh failed, clearing stored auth');
clearStoredAuth();
apiClient.clearAuthToken();
setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
isInitializing: false,
error: null
});
}
} else {
// Access token is still valid - user data already has date conversion applied
apiClient.setAuthToken(stored.accessToken);
console.log("User (from storage) =>", stored.userData);
setAuthState({
user: stored.userData, // Already converted by parseStoredUserData
isAuthenticated: true,
isLoading: false,
isInitializing: false,
error: null
});
console.log('Restored authentication from stored tokens with date conversion');
}
} catch (error) {
console.error('Error initializing auth:', error);
clearStoredAuth();
apiClient.clearAuthToken();
setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
isInitializing: false,
error: null
});
} finally {
initializationCompleted.current = true;
}
}, [apiClient, refreshAccessToken]);
// Run initialization on mount
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// Set up automatic token refresh
useEffect(() => {
if (!authState.isAuthenticated) {
return;
}
const stored = getStoredAuthData();
if (!stored.expiresAt) {
return;
}
// Calculate time until token expires (with 5 minute buffer)
const expiryTime = stored.expiresAt * 1000;
const currentTime = Date.now();
const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer
if (timeUntilExpiry <= 0) {
// Token is already expired or will expire soon, refresh immediately
initializeAuth();
return;
}
// Set up automatic refresh before token expires
const refreshTimer = setTimeout(() => {
console.log('Auto-refreshing token before expiry...');
initializeAuth();
}, timeUntilExpiry);
return () => clearTimeout(refreshTimer);
}, [authState.isAuthenticated, initializeAuth]);
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const authResponse = await apiClient.login(loginData);
// Ensure user data has proper date conversion before storing
if (authResponse.user) {
const userType = authResponse.user.userType ||
(authResponse.user.companyName ? 'employer' :
authResponse.user.firstName ? 'candidate' : 'viewer');
authResponse.user = convertFromApi<Types.User>(authResponse.user, userType);
}
// Store tokens and user data with date conversion
storeAuthData(authResponse);
// Update API client with new token
apiClient.setAuthToken(authResponse.accessToken);
setAuthState({
user: authResponse.user,
isAuthenticated: true,
isLoading: false,
isInitializing: false,
error: null
});
console.log('Login successful with date conversion applied');
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
const logout = useCallback(() => {
// Clear stored authentication data
clearStoredAuth();
// Clear API client token
apiClient.clearAuthToken();
setAuthState({
user: null,
isAuthenticated: false,
isLoading: false,
isInitializing: false,
error: null
});
console.log('User logged out');
}, [apiClient]);
// Update user data in both state and localStorage (with date conversion)
const updateUserData = useCallback((updatedUser: Types.User) => {
// Update localStorage with proper date formatting
updateStoredUserData(updatedUser);
// Update state
setAuthState(prev => ({
...prev,
user: updatedUser
}));
console.log('User data updated with date conversion');
}, []);
const createViewerAccount = useCallback(async (viewerData: CreateViewerRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
// Validate password strength
const passwordValidation = apiClient.validatePasswordStrength(viewerData.password);
if (!passwordValidation.isValid) {
throw new Error(passwordValidation.issues.join(', '));
}
// Validate email
if (!apiClient.validateEmail(viewerData.email)) {
throw new Error('Please enter a valid email address');
}
// Validate username
const usernameValidation = apiClient.validateUsername(viewerData.username);
if (!usernameValidation.isValid) {
throw new Error(usernameValidation.issues.join(', '));
}
// Create viewer - API client automatically applies date conversion
const viewer = await apiClient.createViewer(viewerData);
console.log('Viewer created with date conversion:', viewer);
// Auto-login after successful registration
const loginSuccess = await login({
login: viewerData.email,
password: viewerData.password
});
return loginSuccess;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient, login]);
const createCandidateAccount = useCallback(async (candidateData: CreateCandidateRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
// Validate password strength
const passwordValidation = apiClient.validatePasswordStrength(candidateData.password);
if (!passwordValidation.isValid) {
throw new Error(passwordValidation.issues.join(', '));
}
// Validate email
if (!apiClient.validateEmail(candidateData.email)) {
throw new Error('Please enter a valid email address');
}
// Validate username
const usernameValidation = apiClient.validateUsername(candidateData.username);
if (!usernameValidation.isValid) {
throw new Error(usernameValidation.issues.join(', '));
}
// Create candidate - API client automatically applies date conversion
const candidate = await apiClient.createCandidate(candidateData);
console.log('Candidate created with date conversion:', candidate);
// Auto-login after successful registration
const loginSuccess = await login({
login: candidateData.email,
password: candidateData.password
});
return loginSuccess;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient, login]);
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
// Validate password strength
const passwordValidation = apiClient.validatePasswordStrength(employerData.password);
if (!passwordValidation.isValid) {
throw new Error(passwordValidation.issues.join(', '));
}
// Validate email
if (!apiClient.validateEmail(employerData.email)) {
throw new Error('Please enter a valid email address');
}
// Validate username
const usernameValidation = apiClient.validateUsername(employerData.username);
if (!usernameValidation.isValid) {
throw new Error(usernameValidation.issues.join(', '));
}
// Create employer - API client automatically applies date conversion
const employer = await apiClient.createEmployer(employerData);
console.log('Employer created with date conversion:', employer);
// Auto-login after successful registration
const loginSuccess = await login({
login: employerData.email,
password: employerData.password
});
return loginSuccess;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient, login]);
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.requestPasswordReset({ email });
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
// Force a token refresh (useful for manual refresh)
const refreshAuth = useCallback(async (): Promise<boolean> => {
const stored = getStoredAuthData();
if (!stored.refreshToken) {
return false;
}
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
const refreshResult = await refreshAccessToken(stored.refreshToken);
if (refreshResult) {
storeAuthData(refreshResult);
apiClient.setAuthToken(refreshResult.accessToken);
setAuthState({
user: refreshResult.user,
isAuthenticated: true,
isLoading: false,
isInitializing: false,
error: null
});
return true;
} else {
logout();
return false;
}
}, [apiClient, refreshAccessToken, logout]);
return {
...authState,
login,
logout,
createViewerAccount,
createCandidateAccount,
createEmployerAccount,
requestPasswordReset,
refreshAuth,
updateUserData // New function to update user data with proper storage
};
}
// ============================
// Auth Context Provider (Optional)
// ============================
const AuthContext = createContext<ReturnType<typeof useSecureAuth> | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useSecureAuth();
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// ============================
// Protected Route Component
// ============================
interface ProtectedRouteProps {
children: React.ReactNode;
fallback?: React.ReactNode;
requiredUserType?: 'candidate' | 'employer' | 'viewer';
}
export function ProtectedRoute({
children,
fallback = <div>Please log in to access this page.</div>,
requiredUserType
}: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user } = useAuth();
// Show loading while checking stored tokens
if (isInitializing) {
return <div>Loading...</div>;
}
// Not authenticated
if (!isAuthenticated) {
return <>{fallback}</>;
}
// Check user type if required
if (requiredUserType && user?.userType !== requiredUserType) {
return <div>Access denied. Required user type: {requiredUserType}</div>;
}
return <>{children}</>;
}
// ============================
// Usage Examples with Date Conversion
// ============================
/*
// App.tsx - Root level auth provider
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/candidate/*"
element={
<ProtectedRoute requiredUserType="candidate">
<CandidateRoutes />
</ProtectedRoute>
}
/>
<Route
path="/employer/*"
element={
<ProtectedRoute requiredUserType="employer">
<EmployerRoutes />
</ProtectedRoute>
}
/>
</Routes>
</Router>
</AuthProvider>
);
}
// Component using auth with proper date handling
function Header() {
const { user, isAuthenticated, logout, isInitializing } = useAuth();
if (isInitializing) {
return <div>Loading...</div>;
}
return (
<header>
{isAuthenticated ? (
<div>
<div>
Welcome, {user?.firstName || user?.companyName}!
{user?.createdAt && (
<small>Member since {user.createdAt.toLocaleDateString()}</small>
)}
{user?.lastLogin && (
<small>Last login: {user.lastLogin.toLocaleString()}</small>
)}
</div>
<button onClick={logout}>Logout</button>
</div>
) : (
<div>
<Link to="/login">Login</Link>
<Link to="/register">Register</Link>
</div>
)}
</header>
);
}
// Profile component with date operations
function UserProfile() {
const { user, updateUserData } = useAuth();
if (!user) return null;
// All date operations work properly because dates are Date objects
const accountAge = user.createdAt ?
Math.floor((Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)) : 0;
const handleProfileUpdate = async (updates: Partial<Types.User>) => {
// When updating user data, it will be properly stored with date conversion
const updatedUser = { ...user, ...updates, updatedAt: new Date() };
updateUserData(updatedUser);
};
return (
<div>
<h2>Profile</h2>
<p>Account created: {user.createdAt?.toLocaleDateString()}</p>
<p>Account age: {accountAge} days</p>
{user.lastLogin && (
<p>Last login: {user.lastLogin.toLocaleString()}</p>
)}
{'availabilityDate' in user && user.availabilityDate && (
<p>Available from: {user.availabilityDate.toLocaleDateString()}</p>
)}
{'experience' in user && user.experience?.map((exp, index) => (
<div key={index}>
<h4>{exp.position} at {exp.companyName}</h4>
<p>
{exp.startDate.toLocaleDateString()} -
{exp.endDate ? exp.endDate.toLocaleDateString() : 'Present'}
</p>
</div>
))}
</div>
);
}
// Auto-redirect based on auth state
function LoginPage() {
const { isAuthenticated, isInitializing } = useAuth();
useEffect(() => {
if (isAuthenticated) {
// Redirect to dashboard if already logged in
navigate('/dashboard');
}
}, [isAuthenticated]);
if (isInitializing) {
return <div>Checking authentication...</div>;
}
return <LoginForm />;
}
*/

View File

@ -6,10 +6,8 @@ import { debugConversion } from "types/conversion";
type UserContextType = { type UserContextType = {
apiClient: ApiClient; apiClient: ApiClient;
user: User | null;
guest: Guest; guest: Guest;
candidate: Candidate | null; candidate: Candidate | null;
setUser: (user: User | null) => void;
setCandidate: (candidate: Candidate | null) => void; setCandidate: (candidate: Candidate | null) => void;
}; };
@ -31,8 +29,6 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
const [apiClient, setApiClient] = useState<ApiClient>(new ApiClient()); const [apiClient, setApiClient] = useState<ApiClient>(new ApiClient());
const [candidate, setCandidate] = useState<Candidate | null>(null); const [candidate, setCandidate] = useState<Candidate | null>(null);
const [guest, setGuest] = useState<Guest | null>(null); const [guest, setGuest] = useState<Guest | null>(null);
const [user, setUser] = useState<User | null>(null);
const [activeUser, setActiveUser] = useState<User | null>(null);
useEffect(() => { useEffect(() => {
console.log("Candidate =>", candidate); console.log("Candidate =>", candidate);
@ -42,65 +38,6 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
console.log("Guest =>", guest); console.log("Guest =>", guest);
}, [guest]); }, [guest]);
/* If the user changes to a non-null value, create a new
* apiClient with the access token */
useEffect(() => {
console.log("User => ", user);
if (user === null) {
return;
}
/* This apiClient will persist until the user is changed
* or logged out */
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
throw Error("accessToken is not set for user!");
}
setApiClient(new ApiClient(accessToken));
}, [user]);
/* Handle logout if any consumers of UserProvider setUser to NULL */
useEffect(() => {
/* If there is an active user and it is the same as the
* new user, do nothing */
if (activeUser && activeUser.email === user?.email) {
return;
}
const logout = async () => {
if (!activeUser) {
return;
}
console.log(`Logging out ${activeUser.email}`);
try {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (!accessToken || !refreshToken) {
setSnack("Authentication tokens are invalid.", "error");
return;
}
const results = await apiClient.logout(accessToken, refreshToken);
if (results.error) {
console.error(results.error);
setSnack(results.error.message, "error")
}
} catch (e) {
console.error(e);
setSnack(`Unable to logout: ${e}`, "error")
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('userData');
createGuestSession();
setUser(null);
};
setActiveUser(user);
if (!user) {
logout();
}
}, [user, apiClient, activeUser]);
const createGuestSession = () => { const createGuestSession = () => {
console.log("TODO: Convert this to query the server for the session instead of generating it."); console.log("TODO: Convert this to query the server for the session instead of generating it.");
const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
@ -132,7 +69,6 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
user.lastLogin = new Date(user.lastLogin); user.lastLogin = new Date(user.lastLogin);
} }
setApiClient(new ApiClient(accessToken)); setApiClient(new ApiClient(accessToken));
setUser(user);
} catch (e) { } catch (e) {
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken');
@ -152,7 +88,7 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
} }
return ( return (
<UserContext.Provider value={{ apiClient, candidate, setCandidate, user, setUser, guest }}> <UserContext.Provider value={{ apiClient, candidate, setCandidate, guest }}>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
); );

View File

@ -158,11 +158,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
return ( return (
<Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}> <Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}>
{candidate && <CandidateInfo elevation={4} candidate={candidate} sx={{ minHeight: "max-content" }} />} {candidate && <CandidateInfo action={`Chat with Backstory about ${candidate.firstName}`} elevation={4} candidate={candidate} sx={{ minHeight: "max-content" }} />}
< Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%" }}> < Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%" }}>
{/* Sessions Sidebar */} {/* Sessions Sidebar */}
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}> <Paper sx={{ p: 2, height: '100%', minWidth: { sm: "200px", md: "300px", lg: "400px" }, display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Chat Sessions Chat Sessions
{sessions && ( {sessions && (
@ -215,20 +215,6 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
</Typography> </Typography>
)} )}
</Box> </Box>
{sessions && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.default', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Candidate Info
</Typography>
<Typography variant="body2">
<strong>Name:</strong> {sessions.candidate.fullName}
</Typography>
<Typography variant="body2">
<strong>Email:</strong> {sessions.candidate.email}
</Typography>
</Box>
)}
</Paper> </Paper>
{/* Chat Interface */} {/* Chat Interface */}

View File

@ -0,0 +1,277 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
LinearProgress,
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemButton,
Divider,
Chip,
Stack
} from '@mui/material';
import {
Dashboard as DashboardIcon,
Person as PersonIcon,
Article as ArticleIcon,
Description as DescriptionIcon,
Quiz as QuizIcon,
Analytics as AnalyticsIcon,
Settings as SettingsIcon,
Add as AddIcon,
Visibility as VisibilityIcon,
Download as DownloadIcon,
ContactMail as ContactMailIcon,
Edit as EditIcon,
TipsAndUpdates as TipsIcon,
SettingsBackupRestore
} from '@mui/icons-material';
import { useSecureAuth } from 'hooks/useSecureAuth';
import { LoadingPage } from './LoadingPage';
import { LoginRequired } from './LoginRequired';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { Navigate, useNavigate } from 'react-router-dom';
interface DashboardProps extends BackstoryPageProps {
userName?: string;
profileCompletion?: number;
}
const CandidateDashboardPage: React.FC<DashboardProps> = (props: DashboardProps) => {
const navigate = useNavigate();
const { setSnack } = props;
const { user, isLoading, isInitializing, isAuthenticated } = useSecureAuth();
const profileCompletion = 75;
const sidebarItems = [
{ icon: <DashboardIcon />, text: 'Dashboard', active: true },
{ icon: <PersonIcon />, text: 'Profile', active: false },
{ icon: <ArticleIcon />, text: 'Backstory', active: false },
{ icon: <DescriptionIcon />, text: 'Resumes', active: false },
{ icon: <QuizIcon />, text: 'Q&A Setup', active: false },
{ icon: <AnalyticsIcon />, text: 'Analytics', active: false },
{ icon: <SettingsIcon />, text: 'Settings', active: false },
];
if (isLoading || isInitializing) {
return (<LoadingPage {...props}/>);
}
if (!user || !isAuthenticated) {
return (<LoginRequired {...props}/>);
}
if (user.userType !== 'candidate') {
setSnack(`The page you were on is only available for candidates (you are a ${user.userType}`, 'warning');
navigate('/');
return (<></>);
}
return (
<Box sx={{ display: 'flex', minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
{/* Sidebar */}
<Box
sx={{
width: 250,
backgroundColor: 'white',
borderRight: '1px solid #e0e0e0',
p: 2,
}}
>
<Typography variant="h6" sx={{ mb: 3, fontWeight: 'bold', color: '#1976d2' }}>
JobPortal
</Typography>
<List>
{sidebarItems.map((item, index) => (
<ListItem key={index} disablePadding sx={{ mb: 0.5 }}>
<ListItemButton
sx={{
borderRadius: 1,
backgroundColor: item.active ? '#e3f2fd' : 'transparent',
color: item.active ? '#1976d2' : '#666',
'&:hover': {
backgroundColor: item.active ? '#e3f2fd' : '#f5f5f5',
},
}}
>
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.text}
primaryTypographyProps={{
fontSize: '0.9rem',
fontWeight: item.active ? 600 : 400,
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
{/* Main Content */}
<Box sx={{ flex: 1, p: 3 }}>
{/* Welcome Section */}
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>
Welcome back, {user.firstName}!
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
Your profile is {profileCompletion}% complete
</Typography>
<LinearProgress
variant="determinate"
value={profileCompletion}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: '#e0e0e0',
'& .MuiLinearProgress-bar': {
backgroundColor: '#4caf50',
},
}}
/>
</Box>
<Button
variant="contained"
color="primary"
sx={{ mt: 1 }}
>
Complete Your Profile
</Button>
</Box>
{/* Cards Grid */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Top Row */}
<Box sx={{ display: 'flex', gap: 3 }}>
{/* Resume Builder Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Resume Builder
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#666' }}>
3 custom resumes
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: '#666' }}>
Last created: May 15, 2025
</Typography>
<Button
variant="outlined"
startIcon={<AddIcon />}
fullWidth
>
Create New
</Button>
</CardContent>
</Card>
{/* Recent Activity Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Recent Activity
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<VisibilityIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">5 profile views</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DownloadIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">2 resume downloads</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ContactMailIcon sx={{ fontSize: 16, color: '#666' }} />
<Typography variant="body2">1 direct contact</Typography>
</Box>
</Stack>
<Button
variant="outlined"
fullWidth
>
View All Activity
</Button>
</CardContent>
</Card>
</Box>
{/* Bottom Row */}
<Box sx={{ display: 'flex', gap: 3 }}>
{/* Complete Your Backstory Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Complete Your Backstory
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Add projects
</Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Detail skills
</Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Work history
</Typography>
</Stack>
<Button
variant="outlined"
startIcon={<EditIcon />}
fullWidth
>
Edit Backstory
</Button>
</CardContent>
</Card>
{/* Improvement Suggestions Card */}
<Card sx={{ flex: 1, minHeight: 200 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
Improvement Suggestions
</Typography>
<Stack spacing={1} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Add certifications
</Typography>
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
Enhance your project details
</Typography>
</Stack>
<Button
variant="outlined"
startIcon={<TipsIcon />}
fullWidth
>
View All Tips
</Button>
</CardContent>
</Card>
</Box>
</Box>
</Box>
</Box>
);
};
export { CandidateDashboardPage };

View File

@ -33,130 +33,59 @@ import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from '@mui/icons-material/Description';
import FileUploadIcon from '@mui/icons-material/FileUpload'; import FileUploadIcon from '@mui/icons-material/FileUpload';
import { JobMatchAnalysis } from '../components/JobMatchAnalysis'; import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
import { Candidate } from "types/types";
// Mock types for our application import { useUser } from "hooks/useUser";
interface Candidate { import { useNavigate } from 'react-router-dom';
id: string; import { BackstoryPageProps } from 'components/BackstoryTab';
name: string; import { useSecureAuth } from 'hooks/useSecureAuth';
title: string;
location: string;
email: string;
phone: string;
photoUrl?: string;
resume?: string;
}
interface User {
id: string;
name: string;
company: string;
role: string;
}
// Mock hook for getting the current user
const useUser = (): { user: User | null, loading: boolean } => {
// In a real app, this would check auth state and get user info
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Simulate fetching user data
setTimeout(() => {
setUser({
id: 'emp123',
name: 'Sarah Thompson',
company: 'Tech Innovations Inc.',
role: 'HR Manager'
});
setLoading(false);
}, 800);
}, []);
return { user, loading };
};
// Mock API for fetching candidates
const fetchCandidates = async (searchQuery: string = ''): Promise<Candidate[]> => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
const mockCandidates: Candidate[] = [
{
id: 'c1',
name: 'Alex Johnson',
title: 'Senior Frontend Developer',
location: 'Seattle, WA',
email: 'alex.johnson@example.com',
phone: '(555) 123-4567',
photoUrl: 'https://i.pravatar.cc/150?img=11'
},
{
id: 'c2',
name: 'Morgan Williams',
title: 'Full Stack Engineer',
location: 'Portland, OR',
email: 'morgan.w@example.com',
phone: '(555) 234-5678',
photoUrl: 'https://i.pravatar.cc/150?img=12'
},
{
id: 'c3',
name: 'Jamie Garcia',
title: 'DevOps Specialist',
location: 'San Francisco, CA',
email: 'jamie.g@example.com',
phone: '(555) 345-6789',
photoUrl: 'https://i.pravatar.cc/150?img=13'
},
{
id: 'c4',
name: 'Taylor Chen',
title: 'Backend Developer',
location: 'Austin, TX',
email: 'taylor.c@example.com',
phone: '(555) 456-7890',
photoUrl: 'https://i.pravatar.cc/150?img=14'
},
{
id: 'c5',
name: 'Jordan Smith',
title: 'UI/UX Developer',
location: 'Chicago, IL',
email: 'jordan.s@example.com',
phone: '(555) 567-8901',
photoUrl: 'https://i.pravatar.cc/150?img=15'
}
];
if (!searchQuery) return mockCandidates;
// Filter candidates based on search query
return mockCandidates.filter(candidate =>
candidate.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
candidate.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
candidate.location.toLowerCase().includes(searchQuery.toLowerCase())
);
};
// Main component // Main component
const JobAnalysisPage: React.FC = () => { const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const theme = useTheme(); const theme = useTheme();
const { user, loading: userLoading } = useUser(); const { user } = useSecureAuth();
// State management // State management
const [activeStep, setActiveStep] = useState(0); const [activeStep, setActiveStep] = useState(0);
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null); const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [loadingCandidates, setLoadingCandidates] = useState(false);
const [jobDescription, setJobDescription] = useState(''); const [jobDescription, setJobDescription] = useState('');
const [jobTitle, setJobTitle] = useState(''); const [jobTitle, setJobTitle] = useState('');
const [jobLocation, setJobLocation] = useState(''); const [jobLocation, setJobLocation] = useState('');
const [analysisStarted, setAnalysisStarted] = useState(false); const [analysisStarted, setAnalysisStarted] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [openUploadDialog, setOpenUploadDialog] = useState(false); const [openUploadDialog, setOpenUploadDialog] = useState(false);
const { apiClient } = useUser();
const { setSnack } = props;
const [candidates, setCandidates] = useState<Candidate[] | null>(null);
useEffect(() => {
if (candidates !== null) {
return;
}
const getCandidates = async () => {
try {
const results = await apiClient.getCandidates();
const candidates: Candidate[] = results.data;
candidates.sort((a, b) => {
let result = a.lastName.localeCompare(b.lastName);
if (result === 0) {
result = a.firstName.localeCompare(b.firstName);
}
if (result === 0) {
result = a.username.localeCompare(b.username);
}
return result;
});
console.log(candidates);
setCandidates(candidates);
} catch (err) {
setSnack("" + err);
}
};
getCandidates();
}, [candidates, setSnack]);
// Steps in our process // Steps in our process
const steps = [ const steps = [
{ label: 'Select Candidate', icon: <PersonIcon /> }, { label: 'Select Candidate', icon: <PersonIcon /> },
@ -164,38 +93,6 @@ const JobAnalysisPage: React.FC = () => {
{ label: 'View Analysis', icon: <AssessmentIcon /> } { label: 'View Analysis', icon: <AssessmentIcon /> }
]; ];
// Load initial candidates
useEffect(() => {
const loadCandidates = async () => {
setLoadingCandidates(true);
try {
const data = await fetchCandidates();
setCandidates(data);
} catch (err) {
setError('Failed to load candidates. Please try again.');
} finally {
setLoadingCandidates(false);
}
};
if (user) {
loadCandidates();
}
}, [user]);
// Handler for candidate search
const handleSearch = async () => {
setLoadingCandidates(true);
try {
const data = await fetchCandidates(searchQuery);
setCandidates(data);
} catch (err) {
setError('Search failed. Please try again.');
} finally {
setLoadingCandidates(false);
}
};
// Mock handlers for our analysis APIs // Mock handlers for our analysis APIs
const fetchRequirements = async (): Promise<string[]> => { const fetchRequirements = async (): Promise<string[]> => {
// Simulates extracting requirements from the job description // Simulates extracting requirements from the job description
@ -371,7 +268,7 @@ const JobAnalysisPage: React.FC = () => {
Select a Candidate Select a Candidate
</Typography> </Typography>
<Box sx={{ mb: 3, display: 'flex' }}> {/* <Box sx={{ mb: 3, display: 'flex' }}>
<TextField <TextField
fullWidth fullWidth
variant="outlined" variant="outlined"
@ -389,17 +286,10 @@ const JobAnalysisPage: React.FC = () => {
}} }}
sx={{ mr: 2 }} sx={{ mr: 2 }}
/> />
</Box> </Box> */}
{loadingCandidates ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
) : candidates.length === 0 ? (
<Typography>No candidates found. Please adjust your search criteria.</Typography>
) : (
<Grid container spacing={3}> <Grid container spacing={3}>
{candidates.map((candidate) => ( {candidates?.map((candidate) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={candidate.id}> <Grid size={{ xs: 12, sm: 6, md: 4 }} key={candidate.id}>
<Card <Card
elevation={selectedCandidate?.id === candidate.id ? 8 : 1} elevation={selectedCandidate?.id === candidate.id ? 8 : 1}
@ -418,38 +308,37 @@ const JobAnalysisPage: React.FC = () => {
<CardContent sx={{ flexGrow: 1, p: 3 }}> <CardContent sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ display: 'flex', mb: 2, alignItems: 'center' }}> <Box sx={{ display: 'flex', mb: 2, alignItems: 'center' }}>
<Avatar <Avatar
src={candidate.photoUrl} src={candidate.profileImage}
alt={candidate.name} alt={candidate.firstName}
sx={{ width: 64, height: 64, mr: 2 }} sx={{ width: 64, height: 64, mr: 2 }}
/> />
<Box> <Box>
<Typography variant="h6" component="div"> <Typography variant="h6" component="div">
{candidate.name} {candidate.fullName}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{candidate.title} {candidate.description}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography variant="body2" sx={{ mb: 1 }}> {candidate.location && <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location} <strong>Location:</strong> {candidate.location.country}
</Typography> </Typography>}
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email} <strong>Email:</strong> {candidate.email}
</Typography> </Typography>
<Typography variant="body2"> {candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone} <strong>Phone:</strong> {candidate.phone}
</Typography> </Typography>}
</CardContent> </CardContent>
</CardActionArea> </CardActionArea>
</Card> </Card>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
)}
</Paper> </Paper>
); );
@ -530,7 +419,7 @@ const JobAnalysisPage: React.FC = () => {
{selectedCandidate && ( {selectedCandidate && (
<JobMatchAnalysis <JobMatchAnalysis
jobTitle={jobTitle} jobTitle={jobTitle}
candidateName={selectedCandidate.name} candidateName={selectedCandidate.fullName}
fetchRequirements={fetchRequirements} fetchRequirements={fetchRequirements}
fetchMatchForRequirement={fetchMatchForRequirement} fetchMatchForRequirement={fetchMatchForRequirement}
/> />
@ -538,15 +427,6 @@ const JobAnalysisPage: React.FC = () => {
</Box> </Box>
); );
// If user is loading, show loading state
if (userLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
<CircularProgress />
</Box>
);
}
// If no user is logged in, show message // If no user is logged in, show message
if (!user) { if (!user) {
return ( return (
@ -578,7 +458,8 @@ const JobAnalysisPage: React.FC = () => {
<Stepper activeStep={activeStep} alternativeLabel> <Stepper activeStep={activeStep} alternativeLabel>
{steps.map((step, index) => ( {steps.map((step, index) => (
<Step key={index}> <Step key={index}>
<StepLabel StepIconComponent={() => ( <StepLabel slots={{
stepIcon: () => (
<Avatar <Avatar
sx={{ sx={{
bgcolor: activeStep >= index ? theme.palette.primary.main : theme.palette.grey[300], bgcolor: activeStep >= index ? theme.palette.primary.main : theme.palette.grey[300],
@ -587,7 +468,9 @@ const JobAnalysisPage: React.FC = () => {
> >
{step.icon} {step.icon}
</Avatar> </Avatar>
)}> )
}}
>
{step.label} {step.label}
</StepLabel> </StepLabel>
</Step> </Step>

View File

@ -16,9 +16,36 @@ import {
Card, Card,
CardContent, CardContent,
Divider, Divider,
Avatar Avatar,
IconButton,
InputAdornment,
List,
ListItem,
ListItemIcon,
ListItemText,
Collapse,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
Chip
} from '@mui/material'; } from '@mui/material';
import { Person, PersonAdd, AccountCircle, ExitToApp } from '@mui/icons-material'; import {
Person,
PersonAdd,
AccountCircle,
ExitToApp,
Visibility,
VisibilityOff,
CheckCircle,
Cancel,
ExpandLess,
ExpandMore,
Visibility as ViewIcon,
Work,
Business
} from '@mui/icons-material';
import 'react-phone-number-input/style.css'; import 'react-phone-number-input/style.css';
import PhoneInput from 'react-phone-number-input'; import PhoneInput from 'react-phone-number-input';
import { E164Number } from 'libphonenumber-js/core'; import { E164Number } from 'libphonenumber-js/core';
@ -26,22 +53,15 @@ import './LoginPage.css';
import { ApiClient } from 'services/api-client'; import { ApiClient } from 'services/api-client';
import { useUser } from 'hooks/useUser'; import { useUser } from 'hooks/useUser';
import { useSecureAuth } from 'hooks/useSecureAuth';
import { LocationInput } from 'components/LocationInput';
import { Location } from 'types/types';
// Import conversion utilities import { Candidate } from 'types/types'
import {
formatApiRequest,
parseApiResponse,
handleApiResponse,
extractApiData,
isSuccessResponse,
debugConversion,
type ApiResponse
} from 'types/conversion';
import {
AuthResponse, User, Guest, Candidate
} from 'types/types'
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { BackstoryPageProps } from 'components/BackstoryTab';
type UserRegistrationType = 'viewer' | 'candidate' | 'employer';
interface LoginRequest { interface LoginRequest {
login: string; login: string;
@ -49,25 +69,49 @@ interface LoginRequest {
} }
interface RegisterRequest { interface RegisterRequest {
userType: UserRegistrationType;
username: string; username: string;
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
password: string; password: string;
confirmPassword: string;
phone?: string; phone?: string;
// Employer specific fields (placeholder)
companyName?: string;
industry?: string;
companySize?: string;
}
interface PasswordRequirement {
label: string;
met: boolean;
} }
const apiClient = new ApiClient(); const apiClient = new ApiClient();
const LoginPage: React.FC = () => { const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const navigate = useNavigate(); const { setSnack } = props;
const { user, setUser, guest } = useUser(); const { guest } = useUser();
const [tabValue, setTabValue] = useState(0); const [tabValue, setTabValue] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [phone, setPhone] = useState<E164Number | null>(null); const [phone, setPhone] = useState<E164Number | null>(null);
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || ''; const { createCandidateAccount, createViewerAccount, user, login, isLoading, error } = useSecureAuth();
const [passwordValidation, setPasswordValidation] = useState<{ isValid: boolean; issues: string[] }>({ isValid: true, issues: [] });
const name = (user?.userType === 'candidate' || user?.userType === 'viewer') ? user.username : user?.email || '';
const [location, setLocation] = useState<Partial<Location>>({});
// Password visibility states
const [showLoginPassword, setShowLoginPassword] = useState(false);
const [showRegisterPassword, setShowRegisterPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showPasswordRequirements, setShowPasswordRequirements] = useState(false);
const handleLocationChange = (location: Partial<Location>) => {
setLocation(location);
console.log('Location updated:', location);
};
// Login form state // Login form state
const [loginForm, setLoginForm] = useState<LoginRequest>({ const [loginForm, setLoginForm] = useState<LoginRequest>({
@ -77,14 +121,49 @@ const LoginPage: React.FC = () => {
// Register form state // Register form state
const [registerForm, setRegisterForm] = useState<RegisterRequest>({ const [registerForm, setRegisterForm] = useState<RegisterRequest>({
userType: 'candidate',
username: '', username: '',
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
password: '', password: '',
phone: '' confirmPassword: '',
phone: '',
companyName: '',
industry: '',
companySize: ''
}); });
// Password requirements validation
const getPasswordRequirements = (password: string): PasswordRequirement[] => {
return [
{
label: 'At least 8 characters long',
met: password.length >= 8
},
{
label: 'Contains uppercase letter',
met: /[A-Z]/.test(password)
},
{
label: 'Contains lowercase letter',
met: /[a-z]/.test(password)
},
{
label: 'Contains number',
met: /\d/.test(password)
},
{
label: 'Contains special character (!@#$%^&*)',
met: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
}
];
};
const passwordRequirements = getPasswordRequirements(registerForm.password);
const passwordsMatch = registerForm.password === registerForm.confirmPassword;
const hasPasswordMatchError = registerForm.confirmPassword.length > 0 && !passwordsMatch;
useEffect(() => { useEffect(() => {
if (phone !== registerForm.phone && phone) { if (phone !== registerForm.phone && phone) {
console.log({ phone }); console.log({ phone });
@ -95,94 +174,122 @@ const LoginPage: React.FC = () => {
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null);
setSuccess(null); setSuccess(null);
try { const success = await login(loginForm);
const authResponse = await apiClient.login(loginForm.login, loginForm.password) if (success) {
debugConversion(authResponse, 'Login Response');
// Store tokens in localStorage
localStorage.setItem('accessToken', authResponse.accessToken);
localStorage.setItem('refreshToken', authResponse.refreshToken);
localStorage.setItem('userData', JSON.stringify(authResponse.user));
setSuccess('Login successful!'); setSuccess('Login successful!');
navigate('/'); }
setUser(authResponse.user); };
// Clear form
setLoginForm({ login: '', password: '' }); const handlePasswordChange = (password: string) => {
} catch (err) { setRegisterForm(prev => ({ ...prev, password }));
console.error('Login error:', err); setPasswordValidation(apiClient.validatePasswordStrength(password));
setError(err instanceof Error ? err.message : 'Login failed');
} finally { // Show requirements if password has content and isn't valid
setLoading(false); if (password.length > 0) {
const requirements = getPasswordRequirements(password);
const allMet = requirements.every(req => req.met);
if (!allMet && !showPasswordRequirements) {
setShowPasswordRequirements(true);
}
if (allMet && showPasswordRequirements) {
setShowPasswordRequirements(false);
}
}
};
const handleUserTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const userType = event.target.value as UserRegistrationType;
setRegisterForm(prev => ({ ...prev, userType }));
// Clear location and phone for viewer type
if (userType === 'viewer') {
setLocation({});
setPhone(null);
} }
}; };
const handleRegister = async (e: React.FormEvent) => { const handleRegister = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Check if employer registration is attempted
if (registerForm.userType === 'employer') {
setSnack('Employer registration is not yet supported. Please contact support for employer account setup.', "warning");
return;
}
// Validate passwords match
if (!passwordsMatch) {
return;
}
// Validate password requirements
const allRequirementsMet = passwordRequirements.every(req => req.met);
if (!allRequirementsMet) {
return;
}
setLoading(true); setLoading(true);
setError(null);
setSuccess(null); setSuccess(null);
try { // For now, all non-employer registrations go through candidate creation
const candidate: Candidate = { // This would need to be updated when viewer and employer APIs are available
username: registerForm.username, let success;
email: registerForm.email, switch (registerForm.userType) {
firstName: registerForm.firstName, case 'candidate':
lastName: registerForm.lastName, success = await createCandidateAccount(registerForm);
fullName: `${registerForm.firstName} ${registerForm.lastName}`, break;
phone: registerForm.phone || undefined, case 'viewer':
userType: 'candidate', success = await createViewerAccount(registerForm);
status: 'active', break;
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
experience: [],
education: [],
preferredJobTypes: [],
languages: [],
certifications: [],
location: {
city: '',
country: '',
remote: true
}
};
const result = await apiClient.createCandidate(candidate);
debugConversion(result, 'Registration Response');
setSuccess('Registration successful! You can now login.');
// Clear form and switch to login tab
setRegisterForm({
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
phone: ''
});
setTabValue(0);
} catch (err) {
console.error('Registration error:', err);
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
} }
if (success) {
// Redirect based on user type
if (registerForm.userType === 'viewer') {
window.location.href = '/find-a-candidate';
} else {
window.location.href = '/candidate/dashboard';
}
}
setLoading(false);
}; };
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue); setTabValue(newValue);
setError(null);
setSuccess(null); setSuccess(null);
}; };
// Toggle password visibility functions
const toggleLoginPasswordVisibility = () => setShowLoginPassword(!showLoginPassword);
const toggleRegisterPasswordVisibility = () => setShowRegisterPassword(!showRegisterPassword);
const toggleConfirmPasswordVisibility = () => setShowConfirmPassword(!showConfirmPassword);
// Get user type icon and description
const getUserTypeInfo = (userType: UserRegistrationType) => {
switch (userType) {
case 'viewer':
return {
icon: <ViewIcon />,
title: 'Viewer',
description: 'Chat with Backstory about candidates and explore the platform.'
};
case 'candidate':
return {
icon: <Person />,
title: 'Candidate',
description: 'Use Backstory to generate your resume and help you manage your career. Optionally let people interact with your profile.'
};
case 'employer':
return {
icon: <Business />,
title: 'Employer',
description: 'Post jobs and find talent (Coming Soon.)'
};
}
};
// If user is logged in, show their profile // If user is logged in, show their profile
if (user) { if (user) {
return ( return (
@ -213,7 +320,7 @@ const LoginPage: React.FC = () => {
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}> <Typography variant="body1" sx={{ mb: 1 }}>
<strong>Status:</strong> {user.status} {/* <strong>Status:</strong> {user.status} */}
</Typography> </Typography>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
@ -221,6 +328,11 @@ const LoginPage: React.FC = () => {
<strong>Phone:</strong> {user.phone || 'Not provided'} <strong>Phone:</strong> {user.phone || 'Not provided'}
</Typography> </Typography>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Account type:</strong> {user.userType}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}> <Typography variant="body1" sx={{ mb: 1 }}>
<strong>Last Login:</strong> { <strong>Last Login:</strong> {
@ -258,7 +370,6 @@ const LoginPage: React.FC = () => {
const handleLoginChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleLoginChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target; const { value } = event.target;
setLoginForm({ ...loginForm, login: value }); setLoginForm({ ...loginForm, login: value });
setError(validateInput(value));
}; };
return ( return (
@ -325,7 +436,7 @@ const LoginPage: React.FC = () => {
<TextField <TextField
fullWidth fullWidth
label="Password" label="Password"
type="password" type={showLoginPassword ? 'text' : 'password'}
value={loginForm.password} value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })} onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
margin="normal" margin="normal"
@ -333,6 +444,22 @@ const LoginPage: React.FC = () => {
disabled={loading} disabled={loading}
variant="outlined" variant="outlined"
autoComplete='current-password' autoComplete='current-password'
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={toggleLoginPasswordVisibility}
edge="end"
disabled={loading}
>
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/> />
<Button <Button
@ -354,95 +481,260 @@ const LoginPage: React.FC = () => {
Create Account Create Account
</Typography> </Typography>
<Grid container spacing={2} sx={{ mb: 2 }}> {/* User Type Selection */}
<Grid size={{ xs: 12, sm: 6 }}> <FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
<Typography variant="h6">Select Account Type</Typography>
</FormLabel>
<RadioGroup
value={registerForm.userType}
onChange={handleUserTypeChange}
sx={{ gap: 1 }}
>
{(['viewer', 'candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
const info = getUserTypeInfo(userType);
return (
<FormControlLabel
key={userType}
value={userType}
disabled={loading || userType === 'employer'}
control={<Radio />}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
{info.icon}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{info.title}
{userType === 'employer' && (
<Chip
label="Coming Soon"
size="small"
color="warning"
sx={{ ml: 1 }}
/>
)}
</Typography>
<Typography variant="body2" color="text.secondary">
{info.description}
</Typography>
</Box>
</Box>
}
sx={{
border: '1px solid',
borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
borderRadius: 1,
p: 1,
m: 0,
bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
'&:hover': {
bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
},
opacity: userType === 'employer' ? 0.6 : 1
}}
/>
);
})}
</RadioGroup>
</FormControl>
{/* Employer Placeholder */}
{registerForm.userType === 'employer' && (
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Employer Registration Coming Soon
</Typography>
<Typography variant="body2">
We're currently building our employer features. If you're interested in posting jobs
and finding talent, please contact our support team at support@backstory.com for
early access.
</Typography>
</Alert>
)}
{/* Basic Information Fields */}
{registerForm.userType !== 'employer' && (
<>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
value={registerForm.firstName}
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
value={registerForm.lastName}
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
</Grid>
</Grid>
<TextField <TextField
fullWidth fullWidth
label="First Name" label="Username"
value={registerForm.firstName} value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })} onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
margin="normal"
required required
disabled={loading} disabled={loading}
variant="outlined" variant="outlined"
/> />
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Last Name" label="Email"
value={registerForm.lastName} type="email"
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })} value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
margin="normal"
required required
disabled={loading} disabled={loading}
variant="outlined" variant="outlined"
/> />
</Grid>
</Grid> {/* Conditional fields based on user type */}
{registerForm.userType === 'candidate' && (
<TextField <>
fullWidth <PhoneInput
label="Username" label="Phone (Optional)"
value={registerForm.username} placeholder="Enter phone number"
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })} defaultCountry='US'
margin="normal" value={registerForm.phone}
required disabled={loading}
disabled={loading} onChange={(v) => setPhone(v as E164Number)}
variant="outlined" />
/>
<LocationInput
<TextField value={location}
fullWidth onChange={handleLocationChange}
label="Email" showCity
type="email" helperText="Include your city for more specific job matches"
value={registerForm.email} />
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })} </>
margin="normal" )}
required
disabled={loading} <TextField
variant="outlined" fullWidth
/> label="Password"
type={showRegisterPassword ? 'text' : 'password'}
<PhoneInput value={registerForm.password}
label="Phone (Optional)" onChange={(e) => handlePasswordChange(e.target.value)}
placeholder="Enter phone number" margin="normal"
defaultCountry='US' required
value={registerForm.phone} disabled={loading}
disabled={loading} variant="outlined"
onChange={(v) => setPhone(v as E164Number)} /> slotProps={{
{/* <TextField input: {
fullWidth endAdornment: (
label="Phone (Optional)" <InputAdornment position="end">
type="tel" <IconButton
value={registerForm.phone} aria-label="toggle password visibility"
onChange={(e) => setRegisterForm({ ...registerForm, phone: e.target.value })} onClick={toggleRegisterPasswordVisibility}
margin="normal" edge="end"
disabled={loading} disabled={loading}
variant="outlined" >
/> */} {showRegisterPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
<TextField </InputAdornment>
fullWidth )
label="Password" }
type="password" }}
value={registerForm.password} />
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })}
margin="normal" {/* Password Requirements */}
required {registerForm.password.length > 0 && (
disabled={loading} <Box sx={{ mt: 1, mb: 1 }}>
variant="outlined" <Button
/> onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
<Button size="small"
type="submit" sx={{ mb: 1 }}
fullWidth >
variant="contained" Password Requirements
sx={{ mt: 3, mb: 2 }} </Button>
disabled={loading} <Collapse in={showPasswordRequirements}>
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />} <Paper variant="outlined" sx={{ p: 2 }}>
> <List dense>
{loading ? 'Creating Account...' : 'Create Account'} {passwordRequirements.map((requirement, index) => (
</Button> <ListItem key={index} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
{requirement.met ? (
<CheckCircle color="success" fontSize="small" />
) : (
<Cancel color="error" fontSize="small" />
)}
</ListItemIcon>
<ListItemText
primary={requirement.label}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
color: requirement.met ? 'success.main' : 'error.main'
}
}}
/>
</ListItem>
))}
</List>
</Paper>
</Collapse>
</Box>
)}
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
error={hasPasswordMatchError}
helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle confirm password visibility"
onClick={toggleConfirmPasswordVisibility}
edge="end"
disabled={loading}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
>
{loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
</Button>
</>
)}
</Box> </Box>
)} )}
</Paper> </Paper>

View File

@ -0,0 +1,23 @@
import Box from '@mui/material/Box';
import { BackstoryPageProps } from '../components/BackstoryTab';
import { Message } from '../components/Message';
import { ChatMessage } from 'types/types';
const LoginRequired = (props: BackstoryPageProps) => {
const preamble: ChatMessage = {
sender: 'system',
type: 'preparing',
status: 'done',
sessionId: '',
content: 'You must be logged to view this feature.',
timestamp: new Date()
}
return <Box sx={{display: "flex", flexGrow: 1, maxWidth: "1024px", margin: "0 auto"}}>
<Message message={preamble} {...props} />
</Box>
};
export {
LoginRequired
};

View File

@ -1,26 +1,40 @@
/** /**
* Enhanced API Client with Streaming Support * Enhanced API Client with Streaming Support and Date Conversion
* *
* This demonstrates how to use the generated types with the conversion utilities * This demonstrates how to use the generated types with the conversion utilities
* for seamless frontend-backend communication, including streaming responses. * for seamless frontend-backend communication, including streaming responses and
* automatic date field conversion.
*/ */
// Import generated types (from running generate_types.py) // Import generated types (from running generate_types.py)
import * as Types from 'types/types'; import * as Types from 'types/types';
import { import {
formatApiRequest, formatApiRequest,
// parseApiResponse, parseApiResponse,
// parsePaginatedResponse, parsePaginatedResponse,
handleApiResponse, handleApiResponse,
handlePaginatedApiResponse, handlePaginatedApiResponse,
createPaginatedRequest, createPaginatedRequest,
toUrlParams, toUrlParams,
// extractApiData, extractApiData,
// ApiResponse, ApiResponse,
PaginatedResponse, PaginatedResponse,
PaginatedRequest PaginatedRequest
} from 'types/conversion'; } from 'types/conversion';
// Import generated date conversion functions
import {
convertCandidateFromApi,
convertEmployerFromApi,
convertJobFromApi,
convertJobApplicationFromApi,
convertChatSessionFromApi,
convertChatMessageFromApi,
convertViewerFromApi,
convertFromApi,
convertArrayFromApi
} from 'types/types';
// ============================ // ============================
// Streaming Types and Interfaces // Streaming Types and Interfaces
// ============================ // ============================
@ -41,6 +55,49 @@ interface StreamingResponse {
promise: Promise<Types.ChatMessage[]>; promise: Promise<Types.ChatMessage[]>;
} }
export interface LoginRequest {
login: string; // email or username
password: string;
}
export interface CreateCandidateRequest {
email: string;
username: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
}
export interface CreateEmployerRequest {
email: string;
username: string;
password: string;
companyName: string;
industry: string;
companySize: string;
companyDescription: string;
websiteUrl?: string;
phone?: string;
}
export interface CreateViewerRequest {
email: string;
username: string;
password: string;
firstName: string;
lastName: string;
}
export interface PasswordResetRequest {
email: string;
}
export interface PasswordResetConfirm {
token: string;
newPassword: string;
}
// ============================ // ============================
// Chat Types and Interfaces // Chat Types and Interfaces
// ============================ // ============================
@ -93,16 +150,71 @@ class ApiClient {
} }
// ============================ // ============================
// Authentication Methods // Enhanced Response Handlers with Date Conversion
// ============================ // ============================
async login(login: string, password: string): Promise<Types.AuthResponse> { /**
* Handle API response with automatic date conversion for specific model types
*/
private async handleApiResponseWithConversion<T>(
response: Response,
modelType?: string
): Promise<T> {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
const apiResponse = parseApiResponse<T>(data);
const extractedData = extractApiData(apiResponse);
// Apply model-specific date conversion if modelType is provided
if (modelType) {
return convertFromApi<T>(extractedData, modelType);
}
return extractedData;
}
/**
* Handle paginated API response with automatic date conversion
*/
private async handlePaginatedApiResponseWithConversion<T>(
response: Response,
modelType?: string
): Promise<PaginatedResponse<T>> {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const data = await response.json();
const apiResponse = parsePaginatedResponse<T>(data);
const extractedData = extractApiData(apiResponse);
// Apply model-specific date conversion to array items if modelType is provided
if (modelType && extractedData.data) {
return {
...extractedData,
data: convertArrayFromApi<T>(extractedData.data, modelType)
};
}
return extractedData;
}
// ============================
// Authentication Methods
// ============================
async login(request: LoginRequest): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/login`, { const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ login, password })) body: JSON.stringify(formatApiRequest(request))
}); });
// AuthResponse doesn't typically have date fields, use standard handler
return handleApiResponse<Types.AuthResponse>(response); return handleApiResponse<Types.AuthResponse>(response);
} }
@ -127,17 +239,31 @@ class ApiClient {
} }
// ============================ // ============================
// Candidate Methods // Viewer Methods with Date Conversion
// ============================ // ============================
async createCandidate(candidate: Omit<Types.Candidate, 'id' | 'createdAt' | 'updatedAt'>): Promise<Types.Candidate> { async createViewer(request: CreateViewerRequest): Promise<Types.Viewer> {
const response = await fetch(`${this.baseUrl}/viewers`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return this.handleApiResponseWithConversion<Types.Viewer>(response, 'Viewer');
}
// ============================
// Candidate Methods with Date Conversion
// ============================
async createCandidate(request: CreateCandidateRequest): Promise<Types.Candidate> {
const response = await fetch(`${this.baseUrl}/candidates`, { const response = await fetch(`${this.baseUrl}/candidates`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(candidate)) body: JSON.stringify(formatApiRequest(request))
}); });
return handleApiResponse<Types.Candidate>(response); return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
} }
async getCandidate(username: string): Promise<Types.Candidate> { async getCandidate(username: string): Promise<Types.Candidate> {
@ -145,7 +271,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handleApiResponse<Types.Candidate>(response); return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
} }
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> { async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
@ -155,7 +281,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(updates)) body: JSON.stringify(formatApiRequest(updates))
}); });
return handleApiResponse<Types.Candidate>(response); return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
} }
async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> { async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> {
@ -166,7 +292,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.Candidate>(response); return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
} }
async searchCandidates(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Candidate>> { async searchCandidates(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Candidate>> {
@ -182,21 +308,21 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.Candidate>(response); return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
} }
// ============================ // ============================
// Employer Methods // Employer Methods with Date Conversion
// ============================ // ============================
async createEmployer(employer: Omit<Types.Employer, 'id' | 'createdAt' | 'updatedAt'>): Promise<Types.Employer> { async createEmployer(request: CreateEmployerRequest): Promise<Types.Employer> {
const response = await fetch(`${this.baseUrl}/employers`, { const response = await fetch(`${this.baseUrl}/employers`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(employer)) body: JSON.stringify(formatApiRequest(request))
}); });
return handleApiResponse<Types.Employer>(response); return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
} }
async getEmployer(id: string): Promise<Types.Employer> { async getEmployer(id: string): Promise<Types.Employer> {
@ -204,7 +330,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handleApiResponse<Types.Employer>(response); return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
} }
async updateEmployer(id: string, updates: Partial<Types.Employer>): Promise<Types.Employer> { async updateEmployer(id: string, updates: Partial<Types.Employer>): Promise<Types.Employer> {
@ -214,11 +340,11 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(updates)) body: JSON.stringify(formatApiRequest(updates))
}); });
return handleApiResponse<Types.Employer>(response); return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
} }
// ============================ // ============================
// Job Methods // Job Methods with Date Conversion
// ============================ // ============================
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> { async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
@ -228,7 +354,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(job)) body: JSON.stringify(formatApiRequest(job))
}); });
return handleApiResponse<Types.Job>(response); return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
} }
async getJob(id: string): Promise<Types.Job> { async getJob(id: string): Promise<Types.Job> {
@ -236,7 +362,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handleApiResponse<Types.Job>(response); return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
} }
async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> { async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
@ -247,7 +373,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.Job>(response); return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
} }
async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> { async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
@ -258,7 +384,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.Job>(response); return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
} }
async searchJobs(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Job>> { async searchJobs(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Job>> {
@ -274,11 +400,11 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.Job>(response); return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
} }
// ============================ // ============================
// Job Application Methods // Job Application Methods with Date Conversion
// ============================ // ============================
async applyToJob(application: Omit<Types.JobApplication, 'id' | 'appliedDate' | 'updatedDate' | 'status'>): Promise<Types.JobApplication> { async applyToJob(application: Omit<Types.JobApplication, 'id' | 'appliedDate' | 'updatedDate' | 'status'>): Promise<Types.JobApplication> {
@ -288,7 +414,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(application)) body: JSON.stringify(formatApiRequest(application))
}); });
return handleApiResponse<Types.JobApplication>(response); return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
} }
async getJobApplication(id: string): Promise<Types.JobApplication> { async getJobApplication(id: string): Promise<Types.JobApplication> {
@ -296,7 +422,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handleApiResponse<Types.JobApplication>(response); return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
} }
async getJobApplications(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.JobApplication>> { async getJobApplications(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.JobApplication>> {
@ -307,7 +433,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.JobApplication>(response); return this.handlePaginatedApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
} }
async updateApplicationStatus(id: string, status: Types.ApplicationStatus): Promise<Types.JobApplication> { async updateApplicationStatus(id: string, status: Types.ApplicationStatus): Promise<Types.JobApplication> {
@ -317,14 +443,14 @@ class ApiClient {
body: JSON.stringify(formatApiRequest({ status })) body: JSON.stringify(formatApiRequest({ status }))
}); });
return handleApiResponse<Types.JobApplication>(response); return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
} }
// ============================ // ============================
// Chat Methods // Chat Methods with Date Conversion
// ============================ // ============================
/** /**
* Create a chat session with optional candidate association * Create a chat session with optional candidate association
*/ */
async createChatSessionWithCandidate( async createChatSessionWithCandidate(
@ -336,7 +462,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(request)) body: JSON.stringify(formatApiRequest(request))
}); });
return handleApiResponse<Types.ChatSession>(response); return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
} }
/** /**
@ -353,7 +479,15 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handleApiResponse<CandidateSessionsResponse>(response); // Handle the nested sessions with date conversion
const result = await this.handleApiResponseWithConversion<CandidateSessionsResponse>(response);
// Convert the nested sessions array
if (result.sessions && result.sessions.data) {
result.sessions.data = convertArrayFromApi<Types.ChatSession>(result.sessions.data, 'ChatSession');
}
return result;
} }
/** /**
@ -383,7 +517,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest({ context })) body: JSON.stringify(formatApiRequest({ context }))
}); });
return handleApiResponse<Types.ChatSession>(response); return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
} }
async getChatSession(id: string): Promise<Types.ChatSession> { async getChatSession(id: string): Promise<Types.ChatSession> {
@ -391,7 +525,7 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handleApiResponse<Types.ChatSession>(response); return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
} }
/** /**
@ -404,11 +538,11 @@ class ApiClient {
body: JSON.stringify(formatApiRequest({query})) body: JSON.stringify(formatApiRequest({query}))
}); });
return handleApiResponse<Types.ChatMessage>(response); return this.handleApiResponseWithConversion<Types.ChatMessage>(response, 'ChatMessage');
} }
/** /**
* Send message with streaming response support * Send message with streaming response support and date conversion
*/ */
sendMessageStream( sendMessageStream(
sessionId: string, sessionId: string,
@ -445,7 +579,7 @@ class ApiClient {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let chatMessage: Types.ChatMessage | null = null; let chatMessage: Types.ChatMessage | null = null;
const chatMessageList : Types.ChatMessage[] = []; const chatMessageList: Types.ChatMessage[] = [];
try { try {
while (true) { while (true) {
@ -470,30 +604,35 @@ class ApiClient {
const data = line.slice(5).trim(); const data = line.slice(5).trim();
const incoming: Types.ChatMessageBase = JSON.parse(data); const incoming: Types.ChatMessageBase = JSON.parse(data);
// Convert date fields for incoming messages
const convertedIncoming = convertChatMessageFromApi(incoming);
// Trigger callbacks based on status // Trigger callbacks based on status
if (incoming.status !== chatMessage?.status) { if (convertedIncoming.status !== chatMessage?.status) {
options.onStatusChange?.(incoming.status); options.onStatusChange?.(convertedIncoming.status);
} }
// Handle different status types // Handle different status types
switch (incoming.status) { switch (convertedIncoming.status) {
case 'streaming': case 'streaming':
if (chatMessage === null) { if (chatMessage === null) {
chatMessage = {...incoming}; chatMessage = {...convertedIncoming};
} else { } else {
// Can't do a simple += as typescript thinks .content might not be there // Can't do a simple += as typescript thinks .content might not be there
chatMessage.content = (chatMessage?.content || '') + incoming.content; chatMessage.content = (chatMessage?.content || '') + convertedIncoming.content;
// Update timestamp to latest
chatMessage.timestamp = convertedIncoming.timestamp;
} }
options.onStreaming?.(incoming); options.onStreaming?.(convertedIncoming);
break; break;
case 'error': case 'error':
options.onError?.(incoming); options.onError?.(convertedIncoming);
break; break;
default: default:
chatMessageList.push(incoming); chatMessageList.push(convertedIncoming);
options.onMessage?.(incoming); options.onMessage?.(convertedIncoming);
break; break;
} }
} }
@ -550,7 +689,7 @@ class ApiClient {
} }
/** /**
* Get persisted chat messages for a session * Get persisted chat messages for a session with date conversion
*/ */
async getChatMessages( async getChatMessages(
sessionId: string, sessionId: string,
@ -565,17 +704,101 @@ class ApiClient {
headers: this.defaultHeaders headers: this.defaultHeaders
}); });
return handlePaginatedApiResponse<Types.ChatMessage>(response); return this.handlePaginatedApiResponseWithConversion<Types.ChatMessage>(response, 'ChatMessage');
}
// ============================
// Password Reset Methods
// ============================
async requestPasswordReset(request: PasswordResetRequest): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/password-reset/request`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<{ message: string }>(response);
}
async confirmPasswordReset(request: PasswordResetConfirm): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/password-reset/confirm`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<{ message: string }>(response);
}
// ============================
// Password Validation Utilities
// ============================
validatePasswordStrength(password: string): { isValid: boolean; issues: string[] } {
const issues: string[] = [];
if (password.length < 8) {
issues.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
issues.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
issues.push('Password must contain at least one lowercase letter');
}
if (!/\d/.test(password)) {
issues.push('Password must contain at least one digit');
}
const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?/~`"\'\\';
if (!password.split('').some(char => specialChars.includes(char))) {
issues.push('Password must contain at least one special character');
}
return {
isValid: issues.length === 0,
issues
};
}
validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
validateUsername(username: string): { isValid: boolean; issues: string[] } {
const issues: string[] = [];
if (username.length < 3) {
issues.push('Username must be at least 3 characters long');
}
if (username.length > 30) {
issues.push('Username must be no more than 30 characters long');
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
issues.push('Username can only contain letters, numbers, underscores, and hyphens');
}
return {
isValid: issues.length === 0,
issues
};
} }
// ============================ // ============================
// Error Handling Helper // Error Handling Helper
// ============================ // ============================
async handleRequest<T>(requestFn: () => Promise<Response>): Promise<T> { async handleRequest<T>(requestFn: () => Promise<Response>, modelType?: string): Promise<T> {
try { try {
const response = await requestFn(); const response = await requestFn();
return await handleApiResponse<T>(response); return await this.handleApiResponseWithConversion<T>(response, modelType);
} catch (error) { } catch (error) {
console.error('API request failed:', error); console.error('API request failed:', error);
throw error; throw error;
@ -600,10 +823,10 @@ class ApiClient {
} }
// ============================ // ============================
// React Hooks for Streaming // React Hooks for Streaming with Date Conversion
// ============================ // ============================
/* React Hook Examples for Streaming Chat /* React Hook Examples for Streaming Chat with proper date handling
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
export function useStreamingChat(sessionId: string) { export function useStreamingChat(sessionId: string) {
@ -622,31 +845,39 @@ export function useStreamingChat(sessionId: string) {
const streamingOptions: StreamingOptions = { const streamingOptions: StreamingOptions = {
onMessage: (message) => { onMessage: (message) => {
// Message already has proper Date objects from conversion
setCurrentMessage(message); setCurrentMessage(message);
}, },
onPartialMessage: (content, messageId) => { onStreaming: (chunk) => {
// Chunk also has proper Date objects
setCurrentMessage(prev => prev ? setCurrentMessage(prev => prev ?
{ ...prev, content: prev.content + content } : {
...prev,
content: prev.content + chunk.content,
timestamp: chunk.timestamp // Update to latest timestamp
} :
{ {
id: messageId || '', id: chunk.id || '',
sessionId, sessionId,
status: 'streaming', status: 'streaming',
sender: 'ai', sender: 'ai',
content, content: chunk.content,
timestamp: new Date() timestamp: chunk.timestamp // Already a Date object
} }
); );
}, },
onStatusChange: (status) => { onStatusChange: (status) => {
setCurrentMessage(prev => prev ? { ...prev, status } : null); setCurrentMessage(prev => prev ? { ...prev, status } : null);
}, },
onComplete: (finalMessage) => { onComplete: () => {
setMessages(prev => [...prev, finalMessage]); if (currentMessage) {
setMessages(prev => [...prev, currentMessage]);
}
setCurrentMessage(null); setCurrentMessage(null);
setIsStreaming(false); setIsStreaming(false);
}, },
onError: (err) => { onError: (err) => {
setError(err.message); setError(typeof err === 'string' ? err : err.content);
setIsStreaming(false); setIsStreaming(false);
setCurrentMessage(null); setCurrentMessage(null);
} }
@ -659,7 +890,7 @@ export function useStreamingChat(sessionId: string) {
setError(err instanceof Error ? err.message : 'Failed to send message'); setError(err instanceof Error ? err.message : 'Failed to send message');
setIsStreaming(false); setIsStreaming(false);
} }
}, [sessionId, apiClient]); }, [sessionId, apiClient, currentMessage]);
const cancelStreaming = useCallback(() => { const cancelStreaming = useCallback(() => {
if (streamingRef.current) { if (streamingRef.current) {
@ -679,7 +910,7 @@ export function useStreamingChat(sessionId: string) {
}; };
} }
// Usage in React component: // Usage in React component with proper date handling:
function ChatInterface({ sessionId }: { sessionId: string }) { function ChatInterface({ sessionId }: { sessionId: string }) {
const { const {
messages, messages,
@ -699,14 +930,26 @@ function ChatInterface({ sessionId }: { sessionId: string }) {
<div className="messages"> <div className="messages">
{messages.map(message => ( {messages.map(message => (
<div key={message.id}> <div key={message.id}>
<strong>{message.sender}:</strong> {message.content} <div className="message-header">
<strong>{message.sender}:</strong>
<span className="timestamp">
{message.timestamp.toLocaleTimeString()}
</span>
</div>
<div className="message-content">{message.content}</div>
</div> </div>
))} ))}
{currentMessage && ( {currentMessage && (
<div className="current-message"> <div className="current-message">
<strong>{currentMessage.sender}:</strong> {currentMessage.content} <div className="message-header">
{isStreaming && <span className="streaming-indicator">...</span>} <strong>{currentMessage.sender}:</strong>
<span className="timestamp">
{currentMessage.timestamp.toLocaleTimeString()}
</span>
{isStreaming && <span className="streaming-indicator">...</span>}
</div>
<div className="message-content">{currentMessage.content}</div>
</div> </div>
)} )}
</div> </div>
@ -734,61 +977,96 @@ function ChatInterface({ sessionId }: { sessionId: string }) {
*/ */
// ============================ // ============================
// Usage Examples // Usage Examples with Date Conversion
// ============================ // ============================
/* /*
// Initialize API client // Initialize API client
const apiClient = new ApiClient(); const apiClient = new ApiClient();
// Standard message sending (non-streaming) // All returned objects now have proper Date fields automatically!
// Create a candidate - createdAt, updatedAt, lastLogin are Date objects
try { try {
const message = await apiClient.sendMessage(sessionId, 'Hello, how are you?'); const candidate = await apiClient.createCandidate({
console.log('Response:', message.content); email: 'jane@example.com',
username: 'jane_doe',
password: 'SecurePassword123!',
firstName: 'Jane',
lastName: 'Doe'
});
// These are now Date objects, not strings!
console.log('Created at:', candidate.createdAt.toLocaleDateString());
console.log('Profile created on:', candidate.createdAt.toDateString());
if (candidate.lastLogin) {
console.log('Last seen:', candidate.lastLogin.toRelativeTimeString());
}
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to create candidate:', error);
} }
// Streaming message with callbacks // Get jobs with proper date conversion
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me a long story', { try {
onPartialMessage: (content, messageId) => { const jobs = await apiClient.getJobs({ limit: 10 });
console.log('Partial content:', content);
// Update UI with partial content jobs.data.forEach(job => {
// datePosted, applicationDeadline, featuredUntil are Date objects
console.log(`${job.title} - Posted: ${job.datePosted.toLocaleDateString()}`);
if (job.applicationDeadline) {
const daysRemaining = Math.ceil(
(job.applicationDeadline.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
);
console.log(`Deadline in ${daysRemaining} days`);
}
});
} catch (error) {
console.error('Failed to fetch jobs:', error);
}
// Streaming with proper date conversion
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', {
onStreaming: (chunk) => {
// chunk.timestamp is a Date object
console.log(`Streaming at ${chunk.timestamp.toLocaleTimeString()}:`, chunk.content);
}, },
onStatusChange: (status) => { onMessage: (message) => {
console.log('Status changed:', status); // message.timestamp is a Date object
// Update UI status indicator console.log(`Final message at ${message.timestamp.toLocaleTimeString()}:`, message.content);
}, },
onComplete: (finalMessage) => { onComplete: () => {
console.log('Final message:', finalMessage.content); console.log('Streaming completed');
// Handle completed message
},
onError: (error) => {
console.error('Streaming error:', error);
// Handle error
} }
}); });
// Can cancel the stream if needed // Chat sessions with date conversion
setTimeout(() => {
streamResponse.cancel();
}, 10000); // Cancel after 10 seconds
// Wait for completion
try { try {
const finalMessage = await streamResponse.promise; const chatSession = await apiClient.createChatSession({
console.log('Stream completed:', finalMessage); type: 'job_search',
additionalContext: {}
});
// createdAt and lastActivity are Date objects
console.log('Session created:', chatSession.createdAt.toISOString());
console.log('Last activity:', chatSession.lastActivity.toLocaleDateString());
} catch (error) { } catch (error) {
console.error('Stream failed:', error); console.error('Failed to create chat session:', error);
} }
// Auto-detection: streaming if callbacks provided, standard otherwise // Get chat messages with date conversion
await apiClient.sendMessageAuto(sessionId, 'Quick question', { try {
onPartialMessage: (content) => console.log('Streaming:', content) const messages = await apiClient.getChatMessages(sessionId);
}); // Will use streaming
messages.data.forEach(message => {
await apiClient.sendMessageAuto(sessionId, 'Quick question'); // Will use standard // timestamp is a Date object
console.log(`[${message.timestamp.toLocaleString()}] ${message.sender}: ${message.content}`);
});
} catch (error) {
console.error('Failed to fetch messages:', error);
}
*/ */
export { ApiClient } export { ApiClient }
export type { StreamingOptions, StreamingResponse }; export type { StreamingOptions, StreamingResponse }

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-05-29T23:38:18.286927 // Generated on: 2025-05-30T09:39:47.716115
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -57,9 +57,9 @@ export type UserGender = "female" | "male";
export type UserStatus = "active" | "inactive" | "pending" | "banned"; export type UserStatus = "active" | "inactive" | "pending" | "banned";
export type UserType = "candidate" | "employer" | "guest"; export type UserType = "candidate" | "employer" | "viewer" | "guest";
export type VectorStoreType = "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate"; export type VectorStoreType = "chroma";
// ============================ // ============================
// Interfaces // Interfaces
@ -135,7 +135,11 @@ export interface Authentication {
export interface BaseUser { export interface BaseUser {
id?: string; id?: string;
email: string; email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string; phone?: string;
location?: Location;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
lastLogin?: Date; lastLogin?: Date;
@ -146,19 +150,27 @@ export interface BaseUser {
export interface BaseUserWithType { export interface BaseUserWithType {
id?: string; id?: string;
email: string; email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string; phone?: string;
location?: Location;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
lastLogin?: Date; lastLogin?: Date;
profileImage?: string; profileImage?: string;
status: "active" | "inactive" | "pending" | "banned"; status: "active" | "inactive" | "pending" | "banned";
userType: "candidate" | "employer" | "guest"; userType: "candidate" | "employer" | "viewer" | "guest";
} }
export interface Candidate { export interface Candidate {
id?: string; id?: string;
email: string; email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string; phone?: string;
location?: Location;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
lastLogin?: Date; lastLogin?: Date;
@ -166,22 +178,18 @@ export interface Candidate {
status: "active" | "inactive" | "pending" | "banned"; status: "active" | "inactive" | "pending" | "banned";
userType?: "candidate"; userType?: "candidate";
username: string; username: string;
firstName: string;
lastName: string;
fullName: string;
description?: string; description?: string;
resume?: string; resume?: string;
skills: Array<Skill>; skills?: Array<Skill>;
experience: Array<WorkExperience>; experience?: Array<WorkExperience>;
questions?: Array<CandidateQuestion>; questions?: Array<CandidateQuestion>;
education: Array<Education>; education?: Array<Education>;
preferredJobTypes: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">; preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">;
desiredSalary?: DesiredSalary; desiredSalary?: DesiredSalary;
location: Location;
availabilityDate?: Date; availabilityDate?: Date;
summary?: string; summary?: string;
languages: Array<Language>; languages?: Array<Language>;
certifications: Array<Certification>; certifications?: Array<Certification>;
jobApplications?: Array<JobApplication>; jobApplications?: Array<JobApplication>;
hasProfile?: boolean; hasProfile?: boolean;
age?: number; age?: number;
@ -368,7 +376,11 @@ export interface Education {
export interface Employer { export interface Employer {
id?: string; id?: string;
email: string; email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string; phone?: string;
location?: Location;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
lastLogin?: Date; lastLogin?: Date;
@ -382,7 +394,6 @@ export interface Employer {
companyDescription: string; companyDescription: string;
websiteUrl?: string; websiteUrl?: string;
jobs?: Array<Job>; jobs?: Array<Job>;
location: Location;
companyLogo?: string; companyLogo?: string;
socialLinks?: Array<SocialLink>; socialLinks?: Array<SocialLink>;
poc?: PointOfContact; poc?: PointOfContact;
@ -564,7 +575,7 @@ export interface RAGConfiguration {
description?: string; description?: string;
dataSourceConfigurations: Array<DataSourceConfiguration>; dataSourceConfigurations: Array<DataSourceConfiguration>;
embeddingModel: string; embeddingModel: string;
vectorStoreType: "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate"; vectorStoreType: "chroma";
retrievalParameters: RetrievalParameters; retrievalParameters: RetrievalParameters;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@ -662,6 +673,23 @@ export interface UserPreference {
emailFrequency: "immediate" | "daily" | "weekly" | "never"; emailFrequency: "immediate" | "daily" | "weekly" | "never";
} }
export interface Viewer {
id?: string;
email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string;
location?: Location;
createdAt: Date;
updatedAt: Date;
lastLogin?: Date;
profileImage?: string;
status: "active" | "inactive" | "pending" | "banned";
userType?: "viewer";
username: string;
}
export interface WorkExperience { export interface WorkExperience {
id?: string; id?: string;
companyName: string; companyName: string;
@ -675,11 +703,492 @@ export interface WorkExperience {
achievements?: Array<string>; achievements?: Array<string>;
} }
// ============================
// Date Conversion Functions
// ============================
// These functions convert API responses to properly typed objects
// with Date objects instead of ISO date strings
/**
* Convert Analytics from API response, parsing date fields
* Date fields: timestamp
*/
export function convertAnalyticsFromApi(data: any): Analytics {
if (!data) return data;
return {
...data,
// Convert timestamp from ISO string to Date
timestamp: new Date(data.timestamp),
};
}
/**
* Convert ApplicationDecision from API response, parsing date fields
* Date fields: date
*/
export function convertApplicationDecisionFromApi(data: any): ApplicationDecision {
if (!data) return data;
return {
...data,
// Convert date from ISO string to Date
date: new Date(data.date),
};
}
/**
* Convert Attachment from API response, parsing date fields
* Date fields: uploadedAt
*/
export function convertAttachmentFromApi(data: any): Attachment {
if (!data) return data;
return {
...data,
// Convert uploadedAt from ISO string to Date
uploadedAt: new Date(data.uploadedAt),
};
}
/**
* Convert Authentication from API response, parsing date fields
* Date fields: resetPasswordExpiry, lastPasswordChange, lockedUntil
*/
export function convertAuthenticationFromApi(data: any): Authentication {
if (!data) return data;
return {
...data,
// Convert resetPasswordExpiry from ISO string to Date
resetPasswordExpiry: data.resetPasswordExpiry ? new Date(data.resetPasswordExpiry) : undefined,
// Convert lastPasswordChange from ISO string to Date
lastPasswordChange: new Date(data.lastPasswordChange),
// Convert lockedUntil from ISO string to Date
lockedUntil: data.lockedUntil ? new Date(data.lockedUntil) : undefined,
};
}
/**
* Convert BaseUser from API response, parsing date fields
* Date fields: createdAt, updatedAt, lastLogin
*/
export function convertBaseUserFromApi(data: any): BaseUser {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
// Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
};
}
/**
* Convert BaseUserWithType from API response, parsing date fields
* Date fields: createdAt, updatedAt, lastLogin
*/
export function convertBaseUserWithTypeFromApi(data: any): BaseUserWithType {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
// Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
};
}
/**
* Convert Candidate from API response, parsing date fields
* Date fields: createdAt, updatedAt, lastLogin, availabilityDate
*/
export function convertCandidateFromApi(data: any): Candidate {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
// Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
// Convert availabilityDate from ISO string to Date
availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined,
};
}
/**
* Convert Certification from API response, parsing date fields
* Date fields: issueDate, expirationDate
*/
export function convertCertificationFromApi(data: any): Certification {
if (!data) return data;
return {
...data,
// Convert issueDate from ISO string to Date
issueDate: new Date(data.issueDate),
// Convert expirationDate from ISO string to Date
expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined,
};
}
/**
* Convert ChatMessage from API response, parsing date fields
* Date fields: timestamp
*/
export function convertChatMessageFromApi(data: any): ChatMessage {
if (!data) return data;
return {
...data,
// Convert timestamp from ISO string to Date
timestamp: new Date(data.timestamp),
};
}
/**
* Convert ChatMessageBase from API response, parsing date fields
* Date fields: timestamp
*/
export function convertChatMessageBaseFromApi(data: any): ChatMessageBase {
if (!data) return data;
return {
...data,
// Convert timestamp from ISO string to Date
timestamp: new Date(data.timestamp),
};
}
/**
* Convert ChatMessageUser from API response, parsing date fields
* Date fields: timestamp
*/
export function convertChatMessageUserFromApi(data: any): ChatMessageUser {
if (!data) return data;
return {
...data,
// Convert timestamp from ISO string to Date
timestamp: new Date(data.timestamp),
};
}
/**
* Convert ChatSession from API response, parsing date fields
* Date fields: createdAt, lastActivity
*/
export function convertChatSessionFromApi(data: any): ChatSession {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
// Convert lastActivity from ISO string to Date
lastActivity: data.lastActivity ? new Date(data.lastActivity) : undefined,
};
}
/**
* Convert DataSourceConfiguration from API response, parsing date fields
* Date fields: lastRefreshed
*/
export function convertDataSourceConfigurationFromApi(data: any): DataSourceConfiguration {
if (!data) return data;
return {
...data,
// Convert lastRefreshed from ISO string to Date
lastRefreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : undefined,
};
}
/**
* Convert EditHistory from API response, parsing date fields
* Date fields: editedAt
*/
export function convertEditHistoryFromApi(data: any): EditHistory {
if (!data) return data;
return {
...data,
// Convert editedAt from ISO string to Date
editedAt: new Date(data.editedAt),
};
}
/**
* Convert Education from API response, parsing date fields
* Date fields: startDate, endDate
*/
export function convertEducationFromApi(data: any): Education {
if (!data) return data;
return {
...data,
// Convert startDate from ISO string to Date
startDate: new Date(data.startDate),
// Convert endDate from ISO string to Date
endDate: data.endDate ? new Date(data.endDate) : undefined,
};
}
/**
* Convert Employer from API response, parsing date fields
* Date fields: createdAt, updatedAt, lastLogin
*/
export function convertEmployerFromApi(data: any): Employer {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
// Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
};
}
/**
* Convert Guest from API response, parsing date fields
* Date fields: createdAt, lastActivity
*/
export function convertGuestFromApi(data: any): Guest {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert lastActivity from ISO string to Date
lastActivity: new Date(data.lastActivity),
};
}
/**
* Convert InterviewFeedback from API response, parsing date fields
* Date fields: createdAt, updatedAt
*/
export function convertInterviewFeedbackFromApi(data: any): InterviewFeedback {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
};
}
/**
* Convert InterviewSchedule from API response, parsing date fields
* Date fields: scheduledDate, endDate
*/
export function convertInterviewScheduleFromApi(data: any): InterviewSchedule {
if (!data) return data;
return {
...data,
// Convert scheduledDate from ISO string to Date
scheduledDate: new Date(data.scheduledDate),
// Convert endDate from ISO string to Date
endDate: new Date(data.endDate),
};
}
/**
* Convert Job from API response, parsing date fields
* Date fields: datePosted, applicationDeadline, featuredUntil
*/
export function convertJobFromApi(data: any): Job {
if (!data) return data;
return {
...data,
// Convert datePosted from ISO string to Date
datePosted: new Date(data.datePosted),
// Convert applicationDeadline from ISO string to Date
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined,
// Convert featuredUntil from ISO string to Date
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined,
};
}
/**
* Convert JobApplication from API response, parsing date fields
* Date fields: appliedDate, updatedDate
*/
export function convertJobApplicationFromApi(data: any): JobApplication {
if (!data) return data;
return {
...data,
// Convert appliedDate from ISO string to Date
appliedDate: new Date(data.appliedDate),
// Convert updatedDate from ISO string to Date
updatedDate: new Date(data.updatedDate),
};
}
/**
* Convert MessageReaction from API response, parsing date fields
* Date fields: timestamp
*/
export function convertMessageReactionFromApi(data: any): MessageReaction {
if (!data) return data;
return {
...data,
// Convert timestamp from ISO string to Date
timestamp: new Date(data.timestamp),
};
}
/**
* Convert RAGConfiguration from API response, parsing date fields
* Date fields: createdAt, updatedAt
*/
export function convertRAGConfigurationFromApi(data: any): RAGConfiguration {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
};
}
/**
* Convert RefreshToken from API response, parsing date fields
* Date fields: expiresAt
*/
export function convertRefreshTokenFromApi(data: any): RefreshToken {
if (!data) return data;
return {
...data,
// Convert expiresAt from ISO string to Date
expiresAt: new Date(data.expiresAt),
};
}
/**
* Convert UserActivity from API response, parsing date fields
* Date fields: timestamp
*/
export function convertUserActivityFromApi(data: any): UserActivity {
if (!data) return data;
return {
...data,
// Convert timestamp from ISO string to Date
timestamp: new Date(data.timestamp),
};
}
/**
* Convert Viewer from API response, parsing date fields
* Date fields: createdAt, updatedAt, lastLogin
*/
export function convertViewerFromApi(data: any): Viewer {
if (!data) return data;
return {
...data,
// Convert createdAt from ISO string to Date
createdAt: new Date(data.createdAt),
// Convert updatedAt from ISO string to Date
updatedAt: new Date(data.updatedAt),
// Convert lastLogin from ISO string to Date
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
};
}
/**
* Convert WorkExperience from API response, parsing date fields
* Date fields: startDate, endDate
*/
export function convertWorkExperienceFromApi(data: any): WorkExperience {
if (!data) return data;
return {
...data,
// Convert startDate from ISO string to Date
startDate: new Date(data.startDate),
// Convert endDate from ISO string to Date
endDate: data.endDate ? new Date(data.endDate) : undefined,
};
}
/**
* Generic converter that automatically selects the right conversion function
* based on the model type
*/
export function convertFromApi<T>(data: any, modelType: string): T {
if (!data) return data;
switch (modelType) {
case 'Analytics':
return convertAnalyticsFromApi(data) as T;
case 'ApplicationDecision':
return convertApplicationDecisionFromApi(data) as T;
case 'Attachment':
return convertAttachmentFromApi(data) as T;
case 'Authentication':
return convertAuthenticationFromApi(data) as T;
case 'BaseUser':
return convertBaseUserFromApi(data) as T;
case 'BaseUserWithType':
return convertBaseUserWithTypeFromApi(data) as T;
case 'Candidate':
return convertCandidateFromApi(data) as T;
case 'Certification':
return convertCertificationFromApi(data) as T;
case 'ChatMessage':
return convertChatMessageFromApi(data) as T;
case 'ChatMessageBase':
return convertChatMessageBaseFromApi(data) as T;
case 'ChatMessageUser':
return convertChatMessageUserFromApi(data) as T;
case 'ChatSession':
return convertChatSessionFromApi(data) as T;
case 'DataSourceConfiguration':
return convertDataSourceConfigurationFromApi(data) as T;
case 'EditHistory':
return convertEditHistoryFromApi(data) as T;
case 'Education':
return convertEducationFromApi(data) as T;
case 'Employer':
return convertEmployerFromApi(data) as T;
case 'Guest':
return convertGuestFromApi(data) as T;
case 'InterviewFeedback':
return convertInterviewFeedbackFromApi(data) as T;
case 'InterviewSchedule':
return convertInterviewScheduleFromApi(data) as T;
case 'Job':
return convertJobFromApi(data) as T;
case 'JobApplication':
return convertJobApplicationFromApi(data) as T;
case 'MessageReaction':
return convertMessageReactionFromApi(data) as T;
case 'RAGConfiguration':
return convertRAGConfigurationFromApi(data) as T;
case 'RefreshToken':
return convertRefreshTokenFromApi(data) as T;
case 'UserActivity':
return convertUserActivityFromApi(data) as T;
case 'Viewer':
return convertViewerFromApi(data) as T;
case 'WorkExperience':
return convertWorkExperienceFromApi(data) as T;
default:
return data as T;
}
}
/**
* Convert array of items using the appropriate converter
*/
export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {
if (!data || !Array.isArray(data)) return data;
return data.map(item => convertFromApi<T>(item, modelType));
}
// ============================ // ============================
// Union Types // Union Types
// ============================ // ============================
export type User = Candidate | Employer; export type User = Candidate | Employer | Viewer;
// Export all types // Export all types
export type { }; export type { };

268
src/backend/auth_utils.py Normal file
View File

@ -0,0 +1,268 @@
# auth_utils.py
"""
Secure Authentication Utilities
Provides password hashing, verification, and security features
"""
import bcrypt # type: ignore
import secrets
import logging
from datetime import datetime, timezone
from typing import Dict, Any, Optional, Tuple
from pydantic import BaseModel # type: ignore
logger = logging.getLogger(__name__)
class PasswordSecurity:
"""Handles password hashing and verification using bcrypt"""
@staticmethod
def hash_password(password: str) -> Tuple[str, str]:
"""
Hash a password with a random salt using bcrypt
Args:
password: Plain text password
Returns:
Tuple of (password_hash, salt) both as strings
"""
# Generate a random salt
salt = bcrypt.gensalt()
# Hash the password
password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
return password_hash.decode('utf-8'), salt.decode('utf-8')
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""
Verify a password against its hash
Args:
password: Plain text password to verify
password_hash: Stored password hash
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(
password.encode('utf-8'),
password_hash.encode('utf-8')
)
except Exception as e:
logger.error(f"Password verification error: {e}")
return False
@staticmethod
def generate_secure_token(length: int = 32) -> str:
"""Generate a cryptographically secure random token"""
return secrets.token_urlsafe(length)
class AuthenticationRecord(BaseModel):
"""Authentication record for storing user credentials"""
user_id: str
password_hash: str
salt: str
refresh_tokens: list = []
reset_password_token: Optional[str] = None
reset_password_expiry: Optional[datetime] = None
last_password_change: datetime
mfa_enabled: bool = False
mfa_method: Optional[str] = None
mfa_secret: Optional[str] = None
login_attempts: int = 0
locked_until: Optional[datetime] = None
class Config:
json_encoders = {
datetime: lambda v: v.isoformat() if v else None
}
class SecurityConfig:
"""Security configuration constants"""
MAX_LOGIN_ATTEMPTS = 5
ACCOUNT_LOCKOUT_DURATION_MINUTES = 15
PASSWORD_MIN_LENGTH = 8
TOKEN_EXPIRY_HOURS = 24
REFRESH_TOKEN_EXPIRY_DAYS = 30
class AuthenticationManager:
"""Manages authentication operations with security features"""
def __init__(self, database):
self.database = database
self.password_security = PasswordSecurity()
async def create_user_authentication(self, user_id: str, password: str) -> AuthenticationRecord:
"""
Create authentication record for a new user
Args:
user_id: Unique user identifier
password: Plain text password
Returns:
AuthenticationRecord object
"""
if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH:
raise ValueError(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long")
# Hash the password
password_hash, salt = self.password_security.hash_password(password)
# Create authentication record
auth_record = AuthenticationRecord(
user_id=user_id,
password_hash=password_hash,
salt=salt,
last_password_change=datetime.now(timezone.utc),
login_attempts=0
)
# Store in database
await self.database.set_authentication(user_id, auth_record.model_dump())
logger.info(f"🔐 Created authentication record for user {user_id}")
return auth_record
async def verify_user_credentials(self, login: str, password: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
"""
Verify user credentials with security checks
Args:
login: Email or username
password: Plain text password
Returns:
Tuple of (is_valid, user_data, error_message)
"""
try:
# Get user data
user_data = await self.database.get_user(login)
if not user_data:
logger.warning(f"⚠️ Login attempt with non-existent user: {login}")
return False, None, "Invalid credentials"
# Get authentication record
auth_record = await self.database.get_authentication(user_data["id"])
if not auth_record:
logger.error(f"❌ No authentication record found for user {user_data['id']}")
return False, None, "Authentication record not found"
auth_data = AuthenticationRecord.model_validate(auth_record)
# Check if account is locked
if auth_data.locked_until and auth_data.locked_until > datetime.now(timezone.utc):
logger.warning(f"🔒 Account locked for user {login}")
return False, None, "Account is temporarily locked due to too many failed attempts"
# Verify password
if not self.password_security.verify_password(password, auth_data.password_hash):
# Increment failed attempts
auth_data.login_attempts += 1
# Lock account if too many attempts
if auth_data.login_attempts >= SecurityConfig.MAX_LOGIN_ATTEMPTS:
from datetime import timedelta
auth_data.locked_until = datetime.now(timezone.utc) + timedelta(
minutes=SecurityConfig.ACCOUNT_LOCKOUT_DURATION_MINUTES
)
logger.warning(f"🔒 Account locked for user {login} after {auth_data.login_attempts} failed attempts")
# Update authentication record
await self.database.set_authentication(user_data["id"], auth_data.model_dump())
logger.warning(f"⚠️ Invalid password for user {login} (attempt {auth_data.login_attempts})")
return False, None, "Invalid credentials"
# Reset failed attempts on successful login
if auth_data.login_attempts > 0:
auth_data.login_attempts = 0
auth_data.locked_until = None
await self.database.set_authentication(user_data["id"], auth_data.model_dump())
logger.info(f"✅ Successful authentication for user {login}")
return True, user_data, None
except Exception as e:
logger.error(f"❌ Authentication error for user {login}: {e}")
return False, None, "Authentication failed"
async def check_user_exists(self, email: str, username: str | None = None) -> Tuple[bool, Optional[str]]:
"""
Check if a user already exists with the given email or username
Args:
email: Email address to check
username: Username to check (optional)
Returns:
Tuple of (exists, conflict_field)
"""
try:
# Check email
existing_user = await self.database.get_user(email)
if existing_user:
return True, "email"
# Check username if provided
if username:
existing_user = await self.database.get_user(username)
if existing_user:
return True, "username"
return False, None
except Exception as e:
logger.error(f"❌ Error checking user existence: {e}")
# In case of error, assume user doesn't exist to avoid blocking creation
return False, None
async def update_last_login(self, user_id: str):
"""Update user's last login timestamp"""
try:
user_data = await self.database.get_user_by_id(user_id)
if user_data:
user_data["lastLogin"] = datetime.now(timezone.utc).isoformat()
await self.database.set_user_by_id(user_id, user_data)
except Exception as e:
logger.error(f"❌ Error updating last login for user {user_id}: {e}")
# Utility functions for common operations
def validate_password_strength(password: str) -> Tuple[bool, list]:
"""
Validate password strength
Args:
password: Password to validate
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH:
issues.append(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long")
if not any(c.isupper() for c in password):
issues.append("Password must contain at least one uppercase letter")
if not any(c.islower() for c in password):
issues.append("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in password):
issues.append("Password must contain at least one digit")
# Check for special characters
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
if not any(c in special_chars for c in password):
issues.append("Password must contain at least one special character")
return len(issues) == 0, issues
def sanitize_login_input(login: str) -> str:
"""Sanitize login input (email or username)"""
return login.strip().lower() if login else ""

View File

@ -3,7 +3,7 @@ from typing import Optional, Dict, List, Optional, Any
import json import json
import logging import logging
import os import os
from datetime import datetime, timedelta, UTC from datetime import datetime, timezone, UTC, timedelta
import asyncio import asyncio
from models import ( from models import (
# User models # User models
@ -14,14 +14,14 @@ logger = logging.getLogger(__name__)
class _RedisManager: class _RedisManager:
def __init__(self): def __init__(self):
self.redis_client: 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._connection_pool: Optional[redis.ConnectionPool] = None self._connection_pool: Optional[redis.ConnectionPool] = None
self._is_connected = False self._is_connected = False
async def connect(self): async def connect(self):
"""Initialize Redis connection with connection pooling""" """Initialize Redis connection with connection pooling"""
if self._is_connected and self.redis_client: if self._is_connected and self.redis:
logger.info("Redis already connected") logger.info("Redis already connected")
return return
@ -38,26 +38,26 @@ class _RedisManager:
health_check_interval=30 health_check_interval=30
) )
self.redis_client = redis.Redis( self.redis = redis.Redis(
connection_pool=self._connection_pool connection_pool=self._connection_pool
) )
if not self.redis_client: if not self.redis:
raise RuntimeError("Redis client not initialized") raise RuntimeError("Redis client not initialized")
# Test connection # Test connection
await self.redis_client.ping() await self.redis.ping()
self._is_connected = True self._is_connected = True
logger.info("Successfully connected to Redis") logger.info("Successfully connected to Redis")
# Log Redis info # Log Redis info
info = await self.redis_client.info() info = await self.redis.info()
logger.info(f"Redis version: {info.get('redis_version', 'unknown')}") logger.info(f"Redis version: {info.get('redis_version', 'unknown')}")
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to Redis: {e}") logger.error(f"Failed to connect to Redis: {e}")
self._is_connected = False self._is_connected = False
self.redis_client = None self.redis = None
self._connection_pool = None self._connection_pool = None
raise raise
@ -68,12 +68,12 @@ class _RedisManager:
return return
try: try:
if self.redis_client: if self.redis:
# Wait for any pending operations to complete # Wait for any pending operations to complete
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Close the client # Close the client
await self.redis_client.aclose() await self.redis.aclose()
logger.info("Redis client closed") logger.info("Redis client closed")
if self._connection_pool: if self._connection_pool:
@ -82,7 +82,7 @@ class _RedisManager:
logger.info("Redis connection pool closed") logger.info("Redis connection pool closed")
self._is_connected = False self._is_connected = False
self.redis_client = None self.redis = None
self._connection_pool = None self._connection_pool = None
logger.info("Successfully disconnected from Redis") logger.info("Successfully disconnected from Redis")
@ -91,32 +91,32 @@ class _RedisManager:
logger.error(f"Error during Redis disconnect: {e}") logger.error(f"Error during Redis disconnect: {e}")
# Force cleanup even if there's an error # Force cleanup even if there's an error
self._is_connected = False self._is_connected = False
self.redis_client = None self.redis = None
self._connection_pool = None self._connection_pool = None
def get_client(self) -> redis.Redis: def get_client(self) -> redis.Redis:
"""Get Redis client instance""" """Get Redis client instance"""
if not self._is_connected or not self.redis_client: if not self._is_connected or not self.redis:
raise RuntimeError("Redis client not initialized or disconnected") raise RuntimeError("Redis client not initialized or disconnected")
return self.redis_client return self.redis
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Check if Redis is connected""" """Check if Redis is connected"""
return self._is_connected and self.redis_client is not None return self._is_connected and self.redis is not None
async def health_check(self) -> dict: async def health_check(self) -> dict:
"""Perform health check on Redis connection""" """Perform health check on Redis connection"""
if not self.is_connected: if not self.is_connected:
return {"status": "disconnected", "error": "Redis not connected"} return {"status": "disconnected", "error": "Redis not connected"}
if not self.redis_client: if not self.redis:
raise RuntimeError("Redis client not initialized") raise RuntimeError("Redis client not initialized")
try: try:
# Test basic operations # Test basic operations
await self.redis_client.ping() await self.redis.ping()
info = await self.redis_client.info() info = await self.redis.info()
return { return {
"status": "healthy", "status": "healthy",
@ -137,16 +137,16 @@ class _RedisManager:
return False return False
try: try:
if not self.redis_client: if not self.redis:
raise RuntimeError("Redis client not initialized") raise RuntimeError("Redis client not initialized")
if background: if background:
# Non-blocking background save # Non-blocking background save
await self.redis_client.bgsave() await self.redis.bgsave()
logger.info("Background save initiated") logger.info("Background save initiated")
else: else:
# Blocking save # Blocking save
await self.redis_client.save() await self.redis.save()
logger.info("Synchronous save completed") logger.info("Synchronous save completed")
return True return True
except Exception as e: except Exception as e:
@ -159,19 +159,20 @@ class _RedisManager:
return None return None
try: try:
if not self.redis_client: if not self.redis:
raise RuntimeError("Redis client not initialized") raise RuntimeError("Redis client not initialized")
return await self.redis_client.info() return await self.redis.info()
except Exception as e: except Exception as e:
logger.error(f"Failed to get Redis info: {e}") logger.error(f"Failed to get Redis info: {e}")
return None return None
class RedisDatabase: class RedisDatabase:
def __init__(self, redis_client: redis.Redis): def __init__(self, redis: redis.Redis):
self.redis_client = redis_client self.redis = redis
# Redis key prefixes for different data types # Redis key prefixes for different data types
self.KEY_PREFIXES = { self.KEY_PREFIXES = {
'viewers': 'viewer:',
'candidates': 'candidate:', 'candidates': 'candidate:',
'employers': 'employer:', 'employers': 'employer:',
'jobs': 'job:', 'jobs': 'job:',
@ -197,29 +198,67 @@ class RedisDatabase:
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error(f"Failed to deserialize data: {data}") logger.error(f"Failed to deserialize data: {data}")
return None return None
# Candidates operations # Viewer operations
async def get_candidate(self, candidate_id: str) -> Optional[Dict]: async def get_viewer(self, viewer_id: str) -> Optional[Dict]:
"""Get candidate by ID""" """Get viewer by ID"""
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
data = await self.redis_client.get(key) data = await self.redis.get(key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def set_candidate(self, candidate_id: str, candidate_data: Dict): async def set_viewer(self, viewer_id: str, viewer_data: Dict):
"""Set candidate data""" """Set viewer data"""
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
await self.redis_client.set(key, self._serialize(candidate_data)) await self.redis.set(key, self._serialize(viewer_data))
async def get_all_candidates(self) -> Dict[str, Any]: async def get_all_viewers(self) -> Dict[str, Any]:
"""Get all candidates""" """Get all viewers"""
pattern = f"{self.KEY_PREFIXES['candidates']}*" pattern = f"{self.KEY_PREFIXES['viewers']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
# Use pipeline for efficiency # Use pipeline for efficiency
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
result = {}
for key, value in zip(keys, values):
viewer_id = key.replace(self.KEY_PREFIXES['viewers'], '')
result[viewer_id] = self._deserialize(value)
return result
async def delete_viewer(self, viewer_id: str):
"""Delete viewer"""
key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
await self.redis.delete(key)
# Candidates operations
async def get_candidate(self, candidate_id: str) -> Optional[Dict]:
"""Get candidate by ID"""
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
data = await self.redis.get(key)
return self._deserialize(data) if data else None
async def set_candidate(self, candidate_id: str, candidate_data: Dict):
"""Set candidate data"""
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
await self.redis.set(key, self._serialize(candidate_data))
async def get_all_candidates(self) -> Dict[str, Any]:
"""Get all candidates"""
pattern = f"{self.KEY_PREFIXES['candidates']}*"
keys = await self.redis.keys(pattern)
if not keys:
return {}
# Use pipeline for efficiency
pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -234,29 +273,29 @@ class RedisDatabase:
async def delete_candidate(self, candidate_id: str): async def delete_candidate(self, candidate_id: str):
"""Delete candidate""" """Delete candidate"""
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}" key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Employers operations # Employers operations
async def get_employer(self, employer_id: str) -> Optional[Dict]: async def get_employer(self, employer_id: str) -> Optional[Dict]:
"""Get employer by ID""" """Get employer by ID"""
key = f"{self.KEY_PREFIXES['employers']}{employer_id}" key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
data = await self.redis_client.get(key) data = await self.redis.get(key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def set_employer(self, employer_id: str, employer_data: Dict): async def set_employer(self, employer_id: str, employer_data: Dict):
"""Set employer data""" """Set employer data"""
key = f"{self.KEY_PREFIXES['employers']}{employer_id}" key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
await self.redis_client.set(key, self._serialize(employer_data)) await self.redis.set(key, self._serialize(employer_data))
async def get_all_employers(self) -> Dict[str, Any]: async def get_all_employers(self) -> Dict[str, Any]:
"""Get all employers""" """Get all employers"""
pattern = f"{self.KEY_PREFIXES['employers']}*" pattern = f"{self.KEY_PREFIXES['employers']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -271,29 +310,29 @@ class RedisDatabase:
async def delete_employer(self, employer_id: str): async def delete_employer(self, employer_id: str):
"""Delete employer""" """Delete employer"""
key = f"{self.KEY_PREFIXES['employers']}{employer_id}" key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Jobs operations # Jobs operations
async def get_job(self, job_id: str) -> Optional[Dict]: async def get_job(self, job_id: str) -> Optional[Dict]:
"""Get job by ID""" """Get job by ID"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}" key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
data = await self.redis_client.get(key) data = await self.redis.get(key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def set_job(self, job_id: str, job_data: Dict): async def set_job(self, job_id: str, job_data: Dict):
"""Set job data""" """Set job data"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}" key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
await self.redis_client.set(key, self._serialize(job_data)) await self.redis.set(key, self._serialize(job_data))
async def get_all_jobs(self) -> Dict[str, Any]: async def get_all_jobs(self) -> Dict[str, Any]:
"""Get all jobs""" """Get all jobs"""
pattern = f"{self.KEY_PREFIXES['jobs']}*" pattern = f"{self.KEY_PREFIXES['jobs']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -308,29 +347,29 @@ class RedisDatabase:
async def delete_job(self, job_id: str): async def delete_job(self, job_id: str):
"""Delete job""" """Delete job"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}" key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Job Applications operations # Job Applications operations
async def get_job_application(self, application_id: str) -> Optional[Dict]: async def get_job_application(self, application_id: str) -> Optional[Dict]:
"""Get job application by ID""" """Get job application by ID"""
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}" key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
data = await self.redis_client.get(key) data = await self.redis.get(key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def set_job_application(self, application_id: str, application_data: Dict): async def set_job_application(self, application_id: str, application_data: Dict):
"""Set job application data""" """Set job application data"""
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}" key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
await self.redis_client.set(key, self._serialize(application_data)) await self.redis.set(key, self._serialize(application_data))
async def get_all_job_applications(self) -> Dict[str, Any]: async def get_all_job_applications(self) -> Dict[str, Any]:
"""Get all job applications""" """Get all job applications"""
pattern = f"{self.KEY_PREFIXES['job_applications']}*" pattern = f"{self.KEY_PREFIXES['job_applications']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -345,29 +384,29 @@ class RedisDatabase:
async def delete_job_application(self, application_id: str): async def delete_job_application(self, application_id: str):
"""Delete job application""" """Delete job application"""
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}" key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Chat Sessions operations # Chat Sessions operations
async def get_chat_session(self, session_id: str) -> Optional[Dict]: async def get_chat_session(self, session_id: str) -> Optional[Dict]:
"""Get chat session by ID""" """Get chat session by ID"""
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
data = await self.redis_client.get(key) data = await self.redis.get(key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def set_chat_session(self, session_id: str, session_data: Dict): async def set_chat_session(self, session_id: str, session_data: Dict):
"""Set chat session data""" """Set chat session data"""
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
await self.redis_client.set(key, self._serialize(session_data)) await self.redis.set(key, self._serialize(session_data))
async def get_all_chat_sessions(self) -> Dict[str, Any]: async def get_all_chat_sessions(self) -> Dict[str, Any]:
"""Get all chat sessions""" """Get all chat sessions"""
pattern = f"{self.KEY_PREFIXES['chat_sessions']}*" pattern = f"{self.KEY_PREFIXES['chat_sessions']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -382,36 +421,36 @@ class RedisDatabase:
async def delete_chat_session(self, session_id: str): async def delete_chat_session(self, session_id: str):
"""Delete chat session""" """Delete chat session"""
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}" key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Chat Messages operations (stored as lists) # Chat Messages operations (stored as lists)
async def get_chat_messages(self, session_id: str) -> List[Dict]: async def get_chat_messages(self, session_id: str) -> List[Dict]:
"""Get chat messages for a session""" """Get chat messages for a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
messages = await self.redis_client.lrange(key, 0, -1) messages = await self.redis.lrange(key, 0, -1)
return [self._deserialize(msg) for msg in messages if msg] return [self._deserialize(msg) for msg in messages if msg]
async def add_chat_message(self, session_id: str, message_data: Dict): async def add_chat_message(self, session_id: str, message_data: Dict):
"""Add a chat message to a session""" """Add a chat message to a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
await self.redis_client.rpush(key, self._serialize(message_data)) await self.redis.rpush(key, self._serialize(message_data))
async def set_chat_messages(self, session_id: str, messages: List[Dict]): async def set_chat_messages(self, session_id: str, messages: List[Dict]):
"""Set all chat messages for a session (replaces existing)""" """Set all chat messages for a session (replaces existing)"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
# Clear existing messages # Clear existing messages
await self.redis_client.delete(key) await self.redis.delete(key)
# Add new messages # Add new messages
if messages: if messages:
serialized_messages = [self._serialize(msg) for msg in messages] serialized_messages = [self._serialize(msg) for msg in messages]
await self.redis_client.rpush(key, *serialized_messages) await self.redis.rpush(key, *serialized_messages)
async def get_all_chat_messages(self) -> Dict[str, List[Dict]]: async def get_all_chat_messages(self) -> Dict[str, List[Dict]]:
"""Get all chat messages grouped by session""" """Get all chat messages grouped by session"""
pattern = f"{self.KEY_PREFIXES['chat_messages']}*" pattern = f"{self.KEY_PREFIXES['chat_messages']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
@ -419,7 +458,7 @@ class RedisDatabase:
result = {} result = {}
for key in keys: for key in keys:
session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '') session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '')
messages = await self.redis_client.lrange(key, 0, -1) messages = await self.redis.lrange(key, 0, -1)
result[session_id] = [self._deserialize(msg) for msg in messages if msg] result[session_id] = [self._deserialize(msg) for msg in messages if msg]
return result return result
@ -427,7 +466,7 @@ class RedisDatabase:
async def delete_chat_messages(self, session_id: str): async def delete_chat_messages(self, session_id: str):
"""Delete all chat messages for a session""" """Delete all chat messages for a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Enhanced Chat Session Methods # Enhanced Chat Session Methods
async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]: async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]:
@ -474,7 +513,7 @@ class RedisDatabase:
async def get_chat_message_count(self, session_id: str) -> int: async def get_chat_message_count(self, session_id: str) -> int:
"""Get the total number of messages in a chat session""" """Get the total number of messages in a chat session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
return await self.redis_client.llen(key) return await self.redis.llen(key)
async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]: async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]:
"""Search for messages containing specific text in a session""" """Search for messages containing specific text in a session"""
@ -526,7 +565,7 @@ class RedisDatabase:
async def get_user_by_username(self, username: str) -> Optional[Dict]: async def get_user_by_username(self, username: str) -> Optional[Dict]:
"""Get user by username specifically""" """Get user by username specifically"""
username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}" username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}"
data = await self.redis_client.get(username_key) data = await self.redis.get(username_key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def find_candidate_by_username(self, username: str) -> Optional[Dict]: async def find_candidate_by_username(self, username: str) -> Optional[Dict]:
@ -576,6 +615,7 @@ class RedisDatabase:
stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"] stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"]
return stats return stats
async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]: async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]:
"""Get a summary of chat activity for a specific candidate""" """Get a summary of chat activity for a specific candidate"""
@ -625,7 +665,7 @@ class RedisDatabase:
async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]): async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]):
"""Bulk update multiple chat sessions""" """Bulk update multiple chat sessions"""
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for session_id, updates in session_updates.items(): for session_id, updates in session_updates.items():
session_data = await self.get_chat_session(session_id) session_data = await self.get_chat_session(session_id)
@ -642,23 +682,23 @@ class RedisDatabase:
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]: async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
"""Get AI parameters by ID""" """Get AI parameters by ID"""
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}" key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
data = await self.redis_client.get(key) data = await self.redis.get(key)
return self._deserialize(data) if data else None return self._deserialize(data) if data else None
async def set_ai_parameters(self, param_id: str, param_data: Dict): async def set_ai_parameters(self, param_id: str, param_data: Dict):
"""Set AI parameters data""" """Set AI parameters data"""
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}" key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
await self.redis_client.set(key, self._serialize(param_data)) await self.redis.set(key, self._serialize(param_data))
async def get_all_ai_parameters(self) -> Dict[str, Any]: async def get_all_ai_parameters(self) -> Dict[str, Any]:
"""Get all AI parameters""" """Get all AI parameters"""
pattern = f"{self.KEY_PREFIXES['ai_parameters']}*" pattern = f"{self.KEY_PREFIXES['ai_parameters']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -673,37 +713,18 @@ class RedisDatabase:
async def delete_ai_parameters(self, param_id: str): async def delete_ai_parameters(self, param_id: str):
"""Delete AI parameters""" """Delete AI parameters"""
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}" key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Users operations (for auth)
async def get_user(self, login: str) -> Optional[Dict]:
"""Get user by email or username"""
if '@' in login:
email = login.lower()
key = f"{self.KEY_PREFIXES['users']}{email}"
else:
username = login.lower()
key = f"{self.KEY_PREFIXES['users']}{username}"
data = await self.redis_client.get(key)
return self._deserialize(data) if data else None
async def set_user(self, user: BaseUser, user_data: Dict):
"""Set user data"""
email_key = f"{self.KEY_PREFIXES['users']}{user.email.lower()}"
username_key = f"{self.KEY_PREFIXES['users']}{user.username.lower()}"
serialized_data = self._serialize(user_data)
await self.redis_client.set(email_key, serialized_data)
await self.redis_client.set(username_key, serialized_data)
async def get_all_users(self) -> Dict[str, Any]: async def get_all_users(self) -> Dict[str, Any]:
"""Get all users""" """Get all users"""
pattern = f"{self.KEY_PREFIXES['users']}*" pattern = f"{self.KEY_PREFIXES['users']}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if not keys: if not keys:
return {} return {}
pipe = self.redis_client.pipeline() pipe = self.redis.pipeline()
for key in keys: for key in keys:
pipe.get(key) pipe.get(key)
values = await pipe.execute() values = await pipe.execute()
@ -718,32 +739,334 @@ class RedisDatabase:
async def delete_user(self, email: str): async def delete_user(self, email: str):
"""Delete user""" """Delete user"""
key = f"{self.KEY_PREFIXES['users']}{email}" key = f"{self.KEY_PREFIXES['users']}{email}"
await self.redis_client.delete(key) await self.redis.delete(key)
# Utility methods # Utility methods
async def clear_all_data(self): async def clear_all_data(self):
"""Clear all data from Redis (use with caution!)""" """Clear all data from Redis (use with caution!)"""
for prefix in self.KEY_PREFIXES.values(): for prefix in self.KEY_PREFIXES.values():
pattern = f"{prefix}*" pattern = f"{prefix}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
if keys: if keys:
await self.redis_client.delete(*keys) await self.redis.delete(*keys)
async def get_stats(self) -> Dict[str, int]: async def get_stats(self) -> Dict[str, int]:
"""Get statistics about stored data""" """Get statistics about stored data"""
stats = {} stats = {}
for data_type, prefix in self.KEY_PREFIXES.items(): for data_type, prefix in self.KEY_PREFIXES.items():
pattern = f"{prefix}*" pattern = f"{prefix}*"
keys = await self.redis_client.keys(pattern) keys = await self.redis.keys(pattern)
stats[data_type] = len(keys) stats[data_type] = len(keys)
return stats return stats
# Authentication Record Methods
async def set_authentication(self, user_id: str, auth_data: Dict[str, Any]) -> bool:
"""Store authentication record for a user"""
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}")
return True
except Exception as e:
logger.error(f"❌ Error storing authentication record for {user_id}: {e}")
return False
async def get_authentication(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve authentication record for a user"""
try:
key = f"auth:{user_id}"
data = await self.redis.get(key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"❌ Error retrieving authentication record for {user_id}: {e}")
return None
async def delete_authentication(self, user_id: str) -> bool:
"""Delete authentication record for a user"""
try:
key = f"auth:{user_id}"
result = await self.redis.delete(key)
logger.debug(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}")
return False
# Enhanced User Methods
async def set_user_by_id(self, user_id: str, user_data: Dict[str, Any]) -> bool:
"""Store user data with ID as key for direct lookup"""
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}")
return True
except Exception as e:
logger.error(f"❌ Error storing user by ID {user_id}: {e}")
return False
async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve user data by ID"""
try:
key = f"user_by_id:{user_id}"
data = await self.redis.get(key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"❌ Error retrieving user by ID {user_id}: {e}")
return None
async def user_exists_by_email(self, email: str) -> bool:
"""Check if a user exists with the given email"""
try:
key = f"users:{email.lower()}"
exists = await self.redis.exists(key)
return exists > 0
except Exception as e:
logger.error(f"❌ Error checking user existence by email {email}: {e}")
return False
async def user_exists_by_username(self, username: str) -> bool:
"""Check if a user exists with the given username"""
try:
key = f"users:{username.lower()}"
exists = await self.redis.exists(key)
return exists > 0
except Exception as e:
logger.error(f"❌ Error checking user existence by username {username}: {e}")
return False
# Enhanced user lookup method to support both email and username
async def get_user(self, login: str) -> Optional[Dict[str, Any]]:
"""
Get user by email or username
"""
try:
# Normalize the login string
login = login.strip().lower()
key = f"users:{login}"
data = await self.redis.get(key)
if data:
user_data = json.loads(data)
logger.debug(f"👤 Retrieved user data for {login}")
return user_data
logger.debug(f"👤 No user found for {login}")
return None
except Exception as e:
logger.error(f"❌ Error retrieving user {login}: {e}")
return None
async def set_user(self, login: str, user_data: Dict[str, Any]) -> bool:
"""
Enhanced method to store user data by email or username
Updated version of your existing method
"""
try:
# Normalize the login string
login = login.strip().lower()
key = f"users:{login}"
await self.redis.set(key, json.dumps(user_data, default=str))
logger.debug(f"👤 Stored user data for {login}")
return True
except Exception as e:
logger.error(f"❌ Error storing user {login}: {e}")
return False
# Token Management Methods
async def store_refresh_token(self, user_id: str, token: str, expires_at: datetime, device_info: Dict[str, str]) -> bool:
"""Store refresh token for a user"""
try:
key = f"refresh_token:{token}"
token_data = {
"user_id": user_id,
"expires_at": expires_at.isoformat(),
"device": device_info.get("device", "unknown"),
"ip_address": device_info.get("ip_address", "unknown"),
"is_revoked": False,
"created_at": datetime.now(timezone.utc).isoformat()
}
# Store with expiration
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}")
return True
else:
logger.warning(f"⚠️ Attempted to store expired refresh token for user {user_id}")
return False
except Exception as e:
logger.error(f"❌ Error storing refresh token for {user_id}: {e}")
return False
async def get_refresh_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Retrieve refresh token data"""
try:
key = f"refresh_token:{token}"
data = await self.redis.get(key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"❌ Error retrieving refresh token: {e}")
return None
async def revoke_refresh_token(self, token: str) -> bool:
"""Revoke a refresh token"""
try:
key = f"refresh_token:{token}"
token_data = await self.get_refresh_token(token)
if token_data:
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")
return True
return False
except Exception as e:
logger.error(f"❌ Error revoking refresh token: {e}")
return False
async def revoke_all_user_tokens(self, user_id: str) -> bool:
"""Revoke all refresh tokens for a user"""
try:
# This requires scanning all refresh tokens - consider using a user token index for efficiency
pattern = "refresh_token:*"
cursor = 0
revoked_count = 0
while True:
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
for key in keys:
token_data = await self.redis.get(key)
if token_data:
token_info = json.loads(token_data)
if token_info.get("user_id") == user_id and not token_info.get("is_revoked"):
token_info["is_revoked"] = True
token_info["revoked_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.set(key, json.dumps(token_info, default=str))
revoked_count += 1
if cursor == 0:
break
logger.info(f"🔐 Revoked {revoked_count} refresh tokens for user {user_id}")
return True
except Exception as e:
logger.error(f"❌ Error revoking all tokens for user {user_id}: {e}")
return False
# Password Reset Token Methods
async def store_password_reset_token(self, email: str, token: str, expires_at: datetime) -> bool:
"""Store password reset token"""
try:
key = f"password_reset:{token}"
token_data = {
"email": email.lower(),
"expires_at": expires_at.isoformat(),
"used": False,
"created_at": datetime.now(timezone.utc).isoformat()
}
# Store with expiration
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}")
return True
else:
logger.warning(f"⚠️ Attempted to store expired password reset token for {email}")
return False
except Exception as e:
logger.error(f"❌ Error storing password reset token for {email}: {e}")
return False
async def get_password_reset_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Retrieve password reset token data"""
try:
key = f"password_reset:{token}"
data = await self.redis.get(key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"❌ Error retrieving password reset token: {e}")
return None
async def mark_password_reset_token_used(self, token: str) -> bool:
"""Mark password reset token as used"""
try:
key = f"password_reset:{token}"
token_data = await self.get_password_reset_token(token)
if token_data:
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")
return True
return False
except Exception as e:
logger.error(f"❌ Error marking password reset token as used: {e}")
return False
# User Activity and Security Logging
async def log_security_event(self, user_id: str, event_type: str, details: Dict[str, Any]) -> bool:
"""Log security events for audit purposes"""
try:
key = f"security_log:{user_id}:{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
event_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"event_type": event_type,
"details": details
}
# Add to list (latest events first)
await self.redis.lpush(key, json.dumps(event_data, default=str))
# Keep only last 100 events per day
await self.redis.ltrim(key, 0, 99)
# 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}")
return True
except Exception as e:
logger.error(f"❌ Error logging security event for {user_id}: {e}")
return False
async def get_user_security_log(self, user_id: str, days: int = 7) -> List[Dict[str, Any]]:
"""Retrieve security log for a user"""
try:
events = []
for i in range(days):
date = (datetime.now(timezone.utc) - timedelta(days=i)).strftime('%Y-%m-%d')
key = f"security_log:{user_id}:{date}"
daily_events = await self.redis.lrange(key, 0, -1)
for event_json in daily_events:
events.append(json.loads(event_json))
# Sort by timestamp (most recent first)
events.sort(key=lambda x: x["timestamp"], reverse=True)
return events
except Exception as e:
logger.error(f"❌ Error retrieving security log for {user_id}: {e}")
return []
# Global Redis manager instance # Global Redis manager instance
redis_manager = _RedisManager() redis_manager = _RedisManager()
class DatabaseManager: class DatabaseManager:
"""Enhanced database manager with graceful shutdown capabilities""" """Enhanced database manager with graceful shutdown capabilities"""
def __init__(self): def __init__(self):
self.db: Optional[RedisDatabase] = None self.db: Optional[RedisDatabase] = None
self._shutdown_initiated = False self._shutdown_initiated = False
@ -762,9 +1085,9 @@ class DatabaseManager:
self.db = RedisDatabase(redis_manager.get_client()) self.db = RedisDatabase(redis_manager.get_client())
# Test connection and log stats # Test connection and log stats
if not redis_manager.redis_client: if not redis_manager.redis:
raise RuntimeError("Redis client not initialized") raise RuntimeError("Redis client not initialized")
await redis_manager.redis_client.ping() await redis_manager.redis.ping()
stats = await self.db.get_stats() stats = await self.db.get_stats()
logger.info(f"Database initialized successfully. Stats: {stats}") logger.info(f"Database initialized successfully. Stats: {stats}")
@ -823,10 +1146,10 @@ class DatabaseManager:
# Force Redis to save data to disk # Force Redis to save data to disk
try: try:
if redis_manager.redis_client: if redis_manager.redis:
# Try BGSAVE first (non-blocking) # Try BGSAVE first (non-blocking)
try: try:
await redis_manager.redis_client.bgsave() await redis_manager.redis.bgsave()
logger.info("Background save initiated") logger.info("Background save initiated")
# Wait a bit for background save to start # Wait a bit for background save to start
@ -836,7 +1159,7 @@ class DatabaseManager:
logger.warning(f"Background save failed, trying synchronous save: {e}") logger.warning(f"Background save failed, trying synchronous save: {e}")
try: try:
# Fallback to synchronous save # Fallback to synchronous save
await redis_manager.redis_client.save() await redis_manager.redis.save()
logger.info("Synchronous save completed") logger.info("Synchronous save completed")
except Exception as e2: except Exception as e2:
logger.warning(f"Synchronous save also failed (Redis persistence may be disabled): {e2}") logger.warning(f"Synchronous save also failed (Redis persistence may be disabled): {e2}")
@ -872,4 +1195,5 @@ class DatabaseManager:
raise RuntimeError("Database not initialized") raise RuntimeError("Database not initialized")
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

@ -41,6 +41,9 @@ def test_model_creation():
# Create employer # Create employer
employer = Employer( employer = Employer(
firstName="Mary",
lastName="Smith",
fullName="Mary Smith",
email="hr@company.com", email="hr@company.com",
username="test_employer", username="test_employer",
createdAt=datetime.now(), createdAt=datetime.now(),

View File

@ -1,7 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
""" """
Enhanced Type Generator - Generate TypeScript types from Pydantic models Enhanced Type Generator - Generate TypeScript types from Pydantic models
Now with command line parameters, pre-test validation, and TypeScript compilation Now with command line parameters, pre-test validation, TypeScript compilation,
and automatic date field conversion functions
""" """
import sys import sys
@ -84,6 +85,59 @@ def unwrap_annotated_type(python_type: Any) -> Any:
return python_type return python_type
def is_date_type(python_type: Any) -> bool:
"""Check if a Python type represents a date/datetime"""
# Unwrap any annotations first
python_type = unwrap_annotated_type(python_type)
# Handle Union types (like Optional[datetime])
origin = get_origin(python_type)
if origin is Union:
args = get_args(python_type)
# Check if any of the union args is a date type (excluding None)
return any(is_date_type(arg) for arg in args if arg is not type(None))
# Direct datetime check
if python_type == datetime:
return True
# Check if it's a datetime type from the datetime module
if hasattr(python_type, '__module__') and hasattr(python_type, '__name__'):
module_name = getattr(python_type, '__module__', '')
type_name = getattr(python_type, '__name__', '')
# Check for datetime module types
if module_name == 'datetime' and type_name in ('datetime', 'date', 'time'):
return True
# String representation checks for specific datetime patterns (more restrictive)
type_str = str(python_type)
# Be very specific about datetime patterns to avoid false positives
specific_date_patterns = [
'datetime.datetime',
'datetime.date',
'datetime.time',
'<class \'datetime.datetime\'>',
'<class \'datetime.date\'>',
'<class \'datetime.time\'>',
'typing.DateTime',
'pydantic.datetime',
]
# Check for exact matches or specific patterns
for pattern in specific_date_patterns:
if pattern in type_str:
return True
# Additional check for common datetime type aliases
if hasattr(python_type, '__origin__'):
origin_str = str(python_type.__origin__)
if 'datetime' in origin_str and 'datetime.' in origin_str:
return True
return False
def python_type_to_typescript(python_type: Any, debug: bool = False) -> str: def python_type_to_typescript(python_type: Any, debug: bool = False) -> str:
"""Convert a Python type to TypeScript type string""" """Convert a Python type to TypeScript type string"""
@ -302,6 +356,7 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
"""Process a Pydantic model and return TypeScript interface definition""" """Process a Pydantic model and return TypeScript interface definition"""
interface_name = model_class.__name__ interface_name = model_class.__name__
properties = [] properties = []
date_fields = [] # Track date fields for conversion functions
if debug: if debug:
print(f" 🔍 Processing model: {interface_name}") print(f" 🔍 Processing model: {interface_name}")
@ -327,6 +382,22 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
if debug: if debug:
print(f" Raw type: {field_type}") print(f" Raw type: {field_type}")
# Check if this is a date field
is_date = is_date_type(field_type)
if debug:
print(f" 📅 Date type check for {ts_name}: {is_date} (type: {field_type})")
if is_date:
is_optional = is_field_optional(field_info, field_type, debug)
date_fields.append({
'name': ts_name,
'optional': is_optional
})
if debug:
print(f" 🗓️ Date field detected: {ts_name} (optional: {is_optional})")
elif debug and ('date' in str(field_type).lower() or 'time' in str(field_type).lower()):
print(f" ⚠️ Field {ts_name} contains 'date'/'time' but not detected as date type: {field_type}")
ts_type = python_type_to_typescript(field_type, debug) ts_type = python_type_to_typescript(field_type, debug)
# Check if optional # Check if optional
@ -362,6 +433,22 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
if debug: if debug:
print(f" Raw type: {field_type}") print(f" Raw type: {field_type}")
# Check if this is a date field
is_date = is_date_type(field_type)
if debug:
print(f" 📅 Date type check for {ts_name}: {is_date} (type: {field_type})")
if is_date:
is_optional = is_field_optional(field_info, field_type)
date_fields.append({
'name': ts_name,
'optional': is_optional
})
if debug:
print(f" 🗓️ Date field detected: {ts_name} (optional: {is_optional})")
elif debug and ('date' in str(field_type).lower() or 'time' in str(field_type).lower()):
print(f" ⚠️ Field {ts_name} contains 'date'/'time' but not detected as date type: {field_type}")
ts_type = python_type_to_typescript(field_type, debug) ts_type = python_type_to_typescript(field_type, debug)
# For Pydantic v1, check required and default # For Pydantic v1, check required and default
@ -396,7 +483,8 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
return { return {
'name': interface_name, 'name': interface_name,
'properties': properties 'properties': properties,
'date_fields': date_fields
} }
def process_enum(enum_class) -> Dict[str, Any]: def process_enum(enum_class) -> Dict[str, Any]:
@ -410,6 +498,106 @@ def process_enum(enum_class) -> Dict[str, Any]:
'values': " | ".join(values) 'values': " | ".join(values)
} }
def generate_conversion_functions(interfaces: List[Dict[str, Any]]) -> str:
"""Generate TypeScript conversion functions for models with date fields"""
conversion_functions = []
for interface in interfaces:
interface_name = interface['name']
date_fields = interface.get('date_fields', [])
if not date_fields:
continue # Skip interfaces without date fields
function_name = f"convert{interface_name}FromApi"
# Generate function
func_lines = [
f"/**",
f" * Convert {interface_name} from API response, parsing date fields",
f" * Date fields: {', '.join([f['name'] for f in date_fields])}",
f" */",
f"export function {function_name}(data: any): {interface_name} {{",
f" if (!data) return data;",
f" ",
f" return {{",
f" ...data,"
]
# Add date field conversions with validation
for date_field in date_fields:
field_name = date_field['name']
is_optional = date_field['optional']
# Add a comment for clarity
func_lines.append(f" // Convert {field_name} from ISO string to Date")
if is_optional:
func_lines.append(f" {field_name}: data.{field_name} ? new Date(data.{field_name}) : undefined,")
else:
func_lines.append(f" {field_name}: new Date(data.{field_name}),")
func_lines.extend([
f" }};",
f"}}"
])
conversion_functions.append('\n'.join(func_lines))
if not conversion_functions:
return ""
# Generate the conversion functions section
result = [
"// ============================",
"// Date Conversion Functions",
"// ============================",
"",
"// These functions convert API responses to properly typed objects",
"// with Date objects instead of ISO date strings",
"",
]
result.extend(conversion_functions)
result.append("")
# Generate a generic converter function
models_with_dates = [interface['name'] for interface in interfaces if interface.get('date_fields')]
if models_with_dates:
result.extend([
"/**",
" * Generic converter that automatically selects the right conversion function",
" * based on the model type",
" */",
"export function convertFromApi<T>(data: any, modelType: string): T {",
" if (!data) return data;",
" ",
" switch (modelType) {"
])
for model_name in models_with_dates:
result.append(f" case '{model_name}':")
result.append(f" return convert{model_name}FromApi(data) as T;")
result.extend([
" default:",
" return data as T;",
" }",
"}",
"",
"/**",
" * Convert array of items using the appropriate converter",
" */",
"export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {",
" if (!data || !Array.isArray(data)) return data;",
" return data.map(item => convertFromApi<T>(item, modelType));",
"}",
""
])
return '\n'.join(result)
def generate_typescript_interfaces(source_file: str, debug: bool = False): def generate_typescript_interfaces(source_file: str, debug: bool = False):
"""Generate TypeScript interfaces from models""" """Generate TypeScript interfaces from models"""
@ -449,7 +637,8 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
interface = process_pydantic_model(obj, debug) interface = process_pydantic_model(obj, debug)
interfaces.append(interface) interfaces.append(interface)
print(f" ✅ Found Pydantic model: {name}") date_count = len(interface.get('date_fields', []))
print(f" ✅ Found Pydantic model: {name}" + (f" ({date_count} date fields)" if date_count > 0 else ""))
# Check if it's an Enum # Check if it's an Enum
elif (isinstance(obj, type) and elif (isinstance(obj, type) and
@ -466,7 +655,9 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
traceback.print_exc() traceback.print_exc()
continue continue
total_date_fields = sum(len(interface.get('date_fields', [])) for interface in interfaces)
print(f"\n📊 Found {len(interfaces)} interfaces and {len(enums)} enums") print(f"\n📊 Found {len(interfaces)} interfaces and {len(enums)} enums")
print(f"🗓️ Found {total_date_fields} date fields across all models")
# Generate TypeScript content # Generate TypeScript content
ts_content = f"""// Generated TypeScript types from Pydantic models ts_content = f"""// Generated TypeScript types from Pydantic models
@ -500,8 +691,13 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
ts_content += "}\n\n" ts_content += "}\n\n"
# Add conversion functions
conversion_functions = generate_conversion_functions(interfaces)
if conversion_functions:
ts_content += conversion_functions
# Add user union type if we have user types # Add user union type if we have user types
user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer']] user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer', 'Viewer']]
if len(user_interfaces) >= 2: if len(user_interfaces) >= 2:
ts_content += "// ============================\n" ts_content += "// ============================\n"
ts_content += "// Union Types\n" ts_content += "// Union Types\n"
@ -534,7 +730,7 @@ def compile_typescript(ts_file: str) -> bool:
def main(): def main():
"""Main function with command line argument parsing""" """Main function with command line argument parsing"""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Generate TypeScript types from Pydantic models', description='Generate TypeScript types from Pydantic models with date conversion functions',
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Examples:
@ -544,6 +740,10 @@ Examples:
python generate_types.py --skip-compile # Skip TS compilation python generate_types.py --skip-compile # Skip TS compilation
python generate_types.py --debug # Enable debug output python generate_types.py --debug # Enable debug output
python generate_types.py --source models.py --output types.ts --skip-test --skip-compile --debug python generate_types.py --source models.py --output types.ts --skip-test --skip-compile --debug
Generated conversion functions can be used like:
const candidate = convertCandidateFromApi(apiResponse);
const jobs = convertArrayFromApi<Job>(apiResponse, 'Job');
""" """
) )
@ -580,13 +780,13 @@ Examples:
parser.add_argument( parser.add_argument(
'--version', '-v', '--version', '-v',
action='version', action='version',
version='TypeScript Generator 2.0' version='TypeScript Generator 3.0 (with Date Conversion)'
) )
args = parser.parse_args() args = parser.parse_args()
print("🚀 Enhanced TypeScript Type Generator") print("🚀 Enhanced TypeScript Type Generator with Date Conversion")
print("=" * 50) print("=" * 60)
print(f"📁 Source file: {args.source}") print(f"📁 Source file: {args.source}")
print(f"📁 Output file: {args.output}") print(f"📁 Output file: {args.output}")
print() print()
@ -608,7 +808,7 @@ Examples:
print() print()
# Step 3: Generate TypeScript content # Step 3: Generate TypeScript content
print("🔄 Generating TypeScript types...") print("🔄 Generating TypeScript types and conversion functions...")
if args.debug: if args.debug:
print("🐛 Debug mode enabled - detailed output follows:") print("🐛 Debug mode enabled - detailed output follows:")
print() print()
@ -628,6 +828,28 @@ Examples:
file_size = len(ts_content) file_size = len(ts_content)
print(f"✅ TypeScript types generated: {args.output} ({file_size} characters)") print(f"✅ TypeScript types generated: {args.output} ({file_size} characters)")
# Count conversion functions and provide detailed feedback
conversion_count = ts_content.count('export function convert') - ts_content.count('convertFromApi') - ts_content.count('convertArrayFromApi')
if conversion_count > 0:
print(f"🗓️ Generated {conversion_count} date conversion functions")
if args.debug:
# Show which models have date conversion
models_with_dates = []
for line in ts_content.split('\n'):
if line.startswith('export function convert') and 'FromApi' in line and 'convertFromApi' not in line:
model_name = line.split('convert')[1].split('FromApi')[0]
models_with_dates.append(model_name)
if models_with_dates:
print(f" Models with date conversion: {', '.join(models_with_dates)}")
# Provide troubleshooting info if debug mode
if args.debug:
print(f"\n🐛 Debug mode was enabled. If you see incorrect date conversions:")
print(f" 1. Check the debug output above for '📅 Date type check' lines")
print(f" 2. Look for '⚠️' warnings about false positives")
print(f" 3. Verify your Pydantic model field types are correct")
print(f" 4. Re-run with --debug to see detailed type analysis")
# Step 5: Compile TypeScript (unless skipped) # Step 5: Compile TypeScript (unless skipped)
if not args.skip_compile: if not args.skip_compile:
print() print()
@ -639,15 +861,21 @@ Examples:
# Step 6: Success summary # Step 6: Success summary
print(f"\n🎉 Type generation completed successfully!") print(f"\n🎉 Type generation completed successfully!")
print("=" * 50) print("=" * 60)
print(f"✅ Generated {args.output} from {args.source}") print(f"✅ Generated {args.output} from {args.source}")
print(f"✅ File size: {file_size} characters") print(f"✅ File size: {file_size} characters")
if conversion_count > 0:
print(f"✅ Date conversion functions: {conversion_count}")
if not args.skip_test: if not args.skip_test:
print("✅ Model validation passed") print("✅ Model validation passed")
if not args.skip_compile: if not args.skip_compile:
print("✅ TypeScript syntax validated") print("✅ TypeScript syntax validated")
print(f"\n💡 Usage in your TypeScript project:") print(f"\n💡 Usage in your TypeScript project:")
print(f" import {{ Candidate, Employer, Job }} from './{Path(args.output).stem}';") print(f" import {{ Candidate, Employer, Job, convertCandidateFromApi }} from './{Path(args.output).stem}';")
if conversion_count > 0:
print(f" const candidate = convertCandidateFromApi(apiResponse);")
print(f" const jobs = convertArrayFromApi<Job>(apiResponse, 'Job');")
return True return True

View File

@ -17,15 +17,39 @@ import signal
import json import json
import traceback import traceback
import uuid
import logging
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional
from pydantic import BaseModel, EmailStr, validator # type: ignore
# Prometheus # Prometheus
from prometheus_client import Summary # type: ignore from prometheus_client import Summary # type: ignore
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
from prometheus_client import CollectorRegistry, Counter # type: ignore from prometheus_client import CollectorRegistry, Counter # type: ignore
# =============================
# Import custom modules
# =============================
from auth_utils import (
AuthenticationManager,
validate_password_strength,
sanitize_login_input,
SecurityConfig
)
import model_cast
import defines
import agents
from logger import logger
from database import RedisDatabase, redis_manager, DatabaseManager
from metrics import Metrics
from llm_manager import llm_manager
# =============================
# Import Pydantic models # Import Pydantic models
# =============================
from models import ( from models import (
# User models # User models
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, Candidate, Employer, Viewer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
# Job models # Job models
Job, JobApplication, ApplicationStatus, Job, JobApplication, ApplicationStatus,
@ -37,13 +61,6 @@ from models import (
Location, Skill, WorkExperience, Education Location, Skill, WorkExperience, Education
) )
import model_cast
import defines
import agents
from logger import logger
from database import RedisDatabase, redis_manager, DatabaseManager
from metrics import Metrics
from llm_manager import llm_manager
# Initialize FastAPI app # Initialize FastAPI app
# ============================ # ============================
@ -75,10 +92,6 @@ async def lifespan(app: FastAPI):
# Initialize database # Initialize database
await db_manager.initialize() await db_manager.initialize()
# Seed development data if needed
if defines.debug:
await seed_development_data()
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
@ -131,6 +144,89 @@ ALGORITHM = "HS256"
# Authentication Utilities # Authentication Utilities
# ============================ # ============================
# Request/Response Models
class LoginRequest(BaseModel):
login: str # Can be email or username
password: str
@validator('login')
def sanitize_login(cls, v):
return sanitize_login_input(v)
@validator('password')
def validate_password_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('Password cannot be empty')
return v
class CreateViewerRequest(BaseModel):
email: EmailStr
username: str
password: str
firstName: str
lastName: str
@validator('username')
def validate_username(cls, v):
if not v or len(v.strip()) < 3:
raise ValueError('Username must be at least 3 characters long')
return v.strip().lower()
@validator('password')
def validate_password_strength(cls, v):
is_valid, issues = validate_password_strength(v)
if not is_valid:
raise ValueError('; '.join(issues))
return v
class CreateCandidateRequest(BaseModel):
email: EmailStr
username: str
password: str
firstName: str
lastName: str
# Add other required candidate fields as needed
phone: Optional[str] = None
@validator('username')
def validate_username(cls, v):
if not v or len(v.strip()) < 3:
raise ValueError('Username must be at least 3 characters long')
return v.strip().lower()
@validator('password')
def validate_password_strength(cls, v):
is_valid, issues = validate_password_strength(v)
if not is_valid:
raise ValueError('; '.join(issues))
return v
# Create Employer Endpoint (similar pattern)
class CreateEmployerRequest(BaseModel):
email: EmailStr
username: str
password: str
companyName: str
industry: str
companySize: str
companyDescription: str
# Add other required employer fields
websiteUrl: Optional[str] = None
phone: Optional[str] = None
@validator('username')
def validate_username(cls, v):
if not v or len(v.strip()) < 3:
raise ValueError('Username must be at least 3 characters long')
return v.strip().lower()
@validator('password')
def validate_password_strength(cls, v):
is_valid, issues = validate_password_strength(v)
if not is_valid:
raise ValueError('; '.join(issues))
return v
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
@ -151,17 +247,17 @@ async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials
raise HTTPException(status_code=401, detail="Invalid authentication credentials") raise HTTPException(status_code=401, detail="Invalid authentication credentials")
# Check if token is blacklisted # Check if token is blacklisted
redis_client = redis_manager.get_client() redis = redis_manager.get_client()
blacklist_key = f"blacklisted_token:{credentials.credentials}" blacklist_key = f"blacklisted_token:{credentials.credentials}"
is_blacklisted = await redis_client.exists(blacklist_key) is_blacklisted = await redis.exists(blacklist_key)
if is_blacklisted: if is_blacklisted:
logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}") logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}")
raise HTTPException(status_code=401, detail="Token has been revoked") raise HTTPException(status_code=401, detail="Token has been revoked")
# Optional: Check if all user tokens are revoked (for "logout from all devices") # Optional: Check if all user tokens are revoked (for "logout from all devices")
# user_revoked_key = f"user_tokens_revoked:{user_id}" # user_revoked_key = f"user_tokens_revoked:{user_id}"
# user_tokens_revoked_at = await redis_client.get(user_revoked_key) # user_tokens_revoked_at = await redis.get(user_revoked_key)
# if user_tokens_revoked_at: # if user_tokens_revoked_at:
# revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode()) # revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode())
# token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC) # token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC)
@ -182,7 +278,12 @@ async def get_current_user(
) -> BaseUserWithType: ) -> BaseUserWithType:
"""Get current user from database""" """Get current user from database"""
try: try:
# Check candidates first # Check viewers
viewer = await database.get_viewer(user_id)
if viewer:
return Viewer.model_validate(viewer)
# Check candidates
candidate = await database.get_candidate(user_id) candidate = await database.get_candidate(user_id)
if candidate: if candidate:
return Candidate.model_validate(candidate) return Candidate.model_validate(candidate)
@ -294,65 +395,80 @@ api_router = APIRouter(prefix="/api/1.0")
@api_router.post("/auth/login") @api_router.post("/auth/login")
async def login( async def login(
login: str = Body(...), request: LoginRequest,
password: str = Body(...),
database: RedisDatabase = Depends(get_database) database: RedisDatabase = Depends(get_database)
): ):
"""Login endpoint""" """Secure login endpoint with password verification"""
try: try:
# Check if user exists (simplified - in real app, check hashed password) # Initialize authentication manager
user_data = await database.get_user(login) auth_manager = AuthenticationManager(database)
if not user_data:
logger.info(f"⚠️ Login attempt with non-existent email: {login}") # Verify credentials
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
request.login,
request.password
)
if not is_valid or not user_data:
logger.warning(f"⚠️ Failed login attempt for: {request.login}")
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content=create_error_response("AUTH_FAILED", "Invalid credentials") content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
) )
logger.info(f"🔑 User {login} logged in successfully") # Update last login timestamp
await auth_manager.update_last_login(user_data["id"])
logger.info(f"🔑 User {request.login} logged in successfully")
# Create tokens # Create tokens
access_token = create_access_token(data={"sub": user_data["id"]}) access_token = create_access_token(data={"sub": user_data["id"]})
refresh_token = create_access_token( refresh_token = create_access_token(
data={"sub": user_data["id"], "type": "refresh"}, data={"sub": user_data["id"], "type": "refresh"},
expires_delta=timedelta(days=30) expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS)
) )
# Get user object # Get user object based on type
user = None user = None
if user_data["type"] == "candidate": if user_data["type"] == "candidate":
logger.info(f"🔑 User {login} is a candidate") logger.info(f"🔑 User {request.login} is a candidate")
candidate_data = await database.get_candidate(user_data["id"]) candidate_data = await database.get_candidate(user_data["id"])
if candidate_data: if candidate_data:
user = Candidate.model_validate(candidate_data) user = Candidate.model_validate(candidate_data)
elif user_data["type"] == "employer": elif user_data["type"] == "employer":
logger.info(f"🔑 User {login} is a employer") logger.info(f"🔑 User {request.login} is an employer")
employer_data = await database.get_employer(user_data["id"]) employer_data = await database.get_employer(user_data["id"])
if employer_data: if employer_data:
user = Employer.model_validate(employer_data) user = Employer.model_validate(employer_data)
elif user_data["type"] == "viewer":
logger.info(f"🔑 User {request.login} is an viewer")
viewer_data = await database.get_viewer(user_data["id"])
if viewer_data:
user = Viewer.model_validate(viewer_data)
if not user: if not user:
logger.error(f"❌ User object not found for {user_data['id']}")
return JSONResponse( return JSONResponse(
status_code=404, status_code=404,
content=create_error_response("USER_NOT_FOUND", "User not found") content=create_error_response("USER_NOT_FOUND", "User profile not found")
) )
# Create response
auth_response = AuthResponse( auth_response = AuthResponse(
accessToken=access_token, accessToken=access_token,
refreshToken=refresh_token, refreshToken=refresh_token,
user=user, user=user,
expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp()) expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
) )
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True)) return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
except Exception as e: except Exception as e:
logger.error(f"⚠️ Login error: {e}") logger.error(f" Login error: {e}")
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content=create_error_response("LOGIN_ERROR", str(e)) content=create_error_response("LOGIN_ERROR", "An error occurred during login")
) )
@api_router.post("/auth/logout") @api_router.post("/auth/logout")
async def logout( async def logout(
access_token: str = Body(..., alias="accessToken"), access_token: str = Body(..., alias="accessToken"),
@ -390,12 +506,12 @@ async def logout(
) )
# Get Redis client # Get Redis client
redis_client = redis_manager.get_client() redis = redis_manager.get_client()
# Revoke refresh token (blacklist it until its natural expiration) # Revoke refresh token (blacklist it until its natural expiration)
refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp())) refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp()))
if refresh_ttl > 0: if refresh_ttl > 0:
await redis_client.setex( await redis.setex(
f"blacklisted_token:{refresh_token}", f"blacklisted_token:{refresh_token}",
refresh_ttl, refresh_ttl,
json.dumps({ json.dumps({
@ -418,7 +534,7 @@ async def logout(
if access_user_id == user_id: if access_user_id == user_id:
access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp())) access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp()))
if access_ttl > 0: if access_ttl > 0:
await redis_client.setex( await redis.setex(
f"blacklisted_token:{access_token}", f"blacklisted_token:{access_token}",
access_ttl, access_ttl,
json.dumps({ json.dumps({
@ -438,7 +554,7 @@ async def logout(
# Optional: Revoke all tokens for this user (for "logout from all devices") # Optional: Revoke all tokens for this user (for "logout from all devices")
# Uncomment the following lines if you want to implement this feature: # Uncomment the following lines if you want to implement this feature:
# #
# await redis_client.setex( # await redis.setex(
# f"user_tokens_revoked:{user_id}", # f"user_tokens_revoked:{user_id}",
# timedelta(days=30).total_seconds(), # Max refresh token lifetime # timedelta(days=30).total_seconds(), # Max refresh token lifetime
# datetime.now(UTC).isoformat() # datetime.now(UTC).isoformat()
@ -467,10 +583,10 @@ async def logout_all_devices(
): ):
"""Logout from all devices by revoking all tokens for the user""" """Logout from all devices by revoking all tokens for the user"""
try: try:
redis_client = redis_manager.get_client() redis = redis_manager.get_client()
# Set a timestamp that invalidates all tokens issued before this moment # Set a timestamp that invalidates all tokens issued before this moment
await redis_client.setex( await redis.setex(
f"user_tokens_revoked:{current_user.id}", f"user_tokens_revoked:{current_user.id}",
int(timedelta(days=30).total_seconds()), # Max refresh token lifetime int(timedelta(days=30).total_seconds()), # Max refresh token lifetime
datetime.now(UTC).isoformat() datetime.now(UTC).isoformat()
@ -518,6 +634,10 @@ async def refresh_token_endpoint(
employer_data = await database.get_employer(user_id) employer_data = await database.get_employer(user_id)
if employer_data: if employer_data:
user = Employer.model_validate(employer_data) user = Employer.model_validate(employer_data)
else:
viewer_data = await database.get_viewer(user_id)
if viewer_data:
user = Viewer.model_validate(viewer_data)
if not user: if not user:
return JSONResponse( return JSONResponse(
@ -546,48 +666,195 @@ async def refresh_token_endpoint(
content=create_error_response("REFRESH_ERROR", str(e)) content=create_error_response("REFRESH_ERROR", str(e))
) )
# ============================
# Viewer Endpoints
# ============================
@api_router.post("/viewers")
async def create_viewer(
request: CreateViewerRequest,
database: RedisDatabase = Depends(get_database)
):
"""Create a new viewer with secure password handling and duplicate checking"""
try:
# Initialize authentication manager
auth_manager = AuthenticationManager(database)
# Check if user already exists
user_exists, conflict_field = await auth_manager.check_user_exists(
request.email,
request.username
)
if user_exists and not conflict_field:
raise ValueError("User already exists with this email or username, but conflict_field is not set")
if user_exists and conflict_field:
logger.warning(f"⚠️ Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}")
return JSONResponse(
status_code=409,
content=create_error_response(
"USER_EXISTS",
f"A user with this {conflict_field} already exists"
)
)
# Generate viewer ID
viewer_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
# Prepare viewer data
viewer_data = {
"id": viewer_id,
"email": request.email,
"username": request.username,
"firstName": request.firstName,
"lastName": request.lastName,
"fullName": f"{request.firstName} {request.lastName}",
"createdAt": current_time.isoformat(),
"updatedAt": current_time.isoformat(),
"status": "active",
"userType": "viewer",
}
# Create viewer object and validate
viewer = Viewer.model_validate(viewer_data)
# Create authentication record with hashed password
await auth_manager.create_user_authentication(viewer_id, request.password)
# Store viewer in database
await database.set_viewer(viewer.id, viewer.model_dump())
# Add to users for auth lookup (by email and username)
user_auth_data = {
"id": viewer.id,
"type": "viewer",
"email": viewer.email,
"username": request.username
}
# Store user lookup records
await database.set_user(viewer.email, user_auth_data) # By email
await database.set_user(request.username, user_auth_data) # By username
await database.set_user_by_id(viewer.id, user_auth_data) # By ID
logger.info(f"✅ Created viewer: {viewer.email} (ID: {viewer.id})")
# Return viewer data (excluding sensitive information)
response_data = viewer.model_dump(by_alias=True, exclude_unset=True)
# Remove any sensitive fields from response if needed
return create_success_response(response_data)
except ValueError as ve:
logger.warning(f"⚠️ Validation error creating viewer: {ve}")
return JSONResponse(
status_code=400,
content=create_error_response("VALIDATION_ERROR", str(ve))
)
except Exception as e:
logger.error(f"❌ Viewer creation error: {e}")
logger.error(traceback.format_exc())
return JSONResponse(
status_code=500,
content=create_error_response("CREATION_FAILED", "Failed to create viewer account")
)
# ============================ # ============================
# Candidate Endpoints # Candidate Endpoints
# ============================ # ============================
@api_router.post("/candidates") @api_router.post("/candidates")
async def create_candidate( async def create_candidate(
candidate_data: Dict[str, Any] = Body(...), request: CreateCandidateRequest,
database: RedisDatabase = Depends(get_database) database: RedisDatabase = Depends(get_database)
): ):
"""Create a new candidate""" """Create a new candidate with secure password handling and duplicate checking"""
try: try:
# Add required fields # Initialize authentication manager
candidate_data["id"] = str(uuid.uuid4()) auth_manager = AuthenticationManager(database)
candidate_data["createdAt"] = datetime.now(UTC).isoformat()
candidate_data["updatedAt"] = datetime.now(UTC).isoformat()
# Create candidate # Check if user already exists
candidate = Candidate.model_validate(candidate_data) user_exists, conflict_field = await auth_manager.check_user_exists(
# Check if candidate already exists request.email,
existing_candidate = await database.get_candidate(candidate.id) request.username
if existing_candidate: )
if user_exists and not conflict_field:
raise ValueError("User already exists with this email or username, but conflict_field is not set")
if user_exists and conflict_field:
logger.warning(f"⚠️ Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}")
return JSONResponse( return JSONResponse(
status_code=400, status_code=409,
content=create_error_response("ALREADY_EXISTS", "Candidate already exists") content=create_error_response(
"USER_EXISTS",
f"A user with this {conflict_field} already exists"
)
) )
# Generate candidate ID
candidate_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
# Prepare candidate data
candidate_data = {
"id": candidate_id,
"userType": "candidate",
"email": request.email,
"username": request.username,
"firstName": request.firstName,
"lastName": request.lastName,
"fullName": f"{request.firstName} {request.lastName}",
"phone": request.phone,
"createdAt": current_time.isoformat(),
"updatedAt": current_time.isoformat(),
"status": "active",
}
# Create candidate object and validate
candidate = Candidate.model_validate(candidate_data)
# Create authentication record with hashed password
await auth_manager.create_user_authentication(candidate_id, request.password)
# Store candidate in database
await database.set_candidate(candidate.id, candidate.model_dump()) await database.set_candidate(candidate.id, candidate.model_dump())
# Add to users for auth (simplified) # Add to users for auth lookup (by email and username)
await database.set_user(candidate, { user_auth_data = {
"id": candidate.id, "id": candidate.id,
"type": "candidate" "type": "candidate",
}) "email": candidate.email,
"username": request.username
}
return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True)) # Store user lookup records
await database.set_user(candidate.email, user_auth_data) # By email
await database.set_user(request.username, user_auth_data) # By username
await database.set_user_by_id(candidate.id, user_auth_data) # By ID
except Exception as e: logger.info(f"✅ Created candidate: {candidate.email} (ID: {candidate.id})")
logger.error(f"Candidate creation error: {e}")
# Return candidate data (excluding sensitive information)
response_data = candidate.model_dump(by_alias=True, exclude_unset=True)
# Remove any sensitive fields from response if needed
return create_success_response(response_data)
except ValueError as ve:
logger.warning(f"⚠️ Validation error creating candidate: {ve}")
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content=create_error_response("CREATION_FAILED", str(e)) content=create_error_response("VALIDATION_ERROR", str(ve))
) )
except Exception as e:
logger.error(f"❌ Candidate creation error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("CREATION_FAILED", "Failed to create candidate account")
)
@api_router.get("/candidates/{username}") @api_router.get("/candidates/{username}")
async def get_candidate( async def get_candidate(
username: str = Path(...), username: str = Path(...),
@ -753,6 +1020,185 @@ async def search_candidates(
content=create_error_response("SEARCH_FAILED", str(e)) content=create_error_response("SEARCH_FAILED", str(e))
) )
# ============================
# Employer Endpoints
# ============================
@api_router.post("/employers")
async def create_employer(
request: CreateEmployerRequest,
database: RedisDatabase = Depends(get_database)
):
"""Create a new employer with secure password handling and duplicate checking"""
try:
# Initialize authentication manager
auth_manager = AuthenticationManager(database)
# Check if user already exists
user_exists, conflict_field = await auth_manager.check_user_exists(
request.email,
request.username
)
if user_exists and not conflict_field:
raise ValueError("User already exists with this email or username, but conflict_field is not set")
if user_exists and conflict_field:
logger.warning(f"⚠️ Attempted to create employer with existing {conflict_field}: {getattr(request, conflict_field)}")
return JSONResponse(
status_code=409,
content=create_error_response(
"USER_EXISTS",
f"A user with this {conflict_field} already exists"
)
)
# Generate employer ID
employer_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
# Prepare employer data
employer_data = {
"id": employer_id,
"email": request.email,
"companyName": request.companyName,
"industry": request.industry,
"companySize": request.companySize,
"companyDescription": request.companyDescription,
"websiteUrl": request.websiteUrl,
"phone": request.phone,
"createdAt": current_time.isoformat(),
"updatedAt": current_time.isoformat(),
"status": "active",
"userType": "employer",
"location": {
"city": "",
"country": "",
"remote": False
},
"socialLinks": []
}
# Create employer object and validate
employer = Employer.model_validate(employer_data)
# Create authentication record with hashed password
await auth_manager.create_user_authentication(employer_id, request.password)
# Store employer in database
await database.set_employer(employer.id, employer.model_dump())
# Add to users for auth lookup
user_auth_data = {
"id": employer.id,
"type": "employer",
"email": employer.email,
"username": request.username
}
# Store user lookup records
await database.set_user(employer.email, user_auth_data) # By email
await database.set_user(request.username, user_auth_data) # By username
await database.set_user_by_id(employer.id, user_auth_data) # By ID
logger.info(f"✅ Created employer: {employer.email} (ID: {employer.id})")
# Return employer data (excluding sensitive information)
response_data = employer.model_dump(by_alias=True, exclude_unset=True)
return create_success_response(response_data)
except ValueError as ve:
logger.warning(f"⚠️ Validation error creating employer: {ve}")
return JSONResponse(
status_code=400,
content=create_error_response("VALIDATION_ERROR", str(ve))
)
except Exception as e:
logger.error(f"❌ Employer creation error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("CREATION_FAILED", "Failed to create employer account")
)
# ============================
# Password Reset Endpoints
# ============================
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str
@validator('new_password')
def validate_password_strength(cls, v):
is_valid, issues = validate_password_strength(v)
if not is_valid:
raise ValueError('; '.join(issues))
return v
@api_router.post("/auth/password-reset/request")
async def request_password_reset(
request: PasswordResetRequest,
database: RedisDatabase = Depends(get_database)
):
"""Request password reset"""
try:
# Check if user exists
user_data = await database.get_user(request.email)
if not user_data:
# Don't reveal whether email exists or not
return create_success_response({"message": "If the email exists, a reset link will be sent"})
auth_manager = AuthenticationManager(database)
# Generate reset token
reset_token = auth_manager.password_security.generate_secure_token()
reset_expiry = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry
# Update authentication record
auth_record = await database.get_authentication(user_data["id"])
if auth_record:
auth_record["resetPasswordToken"] = reset_token
auth_record["resetPasswordExpiry"] = reset_expiry.isoformat()
await database.set_authentication(user_data["id"], auth_record)
# TODO: Send email with reset token
logger.info(f"🔐 Password reset requested for: {request.email}")
return create_success_response({"message": "If the email exists, a reset link will be sent"})
except Exception as e:
logger.error(f"❌ Password reset request error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("RESET_ERROR", "An error occurred processing the request")
)
@api_router.post("/auth/password-reset/confirm")
async def confirm_password_reset(
request: PasswordResetConfirm,
database: RedisDatabase = Depends(get_database)
):
"""Confirm password reset with token"""
try:
# Find user by reset token
# This would require a way to lookup by token - you might need to modify your database structure
# For now, this is a placeholder - you'd need to implement token lookup
# in your Redis database structure
return create_success_response({"message": "Password reset successfully"})
except Exception as e:
logger.error(f"❌ Password reset confirm error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("RESET_ERROR", "An error occurred resetting the password")
)
# ============================ # ============================
# Job Endpoints # Job Endpoints
# ============================ # ============================
@ -1349,17 +1795,17 @@ async def enhanced_health_check():
"""Enhanced health check endpoint""" """Enhanced health check endpoint"""
try: try:
database = db_manager.get_database() database = db_manager.get_database()
if not redis_manager.redis_client: if not redis_manager.redis:
raise RuntimeError("Redis client not initialized") raise RuntimeError("Redis client not initialized")
# Test Redis connection # Test Redis connection
await redis_manager.redis_client.ping() await redis_manager.redis.ping()
# Get database stats # Get database stats
stats = await database.get_stats() stats = await database.get_stats()
# Redis info # Redis info
redis_info = await redis_manager.redis_client.info() redis_info = await redis_manager.redis.info()
return { return {
"status": "healthy", "status": "healthy",
@ -1386,9 +1832,9 @@ async def enhanced_health_check():
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@api_router.get("/redis/stats") @api_router.get("/redis/stats")
async def redis_stats(redis_client: redis.Redis = Depends(get_redis)): async def redis_stats(redis: redis.Redis = Depends(get_redis)):
try: try:
info = await redis_client.info() info = await redis.info()
return { return {
"connected_clients": info.get("connected_clients"), "connected_clients": info.get("connected_clients"),
"used_memory_human": info.get("used_memory_human"), "used_memory_human": info.get("used_memory_human"),
@ -1501,96 +1947,6 @@ async def root():
"health": f"{defines.api_prefix}/health" "health": f"{defines.api_prefix}/health"
} }
# ============================
# Development Data Seeding
# ============================
async def seed_development_data():
"""Seed the database with development data"""
try:
database = db_manager.get_database()
# Check if data already exists
stats = await database.get_stats()
if stats.get('candidates', 0) > 0:
logger.info("✅ Development data already exists, skipping seeding")
return
# Create sample location
sample_location = Location(
city="San Francisco",
state="CA",
country="USA",
postalCode="94102"
)
# Create sample candidate
candidate_id = str(uuid.uuid4())
sample_candidate = Candidate(
id=candidate_id,
email="john.doe@example.com",
createdAt=datetime.now(UTC),
updatedAt=datetime.now(UTC),
status="active",
firstName="John",
lastName="Doe",
fullName="John Doe",
username="johndoe",
skills=[],
experience=[],
education=[],
preferredJobTypes=["full-time"],
location=sample_location,
languages=[],
certifications=[]
)
await database.set_candidate(candidate_id, sample_candidate.model_dump())
await database.set_user(sample_candidate, {"id": candidate_id, "type": "candidate"})
# Create sample employer
employer_id = str(uuid.uuid4())
sample_employer = Employer(
id=employer_id,
email="hr@techcorp.com",
createdAt=datetime.now(UTC),
updatedAt=datetime.now(UTC),
status="active",
companyName="TechCorp",
industry="Technology",
companySize="100-500",
companyDescription="Leading technology company",
location=sample_location
)
await database.set_employer(employer_id, sample_employer.model_dump())
await database.set_user(sample_employer, {"id": employer_id, "type": "employer"})
# Create sample job
job_id = str(uuid.uuid4())
sample_job = Job(
id=job_id,
title="Senior Software Engineer",
description="We are looking for a senior software engineer...",
responsibilities=["Develop software", "Lead projects", "Mentor juniors"],
requirements=["5+ years experience", "Python expertise"],
preferredSkills=["FastAPI", "React", "PostgreSQL"],
employerId=employer_id,
location=sample_location,
employmentType="full-time",
datePosted=datetime.now(UTC),
isActive=True,
views=0,
applicationCount=0
)
await database.set_job(job_id, sample_job.model_dump())
logger.info("✅ Development data seeded successfully")
except Exception as e:
logger.error(f"⚠️ Failed to seed development data: {e}")
if __name__ == "__main__": if __name__ == "__main__":
host = defines.host host = defines.host
port = defines.port port = defines.port

View File

@ -15,6 +15,7 @@ T = TypeVar('T')
class UserType(str, Enum): class UserType(str, Enum):
CANDIDATE = "candidate" CANDIDATE = "candidate"
EMPLOYER = "employer" EMPLOYER = "employer"
VIEWER = "viewer"
GUEST = "guest" GUEST = "guest"
class UserGender(str, Enum): class UserGender(str, Enum):
@ -105,11 +106,13 @@ class MFAMethod(str, Enum):
EMAIL = "email" EMAIL = "email"
class VectorStoreType(str, Enum): class VectorStoreType(str, Enum):
PINECONE = "pinecone" CHROMA = "chroma",
QDRANT = "qdrant" # FAISS = "faiss",
FAISS = "faiss" # PINECONE = "pinecone"
MILVUS = "milvus" # QDRANT = "qdrant"
WEAVIATE = "weaviate" # FAISS = "faiss"
# MILVUS = "milvus"
# WEAVIATE = "weaviate"
class DataSourceType(str, Enum): class DataSourceType(str, Enum):
DOCUMENT = "document" DOCUMENT = "document"
@ -366,7 +369,11 @@ class ErrorDetail(BaseModel):
class BaseUser(BaseModel): class BaseUser(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
email: EmailStr email: EmailStr
first_name: str = Field(..., alias="firstName")
last_name: str = Field(..., alias="lastName")
full_name: str = Field(..., alias="fullName")
phone: Optional[str] = None phone: Optional[str] = None
location: Optional[Location] = None
created_at: datetime = Field(..., alias="createdAt") created_at: datetime = Field(..., alias="createdAt")
updated_at: datetime = Field(..., alias="updatedAt") updated_at: datetime = Field(..., alias="updatedAt")
last_login: Optional[datetime] = Field(None, alias="lastLogin") last_login: Optional[datetime] = Field(None, alias="lastLogin")
@ -382,25 +389,25 @@ class BaseUser(BaseModel):
class BaseUserWithType(BaseUser): class BaseUserWithType(BaseUser):
user_type: UserType = Field(..., alias="userType") user_type: UserType = Field(..., alias="userType")
class Viewer(BaseUser):
user_type: Literal[UserType.VIEWER] = Field(UserType.VIEWER, alias="userType")
username: str
class Candidate(BaseUser): class Candidate(BaseUser):
user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType") user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType")
username: str username: str
first_name: str = Field(..., alias="firstName")
last_name: str = Field(..., alias="lastName")
full_name: str = Field(..., alias="fullName")
description: Optional[str] = None description: Optional[str] = None
resume: Optional[str] = None resume: Optional[str] = None
skills: List[Skill] skills: Optional[List[Skill]] = None
experience: List[WorkExperience] experience: Optional[List[WorkExperience]] = None
questions: List[CandidateQuestion] = [] questions: Optional[List[CandidateQuestion]] = None
education: List[Education] education: Optional[List[Education]] = None
preferred_job_types: List[EmploymentType] = Field(..., alias="preferredJobTypes") preferred_job_types: Optional[List[EmploymentType]] = Field(None, alias="preferredJobTypes")
desired_salary: Optional[DesiredSalary] = Field(None, alias="desiredSalary") desired_salary: Optional[DesiredSalary] = Field(None, alias="desiredSalary")
location: Location
availability_date: Optional[datetime] = Field(None, alias="availabilityDate") availability_date: Optional[datetime] = Field(None, alias="availabilityDate")
summary: Optional[str] = None summary: Optional[str] = None
languages: List[Language] languages: Optional[List[Language]] = None
certifications: List[Certification] certifications: Optional[List[Certification]] = None
job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications") job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications")
has_profile: bool = Field(default=False, alias="hasProfile") has_profile: bool = Field(default=False, alias="hasProfile")
# Used for AI generated personas # Used for AI generated personas
@ -417,7 +424,6 @@ class Employer(BaseUser):
company_description: str = Field(..., alias="companyDescription") company_description: str = Field(..., alias="companyDescription")
website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl") website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl")
jobs: Optional[List["Job"]] = None jobs: Optional[List["Job"]] = None
location: Location
company_logo: Optional[str] = Field(None, alias="companyLogo") company_logo: Optional[str] = Field(None, alias="companyLogo")
social_links: Optional[List[SocialLink]] = Field(None, alias="socialLinks") social_links: Optional[List[SocialLink]] = Field(None, alias="socialLinks")
poc: Optional[PointOfContact] = None poc: Optional[PointOfContact] = None
@ -454,7 +460,7 @@ class Authentication(BaseModel):
class AuthResponse(BaseModel): class AuthResponse(BaseModel):
access_token: str = Field(..., alias="accessToken") access_token: str = Field(..., alias="accessToken")
refresh_token: str = Field(..., alias="refreshToken") refresh_token: str = Field(..., alias="refreshToken")
user: Candidate | Employer user: Candidate | Employer | Viewer
expires_at: int = Field(..., alias="expiresAt") expires_at: int = Field(..., alias="expiresAt")
model_config = { model_config = {
"populate_by_name": True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases