Compare commits
2 Commits
89b71a1428
...
4330bd4b7c
Author | SHA1 | Date | |
---|---|---|---|
4330bd4b7c | |||
adb407b19a |
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
@ -27,6 +27,7 @@
|
|||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@uiw/react-json-view": "^2.0.0-alpha.31",
|
"@uiw/react-json-view": "^2.0.0-alpha.31",
|
||||||
"@uiw/react-markdown-editor": "^6.1.4",
|
"@uiw/react-markdown-editor": "^6.1.4",
|
||||||
|
"country-state-city": "^3.2.1",
|
||||||
"jsonrepair": "^3.12.0",
|
"jsonrepair": "^3.12.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
@ -8516,6 +8517,11 @@
|
|||||||
"integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==",
|
"integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/country-state-city": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA=="
|
||||||
|
},
|
||||||
"node_modules/create-require": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@uiw/react-json-view": "^2.0.0-alpha.31",
|
"@uiw/react-json-view": "^2.0.0-alpha.31",
|
||||||
"@uiw/react-markdown-editor": "^6.1.4",
|
"@uiw/react-markdown-editor": "^6.1.4",
|
||||||
|
"country-state-city": "^3.2.1",
|
||||||
"jsonrepair": "^3.12.0",
|
"jsonrepair": "^3.12.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
|
@ -110,12 +110,16 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
|||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
{ candidate.location && <Typography variant="body2" sx={{ mb: 1 }}>
|
{candidate.location &&
|
||||||
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
</Typography> }
|
<strong>Location:</strong> {candidate.location.city}, {candidate.location.state || candidate.location.country}
|
||||||
{ candidate.email && <Typography variant="body2" sx={{ mb: 1 }}>
|
</Typography>
|
||||||
<strong>Email:</strong> {candidate.email}
|
}
|
||||||
</Typography> }
|
{candidate.email &&
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
<strong>Email:</strong> {candidate.email}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
{ candidate.phone && <Typography variant="body2">
|
{ candidate.phone && <Typography variant="body2">
|
||||||
<strong>Phone:</strong> {candidate.phone}
|
<strong>Phone:</strong> {candidate.phone}
|
||||||
</Typography> }
|
</Typography> }
|
||||||
|
@ -5,6 +5,7 @@ import { Quote } from 'components/Quote';
|
|||||||
import { BackstoryElementProps } from 'components/BackstoryTab';
|
import { BackstoryElementProps } from 'components/BackstoryTab';
|
||||||
import { useUser } from 'hooks/useUser';
|
import { useUser } from 'hooks/useUser';
|
||||||
import { Candidate, ChatSession } from 'types/types';
|
import { Candidate, ChatSession } from 'types/types';
|
||||||
|
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||||
|
|
||||||
interface GenerateImageProps extends BackstoryElementProps {
|
interface GenerateImageProps extends BackstoryElementProps {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@ -12,7 +13,7 @@ interface GenerateImageProps extends BackstoryElementProps {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GenerateImage = (props: GenerateImageProps) => {
|
const GenerateImage = (props: GenerateImageProps) => {
|
||||||
const { user } = useUser();
|
const { user } = useSecureAuth();
|
||||||
const { setSnack, chatSession, prompt } = props;
|
const { setSnack, chatSession, prompt } = props;
|
||||||
const [processing, setProcessing] = useState<boolean>(false);
|
const [processing, setProcessing] = useState<boolean>(false);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
|
361
frontend/src/components/LocationInput.tsx
Normal file
361
frontend/src/components/LocationInput.tsx
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Chip,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox
|
||||||
|
} from '@mui/material';
|
||||||
|
import { LocationOn, Public, Home } from '@mui/icons-material';
|
||||||
|
import { Country, State, City } from 'country-state-city';
|
||||||
|
import type { ICountry, IState, ICity } from 'country-state-city';
|
||||||
|
// Import from your types file - adjust path as needed
|
||||||
|
import type { Location } from 'types/types';
|
||||||
|
|
||||||
|
interface LocationInputProps {
|
||||||
|
value?: Partial<Location>;
|
||||||
|
onChange: (location: Partial<Location>) => void;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCity?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationInput: React.FC<LocationInputProps> = ({
|
||||||
|
value = {},
|
||||||
|
onChange,
|
||||||
|
error = false,
|
||||||
|
helperText,
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
showCity = false
|
||||||
|
}) => {
|
||||||
|
// Get all countries from the library
|
||||||
|
const allCountries = Country.getAllCountries();
|
||||||
|
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState<ICountry | null>(
|
||||||
|
value.country ? allCountries.find(c => c.name === value.country) || null : null
|
||||||
|
);
|
||||||
|
const [selectedState, setSelectedState] = useState<IState | null>(null);
|
||||||
|
const [selectedCity, setSelectedCity] = useState<ICity | null>(null);
|
||||||
|
const [isRemote, setIsRemote] = useState<boolean>(value.remote || false);
|
||||||
|
|
||||||
|
// Get states for selected country
|
||||||
|
const availableStates = selectedCountry ? State.getStatesOfCountry(selectedCountry.isoCode) : [];
|
||||||
|
|
||||||
|
// Get cities for selected state
|
||||||
|
const availableCities = selectedCountry && selectedState
|
||||||
|
? City.getCitiesOfState(selectedCountry.isoCode, selectedState.isoCode)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Initialize state and city from value prop
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCountry && value.state) {
|
||||||
|
const stateMatch = availableStates.find(s => s.name === value.state);
|
||||||
|
setSelectedState(stateMatch || null);
|
||||||
|
}
|
||||||
|
}, [selectedCountry, value.state, availableStates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCountry && selectedState && value.city && showCity) {
|
||||||
|
const cityMatch = availableCities.find(c => c.name === value.city);
|
||||||
|
setSelectedCity(cityMatch || null);
|
||||||
|
}
|
||||||
|
}, [selectedCountry, selectedState, value.city, availableCities, showCity]);
|
||||||
|
|
||||||
|
// Update parent component when values change
|
||||||
|
useEffect(() => {
|
||||||
|
const newLocation: Partial<Location> = {};
|
||||||
|
|
||||||
|
if (selectedCountry) {
|
||||||
|
newLocation.country = selectedCountry.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedState) {
|
||||||
|
newLocation.state = selectedState.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCity && showCity) {
|
||||||
|
newLocation.city = selectedCity.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRemote) {
|
||||||
|
newLocation.remote = isRemote;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only call onChange if there's actual data or if clearing
|
||||||
|
if (Object.keys(newLocation).length > 0 || (value.country || value.state || value.city)) {
|
||||||
|
onChange(newLocation);
|
||||||
|
}
|
||||||
|
}, [selectedCountry, selectedState, selectedCity, isRemote, onChange, value.country, value.state, value.city, showCity]);
|
||||||
|
|
||||||
|
const handleCountryChange = (event: any, newValue: ICountry | null) => {
|
||||||
|
setSelectedCountry(newValue);
|
||||||
|
// Clear state and city when country changes
|
||||||
|
setSelectedState(null);
|
||||||
|
setSelectedCity(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStateChange = (event: any, newValue: IState | null) => {
|
||||||
|
setSelectedState(newValue);
|
||||||
|
// Clear city when state changes
|
||||||
|
setSelectedCity(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCityChange = (event: any, newValue: ICity | null) => {
|
||||||
|
setSelectedCity(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setIsRemote(event.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<LocationOn color="primary" />
|
||||||
|
Location {required && <span style={{ color: 'red' }}>*</span>}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{/* Country Selection */}
|
||||||
|
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
|
||||||
|
<Autocomplete
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
options={allCountries}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
disabled={disabled}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Country"
|
||||||
|
variant="outlined"
|
||||||
|
required={required}
|
||||||
|
error={error && required && !selectedCountry}
|
||||||
|
helperText={error && required && !selectedCountry ? 'Country is required' : helperText}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: <Public sx={{ mr: 1, color: 'text.secondary' }} />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<Box component="li" {...props} key={option.isoCode}>
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
width="20"
|
||||||
|
src={`https://flagcdn.com/w20/${option.isoCode.toLowerCase()}.png`}
|
||||||
|
srcSet={`https://flagcdn.com/w40/${option.isoCode.toLowerCase()}.png 2x`}
|
||||||
|
alt=""
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
{option.name}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* State/Region Selection */}
|
||||||
|
{selectedCountry && (
|
||||||
|
<Grid size={{ xs: 12, sm: showCity ? 4 : 6 }}>
|
||||||
|
<Autocomplete
|
||||||
|
value={selectedState}
|
||||||
|
onChange={handleStateChange}
|
||||||
|
options={availableStates}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
disabled={disabled || availableStates.length === 0}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="State/Region"
|
||||||
|
variant="outlined"
|
||||||
|
placeholder={availableStates.length > 0 ? "Select state/region" : "No states available"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* City Selection */}
|
||||||
|
{showCity && selectedCountry && selectedState && (
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<Autocomplete
|
||||||
|
value={selectedCity}
|
||||||
|
onChange={handleCityChange}
|
||||||
|
options={availableCities}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
disabled={disabled || availableCities.length === 0}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="City"
|
||||||
|
variant="outlined"
|
||||||
|
placeholder={availableCities.length > 0 ? "Select city" : "No cities available"}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: <Home sx={{ mr: 1, color: 'text.secondary' }} />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remote Work Option */}
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={isRemote}
|
||||||
|
onChange={handleRemoteToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Open to remote work"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Location Summary Chips */}
|
||||||
|
{(selectedCountry || selectedState || selectedCity || isRemote) && (
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
||||||
|
{selectedCountry && (
|
||||||
|
<Chip
|
||||||
|
icon={<Public />}
|
||||||
|
label={selectedCountry.name}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedState && (
|
||||||
|
<Chip
|
||||||
|
label={selectedState.name}
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedCity && showCity && (
|
||||||
|
<Chip
|
||||||
|
icon={<Home />}
|
||||||
|
label={selectedCity.name}
|
||||||
|
variant="outlined"
|
||||||
|
color="default"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isRemote && (
|
||||||
|
<Chip
|
||||||
|
label="Remote"
|
||||||
|
variant="filled"
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Demo component to show usage with real data
|
||||||
|
const LocationInputDemo: React.FC = () => {
|
||||||
|
const [location, setLocation] = useState<Partial<Location>>({});
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
const handleLocationChange = (newLocation: Partial<Location>) => {
|
||||||
|
setLocation(newLocation);
|
||||||
|
console.log('Location updated:', newLocation);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show some stats about the data
|
||||||
|
const totalCountries = Country.getAllCountries().length;
|
||||||
|
const usStates = State.getStatesOfCountry('US').length;
|
||||||
|
const canadaProvinces = State.getStatesOfCountry('CA').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto' }}>
|
||||||
|
<Typography variant="h4" gutterBottom align="center" color="primary">
|
||||||
|
Location Input with Real Data
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 3 }}>
|
||||||
|
Using country-state-city library with {totalCountries} countries,
|
||||||
|
{usStates} US states, {canadaProvinces} Canadian provinces, and thousands of cities
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Basic Location Input
|
||||||
|
</Typography>
|
||||||
|
<LocationInput
|
||||||
|
value={location}
|
||||||
|
onChange={handleLocationChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={showAdvanced}
|
||||||
|
onChange={(e) => setShowAdvanced(e.target.checked)}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Show city field"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Advanced Location Input (with City)
|
||||||
|
</Typography>
|
||||||
|
<LocationInput
|
||||||
|
value={location}
|
||||||
|
onChange={handleLocationChange}
|
||||||
|
showCity
|
||||||
|
helperText="Include your city for more specific job matches"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Current Location Data:
|
||||||
|
</Typography>
|
||||||
|
<Box component="pre" sx={{
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(location, null, 2)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
💡 This component uses the country-state-city library which is regularly updated
|
||||||
|
and includes ISO codes, flags, and comprehensive location data.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LocationInput };
|
@ -3,6 +3,7 @@ import { Outlet, useLocation, Routes } from "react-router-dom";
|
|||||||
import { Box, Container, Paper } from '@mui/material';
|
import { Box, Container, Paper } from '@mui/material';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import ChatIcon from '@mui/icons-material/Chat';
|
import ChatIcon from '@mui/icons-material/Chat';
|
||||||
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
@ -19,6 +20,7 @@ import { useUser } from 'hooks/useUser';
|
|||||||
import { User } from 'types/types';
|
import { User } from 'types/types';
|
||||||
import { getBackstoryDynamicRoutes } from 'components/layout/BackstoryRoutes';
|
import { getBackstoryDynamicRoutes } from 'components/layout/BackstoryRoutes';
|
||||||
import { LoadingComponent } from "components/LoadingComponent";
|
import { LoadingComponent } from "components/LoadingComponent";
|
||||||
|
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||||
|
|
||||||
type NavigationLinkType = {
|
type NavigationLinkType = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -29,41 +31,44 @@ type NavigationLinkType = {
|
|||||||
|
|
||||||
const DefaultNavItems: NavigationLinkType[] = [
|
const DefaultNavItems: NavigationLinkType[] = [
|
||||||
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
|
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
|
||||||
{ name: 'Docs', path: '/docs', icon: <InfoIcon /> },
|
{ name: 'Docs', path: '/docs', icon: <InfoIcon /> },
|
||||||
// { name: 'How It Works', path: '/how-it-works', icon: <InfoIcon/> },
|
// { name: 'How It Works', path: '/how-it-works', icon: <InfoIcon/> },
|
||||||
// { name: 'For Candidates', path: '/for-candidates', icon: <PersonIcon/> },
|
// { name: 'For Candidates', path: '/for-candidates', icon: <PersonIcon/> },
|
||||||
// { name: 'For Employers', path: '/for-employers', icon: <BusinessIcon/> },
|
// { name: 'For Employers', path: '/for-employers', icon: <BusinessIcon/> },
|
||||||
// { name: 'Pricing', path: '/pricing', icon: <AttachMoneyIcon/> },
|
// { name: 'Pricing', path: '/pricing', icon: <AttachMoneyIcon/> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ViewerNavItems: NavigationLinkType[] = [
|
||||||
|
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CandidateNavItems : NavigationLinkType[]= [
|
const CandidateNavItems : NavigationLinkType[]= [
|
||||||
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
|
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
|
||||||
// { name: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon /> },
|
// { name: 'Job Analysis', path: '/candidate/job-analysis', icon: <WorkIcon /> },
|
||||||
{ name: 'Resume Builder', path: '/resume-builder', icon: <WorkIcon /> },
|
{ name: 'Resume Builder', path: '/candidate/resume-builder', icon: <WorkIcon /> },
|
||||||
// { name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: <WorkIcon /> },
|
// { name: 'Knowledge Explorer', path: '/candidate/knowledge-explorer', icon: <WorkIcon /> },
|
||||||
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
|
{ name: 'Dashboard', icon: <DashboardIcon />, path: '/candidate/dashboard' },
|
||||||
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' },
|
// { name: 'Profile', icon: <PersonIcon />, path: '/candidate/profile' },
|
||||||
// { name: 'Profile', icon: <PersonIcon />, path: '/profile' },
|
// { name: 'Backstory', icon: <HistoryIcon />, path: '/candidate/backstory' },
|
||||||
// { name: 'Backstory', icon: <HistoryIcon />, path: '/backstory' },
|
// { name: 'Resumes', icon: <DescriptionIcon />, path: '/candidate/resumes' },
|
||||||
// { name: 'Resumes', icon: <DescriptionIcon />, path: '/resumes' },
|
// { name: 'Q&A Setup', icon: <QuestionAnswerIcon />, path: '/candidate/qa-setup' },
|
||||||
// { name: 'Q&A Setup', icon: <QuestionAnswerIcon />, path: '/qa-setup' },
|
// { name: 'Analytics', icon: <BarChartIcon />, path: '/candidate/analytics' },
|
||||||
// { name: 'Analytics', icon: <BarChartIcon />, path: '/analytics' },
|
// { name: 'Settings', icon: <SettingsIcon />, path: '/candidate/settings' },
|
||||||
// { name: 'Settings', icon: <SettingsIcon />, path: '/settings' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const EmployerNavItems: NavigationLinkType[] = [
|
const EmployerNavItems: NavigationLinkType[] = [
|
||||||
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
|
{ name: 'Chat', path: '/chat', icon: <ChatIcon /> },
|
||||||
{ name: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon /> },
|
{ name: 'Job Analysis', path: '/employer/job-analysis', icon: <WorkIcon /> },
|
||||||
{ name: 'Resume Builder', path: '/resume-builder', icon: <WorkIcon /> },
|
{ name: 'Resume Builder', path: '/employer/resume-builder', icon: <WorkIcon /> },
|
||||||
{ name: 'Knowledge Explorer', path: '/knowledge-explorer', icon: <WorkIcon /> },
|
{ name: 'Knowledge Explorer', path: '/employer/knowledge-explorer', icon: <WorkIcon /> },
|
||||||
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
|
{ name: 'Find a Candidate', path: '/find-a-candidate', icon: <InfoIcon /> },
|
||||||
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/dashboard' },
|
// { name: 'Dashboard', icon: <DashboardIcon />, path: '/employer/dashboard' },
|
||||||
// { name: 'Search', icon: <SearchIcon />, path: '/search' },
|
// { name: 'Search', icon: <SearchIcon />, path: '/employer/search' },
|
||||||
// { name: 'Saved', icon: <BookmarkIcon />, path: '/saved' },
|
// { name: 'Saved', icon: <BookmarkIcon />, path: '/employer/saved' },
|
||||||
// { name: 'Jobs', icon: <WorkIcon />, path: '/jobs' },
|
// { name: 'Jobs', icon: <WorkIcon />, path: '/employer/jobs' },
|
||||||
// { name: 'Company', icon: <BusinessIcon />, path: '/company' },
|
// { name: 'Company', icon: <BusinessIcon />, path: '/employer/company' },
|
||||||
// { name: 'Analytics', icon: <BarChartIcon />, path: '/analytics' },
|
// { name: 'Analytics', icon: <BarChartIcon />, path: '/employer/analytics' },
|
||||||
// { name: 'Settings', icon: <SettingsIcon />, path: '/settings' },
|
// { name: 'Settings', icon: <SettingsIcon />, path: '/employer/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Navigation links based on user type
|
// Navigation links based on user type
|
||||||
@ -73,10 +78,12 @@ const getNavigationLinks = (user: User | null): NavigationLinkType[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (user.userType) {
|
switch (user.userType) {
|
||||||
|
case 'viewer':
|
||||||
|
return DefaultNavItems.concat(ViewerNavItems);
|
||||||
case 'candidate':
|
case 'candidate':
|
||||||
return CandidateNavItems;
|
return DefaultNavItems.concat(CandidateNavItems);
|
||||||
case 'employer':
|
case 'employer':
|
||||||
return EmployerNavItems;
|
return DefaultNavItems.concat(EmployerNavItems);
|
||||||
default:
|
default:
|
||||||
return DefaultNavItems;
|
return DefaultNavItems;
|
||||||
}
|
}
|
||||||
@ -130,7 +137,8 @@ const BackstoryLayout: React.FC<BackstoryLayoutProps> = (props: BackstoryLayoutP
|
|||||||
const { setSnack, page, chatRef, snackRef, submitQuery } = props;
|
const { setSnack, page, chatRef, snackRef, submitQuery } = props;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, guest, candidate } = useUser();
|
const { guest, candidate } = useUser();
|
||||||
|
const { user } = useSecureAuth();
|
||||||
const [navigationLinks, setNavigationLinks] = useState<NavigationLinkType[]>([]);
|
const [navigationLinks, setNavigationLinks] = useState<NavigationLinkType[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -18,6 +18,7 @@ import { JobAnalysisPage } from 'pages/JobAnalysisPage';
|
|||||||
import { GenerateCandidate } from "pages/GenerateCandidate";
|
import { GenerateCandidate } from "pages/GenerateCandidate";
|
||||||
import { ControlsPage } from 'pages/ControlsPage';
|
import { ControlsPage } from 'pages/ControlsPage';
|
||||||
import { LoginPage } from "pages/LoginPage";
|
import { LoginPage } from "pages/LoginPage";
|
||||||
|
import { CandidateDashboardPage } from "pages/CandidateDashboardPage"
|
||||||
|
|
||||||
const ProfilePage = () => (<BetaPage><Typography variant="h4">Profile</Typography></BetaPage>);
|
const ProfilePage = () => (<BetaPage><Typography variant="h4">Profile</Typography></BetaPage>);
|
||||||
const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>);
|
const BackstoryPage = () => (<BetaPage><Typography variant="h4">Backstory</Typography></BetaPage>);
|
||||||
@ -31,41 +32,44 @@ const LogoutPage = () => (<BetaPage><Typography variant="h4">Logout page...</Typ
|
|||||||
// const DashboardPage = () => (<BetaPage><Typography variant="h4">Dashboard</Typography></BetaPage>);
|
// const DashboardPage = () => (<BetaPage><Typography variant="h4">Dashboard</Typography></BetaPage>);
|
||||||
// const AnalyticsPage = () => (<BetaPage><Typography variant="h4">Analytics</Typography></BetaPage>);
|
// const AnalyticsPage = () => (<BetaPage><Typography variant="h4">Analytics</Typography></BetaPage>);
|
||||||
// const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typography></BetaPage>);
|
// const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typography></BetaPage>);
|
||||||
|
|
||||||
interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
|
interface BackstoryDynamicRoutesProps extends BackstoryPageProps {
|
||||||
chatRef: Ref<ConversationHandle>;
|
chatRef: Ref<ConversationHandle>;
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
}
|
}
|
||||||
const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => {
|
const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNode => {
|
||||||
const { user, setSnack, submitQuery, chatRef } = props;
|
const { user, setSnack, submitQuery, chatRef } = props;
|
||||||
|
const backstoryProps = {
|
||||||
|
setSnack, submitQuery
|
||||||
|
};
|
||||||
let index=0
|
let index=0
|
||||||
const routes = [
|
const routes = [
|
||||||
<Route key={`${index++}`} path="/" element={<HomePage/>} />,
|
<Route key={`${index++}`} path="/" element={<HomePage/>} />,
|
||||||
<Route key={`${index++}`} path="/chat" element={<CandidateChatPage ref={chatRef} setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/chat" element={<CandidateChatPage ref={chatRef} {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/docs" element={<DocsPage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/knowledge-explorer" element={<VectorVisualizerPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/knowledge-explorer" element={<VectorVisualizerPage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/find-a-candidate" element={<CandidateListingPage {...{ setSnack, submitQuery }} />} />,
|
<Route key={`${index++}`} path="/find-a-candidate" element={<CandidateListingPage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/job-analysis" element={<JobAnalysisPage />} />,
|
<Route key={`${index++}`} path="/job-analysis" element={<JobAnalysisPage {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/generate-candidate" element={<GenerateCandidate {...{ setSnack, submitQuery }} />} />,
|
<Route key={`${index++}`} path="/generate-candidate" element={<GenerateCandidate {...backstoryProps} />} />,
|
||||||
<Route key={`${index++}`} path="/settings" element={<ControlsPage {...{ setSnack, submitQuery }} />} />,
|
<Route key={`${index++}`} path="/settings" element={<ControlsPage {...backstoryProps} />} />,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
routes.push(<Route key={`${index++}`} path="/register" element={(<BetaPage><CreateProfilePage /></BetaPage>)} />);
|
routes.push(<Route key={`${index++}`} path="/register" element={(<BetaPage><CreateProfilePage /></BetaPage>)} />);
|
||||||
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage />} />);
|
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage {...backstoryProps} />} />);
|
||||||
routes.push(<Route key={`${index++}`} path="*" element={<BetaPage />} />);
|
routes.push(<Route key={`${index++}`} path="*" element={<BetaPage />} />);
|
||||||
} else {
|
} else {
|
||||||
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage />} />);
|
routes.push(<Route key={`${index++}`} path="/login" element={<LoginPage {...backstoryProps} />} />);
|
||||||
routes.push(<Route key={`${index++}`} path="/logout" element={<LogoutPage />} />);
|
routes.push(<Route key={`${index++}`} path="/logout" element={<LogoutPage />} />);
|
||||||
|
|
||||||
if (user.userType === 'candidate') {
|
if (user.userType === 'candidate') {
|
||||||
routes.splice(-1, 0, ...[
|
routes.splice(-1, 0, ...[
|
||||||
<Route key={`${index++}`} path="/profile" element={<ProfilePage />} />,
|
<Route key={`${index++}`} path="/candidate/dashboard" element={<BetaPage><CandidateDashboardPage {...backstoryProps} /></BetaPage>} />,
|
||||||
<Route key={`${index++}`} path="/backstory" element={<BackstoryPage />} />,
|
<Route key={`${index++}`} path="/candidate/profile" element={<ProfilePage />} />,
|
||||||
<Route key={`${index++}`} path="/resumes" element={<ResumesPage />} />,
|
<Route key={`${index++}`} path="/candidate/backstory" element={<BackstoryPage />} />,
|
||||||
<Route key={`${index++}`} path="/qa-setup" element={<QASetupPage />} />,
|
<Route key={`${index++}`} path="/candidate/resumes" element={<ResumesPage />} />,
|
||||||
|
<Route key={`${index++}`} path="/candidate/qa-setup" element={<QASetupPage />} />,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,11 +33,12 @@ import {
|
|||||||
import { NavigationLinkType } from 'components/layout/BackstoryLayout';
|
import { NavigationLinkType } from 'components/layout/BackstoryLayout';
|
||||||
import { Beta } from 'components/Beta';
|
import { Beta } from 'components/Beta';
|
||||||
import { useUser } from 'hooks/useUser';
|
import { useUser } from 'hooks/useUser';
|
||||||
import { Candidate, Employer } from 'types/types';
|
import { Candidate, Employer, Viewer } from 'types/types';
|
||||||
import { SetSnackType } from 'components/Snack';
|
import { SetSnackType } from 'components/Snack';
|
||||||
import { CopyBubble } from 'components/CopyBubble';
|
import { CopyBubble } from 'components/CopyBubble';
|
||||||
|
|
||||||
import 'components/layout/Header.css';
|
import 'components/layout/Header.css';
|
||||||
|
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||||
|
|
||||||
// Styled components
|
// Styled components
|
||||||
const StyledAppBar = styled(AppBar, {
|
const StyledAppBar = styled(AppBar, {
|
||||||
@ -97,9 +98,10 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
||||||
const { user, setUser } = useUser();
|
const { user, logout } = useSecureAuth();
|
||||||
const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null;
|
const candidate: Candidate | null = (user && user.userType === "candidate") ? user as Candidate : null;
|
||||||
const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null;
|
const employer: Employer | null = (user && user.userType === "employer") ? user as Employer : null;
|
||||||
|
const viewer: Viewer | null = (user && user.userType === "viewer") ? user as Viewer : null;
|
||||||
const {
|
const {
|
||||||
transparent = false,
|
transparent = false,
|
||||||
className,
|
className,
|
||||||
@ -112,7 +114,7 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const name = (candidate ? candidate.username : user?.email) || '';
|
const name = (user?.firstName || user?.email || '');
|
||||||
|
|
||||||
const BackstoryLogo = () => {
|
const BackstoryLogo = () => {
|
||||||
return <Typography
|
return <Typography
|
||||||
@ -175,7 +177,8 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
handleUserMenuClose();
|
handleUserMenuClose();
|
||||||
setUser(null);
|
logout();
|
||||||
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrawerToggle = () => {
|
const handleDrawerToggle = () => {
|
||||||
@ -325,31 +328,28 @@ const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
|
|||||||
vertical: 'top',
|
vertical: 'top',
|
||||||
horizontal: 'right',
|
horizontal: 'right',
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
"& .MuiList-root": { gap: 0 },
|
||||||
|
"& .MuiMenuItem-root": { gap: 1, display: "flex", flexDirection: "row", width: "100%" },
|
||||||
|
"& .MuiSvgIcon-root": { color: "#D4A017" }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={handleUserMenuClose} component="a" href="/profile">
|
<MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}/profile`) }}>
|
||||||
<ListItemIcon>
|
<Person fontSize="small" />
|
||||||
<Person fontSize="small" />
|
<Box>Profile</Box>
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Profile</ListItemText>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleUserMenuClose} component="a" href="/dashboard">
|
<MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}/dashboard`) }}>
|
||||||
<ListItemIcon>
|
<Dashboard fontSize="small" />
|
||||||
<Dashboard fontSize="small" />
|
<Box>Dashboard</Box>
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Dashboard</ListItemText>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleUserMenuClose} component="a" href="/settings">
|
<MenuItem onClick={() => { handleUserMenuClose(); navigate(`/${user.userType}settings`) }}>
|
||||||
<ListItemIcon>
|
<Settings fontSize="small" />
|
||||||
<Settings fontSize="small" />
|
<Box>Settings</Box>
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Settings</ListItemText>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem onClick={handleLogout}>
|
<MenuItem onClick={handleLogout}>
|
||||||
<ListItemIcon>
|
<Logout fontSize="small" />
|
||||||
<Logout fontSize="small" />
|
<Box>Logout</Box>
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Logout</ListItemText>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
|
786
frontend/src/hooks/useSecureAuth.tsx
Normal file
786
frontend/src/hooks/useSecureAuth.tsx
Normal file
@ -0,0 +1,786 @@
|
|||||||
|
// Persistent Authentication Hook with localStorage Integration and Date Conversion
|
||||||
|
// Automatically restoring login state on page refresh with proper date handling
|
||||||
|
|
||||||
|
import React, { createContext, useContext,useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import * as Types from 'types/types';
|
||||||
|
import { useUser } from 'hooks/useUser';
|
||||||
|
import { CreateCandidateRequest, CreateEmployerRequest, CreateViewerRequest, LoginRequest } from 'services/api-client';
|
||||||
|
import { formatApiRequest } from 'types/conversion';
|
||||||
|
// Import date conversion functions
|
||||||
|
import {
|
||||||
|
convertCandidateFromApi,
|
||||||
|
convertEmployerFromApi,
|
||||||
|
convertViewerFromApi,
|
||||||
|
convertFromApi,
|
||||||
|
} from 'types/types';
|
||||||
|
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: Types.User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isInitializing: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token storage utilities
|
||||||
|
const TOKEN_STORAGE = {
|
||||||
|
ACCESS_TOKEN: 'accessToken',
|
||||||
|
REFRESH_TOKEN: 'refreshToken',
|
||||||
|
USER_DATA: 'userData',
|
||||||
|
TOKEN_EXPIRY: 'tokenExpiry'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// JWT token utilities
|
||||||
|
function parseJwtPayload(token: string): any {
|
||||||
|
try {
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
);
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse JWT token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTokenExpired(token: string): boolean {
|
||||||
|
const payload = parseJwtPayload(token);
|
||||||
|
if (!payload || !payload.exp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token expires within the next 5 minutes (buffer time)
|
||||||
|
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
||||||
|
const bufferTime = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
return currentTime >= (expiryTime - bufferTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredAuth(): void {
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE.ACCESS_TOKEN);
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE.REFRESH_TOKEN);
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE.USER_DATA);
|
||||||
|
localStorage.removeItem(TOKEN_STORAGE.TOKEN_EXPIRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert user data to storage format (dates to ISO strings)
|
||||||
|
*/
|
||||||
|
function prepareUserDataForStorage(user: Types.User): string {
|
||||||
|
try {
|
||||||
|
// Convert dates to ISO strings for storage
|
||||||
|
const userForStorage = formatApiRequest(user);
|
||||||
|
return JSON.stringify(userForStorage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to prepare user data for storage:', error);
|
||||||
|
return JSON.stringify(user); // Fallback to direct serialization
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert stored user data back to proper format (ISO strings to dates)
|
||||||
|
*/
|
||||||
|
function parseStoredUserData(userDataStr: string): Types.User | null {
|
||||||
|
try {
|
||||||
|
const rawUserData = JSON.parse(userDataStr);
|
||||||
|
|
||||||
|
// Determine user type and apply appropriate conversion
|
||||||
|
const userType = rawUserData.userType ||
|
||||||
|
(rawUserData.companyName ? 'employer' :
|
||||||
|
rawUserData.firstName ? 'candidate' : 'viewer');
|
||||||
|
|
||||||
|
switch (userType) {
|
||||||
|
case 'candidate':
|
||||||
|
return convertCandidateFromApi(rawUserData) as Types.Candidate;
|
||||||
|
case 'employer':
|
||||||
|
return convertEmployerFromApi(rawUserData) as Types.Employer;
|
||||||
|
case 'viewer':
|
||||||
|
return convertViewerFromApi(rawUserData) as Types.Viewer;
|
||||||
|
default:
|
||||||
|
// Fallback: try to determine by fields present
|
||||||
|
if (rawUserData.companyName) {
|
||||||
|
return convertEmployerFromApi(rawUserData) as Types.Employer;
|
||||||
|
} else if (rawUserData.skills || rawUserData.experience) {
|
||||||
|
return convertCandidateFromApi(rawUserData) as Types.Candidate;
|
||||||
|
} else {
|
||||||
|
return convertViewerFromApi(rawUserData) as Types.Viewer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse stored user data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeAuthData(authResponse: Types.AuthResponse): void {
|
||||||
|
localStorage.setItem(TOKEN_STORAGE.ACCESS_TOKEN, authResponse.accessToken);
|
||||||
|
localStorage.setItem(TOKEN_STORAGE.REFRESH_TOKEN, authResponse.refreshToken);
|
||||||
|
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(authResponse.user));
|
||||||
|
localStorage.setItem(TOKEN_STORAGE.TOKEN_EXPIRY, authResponse.expiresAt.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredAuthData(): {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
userData: Types.User | null;
|
||||||
|
expiresAt: number | null;
|
||||||
|
} {
|
||||||
|
const accessToken = localStorage.getItem(TOKEN_STORAGE.ACCESS_TOKEN);
|
||||||
|
const refreshToken = localStorage.getItem(TOKEN_STORAGE.REFRESH_TOKEN);
|
||||||
|
const userDataStr = localStorage.getItem(TOKEN_STORAGE.USER_DATA);
|
||||||
|
const expiryStr = localStorage.getItem(TOKEN_STORAGE.TOKEN_EXPIRY);
|
||||||
|
|
||||||
|
let userData: Types.User | null = null;
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (userDataStr) {
|
||||||
|
userData = parseStoredUserData(userDataStr);
|
||||||
|
}
|
||||||
|
if (expiryStr) {
|
||||||
|
expiresAt = parseInt(expiryStr, 10);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse stored auth data:', error);
|
||||||
|
// Clear corrupted data
|
||||||
|
clearStoredAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accessToken, refreshToken, userData, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update stored user data (useful when user profile is updated)
|
||||||
|
*/
|
||||||
|
function updateStoredUserData(user: Types.User): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOKEN_STORAGE.USER_DATA, prepareUserDataForStorage(user));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update stored user data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSecureAuth() {
|
||||||
|
const [authState, setAuthState] = useState<AuthState>({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: true, // Start as true, will be set to false after checking tokens
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const {apiClient} = useUser();
|
||||||
|
const initializationCompleted = useRef(false);
|
||||||
|
|
||||||
|
// Token refresh function with date conversion
|
||||||
|
const refreshAccessToken = useCallback(async (refreshToken: string): Promise<Types.AuthResponse | null> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.refreshToken(refreshToken);
|
||||||
|
|
||||||
|
// Ensure user data has proper date conversion
|
||||||
|
if (response.user) {
|
||||||
|
const userType = response.user.userType ||
|
||||||
|
(response.user.companyName ? 'employer' :
|
||||||
|
response.user.firstName ? 'candidate' : 'viewer');
|
||||||
|
|
||||||
|
response.user = convertFromApi<Types.User>(response.user, userType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Initialize authentication state from stored tokens
|
||||||
|
const initializeAuth = useCallback(async () => {
|
||||||
|
if (initializationCompleted.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = getStoredAuthData();
|
||||||
|
|
||||||
|
// If no stored tokens, user is not authenticated
|
||||||
|
if (!stored.accessToken || !stored.refreshToken || !stored.userData) {
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInitializing: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if access token is expired
|
||||||
|
if (isTokenExpired(stored.accessToken)) {
|
||||||
|
console.log('Access token expired, attempting refresh...');
|
||||||
|
|
||||||
|
// Try to refresh the token
|
||||||
|
const refreshResult = await refreshAccessToken(stored.refreshToken);
|
||||||
|
|
||||||
|
if (refreshResult) {
|
||||||
|
// Successfully refreshed - store with proper date conversion
|
||||||
|
storeAuthData(refreshResult);
|
||||||
|
apiClient.setAuthToken(refreshResult.accessToken);
|
||||||
|
|
||||||
|
console.log("User (refreshed) =>", refreshResult.user);
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user: refreshResult.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Token refreshed successfully with date conversion');
|
||||||
|
} else {
|
||||||
|
// Refresh failed, clear stored data
|
||||||
|
console.log('Token refresh failed, clearing stored auth');
|
||||||
|
clearStoredAuth();
|
||||||
|
apiClient.clearAuthToken();
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Access token is still valid - user data already has date conversion applied
|
||||||
|
apiClient.setAuthToken(stored.accessToken);
|
||||||
|
|
||||||
|
console.log("User (from storage) =>", stored.userData);
|
||||||
|
setAuthState({
|
||||||
|
user: stored.userData, // Already converted by parseStoredUserData
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Restored authentication from stored tokens with date conversion');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing auth:', error);
|
||||||
|
clearStoredAuth();
|
||||||
|
apiClient.clearAuthToken();
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
initializationCompleted.current = true;
|
||||||
|
}
|
||||||
|
}, [apiClient, refreshAccessToken]);
|
||||||
|
|
||||||
|
// Run initialization on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initializeAuth();
|
||||||
|
}, [initializeAuth]);
|
||||||
|
|
||||||
|
// Set up automatic token refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = getStoredAuthData();
|
||||||
|
if (!stored.expiresAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time until token expires (with 5 minute buffer)
|
||||||
|
const expiryTime = stored.expiresAt * 1000;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const timeUntilExpiry = expiryTime - currentTime - (5 * 60 * 1000); // 5 minute buffer
|
||||||
|
|
||||||
|
if (timeUntilExpiry <= 0) {
|
||||||
|
// Token is already expired or will expire soon, refresh immediately
|
||||||
|
initializeAuth();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up automatic refresh before token expires
|
||||||
|
const refreshTimer = setTimeout(() => {
|
||||||
|
console.log('Auto-refreshing token before expiry...');
|
||||||
|
initializeAuth();
|
||||||
|
}, timeUntilExpiry);
|
||||||
|
|
||||||
|
return () => clearTimeout(refreshTimer);
|
||||||
|
}, [authState.isAuthenticated, initializeAuth]);
|
||||||
|
|
||||||
|
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResponse = await apiClient.login(loginData);
|
||||||
|
|
||||||
|
// Ensure user data has proper date conversion before storing
|
||||||
|
if (authResponse.user) {
|
||||||
|
const userType = authResponse.user.userType ||
|
||||||
|
(authResponse.user.companyName ? 'employer' :
|
||||||
|
authResponse.user.firstName ? 'candidate' : 'viewer');
|
||||||
|
|
||||||
|
authResponse.user = convertFromApi<Types.User>(authResponse.user, userType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens and user data with date conversion
|
||||||
|
storeAuthData(authResponse);
|
||||||
|
|
||||||
|
// Update API client with new token
|
||||||
|
apiClient.setAuthToken(authResponse.accessToken);
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user: authResponse.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Login successful with date conversion applied');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
// Clear stored authentication data
|
||||||
|
clearStoredAuth();
|
||||||
|
|
||||||
|
// Clear API client token
|
||||||
|
apiClient.clearAuthToken();
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('User logged out');
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Update user data in both state and localStorage (with date conversion)
|
||||||
|
const updateUserData = useCallback((updatedUser: Types.User) => {
|
||||||
|
// Update localStorage with proper date formatting
|
||||||
|
updateStoredUserData(updatedUser);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
user: updatedUser
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('User data updated with date conversion');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createViewerAccount = useCallback(async (viewerData: CreateViewerRequest): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate password strength
|
||||||
|
const passwordValidation = apiClient.validatePasswordStrength(viewerData.password);
|
||||||
|
if (!passwordValidation.isValid) {
|
||||||
|
throw new Error(passwordValidation.issues.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!apiClient.validateEmail(viewerData.email)) {
|
||||||
|
throw new Error('Please enter a valid email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
const usernameValidation = apiClient.validateUsername(viewerData.username);
|
||||||
|
if (!usernameValidation.isValid) {
|
||||||
|
throw new Error(usernameValidation.issues.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create viewer - API client automatically applies date conversion
|
||||||
|
const viewer = await apiClient.createViewer(viewerData);
|
||||||
|
console.log('Viewer created with date conversion:', viewer);
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
const loginSuccess = await login({
|
||||||
|
login: viewerData.email,
|
||||||
|
password: viewerData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
return loginSuccess;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient, login]);
|
||||||
|
|
||||||
|
const createCandidateAccount = useCallback(async (candidateData: CreateCandidateRequest): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate password strength
|
||||||
|
const passwordValidation = apiClient.validatePasswordStrength(candidateData.password);
|
||||||
|
if (!passwordValidation.isValid) {
|
||||||
|
throw new Error(passwordValidation.issues.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!apiClient.validateEmail(candidateData.email)) {
|
||||||
|
throw new Error('Please enter a valid email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
const usernameValidation = apiClient.validateUsername(candidateData.username);
|
||||||
|
if (!usernameValidation.isValid) {
|
||||||
|
throw new Error(usernameValidation.issues.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create candidate - API client automatically applies date conversion
|
||||||
|
const candidate = await apiClient.createCandidate(candidateData);
|
||||||
|
console.log('Candidate created with date conversion:', candidate);
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
const loginSuccess = await login({
|
||||||
|
login: candidateData.email,
|
||||||
|
password: candidateData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
return loginSuccess;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient, login]);
|
||||||
|
|
||||||
|
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate password strength
|
||||||
|
const passwordValidation = apiClient.validatePasswordStrength(employerData.password);
|
||||||
|
if (!passwordValidation.isValid) {
|
||||||
|
throw new Error(passwordValidation.issues.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!apiClient.validateEmail(employerData.email)) {
|
||||||
|
throw new Error('Please enter a valid email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
const usernameValidation = apiClient.validateUsername(employerData.username);
|
||||||
|
if (!usernameValidation.isValid) {
|
||||||
|
throw new Error(usernameValidation.issues.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create employer - API client automatically applies date conversion
|
||||||
|
const employer = await apiClient.createEmployer(employerData);
|
||||||
|
console.log('Employer created with date conversion:', employer);
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
const loginSuccess = await login({
|
||||||
|
login: employerData.email,
|
||||||
|
password: employerData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
return loginSuccess;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient, login]);
|
||||||
|
|
||||||
|
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.requestPasswordReset({ email });
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: false }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Password reset request failed';
|
||||||
|
setAuthState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient]);
|
||||||
|
|
||||||
|
// Force a token refresh (useful for manual refresh)
|
||||||
|
const refreshAuth = useCallback(async (): Promise<boolean> => {
|
||||||
|
const stored = getStoredAuthData();
|
||||||
|
if (!stored.refreshToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
const refreshResult = await refreshAccessToken(stored.refreshToken);
|
||||||
|
|
||||||
|
if (refreshResult) {
|
||||||
|
storeAuthData(refreshResult);
|
||||||
|
apiClient.setAuthToken(refreshResult.accessToken);
|
||||||
|
|
||||||
|
setAuthState({
|
||||||
|
user: refreshResult.user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
isInitializing: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiClient, refreshAccessToken, logout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...authState,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
createViewerAccount,
|
||||||
|
createCandidateAccount,
|
||||||
|
createEmployerAccount,
|
||||||
|
requestPasswordReset,
|
||||||
|
refreshAuth,
|
||||||
|
updateUserData // New function to update user data with proper storage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Auth Context Provider (Optional)
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
const AuthContext = createContext<ReturnType<typeof useSecureAuth> | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const auth = useSecureAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={auth}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Protected Route Component
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
requiredUserType?: 'candidate' | 'employer' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({
|
||||||
|
children,
|
||||||
|
fallback = <div>Please log in to access this page.</div>,
|
||||||
|
requiredUserType
|
||||||
|
}: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, isInitializing, user } = useAuth();
|
||||||
|
|
||||||
|
// Show loading while checking stored tokens
|
||||||
|
if (isInitializing) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user type if required
|
||||||
|
if (requiredUserType && user?.userType !== requiredUserType) {
|
||||||
|
return <div>Access denied. Required user type: {requiredUserType}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Usage Examples with Date Conversion
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// App.tsx - Root level auth provider
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/candidate/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredUserType="candidate">
|
||||||
|
<CandidateRoutes />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/employer/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredUserType="employer">
|
||||||
|
<EmployerRoutes />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component using auth with proper date handling
|
||||||
|
function Header() {
|
||||||
|
const { user, isAuthenticated, logout, isInitializing } = useAuth();
|
||||||
|
|
||||||
|
if (isInitializing) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
Welcome, {user?.firstName || user?.companyName}!
|
||||||
|
{user?.createdAt && (
|
||||||
|
<small>Member since {user.createdAt.toLocaleDateString()}</small>
|
||||||
|
)}
|
||||||
|
{user?.lastLogin && (
|
||||||
|
<small>Last login: {user.lastLogin.toLocaleString()}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={logout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Link to="/login">Login</Link>
|
||||||
|
<Link to="/register">Register</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile component with date operations
|
||||||
|
function UserProfile() {
|
||||||
|
const { user, updateUserData } = useAuth();
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
// All date operations work properly because dates are Date objects
|
||||||
|
const accountAge = user.createdAt ?
|
||||||
|
Math.floor((Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)) : 0;
|
||||||
|
|
||||||
|
const handleProfileUpdate = async (updates: Partial<Types.User>) => {
|
||||||
|
// When updating user data, it will be properly stored with date conversion
|
||||||
|
const updatedUser = { ...user, ...updates, updatedAt: new Date() };
|
||||||
|
updateUserData(updatedUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Profile</h2>
|
||||||
|
<p>Account created: {user.createdAt?.toLocaleDateString()}</p>
|
||||||
|
<p>Account age: {accountAge} days</p>
|
||||||
|
{user.lastLogin && (
|
||||||
|
<p>Last login: {user.lastLogin.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{'availabilityDate' in user && user.availabilityDate && (
|
||||||
|
<p>Available from: {user.availabilityDate.toLocaleDateString()}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{'experience' in user && user.experience?.map((exp, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<h4>{exp.position} at {exp.companyName}</h4>
|
||||||
|
<p>
|
||||||
|
{exp.startDate.toLocaleDateString()} -
|
||||||
|
{exp.endDate ? exp.endDate.toLocaleDateString() : 'Present'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-redirect based on auth state
|
||||||
|
function LoginPage() {
|
||||||
|
const { isAuthenticated, isInitializing } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Redirect to dashboard if already logged in
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (isInitializing) {
|
||||||
|
return <div>Checking authentication...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginForm />;
|
||||||
|
}
|
||||||
|
*/
|
@ -6,10 +6,8 @@ import { debugConversion } from "types/conversion";
|
|||||||
|
|
||||||
type UserContextType = {
|
type UserContextType = {
|
||||||
apiClient: ApiClient;
|
apiClient: ApiClient;
|
||||||
user: User | null;
|
|
||||||
guest: Guest;
|
guest: Guest;
|
||||||
candidate: Candidate | null;
|
candidate: Candidate | null;
|
||||||
setUser: (user: User | null) => void;
|
|
||||||
setCandidate: (candidate: Candidate | null) => void;
|
setCandidate: (candidate: Candidate | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,8 +29,6 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
|
|||||||
const [apiClient, setApiClient] = useState<ApiClient>(new ApiClient());
|
const [apiClient, setApiClient] = useState<ApiClient>(new ApiClient());
|
||||||
const [candidate, setCandidate] = useState<Candidate | null>(null);
|
const [candidate, setCandidate] = useState<Candidate | null>(null);
|
||||||
const [guest, setGuest] = useState<Guest | null>(null);
|
const [guest, setGuest] = useState<Guest | null>(null);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [activeUser, setActiveUser] = useState<User | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Candidate =>", candidate);
|
console.log("Candidate =>", candidate);
|
||||||
@ -42,65 +38,6 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
|
|||||||
console.log("Guest =>", guest);
|
console.log("Guest =>", guest);
|
||||||
}, [guest]);
|
}, [guest]);
|
||||||
|
|
||||||
/* If the user changes to a non-null value, create a new
|
|
||||||
* apiClient with the access token */
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("User => ", user);
|
|
||||||
if (user === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
/* This apiClient will persist until the user is changed
|
|
||||||
* or logged out */
|
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
|
||||||
if (!accessToken) {
|
|
||||||
throw Error("accessToken is not set for user!");
|
|
||||||
}
|
|
||||||
setApiClient(new ApiClient(accessToken));
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
/* Handle logout if any consumers of UserProvider setUser to NULL */
|
|
||||||
useEffect(() => {
|
|
||||||
/* If there is an active user and it is the same as the
|
|
||||||
* new user, do nothing */
|
|
||||||
if (activeUser && activeUser.email === user?.email) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
if (!activeUser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Logging out ${activeUser.email}`);
|
|
||||||
try {
|
|
||||||
const accessToken = localStorage.getItem('accessToken');
|
|
||||||
const refreshToken = localStorage.getItem('refreshToken');
|
|
||||||
if (!accessToken || !refreshToken) {
|
|
||||||
setSnack("Authentication tokens are invalid.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const results = await apiClient.logout(accessToken, refreshToken);
|
|
||||||
if (results.error) {
|
|
||||||
console.error(results.error);
|
|
||||||
setSnack(results.error.message, "error")
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setSnack(`Unable to logout: ${e}`, "error")
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.removeItem('accessToken');
|
|
||||||
localStorage.removeItem('refreshToken');
|
|
||||||
localStorage.removeItem('userData');
|
|
||||||
createGuestSession();
|
|
||||||
setUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
setActiveUser(user);
|
|
||||||
if (!user) {
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}, [user, apiClient, activeUser]);
|
|
||||||
|
|
||||||
const createGuestSession = () => {
|
const createGuestSession = () => {
|
||||||
console.log("TODO: Convert this to query the server for the session instead of generating it.");
|
console.log("TODO: Convert this to query the server for the session instead of generating it.");
|
||||||
const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const sessionId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
@ -132,7 +69,6 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
|
|||||||
user.lastLogin = new Date(user.lastLogin);
|
user.lastLogin = new Date(user.lastLogin);
|
||||||
}
|
}
|
||||||
setApiClient(new ApiClient(accessToken));
|
setApiClient(new ApiClient(accessToken));
|
||||||
setUser(user);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem('accessToken');
|
||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
@ -152,7 +88,7 @@ const UserProvider: React.FC<UserProviderProps> = (props: UserProviderProps) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ apiClient, candidate, setCandidate, user, setUser, guest }}>
|
<UserContext.Provider value={{ apiClient, candidate, setCandidate, guest }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -158,11 +158,11 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}>
|
<Box ref={ref} sx={{ width: "100%", height: "100%", display: "flex", flexGrow: 1, flexDirection: "column" }}>
|
||||||
{candidate && <CandidateInfo elevation={4} candidate={candidate} sx={{ minHeight: "max-content" }} />}
|
{candidate && <CandidateInfo action={`Chat with Backstory about ${candidate.firstName}`} elevation={4} candidate={candidate} sx={{ minHeight: "max-content" }} />}
|
||||||
|
|
||||||
< Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%" }}>
|
< Box sx={{ display: "flex", mt: 1, gap: 1, height: "100%" }}>
|
||||||
{/* Sessions Sidebar */}
|
{/* Sessions Sidebar */}
|
||||||
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Paper sx={{ p: 2, height: '100%', minWidth: { sm: "200px", md: "300px", lg: "400px" }, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Chat Sessions
|
Chat Sessions
|
||||||
{sessions && (
|
{sessions && (
|
||||||
@ -215,20 +215,6 @@ const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((pr
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{sessions && (
|
|
||||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.default', borderRadius: 1 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Candidate Info
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Name:</strong> {sessions.candidate.fullName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<strong>Email:</strong> {sessions.candidate.email}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Chat Interface */}
|
{/* Chat Interface */}
|
||||||
|
277
frontend/src/pages/CandidateDashboardPage.tsx
Normal file
277
frontend/src/pages/CandidateDashboardPage.tsx
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
LinearProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
ListItemButton,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
Stack
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Dashboard as DashboardIcon,
|
||||||
|
Person as PersonIcon,
|
||||||
|
Article as ArticleIcon,
|
||||||
|
Description as DescriptionIcon,
|
||||||
|
Quiz as QuizIcon,
|
||||||
|
Analytics as AnalyticsIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Add as AddIcon,
|
||||||
|
Visibility as VisibilityIcon,
|
||||||
|
Download as DownloadIcon,
|
||||||
|
ContactMail as ContactMailIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
TipsAndUpdates as TipsIcon,
|
||||||
|
SettingsBackupRestore
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||||
|
import { LoadingPage } from './LoadingPage';
|
||||||
|
import { LoginRequired } from './LoginRequired';
|
||||||
|
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||||
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface DashboardProps extends BackstoryPageProps {
|
||||||
|
userName?: string;
|
||||||
|
profileCompletion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CandidateDashboardPage: React.FC<DashboardProps> = (props: DashboardProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setSnack } = props;
|
||||||
|
const { user, isLoading, isInitializing, isAuthenticated } = useSecureAuth();
|
||||||
|
const profileCompletion = 75;
|
||||||
|
const sidebarItems = [
|
||||||
|
{ icon: <DashboardIcon />, text: 'Dashboard', active: true },
|
||||||
|
{ icon: <PersonIcon />, text: 'Profile', active: false },
|
||||||
|
{ icon: <ArticleIcon />, text: 'Backstory', active: false },
|
||||||
|
{ icon: <DescriptionIcon />, text: 'Resumes', active: false },
|
||||||
|
{ icon: <QuizIcon />, text: 'Q&A Setup', active: false },
|
||||||
|
{ icon: <AnalyticsIcon />, text: 'Analytics', active: false },
|
||||||
|
{ icon: <SettingsIcon />, text: 'Settings', active: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoading || isInitializing) {
|
||||||
|
return (<LoadingPage {...props}/>);
|
||||||
|
}
|
||||||
|
if (!user || !isAuthenticated) {
|
||||||
|
return (<LoginRequired {...props}/>);
|
||||||
|
}
|
||||||
|
if (user.userType !== 'candidate') {
|
||||||
|
setSnack(`The page you were on is only available for candidates (you are a ${user.userType}`, 'warning');
|
||||||
|
navigate('/');
|
||||||
|
return (<></>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh', backgroundColor: '#f5f5f5' }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 250,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRight: '1px solid #e0e0e0',
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ mb: 3, fontWeight: 'bold', color: '#1976d2' }}>
|
||||||
|
JobPortal
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{sidebarItems.map((item, index) => (
|
||||||
|
<ListItem key={index} disablePadding sx={{ mb: 0.5 }}>
|
||||||
|
<ListItemButton
|
||||||
|
sx={{
|
||||||
|
borderRadius: 1,
|
||||||
|
backgroundColor: item.active ? '#e3f2fd' : 'transparent',
|
||||||
|
color: item.active ? '#1976d2' : '#666',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: item.active ? '#e3f2fd' : '#f5f5f5',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>
|
||||||
|
{item.icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.text}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: item.active ? 600 : 400,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Box sx={{ flex: 1, p: 3 }}>
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<Typography variant="h4" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||||
|
Welcome back, {user.firstName}!
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||||
|
Your profile is {profileCompletion}% complete
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={profileCompletion}
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
'& .MuiLinearProgress-bar': {
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
Complete Your Profile
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Cards Grid */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Top Row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
{/* Resume Builder Card */}
|
||||||
|
<Card sx={{ flex: 1, minHeight: 200 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||||
|
Resume Builder
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 1, color: '#666' }}>
|
||||||
|
3 custom resumes
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 3, color: '#666' }}>
|
||||||
|
Last created: May 15, 2025
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Create New
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Activity Card */}
|
||||||
|
<Card sx={{ flex: 1, minHeight: 200 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||||
|
Recent Activity
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<VisibilityIcon sx={{ fontSize: 16, color: '#666' }} />
|
||||||
|
<Typography variant="body2">5 profile views</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<DownloadIcon sx={{ fontSize: 16, color: '#666' }} />
|
||||||
|
<Typography variant="body2">2 resume downloads</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<ContactMailIcon sx={{ fontSize: 16, color: '#666' }} />
|
||||||
|
<Typography variant="body2">1 direct contact</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
View All Activity
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom Row */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
|
{/* Complete Your Backstory Card */}
|
||||||
|
<Card sx={{ flex: 1, minHeight: 200 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||||
|
Complete Your Backstory
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
• Add projects
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
• Detail skills
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
• Work history
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Edit Backstory
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Improvement Suggestions Card */}
|
||||||
|
<Card sx={{ flex: 1, minHeight: 200 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold' }}>
|
||||||
|
Improvement Suggestions
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
• Add certifications
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
• Enhance your project details
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<TipsIcon />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
View All Tips
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CandidateDashboardPage };
|
@ -33,130 +33,59 @@ import WorkIcon from '@mui/icons-material/Work';
|
|||||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||||
import { JobMatchAnalysis } from '../components/JobMatchAnalysis';
|
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
|
||||||
|
import { Candidate } from "types/types";
|
||||||
// Mock types for our application
|
import { useUser } from "hooks/useUser";
|
||||||
interface Candidate {
|
import { useNavigate } from 'react-router-dom';
|
||||||
id: string;
|
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||||
name: string;
|
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||||
title: string;
|
|
||||||
location: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
photoUrl?: string;
|
|
||||||
resume?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
company: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock hook for getting the current user
|
|
||||||
const useUser = (): { user: User | null, loading: boolean } => {
|
|
||||||
// In a real app, this would check auth state and get user info
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Simulate fetching user data
|
|
||||||
setTimeout(() => {
|
|
||||||
setUser({
|
|
||||||
id: 'emp123',
|
|
||||||
name: 'Sarah Thompson',
|
|
||||||
company: 'Tech Innovations Inc.',
|
|
||||||
role: 'HR Manager'
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
}, 800);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { user, loading };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock API for fetching candidates
|
|
||||||
const fetchCandidates = async (searchQuery: string = ''): Promise<Candidate[]> => {
|
|
||||||
// Simulate API delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const mockCandidates: Candidate[] = [
|
|
||||||
{
|
|
||||||
id: 'c1',
|
|
||||||
name: 'Alex Johnson',
|
|
||||||
title: 'Senior Frontend Developer',
|
|
||||||
location: 'Seattle, WA',
|
|
||||||
email: 'alex.johnson@example.com',
|
|
||||||
phone: '(555) 123-4567',
|
|
||||||
photoUrl: 'https://i.pravatar.cc/150?img=11'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2',
|
|
||||||
name: 'Morgan Williams',
|
|
||||||
title: 'Full Stack Engineer',
|
|
||||||
location: 'Portland, OR',
|
|
||||||
email: 'morgan.w@example.com',
|
|
||||||
phone: '(555) 234-5678',
|
|
||||||
photoUrl: 'https://i.pravatar.cc/150?img=12'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c3',
|
|
||||||
name: 'Jamie Garcia',
|
|
||||||
title: 'DevOps Specialist',
|
|
||||||
location: 'San Francisco, CA',
|
|
||||||
email: 'jamie.g@example.com',
|
|
||||||
phone: '(555) 345-6789',
|
|
||||||
photoUrl: 'https://i.pravatar.cc/150?img=13'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c4',
|
|
||||||
name: 'Taylor Chen',
|
|
||||||
title: 'Backend Developer',
|
|
||||||
location: 'Austin, TX',
|
|
||||||
email: 'taylor.c@example.com',
|
|
||||||
phone: '(555) 456-7890',
|
|
||||||
photoUrl: 'https://i.pravatar.cc/150?img=14'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c5',
|
|
||||||
name: 'Jordan Smith',
|
|
||||||
title: 'UI/UX Developer',
|
|
||||||
location: 'Chicago, IL',
|
|
||||||
email: 'jordan.s@example.com',
|
|
||||||
phone: '(555) 567-8901',
|
|
||||||
photoUrl: 'https://i.pravatar.cc/150?img=15'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!searchQuery) return mockCandidates;
|
|
||||||
|
|
||||||
// Filter candidates based on search query
|
|
||||||
return mockCandidates.filter(candidate =>
|
|
||||||
candidate.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
candidate.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
candidate.location.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main component
|
// Main component
|
||||||
const JobAnalysisPage: React.FC = () => {
|
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { user, loading: userLoading } = useUser();
|
const { user } = useSecureAuth();
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
|
||||||
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
|
const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [loadingCandidates, setLoadingCandidates] = useState(false);
|
|
||||||
const [jobDescription, setJobDescription] = useState('');
|
const [jobDescription, setJobDescription] = useState('');
|
||||||
const [jobTitle, setJobTitle] = useState('');
|
const [jobTitle, setJobTitle] = useState('');
|
||||||
const [jobLocation, setJobLocation] = useState('');
|
const [jobLocation, setJobLocation] = useState('');
|
||||||
const [analysisStarted, setAnalysisStarted] = useState(false);
|
const [analysisStarted, setAnalysisStarted] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openUploadDialog, setOpenUploadDialog] = useState(false);
|
const [openUploadDialog, setOpenUploadDialog] = useState(false);
|
||||||
|
const { apiClient } = useUser();
|
||||||
|
const { setSnack } = props;
|
||||||
|
const [candidates, setCandidates] = useState<Candidate[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (candidates !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const getCandidates = async () => {
|
||||||
|
try {
|
||||||
|
const results = await apiClient.getCandidates();
|
||||||
|
const candidates: Candidate[] = results.data;
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
let result = a.lastName.localeCompare(b.lastName);
|
||||||
|
if (result === 0) {
|
||||||
|
result = a.firstName.localeCompare(b.firstName);
|
||||||
|
}
|
||||||
|
if (result === 0) {
|
||||||
|
result = a.username.localeCompare(b.username);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
console.log(candidates);
|
||||||
|
setCandidates(candidates);
|
||||||
|
} catch (err) {
|
||||||
|
setSnack("" + err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getCandidates();
|
||||||
|
}, [candidates, setSnack]);
|
||||||
|
|
||||||
// Steps in our process
|
// Steps in our process
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: 'Select Candidate', icon: <PersonIcon /> },
|
{ label: 'Select Candidate', icon: <PersonIcon /> },
|
||||||
@ -164,38 +93,6 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
{ label: 'View Analysis', icon: <AssessmentIcon /> }
|
{ label: 'View Analysis', icon: <AssessmentIcon /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Load initial candidates
|
|
||||||
useEffect(() => {
|
|
||||||
const loadCandidates = async () => {
|
|
||||||
setLoadingCandidates(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchCandidates();
|
|
||||||
setCandidates(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load candidates. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoadingCandidates(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
loadCandidates();
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Handler for candidate search
|
|
||||||
const handleSearch = async () => {
|
|
||||||
setLoadingCandidates(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchCandidates(searchQuery);
|
|
||||||
setCandidates(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Search failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoadingCandidates(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock handlers for our analysis APIs
|
// Mock handlers for our analysis APIs
|
||||||
const fetchRequirements = async (): Promise<string[]> => {
|
const fetchRequirements = async (): Promise<string[]> => {
|
||||||
// Simulates extracting requirements from the job description
|
// Simulates extracting requirements from the job description
|
||||||
@ -371,7 +268,7 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
Select a Candidate
|
Select a Candidate
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ mb: 3, display: 'flex' }}>
|
{/* <Box sx={{ mb: 3, display: 'flex' }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@ -389,17 +286,10 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
sx={{ mr: 2 }}
|
sx={{ mr: 2 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box> */}
|
||||||
|
|
||||||
{loadingCandidates ? (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
) : candidates.length === 0 ? (
|
|
||||||
<Typography>No candidates found. Please adjust your search criteria.</Typography>
|
|
||||||
) : (
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{candidates.map((candidate) => (
|
{candidates?.map((candidate) => (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={candidate.id}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={candidate.id}>
|
||||||
<Card
|
<Card
|
||||||
elevation={selectedCandidate?.id === candidate.id ? 8 : 1}
|
elevation={selectedCandidate?.id === candidate.id ? 8 : 1}
|
||||||
@ -418,38 +308,37 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
<CardContent sx={{ flexGrow: 1, p: 3 }}>
|
<CardContent sx={{ flexGrow: 1, p: 3 }}>
|
||||||
<Box sx={{ display: 'flex', mb: 2, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', mb: 2, alignItems: 'center' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={candidate.photoUrl}
|
src={candidate.profileImage}
|
||||||
alt={candidate.name}
|
alt={candidate.firstName}
|
||||||
sx={{ width: 64, height: 64, mr: 2 }}
|
sx={{ width: 64, height: 64, mr: 2 }}
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" component="div">
|
<Typography variant="h6" component="div">
|
||||||
{candidate.name}
|
{candidate.fullName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{candidate.title}
|
{candidate.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
{candidate.location && <Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<strong>Location:</strong> {candidate.location}
|
<strong>Location:</strong> {candidate.location.country}
|
||||||
</Typography>
|
</Typography>}
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<strong>Email:</strong> {candidate.email}
|
<strong>Email:</strong> {candidate.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
{candidate.phone && <Typography variant="body2">
|
||||||
<strong>Phone:</strong> {candidate.phone}
|
<strong>Phone:</strong> {candidate.phone}
|
||||||
</Typography>
|
</Typography>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -530,7 +419,7 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
{selectedCandidate && (
|
{selectedCandidate && (
|
||||||
<JobMatchAnalysis
|
<JobMatchAnalysis
|
||||||
jobTitle={jobTitle}
|
jobTitle={jobTitle}
|
||||||
candidateName={selectedCandidate.name}
|
candidateName={selectedCandidate.fullName}
|
||||||
fetchRequirements={fetchRequirements}
|
fetchRequirements={fetchRequirements}
|
||||||
fetchMatchForRequirement={fetchMatchForRequirement}
|
fetchMatchForRequirement={fetchMatchForRequirement}
|
||||||
/>
|
/>
|
||||||
@ -538,15 +427,6 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
// If user is loading, show loading state
|
|
||||||
if (userLoading) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '80vh' }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no user is logged in, show message
|
// If no user is logged in, show message
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
@ -578,7 +458,8 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
<Stepper activeStep={activeStep} alternativeLabel>
|
<Stepper activeStep={activeStep} alternativeLabel>
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<Step key={index}>
|
<Step key={index}>
|
||||||
<StepLabel StepIconComponent={() => (
|
<StepLabel slots={{
|
||||||
|
stepIcon: () => (
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: activeStep >= index ? theme.palette.primary.main : theme.palette.grey[300],
|
bgcolor: activeStep >= index ? theme.palette.primary.main : theme.palette.grey[300],
|
||||||
@ -587,7 +468,9 @@ const JobAnalysisPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{step.icon}
|
{step.icon}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}>
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
{step.label}
|
{step.label}
|
||||||
</StepLabel>
|
</StepLabel>
|
||||||
</Step>
|
</Step>
|
||||||
|
@ -16,9 +16,36 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Divider,
|
Divider,
|
||||||
Avatar
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Collapse,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
RadioGroup,
|
||||||
|
FormControlLabel,
|
||||||
|
Radio,
|
||||||
|
Chip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Person, PersonAdd, AccountCircle, ExitToApp } from '@mui/icons-material';
|
import {
|
||||||
|
Person,
|
||||||
|
PersonAdd,
|
||||||
|
AccountCircle,
|
||||||
|
ExitToApp,
|
||||||
|
Visibility,
|
||||||
|
VisibilityOff,
|
||||||
|
CheckCircle,
|
||||||
|
Cancel,
|
||||||
|
ExpandLess,
|
||||||
|
ExpandMore,
|
||||||
|
Visibility as ViewIcon,
|
||||||
|
Work,
|
||||||
|
Business
|
||||||
|
} from '@mui/icons-material';
|
||||||
import 'react-phone-number-input/style.css';
|
import 'react-phone-number-input/style.css';
|
||||||
import PhoneInput from 'react-phone-number-input';
|
import PhoneInput from 'react-phone-number-input';
|
||||||
import { E164Number } from 'libphonenumber-js/core';
|
import { E164Number } from 'libphonenumber-js/core';
|
||||||
@ -26,22 +53,15 @@ import './LoginPage.css';
|
|||||||
|
|
||||||
import { ApiClient } from 'services/api-client';
|
import { ApiClient } from 'services/api-client';
|
||||||
import { useUser } from 'hooks/useUser';
|
import { useUser } from 'hooks/useUser';
|
||||||
|
import { useSecureAuth } from 'hooks/useSecureAuth';
|
||||||
|
import { LocationInput } from 'components/LocationInput';
|
||||||
|
import { Location } from 'types/types';
|
||||||
|
|
||||||
// Import conversion utilities
|
import { Candidate } from 'types/types'
|
||||||
import {
|
|
||||||
formatApiRequest,
|
|
||||||
parseApiResponse,
|
|
||||||
handleApiResponse,
|
|
||||||
extractApiData,
|
|
||||||
isSuccessResponse,
|
|
||||||
debugConversion,
|
|
||||||
type ApiResponse
|
|
||||||
} from 'types/conversion';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AuthResponse, User, Guest, Candidate
|
|
||||||
} from 'types/types'
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||||
|
|
||||||
|
type UserRegistrationType = 'viewer' | 'candidate' | 'employer';
|
||||||
|
|
||||||
interface LoginRequest {
|
interface LoginRequest {
|
||||||
login: string;
|
login: string;
|
||||||
@ -49,25 +69,49 @@ interface LoginRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RegisterRequest {
|
interface RegisterRequest {
|
||||||
|
userType: UserRegistrationType;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
// Employer specific fields (placeholder)
|
||||||
|
companyName?: string;
|
||||||
|
industry?: string;
|
||||||
|
companySize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PasswordRequirement {
|
||||||
|
label: string;
|
||||||
|
met: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiClient = new ApiClient();
|
const apiClient = new ApiClient();
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
|
||||||
const navigate = useNavigate();
|
const { setSnack } = props;
|
||||||
const { user, setUser, guest } = useUser();
|
const { guest } = useUser();
|
||||||
const [tabValue, setTabValue] = useState(0);
|
const [tabValue, setTabValue] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [phone, setPhone] = useState<E164Number | null>(null);
|
const [phone, setPhone] = useState<E164Number | null>(null);
|
||||||
const name = (user?.userType === 'candidate' ? (user as Candidate).username : user?.email) || '';
|
const { createCandidateAccount, createViewerAccount, user, login, isLoading, error } = useSecureAuth();
|
||||||
|
const [passwordValidation, setPasswordValidation] = useState<{ isValid: boolean; issues: string[] }>({ isValid: true, issues: [] });
|
||||||
|
const name = (user?.userType === 'candidate' || user?.userType === 'viewer') ? user.username : user?.email || '';
|
||||||
|
const [location, setLocation] = useState<Partial<Location>>({});
|
||||||
|
|
||||||
|
// Password visibility states
|
||||||
|
const [showLoginPassword, setShowLoginPassword] = useState(false);
|
||||||
|
const [showRegisterPassword, setShowRegisterPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [showPasswordRequirements, setShowPasswordRequirements] = useState(false);
|
||||||
|
|
||||||
|
const handleLocationChange = (location: Partial<Location>) => {
|
||||||
|
setLocation(location);
|
||||||
|
console.log('Location updated:', location);
|
||||||
|
};
|
||||||
|
|
||||||
// Login form state
|
// Login form state
|
||||||
const [loginForm, setLoginForm] = useState<LoginRequest>({
|
const [loginForm, setLoginForm] = useState<LoginRequest>({
|
||||||
@ -77,14 +121,49 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
// Register form state
|
// Register form state
|
||||||
const [registerForm, setRegisterForm] = useState<RegisterRequest>({
|
const [registerForm, setRegisterForm] = useState<RegisterRequest>({
|
||||||
|
userType: 'candidate',
|
||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
password: '',
|
password: '',
|
||||||
phone: ''
|
confirmPassword: '',
|
||||||
|
phone: '',
|
||||||
|
companyName: '',
|
||||||
|
industry: '',
|
||||||
|
companySize: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Password requirements validation
|
||||||
|
const getPasswordRequirements = (password: string): PasswordRequirement[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'At least 8 characters long',
|
||||||
|
met: password.length >= 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Contains uppercase letter',
|
||||||
|
met: /[A-Z]/.test(password)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Contains lowercase letter',
|
||||||
|
met: /[a-z]/.test(password)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Contains number',
|
||||||
|
met: /\d/.test(password)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Contains special character (!@#$%^&*)',
|
||||||
|
met: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordRequirements = getPasswordRequirements(registerForm.password);
|
||||||
|
const passwordsMatch = registerForm.password === registerForm.confirmPassword;
|
||||||
|
const hasPasswordMatchError = registerForm.confirmPassword.length > 0 && !passwordsMatch;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phone !== registerForm.phone && phone) {
|
if (phone !== registerForm.phone && phone) {
|
||||||
console.log({ phone });
|
console.log({ phone });
|
||||||
@ -95,94 +174,122 @@ const LoginPage: React.FC = () => {
|
|||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
const success = await login(loginForm);
|
||||||
const authResponse = await apiClient.login(loginForm.login, loginForm.password)
|
if (success) {
|
||||||
|
|
||||||
debugConversion(authResponse, 'Login Response');
|
|
||||||
|
|
||||||
// Store tokens in localStorage
|
|
||||||
localStorage.setItem('accessToken', authResponse.accessToken);
|
|
||||||
localStorage.setItem('refreshToken', authResponse.refreshToken);
|
|
||||||
localStorage.setItem('userData', JSON.stringify(authResponse.user));
|
|
||||||
|
|
||||||
setSuccess('Login successful!');
|
setSuccess('Login successful!');
|
||||||
navigate('/');
|
}
|
||||||
setUser(authResponse.user);
|
};
|
||||||
// Clear form
|
|
||||||
setLoginForm({ login: '', password: '' });
|
const handlePasswordChange = (password: string) => {
|
||||||
} catch (err) {
|
setRegisterForm(prev => ({ ...prev, password }));
|
||||||
console.error('Login error:', err);
|
setPasswordValidation(apiClient.validatePasswordStrength(password));
|
||||||
setError(err instanceof Error ? err.message : 'Login failed');
|
|
||||||
} finally {
|
// Show requirements if password has content and isn't valid
|
||||||
setLoading(false);
|
if (password.length > 0) {
|
||||||
|
const requirements = getPasswordRequirements(password);
|
||||||
|
const allMet = requirements.every(req => req.met);
|
||||||
|
if (!allMet && !showPasswordRequirements) {
|
||||||
|
setShowPasswordRequirements(true);
|
||||||
|
}
|
||||||
|
if (allMet && showPasswordRequirements) {
|
||||||
|
setShowPasswordRequirements(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const userType = event.target.value as UserRegistrationType;
|
||||||
|
setRegisterForm(prev => ({ ...prev, userType }));
|
||||||
|
|
||||||
|
// Clear location and phone for viewer type
|
||||||
|
if (userType === 'viewer') {
|
||||||
|
setLocation({});
|
||||||
|
setPhone(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Check if employer registration is attempted
|
||||||
|
if (registerForm.userType === 'employer') {
|
||||||
|
setSnack('Employer registration is not yet supported. Please contact support for employer account setup.', "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (!passwordsMatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password requirements
|
||||||
|
const allRequirementsMet = passwordRequirements.every(req => req.met);
|
||||||
|
if (!allRequirementsMet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
try {
|
// For now, all non-employer registrations go through candidate creation
|
||||||
const candidate: Candidate = {
|
// This would need to be updated when viewer and employer APIs are available
|
||||||
username: registerForm.username,
|
let success;
|
||||||
email: registerForm.email,
|
switch (registerForm.userType) {
|
||||||
firstName: registerForm.firstName,
|
case 'candidate':
|
||||||
lastName: registerForm.lastName,
|
success = await createCandidateAccount(registerForm);
|
||||||
fullName: `${registerForm.firstName} ${registerForm.lastName}`,
|
break;
|
||||||
phone: registerForm.phone || undefined,
|
case 'viewer':
|
||||||
userType: 'candidate',
|
success = await createViewerAccount(registerForm);
|
||||||
status: 'active',
|
break;
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
skills: [],
|
|
||||||
experience: [],
|
|
||||||
education: [],
|
|
||||||
preferredJobTypes: [],
|
|
||||||
languages: [],
|
|
||||||
certifications: [],
|
|
||||||
location: {
|
|
||||||
city: '',
|
|
||||||
country: '',
|
|
||||||
remote: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await apiClient.createCandidate(candidate);
|
|
||||||
|
|
||||||
debugConversion(result, 'Registration Response');
|
|
||||||
|
|
||||||
setSuccess('Registration successful! You can now login.');
|
|
||||||
|
|
||||||
// Clear form and switch to login tab
|
|
||||||
setRegisterForm({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
password: '',
|
|
||||||
phone: ''
|
|
||||||
});
|
|
||||||
setTabValue(0);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Registration error:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Redirect based on user type
|
||||||
|
if (registerForm.userType === 'viewer') {
|
||||||
|
window.location.href = '/find-a-candidate';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/candidate/dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||||
setTabValue(newValue);
|
setTabValue(newValue);
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Toggle password visibility functions
|
||||||
|
const toggleLoginPasswordVisibility = () => setShowLoginPassword(!showLoginPassword);
|
||||||
|
const toggleRegisterPasswordVisibility = () => setShowRegisterPassword(!showRegisterPassword);
|
||||||
|
const toggleConfirmPasswordVisibility = () => setShowConfirmPassword(!showConfirmPassword);
|
||||||
|
|
||||||
|
// Get user type icon and description
|
||||||
|
const getUserTypeInfo = (userType: UserRegistrationType) => {
|
||||||
|
switch (userType) {
|
||||||
|
case 'viewer':
|
||||||
|
return {
|
||||||
|
icon: <ViewIcon />,
|
||||||
|
title: 'Viewer',
|
||||||
|
description: 'Chat with Backstory about candidates and explore the platform.'
|
||||||
|
};
|
||||||
|
case 'candidate':
|
||||||
|
return {
|
||||||
|
icon: <Person />,
|
||||||
|
title: 'Candidate',
|
||||||
|
description: 'Use Backstory to generate your resume and help you manage your career. Optionally let people interact with your profile.'
|
||||||
|
};
|
||||||
|
case 'employer':
|
||||||
|
return {
|
||||||
|
icon: <Business />,
|
||||||
|
title: 'Employer',
|
||||||
|
description: 'Post jobs and find talent (Coming Soon.)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// If user is logged in, show their profile
|
// If user is logged in, show their profile
|
||||||
if (user) {
|
if (user) {
|
||||||
return (
|
return (
|
||||||
@ -213,7 +320,7 @@ const LoginPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||||
<strong>Status:</strong> {user.status}
|
{/* <strong>Status:</strong> {user.status} */}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
@ -221,6 +328,11 @@ const LoginPage: React.FC = () => {
|
|||||||
<strong>Phone:</strong> {user.phone || 'Not provided'}
|
<strong>Phone:</strong> {user.phone || 'Not provided'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||||
|
<strong>Account type:</strong> {user.userType}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||||
<strong>Last Login:</strong> {
|
<strong>Last Login:</strong> {
|
||||||
@ -258,7 +370,6 @@ const LoginPage: React.FC = () => {
|
|||||||
const handleLoginChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLoginChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { value } = event.target;
|
const { value } = event.target;
|
||||||
setLoginForm({ ...loginForm, login: value });
|
setLoginForm({ ...loginForm, login: value });
|
||||||
setError(validateInput(value));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -325,7 +436,7 @@ const LoginPage: React.FC = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type={showLoginPassword ? 'text' : 'password'}
|
||||||
value={loginForm.password}
|
value={loginForm.password}
|
||||||
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
@ -333,6 +444,22 @@ const LoginPage: React.FC = () => {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
autoComplete='current-password'
|
autoComplete='current-password'
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle password visibility"
|
||||||
|
onClick={toggleLoginPasswordVisibility}
|
||||||
|
edge="end"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -354,95 +481,260 @@ const LoginPage: React.FC = () => {
|
|||||||
Create Account
|
Create Account
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
{/* User Type Selection */}
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
|
||||||
|
<FormLabel component="legend" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6">Select Account Type</Typography>
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={registerForm.userType}
|
||||||
|
onChange={handleUserTypeChange}
|
||||||
|
sx={{ gap: 1 }}
|
||||||
|
>
|
||||||
|
{(['viewer', 'candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
|
||||||
|
const info = getUserTypeInfo(userType);
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={userType}
|
||||||
|
value={userType}
|
||||||
|
disabled={loading || userType === 'employer'}
|
||||||
|
control={<Radio />}
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
{info.icon}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
|
||||||
|
{info.title}
|
||||||
|
{userType === 'employer' && (
|
||||||
|
<Chip
|
||||||
|
label="Coming Soon"
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{info.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
m: 0,
|
||||||
|
bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
|
||||||
|
},
|
||||||
|
opacity: userType === 'employer' ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* Employer Placeholder */}
|
||||||
|
{registerForm.userType === 'employer' && (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Employer Registration Coming Soon
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
We're currently building our employer features. If you're interested in posting jobs
|
||||||
|
and finding talent, please contact our support team at support@backstory.com for
|
||||||
|
early access.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Information Fields */}
|
||||||
|
{registerForm.userType !== 'employer' && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="First Name"
|
||||||
|
value={registerForm.firstName}
|
||||||
|
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Last Name"
|
||||||
|
value={registerForm.lastName}
|
||||||
|
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="First Name"
|
label="Username"
|
||||||
value={registerForm.firstName}
|
value={registerForm.username}
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
|
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Last Name"
|
label="Email"
|
||||||
value={registerForm.lastName}
|
type="email"
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
|
value={registerForm.email}
|
||||||
|
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
|
||||||
</Grid>
|
{/* Conditional fields based on user type */}
|
||||||
|
{registerForm.userType === 'candidate' && (
|
||||||
<TextField
|
<>
|
||||||
fullWidth
|
<PhoneInput
|
||||||
label="Username"
|
label="Phone (Optional)"
|
||||||
value={registerForm.username}
|
placeholder="Enter phone number"
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
|
defaultCountry='US'
|
||||||
margin="normal"
|
value={registerForm.phone}
|
||||||
required
|
disabled={loading}
|
||||||
disabled={loading}
|
onChange={(v) => setPhone(v as E164Number)}
|
||||||
variant="outlined"
|
/>
|
||||||
/>
|
|
||||||
|
<LocationInput
|
||||||
<TextField
|
value={location}
|
||||||
fullWidth
|
onChange={handleLocationChange}
|
||||||
label="Email"
|
showCity
|
||||||
type="email"
|
helperText="Include your city for more specific job matches"
|
||||||
value={registerForm.email}
|
/>
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
|
</>
|
||||||
margin="normal"
|
)}
|
||||||
required
|
|
||||||
disabled={loading}
|
<TextField
|
||||||
variant="outlined"
|
fullWidth
|
||||||
/>
|
label="Password"
|
||||||
|
type={showRegisterPassword ? 'text' : 'password'}
|
||||||
<PhoneInput
|
value={registerForm.password}
|
||||||
label="Phone (Optional)"
|
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||||
placeholder="Enter phone number"
|
margin="normal"
|
||||||
defaultCountry='US'
|
required
|
||||||
value={registerForm.phone}
|
disabled={loading}
|
||||||
disabled={loading}
|
variant="outlined"
|
||||||
onChange={(v) => setPhone(v as E164Number)} />
|
slotProps={{
|
||||||
{/* <TextField
|
input: {
|
||||||
fullWidth
|
endAdornment: (
|
||||||
label="Phone (Optional)"
|
<InputAdornment position="end">
|
||||||
type="tel"
|
<IconButton
|
||||||
value={registerForm.phone}
|
aria-label="toggle password visibility"
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, phone: e.target.value })}
|
onClick={toggleRegisterPasswordVisibility}
|
||||||
margin="normal"
|
edge="end"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
variant="outlined"
|
>
|
||||||
/> */}
|
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
<TextField
|
</InputAdornment>
|
||||||
fullWidth
|
)
|
||||||
label="Password"
|
}
|
||||||
type="password"
|
}}
|
||||||
value={registerForm.password}
|
/>
|
||||||
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })}
|
|
||||||
margin="normal"
|
{/* Password Requirements */}
|
||||||
required
|
{registerForm.password.length > 0 && (
|
||||||
disabled={loading}
|
<Box sx={{ mt: 1, mb: 1 }}>
|
||||||
variant="outlined"
|
<Button
|
||||||
/>
|
onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
|
||||||
|
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
|
||||||
<Button
|
size="small"
|
||||||
type="submit"
|
sx={{ mb: 1 }}
|
||||||
fullWidth
|
>
|
||||||
variant="contained"
|
Password Requirements
|
||||||
sx={{ mt: 3, mb: 2 }}
|
</Button>
|
||||||
disabled={loading}
|
<Collapse in={showPasswordRequirements}>
|
||||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
>
|
<List dense>
|
||||||
{loading ? 'Creating Account...' : 'Create Account'}
|
{passwordRequirements.map((requirement, index) => (
|
||||||
</Button>
|
<ListItem key={index} sx={{ py: 0.5 }}>
|
||||||
|
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||||
|
{requirement.met ? (
|
||||||
|
<CheckCircle color="success" fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<Cancel color="error" fontSize="small" />
|
||||||
|
)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={requirement.label}
|
||||||
|
sx={{
|
||||||
|
'& .MuiListItemText-primary': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: requirement.met ? 'success.main' : 'error.main'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Confirm Password"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={registerForm.confirmPassword}
|
||||||
|
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
variant="outlined"
|
||||||
|
error={hasPasswordMatchError}
|
||||||
|
helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle confirm password visibility"
|
||||||
|
onClick={toggleConfirmPasswordVisibility}
|
||||||
|
edge="end"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
|
||||||
|
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
23
frontend/src/pages/LoginRequired.tsx
Normal file
23
frontend/src/pages/LoginRequired.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { BackstoryPageProps } from '../components/BackstoryTab';
|
||||||
|
import { Message } from '../components/Message';
|
||||||
|
import { ChatMessage } from 'types/types';
|
||||||
|
|
||||||
|
const LoginRequired = (props: BackstoryPageProps) => {
|
||||||
|
const preamble: ChatMessage = {
|
||||||
|
sender: 'system',
|
||||||
|
type: 'preparing',
|
||||||
|
status: 'done',
|
||||||
|
sessionId: '',
|
||||||
|
content: 'You must be logged to view this feature.',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Box sx={{display: "flex", flexGrow: 1, maxWidth: "1024px", margin: "0 auto"}}>
|
||||||
|
<Message message={preamble} {...props} />
|
||||||
|
</Box>
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
LoginRequired
|
||||||
|
};
|
@ -1,26 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Enhanced API Client with Streaming Support
|
* Enhanced API Client with Streaming Support and Date Conversion
|
||||||
*
|
*
|
||||||
* This demonstrates how to use the generated types with the conversion utilities
|
* This demonstrates how to use the generated types with the conversion utilities
|
||||||
* for seamless frontend-backend communication, including streaming responses.
|
* for seamless frontend-backend communication, including streaming responses and
|
||||||
|
* automatic date field conversion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Import generated types (from running generate_types.py)
|
// Import generated types (from running generate_types.py)
|
||||||
import * as Types from 'types/types';
|
import * as Types from 'types/types';
|
||||||
import {
|
import {
|
||||||
formatApiRequest,
|
formatApiRequest,
|
||||||
// parseApiResponse,
|
parseApiResponse,
|
||||||
// parsePaginatedResponse,
|
parsePaginatedResponse,
|
||||||
handleApiResponse,
|
handleApiResponse,
|
||||||
handlePaginatedApiResponse,
|
handlePaginatedApiResponse,
|
||||||
createPaginatedRequest,
|
createPaginatedRequest,
|
||||||
toUrlParams,
|
toUrlParams,
|
||||||
// extractApiData,
|
extractApiData,
|
||||||
// ApiResponse,
|
ApiResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PaginatedRequest
|
PaginatedRequest
|
||||||
} from 'types/conversion';
|
} from 'types/conversion';
|
||||||
|
|
||||||
|
// Import generated date conversion functions
|
||||||
|
import {
|
||||||
|
convertCandidateFromApi,
|
||||||
|
convertEmployerFromApi,
|
||||||
|
convertJobFromApi,
|
||||||
|
convertJobApplicationFromApi,
|
||||||
|
convertChatSessionFromApi,
|
||||||
|
convertChatMessageFromApi,
|
||||||
|
convertViewerFromApi,
|
||||||
|
convertFromApi,
|
||||||
|
convertArrayFromApi
|
||||||
|
} from 'types/types';
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Streaming Types and Interfaces
|
// Streaming Types and Interfaces
|
||||||
// ============================
|
// ============================
|
||||||
@ -41,6 +55,49 @@ interface StreamingResponse {
|
|||||||
promise: Promise<Types.ChatMessage[]>;
|
promise: Promise<Types.ChatMessage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
login: string; // email or username
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCandidateRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmployerRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
companyName: string;
|
||||||
|
industry: string;
|
||||||
|
companySize: string;
|
||||||
|
companyDescription: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateViewerRequest {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetConfirm {
|
||||||
|
token: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Chat Types and Interfaces
|
// Chat Types and Interfaces
|
||||||
// ============================
|
// ============================
|
||||||
@ -93,16 +150,71 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Authentication Methods
|
// Enhanced Response Handlers with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
async login(login: string, password: string): Promise<Types.AuthResponse> {
|
/**
|
||||||
|
* Handle API response with automatic date conversion for specific model types
|
||||||
|
*/
|
||||||
|
private async handleApiResponseWithConversion<T>(
|
||||||
|
response: Response,
|
||||||
|
modelType?: string
|
||||||
|
): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const apiResponse = parseApiResponse<T>(data);
|
||||||
|
const extractedData = extractApiData(apiResponse);
|
||||||
|
|
||||||
|
// Apply model-specific date conversion if modelType is provided
|
||||||
|
if (modelType) {
|
||||||
|
return convertFromApi<T>(extractedData, modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle paginated API response with automatic date conversion
|
||||||
|
*/
|
||||||
|
private async handlePaginatedApiResponseWithConversion<T>(
|
||||||
|
response: Response,
|
||||||
|
modelType?: string
|
||||||
|
): Promise<PaginatedResponse<T>> {
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const apiResponse = parsePaginatedResponse<T>(data);
|
||||||
|
const extractedData = extractApiData(apiResponse);
|
||||||
|
|
||||||
|
// Apply model-specific date conversion to array items if modelType is provided
|
||||||
|
if (modelType && extractedData.data) {
|
||||||
|
return {
|
||||||
|
...extractedData,
|
||||||
|
data: convertArrayFromApi<T>(extractedData.data, modelType)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Authentication Methods
|
||||||
|
// ============================
|
||||||
|
async login(request: LoginRequest): Promise<Types.AuthResponse> {
|
||||||
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest({ login, password }))
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AuthResponse doesn't typically have date fields, use standard handler
|
||||||
return handleApiResponse<Types.AuthResponse>(response);
|
return handleApiResponse<Types.AuthResponse>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,17 +239,31 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Candidate Methods
|
// Viewer Methods with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
async createCandidate(candidate: Omit<Types.Candidate, 'id' | 'createdAt' | 'updatedAt'>): Promise<Types.Candidate> {
|
async createViewer(request: CreateViewerRequest): Promise<Types.Viewer> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/viewers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.handleApiResponseWithConversion<Types.Viewer>(response, 'Viewer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Candidate Methods with Date Conversion
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
async createCandidate(request: CreateCandidateRequest): Promise<Types.Candidate> {
|
||||||
const response = await fetch(`${this.baseUrl}/candidates`, {
|
const response = await fetch(`${this.baseUrl}/candidates`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest(candidate))
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Candidate>(response);
|
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCandidate(username: string): Promise<Types.Candidate> {
|
async getCandidate(username: string): Promise<Types.Candidate> {
|
||||||
@ -145,7 +271,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Candidate>(response);
|
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
|
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
|
||||||
@ -155,7 +281,7 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest(updates))
|
body: JSON.stringify(formatApiRequest(updates))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Candidate>(response);
|
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> {
|
async getCandidates(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Candidate>> {
|
||||||
@ -166,7 +292,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.Candidate>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchCandidates(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Candidate>> {
|
async searchCandidates(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Candidate>> {
|
||||||
@ -182,21 +308,21 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.Candidate>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Employer Methods
|
// Employer Methods with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
async createEmployer(employer: Omit<Types.Employer, 'id' | 'createdAt' | 'updatedAt'>): Promise<Types.Employer> {
|
async createEmployer(request: CreateEmployerRequest): Promise<Types.Employer> {
|
||||||
const response = await fetch(`${this.baseUrl}/employers`, {
|
const response = await fetch(`${this.baseUrl}/employers`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.defaultHeaders,
|
headers: this.defaultHeaders,
|
||||||
body: JSON.stringify(formatApiRequest(employer))
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Employer>(response);
|
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmployer(id: string): Promise<Types.Employer> {
|
async getEmployer(id: string): Promise<Types.Employer> {
|
||||||
@ -204,7 +330,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Employer>(response);
|
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEmployer(id: string, updates: Partial<Types.Employer>): Promise<Types.Employer> {
|
async updateEmployer(id: string, updates: Partial<Types.Employer>): Promise<Types.Employer> {
|
||||||
@ -214,11 +340,11 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest(updates))
|
body: JSON.stringify(formatApiRequest(updates))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Employer>(response);
|
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Job Methods
|
// Job Methods with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
|
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
|
||||||
@ -228,7 +354,7 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest(job))
|
body: JSON.stringify(formatApiRequest(job))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Job>(response);
|
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJob(id: string): Promise<Types.Job> {
|
async getJob(id: string): Promise<Types.Job> {
|
||||||
@ -236,7 +362,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.Job>(response);
|
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
||||||
@ -247,7 +373,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.Job>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
||||||
@ -258,7 +384,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.Job>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchJobs(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Job>> {
|
async searchJobs(query: string, filters?: Record<string, any>): Promise<PaginatedResponse<Types.Job>> {
|
||||||
@ -274,11 +400,11 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.Job>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Job Application Methods
|
// Job Application Methods with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
async applyToJob(application: Omit<Types.JobApplication, 'id' | 'appliedDate' | 'updatedDate' | 'status'>): Promise<Types.JobApplication> {
|
async applyToJob(application: Omit<Types.JobApplication, 'id' | 'appliedDate' | 'updatedDate' | 'status'>): Promise<Types.JobApplication> {
|
||||||
@ -288,7 +414,7 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest(application))
|
body: JSON.stringify(formatApiRequest(application))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.JobApplication>(response);
|
return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobApplication(id: string): Promise<Types.JobApplication> {
|
async getJobApplication(id: string): Promise<Types.JobApplication> {
|
||||||
@ -296,7 +422,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.JobApplication>(response);
|
return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobApplications(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.JobApplication>> {
|
async getJobApplications(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.JobApplication>> {
|
||||||
@ -307,7 +433,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.JobApplication>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateApplicationStatus(id: string, status: Types.ApplicationStatus): Promise<Types.JobApplication> {
|
async updateApplicationStatus(id: string, status: Types.ApplicationStatus): Promise<Types.JobApplication> {
|
||||||
@ -317,14 +443,14 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest({ status }))
|
body: JSON.stringify(formatApiRequest({ status }))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.JobApplication>(response);
|
return this.handleApiResponseWithConversion<Types.JobApplication>(response, 'JobApplication');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Chat Methods
|
// Chat Methods with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a chat session with optional candidate association
|
* Create a chat session with optional candidate association
|
||||||
*/
|
*/
|
||||||
async createChatSessionWithCandidate(
|
async createChatSessionWithCandidate(
|
||||||
@ -336,7 +462,7 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest(request))
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.ChatSession>(response);
|
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -353,7 +479,15 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<CandidateSessionsResponse>(response);
|
// Handle the nested sessions with date conversion
|
||||||
|
const result = await this.handleApiResponseWithConversion<CandidateSessionsResponse>(response);
|
||||||
|
|
||||||
|
// Convert the nested sessions array
|
||||||
|
if (result.sessions && result.sessions.data) {
|
||||||
|
result.sessions.data = convertArrayFromApi<Types.ChatSession>(result.sessions.data, 'ChatSession');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -383,7 +517,7 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest({ context }))
|
body: JSON.stringify(formatApiRequest({ context }))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.ChatSession>(response);
|
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatSession(id: string): Promise<Types.ChatSession> {
|
async getChatSession(id: string): Promise<Types.ChatSession> {
|
||||||
@ -391,7 +525,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.ChatSession>(response);
|
return this.handleApiResponseWithConversion<Types.ChatSession>(response, 'ChatSession');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -404,11 +538,11 @@ class ApiClient {
|
|||||||
body: JSON.stringify(formatApiRequest({query}))
|
body: JSON.stringify(formatApiRequest({query}))
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleApiResponse<Types.ChatMessage>(response);
|
return this.handleApiResponseWithConversion<Types.ChatMessage>(response, 'ChatMessage');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send message with streaming response support
|
* Send message with streaming response support and date conversion
|
||||||
*/
|
*/
|
||||||
sendMessageStream(
|
sendMessageStream(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -445,7 +579,7 @@ class ApiClient {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let chatMessage: Types.ChatMessage | null = null;
|
let chatMessage: Types.ChatMessage | null = null;
|
||||||
const chatMessageList : Types.ChatMessage[] = [];
|
const chatMessageList: Types.ChatMessage[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -470,30 +604,35 @@ class ApiClient {
|
|||||||
const data = line.slice(5).trim();
|
const data = line.slice(5).trim();
|
||||||
const incoming: Types.ChatMessageBase = JSON.parse(data);
|
const incoming: Types.ChatMessageBase = JSON.parse(data);
|
||||||
|
|
||||||
|
// Convert date fields for incoming messages
|
||||||
|
const convertedIncoming = convertChatMessageFromApi(incoming);
|
||||||
|
|
||||||
// Trigger callbacks based on status
|
// Trigger callbacks based on status
|
||||||
if (incoming.status !== chatMessage?.status) {
|
if (convertedIncoming.status !== chatMessage?.status) {
|
||||||
options.onStatusChange?.(incoming.status);
|
options.onStatusChange?.(convertedIncoming.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different status types
|
// Handle different status types
|
||||||
switch (incoming.status) {
|
switch (convertedIncoming.status) {
|
||||||
case 'streaming':
|
case 'streaming':
|
||||||
if (chatMessage === null) {
|
if (chatMessage === null) {
|
||||||
chatMessage = {...incoming};
|
chatMessage = {...convertedIncoming};
|
||||||
} else {
|
} else {
|
||||||
// Can't do a simple += as typescript thinks .content might not be there
|
// Can't do a simple += as typescript thinks .content might not be there
|
||||||
chatMessage.content = (chatMessage?.content || '') + incoming.content;
|
chatMessage.content = (chatMessage?.content || '') + convertedIncoming.content;
|
||||||
|
// Update timestamp to latest
|
||||||
|
chatMessage.timestamp = convertedIncoming.timestamp;
|
||||||
}
|
}
|
||||||
options.onStreaming?.(incoming);
|
options.onStreaming?.(convertedIncoming);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
options.onError?.(incoming);
|
options.onError?.(convertedIncoming);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
chatMessageList.push(incoming);
|
chatMessageList.push(convertedIncoming);
|
||||||
options.onMessage?.(incoming);
|
options.onMessage?.(convertedIncoming);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -550,7 +689,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get persisted chat messages for a session
|
* Get persisted chat messages for a session with date conversion
|
||||||
*/
|
*/
|
||||||
async getChatMessages(
|
async getChatMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -565,17 +704,101 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return handlePaginatedApiResponse<Types.ChatMessage>(response);
|
return this.handlePaginatedApiResponseWithConversion<Types.ChatMessage>(response, 'ChatMessage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Password Reset Methods
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
async requestPasswordReset(request: PasswordResetRequest): Promise<{ message: string }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/password-reset/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ message: string }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmPasswordReset(request: PasswordResetConfirm): Promise<{ message: string }> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/auth/password-reset/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<{ message: string }>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Password Validation Utilities
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
validatePasswordStrength(password: string): { isValid: boolean; issues: string[] } {
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
issues.push('Password must be at least 8 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[A-Z]/.test(password)) {
|
||||||
|
issues.push('Password must contain at least one uppercase letter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[a-z]/.test(password)) {
|
||||||
|
issues.push('Password must contain at least one lowercase letter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/\d/.test(password)) {
|
||||||
|
issues.push('Password must contain at least one digit');
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?/~`"\'\\';
|
||||||
|
if (!password.split('').some(char => specialChars.includes(char))) {
|
||||||
|
issues.push('Password must contain at least one special character');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: issues.length === 0,
|
||||||
|
issues
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validateEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUsername(username: string): { isValid: boolean; issues: string[] } {
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
issues.push('Username must be at least 3 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length > 30) {
|
||||||
|
issues.push('Username must be no more than 30 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||||
|
issues.push('Username can only contain letters, numbers, underscores, and hyphens');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: issues.length === 0,
|
||||||
|
issues
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Error Handling Helper
|
// Error Handling Helper
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
async handleRequest<T>(requestFn: () => Promise<Response>): Promise<T> {
|
async handleRequest<T>(requestFn: () => Promise<Response>, modelType?: string): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const response = await requestFn();
|
const response = await requestFn();
|
||||||
return await handleApiResponse<T>(response);
|
return await this.handleApiResponseWithConversion<T>(response, modelType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API request failed:', error);
|
console.error('API request failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -600,10 +823,10 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// React Hooks for Streaming
|
// React Hooks for Streaming with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
/* React Hook Examples for Streaming Chat
|
/* React Hook Examples for Streaming Chat with proper date handling
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
export function useStreamingChat(sessionId: string) {
|
export function useStreamingChat(sessionId: string) {
|
||||||
@ -622,31 +845,39 @@ export function useStreamingChat(sessionId: string) {
|
|||||||
|
|
||||||
const streamingOptions: StreamingOptions = {
|
const streamingOptions: StreamingOptions = {
|
||||||
onMessage: (message) => {
|
onMessage: (message) => {
|
||||||
|
// Message already has proper Date objects from conversion
|
||||||
setCurrentMessage(message);
|
setCurrentMessage(message);
|
||||||
},
|
},
|
||||||
onPartialMessage: (content, messageId) => {
|
onStreaming: (chunk) => {
|
||||||
|
// Chunk also has proper Date objects
|
||||||
setCurrentMessage(prev => prev ?
|
setCurrentMessage(prev => prev ?
|
||||||
{ ...prev, content: prev.content + content } :
|
{
|
||||||
|
...prev,
|
||||||
|
content: prev.content + chunk.content,
|
||||||
|
timestamp: chunk.timestamp // Update to latest timestamp
|
||||||
|
} :
|
||||||
{
|
{
|
||||||
id: messageId || '',
|
id: chunk.id || '',
|
||||||
sessionId,
|
sessionId,
|
||||||
status: 'streaming',
|
status: 'streaming',
|
||||||
sender: 'ai',
|
sender: 'ai',
|
||||||
content,
|
content: chunk.content,
|
||||||
timestamp: new Date()
|
timestamp: chunk.timestamp // Already a Date object
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
setCurrentMessage(prev => prev ? { ...prev, status } : null);
|
setCurrentMessage(prev => prev ? { ...prev, status } : null);
|
||||||
},
|
},
|
||||||
onComplete: (finalMessage) => {
|
onComplete: () => {
|
||||||
setMessages(prev => [...prev, finalMessage]);
|
if (currentMessage) {
|
||||||
|
setMessages(prev => [...prev, currentMessage]);
|
||||||
|
}
|
||||||
setCurrentMessage(null);
|
setCurrentMessage(null);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err.message);
|
setError(typeof err === 'string' ? err : err.content);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setCurrentMessage(null);
|
setCurrentMessage(null);
|
||||||
}
|
}
|
||||||
@ -659,7 +890,7 @@ export function useStreamingChat(sessionId: string) {
|
|||||||
setError(err instanceof Error ? err.message : 'Failed to send message');
|
setError(err instanceof Error ? err.message : 'Failed to send message');
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
}, [sessionId, apiClient]);
|
}, [sessionId, apiClient, currentMessage]);
|
||||||
|
|
||||||
const cancelStreaming = useCallback(() => {
|
const cancelStreaming = useCallback(() => {
|
||||||
if (streamingRef.current) {
|
if (streamingRef.current) {
|
||||||
@ -679,7 +910,7 @@ export function useStreamingChat(sessionId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage in React component:
|
// Usage in React component with proper date handling:
|
||||||
function ChatInterface({ sessionId }: { sessionId: string }) {
|
function ChatInterface({ sessionId }: { sessionId: string }) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@ -699,14 +930,26 @@ function ChatInterface({ sessionId }: { sessionId: string }) {
|
|||||||
<div className="messages">
|
<div className="messages">
|
||||||
{messages.map(message => (
|
{messages.map(message => (
|
||||||
<div key={message.id}>
|
<div key={message.id}>
|
||||||
<strong>{message.sender}:</strong> {message.content}
|
<div className="message-header">
|
||||||
|
<strong>{message.sender}:</strong>
|
||||||
|
<span className="timestamp">
|
||||||
|
{message.timestamp.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-content">{message.content}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{currentMessage && (
|
{currentMessage && (
|
||||||
<div className="current-message">
|
<div className="current-message">
|
||||||
<strong>{currentMessage.sender}:</strong> {currentMessage.content}
|
<div className="message-header">
|
||||||
{isStreaming && <span className="streaming-indicator">...</span>}
|
<strong>{currentMessage.sender}:</strong>
|
||||||
|
<span className="timestamp">
|
||||||
|
{currentMessage.timestamp.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
{isStreaming && <span className="streaming-indicator">...</span>}
|
||||||
|
</div>
|
||||||
|
<div className="message-content">{currentMessage.content}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -734,61 +977,96 @@ function ChatInterface({ sessionId }: { sessionId: string }) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Usage Examples
|
// Usage Examples with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
/*
|
/*
|
||||||
// Initialize API client
|
// Initialize API client
|
||||||
const apiClient = new ApiClient();
|
const apiClient = new ApiClient();
|
||||||
|
|
||||||
// Standard message sending (non-streaming)
|
// All returned objects now have proper Date fields automatically!
|
||||||
|
|
||||||
|
// Create a candidate - createdAt, updatedAt, lastLogin are Date objects
|
||||||
try {
|
try {
|
||||||
const message = await apiClient.sendMessage(sessionId, 'Hello, how are you?');
|
const candidate = await apiClient.createCandidate({
|
||||||
console.log('Response:', message.content);
|
email: 'jane@example.com',
|
||||||
|
username: 'jane_doe',
|
||||||
|
password: 'SecurePassword123!',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// These are now Date objects, not strings!
|
||||||
|
console.log('Created at:', candidate.createdAt.toLocaleDateString());
|
||||||
|
console.log('Profile created on:', candidate.createdAt.toDateString());
|
||||||
|
|
||||||
|
if (candidate.lastLogin) {
|
||||||
|
console.log('Last seen:', candidate.lastLogin.toRelativeTimeString());
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to create candidate:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming message with callbacks
|
// Get jobs with proper date conversion
|
||||||
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me a long story', {
|
try {
|
||||||
onPartialMessage: (content, messageId) => {
|
const jobs = await apiClient.getJobs({ limit: 10 });
|
||||||
console.log('Partial content:', content);
|
|
||||||
// Update UI with partial content
|
jobs.data.forEach(job => {
|
||||||
|
// datePosted, applicationDeadline, featuredUntil are Date objects
|
||||||
|
console.log(`${job.title} - Posted: ${job.datePosted.toLocaleDateString()}`);
|
||||||
|
|
||||||
|
if (job.applicationDeadline) {
|
||||||
|
const daysRemaining = Math.ceil(
|
||||||
|
(job.applicationDeadline.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
console.log(`Deadline in ${daysRemaining} days`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch jobs:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming with proper date conversion
|
||||||
|
const streamResponse = apiClient.sendMessageStream(sessionId, 'Tell me about job opportunities', {
|
||||||
|
onStreaming: (chunk) => {
|
||||||
|
// chunk.timestamp is a Date object
|
||||||
|
console.log(`Streaming at ${chunk.timestamp.toLocaleTimeString()}:`, chunk.content);
|
||||||
},
|
},
|
||||||
onStatusChange: (status) => {
|
onMessage: (message) => {
|
||||||
console.log('Status changed:', status);
|
// message.timestamp is a Date object
|
||||||
// Update UI status indicator
|
console.log(`Final message at ${message.timestamp.toLocaleTimeString()}:`, message.content);
|
||||||
},
|
},
|
||||||
onComplete: (finalMessage) => {
|
onComplete: () => {
|
||||||
console.log('Final message:', finalMessage.content);
|
console.log('Streaming completed');
|
||||||
// Handle completed message
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Streaming error:', error);
|
|
||||||
// Handle error
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Can cancel the stream if needed
|
// Chat sessions with date conversion
|
||||||
setTimeout(() => {
|
|
||||||
streamResponse.cancel();
|
|
||||||
}, 10000); // Cancel after 10 seconds
|
|
||||||
|
|
||||||
// Wait for completion
|
|
||||||
try {
|
try {
|
||||||
const finalMessage = await streamResponse.promise;
|
const chatSession = await apiClient.createChatSession({
|
||||||
console.log('Stream completed:', finalMessage);
|
type: 'job_search',
|
||||||
|
additionalContext: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// createdAt and lastActivity are Date objects
|
||||||
|
console.log('Session created:', chatSession.createdAt.toISOString());
|
||||||
|
console.log('Last activity:', chatSession.lastActivity.toLocaleDateString());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Stream failed:', error);
|
console.error('Failed to create chat session:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-detection: streaming if callbacks provided, standard otherwise
|
// Get chat messages with date conversion
|
||||||
await apiClient.sendMessageAuto(sessionId, 'Quick question', {
|
try {
|
||||||
onPartialMessage: (content) => console.log('Streaming:', content)
|
const messages = await apiClient.getChatMessages(sessionId);
|
||||||
}); // Will use streaming
|
|
||||||
|
messages.data.forEach(message => {
|
||||||
await apiClient.sendMessageAuto(sessionId, 'Quick question'); // Will use standard
|
// timestamp is a Date object
|
||||||
|
console.log(`[${message.timestamp.toLocaleString()}] ${message.sender}: ${message.content}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch messages:', error);
|
||||||
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ApiClient }
|
export { ApiClient }
|
||||||
export type { StreamingOptions, StreamingResponse };
|
export type { StreamingOptions, StreamingResponse }
|
@ -1,6 +1,6 @@
|
|||||||
// Generated TypeScript types from Pydantic models
|
// Generated TypeScript types from Pydantic models
|
||||||
// Source: src/backend/models.py
|
// Source: src/backend/models.py
|
||||||
// Generated on: 2025-05-29T23:38:18.286927
|
// Generated on: 2025-05-30T09:39:47.716115
|
||||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -57,9 +57,9 @@ export type UserGender = "female" | "male";
|
|||||||
|
|
||||||
export type UserStatus = "active" | "inactive" | "pending" | "banned";
|
export type UserStatus = "active" | "inactive" | "pending" | "banned";
|
||||||
|
|
||||||
export type UserType = "candidate" | "employer" | "guest";
|
export type UserType = "candidate" | "employer" | "viewer" | "guest";
|
||||||
|
|
||||||
export type VectorStoreType = "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate";
|
export type VectorStoreType = "chroma";
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Interfaces
|
// Interfaces
|
||||||
@ -135,7 +135,11 @@ export interface Authentication {
|
|||||||
export interface BaseUser {
|
export interface BaseUser {
|
||||||
id?: string;
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
location?: Location;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
@ -146,19 +150,27 @@ export interface BaseUser {
|
|||||||
export interface BaseUserWithType {
|
export interface BaseUserWithType {
|
||||||
id?: string;
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
location?: Location;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
profileImage?: string;
|
profileImage?: string;
|
||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
userType: "candidate" | "employer" | "guest";
|
userType: "candidate" | "employer" | "viewer" | "guest";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Candidate {
|
export interface Candidate {
|
||||||
id?: string;
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
location?: Location;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
@ -166,22 +178,18 @@ export interface Candidate {
|
|||||||
status: "active" | "inactive" | "pending" | "banned";
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
userType?: "candidate";
|
userType?: "candidate";
|
||||||
username: string;
|
username: string;
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
fullName: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
resume?: string;
|
resume?: string;
|
||||||
skills: Array<Skill>;
|
skills?: Array<Skill>;
|
||||||
experience: Array<WorkExperience>;
|
experience?: Array<WorkExperience>;
|
||||||
questions?: Array<CandidateQuestion>;
|
questions?: Array<CandidateQuestion>;
|
||||||
education: Array<Education>;
|
education?: Array<Education>;
|
||||||
preferredJobTypes: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">;
|
preferredJobTypes?: Array<"full-time" | "part-time" | "contract" | "internship" | "freelance">;
|
||||||
desiredSalary?: DesiredSalary;
|
desiredSalary?: DesiredSalary;
|
||||||
location: Location;
|
|
||||||
availabilityDate?: Date;
|
availabilityDate?: Date;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
languages: Array<Language>;
|
languages?: Array<Language>;
|
||||||
certifications: Array<Certification>;
|
certifications?: Array<Certification>;
|
||||||
jobApplications?: Array<JobApplication>;
|
jobApplications?: Array<JobApplication>;
|
||||||
hasProfile?: boolean;
|
hasProfile?: boolean;
|
||||||
age?: number;
|
age?: number;
|
||||||
@ -368,7 +376,11 @@ export interface Education {
|
|||||||
export interface Employer {
|
export interface Employer {
|
||||||
id?: string;
|
id?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
location?: Location;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
@ -382,7 +394,6 @@ export interface Employer {
|
|||||||
companyDescription: string;
|
companyDescription: string;
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
jobs?: Array<Job>;
|
jobs?: Array<Job>;
|
||||||
location: Location;
|
|
||||||
companyLogo?: string;
|
companyLogo?: string;
|
||||||
socialLinks?: Array<SocialLink>;
|
socialLinks?: Array<SocialLink>;
|
||||||
poc?: PointOfContact;
|
poc?: PointOfContact;
|
||||||
@ -564,7 +575,7 @@ export interface RAGConfiguration {
|
|||||||
description?: string;
|
description?: string;
|
||||||
dataSourceConfigurations: Array<DataSourceConfiguration>;
|
dataSourceConfigurations: Array<DataSourceConfiguration>;
|
||||||
embeddingModel: string;
|
embeddingModel: string;
|
||||||
vectorStoreType: "pinecone" | "qdrant" | "faiss" | "milvus" | "weaviate";
|
vectorStoreType: "chroma";
|
||||||
retrievalParameters: RetrievalParameters;
|
retrievalParameters: RetrievalParameters;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@ -662,6 +673,23 @@ export interface UserPreference {
|
|||||||
emailFrequency: "immediate" | "daily" | "weekly" | "never";
|
emailFrequency: "immediate" | "daily" | "weekly" | "never";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Viewer {
|
||||||
|
id?: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
|
phone?: string;
|
||||||
|
location?: Location;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
lastLogin?: Date;
|
||||||
|
profileImage?: string;
|
||||||
|
status: "active" | "inactive" | "pending" | "banned";
|
||||||
|
userType?: "viewer";
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkExperience {
|
export interface WorkExperience {
|
||||||
id?: string;
|
id?: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
@ -675,11 +703,492 @@ export interface WorkExperience {
|
|||||||
achievements?: Array<string>;
|
achievements?: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// Date Conversion Functions
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
// These functions convert API responses to properly typed objects
|
||||||
|
// with Date objects instead of ISO date strings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Analytics from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertAnalyticsFromApi(data: any): Analytics {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert ApplicationDecision from API response, parsing date fields
|
||||||
|
* Date fields: date
|
||||||
|
*/
|
||||||
|
export function convertApplicationDecisionFromApi(data: any): ApplicationDecision {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert date from ISO string to Date
|
||||||
|
date: new Date(data.date),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Attachment from API response, parsing date fields
|
||||||
|
* Date fields: uploadedAt
|
||||||
|
*/
|
||||||
|
export function convertAttachmentFromApi(data: any): Attachment {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert uploadedAt from ISO string to Date
|
||||||
|
uploadedAt: new Date(data.uploadedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Authentication from API response, parsing date fields
|
||||||
|
* Date fields: resetPasswordExpiry, lastPasswordChange, lockedUntil
|
||||||
|
*/
|
||||||
|
export function convertAuthenticationFromApi(data: any): Authentication {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert resetPasswordExpiry from ISO string to Date
|
||||||
|
resetPasswordExpiry: data.resetPasswordExpiry ? new Date(data.resetPasswordExpiry) : undefined,
|
||||||
|
// Convert lastPasswordChange from ISO string to Date
|
||||||
|
lastPasswordChange: new Date(data.lastPasswordChange),
|
||||||
|
// Convert lockedUntil from ISO string to Date
|
||||||
|
lockedUntil: data.lockedUntil ? new Date(data.lockedUntil) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert BaseUser from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt, lastLogin
|
||||||
|
*/
|
||||||
|
export function convertBaseUserFromApi(data: any): BaseUser {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
// Convert lastLogin from ISO string to Date
|
||||||
|
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert BaseUserWithType from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt, lastLogin
|
||||||
|
*/
|
||||||
|
export function convertBaseUserWithTypeFromApi(data: any): BaseUserWithType {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
// Convert lastLogin from ISO string to Date
|
||||||
|
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Candidate from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt, lastLogin, availabilityDate
|
||||||
|
*/
|
||||||
|
export function convertCandidateFromApi(data: any): Candidate {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
// Convert lastLogin from ISO string to Date
|
||||||
|
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
|
||||||
|
// Convert availabilityDate from ISO string to Date
|
||||||
|
availabilityDate: data.availabilityDate ? new Date(data.availabilityDate) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Certification from API response, parsing date fields
|
||||||
|
* Date fields: issueDate, expirationDate
|
||||||
|
*/
|
||||||
|
export function convertCertificationFromApi(data: any): Certification {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert issueDate from ISO string to Date
|
||||||
|
issueDate: new Date(data.issueDate),
|
||||||
|
// Convert expirationDate from ISO string to Date
|
||||||
|
expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert ChatMessage from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertChatMessageFromApi(data: any): ChatMessage {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert ChatMessageBase from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertChatMessageBaseFromApi(data: any): ChatMessageBase {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert ChatMessageUser from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertChatMessageUserFromApi(data: any): ChatMessageUser {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert ChatSession from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, lastActivity
|
||||||
|
*/
|
||||||
|
export function convertChatSessionFromApi(data: any): ChatSession {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
|
||||||
|
// Convert lastActivity from ISO string to Date
|
||||||
|
lastActivity: data.lastActivity ? new Date(data.lastActivity) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert DataSourceConfiguration from API response, parsing date fields
|
||||||
|
* Date fields: lastRefreshed
|
||||||
|
*/
|
||||||
|
export function convertDataSourceConfigurationFromApi(data: any): DataSourceConfiguration {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert lastRefreshed from ISO string to Date
|
||||||
|
lastRefreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert EditHistory from API response, parsing date fields
|
||||||
|
* Date fields: editedAt
|
||||||
|
*/
|
||||||
|
export function convertEditHistoryFromApi(data: any): EditHistory {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert editedAt from ISO string to Date
|
||||||
|
editedAt: new Date(data.editedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Education from API response, parsing date fields
|
||||||
|
* Date fields: startDate, endDate
|
||||||
|
*/
|
||||||
|
export function convertEducationFromApi(data: any): Education {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert startDate from ISO string to Date
|
||||||
|
startDate: new Date(data.startDate),
|
||||||
|
// Convert endDate from ISO string to Date
|
||||||
|
endDate: data.endDate ? new Date(data.endDate) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Employer from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt, lastLogin
|
||||||
|
*/
|
||||||
|
export function convertEmployerFromApi(data: any): Employer {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
// Convert lastLogin from ISO string to Date
|
||||||
|
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Guest from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, lastActivity
|
||||||
|
*/
|
||||||
|
export function convertGuestFromApi(data: any): Guest {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert lastActivity from ISO string to Date
|
||||||
|
lastActivity: new Date(data.lastActivity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert InterviewFeedback from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt
|
||||||
|
*/
|
||||||
|
export function convertInterviewFeedbackFromApi(data: any): InterviewFeedback {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert InterviewSchedule from API response, parsing date fields
|
||||||
|
* Date fields: scheduledDate, endDate
|
||||||
|
*/
|
||||||
|
export function convertInterviewScheduleFromApi(data: any): InterviewSchedule {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert scheduledDate from ISO string to Date
|
||||||
|
scheduledDate: new Date(data.scheduledDate),
|
||||||
|
// Convert endDate from ISO string to Date
|
||||||
|
endDate: new Date(data.endDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Job from API response, parsing date fields
|
||||||
|
* Date fields: datePosted, applicationDeadline, featuredUntil
|
||||||
|
*/
|
||||||
|
export function convertJobFromApi(data: any): Job {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert datePosted from ISO string to Date
|
||||||
|
datePosted: new Date(data.datePosted),
|
||||||
|
// Convert applicationDeadline from ISO string to Date
|
||||||
|
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined,
|
||||||
|
// Convert featuredUntil from ISO string to Date
|
||||||
|
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert JobApplication from API response, parsing date fields
|
||||||
|
* Date fields: appliedDate, updatedDate
|
||||||
|
*/
|
||||||
|
export function convertJobApplicationFromApi(data: any): JobApplication {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert appliedDate from ISO string to Date
|
||||||
|
appliedDate: new Date(data.appliedDate),
|
||||||
|
// Convert updatedDate from ISO string to Date
|
||||||
|
updatedDate: new Date(data.updatedDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert MessageReaction from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertMessageReactionFromApi(data: any): MessageReaction {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert RAGConfiguration from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt
|
||||||
|
*/
|
||||||
|
export function convertRAGConfigurationFromApi(data: any): RAGConfiguration {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert RefreshToken from API response, parsing date fields
|
||||||
|
* Date fields: expiresAt
|
||||||
|
*/
|
||||||
|
export function convertRefreshTokenFromApi(data: any): RefreshToken {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert expiresAt from ISO string to Date
|
||||||
|
expiresAt: new Date(data.expiresAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert UserActivity from API response, parsing date fields
|
||||||
|
* Date fields: timestamp
|
||||||
|
*/
|
||||||
|
export function convertUserActivityFromApi(data: any): UserActivity {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert timestamp from ISO string to Date
|
||||||
|
timestamp: new Date(data.timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert Viewer from API response, parsing date fields
|
||||||
|
* Date fields: createdAt, updatedAt, lastLogin
|
||||||
|
*/
|
||||||
|
export function convertViewerFromApi(data: any): Viewer {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert createdAt from ISO string to Date
|
||||||
|
createdAt: new Date(data.createdAt),
|
||||||
|
// Convert updatedAt from ISO string to Date
|
||||||
|
updatedAt: new Date(data.updatedAt),
|
||||||
|
// Convert lastLogin from ISO string to Date
|
||||||
|
lastLogin: data.lastLogin ? new Date(data.lastLogin) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Convert WorkExperience from API response, parsing date fields
|
||||||
|
* Date fields: startDate, endDate
|
||||||
|
*/
|
||||||
|
export function convertWorkExperienceFromApi(data: any): WorkExperience {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
// Convert startDate from ISO string to Date
|
||||||
|
startDate: new Date(data.startDate),
|
||||||
|
// Convert endDate from ISO string to Date
|
||||||
|
endDate: data.endDate ? new Date(data.endDate) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic converter that automatically selects the right conversion function
|
||||||
|
* based on the model type
|
||||||
|
*/
|
||||||
|
export function convertFromApi<T>(data: any, modelType: string): T {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
switch (modelType) {
|
||||||
|
case 'Analytics':
|
||||||
|
return convertAnalyticsFromApi(data) as T;
|
||||||
|
case 'ApplicationDecision':
|
||||||
|
return convertApplicationDecisionFromApi(data) as T;
|
||||||
|
case 'Attachment':
|
||||||
|
return convertAttachmentFromApi(data) as T;
|
||||||
|
case 'Authentication':
|
||||||
|
return convertAuthenticationFromApi(data) as T;
|
||||||
|
case 'BaseUser':
|
||||||
|
return convertBaseUserFromApi(data) as T;
|
||||||
|
case 'BaseUserWithType':
|
||||||
|
return convertBaseUserWithTypeFromApi(data) as T;
|
||||||
|
case 'Candidate':
|
||||||
|
return convertCandidateFromApi(data) as T;
|
||||||
|
case 'Certification':
|
||||||
|
return convertCertificationFromApi(data) as T;
|
||||||
|
case 'ChatMessage':
|
||||||
|
return convertChatMessageFromApi(data) as T;
|
||||||
|
case 'ChatMessageBase':
|
||||||
|
return convertChatMessageBaseFromApi(data) as T;
|
||||||
|
case 'ChatMessageUser':
|
||||||
|
return convertChatMessageUserFromApi(data) as T;
|
||||||
|
case 'ChatSession':
|
||||||
|
return convertChatSessionFromApi(data) as T;
|
||||||
|
case 'DataSourceConfiguration':
|
||||||
|
return convertDataSourceConfigurationFromApi(data) as T;
|
||||||
|
case 'EditHistory':
|
||||||
|
return convertEditHistoryFromApi(data) as T;
|
||||||
|
case 'Education':
|
||||||
|
return convertEducationFromApi(data) as T;
|
||||||
|
case 'Employer':
|
||||||
|
return convertEmployerFromApi(data) as T;
|
||||||
|
case 'Guest':
|
||||||
|
return convertGuestFromApi(data) as T;
|
||||||
|
case 'InterviewFeedback':
|
||||||
|
return convertInterviewFeedbackFromApi(data) as T;
|
||||||
|
case 'InterviewSchedule':
|
||||||
|
return convertInterviewScheduleFromApi(data) as T;
|
||||||
|
case 'Job':
|
||||||
|
return convertJobFromApi(data) as T;
|
||||||
|
case 'JobApplication':
|
||||||
|
return convertJobApplicationFromApi(data) as T;
|
||||||
|
case 'MessageReaction':
|
||||||
|
return convertMessageReactionFromApi(data) as T;
|
||||||
|
case 'RAGConfiguration':
|
||||||
|
return convertRAGConfigurationFromApi(data) as T;
|
||||||
|
case 'RefreshToken':
|
||||||
|
return convertRefreshTokenFromApi(data) as T;
|
||||||
|
case 'UserActivity':
|
||||||
|
return convertUserActivityFromApi(data) as T;
|
||||||
|
case 'Viewer':
|
||||||
|
return convertViewerFromApi(data) as T;
|
||||||
|
case 'WorkExperience':
|
||||||
|
return convertWorkExperienceFromApi(data) as T;
|
||||||
|
default:
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert array of items using the appropriate converter
|
||||||
|
*/
|
||||||
|
export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {
|
||||||
|
if (!data || !Array.isArray(data)) return data;
|
||||||
|
return data.map(item => convertFromApi<T>(item, modelType));
|
||||||
|
}
|
||||||
// ============================
|
// ============================
|
||||||
// Union Types
|
// Union Types
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
export type User = Candidate | Employer;
|
export type User = Candidate | Employer | Viewer;
|
||||||
|
|
||||||
// Export all types
|
// Export all types
|
||||||
export type { };
|
export type { };
|
||||||
|
268
src/backend/auth_utils.py
Normal file
268
src/backend/auth_utils.py
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# auth_utils.py
|
||||||
|
"""
|
||||||
|
Secure Authentication Utilities
|
||||||
|
Provides password hashing, verification, and security features
|
||||||
|
"""
|
||||||
|
|
||||||
|
import bcrypt # type: ignore
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, Optional, Tuple
|
||||||
|
from pydantic import BaseModel # type: ignore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class PasswordSecurity:
|
||||||
|
"""Handles password hashing and verification using bcrypt"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_password(password: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Hash a password with a random salt using bcrypt
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Plain text password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (password_hash, salt) both as strings
|
||||||
|
"""
|
||||||
|
# Generate a random salt
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
|
||||||
|
# Hash the password
|
||||||
|
password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||||
|
|
||||||
|
return password_hash.decode('utf-8'), salt.decode('utf-8')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify a password against its hash
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Plain text password to verify
|
||||||
|
password_hash: Stored password hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password matches, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(
|
||||||
|
password.encode('utf-8'),
|
||||||
|
password_hash.encode('utf-8')
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password verification error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_secure_token(length: int = 32) -> str:
|
||||||
|
"""Generate a cryptographically secure random token"""
|
||||||
|
return secrets.token_urlsafe(length)
|
||||||
|
|
||||||
|
class AuthenticationRecord(BaseModel):
|
||||||
|
"""Authentication record for storing user credentials"""
|
||||||
|
user_id: str
|
||||||
|
password_hash: str
|
||||||
|
salt: str
|
||||||
|
refresh_tokens: list = []
|
||||||
|
reset_password_token: Optional[str] = None
|
||||||
|
reset_password_expiry: Optional[datetime] = None
|
||||||
|
last_password_change: datetime
|
||||||
|
mfa_enabled: bool = False
|
||||||
|
mfa_method: Optional[str] = None
|
||||||
|
mfa_secret: Optional[str] = None
|
||||||
|
login_attempts: int = 0
|
||||||
|
locked_until: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
datetime: lambda v: v.isoformat() if v else None
|
||||||
|
}
|
||||||
|
|
||||||
|
class SecurityConfig:
|
||||||
|
"""Security configuration constants"""
|
||||||
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
|
ACCOUNT_LOCKOUT_DURATION_MINUTES = 15
|
||||||
|
PASSWORD_MIN_LENGTH = 8
|
||||||
|
TOKEN_EXPIRY_HOURS = 24
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS = 30
|
||||||
|
|
||||||
|
class AuthenticationManager:
|
||||||
|
"""Manages authentication operations with security features"""
|
||||||
|
|
||||||
|
def __init__(self, database):
|
||||||
|
self.database = database
|
||||||
|
self.password_security = PasswordSecurity()
|
||||||
|
|
||||||
|
async def create_user_authentication(self, user_id: str, password: str) -> AuthenticationRecord:
|
||||||
|
"""
|
||||||
|
Create authentication record for a new user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Unique user identifier
|
||||||
|
password: Plain text password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuthenticationRecord object
|
||||||
|
"""
|
||||||
|
if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH:
|
||||||
|
raise ValueError(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long")
|
||||||
|
|
||||||
|
# Hash the password
|
||||||
|
password_hash, salt = self.password_security.hash_password(password)
|
||||||
|
|
||||||
|
# Create authentication record
|
||||||
|
auth_record = AuthenticationRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
password_hash=password_hash,
|
||||||
|
salt=salt,
|
||||||
|
last_password_change=datetime.now(timezone.utc),
|
||||||
|
login_attempts=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in database
|
||||||
|
await self.database.set_authentication(user_id, auth_record.model_dump())
|
||||||
|
|
||||||
|
logger.info(f"🔐 Created authentication record for user {user_id}")
|
||||||
|
return auth_record
|
||||||
|
|
||||||
|
async def verify_user_credentials(self, login: str, password: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Verify user credentials with security checks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
login: Email or username
|
||||||
|
password: Plain text password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, user_data, error_message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user data
|
||||||
|
user_data = await self.database.get_user(login)
|
||||||
|
if not user_data:
|
||||||
|
logger.warning(f"⚠️ Login attempt with non-existent user: {login}")
|
||||||
|
return False, None, "Invalid credentials"
|
||||||
|
|
||||||
|
# Get authentication record
|
||||||
|
auth_record = await self.database.get_authentication(user_data["id"])
|
||||||
|
if not auth_record:
|
||||||
|
logger.error(f"❌ No authentication record found for user {user_data['id']}")
|
||||||
|
return False, None, "Authentication record not found"
|
||||||
|
|
||||||
|
auth_data = AuthenticationRecord.model_validate(auth_record)
|
||||||
|
|
||||||
|
# Check if account is locked
|
||||||
|
if auth_data.locked_until and auth_data.locked_until > datetime.now(timezone.utc):
|
||||||
|
logger.warning(f"🔒 Account locked for user {login}")
|
||||||
|
return False, None, "Account is temporarily locked due to too many failed attempts"
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not self.password_security.verify_password(password, auth_data.password_hash):
|
||||||
|
# Increment failed attempts
|
||||||
|
auth_data.login_attempts += 1
|
||||||
|
|
||||||
|
# Lock account if too many attempts
|
||||||
|
if auth_data.login_attempts >= SecurityConfig.MAX_LOGIN_ATTEMPTS:
|
||||||
|
from datetime import timedelta
|
||||||
|
auth_data.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=SecurityConfig.ACCOUNT_LOCKOUT_DURATION_MINUTES
|
||||||
|
)
|
||||||
|
logger.warning(f"🔒 Account locked for user {login} after {auth_data.login_attempts} failed attempts")
|
||||||
|
|
||||||
|
# Update authentication record
|
||||||
|
await self.database.set_authentication(user_data["id"], auth_data.model_dump())
|
||||||
|
|
||||||
|
logger.warning(f"⚠️ Invalid password for user {login} (attempt {auth_data.login_attempts})")
|
||||||
|
return False, None, "Invalid credentials"
|
||||||
|
|
||||||
|
# Reset failed attempts on successful login
|
||||||
|
if auth_data.login_attempts > 0:
|
||||||
|
auth_data.login_attempts = 0
|
||||||
|
auth_data.locked_until = None
|
||||||
|
await self.database.set_authentication(user_data["id"], auth_data.model_dump())
|
||||||
|
|
||||||
|
logger.info(f"✅ Successful authentication for user {login}")
|
||||||
|
return True, user_data, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Authentication error for user {login}: {e}")
|
||||||
|
return False, None, "Authentication failed"
|
||||||
|
|
||||||
|
async def check_user_exists(self, email: str, username: str | None = None) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Check if a user already exists with the given email or username
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Email address to check
|
||||||
|
username: Username to check (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (exists, conflict_field)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check email
|
||||||
|
existing_user = await self.database.get_user(email)
|
||||||
|
if existing_user:
|
||||||
|
return True, "email"
|
||||||
|
|
||||||
|
# Check username if provided
|
||||||
|
if username:
|
||||||
|
existing_user = await self.database.get_user(username)
|
||||||
|
if existing_user:
|
||||||
|
return True, "username"
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error checking user existence: {e}")
|
||||||
|
# In case of error, assume user doesn't exist to avoid blocking creation
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
async def update_last_login(self, user_id: str):
|
||||||
|
"""Update user's last login timestamp"""
|
||||||
|
try:
|
||||||
|
user_data = await self.database.get_user_by_id(user_id)
|
||||||
|
if user_data:
|
||||||
|
user_data["lastLogin"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await self.database.set_user_by_id(user_id, user_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error updating last login for user {user_id}: {e}")
|
||||||
|
|
||||||
|
# Utility functions for common operations
|
||||||
|
def validate_password_strength(password: str) -> Tuple[bool, list]:
|
||||||
|
"""
|
||||||
|
Validate password strength
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Password to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, list_of_issues)
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
if len(password) < SecurityConfig.PASSWORD_MIN_LENGTH:
|
||||||
|
issues.append(f"Password must be at least {SecurityConfig.PASSWORD_MIN_LENGTH} characters long")
|
||||||
|
|
||||||
|
if not any(c.isupper() for c in password):
|
||||||
|
issues.append("Password must contain at least one uppercase letter")
|
||||||
|
|
||||||
|
if not any(c.islower() for c in password):
|
||||||
|
issues.append("Password must contain at least one lowercase letter")
|
||||||
|
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
issues.append("Password must contain at least one digit")
|
||||||
|
|
||||||
|
# Check for special characters
|
||||||
|
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||||
|
if not any(c in special_chars for c in password):
|
||||||
|
issues.append("Password must contain at least one special character")
|
||||||
|
|
||||||
|
return len(issues) == 0, issues
|
||||||
|
|
||||||
|
def sanitize_login_input(login: str) -> str:
|
||||||
|
"""Sanitize login input (email or username)"""
|
||||||
|
return login.strip().lower() if login else ""
|
@ -3,7 +3,7 @@ from typing import Optional, Dict, List, Optional, Any
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, UTC
|
from datetime import datetime, timezone, UTC, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
from models import (
|
from models import (
|
||||||
# User models
|
# User models
|
||||||
@ -14,14 +14,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class _RedisManager:
|
class _RedisManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.redis_client: Optional[redis.Redis] = None
|
self.redis: Optional[redis.Redis] = None
|
||||||
self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379")
|
self.redis_url = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||||
self._connection_pool: Optional[redis.ConnectionPool] = None
|
self._connection_pool: Optional[redis.ConnectionPool] = None
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Initialize Redis connection with connection pooling"""
|
"""Initialize Redis connection with connection pooling"""
|
||||||
if self._is_connected and self.redis_client:
|
if self._is_connected and self.redis:
|
||||||
logger.info("Redis already connected")
|
logger.info("Redis already connected")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -38,26 +38,26 @@ class _RedisManager:
|
|||||||
health_check_interval=30
|
health_check_interval=30
|
||||||
)
|
)
|
||||||
|
|
||||||
self.redis_client = redis.Redis(
|
self.redis = redis.Redis(
|
||||||
connection_pool=self._connection_pool
|
connection_pool=self._connection_pool
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self.redis_client:
|
if not self.redis:
|
||||||
raise RuntimeError("Redis client not initialized")
|
raise RuntimeError("Redis client not initialized")
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
await self.redis_client.ping()
|
await self.redis.ping()
|
||||||
self._is_connected = True
|
self._is_connected = True
|
||||||
logger.info("Successfully connected to Redis")
|
logger.info("Successfully connected to Redis")
|
||||||
|
|
||||||
# Log Redis info
|
# Log Redis info
|
||||||
info = await self.redis_client.info()
|
info = await self.redis.info()
|
||||||
logger.info(f"Redis version: {info.get('redis_version', 'unknown')}")
|
logger.info(f"Redis version: {info.get('redis_version', 'unknown')}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to Redis: {e}")
|
logger.error(f"Failed to connect to Redis: {e}")
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
self.redis_client = None
|
self.redis = None
|
||||||
self._connection_pool = None
|
self._connection_pool = None
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@ -68,12 +68,12 @@ class _RedisManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.redis_client:
|
if self.redis:
|
||||||
# Wait for any pending operations to complete
|
# Wait for any pending operations to complete
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Close the client
|
# Close the client
|
||||||
await self.redis_client.aclose()
|
await self.redis.aclose()
|
||||||
logger.info("Redis client closed")
|
logger.info("Redis client closed")
|
||||||
|
|
||||||
if self._connection_pool:
|
if self._connection_pool:
|
||||||
@ -82,7 +82,7 @@ class _RedisManager:
|
|||||||
logger.info("Redis connection pool closed")
|
logger.info("Redis connection pool closed")
|
||||||
|
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
self.redis_client = None
|
self.redis = None
|
||||||
self._connection_pool = None
|
self._connection_pool = None
|
||||||
|
|
||||||
logger.info("Successfully disconnected from Redis")
|
logger.info("Successfully disconnected from Redis")
|
||||||
@ -91,32 +91,32 @@ class _RedisManager:
|
|||||||
logger.error(f"Error during Redis disconnect: {e}")
|
logger.error(f"Error during Redis disconnect: {e}")
|
||||||
# Force cleanup even if there's an error
|
# Force cleanup even if there's an error
|
||||||
self._is_connected = False
|
self._is_connected = False
|
||||||
self.redis_client = None
|
self.redis = None
|
||||||
self._connection_pool = None
|
self._connection_pool = None
|
||||||
|
|
||||||
def get_client(self) -> redis.Redis:
|
def get_client(self) -> redis.Redis:
|
||||||
"""Get Redis client instance"""
|
"""Get Redis client instance"""
|
||||||
if not self._is_connected or not self.redis_client:
|
if not self._is_connected or not self.redis:
|
||||||
raise RuntimeError("Redis client not initialized or disconnected")
|
raise RuntimeError("Redis client not initialized or disconnected")
|
||||||
return self.redis_client
|
return self.redis
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Check if Redis is connected"""
|
"""Check if Redis is connected"""
|
||||||
return self._is_connected and self.redis_client is not None
|
return self._is_connected and self.redis is not None
|
||||||
|
|
||||||
async def health_check(self) -> dict:
|
async def health_check(self) -> dict:
|
||||||
"""Perform health check on Redis connection"""
|
"""Perform health check on Redis connection"""
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
return {"status": "disconnected", "error": "Redis not connected"}
|
return {"status": "disconnected", "error": "Redis not connected"}
|
||||||
|
|
||||||
if not self.redis_client:
|
if not self.redis:
|
||||||
raise RuntimeError("Redis client not initialized")
|
raise RuntimeError("Redis client not initialized")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Test basic operations
|
# Test basic operations
|
||||||
await self.redis_client.ping()
|
await self.redis.ping()
|
||||||
info = await self.redis_client.info()
|
info = await self.redis.info()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
@ -137,16 +137,16 @@ class _RedisManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.redis_client:
|
if not self.redis:
|
||||||
raise RuntimeError("Redis client not initialized")
|
raise RuntimeError("Redis client not initialized")
|
||||||
|
|
||||||
if background:
|
if background:
|
||||||
# Non-blocking background save
|
# Non-blocking background save
|
||||||
await self.redis_client.bgsave()
|
await self.redis.bgsave()
|
||||||
logger.info("Background save initiated")
|
logger.info("Background save initiated")
|
||||||
else:
|
else:
|
||||||
# Blocking save
|
# Blocking save
|
||||||
await self.redis_client.save()
|
await self.redis.save()
|
||||||
logger.info("Synchronous save completed")
|
logger.info("Synchronous save completed")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -159,19 +159,20 @@ class _RedisManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.redis_client:
|
if not self.redis:
|
||||||
raise RuntimeError("Redis client not initialized")
|
raise RuntimeError("Redis client not initialized")
|
||||||
return await self.redis_client.info()
|
return await self.redis.info()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get Redis info: {e}")
|
logger.error(f"Failed to get Redis info: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class RedisDatabase:
|
class RedisDatabase:
|
||||||
def __init__(self, redis_client: redis.Redis):
|
def __init__(self, redis: redis.Redis):
|
||||||
self.redis_client = redis_client
|
self.redis = redis
|
||||||
|
|
||||||
# Redis key prefixes for different data types
|
# Redis key prefixes for different data types
|
||||||
self.KEY_PREFIXES = {
|
self.KEY_PREFIXES = {
|
||||||
|
'viewers': 'viewer:',
|
||||||
'candidates': 'candidate:',
|
'candidates': 'candidate:',
|
||||||
'employers': 'employer:',
|
'employers': 'employer:',
|
||||||
'jobs': 'job:',
|
'jobs': 'job:',
|
||||||
@ -197,29 +198,67 @@ class RedisDatabase:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error(f"Failed to deserialize data: {data}")
|
logger.error(f"Failed to deserialize data: {data}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Candidates operations
|
# Viewer operations
|
||||||
async def get_candidate(self, candidate_id: str) -> Optional[Dict]:
|
async def get_viewer(self, viewer_id: str) -> Optional[Dict]:
|
||||||
"""Get candidate by ID"""
|
"""Get viewer by ID"""
|
||||||
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
|
key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
|
||||||
data = await self.redis_client.get(key)
|
data = await self.redis.get(key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def set_candidate(self, candidate_id: str, candidate_data: Dict):
|
async def set_viewer(self, viewer_id: str, viewer_data: Dict):
|
||||||
"""Set candidate data"""
|
"""Set viewer data"""
|
||||||
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
|
key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
|
||||||
await self.redis_client.set(key, self._serialize(candidate_data))
|
await self.redis.set(key, self._serialize(viewer_data))
|
||||||
|
|
||||||
async def get_all_candidates(self) -> Dict[str, Any]:
|
async def get_all_viewers(self) -> Dict[str, Any]:
|
||||||
"""Get all candidates"""
|
"""Get all viewers"""
|
||||||
pattern = f"{self.KEY_PREFIXES['candidates']}*"
|
pattern = f"{self.KEY_PREFIXES['viewers']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Use pipeline for efficiency
|
# Use pipeline for efficiency
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
|
for key in keys:
|
||||||
|
pipe.get(key)
|
||||||
|
values = await pipe.execute()
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for key, value in zip(keys, values):
|
||||||
|
viewer_id = key.replace(self.KEY_PREFIXES['viewers'], '')
|
||||||
|
result[viewer_id] = self._deserialize(value)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def delete_viewer(self, viewer_id: str):
|
||||||
|
"""Delete viewer"""
|
||||||
|
key = f"{self.KEY_PREFIXES['viewers']}{viewer_id}"
|
||||||
|
await self.redis.delete(key)
|
||||||
|
|
||||||
|
# Candidates operations
|
||||||
|
async def get_candidate(self, candidate_id: str) -> Optional[Dict]:
|
||||||
|
"""Get candidate by ID"""
|
||||||
|
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
|
async def set_candidate(self, candidate_id: str, candidate_data: Dict):
|
||||||
|
"""Set candidate data"""
|
||||||
|
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
|
||||||
|
await self.redis.set(key, self._serialize(candidate_data))
|
||||||
|
|
||||||
|
async def get_all_candidates(self) -> Dict[str, Any]:
|
||||||
|
"""Get all candidates"""
|
||||||
|
pattern = f"{self.KEY_PREFIXES['candidates']}*"
|
||||||
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Use pipeline for efficiency
|
||||||
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -234,29 +273,29 @@ class RedisDatabase:
|
|||||||
async def delete_candidate(self, candidate_id: str):
|
async def delete_candidate(self, candidate_id: str):
|
||||||
"""Delete candidate"""
|
"""Delete candidate"""
|
||||||
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
|
key = f"{self.KEY_PREFIXES['candidates']}{candidate_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Employers operations
|
# Employers operations
|
||||||
async def get_employer(self, employer_id: str) -> Optional[Dict]:
|
async def get_employer(self, employer_id: str) -> Optional[Dict]:
|
||||||
"""Get employer by ID"""
|
"""Get employer by ID"""
|
||||||
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
|
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
|
||||||
data = await self.redis_client.get(key)
|
data = await self.redis.get(key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def set_employer(self, employer_id: str, employer_data: Dict):
|
async def set_employer(self, employer_id: str, employer_data: Dict):
|
||||||
"""Set employer data"""
|
"""Set employer data"""
|
||||||
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
|
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
|
||||||
await self.redis_client.set(key, self._serialize(employer_data))
|
await self.redis.set(key, self._serialize(employer_data))
|
||||||
|
|
||||||
async def get_all_employers(self) -> Dict[str, Any]:
|
async def get_all_employers(self) -> Dict[str, Any]:
|
||||||
"""Get all employers"""
|
"""Get all employers"""
|
||||||
pattern = f"{self.KEY_PREFIXES['employers']}*"
|
pattern = f"{self.KEY_PREFIXES['employers']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -271,29 +310,29 @@ class RedisDatabase:
|
|||||||
async def delete_employer(self, employer_id: str):
|
async def delete_employer(self, employer_id: str):
|
||||||
"""Delete employer"""
|
"""Delete employer"""
|
||||||
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
|
key = f"{self.KEY_PREFIXES['employers']}{employer_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Jobs operations
|
# Jobs operations
|
||||||
async def get_job(self, job_id: str) -> Optional[Dict]:
|
async def get_job(self, job_id: str) -> Optional[Dict]:
|
||||||
"""Get job by ID"""
|
"""Get job by ID"""
|
||||||
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
||||||
data = await self.redis_client.get(key)
|
data = await self.redis.get(key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def set_job(self, job_id: str, job_data: Dict):
|
async def set_job(self, job_id: str, job_data: Dict):
|
||||||
"""Set job data"""
|
"""Set job data"""
|
||||||
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
||||||
await self.redis_client.set(key, self._serialize(job_data))
|
await self.redis.set(key, self._serialize(job_data))
|
||||||
|
|
||||||
async def get_all_jobs(self) -> Dict[str, Any]:
|
async def get_all_jobs(self) -> Dict[str, Any]:
|
||||||
"""Get all jobs"""
|
"""Get all jobs"""
|
||||||
pattern = f"{self.KEY_PREFIXES['jobs']}*"
|
pattern = f"{self.KEY_PREFIXES['jobs']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -308,29 +347,29 @@ class RedisDatabase:
|
|||||||
async def delete_job(self, job_id: str):
|
async def delete_job(self, job_id: str):
|
||||||
"""Delete job"""
|
"""Delete job"""
|
||||||
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Job Applications operations
|
# Job Applications operations
|
||||||
async def get_job_application(self, application_id: str) -> Optional[Dict]:
|
async def get_job_application(self, application_id: str) -> Optional[Dict]:
|
||||||
"""Get job application by ID"""
|
"""Get job application by ID"""
|
||||||
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
|
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
|
||||||
data = await self.redis_client.get(key)
|
data = await self.redis.get(key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def set_job_application(self, application_id: str, application_data: Dict):
|
async def set_job_application(self, application_id: str, application_data: Dict):
|
||||||
"""Set job application data"""
|
"""Set job application data"""
|
||||||
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
|
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
|
||||||
await self.redis_client.set(key, self._serialize(application_data))
|
await self.redis.set(key, self._serialize(application_data))
|
||||||
|
|
||||||
async def get_all_job_applications(self) -> Dict[str, Any]:
|
async def get_all_job_applications(self) -> Dict[str, Any]:
|
||||||
"""Get all job applications"""
|
"""Get all job applications"""
|
||||||
pattern = f"{self.KEY_PREFIXES['job_applications']}*"
|
pattern = f"{self.KEY_PREFIXES['job_applications']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -345,29 +384,29 @@ class RedisDatabase:
|
|||||||
async def delete_job_application(self, application_id: str):
|
async def delete_job_application(self, application_id: str):
|
||||||
"""Delete job application"""
|
"""Delete job application"""
|
||||||
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
|
key = f"{self.KEY_PREFIXES['job_applications']}{application_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Chat Sessions operations
|
# Chat Sessions operations
|
||||||
async def get_chat_session(self, session_id: str) -> Optional[Dict]:
|
async def get_chat_session(self, session_id: str) -> Optional[Dict]:
|
||||||
"""Get chat session by ID"""
|
"""Get chat session by ID"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
||||||
data = await self.redis_client.get(key)
|
data = await self.redis.get(key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def set_chat_session(self, session_id: str, session_data: Dict):
|
async def set_chat_session(self, session_id: str, session_data: Dict):
|
||||||
"""Set chat session data"""
|
"""Set chat session data"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
||||||
await self.redis_client.set(key, self._serialize(session_data))
|
await self.redis.set(key, self._serialize(session_data))
|
||||||
|
|
||||||
async def get_all_chat_sessions(self) -> Dict[str, Any]:
|
async def get_all_chat_sessions(self) -> Dict[str, Any]:
|
||||||
"""Get all chat sessions"""
|
"""Get all chat sessions"""
|
||||||
pattern = f"{self.KEY_PREFIXES['chat_sessions']}*"
|
pattern = f"{self.KEY_PREFIXES['chat_sessions']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -382,36 +421,36 @@ class RedisDatabase:
|
|||||||
async def delete_chat_session(self, session_id: str):
|
async def delete_chat_session(self, session_id: str):
|
||||||
"""Delete chat session"""
|
"""Delete chat session"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Chat Messages operations (stored as lists)
|
# Chat Messages operations (stored as lists)
|
||||||
async def get_chat_messages(self, session_id: str) -> List[Dict]:
|
async def get_chat_messages(self, session_id: str) -> List[Dict]:
|
||||||
"""Get chat messages for a session"""
|
"""Get chat messages for a session"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
messages = await self.redis_client.lrange(key, 0, -1)
|
messages = await self.redis.lrange(key, 0, -1)
|
||||||
return [self._deserialize(msg) for msg in messages if msg]
|
return [self._deserialize(msg) for msg in messages if msg]
|
||||||
|
|
||||||
async def add_chat_message(self, session_id: str, message_data: Dict):
|
async def add_chat_message(self, session_id: str, message_data: Dict):
|
||||||
"""Add a chat message to a session"""
|
"""Add a chat message to a session"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
await self.redis_client.rpush(key, self._serialize(message_data))
|
await self.redis.rpush(key, self._serialize(message_data))
|
||||||
|
|
||||||
async def set_chat_messages(self, session_id: str, messages: List[Dict]):
|
async def set_chat_messages(self, session_id: str, messages: List[Dict]):
|
||||||
"""Set all chat messages for a session (replaces existing)"""
|
"""Set all chat messages for a session (replaces existing)"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
|
|
||||||
# Clear existing messages
|
# Clear existing messages
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Add new messages
|
# Add new messages
|
||||||
if messages:
|
if messages:
|
||||||
serialized_messages = [self._serialize(msg) for msg in messages]
|
serialized_messages = [self._serialize(msg) for msg in messages]
|
||||||
await self.redis_client.rpush(key, *serialized_messages)
|
await self.redis.rpush(key, *serialized_messages)
|
||||||
|
|
||||||
async def get_all_chat_messages(self) -> Dict[str, List[Dict]]:
|
async def get_all_chat_messages(self) -> Dict[str, List[Dict]]:
|
||||||
"""Get all chat messages grouped by session"""
|
"""Get all chat messages grouped by session"""
|
||||||
pattern = f"{self.KEY_PREFIXES['chat_messages']}*"
|
pattern = f"{self.KEY_PREFIXES['chat_messages']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
@ -419,7 +458,7 @@ class RedisDatabase:
|
|||||||
result = {}
|
result = {}
|
||||||
for key in keys:
|
for key in keys:
|
||||||
session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '')
|
session_id = key.replace(self.KEY_PREFIXES['chat_messages'], '')
|
||||||
messages = await self.redis_client.lrange(key, 0, -1)
|
messages = await self.redis.lrange(key, 0, -1)
|
||||||
result[session_id] = [self._deserialize(msg) for msg in messages if msg]
|
result[session_id] = [self._deserialize(msg) for msg in messages if msg]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@ -427,7 +466,7 @@ class RedisDatabase:
|
|||||||
async def delete_chat_messages(self, session_id: str):
|
async def delete_chat_messages(self, session_id: str):
|
||||||
"""Delete all chat messages for a session"""
|
"""Delete all chat messages for a session"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Enhanced Chat Session Methods
|
# Enhanced Chat Session Methods
|
||||||
async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]:
|
async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]:
|
||||||
@ -474,7 +513,7 @@ class RedisDatabase:
|
|||||||
async def get_chat_message_count(self, session_id: str) -> int:
|
async def get_chat_message_count(self, session_id: str) -> int:
|
||||||
"""Get the total number of messages in a chat session"""
|
"""Get the total number of messages in a chat session"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
return await self.redis_client.llen(key)
|
return await self.redis.llen(key)
|
||||||
|
|
||||||
async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]:
|
async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]:
|
||||||
"""Search for messages containing specific text in a session"""
|
"""Search for messages containing specific text in a session"""
|
||||||
@ -526,7 +565,7 @@ class RedisDatabase:
|
|||||||
async def get_user_by_username(self, username: str) -> Optional[Dict]:
|
async def get_user_by_username(self, username: str) -> Optional[Dict]:
|
||||||
"""Get user by username specifically"""
|
"""Get user by username specifically"""
|
||||||
username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}"
|
username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}"
|
||||||
data = await self.redis_client.get(username_key)
|
data = await self.redis.get(username_key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def find_candidate_by_username(self, username: str) -> Optional[Dict]:
|
async def find_candidate_by_username(self, username: str) -> Optional[Dict]:
|
||||||
@ -576,6 +615,7 @@ class RedisDatabase:
|
|||||||
stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"]
|
stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"]
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]:
|
async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]:
|
||||||
"""Get a summary of chat activity for a specific candidate"""
|
"""Get a summary of chat activity for a specific candidate"""
|
||||||
@ -625,7 +665,7 @@ class RedisDatabase:
|
|||||||
|
|
||||||
async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]):
|
async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]):
|
||||||
"""Bulk update multiple chat sessions"""
|
"""Bulk update multiple chat sessions"""
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
|
|
||||||
for session_id, updates in session_updates.items():
|
for session_id, updates in session_updates.items():
|
||||||
session_data = await self.get_chat_session(session_id)
|
session_data = await self.get_chat_session(session_id)
|
||||||
@ -642,23 +682,23 @@ class RedisDatabase:
|
|||||||
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
|
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
|
||||||
"""Get AI parameters by ID"""
|
"""Get AI parameters by ID"""
|
||||||
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
|
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
|
||||||
data = await self.redis_client.get(key)
|
data = await self.redis.get(key)
|
||||||
return self._deserialize(data) if data else None
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
async def set_ai_parameters(self, param_id: str, param_data: Dict):
|
async def set_ai_parameters(self, param_id: str, param_data: Dict):
|
||||||
"""Set AI parameters data"""
|
"""Set AI parameters data"""
|
||||||
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
|
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
|
||||||
await self.redis_client.set(key, self._serialize(param_data))
|
await self.redis.set(key, self._serialize(param_data))
|
||||||
|
|
||||||
async def get_all_ai_parameters(self) -> Dict[str, Any]:
|
async def get_all_ai_parameters(self) -> Dict[str, Any]:
|
||||||
"""Get all AI parameters"""
|
"""Get all AI parameters"""
|
||||||
pattern = f"{self.KEY_PREFIXES['ai_parameters']}*"
|
pattern = f"{self.KEY_PREFIXES['ai_parameters']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -673,37 +713,18 @@ class RedisDatabase:
|
|||||||
async def delete_ai_parameters(self, param_id: str):
|
async def delete_ai_parameters(self, param_id: str):
|
||||||
"""Delete AI parameters"""
|
"""Delete AI parameters"""
|
||||||
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
|
key = f"{self.KEY_PREFIXES['ai_parameters']}{param_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Users operations (for auth)
|
|
||||||
async def get_user(self, login: str) -> Optional[Dict]:
|
|
||||||
"""Get user by email or username"""
|
|
||||||
if '@' in login:
|
|
||||||
email = login.lower()
|
|
||||||
key = f"{self.KEY_PREFIXES['users']}{email}"
|
|
||||||
else:
|
|
||||||
username = login.lower()
|
|
||||||
key = f"{self.KEY_PREFIXES['users']}{username}"
|
|
||||||
data = await self.redis_client.get(key)
|
|
||||||
return self._deserialize(data) if data else None
|
|
||||||
|
|
||||||
async def set_user(self, user: BaseUser, user_data: Dict):
|
|
||||||
"""Set user data"""
|
|
||||||
email_key = f"{self.KEY_PREFIXES['users']}{user.email.lower()}"
|
|
||||||
username_key = f"{self.KEY_PREFIXES['users']}{user.username.lower()}"
|
|
||||||
serialized_data = self._serialize(user_data)
|
|
||||||
await self.redis_client.set(email_key, serialized_data)
|
|
||||||
await self.redis_client.set(username_key, serialized_data)
|
|
||||||
|
|
||||||
async def get_all_users(self) -> Dict[str, Any]:
|
async def get_all_users(self) -> Dict[str, Any]:
|
||||||
"""Get all users"""
|
"""Get all users"""
|
||||||
pattern = f"{self.KEY_PREFIXES['users']}*"
|
pattern = f"{self.KEY_PREFIXES['users']}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
|
|
||||||
if not keys:
|
if not keys:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
pipe = self.redis_client.pipeline()
|
pipe = self.redis.pipeline()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
pipe.get(key)
|
pipe.get(key)
|
||||||
values = await pipe.execute()
|
values = await pipe.execute()
|
||||||
@ -718,32 +739,334 @@ class RedisDatabase:
|
|||||||
async def delete_user(self, email: str):
|
async def delete_user(self, email: str):
|
||||||
"""Delete user"""
|
"""Delete user"""
|
||||||
key = f"{self.KEY_PREFIXES['users']}{email}"
|
key = f"{self.KEY_PREFIXES['users']}{email}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis.delete(key)
|
||||||
|
|
||||||
# Utility methods
|
# Utility methods
|
||||||
async def clear_all_data(self):
|
async def clear_all_data(self):
|
||||||
"""Clear all data from Redis (use with caution!)"""
|
"""Clear all data from Redis (use with caution!)"""
|
||||||
for prefix in self.KEY_PREFIXES.values():
|
for prefix in self.KEY_PREFIXES.values():
|
||||||
pattern = f"{prefix}*"
|
pattern = f"{prefix}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
if keys:
|
if keys:
|
||||||
await self.redis_client.delete(*keys)
|
await self.redis.delete(*keys)
|
||||||
|
|
||||||
async def get_stats(self) -> Dict[str, int]:
|
async def get_stats(self) -> Dict[str, int]:
|
||||||
"""Get statistics about stored data"""
|
"""Get statistics about stored data"""
|
||||||
stats = {}
|
stats = {}
|
||||||
for data_type, prefix in self.KEY_PREFIXES.items():
|
for data_type, prefix in self.KEY_PREFIXES.items():
|
||||||
pattern = f"{prefix}*"
|
pattern = f"{prefix}*"
|
||||||
keys = await self.redis_client.keys(pattern)
|
keys = await self.redis.keys(pattern)
|
||||||
stats[data_type] = len(keys)
|
stats[data_type] = len(keys)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
# Authentication Record Methods
|
||||||
|
async def set_authentication(self, user_id: str, auth_data: Dict[str, Any]) -> bool:
|
||||||
|
"""Store authentication record for a user"""
|
||||||
|
try:
|
||||||
|
key = f"auth:{user_id}"
|
||||||
|
await self.redis.set(key, json.dumps(auth_data, default=str))
|
||||||
|
logger.debug(f"🔐 Stored authentication record for user {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error storing authentication record for {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_authentication(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Retrieve authentication record for a user"""
|
||||||
|
try:
|
||||||
|
key = f"auth:{user_id}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving authentication record for {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_authentication(self, user_id: str) -> bool:
|
||||||
|
"""Delete authentication record for a user"""
|
||||||
|
try:
|
||||||
|
key = f"auth:{user_id}"
|
||||||
|
result = await self.redis.delete(key)
|
||||||
|
logger.debug(f"🔐 Deleted authentication record for user {user_id}")
|
||||||
|
return result > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error deleting authentication record for {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Enhanced User Methods
|
||||||
|
async def set_user_by_id(self, user_id: str, user_data: Dict[str, Any]) -> bool:
|
||||||
|
"""Store user data with ID as key for direct lookup"""
|
||||||
|
try:
|
||||||
|
key = f"user_by_id:{user_id}"
|
||||||
|
await self.redis.set(key, json.dumps(user_data, default=str))
|
||||||
|
logger.debug(f"👤 Stored user data by ID for {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error storing user by ID {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Retrieve user data by ID"""
|
||||||
|
try:
|
||||||
|
key = f"user_by_id:{user_id}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving user by ID {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def user_exists_by_email(self, email: str) -> bool:
|
||||||
|
"""Check if a user exists with the given email"""
|
||||||
|
try:
|
||||||
|
key = f"users:{email.lower()}"
|
||||||
|
exists = await self.redis.exists(key)
|
||||||
|
return exists > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error checking user existence by email {email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def user_exists_by_username(self, username: str) -> bool:
|
||||||
|
"""Check if a user exists with the given username"""
|
||||||
|
try:
|
||||||
|
key = f"users:{username.lower()}"
|
||||||
|
exists = await self.redis.exists(key)
|
||||||
|
return exists > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error checking user existence by username {username}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Enhanced user lookup method to support both email and username
|
||||||
|
async def get_user(self, login: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get user by email or username
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Normalize the login string
|
||||||
|
login = login.strip().lower()
|
||||||
|
key = f"users:{login}"
|
||||||
|
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
if data:
|
||||||
|
user_data = json.loads(data)
|
||||||
|
logger.debug(f"👤 Retrieved user data for {login}")
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
logger.debug(f"👤 No user found for {login}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving user {login}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_user(self, login: str, user_data: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Enhanced method to store user data by email or username
|
||||||
|
Updated version of your existing method
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Normalize the login string
|
||||||
|
login = login.strip().lower()
|
||||||
|
key = f"users:{login}"
|
||||||
|
|
||||||
|
await self.redis.set(key, json.dumps(user_data, default=str))
|
||||||
|
logger.debug(f"👤 Stored user data for {login}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error storing user {login}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Token Management Methods
|
||||||
|
async def store_refresh_token(self, user_id: str, token: str, expires_at: datetime, device_info: Dict[str, str]) -> bool:
|
||||||
|
"""Store refresh token for a user"""
|
||||||
|
try:
|
||||||
|
key = f"refresh_token:{token}"
|
||||||
|
token_data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"device": device_info.get("device", "unknown"),
|
||||||
|
"ip_address": device_info.get("ip_address", "unknown"),
|
||||||
|
"is_revoked": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store with expiration
|
||||||
|
ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds())
|
||||||
|
if ttl_seconds > 0:
|
||||||
|
await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str))
|
||||||
|
logger.debug(f"🔐 Stored refresh token for user {user_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Attempted to store expired refresh token for user {user_id}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error storing refresh token for {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_refresh_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Retrieve refresh token data"""
|
||||||
|
try:
|
||||||
|
key = f"refresh_token:{token}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving refresh token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def revoke_refresh_token(self, token: str) -> bool:
|
||||||
|
"""Revoke a refresh token"""
|
||||||
|
try:
|
||||||
|
key = f"refresh_token:{token}"
|
||||||
|
token_data = await self.get_refresh_token(token)
|
||||||
|
if token_data:
|
||||||
|
token_data["is_revoked"] = True
|
||||||
|
token_data["revoked_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await self.redis.set(key, json.dumps(token_data, default=str))
|
||||||
|
logger.debug(f"🔐 Revoked refresh token")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error revoking refresh token: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def revoke_all_user_tokens(self, user_id: str) -> bool:
|
||||||
|
"""Revoke all refresh tokens for a user"""
|
||||||
|
try:
|
||||||
|
# This requires scanning all refresh tokens - consider using a user token index for efficiency
|
||||||
|
pattern = "refresh_token:*"
|
||||||
|
cursor = 0
|
||||||
|
revoked_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
token_data = await self.redis.get(key)
|
||||||
|
if token_data:
|
||||||
|
token_info = json.loads(token_data)
|
||||||
|
if token_info.get("user_id") == user_id and not token_info.get("is_revoked"):
|
||||||
|
token_info["is_revoked"] = True
|
||||||
|
token_info["revoked_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await self.redis.set(key, json.dumps(token_info, default=str))
|
||||||
|
revoked_count += 1
|
||||||
|
|
||||||
|
if cursor == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"🔐 Revoked {revoked_count} refresh tokens for user {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error revoking all tokens for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Password Reset Token Methods
|
||||||
|
async def store_password_reset_token(self, email: str, token: str, expires_at: datetime) -> bool:
|
||||||
|
"""Store password reset token"""
|
||||||
|
try:
|
||||||
|
key = f"password_reset:{token}"
|
||||||
|
token_data = {
|
||||||
|
"email": email.lower(),
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"used": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store with expiration
|
||||||
|
ttl_seconds = int((expires_at - datetime.now(timezone.utc)).total_seconds())
|
||||||
|
if ttl_seconds > 0:
|
||||||
|
await self.redis.setex(key, ttl_seconds, json.dumps(token_data, default=str))
|
||||||
|
logger.debug(f"🔐 Stored password reset token for {email}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Attempted to store expired password reset token for {email}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error storing password reset token for {email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_password_reset_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Retrieve password reset token data"""
|
||||||
|
try:
|
||||||
|
key = f"password_reset:{token}"
|
||||||
|
data = await self.redis.get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving password reset token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def mark_password_reset_token_used(self, token: str) -> bool:
|
||||||
|
"""Mark password reset token as used"""
|
||||||
|
try:
|
||||||
|
key = f"password_reset:{token}"
|
||||||
|
token_data = await self.get_password_reset_token(token)
|
||||||
|
if token_data:
|
||||||
|
token_data["used"] = True
|
||||||
|
token_data["used_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await self.redis.set(key, json.dumps(token_data, default=str))
|
||||||
|
logger.debug(f"🔐 Marked password reset token as used")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error marking password reset token as used: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# User Activity and Security Logging
|
||||||
|
async def log_security_event(self, user_id: str, event_type: str, details: Dict[str, Any]) -> bool:
|
||||||
|
"""Log security events for audit purposes"""
|
||||||
|
try:
|
||||||
|
key = f"security_log:{user_id}:{datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
||||||
|
event_data = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"user_id": user_id,
|
||||||
|
"event_type": event_type,
|
||||||
|
"details": details
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add to list (latest events first)
|
||||||
|
await self.redis.lpush(key, json.dumps(event_data, default=str))
|
||||||
|
|
||||||
|
# Keep only last 100 events per day
|
||||||
|
await self.redis.ltrim(key, 0, 99)
|
||||||
|
|
||||||
|
# Set expiration for 30 days
|
||||||
|
await self.redis.expire(key, 30 * 24 * 60 * 60)
|
||||||
|
|
||||||
|
logger.debug(f"🔒 Logged security event {event_type} for user {user_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error logging security event for {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_user_security_log(self, user_id: str, days: int = 7) -> List[Dict[str, Any]]:
|
||||||
|
"""Retrieve security log for a user"""
|
||||||
|
try:
|
||||||
|
events = []
|
||||||
|
for i in range(days):
|
||||||
|
date = (datetime.now(timezone.utc) - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||||
|
key = f"security_log:{user_id}:{date}"
|
||||||
|
|
||||||
|
daily_events = await self.redis.lrange(key, 0, -1)
|
||||||
|
for event_json in daily_events:
|
||||||
|
events.append(json.loads(event_json))
|
||||||
|
|
||||||
|
# Sort by timestamp (most recent first)
|
||||||
|
events.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||||
|
return events
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error retrieving security log for {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# Global Redis manager instance
|
# Global Redis manager instance
|
||||||
redis_manager = _RedisManager()
|
redis_manager = _RedisManager()
|
||||||
|
|
||||||
class DatabaseManager:
|
class DatabaseManager:
|
||||||
"""Enhanced database manager with graceful shutdown capabilities"""
|
"""Enhanced database manager with graceful shutdown capabilities"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db: Optional[RedisDatabase] = None
|
self.db: Optional[RedisDatabase] = None
|
||||||
self._shutdown_initiated = False
|
self._shutdown_initiated = False
|
||||||
@ -762,9 +1085,9 @@ class DatabaseManager:
|
|||||||
self.db = RedisDatabase(redis_manager.get_client())
|
self.db = RedisDatabase(redis_manager.get_client())
|
||||||
|
|
||||||
# Test connection and log stats
|
# Test connection and log stats
|
||||||
if not redis_manager.redis_client:
|
if not redis_manager.redis:
|
||||||
raise RuntimeError("Redis client not initialized")
|
raise RuntimeError("Redis client not initialized")
|
||||||
await redis_manager.redis_client.ping()
|
await redis_manager.redis.ping()
|
||||||
stats = await self.db.get_stats()
|
stats = await self.db.get_stats()
|
||||||
logger.info(f"Database initialized successfully. Stats: {stats}")
|
logger.info(f"Database initialized successfully. Stats: {stats}")
|
||||||
|
|
||||||
@ -823,10 +1146,10 @@ class DatabaseManager:
|
|||||||
|
|
||||||
# Force Redis to save data to disk
|
# Force Redis to save data to disk
|
||||||
try:
|
try:
|
||||||
if redis_manager.redis_client:
|
if redis_manager.redis:
|
||||||
# Try BGSAVE first (non-blocking)
|
# Try BGSAVE first (non-blocking)
|
||||||
try:
|
try:
|
||||||
await redis_manager.redis_client.bgsave()
|
await redis_manager.redis.bgsave()
|
||||||
logger.info("Background save initiated")
|
logger.info("Background save initiated")
|
||||||
|
|
||||||
# Wait a bit for background save to start
|
# Wait a bit for background save to start
|
||||||
@ -836,7 +1159,7 @@ class DatabaseManager:
|
|||||||
logger.warning(f"Background save failed, trying synchronous save: {e}")
|
logger.warning(f"Background save failed, trying synchronous save: {e}")
|
||||||
try:
|
try:
|
||||||
# Fallback to synchronous save
|
# Fallback to synchronous save
|
||||||
await redis_manager.redis_client.save()
|
await redis_manager.redis.save()
|
||||||
logger.info("Synchronous save completed")
|
logger.info("Synchronous save completed")
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.warning(f"Synchronous save also failed (Redis persistence may be disabled): {e2}")
|
logger.warning(f"Synchronous save also failed (Redis persistence may be disabled): {e2}")
|
||||||
@ -872,4 +1195,5 @@ class DatabaseManager:
|
|||||||
raise RuntimeError("Database not initialized")
|
raise RuntimeError("Database not initialized")
|
||||||
if self._shutdown_initiated:
|
if self._shutdown_initiated:
|
||||||
raise RuntimeError("Application is shutting down")
|
raise RuntimeError("Application is shutting down")
|
||||||
return self.db
|
return self.db
|
||||||
|
|
@ -41,6 +41,9 @@ def test_model_creation():
|
|||||||
|
|
||||||
# Create employer
|
# Create employer
|
||||||
employer = Employer(
|
employer = Employer(
|
||||||
|
firstName="Mary",
|
||||||
|
lastName="Smith",
|
||||||
|
fullName="Mary Smith",
|
||||||
email="hr@company.com",
|
email="hr@company.com",
|
||||||
username="test_employer",
|
username="test_employer",
|
||||||
createdAt=datetime.now(),
|
createdAt=datetime.now(),
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""
|
"""
|
||||||
Enhanced Type Generator - Generate TypeScript types from Pydantic models
|
Enhanced Type Generator - Generate TypeScript types from Pydantic models
|
||||||
Now with command line parameters, pre-test validation, and TypeScript compilation
|
Now with command line parameters, pre-test validation, TypeScript compilation,
|
||||||
|
and automatic date field conversion functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@ -84,6 +85,59 @@ def unwrap_annotated_type(python_type: Any) -> Any:
|
|||||||
|
|
||||||
return python_type
|
return python_type
|
||||||
|
|
||||||
|
def is_date_type(python_type: Any) -> bool:
|
||||||
|
"""Check if a Python type represents a date/datetime"""
|
||||||
|
# Unwrap any annotations first
|
||||||
|
python_type = unwrap_annotated_type(python_type)
|
||||||
|
|
||||||
|
# Handle Union types (like Optional[datetime])
|
||||||
|
origin = get_origin(python_type)
|
||||||
|
if origin is Union:
|
||||||
|
args = get_args(python_type)
|
||||||
|
# Check if any of the union args is a date type (excluding None)
|
||||||
|
return any(is_date_type(arg) for arg in args if arg is not type(None))
|
||||||
|
|
||||||
|
# Direct datetime check
|
||||||
|
if python_type == datetime:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if it's a datetime type from the datetime module
|
||||||
|
if hasattr(python_type, '__module__') and hasattr(python_type, '__name__'):
|
||||||
|
module_name = getattr(python_type, '__module__', '')
|
||||||
|
type_name = getattr(python_type, '__name__', '')
|
||||||
|
|
||||||
|
# Check for datetime module types
|
||||||
|
if module_name == 'datetime' and type_name in ('datetime', 'date', 'time'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# String representation checks for specific datetime patterns (more restrictive)
|
||||||
|
type_str = str(python_type)
|
||||||
|
|
||||||
|
# Be very specific about datetime patterns to avoid false positives
|
||||||
|
specific_date_patterns = [
|
||||||
|
'datetime.datetime',
|
||||||
|
'datetime.date',
|
||||||
|
'datetime.time',
|
||||||
|
'<class \'datetime.datetime\'>',
|
||||||
|
'<class \'datetime.date\'>',
|
||||||
|
'<class \'datetime.time\'>',
|
||||||
|
'typing.DateTime',
|
||||||
|
'pydantic.datetime',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check for exact matches or specific patterns
|
||||||
|
for pattern in specific_date_patterns:
|
||||||
|
if pattern in type_str:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Additional check for common datetime type aliases
|
||||||
|
if hasattr(python_type, '__origin__'):
|
||||||
|
origin_str = str(python_type.__origin__)
|
||||||
|
if 'datetime' in origin_str and 'datetime.' in origin_str:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def python_type_to_typescript(python_type: Any, debug: bool = False) -> str:
|
def python_type_to_typescript(python_type: Any, debug: bool = False) -> str:
|
||||||
"""Convert a Python type to TypeScript type string"""
|
"""Convert a Python type to TypeScript type string"""
|
||||||
|
|
||||||
@ -302,6 +356,7 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
|
|||||||
"""Process a Pydantic model and return TypeScript interface definition"""
|
"""Process a Pydantic model and return TypeScript interface definition"""
|
||||||
interface_name = model_class.__name__
|
interface_name = model_class.__name__
|
||||||
properties = []
|
properties = []
|
||||||
|
date_fields = [] # Track date fields for conversion functions
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
print(f" 🔍 Processing model: {interface_name}")
|
print(f" 🔍 Processing model: {interface_name}")
|
||||||
@ -327,6 +382,22 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
|
|||||||
if debug:
|
if debug:
|
||||||
print(f" Raw type: {field_type}")
|
print(f" Raw type: {field_type}")
|
||||||
|
|
||||||
|
# Check if this is a date field
|
||||||
|
is_date = is_date_type(field_type)
|
||||||
|
if debug:
|
||||||
|
print(f" 📅 Date type check for {ts_name}: {is_date} (type: {field_type})")
|
||||||
|
|
||||||
|
if is_date:
|
||||||
|
is_optional = is_field_optional(field_info, field_type, debug)
|
||||||
|
date_fields.append({
|
||||||
|
'name': ts_name,
|
||||||
|
'optional': is_optional
|
||||||
|
})
|
||||||
|
if debug:
|
||||||
|
print(f" 🗓️ Date field detected: {ts_name} (optional: {is_optional})")
|
||||||
|
elif debug and ('date' in str(field_type).lower() or 'time' in str(field_type).lower()):
|
||||||
|
print(f" ⚠️ Field {ts_name} contains 'date'/'time' but not detected as date type: {field_type}")
|
||||||
|
|
||||||
ts_type = python_type_to_typescript(field_type, debug)
|
ts_type = python_type_to_typescript(field_type, debug)
|
||||||
|
|
||||||
# Check if optional
|
# Check if optional
|
||||||
@ -362,6 +433,22 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
|
|||||||
if debug:
|
if debug:
|
||||||
print(f" Raw type: {field_type}")
|
print(f" Raw type: {field_type}")
|
||||||
|
|
||||||
|
# Check if this is a date field
|
||||||
|
is_date = is_date_type(field_type)
|
||||||
|
if debug:
|
||||||
|
print(f" 📅 Date type check for {ts_name}: {is_date} (type: {field_type})")
|
||||||
|
|
||||||
|
if is_date:
|
||||||
|
is_optional = is_field_optional(field_info, field_type)
|
||||||
|
date_fields.append({
|
||||||
|
'name': ts_name,
|
||||||
|
'optional': is_optional
|
||||||
|
})
|
||||||
|
if debug:
|
||||||
|
print(f" 🗓️ Date field detected: {ts_name} (optional: {is_optional})")
|
||||||
|
elif debug and ('date' in str(field_type).lower() or 'time' in str(field_type).lower()):
|
||||||
|
print(f" ⚠️ Field {ts_name} contains 'date'/'time' but not detected as date type: {field_type}")
|
||||||
|
|
||||||
ts_type = python_type_to_typescript(field_type, debug)
|
ts_type = python_type_to_typescript(field_type, debug)
|
||||||
|
|
||||||
# For Pydantic v1, check required and default
|
# For Pydantic v1, check required and default
|
||||||
@ -396,7 +483,8 @@ def process_pydantic_model(model_class, debug: bool = False) -> Dict[str, Any]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'name': interface_name,
|
'name': interface_name,
|
||||||
'properties': properties
|
'properties': properties,
|
||||||
|
'date_fields': date_fields
|
||||||
}
|
}
|
||||||
|
|
||||||
def process_enum(enum_class) -> Dict[str, Any]:
|
def process_enum(enum_class) -> Dict[str, Any]:
|
||||||
@ -410,6 +498,106 @@ def process_enum(enum_class) -> Dict[str, Any]:
|
|||||||
'values': " | ".join(values)
|
'values': " | ".join(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def generate_conversion_functions(interfaces: List[Dict[str, Any]]) -> str:
|
||||||
|
"""Generate TypeScript conversion functions for models with date fields"""
|
||||||
|
conversion_functions = []
|
||||||
|
|
||||||
|
for interface in interfaces:
|
||||||
|
interface_name = interface['name']
|
||||||
|
date_fields = interface.get('date_fields', [])
|
||||||
|
|
||||||
|
if not date_fields:
|
||||||
|
continue # Skip interfaces without date fields
|
||||||
|
|
||||||
|
function_name = f"convert{interface_name}FromApi"
|
||||||
|
|
||||||
|
# Generate function
|
||||||
|
func_lines = [
|
||||||
|
f"/**",
|
||||||
|
f" * Convert {interface_name} from API response, parsing date fields",
|
||||||
|
f" * Date fields: {', '.join([f['name'] for f in date_fields])}",
|
||||||
|
f" */",
|
||||||
|
f"export function {function_name}(data: any): {interface_name} {{",
|
||||||
|
f" if (!data) return data;",
|
||||||
|
f" ",
|
||||||
|
f" return {{",
|
||||||
|
f" ...data,"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add date field conversions with validation
|
||||||
|
for date_field in date_fields:
|
||||||
|
field_name = date_field['name']
|
||||||
|
is_optional = date_field['optional']
|
||||||
|
|
||||||
|
# Add a comment for clarity
|
||||||
|
func_lines.append(f" // Convert {field_name} from ISO string to Date")
|
||||||
|
|
||||||
|
if is_optional:
|
||||||
|
func_lines.append(f" {field_name}: data.{field_name} ? new Date(data.{field_name}) : undefined,")
|
||||||
|
else:
|
||||||
|
func_lines.append(f" {field_name}: new Date(data.{field_name}),")
|
||||||
|
|
||||||
|
func_lines.extend([
|
||||||
|
f" }};",
|
||||||
|
f"}}"
|
||||||
|
])
|
||||||
|
|
||||||
|
conversion_functions.append('\n'.join(func_lines))
|
||||||
|
|
||||||
|
if not conversion_functions:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Generate the conversion functions section
|
||||||
|
result = [
|
||||||
|
"// ============================",
|
||||||
|
"// Date Conversion Functions",
|
||||||
|
"// ============================",
|
||||||
|
"",
|
||||||
|
"// These functions convert API responses to properly typed objects",
|
||||||
|
"// with Date objects instead of ISO date strings",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
result.extend(conversion_functions)
|
||||||
|
result.append("")
|
||||||
|
|
||||||
|
# Generate a generic converter function
|
||||||
|
models_with_dates = [interface['name'] for interface in interfaces if interface.get('date_fields')]
|
||||||
|
|
||||||
|
if models_with_dates:
|
||||||
|
result.extend([
|
||||||
|
"/**",
|
||||||
|
" * Generic converter that automatically selects the right conversion function",
|
||||||
|
" * based on the model type",
|
||||||
|
" */",
|
||||||
|
"export function convertFromApi<T>(data: any, modelType: string): T {",
|
||||||
|
" if (!data) return data;",
|
||||||
|
" ",
|
||||||
|
" switch (modelType) {"
|
||||||
|
])
|
||||||
|
|
||||||
|
for model_name in models_with_dates:
|
||||||
|
result.append(f" case '{model_name}':")
|
||||||
|
result.append(f" return convert{model_name}FromApi(data) as T;")
|
||||||
|
|
||||||
|
result.extend([
|
||||||
|
" default:",
|
||||||
|
" return data as T;",
|
||||||
|
" }",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"/**",
|
||||||
|
" * Convert array of items using the appropriate converter",
|
||||||
|
" */",
|
||||||
|
"export function convertArrayFromApi<T>(data: any[], modelType: string): T[] {",
|
||||||
|
" if (!data || !Array.isArray(data)) return data;",
|
||||||
|
" return data.map(item => convertFromApi<T>(item, modelType));",
|
||||||
|
"}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
return '\n'.join(result)
|
||||||
|
|
||||||
def generate_typescript_interfaces(source_file: str, debug: bool = False):
|
def generate_typescript_interfaces(source_file: str, debug: bool = False):
|
||||||
"""Generate TypeScript interfaces from models"""
|
"""Generate TypeScript interfaces from models"""
|
||||||
|
|
||||||
@ -449,7 +637,8 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
|
|||||||
|
|
||||||
interface = process_pydantic_model(obj, debug)
|
interface = process_pydantic_model(obj, debug)
|
||||||
interfaces.append(interface)
|
interfaces.append(interface)
|
||||||
print(f" ✅ Found Pydantic model: {name}")
|
date_count = len(interface.get('date_fields', []))
|
||||||
|
print(f" ✅ Found Pydantic model: {name}" + (f" ({date_count} date fields)" if date_count > 0 else ""))
|
||||||
|
|
||||||
# Check if it's an Enum
|
# Check if it's an Enum
|
||||||
elif (isinstance(obj, type) and
|
elif (isinstance(obj, type) and
|
||||||
@ -466,7 +655,9 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
total_date_fields = sum(len(interface.get('date_fields', [])) for interface in interfaces)
|
||||||
print(f"\n📊 Found {len(interfaces)} interfaces and {len(enums)} enums")
|
print(f"\n📊 Found {len(interfaces)} interfaces and {len(enums)} enums")
|
||||||
|
print(f"🗓️ Found {total_date_fields} date fields across all models")
|
||||||
|
|
||||||
# Generate TypeScript content
|
# Generate TypeScript content
|
||||||
ts_content = f"""// Generated TypeScript types from Pydantic models
|
ts_content = f"""// Generated TypeScript types from Pydantic models
|
||||||
@ -500,8 +691,13 @@ def generate_typescript_interfaces(source_file: str, debug: bool = False):
|
|||||||
|
|
||||||
ts_content += "}\n\n"
|
ts_content += "}\n\n"
|
||||||
|
|
||||||
|
# Add conversion functions
|
||||||
|
conversion_functions = generate_conversion_functions(interfaces)
|
||||||
|
if conversion_functions:
|
||||||
|
ts_content += conversion_functions
|
||||||
|
|
||||||
# Add user union type if we have user types
|
# Add user union type if we have user types
|
||||||
user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer']]
|
user_interfaces = [i for i in interfaces if i['name'] in ['Candidate', 'Employer', 'Viewer']]
|
||||||
if len(user_interfaces) >= 2:
|
if len(user_interfaces) >= 2:
|
||||||
ts_content += "// ============================\n"
|
ts_content += "// ============================\n"
|
||||||
ts_content += "// Union Types\n"
|
ts_content += "// Union Types\n"
|
||||||
@ -534,7 +730,7 @@ def compile_typescript(ts_file: str) -> bool:
|
|||||||
def main():
|
def main():
|
||||||
"""Main function with command line argument parsing"""
|
"""Main function with command line argument parsing"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Generate TypeScript types from Pydantic models',
|
description='Generate TypeScript types from Pydantic models with date conversion functions',
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
@ -544,6 +740,10 @@ Examples:
|
|||||||
python generate_types.py --skip-compile # Skip TS compilation
|
python generate_types.py --skip-compile # Skip TS compilation
|
||||||
python generate_types.py --debug # Enable debug output
|
python generate_types.py --debug # Enable debug output
|
||||||
python generate_types.py --source models.py --output types.ts --skip-test --skip-compile --debug
|
python generate_types.py --source models.py --output types.ts --skip-test --skip-compile --debug
|
||||||
|
|
||||||
|
Generated conversion functions can be used like:
|
||||||
|
const candidate = convertCandidateFromApi(apiResponse);
|
||||||
|
const jobs = convertArrayFromApi<Job>(apiResponse, 'Job');
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -580,13 +780,13 @@ Examples:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--version', '-v',
|
'--version', '-v',
|
||||||
action='version',
|
action='version',
|
||||||
version='TypeScript Generator 2.0'
|
version='TypeScript Generator 3.0 (with Date Conversion)'
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("🚀 Enhanced TypeScript Type Generator")
|
print("🚀 Enhanced TypeScript Type Generator with Date Conversion")
|
||||||
print("=" * 50)
|
print("=" * 60)
|
||||||
print(f"📁 Source file: {args.source}")
|
print(f"📁 Source file: {args.source}")
|
||||||
print(f"📁 Output file: {args.output}")
|
print(f"📁 Output file: {args.output}")
|
||||||
print()
|
print()
|
||||||
@ -608,7 +808,7 @@ Examples:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
# Step 3: Generate TypeScript content
|
# Step 3: Generate TypeScript content
|
||||||
print("🔄 Generating TypeScript types...")
|
print("🔄 Generating TypeScript types and conversion functions...")
|
||||||
if args.debug:
|
if args.debug:
|
||||||
print("🐛 Debug mode enabled - detailed output follows:")
|
print("🐛 Debug mode enabled - detailed output follows:")
|
||||||
print()
|
print()
|
||||||
@ -628,6 +828,28 @@ Examples:
|
|||||||
file_size = len(ts_content)
|
file_size = len(ts_content)
|
||||||
print(f"✅ TypeScript types generated: {args.output} ({file_size} characters)")
|
print(f"✅ TypeScript types generated: {args.output} ({file_size} characters)")
|
||||||
|
|
||||||
|
# Count conversion functions and provide detailed feedback
|
||||||
|
conversion_count = ts_content.count('export function convert') - ts_content.count('convertFromApi') - ts_content.count('convertArrayFromApi')
|
||||||
|
if conversion_count > 0:
|
||||||
|
print(f"🗓️ Generated {conversion_count} date conversion functions")
|
||||||
|
if args.debug:
|
||||||
|
# Show which models have date conversion
|
||||||
|
models_with_dates = []
|
||||||
|
for line in ts_content.split('\n'):
|
||||||
|
if line.startswith('export function convert') and 'FromApi' in line and 'convertFromApi' not in line:
|
||||||
|
model_name = line.split('convert')[1].split('FromApi')[0]
|
||||||
|
models_with_dates.append(model_name)
|
||||||
|
if models_with_dates:
|
||||||
|
print(f" Models with date conversion: {', '.join(models_with_dates)}")
|
||||||
|
|
||||||
|
# Provide troubleshooting info if debug mode
|
||||||
|
if args.debug:
|
||||||
|
print(f"\n🐛 Debug mode was enabled. If you see incorrect date conversions:")
|
||||||
|
print(f" 1. Check the debug output above for '📅 Date type check' lines")
|
||||||
|
print(f" 2. Look for '⚠️' warnings about false positives")
|
||||||
|
print(f" 3. Verify your Pydantic model field types are correct")
|
||||||
|
print(f" 4. Re-run with --debug to see detailed type analysis")
|
||||||
|
|
||||||
# Step 5: Compile TypeScript (unless skipped)
|
# Step 5: Compile TypeScript (unless skipped)
|
||||||
if not args.skip_compile:
|
if not args.skip_compile:
|
||||||
print()
|
print()
|
||||||
@ -639,15 +861,21 @@ Examples:
|
|||||||
|
|
||||||
# Step 6: Success summary
|
# Step 6: Success summary
|
||||||
print(f"\n🎉 Type generation completed successfully!")
|
print(f"\n🎉 Type generation completed successfully!")
|
||||||
print("=" * 50)
|
print("=" * 60)
|
||||||
print(f"✅ Generated {args.output} from {args.source}")
|
print(f"✅ Generated {args.output} from {args.source}")
|
||||||
print(f"✅ File size: {file_size} characters")
|
print(f"✅ File size: {file_size} characters")
|
||||||
|
if conversion_count > 0:
|
||||||
|
print(f"✅ Date conversion functions: {conversion_count}")
|
||||||
if not args.skip_test:
|
if not args.skip_test:
|
||||||
print("✅ Model validation passed")
|
print("✅ Model validation passed")
|
||||||
if not args.skip_compile:
|
if not args.skip_compile:
|
||||||
print("✅ TypeScript syntax validated")
|
print("✅ TypeScript syntax validated")
|
||||||
|
|
||||||
print(f"\n💡 Usage in your TypeScript project:")
|
print(f"\n💡 Usage in your TypeScript project:")
|
||||||
print(f" import {{ Candidate, Employer, Job }} from './{Path(args.output).stem}';")
|
print(f" import {{ Candidate, Employer, Job, convertCandidateFromApi }} from './{Path(args.output).stem}';")
|
||||||
|
if conversion_count > 0:
|
||||||
|
print(f" const candidate = convertCandidateFromApi(apiResponse);")
|
||||||
|
print(f" const jobs = convertArrayFromApi<Job>(apiResponse, 'Job');")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -17,15 +17,39 @@ import signal
|
|||||||
import json
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from pydantic import BaseModel, EmailStr, validator # type: ignore
|
||||||
# Prometheus
|
# Prometheus
|
||||||
from prometheus_client import Summary # type: ignore
|
from prometheus_client import Summary # type: ignore
|
||||||
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
||||||
from prometheus_client import CollectorRegistry, Counter # type: ignore
|
from prometheus_client import CollectorRegistry, Counter # type: ignore
|
||||||
|
|
||||||
|
# =============================
|
||||||
|
# Import custom modules
|
||||||
|
# =============================
|
||||||
|
from auth_utils import (
|
||||||
|
AuthenticationManager,
|
||||||
|
validate_password_strength,
|
||||||
|
sanitize_login_input,
|
||||||
|
SecurityConfig
|
||||||
|
)
|
||||||
|
import model_cast
|
||||||
|
import defines
|
||||||
|
import agents
|
||||||
|
from logger import logger
|
||||||
|
from database import RedisDatabase, redis_manager, DatabaseManager
|
||||||
|
from metrics import Metrics
|
||||||
|
from llm_manager import llm_manager
|
||||||
|
|
||||||
|
# =============================
|
||||||
# Import Pydantic models
|
# Import Pydantic models
|
||||||
|
# =============================
|
||||||
from models import (
|
from models import (
|
||||||
# User models
|
# User models
|
||||||
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
|
Candidate, Employer, Viewer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
|
||||||
|
|
||||||
# Job models
|
# Job models
|
||||||
Job, JobApplication, ApplicationStatus,
|
Job, JobApplication, ApplicationStatus,
|
||||||
@ -37,13 +61,6 @@ from models import (
|
|||||||
Location, Skill, WorkExperience, Education
|
Location, Skill, WorkExperience, Education
|
||||||
)
|
)
|
||||||
|
|
||||||
import model_cast
|
|
||||||
import defines
|
|
||||||
import agents
|
|
||||||
from logger import logger
|
|
||||||
from database import RedisDatabase, redis_manager, DatabaseManager
|
|
||||||
from metrics import Metrics
|
|
||||||
from llm_manager import llm_manager
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
# ============================
|
# ============================
|
||||||
@ -75,10 +92,6 @@ async def lifespan(app: FastAPI):
|
|||||||
# Initialize database
|
# Initialize database
|
||||||
await db_manager.initialize()
|
await db_manager.initialize()
|
||||||
|
|
||||||
# Seed development data if needed
|
|
||||||
if defines.debug:
|
|
||||||
await seed_development_data()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
@ -131,6 +144,89 @@ ALGORITHM = "HS256"
|
|||||||
# Authentication Utilities
|
# Authentication Utilities
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
login: str # Can be email or username
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@validator('login')
|
||||||
|
def sanitize_login(cls, v):
|
||||||
|
return sanitize_login_input(v)
|
||||||
|
|
||||||
|
@validator('password')
|
||||||
|
def validate_password_not_empty(cls, v):
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError('Password cannot be empty')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class CreateViewerRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
firstName: str
|
||||||
|
lastName: str
|
||||||
|
|
||||||
|
@validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not v or len(v.strip()) < 3:
|
||||||
|
raise ValueError('Username must be at least 3 characters long')
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
@validator('password')
|
||||||
|
def validate_password_strength(cls, v):
|
||||||
|
is_valid, issues = validate_password_strength(v)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError('; '.join(issues))
|
||||||
|
return v
|
||||||
|
|
||||||
|
class CreateCandidateRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
firstName: str
|
||||||
|
lastName: str
|
||||||
|
# Add other required candidate fields as needed
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not v or len(v.strip()) < 3:
|
||||||
|
raise ValueError('Username must be at least 3 characters long')
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
@validator('password')
|
||||||
|
def validate_password_strength(cls, v):
|
||||||
|
is_valid, issues = validate_password_strength(v)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError('; '.join(issues))
|
||||||
|
return v
|
||||||
|
|
||||||
|
# Create Employer Endpoint (similar pattern)
|
||||||
|
class CreateEmployerRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
companyName: str
|
||||||
|
industry: str
|
||||||
|
companySize: str
|
||||||
|
companyDescription: str
|
||||||
|
# Add other required employer fields
|
||||||
|
websiteUrl: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not v or len(v.strip()) < 3:
|
||||||
|
raise ValueError('Username must be at least 3 characters long')
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
@validator('password')
|
||||||
|
def validate_password_strength(cls, v):
|
||||||
|
is_valid, issues = validate_password_strength(v)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError('; '.join(issues))
|
||||||
|
return v
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
@ -151,17 +247,17 @@ async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials
|
|||||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||||
|
|
||||||
# Check if token is blacklisted
|
# Check if token is blacklisted
|
||||||
redis_client = redis_manager.get_client()
|
redis = redis_manager.get_client()
|
||||||
blacklist_key = f"blacklisted_token:{credentials.credentials}"
|
blacklist_key = f"blacklisted_token:{credentials.credentials}"
|
||||||
|
|
||||||
is_blacklisted = await redis_client.exists(blacklist_key)
|
is_blacklisted = await redis.exists(blacklist_key)
|
||||||
if is_blacklisted:
|
if is_blacklisted:
|
||||||
logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}")
|
logger.warning(f"🚫 Attempt to use blacklisted token for user {user_id}")
|
||||||
raise HTTPException(status_code=401, detail="Token has been revoked")
|
raise HTTPException(status_code=401, detail="Token has been revoked")
|
||||||
|
|
||||||
# Optional: Check if all user tokens are revoked (for "logout from all devices")
|
# Optional: Check if all user tokens are revoked (for "logout from all devices")
|
||||||
# user_revoked_key = f"user_tokens_revoked:{user_id}"
|
# user_revoked_key = f"user_tokens_revoked:{user_id}"
|
||||||
# user_tokens_revoked_at = await redis_client.get(user_revoked_key)
|
# user_tokens_revoked_at = await redis.get(user_revoked_key)
|
||||||
# if user_tokens_revoked_at:
|
# if user_tokens_revoked_at:
|
||||||
# revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode())
|
# revoked_timestamp = datetime.fromisoformat(user_tokens_revoked_at.decode())
|
||||||
# token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC)
|
# token_issued_at = datetime.fromtimestamp(payload.get("iat", 0), UTC)
|
||||||
@ -182,7 +278,12 @@ async def get_current_user(
|
|||||||
) -> BaseUserWithType:
|
) -> BaseUserWithType:
|
||||||
"""Get current user from database"""
|
"""Get current user from database"""
|
||||||
try:
|
try:
|
||||||
# Check candidates first
|
# Check viewers
|
||||||
|
viewer = await database.get_viewer(user_id)
|
||||||
|
if viewer:
|
||||||
|
return Viewer.model_validate(viewer)
|
||||||
|
|
||||||
|
# Check candidates
|
||||||
candidate = await database.get_candidate(user_id)
|
candidate = await database.get_candidate(user_id)
|
||||||
if candidate:
|
if candidate:
|
||||||
return Candidate.model_validate(candidate)
|
return Candidate.model_validate(candidate)
|
||||||
@ -294,65 +395,80 @@ api_router = APIRouter(prefix="/api/1.0")
|
|||||||
|
|
||||||
@api_router.post("/auth/login")
|
@api_router.post("/auth/login")
|
||||||
async def login(
|
async def login(
|
||||||
login: str = Body(...),
|
request: LoginRequest,
|
||||||
password: str = Body(...),
|
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Login endpoint"""
|
"""Secure login endpoint with password verification"""
|
||||||
try:
|
try:
|
||||||
# Check if user exists (simplified - in real app, check hashed password)
|
# Initialize authentication manager
|
||||||
user_data = await database.get_user(login)
|
auth_manager = AuthenticationManager(database)
|
||||||
if not user_data:
|
|
||||||
logger.info(f"⚠️ Login attempt with non-existent email: {login}")
|
# Verify credentials
|
||||||
|
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
|
||||||
|
request.login,
|
||||||
|
request.password
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid or not user_data:
|
||||||
|
logger.warning(f"⚠️ Failed login attempt for: {request.login}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content=create_error_response("AUTH_FAILED", "Invalid credentials")
|
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"🔑 User {login} logged in successfully")
|
# Update last login timestamp
|
||||||
|
await auth_manager.update_last_login(user_data["id"])
|
||||||
|
|
||||||
|
logger.info(f"🔑 User {request.login} logged in successfully")
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
access_token = create_access_token(data={"sub": user_data["id"]})
|
access_token = create_access_token(data={"sub": user_data["id"]})
|
||||||
refresh_token = create_access_token(
|
refresh_token = create_access_token(
|
||||||
data={"sub": user_data["id"], "type": "refresh"},
|
data={"sub": user_data["id"], "type": "refresh"},
|
||||||
expires_delta=timedelta(days=30)
|
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user object
|
# Get user object based on type
|
||||||
user = None
|
user = None
|
||||||
if user_data["type"] == "candidate":
|
if user_data["type"] == "candidate":
|
||||||
logger.info(f"🔑 User {login} is a candidate")
|
logger.info(f"🔑 User {request.login} is a candidate")
|
||||||
candidate_data = await database.get_candidate(user_data["id"])
|
candidate_data = await database.get_candidate(user_data["id"])
|
||||||
if candidate_data:
|
if candidate_data:
|
||||||
user = Candidate.model_validate(candidate_data)
|
user = Candidate.model_validate(candidate_data)
|
||||||
elif user_data["type"] == "employer":
|
elif user_data["type"] == "employer":
|
||||||
logger.info(f"🔑 User {login} is a employer")
|
logger.info(f"🔑 User {request.login} is an employer")
|
||||||
employer_data = await database.get_employer(user_data["id"])
|
employer_data = await database.get_employer(user_data["id"])
|
||||||
if employer_data:
|
if employer_data:
|
||||||
user = Employer.model_validate(employer_data)
|
user = Employer.model_validate(employer_data)
|
||||||
|
elif user_data["type"] == "viewer":
|
||||||
|
logger.info(f"🔑 User {request.login} is an viewer")
|
||||||
|
viewer_data = await database.get_viewer(user_data["id"])
|
||||||
|
if viewer_data:
|
||||||
|
user = Viewer.model_validate(viewer_data)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
|
logger.error(f"❌ User object not found for {user_data['id']}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
content=create_error_response("USER_NOT_FOUND", "User not found")
|
content=create_error_response("USER_NOT_FOUND", "User profile not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create response
|
||||||
auth_response = AuthResponse(
|
auth_response = AuthResponse(
|
||||||
accessToken=access_token,
|
accessToken=access_token,
|
||||||
refreshToken=refresh_token,
|
refreshToken=refresh_token,
|
||||||
user=user,
|
user=user,
|
||||||
expiresAt=int((datetime.now(UTC) + timedelta(hours=24)).timestamp())
|
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"⚠️ Login error: {e}")
|
logger.error(f"❌ Login error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content=create_error_response("LOGIN_ERROR", str(e))
|
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
|
||||||
)
|
)
|
||||||
|
|
||||||
@api_router.post("/auth/logout")
|
@api_router.post("/auth/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
access_token: str = Body(..., alias="accessToken"),
|
access_token: str = Body(..., alias="accessToken"),
|
||||||
@ -390,12 +506,12 @@ async def logout(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get Redis client
|
# Get Redis client
|
||||||
redis_client = redis_manager.get_client()
|
redis = redis_manager.get_client()
|
||||||
|
|
||||||
# Revoke refresh token (blacklist it until its natural expiration)
|
# Revoke refresh token (blacklist it until its natural expiration)
|
||||||
refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp()))
|
refresh_ttl = max(0, refresh_exp - int(datetime.now(UTC).timestamp()))
|
||||||
if refresh_ttl > 0:
|
if refresh_ttl > 0:
|
||||||
await redis_client.setex(
|
await redis.setex(
|
||||||
f"blacklisted_token:{refresh_token}",
|
f"blacklisted_token:{refresh_token}",
|
||||||
refresh_ttl,
|
refresh_ttl,
|
||||||
json.dumps({
|
json.dumps({
|
||||||
@ -418,7 +534,7 @@ async def logout(
|
|||||||
if access_user_id == user_id:
|
if access_user_id == user_id:
|
||||||
access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp()))
|
access_ttl = max(0, access_exp - int(datetime.now(UTC).timestamp()))
|
||||||
if access_ttl > 0:
|
if access_ttl > 0:
|
||||||
await redis_client.setex(
|
await redis.setex(
|
||||||
f"blacklisted_token:{access_token}",
|
f"blacklisted_token:{access_token}",
|
||||||
access_ttl,
|
access_ttl,
|
||||||
json.dumps({
|
json.dumps({
|
||||||
@ -438,7 +554,7 @@ async def logout(
|
|||||||
# Optional: Revoke all tokens for this user (for "logout from all devices")
|
# Optional: Revoke all tokens for this user (for "logout from all devices")
|
||||||
# Uncomment the following lines if you want to implement this feature:
|
# Uncomment the following lines if you want to implement this feature:
|
||||||
#
|
#
|
||||||
# await redis_client.setex(
|
# await redis.setex(
|
||||||
# f"user_tokens_revoked:{user_id}",
|
# f"user_tokens_revoked:{user_id}",
|
||||||
# timedelta(days=30).total_seconds(), # Max refresh token lifetime
|
# timedelta(days=30).total_seconds(), # Max refresh token lifetime
|
||||||
# datetime.now(UTC).isoformat()
|
# datetime.now(UTC).isoformat()
|
||||||
@ -467,10 +583,10 @@ async def logout_all_devices(
|
|||||||
):
|
):
|
||||||
"""Logout from all devices by revoking all tokens for the user"""
|
"""Logout from all devices by revoking all tokens for the user"""
|
||||||
try:
|
try:
|
||||||
redis_client = redis_manager.get_client()
|
redis = redis_manager.get_client()
|
||||||
|
|
||||||
# Set a timestamp that invalidates all tokens issued before this moment
|
# Set a timestamp that invalidates all tokens issued before this moment
|
||||||
await redis_client.setex(
|
await redis.setex(
|
||||||
f"user_tokens_revoked:{current_user.id}",
|
f"user_tokens_revoked:{current_user.id}",
|
||||||
int(timedelta(days=30).total_seconds()), # Max refresh token lifetime
|
int(timedelta(days=30).total_seconds()), # Max refresh token lifetime
|
||||||
datetime.now(UTC).isoformat()
|
datetime.now(UTC).isoformat()
|
||||||
@ -518,6 +634,10 @@ async def refresh_token_endpoint(
|
|||||||
employer_data = await database.get_employer(user_id)
|
employer_data = await database.get_employer(user_id)
|
||||||
if employer_data:
|
if employer_data:
|
||||||
user = Employer.model_validate(employer_data)
|
user = Employer.model_validate(employer_data)
|
||||||
|
else:
|
||||||
|
viewer_data = await database.get_viewer(user_id)
|
||||||
|
if viewer_data:
|
||||||
|
user = Viewer.model_validate(viewer_data)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -546,48 +666,195 @@ async def refresh_token_endpoint(
|
|||||||
content=create_error_response("REFRESH_ERROR", str(e))
|
content=create_error_response("REFRESH_ERROR", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Viewer Endpoints
|
||||||
|
# ============================
|
||||||
|
@api_router.post("/viewers")
|
||||||
|
async def create_viewer(
|
||||||
|
request: CreateViewerRequest,
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Create a new viewer with secure password handling and duplicate checking"""
|
||||||
|
try:
|
||||||
|
# Initialize authentication manager
|
||||||
|
auth_manager = AuthenticationManager(database)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
user_exists, conflict_field = await auth_manager.check_user_exists(
|
||||||
|
request.email,
|
||||||
|
request.username
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_exists and not conflict_field:
|
||||||
|
raise ValueError("User already exists with this email or username, but conflict_field is not set")
|
||||||
|
|
||||||
|
if user_exists and conflict_field:
|
||||||
|
logger.warning(f"⚠️ Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content=create_error_response(
|
||||||
|
"USER_EXISTS",
|
||||||
|
f"A user with this {conflict_field} already exists"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate viewer ID
|
||||||
|
viewer_id = str(uuid.uuid4())
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Prepare viewer data
|
||||||
|
viewer_data = {
|
||||||
|
"id": viewer_id,
|
||||||
|
"email": request.email,
|
||||||
|
"username": request.username,
|
||||||
|
"firstName": request.firstName,
|
||||||
|
"lastName": request.lastName,
|
||||||
|
"fullName": f"{request.firstName} {request.lastName}",
|
||||||
|
"createdAt": current_time.isoformat(),
|
||||||
|
"updatedAt": current_time.isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
"userType": "viewer",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create viewer object and validate
|
||||||
|
viewer = Viewer.model_validate(viewer_data)
|
||||||
|
|
||||||
|
# Create authentication record with hashed password
|
||||||
|
await auth_manager.create_user_authentication(viewer_id, request.password)
|
||||||
|
|
||||||
|
# Store viewer in database
|
||||||
|
await database.set_viewer(viewer.id, viewer.model_dump())
|
||||||
|
|
||||||
|
# Add to users for auth lookup (by email and username)
|
||||||
|
user_auth_data = {
|
||||||
|
"id": viewer.id,
|
||||||
|
"type": "viewer",
|
||||||
|
"email": viewer.email,
|
||||||
|
"username": request.username
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store user lookup records
|
||||||
|
await database.set_user(viewer.email, user_auth_data) # By email
|
||||||
|
await database.set_user(request.username, user_auth_data) # By username
|
||||||
|
await database.set_user_by_id(viewer.id, user_auth_data) # By ID
|
||||||
|
|
||||||
|
logger.info(f"✅ Created viewer: {viewer.email} (ID: {viewer.id})")
|
||||||
|
|
||||||
|
# Return viewer data (excluding sensitive information)
|
||||||
|
response_data = viewer.model_dump(by_alias=True, exclude_unset=True)
|
||||||
|
# Remove any sensitive fields from response if needed
|
||||||
|
|
||||||
|
return create_success_response(response_data)
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
logger.warning(f"⚠️ Validation error creating viewer: {ve}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response("VALIDATION_ERROR", str(ve))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Viewer creation error: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("CREATION_FAILED", "Failed to create viewer account")
|
||||||
|
)
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# Candidate Endpoints
|
# Candidate Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
|
|
||||||
@api_router.post("/candidates")
|
@api_router.post("/candidates")
|
||||||
async def create_candidate(
|
async def create_candidate(
|
||||||
candidate_data: Dict[str, Any] = Body(...),
|
request: CreateCandidateRequest,
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Create a new candidate"""
|
"""Create a new candidate with secure password handling and duplicate checking"""
|
||||||
try:
|
try:
|
||||||
# Add required fields
|
# Initialize authentication manager
|
||||||
candidate_data["id"] = str(uuid.uuid4())
|
auth_manager = AuthenticationManager(database)
|
||||||
candidate_data["createdAt"] = datetime.now(UTC).isoformat()
|
|
||||||
candidate_data["updatedAt"] = datetime.now(UTC).isoformat()
|
|
||||||
|
|
||||||
# Create candidate
|
# Check if user already exists
|
||||||
candidate = Candidate.model_validate(candidate_data)
|
user_exists, conflict_field = await auth_manager.check_user_exists(
|
||||||
# Check if candidate already exists
|
request.email,
|
||||||
existing_candidate = await database.get_candidate(candidate.id)
|
request.username
|
||||||
if existing_candidate:
|
)
|
||||||
|
|
||||||
|
if user_exists and not conflict_field:
|
||||||
|
raise ValueError("User already exists with this email or username, but conflict_field is not set")
|
||||||
|
|
||||||
|
if user_exists and conflict_field:
|
||||||
|
logger.warning(f"⚠️ Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=409,
|
||||||
content=create_error_response("ALREADY_EXISTS", "Candidate already exists")
|
content=create_error_response(
|
||||||
|
"USER_EXISTS",
|
||||||
|
f"A user with this {conflict_field} already exists"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generate candidate ID
|
||||||
|
candidate_id = str(uuid.uuid4())
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Prepare candidate data
|
||||||
|
candidate_data = {
|
||||||
|
"id": candidate_id,
|
||||||
|
"userType": "candidate",
|
||||||
|
"email": request.email,
|
||||||
|
"username": request.username,
|
||||||
|
"firstName": request.firstName,
|
||||||
|
"lastName": request.lastName,
|
||||||
|
"fullName": f"{request.firstName} {request.lastName}",
|
||||||
|
"phone": request.phone,
|
||||||
|
"createdAt": current_time.isoformat(),
|
||||||
|
"updatedAt": current_time.isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create candidate object and validate
|
||||||
|
candidate = Candidate.model_validate(candidate_data)
|
||||||
|
|
||||||
|
# Create authentication record with hashed password
|
||||||
|
await auth_manager.create_user_authentication(candidate_id, request.password)
|
||||||
|
|
||||||
|
# Store candidate in database
|
||||||
await database.set_candidate(candidate.id, candidate.model_dump())
|
await database.set_candidate(candidate.id, candidate.model_dump())
|
||||||
|
|
||||||
# Add to users for auth (simplified)
|
# Add to users for auth lookup (by email and username)
|
||||||
await database.set_user(candidate, {
|
user_auth_data = {
|
||||||
"id": candidate.id,
|
"id": candidate.id,
|
||||||
"type": "candidate"
|
"type": "candidate",
|
||||||
})
|
"email": candidate.email,
|
||||||
|
"username": request.username
|
||||||
|
}
|
||||||
|
|
||||||
return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True))
|
# Store user lookup records
|
||||||
|
await database.set_user(candidate.email, user_auth_data) # By email
|
||||||
|
await database.set_user(request.username, user_auth_data) # By username
|
||||||
|
await database.set_user_by_id(candidate.id, user_auth_data) # By ID
|
||||||
|
|
||||||
except Exception as e:
|
logger.info(f"✅ Created candidate: {candidate.email} (ID: {candidate.id})")
|
||||||
logger.error(f"Candidate creation error: {e}")
|
|
||||||
|
# Return candidate data (excluding sensitive information)
|
||||||
|
response_data = candidate.model_dump(by_alias=True, exclude_unset=True)
|
||||||
|
# Remove any sensitive fields from response if needed
|
||||||
|
|
||||||
|
return create_success_response(response_data)
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
logger.warning(f"⚠️ Validation error creating candidate: {ve}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content=create_error_response("CREATION_FAILED", str(e))
|
content=create_error_response("VALIDATION_ERROR", str(ve))
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Candidate creation error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("CREATION_FAILED", "Failed to create candidate account")
|
||||||
|
)
|
||||||
|
|
||||||
@api_router.get("/candidates/{username}")
|
@api_router.get("/candidates/{username}")
|
||||||
async def get_candidate(
|
async def get_candidate(
|
||||||
username: str = Path(...),
|
username: str = Path(...),
|
||||||
@ -753,6 +1020,185 @@ async def search_candidates(
|
|||||||
content=create_error_response("SEARCH_FAILED", str(e))
|
content=create_error_response("SEARCH_FAILED", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Employer Endpoints
|
||||||
|
# ============================
|
||||||
|
|
||||||
|
@api_router.post("/employers")
|
||||||
|
async def create_employer(
|
||||||
|
request: CreateEmployerRequest,
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Create a new employer with secure password handling and duplicate checking"""
|
||||||
|
try:
|
||||||
|
# Initialize authentication manager
|
||||||
|
auth_manager = AuthenticationManager(database)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
user_exists, conflict_field = await auth_manager.check_user_exists(
|
||||||
|
request.email,
|
||||||
|
request.username
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_exists and not conflict_field:
|
||||||
|
raise ValueError("User already exists with this email or username, but conflict_field is not set")
|
||||||
|
|
||||||
|
if user_exists and conflict_field:
|
||||||
|
logger.warning(f"⚠️ Attempted to create employer with existing {conflict_field}: {getattr(request, conflict_field)}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content=create_error_response(
|
||||||
|
"USER_EXISTS",
|
||||||
|
f"A user with this {conflict_field} already exists"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate employer ID
|
||||||
|
employer_id = str(uuid.uuid4())
|
||||||
|
current_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Prepare employer data
|
||||||
|
employer_data = {
|
||||||
|
"id": employer_id,
|
||||||
|
"email": request.email,
|
||||||
|
"companyName": request.companyName,
|
||||||
|
"industry": request.industry,
|
||||||
|
"companySize": request.companySize,
|
||||||
|
"companyDescription": request.companyDescription,
|
||||||
|
"websiteUrl": request.websiteUrl,
|
||||||
|
"phone": request.phone,
|
||||||
|
"createdAt": current_time.isoformat(),
|
||||||
|
"updatedAt": current_time.isoformat(),
|
||||||
|
"status": "active",
|
||||||
|
"userType": "employer",
|
||||||
|
"location": {
|
||||||
|
"city": "",
|
||||||
|
"country": "",
|
||||||
|
"remote": False
|
||||||
|
},
|
||||||
|
"socialLinks": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create employer object and validate
|
||||||
|
employer = Employer.model_validate(employer_data)
|
||||||
|
|
||||||
|
# Create authentication record with hashed password
|
||||||
|
await auth_manager.create_user_authentication(employer_id, request.password)
|
||||||
|
|
||||||
|
# Store employer in database
|
||||||
|
await database.set_employer(employer.id, employer.model_dump())
|
||||||
|
|
||||||
|
# Add to users for auth lookup
|
||||||
|
user_auth_data = {
|
||||||
|
"id": employer.id,
|
||||||
|
"type": "employer",
|
||||||
|
"email": employer.email,
|
||||||
|
"username": request.username
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store user lookup records
|
||||||
|
await database.set_user(employer.email, user_auth_data) # By email
|
||||||
|
await database.set_user(request.username, user_auth_data) # By username
|
||||||
|
await database.set_user_by_id(employer.id, user_auth_data) # By ID
|
||||||
|
|
||||||
|
logger.info(f"✅ Created employer: {employer.email} (ID: {employer.id})")
|
||||||
|
|
||||||
|
# Return employer data (excluding sensitive information)
|
||||||
|
response_data = employer.model_dump(by_alias=True, exclude_unset=True)
|
||||||
|
|
||||||
|
return create_success_response(response_data)
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
logger.warning(f"⚠️ Validation error creating employer: {ve}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response("VALIDATION_ERROR", str(ve))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Employer creation error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("CREATION_FAILED", "Failed to create employer account")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Password Reset Endpoints
|
||||||
|
# ============================
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class PasswordResetConfirm(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@validator('new_password')
|
||||||
|
def validate_password_strength(cls, v):
|
||||||
|
is_valid, issues = validate_password_strength(v)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError('; '.join(issues))
|
||||||
|
return v
|
||||||
|
|
||||||
|
@api_router.post("/auth/password-reset/request")
|
||||||
|
async def request_password_reset(
|
||||||
|
request: PasswordResetRequest,
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Request password reset"""
|
||||||
|
try:
|
||||||
|
# Check if user exists
|
||||||
|
user_data = await database.get_user(request.email)
|
||||||
|
if not user_data:
|
||||||
|
# Don't reveal whether email exists or not
|
||||||
|
return create_success_response({"message": "If the email exists, a reset link will be sent"})
|
||||||
|
|
||||||
|
auth_manager = AuthenticationManager(database)
|
||||||
|
|
||||||
|
# Generate reset token
|
||||||
|
reset_token = auth_manager.password_security.generate_secure_token()
|
||||||
|
reset_expiry = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry
|
||||||
|
|
||||||
|
# Update authentication record
|
||||||
|
auth_record = await database.get_authentication(user_data["id"])
|
||||||
|
if auth_record:
|
||||||
|
auth_record["resetPasswordToken"] = reset_token
|
||||||
|
auth_record["resetPasswordExpiry"] = reset_expiry.isoformat()
|
||||||
|
await database.set_authentication(user_data["id"], auth_record)
|
||||||
|
|
||||||
|
# TODO: Send email with reset token
|
||||||
|
logger.info(f"🔐 Password reset requested for: {request.email}")
|
||||||
|
|
||||||
|
return create_success_response({"message": "If the email exists, a reset link will be sent"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Password reset request error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("RESET_ERROR", "An error occurred processing the request")
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.post("/auth/password-reset/confirm")
|
||||||
|
async def confirm_password_reset(
|
||||||
|
request: PasswordResetConfirm,
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Confirm password reset with token"""
|
||||||
|
try:
|
||||||
|
# Find user by reset token
|
||||||
|
# This would require a way to lookup by token - you might need to modify your database structure
|
||||||
|
|
||||||
|
# For now, this is a placeholder - you'd need to implement token lookup
|
||||||
|
# in your Redis database structure
|
||||||
|
|
||||||
|
return create_success_response({"message": "Password reset successfully"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Password reset confirm error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("RESET_ERROR", "An error occurred resetting the password")
|
||||||
|
)
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# Job Endpoints
|
# Job Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
@ -1349,17 +1795,17 @@ async def enhanced_health_check():
|
|||||||
"""Enhanced health check endpoint"""
|
"""Enhanced health check endpoint"""
|
||||||
try:
|
try:
|
||||||
database = db_manager.get_database()
|
database = db_manager.get_database()
|
||||||
if not redis_manager.redis_client:
|
if not redis_manager.redis:
|
||||||
raise RuntimeError("Redis client not initialized")
|
raise RuntimeError("Redis client not initialized")
|
||||||
|
|
||||||
# Test Redis connection
|
# Test Redis connection
|
||||||
await redis_manager.redis_client.ping()
|
await redis_manager.redis.ping()
|
||||||
|
|
||||||
# Get database stats
|
# Get database stats
|
||||||
stats = await database.get_stats()
|
stats = await database.get_stats()
|
||||||
|
|
||||||
# Redis info
|
# Redis info
|
||||||
redis_info = await redis_manager.redis_client.info()
|
redis_info = await redis_manager.redis.info()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
@ -1386,9 +1832,9 @@ async def enhanced_health_check():
|
|||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
@api_router.get("/redis/stats")
|
@api_router.get("/redis/stats")
|
||||||
async def redis_stats(redis_client: redis.Redis = Depends(get_redis)):
|
async def redis_stats(redis: redis.Redis = Depends(get_redis)):
|
||||||
try:
|
try:
|
||||||
info = await redis_client.info()
|
info = await redis.info()
|
||||||
return {
|
return {
|
||||||
"connected_clients": info.get("connected_clients"),
|
"connected_clients": info.get("connected_clients"),
|
||||||
"used_memory_human": info.get("used_memory_human"),
|
"used_memory_human": info.get("used_memory_human"),
|
||||||
@ -1501,96 +1947,6 @@ async def root():
|
|||||||
"health": f"{defines.api_prefix}/health"
|
"health": f"{defines.api_prefix}/health"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================
|
|
||||||
# Development Data Seeding
|
|
||||||
# ============================
|
|
||||||
|
|
||||||
async def seed_development_data():
|
|
||||||
"""Seed the database with development data"""
|
|
||||||
try:
|
|
||||||
database = db_manager.get_database()
|
|
||||||
|
|
||||||
# Check if data already exists
|
|
||||||
stats = await database.get_stats()
|
|
||||||
if stats.get('candidates', 0) > 0:
|
|
||||||
logger.info("✅ Development data already exists, skipping seeding")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create sample location
|
|
||||||
sample_location = Location(
|
|
||||||
city="San Francisco",
|
|
||||||
state="CA",
|
|
||||||
country="USA",
|
|
||||||
postalCode="94102"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create sample candidate
|
|
||||||
candidate_id = str(uuid.uuid4())
|
|
||||||
sample_candidate = Candidate(
|
|
||||||
id=candidate_id,
|
|
||||||
email="john.doe@example.com",
|
|
||||||
createdAt=datetime.now(UTC),
|
|
||||||
updatedAt=datetime.now(UTC),
|
|
||||||
status="active",
|
|
||||||
firstName="John",
|
|
||||||
lastName="Doe",
|
|
||||||
fullName="John Doe",
|
|
||||||
username="johndoe",
|
|
||||||
skills=[],
|
|
||||||
experience=[],
|
|
||||||
education=[],
|
|
||||||
preferredJobTypes=["full-time"],
|
|
||||||
location=sample_location,
|
|
||||||
languages=[],
|
|
||||||
certifications=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
await database.set_candidate(candidate_id, sample_candidate.model_dump())
|
|
||||||
await database.set_user(sample_candidate, {"id": candidate_id, "type": "candidate"})
|
|
||||||
|
|
||||||
# Create sample employer
|
|
||||||
employer_id = str(uuid.uuid4())
|
|
||||||
sample_employer = Employer(
|
|
||||||
id=employer_id,
|
|
||||||
email="hr@techcorp.com",
|
|
||||||
createdAt=datetime.now(UTC),
|
|
||||||
updatedAt=datetime.now(UTC),
|
|
||||||
status="active",
|
|
||||||
companyName="TechCorp",
|
|
||||||
industry="Technology",
|
|
||||||
companySize="100-500",
|
|
||||||
companyDescription="Leading technology company",
|
|
||||||
location=sample_location
|
|
||||||
)
|
|
||||||
|
|
||||||
await database.set_employer(employer_id, sample_employer.model_dump())
|
|
||||||
await database.set_user(sample_employer, {"id": employer_id, "type": "employer"})
|
|
||||||
|
|
||||||
# Create sample job
|
|
||||||
job_id = str(uuid.uuid4())
|
|
||||||
sample_job = Job(
|
|
||||||
id=job_id,
|
|
||||||
title="Senior Software Engineer",
|
|
||||||
description="We are looking for a senior software engineer...",
|
|
||||||
responsibilities=["Develop software", "Lead projects", "Mentor juniors"],
|
|
||||||
requirements=["5+ years experience", "Python expertise"],
|
|
||||||
preferredSkills=["FastAPI", "React", "PostgreSQL"],
|
|
||||||
employerId=employer_id,
|
|
||||||
location=sample_location,
|
|
||||||
employmentType="full-time",
|
|
||||||
datePosted=datetime.now(UTC),
|
|
||||||
isActive=True,
|
|
||||||
views=0,
|
|
||||||
applicationCount=0
|
|
||||||
)
|
|
||||||
|
|
||||||
await database.set_job(job_id, sample_job.model_dump())
|
|
||||||
|
|
||||||
logger.info("✅ Development data seeded successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"⚠️ Failed to seed development data: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
host = defines.host
|
host = defines.host
|
||||||
port = defines.port
|
port = defines.port
|
||||||
|
@ -15,6 +15,7 @@ T = TypeVar('T')
|
|||||||
class UserType(str, Enum):
|
class UserType(str, Enum):
|
||||||
CANDIDATE = "candidate"
|
CANDIDATE = "candidate"
|
||||||
EMPLOYER = "employer"
|
EMPLOYER = "employer"
|
||||||
|
VIEWER = "viewer"
|
||||||
GUEST = "guest"
|
GUEST = "guest"
|
||||||
|
|
||||||
class UserGender(str, Enum):
|
class UserGender(str, Enum):
|
||||||
@ -105,11 +106,13 @@ class MFAMethod(str, Enum):
|
|||||||
EMAIL = "email"
|
EMAIL = "email"
|
||||||
|
|
||||||
class VectorStoreType(str, Enum):
|
class VectorStoreType(str, Enum):
|
||||||
PINECONE = "pinecone"
|
CHROMA = "chroma",
|
||||||
QDRANT = "qdrant"
|
# FAISS = "faiss",
|
||||||
FAISS = "faiss"
|
# PINECONE = "pinecone"
|
||||||
MILVUS = "milvus"
|
# QDRANT = "qdrant"
|
||||||
WEAVIATE = "weaviate"
|
# FAISS = "faiss"
|
||||||
|
# MILVUS = "milvus"
|
||||||
|
# WEAVIATE = "weaviate"
|
||||||
|
|
||||||
class DataSourceType(str, Enum):
|
class DataSourceType(str, Enum):
|
||||||
DOCUMENT = "document"
|
DOCUMENT = "document"
|
||||||
@ -366,7 +369,11 @@ class ErrorDetail(BaseModel):
|
|||||||
class BaseUser(BaseModel):
|
class BaseUser(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
first_name: str = Field(..., alias="firstName")
|
||||||
|
last_name: str = Field(..., alias="lastName")
|
||||||
|
full_name: str = Field(..., alias="fullName")
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
|
location: Optional[Location] = None
|
||||||
created_at: datetime = Field(..., alias="createdAt")
|
created_at: datetime = Field(..., alias="createdAt")
|
||||||
updated_at: datetime = Field(..., alias="updatedAt")
|
updated_at: datetime = Field(..., alias="updatedAt")
|
||||||
last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
||||||
@ -382,25 +389,25 @@ class BaseUser(BaseModel):
|
|||||||
class BaseUserWithType(BaseUser):
|
class BaseUserWithType(BaseUser):
|
||||||
user_type: UserType = Field(..., alias="userType")
|
user_type: UserType = Field(..., alias="userType")
|
||||||
|
|
||||||
|
class Viewer(BaseUser):
|
||||||
|
user_type: Literal[UserType.VIEWER] = Field(UserType.VIEWER, alias="userType")
|
||||||
|
username: str
|
||||||
|
|
||||||
class Candidate(BaseUser):
|
class Candidate(BaseUser):
|
||||||
user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType")
|
user_type: Literal[UserType.CANDIDATE] = Field(UserType.CANDIDATE, alias="userType")
|
||||||
username: str
|
username: str
|
||||||
first_name: str = Field(..., alias="firstName")
|
|
||||||
last_name: str = Field(..., alias="lastName")
|
|
||||||
full_name: str = Field(..., alias="fullName")
|
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
resume: Optional[str] = None
|
resume: Optional[str] = None
|
||||||
skills: List[Skill]
|
skills: Optional[List[Skill]] = None
|
||||||
experience: List[WorkExperience]
|
experience: Optional[List[WorkExperience]] = None
|
||||||
questions: List[CandidateQuestion] = []
|
questions: Optional[List[CandidateQuestion]] = None
|
||||||
education: List[Education]
|
education: Optional[List[Education]] = None
|
||||||
preferred_job_types: List[EmploymentType] = Field(..., alias="preferredJobTypes")
|
preferred_job_types: Optional[List[EmploymentType]] = Field(None, alias="preferredJobTypes")
|
||||||
desired_salary: Optional[DesiredSalary] = Field(None, alias="desiredSalary")
|
desired_salary: Optional[DesiredSalary] = Field(None, alias="desiredSalary")
|
||||||
location: Location
|
|
||||||
availability_date: Optional[datetime] = Field(None, alias="availabilityDate")
|
availability_date: Optional[datetime] = Field(None, alias="availabilityDate")
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
languages: List[Language]
|
languages: Optional[List[Language]] = None
|
||||||
certifications: List[Certification]
|
certifications: Optional[List[Certification]] = None
|
||||||
job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications")
|
job_applications: Optional[List["JobApplication"]] = Field(None, alias="jobApplications")
|
||||||
has_profile: bool = Field(default=False, alias="hasProfile")
|
has_profile: bool = Field(default=False, alias="hasProfile")
|
||||||
# Used for AI generated personas
|
# Used for AI generated personas
|
||||||
@ -417,7 +424,6 @@ class Employer(BaseUser):
|
|||||||
company_description: str = Field(..., alias="companyDescription")
|
company_description: str = Field(..., alias="companyDescription")
|
||||||
website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl")
|
website_url: Optional[HttpUrl] = Field(None, alias="websiteUrl")
|
||||||
jobs: Optional[List["Job"]] = None
|
jobs: Optional[List["Job"]] = None
|
||||||
location: Location
|
|
||||||
company_logo: Optional[str] = Field(None, alias="companyLogo")
|
company_logo: Optional[str] = Field(None, alias="companyLogo")
|
||||||
social_links: Optional[List[SocialLink]] = Field(None, alias="socialLinks")
|
social_links: Optional[List[SocialLink]] = Field(None, alias="socialLinks")
|
||||||
poc: Optional[PointOfContact] = None
|
poc: Optional[PointOfContact] = None
|
||||||
@ -454,7 +460,7 @@ class Authentication(BaseModel):
|
|||||||
class AuthResponse(BaseModel):
|
class AuthResponse(BaseModel):
|
||||||
access_token: str = Field(..., alias="accessToken")
|
access_token: str = Field(..., alias="accessToken")
|
||||||
refresh_token: str = Field(..., alias="refreshToken")
|
refresh_token: str = Field(..., alias="refreshToken")
|
||||||
user: Candidate | Employer
|
user: Candidate | Employer | Viewer
|
||||||
expires_at: int = Field(..., alias="expiresAt")
|
expires_at: int = Field(..., alias="expiresAt")
|
||||||
model_config = {
|
model_config = {
|
||||||
"populate_by_name": True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
Loading…
x
Reference in New Issue
Block a user