Compare commits

..

No commits in common. "4330bd4b7ce0f203f0c5769152b569164f7289ad" and "89b71a1428f0088f5c517907c4d63acfd81b3c26" have entirely different histories.

23 changed files with 913 additions and 4453 deletions

View File

@ -27,7 +27,6 @@
"@types/react-dom": "^19.0.4",
"@uiw/react-json-view": "^2.0.0-alpha.31",
"@uiw/react-markdown-editor": "^6.1.4",
"country-state-city": "^3.2.1",
"jsonrepair": "^3.12.0",
"lodash": "^4.17.21",
"lucide-react": "^0.511.0",
@ -8517,11 +8516,6 @@
"integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,786 +0,0 @@
// 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,8 +6,10 @@ import { debugConversion } from "types/conversion";
type UserContextType = {
apiClient: ApiClient;
user: User | null;
guest: Guest;
candidate: Candidate | null;
setUser: (user: User | null) => void;
setCandidate: (candidate: Candidate | null) => void;
};
@ -29,6 +31,8 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
const [apiClient, setApiClient] = useState<ApiClient>(new ApiClient());
const [candidate, setCandidate] = useState<Candidate | null>(null);
const [guest, setGuest] = useState<Guest | null>(null);
const [user, setUser] = useState<User | null>(null);
const [activeUser, setActiveUser] = useState<User | null>(null);
useEffect(() => {
console.log("Candidate =>", candidate);
@ -38,6 +42,65 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
console.log("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 = () => {
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)}`;
@ -69,6 +132,7 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
user.lastLogin = new Date(user.lastLogin);
}
setApiClient(new ApiClient(accessToken));
setUser(user);
} catch (e) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
@ -88,7 +152,7 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
}
return (
<UserContext.Provider value={{ apiClient, candidate, setCandidate, guest }}>
<UserContext.Provider value={{ apiClient, candidate, setCandidate, user, setUser, guest }}>
{children}
</UserContext.Provider>
);

View File

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

View File

@ -1,277 +0,0 @@
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,59 +33,130 @@ import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment';
import DescriptionIcon from '@mui/icons-material/Description';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
import { Candidate } from "types/types";
import { useUser } from "hooks/useUser";
import { useNavigate } from 'react-router-dom';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { useSecureAuth } from 'hooks/useSecureAuth';
import { JobMatchAnalysis } from '../components/JobMatchAnalysis';
// Mock types for our application
interface Candidate {
id: string;
name: string;
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
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const JobAnalysisPage: React.FC = () => {
const theme = useTheme();
const { user } = useSecureAuth();
const { user, loading: userLoading } = useUser();
// State management
const [activeStep, setActiveStep] = useState(0);
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [loadingCandidates, setLoadingCandidates] = useState(false);
const [jobDescription, setJobDescription] = useState('');
const [jobTitle, setJobTitle] = useState('');
const [jobLocation, setJobLocation] = useState('');
const [analysisStarted, setAnalysisStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
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
const steps = [
{ label: 'Select Candidate', icon: <PersonIcon /> },
@ -93,6 +164,38 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
{ 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
const fetchRequirements = async (): Promise<string[]> => {
// Simulates extracting requirements from the job description
@ -268,7 +371,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
Select a Candidate
</Typography>
{/* <Box sx={{ mb: 3, display: 'flex' }}>
<Box sx={{ mb: 3, display: 'flex' }}>
<TextField
fullWidth
variant="outlined"
@ -286,10 +389,17 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
}}
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}>
{candidates?.map((candidate) => (
{candidates.map((candidate) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={candidate.id}>
<Card
elevation={selectedCandidate?.id === candidate.id ? 8 : 1}
@ -308,37 +418,38 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
<CardContent sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ display: 'flex', mb: 2, alignItems: 'center' }}>
<Avatar
src={candidate.profileImage}
alt={candidate.firstName}
src={candidate.photoUrl}
alt={candidate.name}
sx={{ width: 64, height: 64, mr: 2 }}
/>
<Box>
<Typography variant="h6" component="div">
{candidate.fullName}
{candidate.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{candidate.description}
{candidate.title}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 2 }} />
{candidate.location && <Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location.country}
</Typography>}
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Location:</strong> {candidate.location}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Email:</strong> {candidate.email}
</Typography>
{candidate.phone && <Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>}
<Typography variant="body2">
<strong>Phone:</strong> {candidate.phone}
</Typography>
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
</Grid>
)}
</Paper>
);
@ -419,7 +530,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
{selectedCandidate && (
<JobMatchAnalysis
jobTitle={jobTitle}
candidateName={selectedCandidate.fullName}
candidateName={selectedCandidate.name}
fetchRequirements={fetchRequirements}
fetchMatchForRequirement={fetchMatchForRequirement}
/>
@ -427,6 +538,15 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</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 (!user) {
return (
@ -458,8 +578,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((step, index) => (
<Step key={index}>
<StepLabel slots={{
stepIcon: () => (
<StepLabel StepIconComponent={() => (
<Avatar
sx={{
bgcolor: activeStep >= index ? theme.palette.primary.main : theme.palette.grey[300],
@ -468,9 +587,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
>
{step.icon}
</Avatar>
)
}}
>
)}>
{step.label}
</StepLabel>
</Step>

View File

@ -16,36 +16,9 @@ import {
Card,
CardContent,
Divider,
Avatar,
IconButton,
InputAdornment,
List,
ListItem,
ListItemIcon,
ListItemText,
Collapse,
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
Chip
Avatar
} from '@mui/material';
import {
Person,
PersonAdd,
AccountCircle,
ExitToApp,
Visibility,
VisibilityOff,
CheckCircle,
Cancel,
ExpandLess,
ExpandMore,
Visibility as ViewIcon,
Work,
Business
} from '@mui/icons-material';
import { Person, PersonAdd, AccountCircle, ExitToApp } from '@mui/icons-material';
import 'react-phone-number-input/style.css';
import PhoneInput from 'react-phone-number-input';
import { E164Number } from 'libphonenumber-js/core';
@ -53,15 +26,22 @@ import './LoginPage.css';
import { ApiClient } from 'services/api-client';
import { useUser } from 'hooks/useUser';
import { useSecureAuth } from 'hooks/useSecureAuth';
import { LocationInput } from 'components/LocationInput';
import { Location } from 'types/types';
import { Candidate } from 'types/types'
// Import conversion utilities
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 { BackstoryPageProps } from 'components/BackstoryTab';
type UserRegistrationType = 'viewer' | 'candidate' | 'employer';
interface LoginRequest {
login: string;
@ -69,49 +49,25 @@ interface LoginRequest {
}
interface RegisterRequest {
userType: UserRegistrationType;
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
confirmPassword: string;
phone?: string;
// Employer specific fields (placeholder)
companyName?: string;
industry?: string;
companySize?: string;
}
interface PasswordRequirement {
label: string;
met: boolean;
}
const apiClient = new ApiClient();
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const { setSnack } = props;
const { guest } = useUser();
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { user, setUser, guest } = useUser();
const [tabValue, setTabValue] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [phone, setPhone] = useState<E164Number | null>(null);
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);
};
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || '';
// Login form state
const [loginForm, setLoginForm] = useState<LoginRequest>({
@ -121,49 +77,14 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
// Register form state
const [registerForm, setRegisterForm] = useState<RegisterRequest>({
userType: 'candidate',
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
confirmPassword: '',
phone: '',
companyName: '',
industry: '',
companySize: ''
phone: ''
});
// 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(() => {
if (phone !== registerForm.phone && phone) {
console.log({ phone });
@ -174,122 +95,94 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
const success = await login(loginForm);
if (success) {
try {
const authResponse = await apiClient.login(loginForm.login, loginForm.password)
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!');
}
};
const handlePasswordChange = (password: string) => {
setRegisterForm(prev => ({ ...prev, password }));
setPasswordValidation(apiClient.validatePasswordStrength(password));
// Show requirements if password has content and isn't valid
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);
navigate('/');
setUser(authResponse.user);
// Clear form
setLoginForm({ login: '', password: '' });
} catch (err) {
console.error('Login error:', err);
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
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);
setError(null);
setSuccess(null);
// For now, all non-employer registrations go through candidate creation
// This would need to be updated when viewer and employer APIs are available
let success;
switch (registerForm.userType) {
case 'candidate':
success = await createCandidateAccount(registerForm);
break;
case 'viewer':
success = await createViewerAccount(registerForm);
break;
}
try {
const candidate: Candidate = {
username: registerForm.username,
email: registerForm.email,
firstName: registerForm.firstName,
lastName: registerForm.lastName,
fullName: `${registerForm.firstName} ${registerForm.lastName}`,
phone: registerForm.phone || undefined,
userType: 'candidate',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
experience: [],
education: [],
preferredJobTypes: [],
languages: [],
certifications: [],
location: {
city: '',
country: '',
remote: true
}
};
if (success) {
// Redirect based on user type
if (registerForm.userType === 'viewer') {
window.location.href = '/find-a-candidate';
} else {
window.location.href = '/candidate/dashboard';
}
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);
}
setLoading(false);
};
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
setError(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) {
return (
@ -320,7 +213,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
{/* <strong>Status:</strong> {user.status} */}
<strong>Status:</strong> {user.status}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
@ -328,11 +221,6 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
<strong>Phone:</strong> {user.phone || 'Not provided'}
</Typography>
</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 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Last Login:</strong> {
@ -370,6 +258,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const handleLoginChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setLoginForm({ ...loginForm, login: value });
setError(validateInput(value));
};
return (
@ -436,7 +325,7 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
<TextField
fullWidth
label="Password"
type={showLoginPassword ? 'text' : 'password'}
type="password"
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
margin="normal"
@ -444,22 +333,6 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
disabled={loading}
variant="outlined"
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
@ -481,260 +354,95 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
Create Account
</Typography>
{/* User Type Selection */}
<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>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Username"
value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
margin="normal"
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="Email"
type="email"
value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
margin="normal"
label="Last Name"
value={registerForm.lastName}
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
{/* Conditional fields based on user type */}
{registerForm.userType === 'candidate' && (
<>
<PhoneInput
label="Phone (Optional)"
placeholder="Enter phone number"
defaultCountry='US'
value={registerForm.phone}
disabled={loading}
onChange={(v) => setPhone(v as E164Number)}
/>
<LocationInput
value={location}
onChange={handleLocationChange}
showCity
helperText="Include your city for more specific job matches"
/>
</>
)}
<TextField
fullWidth
label="Password"
type={showRegisterPassword ? 'text' : 'password'}
value={registerForm.password}
onChange={(e) => handlePasswordChange(e.target.value)}
margin="normal"
required
disabled={loading}
variant="outlined"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={toggleRegisterPasswordVisibility}
edge="end"
disabled={loading}
>
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
{/* Password Requirements */}
{registerForm.password.length > 0 && (
<Box sx={{ mt: 1, mb: 1 }}>
<Button
onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
size="small"
sx={{ mb: 1 }}
>
Password Requirements
</Button>
<Collapse in={showPasswordRequirements}>
<Paper variant="outlined" sx={{ p: 2 }}>
<List dense>
{passwordRequirements.map((requirement, index) => (
<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>
</>
)}
</Grid>
</Grid>
<TextField
fullWidth
label="Username"
value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
<TextField
fullWidth
label="Email"
type="email"
value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
<PhoneInput
label="Phone (Optional)"
placeholder="Enter phone number"
defaultCountry='US'
value={registerForm.phone}
disabled={loading}
onChange={(v) => setPhone(v as E164Number)} />
{/* <TextField
fullWidth
label="Phone (Optional)"
type="tel"
value={registerForm.phone}
onChange={(e) => setRegisterForm({ ...registerForm, phone: e.target.value })}
margin="normal"
disabled={loading}
variant="outlined"
/> */}
<TextField
fullWidth
label="Password"
type="password"
value={registerForm.password}
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
>
{loading ? 'Creating Account...' : 'Create Account'}
</Button>
</Box>
)}
</Paper>

View File

@ -1,23 +0,0 @@
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,40 +1,26 @@
/**
* Enhanced API Client with Streaming Support and Date Conversion
* Enhanced API Client with Streaming Support
*
* This demonstrates how to use the generated types with the conversion utilities
* for seamless frontend-backend communication, including streaming responses and
* automatic date field conversion.
* for seamless frontend-backend communication, including streaming responses.
*/
// Import generated types (from running generate_types.py)
import * as Types from 'types/types';
import {
formatApiRequest,
parseApiResponse,
parsePaginatedResponse,
// parseApiResponse,
// parsePaginatedResponse,
handleApiResponse,
handlePaginatedApiResponse,
createPaginatedRequest,
toUrlParams,
extractApiData,
ApiResponse,
// extractApiData,
// ApiResponse,
PaginatedResponse,
PaginatedRequest
} 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
// ============================
@ -55,49 +41,6 @@ interface StreamingResponse {
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
// ============================
@ -149,72 +92,17 @@ class ApiClient {
};
}
// ============================
// Enhanced Response Handlers with Date Conversion
// ============================
/**
* 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> {
async login(login: string, password: string): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
body: JSON.stringify(formatApiRequest({ login, password }))
});
// AuthResponse doesn't typically have date fields, use standard handler
return handleApiResponse<Types.AuthResponse>(response);
}
@ -239,31 +127,17 @@ class ApiClient {
}
// ============================
// Viewer Methods with Date Conversion
// Candidate Methods
// ============================
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> {
async createCandidate(candidate: Omit<Types.Candidate, 'id' | 'createdAt' | 'updatedAt'>): Promise<Types.Candidate> {
const response = await fetch(`${this.baseUrl}/candidates`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
body: JSON.stringify(formatApiRequest(candidate))
});
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
return handleApiResponse<Types.Candidate>(response);
}
async getCandidate(username: string): Promise<Types.Candidate> {
@ -271,7 +145,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
return handleApiResponse<Types.Candidate>(response);
}
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
@ -281,7 +155,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(updates))
});
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
return handleApiResponse<Types.Candidate>(response);
}
async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> {
@ -292,7 +166,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
return handlePaginatedApiResponse<Types.Candidate>(response);
}
async searchCandidates(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Candidate>> {
@ -308,21 +182,21 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
return handlePaginatedApiResponse<Types.Candidate>(response);
}
// ============================
// Employer Methods with Date Conversion
// Employer Methods
// ============================
async createEmployer(request: CreateEmployerRequest): Promise<Types.Employer> {
async createEmployer(employer: Omit<Types.Employer, 'id' | 'createdAt' | 'updatedAt'>): Promise<Types.Employer> {
const response = await fetch(`${this.baseUrl}/employers`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
body: JSON.stringify(formatApiRequest(employer))
});
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
return handleApiResponse<Types.Employer>(response);
}
async getEmployer(id: string): Promise<Types.Employer> {
@ -330,7 +204,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
return handleApiResponse<Types.Employer>(response);
}
async updateEmployer(id: string, updates: Partial<Types.Employer>): Promise<Types.Employer> {
@ -340,11 +214,11 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(updates))
});
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
return handleApiResponse<Types.Employer>(response);
}
// ============================
// Job Methods with Date Conversion
// Job Methods
// ============================
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
@ -354,7 +228,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(job))
});
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
return handleApiResponse<Types.Job>(response);
}
async getJob(id: string): Promise<Types.Job> {
@ -362,7 +236,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
return handleApiResponse<Types.Job>(response);
}
async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
@ -373,7 +247,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
return handlePaginatedApiResponse<Types.Job>(response);
}
async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
@ -384,7 +258,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
return handlePaginatedApiResponse<Types.Job>(response);
}
async searchJobs(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Job>> {
@ -400,11 +274,11 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
return handlePaginatedApiResponse<Types.Job>(response);
}
// ============================
// Job Application Methods with Date Conversion
// Job Application Methods
// ============================
async applyToJob(application: Omit<Types.JobApplication, 'id' | 'appliedDate' | 'updatedDate' | 'status'>): Promise<Types.JobApplication> {
@ -414,7 +288,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(application))
});
return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
return handleApiResponse<Types.JobApplication>(response);
}
async getJobApplication(id: string): Promise<Types.JobApplication> {
@ -422,7 +296,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
return handleApiResponse<Types.JobApplication>(response);
}
async getJobApplications(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.JobApplication>> {
@ -433,7 +307,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handlePaginatedApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
return handlePaginatedApiResponse<Types.JobApplication>(response);
}
async updateApplicationStatus(id: string, status: Types.ApplicationStatus): Promise<Types.JobApplication> {
@ -443,14 +317,14 @@ class ApiClient {
body: JSON.stringify(formatApiRequest({ status }))
});
return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
return handleApiResponse<Types.JobApplication>(response);
}
// ============================
// Chat Methods with Date Conversion
// Chat Methods
// ============================
/**
/**
* Create a chat session with optional candidate association
*/
async createChatSessionWithCandidate(
@ -462,7 +336,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest(request))
});
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
return handleApiResponse<Types.ChatSession>(response);
}
/**
@ -479,15 +353,7 @@ class ApiClient {
headers: this.defaultHeaders
});
// 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;
return handleApiResponse<CandidateSessionsResponse>(response);
}
/**
@ -517,7 +383,7 @@ class ApiClient {
body: JSON.stringify(formatApiRequest({ context }))
});
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
return handleApiResponse<Types.ChatSession>(response);
}
async getChatSession(id: string): Promise<Types.ChatSession> {
@ -525,7 +391,7 @@ class ApiClient {
headers: this.defaultHeaders
});
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
return handleApiResponse<Types.ChatSession>(response);
}
/**
@ -538,11 +404,11 @@ class ApiClient {
body: JSON.stringify(formatApiRequest({query}))
});
return this.handleApiResponseWithConversion<Types.ChatMessage>(response, 'ChatMessage');
return handleApiResponse<Types.ChatMessage>(response);
}
/**
* Send message with streaming response support and date conversion
* Send message with streaming response support
*/
sendMessageStream(
sessionId: string,
@ -579,7 +445,7 @@ class ApiClient {
const decoder = new TextDecoder();
let buffer = '';
let chatMessage: Types.ChatMessage | null = null;
const chatMessageList: Types.ChatMessage[] = [];
const chatMessageList : Types.ChatMessage[] = [];
try {
while (true) {
@ -604,35 +470,30 @@ class ApiClient {
const data = line.slice(5).trim();
const incoming: Types.ChatMessageBase = JSON.parse(data);
// Convert date fields for incoming messages
const convertedIncoming = convertChatMessageFromApi(incoming);
// Trigger callbacks based on status
if (convertedIncoming.status !== chatMessage?.status) {
options.onStatusChange?.(convertedIncoming.status);
if (incoming.status !== chatMessage?.status) {
options.onStatusChange?.(incoming.status);
}
// Handle different status types
switch (convertedIncoming.status) {
switch (incoming.status) {
case 'streaming':
if (chatMessage === null) {
chatMessage = {...convertedIncoming};
chatMessage = {...incoming};
} else {
// Can't do a simple += as typescript thinks .content might not be there
chatMessage.content = (chatMessage?.content || '') + convertedIncoming.content;
// Update timestamp to latest
chatMessage.timestamp = convertedIncoming.timestamp;
chatMessage.content = (chatMessage?.content || '') + incoming.content;
}
options.onStreaming?.(convertedIncoming);
options.onStreaming?.(incoming);
break;
case 'error':
options.onError?.(convertedIncoming);
options.onError?.(incoming);
break;
default:
chatMessageList.push(convertedIncoming);
options.onMessage?.(convertedIncoming);
chatMessageList.push(incoming);
options.onMessage?.(incoming);
break;
}
}
@ -689,7 +550,7 @@ class ApiClient {
}
/**
* Get persisted chat messages for a session with date conversion
* Get persisted chat messages for a session
*/
async getChatMessages(
sessionId: string,
@ -704,101 +565,17 @@ class ApiClient {
headers: this.defaultHeaders
});
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
};
return handlePaginatedApiResponse<Types.ChatMessage>(response);
}
// ============================
// Error Handling Helper
// ============================
async handleRequest<T>(requestFn: () => Promise<Response>, modelType?: string): Promise<T> {
async handleRequest<T>(requestFn: () => Promise<Response>): Promise<T> {
try {
const response = await requestFn();
return await this.handleApiResponseWithConversion<T>(response, modelType);
return await handleApiResponse<T>(response);
} catch (error) {
console.error('API request failed:', error);
throw error;
@ -823,10 +600,10 @@ class ApiClient {
}
// ============================
// React Hooks for Streaming with Date Conversion
// React Hooks for Streaming
// ============================
/* React Hook Examples for Streaming Chat with proper date handling
/* React Hook Examples for Streaming Chat
import { useState, useEffect, useCallback, useRef } from 'react';
export function useStreamingChat(sessionId: string) {
@ -845,39 +622,31 @@ export function useStreamingChat(sessionId: string) {
const streamingOptions: StreamingOptions = {
onMessage: (message) => {
// Message already has proper Date objects from conversion
setCurrentMessage(message);
},
onStreaming: (chunk) => {
// Chunk also has proper Date objects
onPartialMessage: (content, messageId) => {
setCurrentMessage(prev => prev ?
{
...prev,
content: prev.content + chunk.content,
timestamp: chunk.timestamp // Update to latest timestamp
} :
{ ...prev, content: prev.content + content } :
{
id: chunk.id || '',
id: messageId || '',
sessionId,
status: 'streaming',
sender: 'ai',
content: chunk.content,
timestamp: chunk.timestamp // Already a Date object
content,
timestamp: new Date()
}
);
},
onStatusChange: (status) => {
setCurrentMessage(prev => prev ? { ...prev, status } : null);
},
onComplete: () => {
if (currentMessage) {
setMessages(prev => [...prev, currentMessage]);
}
onComplete: (finalMessage) => {
setMessages(prev => [...prev, finalMessage]);
setCurrentMessage(null);
setIsStreaming(false);
},
onError: (err) => {
setError(typeof err === 'string' ? err : err.content);
setError(err.message);
setIsStreaming(false);
setCurrentMessage(null);
}
@ -890,7 +659,7 @@ export function useStreamingChat(sessionId: string) {
setError(err instanceof Error ? err.message : 'Failed to send message');
setIsStreaming(false);
}
}, [sessionId, apiClient, currentMessage]);
}, [sessionId, apiClient]);
const cancelStreaming = useCallback(() => {
if (streamingRef.current) {
@ -910,7 +679,7 @@ export function useStreamingChat(sessionId: string) {
};
}
// Usage in React component with proper date handling:
// Usage in React component:
function ChatInterface({ sessionId }: { sessionId: string }) {
const {
messages,
@ -930,26 +699,14 @@ function ChatInterface({ sessionId }: { sessionId: string }) {
<div className="messages">
{messages.map(message => (
<div key={message.id}>
<div className="message-header">
<strong>{message.sender}:</strong>
<span className="timestamp">
{message.timestamp.toLocaleTimeString()}
</span>
</div>
<div className="message-content">{message.content}</div>
<strong>{message.sender}:</strong> {message.content}
</div>
))}
{currentMessage && (
<div className="current-message">
<div className="message-header">
<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>
<strong>{currentMessage.sender}:</strong> {currentMessage.content}
{isStreaming && <span className="streaming-indicator">...</span>}
</div>
)}
</div>
@ -977,96 +734,61 @@ function ChatInterface({ sessionId }: { sessionId: string }) {
*/
// ============================
// Usage Examples with Date Conversion
// Usage Examples
// ============================
/*
// Initialize API client
const apiClient = new ApiClient();
// All returned objects now have proper Date fields automatically!
// Create a candidate - createdAt, updatedAt, lastLogin are Date objects
// Standard message sending (non-streaming)
try {
const candidate = await apiClient.createCandidate({
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());
}
const message = await apiClient.sendMessage(sessionId, 'Hello, how are you?');
console.log('Response:', message.content);
} catch (error) {
console.error('Failed to create candidate:', error);
console.error('Failed to send message:', error);
}
// Get jobs with proper date conversion
try {
const jobs = await apiClient.getJobs({ limit: 10 });
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);
// Streaming message with callbacks
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me a long story', {
onPartialMessage: (content, messageId) => {
console.log('Partial content:', content);
// Update UI with partial content
},
onMessage: (message) => {
// message.timestamp is a Date object
console.log(`Final message at ${message.timestamp.toLocaleTimeString()}:`, message.content);
onStatusChange: (status) => {
console.log('Status changed:', status);
// Update UI status indicator
},
onComplete: () => {
console.log('Streaming completed');
onComplete: (finalMessage) => {
console.log('Final message:', finalMessage.content);
// Handle completed message
},
onError: (error) => {
console.error('Streaming error:', error);
// Handle error
}
});
// Chat sessions with date conversion
// Can cancel the stream if needed
setTimeout(() => {
streamResponse.cancel();
}, 10000); // Cancel after 10 seconds
// Wait for completion
try {
const chatSession = await apiClient.createChatSession({
type: 'job_search',
additionalContext: {}
});
// createdAt and lastActivity are Date objects
console.log('Session created:', chatSession.createdAt.toISOString());
console.log('Last activity:', chatSession.lastActivity.toLocaleDateString());
const finalMessage = await streamResponse.promise;
console.log('Stream completed:', finalMessage);
} catch (error) {
console.error('Failed to create chat session:', error);
console.error('Stream failed:', error);
}
// Get chat messages with date conversion
try {
const messages = await apiClient.getChatMessages(sessionId);
messages.data.forEach(message => {
// timestamp is a Date object
console.log(`[${message.timestamp.toLocaleString()}] ${message.sender}: ${message.content}`);
});
} catch (error) {
console.error('Failed to fetch messages:', error);
}
// Auto-detection: streaming if callbacks provided, standard otherwise
await apiClient.sendMessageAuto(sessionId, 'Quick question', {
onPartialMessage: (content) => console.log('Streaming:', content)
}); // Will use streaming
await apiClient.sendMessageAuto(sessionId, 'Quick question'); // Will use standard
*/
export { ApiClient }
export type { StreamingOptions, StreamingResponse }
export type { StreamingOptions, StreamingResponse };

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// Source: src/backend/models.py
// Generated on: 2025-05-30T09:39:47.716115
// Generated on: 2025-05-29T23:38:18.286927
// 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 UserType = "candidate" | "employer" | "viewer" | "guest";
export type UserType = "candidate" | "employer" | "guest";
export type VectorStoreType = "chroma";
export type VectorStoreType = "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate";
// ============================
// Interfaces
@ -135,11 +135,7 @@ export interface Authentication {
export interface BaseUser {
id?: string;
email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string;
location?: Location;
createdAt: Date;
updatedAt: Date;
lastLogin?: Date;
@ -150,27 +146,19 @@ export interface BaseUser {
export interface BaseUserWithType {
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: "candidate" | "employer" | "viewer" | "guest";
userType: "candidate" | "employer" | "guest";
}
export interface Candidate {
id?: string;
email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string;
location?: Location;
createdAt: Date;
updatedAt: Date;
lastLogin?: Date;
@ -178,18 +166,22 @@ export interface Candidate {
status: "active" | "inactive" | "pending" | "banned";
userType?: "candidate";
username: string;
firstName: string;
lastName: string;
fullName: string;
description?: string;
resume?: string;
skills?: Array<Skill>;
experience?: Array<WorkExperience>;
skills: Array<Skill>;
experience: Array<WorkExperience>;
questions?: Array<CandidateQuestion>;
education?: Array<Education>;
preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">;
education: Array<Education>;
preferredJobTypes: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">;
desiredSalary?: DesiredSalary;
location: Location;
availabilityDate?: Date;
summary?: string;
languages?: Array<Language>;
certifications?: Array<Certification>;
languages: Array<Language>;
certifications: Array<Certification>;
jobApplications?: Array<JobApplication>;
hasProfile?: boolean;
age?: number;
@ -376,11 +368,7 @@ export interface Education {
export interface Employer {
id?: string;
email: string;
firstName: string;
lastName: string;
fullName: string;
phone?: string;
location?: Location;
createdAt: Date;
updatedAt: Date;
lastLogin?: Date;
@ -394,6 +382,7 @@ export interface Employer {
companyDescription: string;
websiteUrl?: string;
jobs?: Array<Job>;
location: Location;
companyLogo?: string;
socialLinks?: Array<SocialLink>;
poc?: PointOfContact;
@ -575,7 +564,7 @@ export interface RAGConfiguration {
description?: string;
dataSourceConfigurations: Array<DataSourceConfiguration>;
embeddingModel: string;
vectorStoreType: "chroma";
vectorStoreType: "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate";
retrievalParameters: RetrievalParameters;
createdAt: Date;
updatedAt: Date;
@ -673,23 +662,6 @@ export interface UserPreference {
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 {
id?: string;
companyName: string;
@ -703,492 +675,11 @@ export interface WorkExperience {
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
// ============================
export type User = Candidate | Employer | Viewer;
export type User = Candidate | Employer;
// Export all types
export type { };

View File

@ -1,268 +0,0 @@
# 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 logging
import os
from datetime import datetime, timezone, UTC, timedelta
from datetime import datetime, timedelta, UTC
import asyncio
from models import (
# User models
@ -14,14 +14,14 @@ logger = logging.getLogger(__name__)
class _RedisManager:
def __init__(self):
self.redis: Optional[redis.Redis] = None
self.redis_client: Optional[redis.Redis] = None
self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379")
self._connection_pool: Optional[redis.ConnectionPool] = None
self._is_connected = False
async def connect(self):
"""Initialize Redis connection with connection pooling"""
if self._is_connected and self.redis:
if self._is_connected and self.redis_client:
logger.info("Redis already connected")
return
@ -38,26 +38,26 @@ class _RedisManager:
health_check_interval=30
)
self.redis = redis.Redis(
self.redis_client = redis.Redis(
connection_pool=self._connection_pool
)
if not self.redis:
if not self.redis_client:
raise RuntimeError("Redis client not initialized")
# Test connection
await self.redis.ping()
await self.redis_client.ping()
self._is_connected = True
logger.info("Successfully connected to Redis")
# Log Redis info
info = await self.redis.info()
info = await self.redis_client.info()
logger.info(f"Redis version: {info.get('redis_version', 'unknown')}")
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
self._is_connected = False
self.redis = None
self.redis_client = None
self._connection_pool = None
raise
@ -68,12 +68,12 @@ class _RedisManager:
return
try:
if self.redis:
if self.redis_client:
# Wait for any pending operations to complete
await asyncio.sleep(0.1)
# Close the client
await self.redis.aclose()
await self.redis_client.aclose()
logger.info("Redis client closed")
if self._connection_pool:
@ -82,7 +82,7 @@ class _RedisManager:
logger.info("Redis connection pool closed")
self._is_connected = False
self.redis = None
self.redis_client = None
self._connection_pool = None
logger.info("Successfully disconnected from Redis")
@ -91,32 +91,32 @@ class _RedisManager:
logger.error(f"Error during Redis disconnect: {e}")
# Force cleanup even if there's an error
self._is_connected = False
self.redis = None
self.redis_client = None
self._connection_pool = None
def get_client(self) -> redis.Redis:
"""Get Redis client instance"""
if not self._is_connected or not self.redis:
if not self._is_connected or not self.redis_client:
raise RuntimeError("Redis client not initialized or disconnected")
return self.redis
return self.redis_client
@property
def is_connected(self) -> bool:
"""Check if Redis is connected"""
return self._is_connected and self.redis is not None
return self._is_connected and self.redis_client is not None
async def health_check(self) -> dict:
"""Perform health check on Redis connection"""
if not self.is_connected:
return {"status": "disconnected", "error": "Redis not connected"}
if not self.redis:
if not self.redis_client:
raise RuntimeError("Redis client not initialized")
try:
# Test basic operations
await self.redis.ping()
info = await self.redis.info()
await self.redis_client.ping()
info = await self.redis_client.info()
return {
"status": "healthy",
@ -137,16 +137,16 @@ class _RedisManager:
return False
try:
if not self.redis:
if not self.redis_client:
raise RuntimeError("Redis client not initialized")
if background:
# Non-blocking background save
await self.redis.bgsave()
await self.redis_client.bgsave()
logger.info("Background save initiated")
else:
# Blocking save
await self.redis.save()
await self.redis_client.save()
logger.info("Synchronous save completed")
return True
except Exception as e:
@ -159,20 +159,19 @@ class _RedisManager:
return None
try:
if not self.redis:
if not self.redis_client:
raise RuntimeError("Redis client not initialized")
return await self.redis.info()
return await self.redis_client.info()
except Exception as e:
logger.error(f"Failed to get Redis info: {e}")
return None
class RedisDatabase:
def __init__(self, redis: redis.Redis):
self.redis = redis
def __init__(self, redis_client: redis.Redis):
self.redis_client = redis_client
# Redis key prefixes for different data types
self.KEY_PREFIXES = {
'viewers': 'viewer:',
'candidates': 'candidate:',
'employers': 'employer:',
'jobs': 'job:',
@ -198,67 +197,29 @@ class RedisDatabase:
except json.JSONDecodeError:
logger.error(f"Failed to deserialize data: {data}")
return None
# Viewer operations
async def get_viewer(self, viewer_id: str) -> Optional[Dict]:
"""Get viewer by ID"""
key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
data = await self.redis.get(key)
return self._deserialize(data) if data else None
async def set_viewer(self, viewer_id: str, viewer_data: Dict):
"""Set viewer data"""
key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
await self.redis.set(key, self._serialize(viewer_data))
async def get_all_viewers(self) -> Dict[str, Any]:
"""Get all viewers"""
pattern = f"{self.KEY_PREFIXES['viewers']}*"
keys = await self.redis.keys(pattern)
if not keys:
return {}
# Use pipeline for efficiency
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)
data = await self.redis_client.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))
await self.redis_client.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)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
# Use pipeline for efficiency
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -273,29 +234,29 @@ class RedisDatabase:
async def delete_candidate(self, candidate_id: str):
"""Delete candidate"""
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Employers operations
async def get_employer(self, employer_id: str) -> Optional[Dict]:
"""Get employer by ID"""
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
data = await self.redis.get(key)
data = await self.redis_client.get(key)
return self._deserialize(data) if data else None
async def set_employer(self, employer_id: str, employer_data: Dict):
"""Set employer data"""
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
await self.redis.set(key, self._serialize(employer_data))
await self.redis_client.set(key, self._serialize(employer_data))
async def get_all_employers(self) -> Dict[str, Any]:
"""Get all employers"""
pattern = f"{self.KEY_PREFIXES['employers']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -310,29 +271,29 @@ class RedisDatabase:
async def delete_employer(self, employer_id: str):
"""Delete employer"""
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Jobs operations
async def get_job(self, job_id: str) -> Optional[Dict]:
"""Get job by ID"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
data = await self.redis.get(key)
data = await self.redis_client.get(key)
return self._deserialize(data) if data else None
async def set_job(self, job_id: str, job_data: Dict):
"""Set job data"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
await self.redis.set(key, self._serialize(job_data))
await self.redis_client.set(key, self._serialize(job_data))
async def get_all_jobs(self) -> Dict[str, Any]:
"""Get all jobs"""
pattern = f"{self.KEY_PREFIXES['jobs']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -347,29 +308,29 @@ class RedisDatabase:
async def delete_job(self, job_id: str):
"""Delete job"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Job Applications operations
async def get_job_application(self, application_id: str) -> Optional[Dict]:
"""Get job application by ID"""
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
data = await self.redis.get(key)
data = await self.redis_client.get(key)
return self._deserialize(data) if data else None
async def set_job_application(self, application_id: str, application_data: Dict):
"""Set job application data"""
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
await self.redis.set(key, self._serialize(application_data))
await self.redis_client.set(key, self._serialize(application_data))
async def get_all_job_applications(self) -> Dict[str, Any]:
"""Get all job applications"""
pattern = f"{self.KEY_PREFIXES['job_applications']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -384,29 +345,29 @@ class RedisDatabase:
async def delete_job_application(self, application_id: str):
"""Delete job application"""
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Chat Sessions operations
async def get_chat_session(self, session_id: str) -> Optional[Dict]:
"""Get chat session by ID"""
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
data = await self.redis.get(key)
data = await self.redis_client.get(key)
return self._deserialize(data) if data else None
async def set_chat_session(self, session_id: str, session_data: Dict):
"""Set chat session data"""
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
await self.redis.set(key, self._serialize(session_data))
await self.redis_client.set(key, self._serialize(session_data))
async def get_all_chat_sessions(self) -> Dict[str, Any]:
"""Get all chat sessions"""
pattern = f"{self.KEY_PREFIXES['chat_sessions']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -421,36 +382,36 @@ class RedisDatabase:
async def delete_chat_session(self, session_id: str):
"""Delete chat session"""
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Chat Messages operations (stored as lists)
async def get_chat_messages(self, session_id: str) -> List[Dict]:
"""Get chat messages for a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
messages = await self.redis.lrange(key, 0, -1)
messages = await self.redis_client.lrange(key, 0, -1)
return [self._deserialize(msg) for msg in messages if msg]
async def add_chat_message(self, session_id: str, message_data: Dict):
"""Add a chat message to a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
await self.redis.rpush(key, self._serialize(message_data))
await self.redis_client.rpush(key, self._serialize(message_data))
async def set_chat_messages(self, session_id: str, messages: List[Dict]):
"""Set all chat messages for a session (replaces existing)"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
# Clear existing messages
await self.redis.delete(key)
await self.redis_client.delete(key)
# Add new messages
if messages:
serialized_messages = [self._serialize(msg) for msg in messages]
await self.redis.rpush(key, *serialized_messages)
await self.redis_client.rpush(key, *serialized_messages)
async def get_all_chat_messages(self) -> Dict[str, List[Dict]]:
"""Get all chat messages grouped by session"""
pattern = f"{self.KEY_PREFIXES['chat_messages']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
@ -458,7 +419,7 @@ class RedisDatabase:
result = {}
for key in keys:
session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '')
messages = await self.redis.lrange(key, 0, -1)
messages = await self.redis_client.lrange(key, 0, -1)
result[session_id] = [self._deserialize(msg) for msg in messages if msg]
return result
@ -466,7 +427,7 @@ class RedisDatabase:
async def delete_chat_messages(self, session_id: str):
"""Delete all chat messages for a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Enhanced Chat Session Methods
async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]:
@ -513,7 +474,7 @@ class RedisDatabase:
async def get_chat_message_count(self, session_id: str) -> int:
"""Get the total number of messages in a chat session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
return await self.redis.llen(key)
return await self.redis_client.llen(key)
async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]:
"""Search for messages containing specific text in a session"""
@ -565,7 +526,7 @@ class RedisDatabase:
async def get_user_by_username(self, username: str) -> Optional[Dict]:
"""Get user by username specifically"""
username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}"
data = await self.redis.get(username_key)
data = await self.redis_client.get(username_key)
return self._deserialize(data) if data else None
async def find_candidate_by_username(self, username: str) -> Optional[Dict]:
@ -615,7 +576,6 @@ class RedisDatabase:
stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"]
return stats
async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]:
"""Get a summary of chat activity for a specific candidate"""
@ -665,7 +625,7 @@ class RedisDatabase:
async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]):
"""Bulk update multiple chat sessions"""
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for session_id, updates in session_updates.items():
session_data = await self.get_chat_session(session_id)
@ -682,23 +642,23 @@ class RedisDatabase:
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
"""Get AI parameters by ID"""
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
data = await self.redis.get(key)
data = await self.redis_client.get(key)
return self._deserialize(data) if data else None
async def set_ai_parameters(self, param_id: str, param_data: Dict):
"""Set AI parameters data"""
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
await self.redis.set(key, self._serialize(param_data))
await self.redis_client.set(key, self._serialize(param_data))
async def get_all_ai_parameters(self) -> Dict[str, Any]:
"""Get all AI parameters"""
pattern = f"{self.KEY_PREFIXES['ai_parameters']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -713,18 +673,37 @@ class RedisDatabase:
async def delete_ai_parameters(self, param_id: str):
"""Delete AI parameters"""
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
await self.redis.delete(key)
await self.redis_client.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]:
"""Get all users"""
pattern = f"{self.KEY_PREFIXES['users']}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if not keys:
return {}
pipe = self.redis.pipeline()
pipe = self.redis_client.pipeline()
for key in keys:
pipe.get(key)
values = await pipe.execute()
@ -739,334 +718,32 @@ class RedisDatabase:
async def delete_user(self, email: str):
"""Delete user"""
key = f"{self.KEY_PREFIXES['users']}{email}"
await self.redis.delete(key)
await self.redis_client.delete(key)
# Utility methods
async def clear_all_data(self):
"""Clear all data from Redis (use with caution!)"""
for prefix in self.KEY_PREFIXES.values():
pattern = f"{prefix}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
if keys:
await self.redis.delete(*keys)
await self.redis_client.delete(*keys)
async def get_stats(self) -> Dict[str, int]:
"""Get statistics about stored data"""
stats = {}
for data_type, prefix in self.KEY_PREFIXES.items():
pattern = f"{prefix}*"
keys = await self.redis.keys(pattern)
keys = await self.redis_client.keys(pattern)
stats[data_type] = len(keys)
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
redis_manager = _RedisManager()
class DatabaseManager:
"""Enhanced database manager with graceful shutdown capabilities"""
def __init__(self):
self.db: Optional[RedisDatabase] = None
self._shutdown_initiated = False
@ -1085,9 +762,9 @@ class DatabaseManager:
self.db = RedisDatabase(redis_manager.get_client())
# Test connection and log stats
if not redis_manager.redis:
if not redis_manager.redis_client:
raise RuntimeError("Redis client not initialized")
await redis_manager.redis.ping()
await redis_manager.redis_client.ping()
stats = await self.db.get_stats()
logger.info(f"Database initialized successfully. Stats: {stats}")
@ -1146,10 +823,10 @@ class DatabaseManager:
# Force Redis to save data to disk
try:
if redis_manager.redis:
if redis_manager.redis_client:
# Try BGSAVE first (non-blocking)
try:
await redis_manager.redis.bgsave()
await redis_manager.redis_client.bgsave()
logger.info("Background save initiated")
# Wait a bit for background save to start
@ -1159,7 +836,7 @@ class DatabaseManager:
logger.warning(f"Background save failed, trying synchronous save: {e}")
try:
# Fallback to synchronous save
await redis_manager.redis.save()
await redis_manager.redis_client.save()
logger.info("Synchronous save completed")
except Exception as e2:
logger.warning(f"Synchronous save also failed (Redis persistence may be disabled): {e2}")
@ -1195,5 +872,4 @@ class DatabaseManager:
raise RuntimeError("Database not initialized")
if self._shutdown_initiated:
raise RuntimeError("Application is shutting down")
return self.db
return self.db

View File

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

View File

@ -1,8 +1,7 @@
#!/usr/bin/env python
"""
Enhanced Type Generator - Generate TypeScript types from Pydantic models
Now with command line parameters, pre-test validation, TypeScript compilation,
and automatic date field conversion functions
Now with command line parameters, pre-test validation, and TypeScript compilation
"""
import sys
@ -85,59 +84,6 @@ def unwrap_annotated_type(python_type: Any) -> Any:
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:
"""Convert a Python type to TypeScript type string"""
@ -356,7 +302,6 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
"""Process a Pydantic model and return TypeScript interface definition"""
interface_name = model_class.__name__
properties = []
date_fields = [] # Track date fields for conversion functions
if debug:
print(f" 🔍 Processing model: {interface_name}")
@ -382,22 +327,6 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
if debug:
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)
# Check if optional
@ -433,22 +362,6 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
if debug:
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)
# For Pydantic v1, check required and default
@ -483,8 +396,7 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
return {
'name': interface_name,
'properties': properties,
'date_fields': date_fields
'properties': properties
}
def process_enum(enum_class) -> Dict[str, Any]:
@ -498,106 +410,6 @@ def process_enum(enum_class) -> Dict[str, Any]:
'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):
"""Generate TypeScript interfaces from models"""
@ -637,8 +449,7 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
interface = process_pydantic_model(obj, debug)
interfaces.append(interface)
date_count = len(interface.get('date_fields', []))
print(f" ✅ Found Pydantic model: {name}" + (f" ({date_count} date fields)" if date_count > 0 else ""))
print(f" ✅ Found Pydantic model: {name}")
# Check if it's an Enum
elif (isinstance(obj, type) and
@ -655,9 +466,7 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
traceback.print_exc()
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"🗓️ Found {total_date_fields} date fields across all models")
# Generate TypeScript content
ts_content = f"""// Generated TypeScript types from Pydantic models
@ -691,13 +500,8 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
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
user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer', 'Viewer']]
user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer']]
if len(user_interfaces) >= 2:
ts_content += "// ============================\n"
ts_content += "// Union Types\n"
@ -730,7 +534,7 @@ def compile_typescript(ts_file: str) -> bool:
def main():
"""Main function with command line argument parsing"""
parser = argparse.ArgumentParser(
description='Generate TypeScript types from Pydantic models with date conversion functions',
description='Generate TypeScript types from Pydantic models',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
@ -740,10 +544,6 @@ Examples:
python generate_types.py --skip-compile # Skip TS compilation
python generate_types.py --debug # Enable debug output
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');
"""
)
@ -780,13 +580,13 @@ Generated conversion functions can be used like:
parser.add_argument(
'--version', '-v',
action='version',
version='TypeScript Generator 3.0 (with Date Conversion)'
version='TypeScript Generator 2.0'
)
args = parser.parse_args()
print("🚀 Enhanced TypeScript Type Generator with Date Conversion")
print("=" * 60)
print("🚀 Enhanced TypeScript Type Generator")
print("=" * 50)
print(f"📁 Source file: {args.source}")
print(f"📁 Output file: {args.output}")
print()
@ -808,7 +608,7 @@ Generated conversion functions can be used like:
print()
# Step 3: Generate TypeScript content
print("🔄 Generating TypeScript types and conversion functions...")
print("🔄 Generating TypeScript types...")
if args.debug:
print("🐛 Debug mode enabled - detailed output follows:")
print()
@ -828,28 +628,6 @@ Generated conversion functions can be used like:
file_size = len(ts_content)
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)
if not args.skip_compile:
print()
@ -861,21 +639,15 @@ Generated conversion functions can be used like:
# Step 6: Success summary
print(f"\n🎉 Type generation completed successfully!")
print("=" * 60)
print("=" * 50)
print(f"✅ Generated {args.output} from {args.source}")
print(f"✅ File size: {file_size} characters")
if conversion_count > 0:
print(f"✅ Date conversion functions: {conversion_count}")
if not args.skip_test:
print("✅ Model validation passed")
if not args.skip_compile:
print("✅ TypeScript syntax validated")
print(f"\n💡 Usage in your TypeScript project:")
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');")
print(f" import {{ Candidate, Employer, Job }} from './{Path(args.output).stem}';")
return True

View File

@ -17,39 +17,15 @@ import signal
import json
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
from prometheus_client import Summary # type: ignore
from prometheus_fastapi_instrumentator import Instrumentator # 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
# =============================
from models import (
# User models
Candidate, Employer, Viewer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
# Job models
Job, JobApplication, ApplicationStatus,
@ -61,6 +37,13 @@ from models import (
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
# ============================
@ -92,6 +75,10 @@ async def lifespan(app: FastAPI):
# Initialize database
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.SIGINT, signal_handler)
@ -144,89 +131,6 @@ ALGORITHM = "HS256"
# 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):
to_encode = data.copy()
if expires_delta:
@ -247,17 +151,17 @@ async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
# Check if token is blacklisted
redis = redis_manager.get_client()
redis_client = redis_manager.get_client()
blacklist_key = f"blacklisted_token:{credentials.credentials}"
is_blacklisted = await redis.exists(blacklist_key)
is_blacklisted = await redis_client.exists(blacklist_key)
if is_blacklisted:
logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}")
raise HTTPException(status_code=401, detail="Token has been revoked")
# Optional: Check if all user tokens are revoked (for "logout from all devices")
# user_revoked_key = f"user_tokens_revoked:{user_id}"
# user_tokens_revoked_at = await redis.get(user_revoked_key)
# user_tokens_revoked_at = await redis_client.get(user_revoked_key)
# if user_tokens_revoked_at:
# revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode())
# token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC)
@ -278,12 +182,7 @@ async def get_current_user(
) -> BaseUserWithType:
"""Get current user from database"""
try:
# Check viewers
viewer = await database.get_viewer(user_id)
if viewer:
return Viewer.model_validate(viewer)
# Check candidates
# Check candidates first
candidate = await database.get_candidate(user_id)
if candidate:
return Candidate.model_validate(candidate)
@ -395,80 +294,65 @@ api_router = APIRouter(prefix="/api/1.0")
@api_router.post("/auth/login")
async def login(
request: LoginRequest,
login: str = Body(...),
password: str = Body(...),
database: RedisDatabase = Depends(get_database)
):
"""Secure login endpoint with password verification"""
"""Login endpoint"""
try:
# Initialize authentication manager
auth_manager = AuthenticationManager(database)
# 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}")
# Check if user exists (simplified - in real app, check hashed password)
user_data = await database.get_user(login)
if not user_data:
logger.info(f"⚠️ Login attempt with non-existent email: {login}")
return JSONResponse(
status_code=401,
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
content=create_error_response("AUTH_FAILED", "Invalid credentials")
)
# Update last login timestamp
await auth_manager.update_last_login(user_data["id"])
logger.info(f"🔑 User {request.login} logged in successfully")
logger.info(f"🔑 User {login} logged in successfully")
# Create tokens
access_token = create_access_token(data={"sub": user_data["id"]})
refresh_token = create_access_token(
data={"sub": user_data["id"], "type": "refresh"},
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS)
expires_delta=timedelta(days=30)
)
# Get user object based on type
# Get user object
user = None
if user_data["type"] == "candidate":
logger.info(f"🔑 User {request.login} is a candidate")
logger.info(f"🔑 User {login} is a candidate")
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user = Candidate.model_validate(candidate_data)
elif user_data["type"] == "employer":
logger.info(f"🔑 User {request.login} is an employer")
logger.info(f"🔑 User {login} is a employer")
employer_data = await database.get_employer(user_data["id"])
if 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)
user = Employer.model_validate(employer_data)
if not user:
logger.error(f"❌ User object not found for {user_data['id']}")
return JSONResponse(
status_code=404,
content=create_error_response("USER_NOT_FOUND", "User profile not found")
content=create_error_response("USER_NOT_FOUND", "User not found")
)
# Create response
auth_response = AuthResponse(
accessToken=access_token,
refreshToken=refresh_token,
user=user,
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp())
)
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f" Login error: {e}")
logger.error(f"⚠️ Login error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
content=create_error_response("LOGIN_ERROR", str(e))
)
@api_router.post("/auth/logout")
async def logout(
access_token: str = Body(..., alias="accessToken"),
@ -506,12 +390,12 @@ async def logout(
)
# Get Redis client
redis = redis_manager.get_client()
redis_client = redis_manager.get_client()
# Revoke refresh token (blacklist it until its natural expiration)
refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp()))
if refresh_ttl > 0:
await redis.setex(
await redis_client.setex(
f"blacklisted_token:{refresh_token}",
refresh_ttl,
json.dumps({
@ -534,7 +418,7 @@ async def logout(
if access_user_id == user_id:
access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp()))
if access_ttl > 0:
await redis.setex(
await redis_client.setex(
f"blacklisted_token:{access_token}",
access_ttl,
json.dumps({
@ -554,7 +438,7 @@ async def logout(
# Optional: Revoke all tokens for this user (for "logout from all devices")
# Uncomment the following lines if you want to implement this feature:
#
# await redis.setex(
# await redis_client.setex(
# f"user_tokens_revoked:{user_id}",
# timedelta(days=30).total_seconds(), # Max refresh token lifetime
# datetime.now(UTC).isoformat()
@ -583,10 +467,10 @@ async def logout_all_devices(
):
"""Logout from all devices by revoking all tokens for the user"""
try:
redis = redis_manager.get_client()
redis_client = redis_manager.get_client()
# Set a timestamp that invalidates all tokens issued before this moment
await redis.setex(
await redis_client.setex(
f"user_tokens_revoked:{current_user.id}",
int(timedelta(days=30).total_seconds()), # Max refresh token lifetime
datetime.now(UTC).isoformat()
@ -634,10 +518,6 @@ async def refresh_token_endpoint(
employer_data = await database.get_employer(user_id)
if 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:
return JSONResponse(
@ -666,195 +546,48 @@ async def refresh_token_endpoint(
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
# ============================
@api_router.post("/candidates")
async def create_candidate(
request: CreateCandidateRequest,
candidate_data: Dict[str, Any] = Body(...),
database: RedisDatabase = Depends(get_database)
):
"""Create a new candidate with secure password handling and duplicate checking"""
"""Create a new candidate"""
try:
# Initialize authentication manager
auth_manager = AuthenticationManager(database)
# Add required fields
candidate_data["id"] = str(uuid.uuid4())
candidate_data["createdAt"] = datetime.now(UTC).isoformat()
candidate_data["updatedAt"] = datetime.now(UTC).isoformat()
# 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 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
# Create candidate
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
# Check if candidate already exists
existing_candidate = await database.get_candidate(candidate.id)
if existing_candidate:
return JSONResponse(
status_code=400,
content=create_error_response("ALREADY_EXISTS", "Candidate already exists")
)
await database.set_candidate(candidate.id, candidate.model_dump())
# Add to users for auth lookup (by email and username)
user_auth_data = {
# Add to users for auth (simplified)
await database.set_user(candidate, {
"id": candidate.id,
"type": "candidate",
"email": candidate.email,
"username": request.username
}
"type": "candidate"
})
# 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
return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True))
logger.info(f"✅ Created candidate: {candidate.email} (ID: {candidate.id})")
# 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}")
except Exception as e:
logger.error(f"Candidate creation error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("VALIDATION_ERROR", str(ve))
content=create_error_response("CREATION_FAILED", str(e))
)
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}")
async def get_candidate(
username: str = Path(...),
@ -1020,185 +753,6 @@ async def search_candidates(
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
# ============================
@ -1795,17 +1349,17 @@ async def enhanced_health_check():
"""Enhanced health check endpoint"""
try:
database = db_manager.get_database()
if not redis_manager.redis:
if not redis_manager.redis_client:
raise RuntimeError("Redis client not initialized")
# Test Redis connection
await redis_manager.redis.ping()
await redis_manager.redis_client.ping()
# Get database stats
stats = await database.get_stats()
# Redis info
redis_info = await redis_manager.redis.info()
redis_info = await redis_manager.redis_client.info()
return {
"status": "healthy",
@ -1832,9 +1386,9 @@ async def enhanced_health_check():
return {"status": "error", "message": str(e)}
@api_router.get("/redis/stats")
async def redis_stats(redis: redis.Redis = Depends(get_redis)):
async def redis_stats(redis_client: redis.Redis = Depends(get_redis)):
try:
info = await redis.info()
info = await redis_client.info()
return {
"connected_clients": info.get("connected_clients"),
"used_memory_human": info.get("used_memory_human"),
@ -1947,6 +1501,96 @@ async def root():
"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__":
host = defines.host
port = defines.port

View File

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