diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b4824cf..27ba9bd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,8 +48,8 @@ jobs: API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }} with: source-directory: client/build - destination-github-username: ${{secrets.DESTINATION_GITHUB_USERNAME}} - destination-repository-name: ${{secrets.DESTINATION_REPOSITORY_NAME}} + destination-github-username: 'axleshift' + destination-repository-name: 'hr2' target-branch: "hr2-frontend" user-email: "pedjovensindol.edu@gmail.com" commit-message: "Build client" @@ -85,8 +85,8 @@ jobs: API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }} with: source-directory: server - destination-github-username: ${{secrets.DESTINATION_GITHUB_USERNAME}} - destination-repository-name: ${{secrets.DESTINATION_REPOSITORY_NAME}} + destination-github-username: 'axleshift' + destination-repository-name: 'hr2' target-branch: "hr2-backend" user-email: "pedjovensindol.edu@gmail.com" commit-message: "Build server" diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 72999b8f..796330ee 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - repository: "${{secrets.DESTINATION_GITHUB_USERNAME}}/${{secrets.DESTINATION_REPOSITORY_NAME}}" + repository: "axleshift/hr2" - uses: actions/labeler@v5 with: repo-token: "${{ secrets.API_TOKEN_GITHUB }}" diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 6bc169cc..6f3e15a1 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -13,7 +13,7 @@ jobs: terraform: runs-on: ubuntu-latest needs: [ docker ] - if: github.repository == 'freight-capstone/hr2' + if: github.repository == 'axleshift/hr2' steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 645d64fb..da3c1ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ server/dist +server/dist/public/applicants +server/src/public/applicants client/build node_modules/* diff --git a/client/package-lock.json b/client/package-lock.json index 2762858b..83a14ebf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,6 +21,7 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@popperjs/core": "^2.11.8", + "@react-oauth/google": "^0.12.1", "@react-pdf/renderer": "^3.4.4", "axios": "^1.7.7", "chart.js": "^4.4.3", @@ -32,6 +33,7 @@ "react": "^18.3.1", "react-calendar": "^5.0.0", "react-dom": "^18.3.1", + "react-google-button": "^0.8.0", "react-google-recaptcha": "^3.1.0", "react-pdf": "^9.1.1", "react-redux": "^9.1.2", @@ -2854,6 +2856,16 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-oauth/google": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", + "integrity": "sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@react-pdf/fns": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-2.2.1.tgz", @@ -8298,6 +8310,18 @@ } } }, + "node_modules/react-google-button": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/react-google-button/-/react-google-button-0.8.0.tgz", + "integrity": "sha512-0u6mbDcTUmWzgXsld8FJpbrXP+zDsNQVYPIt225aBqwgJ/CdrDiep1PZOpnyntyjsejWG0TtQr6uZB4eIaXlJQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/react-google-recaptcha": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", @@ -9931,9 +9955,9 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", + "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/client/package.json b/client/package.json index 5cded7d9..f92957ac 100644 --- a/client/package.json +++ b/client/package.json @@ -36,6 +36,7 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@popperjs/core": "^2.11.8", + "@react-oauth/google": "^0.12.1", "@react-pdf/renderer": "^3.4.4", "axios": "^1.7.7", "chart.js": "^4.4.3", @@ -47,6 +48,7 @@ "react": "^18.3.1", "react-calendar": "^5.0.0", "react-dom": "^18.3.1", + "react-google-button": "^0.8.0", "react-google-recaptcha": "^3.1.0", "react-pdf": "^9.1.1", "react-redux": "^9.1.2", diff --git a/client/src/App.jsx b/client/src/App.jsx index e35ab0f6..09d29acd 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,11 +1,13 @@ import React, { Suspense, useEffect } from 'react' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { useSelector } from 'react-redux' +import { GoogleOAuthProvider } from '@react-oauth/google' import AuthProvider from './context/authContext' import AppProvider from './context/appContext' import ProtectedRoute from './components/ProtectedRoute' import { CSpinner, useColorModes } from '@coreui/react' import './scss/style.scss' +import { config } from './config' // Containers const DefaultLayout = React.lazy(() => import('./layout/DefaultLayout')) @@ -14,15 +16,13 @@ const DefaultLayout = React.lazy(() => import('./layout/DefaultLayout')) const Login = React.lazy(() => import('./views/auth/Login')) const Register = React.lazy(() => import('./views/auth/Register')) const VerifyEmail = React.lazy(() => import('./views/auth/VerifyEmail')) +const OTPPage = React.lazy(() => import('./views/auth/Otp')) const Page404 = React.lazy(() => import('./views/errors/Page404')) const Page500 = React.lazy(() => import('./views/errors/Page500')) -// const Terms = React.lazy(() => import('./views/legal/Terms')) const Policy = React.lazy(() => import('./views/legal/PolicyTerms')) -// const ApplicantProfilePage = React.lazy(() => import('./views/applicants/ApplicantProfile')) - const App = () => { const { isColorModeSet, setColorMode } = useColorModes('theme') const storedTheme = useSelector((state) => state.theme) @@ -42,7 +42,7 @@ const App = () => { }, []) // eslint-disable-line react-hooks/exhaustive-deps return ( - <> + @@ -56,13 +56,11 @@ const App = () => { } /> } /> + } /> } /> } /> } /> - {/* } /> - } /> */} - {/* Protect DefaultLayout route */} { - + ) } diff --git a/client/src/_nav.js b/client/src/_nav.js index 8eeed4c4..f5e7ef89 100644 --- a/client/src/_nav.js +++ b/client/src/_nav.js @@ -31,16 +31,16 @@ import { } from '@fortawesome/free-solid-svg-icons' const _nav = [ - // { - // component: CNavItem, - // name: 'Overview', - // to: '/dashboard/overview', - // icon: , - // badge: { - // color: 'danger', - // text: 'unfinished', - // }, - // }, + { + component: CNavItem, + name: 'Overview', + to: '/dashboard/overview', + icon: , + badge: { + color: 'danger', + text: 'unfinished', + }, + }, { component: CNavTitle, name: 'Recruitment', @@ -63,12 +63,12 @@ const _nav = [ to: '/recruitment/jobposts', icon: , }, - { - component: CNavItem, - name: 'Request', - to: '/recruitment/jobposting/request', - icon: , - }, + // { + // component: CNavItem, + // name: 'Request', + // to: '/recruitment/jobposting/request', + // icon: , + // }, { component: CNavTitle, name: 'Application Tracking', diff --git a/client/src/api/axios.js b/client/src/api/axios.js index d26cbd98..84a17b3e 100644 --- a/client/src/api/axios.js +++ b/client/src/api/axios.js @@ -8,54 +8,61 @@ const instance = axios.create({ baseURL: baseUrl, withCredentials: true, headers: { - 'Content-Type': 'application/json', 'X-API-KEY': apiKey, }, }) const handleError = (error) => { if (axios.isAxiosError(error)) { - // This is an Axios error return { status: error.response ? error.response.status : null, message: error.response ? error.response.data : error.message, } } else { - // This is a non-Axios error return { status: null, message: error.message } } } const post = async (url, data) => { try { - const response = await instance.post(url, data) + const isFormData = data instanceof FormData + + const response = await instance.post(url, data, { + headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined, + }) + return response } catch (error) { const handledError = handleError(error) console.error('POST Error:', handledError) - return handledError // return error information + return handledError } } -const get = async (url) => { +const put = async (url, data) => { try { - const response = await instance.get(url) + const isFormData = data instanceof FormData + + const response = await instance.put(url, data, { + headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined, + }) + return response } catch (error) { const handledError = handleError(error) - console.error('GET Error:', handledError) - return handledError // return error information + console.error('PUT Error:', handledError) + return handledError } } -const put = async (url, data) => { +const get = async (url) => { try { - const response = await instance.put(url, data) + const response = await instance.get(url) return response } catch (error) { const handledError = handleError(error) - console.error('PUT Error:', handledError) - return handledError // return error information + console.error('GET Error:', handledError) + return handledError } } @@ -66,11 +73,10 @@ const del = async (url) => { } catch (error) { const handledError = handleError(error) console.error('DELETE Error:', handledError) - return handledError // return error information + return handledError } } -// for fetching files from the server const getFile = async (url, options = {}) => { const { responseType = 'blob', headers = {} } = options @@ -83,7 +89,7 @@ const getFile = async (url, options = {}) => { } catch (error) { const handledError = handleError(error) console.error('Error fetching file:', handledError) - return handledError // return error information + return handledError } } diff --git a/client/src/components/PDFViewerModal.jsx b/client/src/components/PDFViewerModal.jsx new file mode 100644 index 00000000..3a4cc499 --- /dev/null +++ b/client/src/components/PDFViewerModal.jsx @@ -0,0 +1,142 @@ +import React, { useState, useEffect, useMemo } from 'react' +import PropTypes from 'prop-types' +import { Document, Page, pdfjs } from 'react-pdf' +import { + CModal, + CModalHeader, + CModalTitle, + CModalBody, + CSpinner, + CButton, + CAlert, + CContainer, + CRow, + CCol +} from '@coreui/react' +import { getFile } from '../api/axios' + +// Configure PDF.js worker +pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +const PdfViewerModal = ({ isVisible, onClose, url, width = 600 }) => { + const [pdfData, setPdfData] = useState(null) + const [numPages, setNumPages] = useState(null) + const [pageNumber, setPageNumber] = useState(1) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + // Fetch PDF on mount + useEffect(() => { + if (!isVisible || !url) return + + const fetchPdf = async () => { + setLoading(true); + setError(null); + setPdfData(null); + + try { + const response = await getFile(url, { responseType: 'arraybuffer' }); + if (response?.data) { + const buffer = new Uint8Array(response.data); + setPdfData(buffer); + } else { + setError(new Error(response?.message || 'Unknown error fetching PDF')); + } + } catch (err) { + setError(err); + } finally { + setLoading(false); + } + }; + + fetchPdf() + }, [url, isVisible]) + + const memoizedFile = useMemo(() => ({ data: pdfData }), [pdfData]); + + // Called when PDF is loaded by react-pdf + const onDocumentLoadSuccess = ({ numPages: loadedPages }) => { + setNumPages(loadedPages) + setPageNumber(1) + } + + // Navigation handlers + const goToPrev = () => setPageNumber((prev) => Math.max(prev - 1, 1)) + const goToNext = () => setPageNumber((prev) => Math.min(prev + 1, numPages)) + + return ( + + + PDF Viewer + + + {loading && ( +
+ Fetching document... +
+ )} + {error && ( + + Error loading PDF: {error.message} + + )} + {pdfData && !loading && ( + <> + + + + Prev + + + Page {pageNumber} of {numPages || '--'} + + + = numPages} + >Next + + + + + + Loading pages... + + } + error={ + + Failed to render document. + + } + > + + + + + + + )} +
+
+ ) +} + +PdfViewerModal.propTypes = { + isVisible: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + url: PropTypes.string.isRequired, + width: PropTypes.number, +} + +export default PdfViewerModal diff --git a/client/src/components/ProtectedRoute.jsx b/client/src/components/ProtectedRoute.jsx index 54bd4d7e..3a55776d 100644 --- a/client/src/components/ProtectedRoute.jsx +++ b/client/src/components/ProtectedRoute.jsx @@ -5,13 +5,13 @@ import propTypes from 'prop-types' const ProtectedRoute = ({ children }) => { const env = import.meta.env.VITE_NODE_ENV console.log('ProtectedRoute.js: env: ', env) - const { isAuthenticated } = useContext(AuthContext) + const { isAuthenticated, isKnownDevice } = useContext(AuthContext) if (env === 'development') { return children } - if (!isAuthenticated) { + if (!isAuthenticated && !isKnownDevice) { return } return children diff --git a/client/src/config.js b/client/src/config.js index 175df6a9..6fabb229 100644 --- a/client/src/config.js +++ b/client/src/config.js @@ -10,5 +10,9 @@ export const config = { siteKey: import.meta.env.VITE_REACT_GOOGLE_RECAPTCHA_SITE_KEY, secretKey: import.meta.env.VITE_REACT_GOOGLE_RECAPTCHA_SECRET_KEY, }, + oAuth2: { + clientId: import.meta.env.VITE_REACT_GOOGLE_CLIENT_ID, + url: import.meta.env.VITE_REACT_GOOGLE_AUTH, + }, }, } diff --git a/client/src/context/authContext.jsx b/client/src/context/authContext.jsx index da350724..af909339 100644 --- a/client/src/context/authContext.jsx +++ b/client/src/context/authContext.jsx @@ -2,6 +2,7 @@ import React, { createContext, useState, useEffect } from 'react' import PropTypes from 'prop-types' import Cookies from 'js-cookie' import { post, get } from '../api/axios' + export const AuthContext = createContext({ userInformation: { _id: '', @@ -15,6 +16,9 @@ export const AuthContext = createContext({ isAuthenticated: false, login: () => {}, logout: () => {}, + sendOTP: () => {}, + verifyOTP: () => {}, + isKnownDevice: false, // New state to handle OTP page redirection }) export const AuthProvider = ({ children }) => { @@ -27,15 +31,56 @@ export const AuthProvider = ({ children }) => { username: '', status: '', role: '', - token: '', }) + const [otpSent, setOtpSent] = useState(false) + const [isKnownDevice, setIsKnownDevice] = useState(false) + // Login function with new device detection and OTP redirection logic const login = async (username, password, callback) => { try { const res = await post('/auth/login', { username, password }) if (res.status === 200) { setIsAuthenticated(true) setUserInformation(res.data.data) + + // If isKnownDevice is false, it means OTP verification is required + if (!res.data.isKnownDevice) { + setIsKnownDevice(false) + callback(true) + } else { + callback(true) // Directly authenticated + } + } else { + callback(false) + } + } catch (error) { + callback(false) + console.error(error) + } + } + + const sendOTP = async (username, callback) => { + try { + const res = await post('/auth/send-otp', { username }) + if (res.status === 200) { + setOtpSent(true) + callback(true) + } else { + callback(false) + } + } catch (error) { + callback(false) + console.error(error) + } + } + + const verifyOTP = async (otp, callback) => { + try { + const res = await post('/auth/verify-otp', { otp }) + if (res.status === 200) { + setIsAuthenticated(true) + setUserInformation(res.data.data) + setIsKnownDevice(true) callback(true) } else { callback(false) @@ -59,7 +104,6 @@ export const AuthProvider = ({ children }) => { username: '', status: '', role: '', - token: '', }) callback(true) } @@ -71,9 +115,10 @@ export const AuthProvider = ({ children }) => { const verifySession = async () => { try { - const res = await get('/auth/verify') + const res = await get('/auth/me') if (res.status === 200) { setIsAuthenticated(true) + setIsKnownDevice(true) setUserInformation(res.data.data) } } catch (error) { @@ -86,11 +131,23 @@ export const AuthProvider = ({ children }) => { }, [isAuthenticated]) return ( - + {children} ) } + AuthProvider.propTypes = { children: PropTypes.node.isRequired, } diff --git a/client/src/scss/_custom.scss b/client/src/scss/_custom.scss index e38c1d7e..78b50110 100644 --- a/client/src/scss/_custom.scss +++ b/client/src/scss/_custom.scss @@ -202,3 +202,32 @@ -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); } + +// Avatar container class +.user-image { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +@media (max-width: 800px) { + .user-image { + width: 100%; + height: 100%; + } +} + +@media (max-width: 600px) { + .user-image { + width: 100%; + height: 100%; + } +} + +@media (max-width: 400px) { + .user-image { + width: 100%; + height: 100%; + } +} diff --git a/client/src/utils/index.js b/client/src/utils/index.js index 11c7147d..d0173db1 100644 --- a/client/src/utils/index.js +++ b/client/src/utils/index.js @@ -18,12 +18,17 @@ export const formatDate = (date, format = 'MMM DD, YYYY') => { * @param {integer} value * @returns formatted currency string in PHP (Philippine Peso) format */ -export const formatCurency = (value) => { - return new Intl.NumberFormat('en-US', { +export const formatCurrency = (value) => { + const number = Number(value) + + if (isNaN(number)) return '₱0' + + return new Intl.NumberFormat('en-PH', { style: 'currency', currency: 'PHP', minimumFractionDigits: 0, - }).format(value) + maximumFractionDigits: 0, + }).format(number) } /** diff --git a/client/src/views/applicants/ApplicantProfile.jsx b/client/src/views/applicants/ApplicantProfile.jsx index eeeca1df..e14b3bfa 100644 --- a/client/src/views/applicants/ApplicantProfile.jsx +++ b/client/src/views/applicants/ApplicantProfile.jsx @@ -22,19 +22,54 @@ import { CSpinner, CInputGroupText, CFormLabel, + CAlert, + CAvatar, + CBadge, } from '@coreui/react' import React, { useContext, useEffect, useState } from 'react' import { useParams } from 'react-router-dom' import { get, put } from '../../api/axios' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' -import { useForm } from 'react-hook-form' +import { Controller, useForm } from 'react-hook-form' import { AppContext } from '../../context/appContext' import EventTab from './tabs/EventTab' import DocsTab from './tabs/DocsTab' import { AuthContext } from '../../context/authContext' +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const ACCEPTED_FILE_TYPES = ['application/pdf', 'image/jpeg', 'image/png'] + +const pdfFileSchema = z + .any() + .transform((file) => { + if (!file || (file instanceof FileList && file.length === 0)) return undefined + if (file instanceof FileList) return file.item(0) + return file + }) + .refine((file) => !file || file.size <= MAX_FILE_SIZE, { + message: 'File must be 5MB or less', + }) + .refine((file) => !file || ['application/pdf'].includes(file.type), { + message: 'Unsupported file format', + }) + +const imgFileSchema = z + .any() + .transform((file) => { + if (!file || (file instanceof FileList && file.length === 0)) return undefined + if (file instanceof FileList) return file.item(0) + return file + }) + .refine((file) => !file || file.size <= MAX_FILE_SIZE, { + message: 'File must be 5MB or less', + }) + .refine((file) => !file || ['image/jpeg', 'image/png'].includes(file.type), { + message: 'Unsupported file format', + }) + +// Your full schema with file fields const profileSchema = z.object({ firstname: z.string().min(1).max(50), lastname: z.string().min(1).max(50), @@ -44,11 +79,10 @@ const profileSchema = z.object({ phone: z.string().min(10).max(15), address: z.string().min(1), preferredWorkLocation: z.string().min(1), - linkedInProfile: z.string().url().optional(), - portfolioLink: z.string().url().optional(), + linkedInProfile: z.string().url().or(z.literal('')).optional(), + portfolioLink: z.string().url().or(z.literal('')).optional(), yearsOfExperience: z.number().min(0), currentMostRecentJob: z.string().min(1), - // highestQualification: z.string().min(1), highestQualification: z.enum(['none', 'elementary', 'high school', 'college', 'masters', 'phd']), majorFieldOfStudy: z.string().min(1), institution: z.string().min(1), @@ -61,6 +95,27 @@ const profileSchema = z.object({ availability: z.string().min(1), jobAppliedFor: z.string().min(1), whyInterestedInRole: z.string().min(1), + + // Files + files: z + .object({ + resume: pdfFileSchema.optional(), + medCert: pdfFileSchema.optional(), + birthCert: pdfFileSchema.optional(), + NBIClearance: pdfFileSchema.optional(), + policeClearance: pdfFileSchema.optional(), + TOR: pdfFileSchema.optional(), + idPhoto: imgFileSchema.optional(), + }) + .optional(), + + // IDs + ids: z.object({ + TIN: z.string().optional(), + SSS: z.string().optional(), + philHealth: z.string().optional(), + pagIBIGFundNumber: z.string().optional(), + }), }) const ApplicantProfilePage = () => { @@ -71,14 +126,30 @@ const ApplicantProfilePage = () => { const [isLoading, setIsLoading] = useState(true) const [isSubmitLoading, setIsSubmitLoading] = useState(false) + const [isRejectLoading, setIsRejectLoading] = useState(false) + + const APP_STATUS = { + journey: [ + { key: 'isShortlisted', label: 'Shortlisted', color: 'success' }, + { key: 'isInitialInterview', label: 'Initial Interview', color: 'warning' }, + { key: 'isTechnicalInterview', label: 'Technical Interview', color: 'primary' }, + { key: 'isPanelInterview', label: 'Panel Interview', color: 'danger' }, + { key: 'isBehavioralInterview', label: 'Behavior Interview', color: 'info' }, + { key: 'isFinalInterview', label: 'Final Interview', color: 'warning' }, + { key: 'isJobOffer', label: 'Job Offer', color: 'primary' }, + { key: 'isHired', label: 'Hired / Deployment', color: 'success' }, + ], + } const { register, handleSubmit, reset, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(profileSchema), + defaultValues: { keySkills: '' }, }) const getApplicant = async (id) => { @@ -106,16 +177,89 @@ const ApplicantProfilePage = () => { const updateApplicant = async (data) => { try { setIsSubmitLoading(true) - const res = await put(`/applicant/${applicant._id}`, data) + + const formData = new FormData() + + formData.append('firstname', data.firstname) + formData.append('lastname', data.lastname) + formData.append('middlename', data.middlename) + if (data.suffix) formData.append('suffix', data.suffix) + formData.append('email', data.email) + formData.append('phone', data.phone) + formData.append('address', data.address) + formData.append('preferredWorkLocation', data.preferredWorkLocation) + formData.append('linkedInProfile', data.linkedInProfile || '') + formData.append('portfolioLink', data.portfolioLink || '') + formData.append('yearsOfExperience', String(data.yearsOfExperience)) + formData.append('currentMostRecentJob', data.currentMostRecentJob) + formData.append('highestQualification', data.highestQualification) + formData.append('majorFieldOfStudy', data.majorFieldOfStudy) + formData.append('institution', data.institution) + formData.append('graduationYear', String(data.graduationYear)) + formData.append('keySkills', data.keySkills) + formData.append('softwareProficiency', data.softwareProficiency) + if (data.certifications) formData.append('certifications', data.certifications) + formData.append('coverLetter', data.coverLetter) + formData.append('salaryExpectation', String(data.salaryExpectation)) + formData.append('availability', data.availability) + formData.append('jobAppliedFor', data.jobAppliedFor) + formData.append('whyInterestedInRole', data.whyInterestedInRole) + + formData.append('TIN', data.ids?.TIN || '') + formData.append('SSS', data.ids?.SSS || '') + formData.append('philHealth', data.ids?.philHealth || '') + formData.append('pagIBIGFundNumber', data.ids?.pagIBIGFundNumber || '') + + if (data.files?.resume) formData.append('resume', data.files.resume) + if (data.files?.medCert) formData.append('medCert', data.files.medCert) + if (data.files?.birthCert) formData.append('birthCert', data.files.birthCert) + if (data.files?.NBIClearance) formData.append('NBIClearance', data.files.NBIClearance) + if (data.files?.policeClearance) + formData.append('policeClearance', data.files.policeClearance) + if (data.files?.TOR) formData.append('TOR', data.files.TOR) + if (data.files?.idPhoto) formData.append('idPhoto', data.files.idPhoto) + + const res = await put(`/applicant/${applicant._id}`, formData) + if (res.status === 200) { + console.log(res.data.data) setApplicant(res.data.data) - reset(res.data.data) - addToast('Success', res.data.message, 'success') - } else { - addToast('Error', res.message.message, 'danger') + // reset(res.data); + setValue('firstname', res.data.data.firstname) + setValue('lastname', res.data.data.lastname) + setValue('middlename', res.data.data.middlename) + setValue('suffix', res.data.data.suffix || '') + setValue('email', res.data.data.email) + setValue('phone', res.data.data.phone) + setValue('address', res.data.data.address) + setValue('preferredWorkLocation', res.data.data.preferredWorkLocation) + setValue('linkedInProfile', res.data.data.linkedInProfile || '') + setValue('portfolioLink', res.data.data.portfolioLink || '') + setValue('yearsOfExperience', res.data.data.yearsOfExperience || 0) + setValue('currentMostRecentJob', res.data.data.currentMostRecentJob) + setValue('highestQualification', res.data.data.highestQualification) + setValue('majorFieldOfStudy', res.data.data.majorFieldOfStudy) + setValue('institution', res.data.data.institution) + setValue('graduationYear', res.data.data.graduationYear) + setValue('keySkills', res.data.data.keySkills || '') + setValue('softwareProficiency', res.data.data.softwareProficiency || '') + setValue('certifications', res.data.data.certifications || '') + setValue('coverLetter', res.data.data.coverLetter || '') + setValue('salaryExpectation', res.data.data.salaryExpectation || 0) + setValue('availability', res.data.data.availability) + setValue('jobAppliedFor', res.data.data.jobAppliedFor) + setValue('whyInterestedInRole', res.data.data.whyInterestedInRole || '') + + // For nested fields (like IDs) + setValue('ids.TIN', res.data.data.ids?.TIN || '') + setValue('ids.SSS', res.data.data.ids?.SSS || '') + setValue('ids.philHealth', res.data.data.ids?.philHealth || '') + setValue('ids.pagIBIGFundNumber', res.data.data.ids?.pagIBIGFundNumber || '') + addToast('Success', res.data.data.message, 'success') } } catch (error) { console.error(error) + addToast('Error', error.message || 'Something went wrong', 'danger') } finally { setIsSubmitLoading(false) } @@ -158,6 +302,26 @@ const ApplicantProfilePage = () => { } } + const handleReject = async () => { + try { + if (!confirm(`Are you sure you want to reject this applicant?`)) { + return + } + setIsRejectLoading(true) + const res = await get(`/applicant/${applicantId}/reject`) + + if (res.status === 200) { + reset(res.data.data) + setApplicant(res.data.data) + addToast('Success', res.data.message, 'success') + } + } catch (error) { + console.log(error) + } finally { + setIsRejectLoading(false) + } + } + const handleSendData = async () => { try { if (!confirm('Are you sure you want to send this to Employee Management?')) { @@ -182,6 +346,27 @@ const ApplicantProfilePage = () => {

Applicant Profile

+ {['admin'].includes(userInformation.role) && ( + + Applicant ID: {applicantId} + + )} + + + Status:{' '} + + {applicant?.status} + + + @@ -201,95 +386,31 @@ const ApplicantProfilePage = () => { ) : ( {/* Personal Information Section */} - {['admin', 'manager'].includes(userInformation.role) && ( - <> -

Application Statuses

- - - Shortlisted - - - handleStatUpdate('isShortlisted')} - > - Update - - - - - Initial Interview - - - handleStatUpdate('isInitialInterview')} - > - Update - - - - - - - Final Interview - - - handleStatUpdate('isFinalInterview')} - > - Update - - - - - Job Offer - - - handleStatUpdate('isJobOffer')} - disabled - > - Update - - - - - - )} - - - {applicant?.isShortlisted && - applicant?.isInitialInterview && - applicant?.isFinalInterview && - applicant?.isJobOffer ? ( - handleSendData()}> - Send Applicant Information to Employee Management - - ) : ( - - Can't send data to Employee Mngmt, need Checklist to be completed - - )} +
+

Personal Information

+ getApplicant(applicant._id)}> + Refresh + +
+ + + {/* Avatar Column */} + + Applicant Avatar - -

Personal Information

- - + + {/* Form Column */} + { {errors.firstname && ( {errors.firstname.message} )} - - { {errors.lastname && ( {errors.lastname.message} )} - - - - + { {errors.middlename && ( {errors.middlename.message} )} - - + { )} + @@ -392,7 +508,51 @@ const ApplicantProfilePage = () => { )} - +

Application Status

+ + {APP_STATUS.journey.map((status) => ( + + +
+
+ {status.label} +
+ {applicant?.statuses?.journey && + status.key in applicant.statuses.journey + ? applicant.statuses.journey[status.key] + ? 'Yes' + : 'No' + : 'N/A'} +
+
+ {['admin', 'manager'].includes(userInformation.role) ? ( + handleStatUpdate(status.key)} + className="ms-2" + > + Update + + ) : ( +

No Permission

+ )} +
+
+
+ ))} +
{/* Work Information Section */}

Work Information

@@ -577,18 +737,159 @@ const ApplicantProfilePage = () => { )}
- + {['admin', 'manager', 'recruiters'].includes(userInformation.role) && ( + <> +

Pre-employment Requirements

+ + + + {errors.ids?.TIN && ( + {errors.ids.TIN.message} + )} + + + + {errors.ids?.SSS && ( + {errors.ids.SSS.message} + )} + + + + + + {errors.ids?.philHealth && ( + {errors.ids.philHealth.message} + )} + + + + {errors.ids?.pagIBIGFundNumber && ( + + {errors.ids.pagIBIGFundNumber.message} + + )} + + +
Required Documents
+ + + + {errors.files?.resume && ( + {errors.files.resume.message} + )} + + + + {errors.files?.medCert && ( + {errors.files.medCert.message} + )} + + + + + + {errors.files?.birthCert && ( + + {errors.files.birthCert.message} + + )} + + + + {errors.files?.NBIClearance && ( + + {errors.files.NBIClearance.message} + + )} + + + + + + {errors.files?.policeClearance && ( + + {errors.files.policeClearance.message} + + )} + + + + {errors.files?.TOR && ( + {errors.files.TOR.message} + )} + + + + )} - {['admin', 'manager', 'recruiter'].includes(userInformation.role) && ( - handleDelete(applicant._id)} - > - Delete - - )} +
+ {['admin', 'manager', 'recruiter'].includes(userInformation.role) && ( + handleDelete(applicant._id)} + > + Delete + + )} + {['admin', 'manager', 'recruiter'].includes(userInformation.role) && ( + handleReject(applicant._id)} + disabled={isRejectLoading} + > + {isRejectLoading ? 'Loading...' : 'Reject'} + + )} +
{isSubmitLoading ? ( <> @@ -605,12 +906,20 @@ const ApplicantProfilePage = () => { )} - - - - - - + {applicant && ( + <> + + + + + + + + )}
diff --git a/client/src/views/applicants/Interviews.jsx b/client/src/views/applicants/Interviews.jsx index a506ac5c..e959f66f 100644 --- a/client/src/views/applicants/Interviews.jsx +++ b/client/src/views/applicants/Interviews.jsx @@ -176,7 +176,7 @@ const Interviews = () => { {event.author.lastname}, {event.author.firstname} - {event.type} + {event.type} {formatDate(event.date)} {formatTime(event.timeslot?.start)} - {formatTime(event.timeslot?.end)}{' '} @@ -232,7 +232,7 @@ const Interviews = () => { -
+ {/*

Recent Interviews

@@ -299,7 +299,7 @@ const Interviews = () => { setIsInterviewFormVisible(true) }} > - Manage + Issue a Job Offer?
@@ -311,7 +311,8 @@ const Interviews = () => { - + */} + { const [joboffers, setJoboffers] = useState([]) @@ -44,6 +47,17 @@ const Joboffers = () => { const [totalPages, setTotalPages] = useState(0) const [totalItems, setTotalItems] = useState(0) + // Recent + const [applicants, setApplicants] = useState([]) + const [isApplicantsLoading, setApplicantsLoading] = useState(false) + + // Interview Form + const [isInterviewFormVisible, setIsInterviewFormVisible] = useState(false) + const [interview, setInterview] = useState({}) + const [interviewFormState, setInterviewFormState] = useState('view') + const [applicant, setApplicant] = useState({}) + const [event, setEvent] = useState({}) + const getAllJoboffers = async () => { try { setIsJobofferLoading(true) @@ -64,13 +78,42 @@ const Joboffers = () => { } } + const getAllApplicants = async () => { + try { + setApplicantsLoading(true) + const res = await get('/applicant/joboffer/all') + console.log(res.data.data) + if (res.status === 200) { + setApplicants(res.data.data) + } + } catch (error) { + console.error(error) + } finally { + setApplicantsLoading(false) + } + } + useEffect(() => { getAllJoboffers() + getAllApplicants() }, [currentPage, itemsPerPage, totalPages, totalItems]) return ( <> + + + { + setApplicant(applicant) + setJobofferFormState('create') + setJobofferFormIsVisible(true) + }} + /> + +

Joboffers

@@ -210,6 +253,7 @@ const Joboffers = () => { }} state={jobofferFormState} joboffer={jobOffer} + applicantData={applicant} />
diff --git a/client/src/views/applicants/Screening.jsx b/client/src/views/applicants/Screening.jsx index ec7813c4..8a83f764 100644 --- a/client/src/views/applicants/Screening.jsx +++ b/client/src/views/applicants/Screening.jsx @@ -75,6 +75,7 @@ const Screening = () => { // Search states const [searchInput, setSearchInput] = useState('') const [isSearchMode, setSearchMode] = useState(false) + const [isAllowReject, setIsAllowReject] = useState(false) // Form Elements states const [isEdit, setIsEdit] = useState(false) @@ -242,38 +243,40 @@ const Screening = () => { } const handleFillMockData = () => { - formReset({ - id: '', + const mockData = { firstname: 'John', lastname: 'Doe', - middlename: 'Ville', + middlename: 'A', suffix: 'Jr.', - email: 'johndoe@mail.com', - phone: '09493260755', - address: 'Quezon City, Philippines', - prefferedWorkLocation: 'Remote', - linkedInProfile: 'https://www.linkedin.com/in/johndoe', - portfolioLink: 'https://www.github.com/johndoe', + email: 'john.doe@example.com', + phone: '09171234567', + address: '123 Mockingbird Lane', + preferredWorkLocation: 'Manila', + linkedInProfile: 'https://linkedin.com/in/johndoe', + portfolioLink: 'https://johndoe.dev', yearsOfExperience: 5, - currentMostRecentJob: 'Software Developer', + currentMostRecentJob: 'Frontend Developer', highestQualification: 'college', majorFieldOfStudy: 'Computer Science', - institution: 'University of Lagos', - graduationYear: 2021, - keySkills: 'React, NodeJS, MongoDB', - softwareProficiency: 'MS Office, Adobe Suite', - certifications: 'Certification 1, Certification 2', - coverLetter: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc tincidunt ultricies. Nullam nec purus nec nunc tincidunt ultricies. Nullam nec purus nec nunc tincidunt ultricies.', - salaryExpectation: 50000, - availability: 'Immediate', - jobAppliedFor: 'Software Developer', - whyInterestedInRole: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc tincidunt ultricies. Nullam nec purus nec nunc tincidunt ultricies. Nullam nec purus nec nunc tincidunt ultricies', - - // tags: [], - // file: [], - }) + institution: 'Mock University', + graduationYear: 2020, + keySkills: 'JavaScript, React, CSS', + softwareProficiency: 'VSCode, Git, Figma', + certifications: 'AWS Certified Developer', + coverLetter: 'I am excited to apply for this role...', + salaryExpectation: 70000, + availability: 'Immediately', + jobAppliedFor: 'Web Developer', + whyInterestedInRole: 'The role aligns with my goals and experience.', + ids: { + TIN: '123-456-789', + SSS: '12-3456789-0', + philHealth: '1234-5678-9012', + pagIBIGFundNumber: '1234-5678-9012', + }, + files: {}, // left empty — optional + } + formReset(mockData) } const handleUpload = async () => { @@ -335,7 +338,9 @@ const Screening = () => { setIsLoading(true) const res = isSearchMode ? await get(`/applicant/search?query=${searchInput}&page=${page}&limit=${limit}&tags=`) - : await get(`/applicant/all?page=${currentPage}&limit=${itemsPerPage}`) + : await get(`/applicant/all?page=${page}&limit=${limit}&showRejected=${isAllowReject}`) + + console.log(res.data) if (res.status === 200 || res.status === 201) { setApplicants(res.data.data) setCurrentPage(res.data.currentPage) @@ -650,19 +655,20 @@ const Screening = () => {

Personal Information

- {config.env === 'development' && ( - - { - handleFillMockData() - }} - > - Fill Mock Data - - - )} + {config.env === 'development' || + (['admin'].includes(userInformation.role) && ( + + { + handleFillMockData() + }} + > + Fill Mock Data + + + ))}
@@ -1267,6 +1273,16 @@ const Screening = () => { + + + setIsAllowReject((prev) => !prev)} + > + {isAllowReject ? 'Hide Rejected' : 'Allow Reject'} + + + @@ -1322,11 +1338,52 @@ const Screening = () => { ) })} - {p.isShortlisted && ( + {p.statuses.journey.isShortlisted && ( Shortlisted )} + + {p.statuses.journey.isInitialInterview && ( + + Initial Interview + + )} + + {p.statuses.journey.isTechnicalInterview && ( + + Technical Interview + + )} + + {p.statuses.journey.isPanelInterview && ( + + Panel Interview + + )} + + {p.statuses.journey.isBehavioralInterview && ( + + Behavioral Interview + + )} + + {p.statuses.journey.isFinalInterview && ( + + Final Interview + + )} + + {p.statuses.journey.isJobOffer && ( + + Job Offered + + )} + {p.statuses.journey.isHired && ( + + Initial Interview + + )} diff --git a/client/src/views/applicants/Shortlisted.jsx b/client/src/views/applicants/Shortlisted.jsx index 7a15ff83..00e06a92 100644 --- a/client/src/views/applicants/Shortlisted.jsx +++ b/client/src/views/applicants/Shortlisted.jsx @@ -177,6 +177,52 @@ const Shortlisted = () => { ) })} + {app.statuses.journey.isShortlisted && ( + + Shortlisted + + )} + + {app.statuses.journey.isInitialInterview && ( + + Initial Interview + + )} + + {app.statuses.journey.isTechnicalInterview && ( + + Technical Interview + + )} + + {app.statuses.journey.isPanelInterview && ( + + Panel Interview + + )} + + {app.statuses.journey.isBehavioralInterview && ( + + Behavioral Interview + + )} + + {app.statuses.journey.isFinalInterview && ( + + Final Interview + + )} + + {app.statuses.journey.isJobOffer && ( + + Job Offered + + )} + {app.statuses.journey.isHired && ( + + Initial Interview + + )} diff --git a/client/src/views/applicants/components/EligibleForJobOffer.jsx b/client/src/views/applicants/components/EligibleForJobOffer.jsx new file mode 100644 index 00000000..f9457d56 --- /dev/null +++ b/client/src/views/applicants/components/EligibleForJobOffer.jsx @@ -0,0 +1,91 @@ +import React, { useEffect } from 'react' +import PropTypes from 'prop-types' +import { + CCard, + CCardBody, + CTable, + CTableHead, + CTableBody, + CTableRow, + CTableHeaderCell, + CTableDataCell, + CSpinner, + CButton, + CTooltip, +} from '@coreui/react' +import { formatDate, trimString } from '../../../utils' + +const EligibleForJobOffer = ({ applicants = [], loading, onIssueOffer }) => { + useEffect(() => { + console.log('Applicants: ', applicants) + }, [applicants]) + return ( + <> +

Eligible for Job Offer

+ + + + + + # + Applicant + Position + Preferred Location + Interview Date + Action + + + + {loading ? ( + + +
+ +
+
+
+ ) : applicants.length === 0 ? ( + + +
+ No eligible applicants found +
+
+
+ ) : ( + applicants.map((applicant) => ( + + + {trimString(applicant._id, 10)} + + + {applicant.lastname}, {applicant.firstname} + + {applicant.jobAppliedFor || 'N/A'} + {applicant.preferredWorkLocation || 'N/A'} + + {formatDate(applicant.interviewDate || applicant.updatedAt)} + + + onIssueOffer(applicant)}> + Issue Offer + + + + )) + )} +
+
+
+
+ + ) +} + +EligibleForJobOffer.propTypes = { + applicants: PropTypes.array.isRequired, + loading: PropTypes.bool.isRequired, + onIssueOffer: PropTypes.func, +} + +export default EligibleForJobOffer diff --git a/client/src/views/applicants/modal/FileUploadForm.jsx b/client/src/views/applicants/modal/FileUploadForm.jsx new file mode 100644 index 00000000..a9334a9d --- /dev/null +++ b/client/src/views/applicants/modal/FileUploadForm.jsx @@ -0,0 +1,127 @@ +import React, { useState, useContext } from 'react' +import { AuthContext } from '../../../context/authContext' +import propTypes from 'prop-types' +import { + CModal, + CModalHeader, + CModalBody, + CModalTitle, + CContainer, + CRow, + CCol, + CForm, + CFormLabel, + CFormInput, + CButton, + CAlert, + CSpinner, +} from '@coreui/react' +import { post } from '../../../api/axios' +import { AppContext } from '../../../context/appContext' + +const FileUploadForm = ({ isVisible, onClose, docCategory, applicantData }) => { + const { addToast } = useContext(AppContext) + const { userInformation } = useContext(AuthContext) + + const [isLoading, setIsLoading] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + + // Handle file selection + const handleFileChange = (e) => { + setSelectedFile(e.target.files[0]) + } + + // Handle file upload + const handleSubmit = async (e) => { + e.preventDefault() + + if (!selectedFile) { + addToast('Error', 'Please select a file to upload.', 'danger') + return + } + + setIsLoading(true) + + const formData = new FormData() + formData.append('file', selectedFile) + formData.append('category', docCategory) + formData.append('applicant_Id', applicantData._id) + formData.append('author_Id', userInformation._id) + + try { + const res = await post('/document/upload', formData) + + if (res.status === 200 || res.status === 201) { + addToast('Success', 'File uploaded successfully.', 'success') + setSelectedFile(null) + onClose() + } + } catch (error) { + addToast('Error', 'Failed to upload the file. Please try again later.', 'danger') + } finally { + setIsLoading(false) + } + } + + return ( + + + Upload Document + + + + + + + Select File + + + + + {isLoading && ( + + + + Uploading... + + + )} + + + + + Close + + + Upload + + + + + + + + ) +} + +FileUploadForm.propTypes = { + isVisible: propTypes.bool.isRequired, + onClose: propTypes.func.isRequired, + docCategory: propTypes.string.isRequired, + applicantData: propTypes.object.isRequired, +} + +export default FileUploadForm diff --git a/client/src/views/applicants/modal/JobOfferForm.jsx b/client/src/views/applicants/modal/JobOfferForm.jsx index 81414fc6..e5b734ac 100644 --- a/client/src/views/applicants/modal/JobOfferForm.jsx +++ b/client/src/views/applicants/modal/JobOfferForm.jsx @@ -25,44 +25,35 @@ import { zodResolver } from '@hookform/resolvers/zod' import { get, post, put } from '../../../api/axios' import { AppContext } from '../../../context/appContext' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faEnvelope, faMailForward } from '@fortawesome/free-solid-svg-icons' +import { faEnvelope } from '@fortawesome/free-solid-svg-icons' +import { formatDate } from '../../../utils' const OFFER_STATUSES = ['Pending', 'Accepted', 'Declined'] +const JOB_TYPES = ['Contractual', 'Regular', 'Temporary', 'Freelance'] const jobofferSchema = z.object({ position: z.string().min(1).max(30), salary: z.coerce.number().min(1).default(1), - // startDate: z.date(), startDate: z.string().refine((val) => !isNaN(Date.parse(val)), { message: 'Invalid date' }), - benefits: z.string().min(1, { message: 'Benefits is required' }), - status: z.enum(['Pending', 'Accepted', 'Declined']), + benefits: z.string().min(1, { message: 'Benefits are required' }), + jobType: z.enum(JOB_TYPES), + contractDuration: z.string().optional(), + location: z.string().min(1, { message: 'Location is required' }), + status: z.enum(OFFER_STATUSES), notes: z.string().optional(), - approvedBy: z.string().optional(), - // approvedDate: z.string().optional(), - approvedDate: z - .string() - .optional() - .refine((val) => !isNaN(Date.parse(val)), { message: 'Invalid date' }), }) -const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => { +const JobOfferForm = ({ isVisible, onClose, state, joboffer, applicantData }) => { const { addToast } = useContext(AppContext) const { userInformation } = useContext(AuthContext) - + const [applicant, setApplicant] = useState([]) const [isConfirmed, setIsConfirmed] = useState(false) - const [jobofferData, setJobofferData] = useState({}) - const [interviewData, setInterviewData] = useState({}) - const [isSubmitLoading, setIsSubmitLoading] = useState(false) - const [isEmailLoading, setIsEmailLoading] = useState(false) - const [isFormLoading, setIsFormLoading] = useState(false) - const [formState, setFormState] = useState('view') const [isFormVisible, setIsFormVisible] = useState(false) - const [isApproved, setIsApproved] = useState(false) const { @@ -70,7 +61,6 @@ const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => { handleSubmit, reset, setValue, - watch, formState: { errors }, } = useForm({ resolver: async (data, context, options) => { @@ -82,59 +72,48 @@ const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => { const jobofferSubmit = async (data) => { try { + console.log('clicked', data) setIsSubmitLoading(true) - const appId = interviewData.applicant?._id - const res = - formState === 'edit' - ? await put(`/applicant/joboffer/${jobofferData._id}?isApproved=${isApproved}`, data) - : await post(`/applicant/joboffer/${appId}`, data) - console.log('res', JSON.stringify(res.data, null, 2)) - - switch (res.status) { - case 201: - addToast('Success', res.data.message, 'success') - setJobofferData(res.data.data) - break - case 200: - addToast('Success', res.data.message, 'success') - setJobofferData(res.data.data) - break - - default: - addToast('Error', res.message.message, 'danger') - break + const formData = { + position: data.position, + salary: data.salary, + startDate: data.startDate, + benefits: data.benefits, + notes: data.notes, + jobType: data.jobType, + contractDuration: data.contractDuration, + location: data.location, + status: data.status, } - onClose() - } catch (error) { - console.error(error) - } finally { - setIsSubmitLoading(false) - } - } - const getJoboffer = async (joboffer) => { - try { - setIsFormLoading(true) - const res = await get(`/applicant/joboffer/${joboffer._id}`) - if (res.status === 200) { + const appId = applicant._id + const res = + formState === 'edit' + ? await put(`/applicant/joboffer/${jobofferData._id}?isApproved=${isApproved}`, formData) + : await post(`/applicant/joboffer/${appId}`, formData) + + if (res.status === 200 || res.status === 201) { + addToast('Success', res.data.message, 'success') + setFormState('edit') setJobofferData(res.data.data) reset(res.data.data) + } else { + addToast('Error', res.message.message, 'danger') } + + onClose() } catch (error) { console.error(error) } finally { - setIsFormLoading(false) + setIsSubmitLoading(false) } } const handleSendEmail = async (jobofferId) => { try { setIsEmailLoading(true) - console.log(jobofferId) const res = await post(`/applicant/joboffer/send-email/${jobofferId}`) - console.log('job Offer Email', JSON.stringify(res.data, null, 2)) - if (res.status === 200) { addToast('Success', res.data.message, 'success') } @@ -147,46 +126,36 @@ const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => { useEffect(() => { if (isVisible) { - console.log('Stae', JSON.stringify(formState)) setIsFormVisible(isVisible) - if (interview) { - setInterviewData(interview) - reset({ - position: interview.job ?? 'No Data', - startDate: new Date(interview.date).toISOString().split('T')[0], - salary: interview.salaryExpectation, - status: 'Pending', - }) + if (applicant) { + setApplicant(applicantData) } if (joboffer) { setJobofferData(joboffer) + setApplicant(joboffer.applicant) reset({ position: joboffer.position ?? 'No Data', - startDate: new Date(joboffer.startDate).toISOString().split('T')[0], + startDate: joboffer.startDate + ? new Date(joboffer.startDate).toISOString().split('T')[0] + : 'No Data', salary: joboffer.salary, - status: joboffer.status, + status: joboffer.status ?? 'Pending', // Ensuring default value benefits: joboffer.benefits, notes: joboffer.notes, - approvedBy: joboffer.approvedBy - ? `${joboffer.approvedBy.lastname}, ${joboffer.approvedBy.firstname}` - : '', - approvedDate: joboffer.approvedDate - ? new Date(joboffer.approvedDate).toISOString().split('T')[0] - : new Date(), + jobType: joboffer.jobType ?? 'Regular', // New field for job type + contractDuration: joboffer.contractDuration ?? '', + location: joboffer.location ?? '', + }) + } else { + reset({ + position: applicant.jobAppliedFor, + location: applicant.preferredWorkLocation, }) } } - }, [isVisible, interview, joboffer, formState]) - - useForm(() => { - if (formState) { - if (formState === 'edit' || formState === 'create') { - setIsReadOnly(false) - } - } - }, [formState]) + }, [isVisible, joboffer, formState]) useEffect(() => { if (state) { @@ -220,10 +189,18 @@ const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => { + + +

+ {applicant.lastname}, {applicant.firstname} {applicant.middlename}{' '} + {applicant.suffix && applicant.suffix} +

+
+
{ { Start Date - { + {JOB_TYPES.map((type, index) => ( + + ))} + + {errors.jobType && ( + {errors.jobType.message} + )} + + + + + - {OFFER_STATUSES.map((o, index) => { - return ( - - ) - })} + {OFFER_STATUSES.map((status, index) => ( + + ))} + {errors.status && ( + {errors.status.message} + )} + + + + {errors.location && ( + {errors.location.message} + )} - {jobofferData?.approvedBy && ( - - - - {errors.approvedBy && ( - {errors.approvedBy.message} - )} - - - - {errors.approvedDate && ( - {errors.approvedDate.message} - )} - - - )} { {errors.notes && {errors.notes.message}} - {!joboffer.approvedBy && ( + {!joboffer?.approvedBy && ( { size="sm" onClick={() => setIsApproved(!isApproved)} > - {isApproved ? 'Approve' : 'Disapproved'} + {isApproved ? 'Approve' : 'Disapprove'} @@ -351,8 +332,18 @@ const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => {
+ {joboffer?.approvedBy && ( + +

+ Approved Date: {formatDate(jobofferData.approvedDate)} +
+ Approved By:{' '} + {(jobofferData.approvedBy.lastname, jobofferData.approvedBy.firstname)} +

+
+ )} - {!joboffer.emailSent && joboffer.approvedBy && ( + {!joboffer?.emailSent && joboffer?.approvedBy && ( { <> Loading... - ) : formState === 'create' ? ( - 'Issue Job Offer' + ) : formState === 'edit' ? ( + 'Update' ) : ( - 'Update Job Offer' + 'Submit' )} @@ -393,9 +384,9 @@ const JobOfferForm = ({ isVisible, onClose, state, interview, joboffer }) => { JobOfferForm.propTypes = { isVisible: propTypes.bool.isRequired, onClose: propTypes.func.isRequired, - state: propTypes.string.isRequired, - interview: propTypes.object, + state: propTypes.string, joboffer: propTypes.object, + applicantData: propTypes.object, } export default JobOfferForm diff --git a/client/src/views/applicants/modal/ScheduleForm.jsx b/client/src/views/applicants/modal/ScheduleForm.jsx index d9b30f1a..3df971e9 100644 --- a/client/src/views/applicants/modal/ScheduleForm.jsx +++ b/client/src/views/applicants/modal/ScheduleForm.jsx @@ -107,9 +107,7 @@ const ScheduleForm = ({ isVisible, onClose, isDarkMode, applicantData }) => { const handleBookApplicantToEvent = async (eventId) => { try { setIsBookingLoading(true) - const formData = new FormData() - formData.append('applicantId', applicantData._id) - const res = await post(`/facilities/events/${eventId}/book`, formData) + const res = await post(`/facilities/events/${eventId}/book/applicant/${applicantData._id}`) console.log('Response', res.data) if (res.status === 200 || res.status === 201) { addToast('Success', 'Applicant has been booked to the event', 'success') diff --git a/client/src/views/applicants/modal/ScreeningForm.jsx b/client/src/views/applicants/modal/ScreeningForm.jsx index 42bd26bf..b0fd30ae 100644 --- a/client/src/views/applicants/modal/ScreeningForm.jsx +++ b/client/src/views/applicants/modal/ScreeningForm.jsx @@ -218,11 +218,11 @@ const ScreeningForm = ({ isVisible, onClose, state, applicant }) => { const res = JSearchQuery ? await get( - `/applicant/screen/all/${applicant._id}?query=${JSearchQuery}&page=${currentPage}&limit=${itemsPerPage}&sort=desc`, - ) + `/applicant/screen/all/${applicant._id}?query=${JSearchQuery}&page=${currentPage}&limit=${itemsPerPage}&sort=desc`, + ) : await get( - `/applicant/screen/all/${applicant._id}?page=${currentPage}&limit=${itemsPerPage}&sort=desc`, - ) + `/applicant/screen/all/${applicant._id}?page=${currentPage}&limit=${itemsPerPage}&sort=desc`, + ) const sce = res.data setScreenings(sce.data) setTotalItems(sce.totalItems) @@ -781,17 +781,25 @@ const ScreeningForm = ({ isVisible, onClose, state, applicant }) => {
{j.title}
- Responsibilities:{' '} + Author: + {typeof job.author === 'string' + ? job.author // If it's a string, display it as is + : job.author && job.author.firstname // If it's an object, display full name + ? `${job.author.firstname} ${job.author.lastname}` + : 'Unknown'} + + + Responsibilities: {trimString(j.responsibilities, 50)}
- Requirements:{' '} + Requirements: {trimString(j.requirements, 50)}
- Qualifications:{' '} + Qualifications: {trimString(j.qualifications, 50)}
@@ -863,7 +871,7 @@ const ScreeningForm = ({ isVisible, onClose, state, applicant }) => { - Custom Prompt{' '} + Custom Prompt (Do not use if possible) diff --git a/client/src/views/applicants/tabs/DocsTab.jsx b/client/src/views/applicants/tabs/DocsTab.jsx index c8c81296..077faf1d 100644 --- a/client/src/views/applicants/tabs/DocsTab.jsx +++ b/client/src/views/applicants/tabs/DocsTab.jsx @@ -44,8 +44,9 @@ import { import Interviews from './Interviews' import Screenings from './Screenings' import Joboffers from './Joboffers' +import FilesTab from './FilesTab' -const DocsTab = ({ applicantId }) => { +const DocsTab = ({ applicantId, applicantFiles, applicantInterviews }) => { return ( <> @@ -56,6 +57,7 @@ const DocsTab = ({ applicantId }) => { Interviews Screenings Job Offers + Files @@ -67,6 +69,13 @@ const DocsTab = ({ applicantId }) => { + + + @@ -78,6 +87,8 @@ const DocsTab = ({ applicantId }) => { DocsTab.propTypes = { applicantId: propTypes.string, + applicantFiles: propTypes.object, + applicantInterviews: propTypes.object, } export default DocsTab diff --git a/client/src/views/applicants/tabs/FilesTab.jsx b/client/src/views/applicants/tabs/FilesTab.jsx new file mode 100644 index 00000000..acd4b219 --- /dev/null +++ b/client/src/views/applicants/tabs/FilesTab.jsx @@ -0,0 +1,167 @@ +import React, { useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { CContainer, CListGroup, CListGroupItem, CButton, CSpinner } from '@coreui/react' +import PdfViewerModal from '../../../components/PDFViewerModal' +import { post } from '../../../api/axios' + +const FilesTab = ({ applicantId, files = [], interviews = [] }) => { + const [selectedFileType, setSelectedFileType] = useState(null) + const [viewerVisible, setViewerVisible] = useState(false) + const [loadingKey, setLoadingKey] = useState(null) + const [allFiles, setAllFiles] = useState({ ...files, ...interviews }) // Manage state locally + const fileInputRef = useRef() + + // Human-friendly labels + const fileLabels = { + resume: 'Resume', + medCert: 'Medical Certificate', + birthCert: 'Birth Certificate', + NBIClearance: 'NBI Clearance', + policeClearance: 'Police Clearance', + TOR: 'Transcript of Record (TOR)', + idPhoto: 'Identification Photo', + InitialInterview: 'Initial Interview', + TechnicalInterview: 'Technical Interview', + PanelInterview: 'Panel Interview', + BehavioralInterview: 'Behavioral Interview', + FinalInterview: 'Final Interview', + } + + const validFileKeys = [ + 'resume', + 'medCert', + 'birthCert', + 'NBIClearance', + 'policeClearance', + 'TOR', + 'idPhoto', + ] + const validInterviewKeys = [ + 'InitialInterview', + 'TechnicalInterview', + 'PanelInterview', + 'BehavioralInterview', + 'FinalInterview', + ] + + const handleOpen = (fileType) => { + if (!allFiles[fileType]) return + setLoadingKey(fileType) + setTimeout(() => { + setSelectedFileType(fileType) + setViewerVisible(true) + setLoadingKey(null) + }, 200) + } + + const handleUploadClick = (key) => { + fileInputRef.current.dataset.fileType = key + fileInputRef.current.click() + } + + const handleFileChange = async (e) => { + const file = e.target.files[0] + const fileType = e.target.dataset.fileType + if (!file || !fileType) return + + const formData = new FormData() + formData.append('file', file) + + try { + setLoadingKey(fileType) + // Upload the file via the axios POST method + const res = await post(`/applicant/file/${applicantId}/interview/${fileType}`, formData) + + if (res.status === 200) { + console.log(res.data) + // Assuming the file name or URL is returned + setAllFiles((prevFiles) => ({ + ...prevFiles, + [fileType]: file.name, // You can replace with file URL if needed + })) + } else { + alert('Upload failed') + } + } catch (err) { + console.error('Upload error:', err) + alert('Upload failed') + } finally { + setLoadingKey(null) + e.target.value = null + } + } + + const handleClose = () => { + setViewerVisible(false) + setSelectedFileType(null) + } + + return ( + <> + + + {Object.entries(fileLabels).map(([key, label]) => ( + + {label} + + {validFileKeys.includes(key) ? ( + handleOpen(key)} + > + {loadingKey === key ? ( + <> + Loading... + + ) : files[key] ? ( + 'View' + ) : ( + 'No File' + )} + + ) : validInterviewKeys.includes(key) ? ( + loadingKey === key ? ( + + ) : interviews[key] ? ( +
+ handleOpen(key)}> + View + + handleUploadClick(key)}> + Reupload + +
+ ) : ( + handleUploadClick(key)}> + Upload + + ) + ) : null} +
+ ))} +
+
+ + + + {selectedFileType && ( + + )} + + ) +} + +FilesTab.propTypes = { + applicantId: PropTypes.string.isRequired, + files: PropTypes.object.isRequired, + interviews: PropTypes.object.isRequired, +} + +export default FilesTab diff --git a/client/src/views/applicants/tabs/Interviews.jsx b/client/src/views/applicants/tabs/Interviews.jsx index 67274fb9..6fdf55aa 100644 --- a/client/src/views/applicants/tabs/Interviews.jsx +++ b/client/src/views/applicants/tabs/Interviews.jsx @@ -42,7 +42,7 @@ import { } from '@coreui/react' import { get } from '../../../api/axios' -import { formatCurency, formatDate } from '../../../utils' +import { formatCurrency, formatDate } from '../../../utils' import JobOfferForm from '../modal/JobOfferForm' import AppPagination from '../../../components/AppPagination' import { AuthContext } from '../../../context/authContext' @@ -285,7 +285,7 @@ const Interviews = ({ applicantId }) => { readOnly defaultValue={ int.salaryExpectation - ? formatCurency(parseInt(int.salaryExpectation)) + ? formatCurrency(parseInt(int.salaryExpectation)) : 'No data' } /> diff --git a/client/src/views/applicants/tabs/Joboffers.jsx b/client/src/views/applicants/tabs/Joboffers.jsx index a8d25775..719e24fa 100644 --- a/client/src/views/applicants/tabs/Joboffers.jsx +++ b/client/src/views/applicants/tabs/Joboffers.jsx @@ -43,7 +43,7 @@ import { } from '@coreui/react' import { get } from '../../../api/axios' -import { formatCurency, formatDate } from '../../../utils' +import { formatCurrency, formatDate } from '../../../utils' import AppPagination from '../../../components/AppPagination' @@ -174,7 +174,7 @@ const Joboffers = ({ applicantId }) => {
diff --git a/client/src/views/auth/Login.jsx b/client/src/views/auth/Login.jsx index 85604c92..c63fd7c5 100644 --- a/client/src/views/auth/Login.jsx +++ b/client/src/views/auth/Login.jsx @@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { config } from '../../config' import ReCAPTCHA from 'react-google-recaptcha' +import GoogleButton from 'react-google-button' import { CButton, CCard, @@ -30,7 +31,7 @@ import { faUser, faLock, faEye, faEyeSlash } from '@fortawesome/free-solid-svg-i const Login = () => { const navigate = useNavigate() const recaptchaRef = useRef() - const { login, isAuthenticated } = useContext(AuthContext) + const { login, sendOTP, otpSent, isKnownDevice } = useContext(AuthContext) const { addToast } = useContext(AppContext) const [isPasswordVisible, setIsPasswordVisible] = useState(false) const [errorMessage, setErrorMessage] = useState('') @@ -49,20 +50,7 @@ const Login = () => { resolver: zodResolver(loginSchema), }) - // const onSubmit = (data) => { - // setIsLoading(true) - // const token = recaptchaRef.current.getValue() - // login(data.username, data.password, (success) => { - // setIsLoading(false) - // if (success) { - // navigate('/dashboard/overview') - // } else { - // setErrorMessage('Invalid username or password') - // } - // }) - // } - - const onSubmit = (data) => { + const onSubmit = async (data) => { setIsLoading(true) if (recaptchaRef.current) { @@ -90,22 +78,33 @@ const Login = () => { return } + // Send OTP after login success login(formData.username, formData.password, (success) => { - setIsLoading(false) if (success) { - // navigate('/dashboard/overview') - navigate('/recruitment/jobposting') + // If OTP is needed, navigate to OTP page + if (isKnownDevice) { + setIsLoading(false) + navigate('/dashboard/overview') + } else { + sendOTP(formData.username, (otpSuccess) => { + setIsLoading(false) + if (otpSuccess) { + navigate('/otp') // Redirect to OTP page after sending OTP + } else { + setErrorMessage('Failed to send OTP. Please try again.') + } + }) + } } else { - setErrorMessage('Invalid username or password') + setIsLoading(false) + setErrorMessage('Login failed. Please check your credentials.') } }) } - useEffect(() => { - if (isAuthenticated) { - navigate('/dashboard') - } - }, [isAuthenticated, navigate]) + const handleGoogleLogin = () => { + window.location.href = config.google.oAuth2.url + } return (
@@ -165,33 +164,12 @@ const Login = () => { )} -

- - By continuing, you agree to our - navigate('/PolicyTerms')} className="link-primary"> - {' '} - Privacy Policy{' '} - - and - navigate('/PolicyTerms')} className="link-primary"> - {' '} - Terms of Service - - . - -

- -
+
{!isLoading ? ( - <> - - Login - - navigate('/register')}> - Signup - - + + Login + ) : ( Logging in... @@ -199,27 +177,17 @@ const Login = () => { )}
+ +
+

OR

+
+ + handleGoogleLogin()} /> - {config.env === 'development' && ( - - -
Developer Build
-
-
- )} - - -
- - {config.appVersion} - -
-
-
) diff --git a/client/src/views/auth/Otp.jsx b/client/src/views/auth/Otp.jsx new file mode 100644 index 00000000..66a870fb --- /dev/null +++ b/client/src/views/auth/Otp.jsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect, useContext } from 'react' +import { + CContainer, + CRow, + CCol, + CForm, + CFormLabel, + CFormInput, + CButton, + CAlert, + CFormFeedback, +} from '@coreui/react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { AuthContext } from '../../context/authContext' +import { post } from '../../api/axios' +import { useNavigate } from 'react-router-dom' + +// Zod schema +const otpSchema = z.object({ + otp: z + .string() + .min(6, 'OTP must be 6 digits') + .max(6, 'OTP must be 6 digits') + .regex(/^\d+$/, 'OTP must only contain numbers'), +}) + +const OTPPage = () => { + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(otpSchema), + }) + + const navigate = useNavigate() + const { userInformation } = useContext(AuthContext) + + const [resendTimer, setResendTimer] = useState(0) + const [resendMessage, setResendMessage] = useState('') + const [submissionError, setSubmissionError] = useState('') + const [successMessage, setSuccessMessage] = useState('') + + const onSubmit = async (data) => { + try { + const formData = new FormData() + formData.append('username', userInformation.username) + formData.append('otp', data.otp) + + const res = await post('/auth/verify-otp', formData) + + if (res.status === 200) { + setSuccessMessage('OTP Verified Successfully!') + setSubmissionError('') + setTimeout(() => navigate('/dashboard'), 1500) + } + } catch (error) { + console.error(error) + setSubmissionError(error?.response?.data?.message || 'Verification failed. Please try again.') + } + } + + const handleResendOtp = async () => { + try { + const formData = new FormData() + formData.append('username', userInformation.username) + + const res = await post('/auth/send-otp', formData) + + if (res.status === 200) { + setResendMessage('A new OTP has been sent to your email.') + setResendTimer(60) + reset() + } + } catch (error) { + console.error(error) + setResendMessage('Failed to resend OTP. Please try again later.') + } + } + + useEffect(() => { + if (resendTimer === 0) return + const interval = setInterval(() => { + setResendTimer((prev) => prev - 1) + }, 1000) + return () => clearInterval(interval) + }, [resendTimer]) + + return ( + + + +

Verify One-Time Password

+ + {successMessage && {successMessage}} + {submissionError && {submissionError}} + + +

+ Welcome back {userInformation.username}! We sent an OTP to your email to confirm your + information. +

+
+ + +
+ Enter OTP + + {errors.otp && ( + + {errors.otp.message} + + )} +
+
+ + Verify OTP + +
+
+ +
+ 0} + onClick={handleResendOtp} + > + {resendTimer > 0 ? `Resend OTP in ${resendTimer}s` : 'Resend OTP'} + + {resendMessage && ( + + {resendMessage} + + )} +
+
+
+
+ ) +} + +export default OTPPage diff --git a/client/src/views/dashboard/MainChart.jsx b/client/src/views/dashboard/MainChart.jsx index 922c0d02..c0dafa13 100644 --- a/client/src/views/dashboard/MainChart.jsx +++ b/client/src/views/dashboard/MainChart.jsx @@ -1,133 +1,101 @@ -import React, { useEffect, useRef } from 'react' - +import React, { useEffect, useRef, useMemo } from 'react' +import PropTypes from 'prop-types' import { CChartLine } from '@coreui/react-chartjs' import { getStyle } from '@coreui/utils' -const MainChart = () => { +const MainChart = ({ labels, datasets }) => { const chartRef = useRef(null) + // Dynamically calculate the max and stepSize for Y-axis + const { maxY, stepSize } = useMemo(() => { + const allDataPoints = datasets.flatMap(dataset => dataset.data) + const max = Math.max(...allDataPoints, 0) + const roundedMax = Math.ceil(max / 50) * 50 || 250 + return { + maxY: roundedMax, + stepSize: Math.ceil(roundedMax / 5), + } + }, [datasets]) + useEffect(() => { - document.documentElement.addEventListener('ColorSchemeChange', () => { + const updateColors = () => { if (chartRef.current) { - setTimeout(() => { - chartRef.current.options.scales.x.grid.borderColor = getStyle( - '--cui-border-color-translucent', - ) - chartRef.current.options.scales.x.grid.color = getStyle('--cui-border-color-translucent') - chartRef.current.options.scales.x.ticks.color = getStyle('--cui-body-color') - chartRef.current.options.scales.y.grid.borderColor = getStyle( - '--cui-border-color-translucent', - ) - chartRef.current.options.scales.y.grid.color = getStyle('--cui-border-color-translucent') - chartRef.current.options.scales.y.ticks.color = getStyle('--cui-body-color') - chartRef.current.update() - }) + const xGrid = chartRef.current.options.scales.x.grid + const xTicks = chartRef.current.options.scales.x.ticks + const yGrid = chartRef.current.options.scales.y.grid + const yTicks = chartRef.current.options.scales.y.ticks + + xGrid.borderColor = getStyle('--cui-border-color-translucent') + xGrid.color = getStyle('--cui-border-color-translucent') + xTicks.color = getStyle('--cui-body-color') + + yGrid.borderColor = getStyle('--cui-border-color-translucent') + yGrid.color = getStyle('--cui-border-color-translucent') + yTicks.color = getStyle('--cui-body-color') + + chartRef.current.update() } - }) - }, [chartRef]) + } - const random = () => Math.round(Math.random() * 100) + document.documentElement.addEventListener('ColorSchemeChange', updateColors) + return () => document.documentElement.removeEventListener('ColorSchemeChange', updateColors) + }, []) return ( - <> - - + }, + elements: { + line: { + tension: 0.4, + }, + point: { + radius: 0, + hitRadius: 10, + hoverRadius: 4, + hoverBorderWidth: 3, + }, + }, + }} + /> ) } +MainChart.propTypes = { + labels: PropTypes.arrayOf(PropTypes.string).isRequired, + datasets: PropTypes.arrayOf(PropTypes.object).isRequired, +} + export default MainChart diff --git a/client/src/views/dashboard/Overview.jsx b/client/src/views/dashboard/Overview.jsx index 5f09d47e..9d1b6403 100644 --- a/client/src/views/dashboard/Overview.jsx +++ b/client/src/views/dashboard/Overview.jsx @@ -1,37 +1,612 @@ +import React, { useState, useEffect } from 'react' +import classNames from 'classnames' + import { - CContainer, - CRow, - CCol, - CForm, - CFormInput, - CInputGroup, + CAvatar, CButton, - CTooltip, - CFormFeedback, - CInputGroupText, + CButtonGroup, + CCard, + CCardBody, + CCardFooter, + CCardHeader, + CCol, + CProgress, + CRow, + CTable, + CTableBody, + CTableDataCell, + CTableHead, + CTableHeaderCell, + CTableRow, } from '@coreui/react' -import React from 'react' +import CIcon from '@coreui/icons-react' +import { cilCloudDownload, cilPeople } from '@coreui/icons' + +import WidgetsBrand from '../widgets/WidgetsBrand' +import WidgetsDropdown from '../widgets/WidgetsDropdown' +import MainChart from './MainChart' +import { get } from '../../api/axios' +import { formatCurrency, formatDate } from '../../utils' +import { useNavigate } from 'react-router-dom' const Overview = () => { + const navigate = useNavigate() + const [data, setData] = useState(null) + + const now = new Date(); + const lastWeek = new Date(now); + lastWeek.setDate(now.getDate() - 7); + + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + const APP_STATUS = { + journey: [ + { key: 'isShortlisted', label: 'Shortlisted', color: 'success' }, + { key: 'isInitialInterview', label: 'Initial Interview', color: 'warning' }, + { key: 'isTechnicalInterview', label: 'Technical Interview', color: 'primary' }, + { key: 'isPanelInterview', label: 'Panel Interview', color: 'danger' }, + { key: 'isBehavioralInterview', label: 'Behavior Interview', color: 'info' }, + { key: 'isFinalInterview', label: 'Final Interview', color: 'warning' }, + { key: 'isJobOffer', label: 'Job Offer', color: 'primary' }, + { key: 'isHired', label: 'Hired / Deployment', color: 'success' }, + ], + } + + const getStageColor = (stageName) => { + switch (stageName) { + case 'Applied': + return 'primary'; // Apply blue color for "Applied" + case 'Shortlisted': + return 'success'; // Green color for "Shortlisted" + case 'Initial Interview': + return 'warning'; // Yellow color for "Initial Interview" + case 'Technical Interview': + return 'info'; // Light blue color for "Technical Interview" + case 'Panel Interview': + return 'danger'; // Red color for "Panel Interview" + case 'Behavioral Interview': + return 'dark'; // Dark color for "Behavioral Interview" + case 'Final Interview': + return 'secondary'; // Light grey color for "Final Interview" + case 'Job Offer': + return 'primary'; // Blue color for "Job Offer" + case 'Hired / Deployment': + return 'success'; // Green color for "Hired" + default: + return 'light'; // Default color if unknown + } + }; + + useEffect(() => { + const fetchData = async () => { + try { + const res = await get(`/stats`) + setData(res.data) + } catch (error) { + console.error('Error fetching data:', error) + } + } + + fetchData() + }, []) // Empty dependency array ensures it runs once after the component mounts + + if (!data) { + return
Loading...
// Optionally show a loading state while data is being fetched + } + + const gotoApplicant = (applicant) => { + try { + const applicantId = applicant._id + navigate(`/applicant/profile/${applicantId}`) + // window.open(`/applicant/profile/${applicantId}`) + } catch (error) { + console.error(error) + } + } + + const prepareChartData = (applicantsPerDay) => { + const now = new Date() + const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() + + const dayMap = {} + applicantsPerDay.forEach(entry => { + const date = new Date(entry._id) + const day = date.getDate() + dayMap[day] = entry.count + }) + + const labels = Array.from({ length: daysInMonth }, (_, i) => `${i + 1}`) + const data = labels.map(day => dayMap[parseInt(day)] || 0) + + return { + labels, + datasets: [ + { + label: 'Applicants per Day', + backgroundColor: `rgba(0, 123, 255, 0.1)`, + borderColor: '#007bff', + pointHoverBackgroundColor: '#007bff', + borderWidth: 2, + data, + fill: true, + }, + ], + } + } + + const prepareEventData = (eventsPerDay) => { + const now = new Date() + const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() + + const dayMap = {} + eventsPerDay.forEach(entry => { + const date = new Date(entry._id) + const day = date.getDate() + dayMap[day] = entry.count + }) + + const labels = Array.from({ length: daysInMonth }, (_, i) => `${i + 1}`) + const data = labels.map(day => dayMap[parseInt(day)] || 0) + + return { + labels, + datasets: [ + { + label: 'Events per Day', + backgroundColor: `rgba(255, 99, 132, 0.1)`, + borderColor: '#ff6384', + pointHoverBackgroundColor: '#ff6384', + borderWidth: 2, + data, + fill: true, + }, + ], + } + } + + const combineChartData = (applicantsPerDay, eventsPerDay) => { + const applicantsData = prepareChartData(applicantsPerDay) + const eventsData = prepareEventData(eventsPerDay) + + return { + labels: applicantsData.labels, // Same labels for both + datasets: [ + ...applicantsData.datasets, // Add applicants dataset + ...eventsData.datasets, // Add events dataset + ], + } + } + + // Extract actual data + const { + applicants: { + totalApplicants, + recentApplicants, + applicantsPerDay, + applicantsThisMonth, + currentStages + }, + jobpostings: { + totalJobPostings, + recentJobs, + activeJobPostings, + expiredJobPostings, + jobTypes, + }, + events: { + totalEvents, + recentEvents, + eventsPerDay, + eventsThisMonth, + eventTypes, + eventApprovalStatus, + eventParticipants, + eventCapacityUtilization + } + } = data + + + // Prepare progress data based on actual data + const progress = [ + { title: 'Total Applicants', value: `${totalApplicants} Applicants`, color: 'success' }, // assuming 1000 is your goal + { title: 'Recent Applicants', value: `${recentApplicants.length} Applicants`, percent: (recentApplicants.length / totalApplicants) * 100, color: 'warning' }, + { title: 'Applicants This Month', value: `${applicantsThisMonth} Applicants`, percent: (applicantsThisMonth / totalApplicants) * 100, color: 'info' }, + + { title: 'Total Events', value: `${totalEvents} Events`, color: 'warning' }, + { title: 'Recent Events', value: `${recentEvents.length} Recent Events`, percent: (recentEvents.length / totalApplicants) * 100, color: 'success' }, + { title: 'Events This Month', value: `${eventsThisMonth} Events`, percent: (eventsThisMonth / totalEvents) * 100, color: 'info' }, + ]; + + // Extract user data dynamically from recentApplicants + const table = recentApplicants.map((applicant, index) => ({ + user: { + _id: applicant._id, + name: `${applicant.firstname} ${applicant.lastname}`, // Full name + qualification: applicant.highestQualification, + experience: applicant.yearsOfExperience, // Experience in years + }, + jobAppliedFor: applicant.jobAppliedFor, // Job applied for + activity: `Last updated: ${new Date(applicant.createdAt).toLocaleString()}`, // Last updated timestamp + })) || [] + return ( <> - - - -

Dashboard

- {/* - - */} -
-
- - -

- Page is still under construction. Please check back later. -

+ {/* + + + + + +

+ Journey Stages Over Time +

+
+ + + + + +
+ {journeyProgression && ( + + )} +
+ + + {currentStages.map((item, i) => ( + + + +
{item.count}
+
{item._id}
+
+
+
+ ))} +
+
+
+
+
*/} + {/* */} + + + + + + +

+ Applicant and Events Overview +

+
+ {`${startOfMonth.toLocaleString('default', { month: 'long' })} ${startOfMonth.getFullYear()}`} +
+ +
+ + + + + +
+ +
+ + {progress.map((item, index, items) => ( + +
{item.title}
+
+ {item.value} {item.percent ? `(` + item.percent.toFixed(0) + `%)` : ''} +
+ { + item.percent + ? + : + } +
+ ))} +
+
+
+
+
+ {/* */} + + {currentStages.map((item, i) => ( + + + +
{item.count}
+
{item._id}
+
+
-
-
+ ))} + + + +
Recent Applicants
+
+
+ + + + + + + + {/* ID */} + User + Qualification + Experience + Job Applied For + Activity + Actions + + + + {recentApplicants.map((applicant, index) => ( + + + {/* */} + { + applicant.avatar ? ( + + ) : ( + + ) + } + + {/* {applicant._id} */} + {`${applicant.firstname} ${applicant.lastname}`} + {applicant.highestQualification} + {applicant.yearsOfExperience} years + {applicant.jobAppliedFor} + + Last updated: {formatDate(applicant.createdAt)} + + + gotoApplicant(applicant)}> + Manage + + + + ))} + + + + + + + Top Jobs Applied For + +
+ Job Title + Applicants +
+ {data.applicants?.histograms.jobAppliedFor.map((item, index) => ( +
+ {item._id || 'Unknown'} + {item.count} +
+ ))} +
+
+
+ + + + Top Qualifications + +
+ Qualification + Applicants +
+ {data.applicants?.histograms.qualifications.map((item, index) => ( +
+ {item._id || 'Unknown'} + {item.count} +
+ ))} +
+
+
+ + + + Graduation Year Distribution + +
+ Year + Applicants +
+ {data.applicants?.histograms.graduationYear.map((item, index) => ( +
+ {item._id} + {item.count} +
+ ))} +
+
+
+ + + + Experience Breakdown + +
+ Years + Applicants +
+ {data.applicants?.histograms.yearsOfExperience.map((item, index) => ( +
+ {item._id} + {item.count} +
+ ))} +
+
+ +
+
+ + +

Jobpostings Overview

+
+
+ + + + Total Job Postings + +
{totalJobPostings}
+
All job postings created
+
+
+
+ + + + Active Job Postings + +
{activeJobPostings}
+
Currently published and accepting applicants
+
+
+
+ + + + Expired Job Postings + +
{expiredJobPostings}
+
No longer active
+
+
+
+
+ + +
Recent Job Postings
+
+
+ + + + + Title + Type + Location + Salary Range + Status + Created At + Actions + + + + {recentJobs.map((job, index) => ( + + {job.title} + {job.type} + {job.location} + {formatCurrency(job.salary_min)} - {formatCurrency(job.salary_max)} + {job.status || '—'} + {formatDate(job.createdAt)} + + + Manage + + + + ))} + + + + + + +

Events Overview

+
+
+ + +
Upcoming / Recent Events
+
+
+ + + + + Name + Type + Date + Available + Facility + Created At + {/* Actions */} + + + + {recentEvents.map((job, index) => ( + + {job.name} + {job.type} + {formatDate(job.date)} + {job.isAvailable ? 'Yes' : 'No'} + {job.facility.name} + {formatDate(job.createdAt)} + {/* + + Manage + + */} + + ))} + + + ) } diff --git a/client/src/views/facility/modals/EventForm.jsx b/client/src/views/facility/modals/EventForm.jsx index 623670da..fafcf93e 100644 --- a/client/src/views/facility/modals/EventForm.jsx +++ b/client/src/views/facility/modals/EventForm.jsx @@ -24,13 +24,15 @@ import { CFormLabel, CFormSelect, CFormSwitch, + CInputGroupText, + CInputGroup, } from '@coreui/react' import React, { useContext, useEffect, useState } from 'react' import propTypes from 'prop-types' import { AppContext } from '../../../context/appContext' import { z } from 'zod' -import { useForm } from 'react-hook-form' +import { useFieldArray, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { del, get, post, put } from '../../../api/axios' import { config } from '../../../config' @@ -41,6 +43,29 @@ import InterviewForm from './InterviewForm' import ConfirmationDialog from '../../../components/ConfirmationDialog' +const EVENT_TYPES = [ + 'Initial Interview', + 'Final Interview', + 'Technical Interview', + 'Panel Interview', + 'Behavioral Interview', + 'Orientation', + 'Other', +] + +const EventSchema = z.object({ + name: z.string().min(3).max(50), + description: z.string().optional(), + capacity: z.number().int().positive(), + type: z.enum(EVENT_TYPES, { + errorMap: () => ({ message: 'Invalid Event Type' }), + }), + isApproved: z + .union([z.boolean(), z.object({ status: z.boolean() })]) + .transform((val) => (typeof val === 'boolean' ? val : val.status)) + .default(false), +}) + const EventForm = ({ isVisible, onClose, slot, state }) => { const { addToast } = useContext(AppContext) const { userInformation } = useContext(AuthContext) @@ -49,7 +74,6 @@ const EventForm = ({ isVisible, onClose, slot, state }) => { const [isEventLoading, setIsEventLoading] = useState(false) const [eventFormState, setEventFormState] = useState('view') const [isReadOnly, setIsReadOnly] = useState(true) - const [eventTypes, setEventTypes] = useState(['Initial Interview', 'Final Interview', 'Other']) const [isSubmitLoading, setIsSubmitLoading] = useState(false) const [isRemoveLoading, setIsRemoveLoading] = useState(false) @@ -95,21 +119,9 @@ const EventForm = ({ isVisible, onClose, slot, state }) => { } } - const EventSchema = z.object({ - name: z.string().min(3).max(50), - description: z.string().optional(), - capacity: z.number().int().positive(), - type: z.enum(eventTypes, { - errorMap: () => ({ message: 'Invalid Event Type' }), - }), - isApproved: z - .union([z.boolean(), z.object({ status: z.boolean() })]) - .transform((val) => (typeof val === 'boolean' ? val : val.status)) - .default(false), - }) - const { register, + control, watch, handleSubmit, reset: formReset, @@ -130,12 +142,21 @@ const EventForm = ({ isVisible, onClose, slot, state }) => { const handleEventSubmit = async (data) => { try { setIsSubmitLoading(true) - const formData = new FormData() - formData.append('name', data.name) - formData.append('description', data.description) - formData.append('capacity', data.capacity) - formData.append('type', data.type) - formData.append('isApproved', data.isApproved) + // const formData = new FormData() + // formData.append('name', data.name) + // formData.append('description', data.description) + // formData.append('capacity', data.capacity) + // formData.append('type', data.type) + // formData.append('isApproved', data.isApproved) + + const formData = { + name: data.name, + description: data.description, + requirements: data.requirements, + capacity: data.capacity, + type: data.type, + isApproved: data.isApproved, + } console.log('Event Form', formData) @@ -406,7 +427,7 @@ const EventForm = ({ isVisible, onClose, slot, state }) => { disabled={isReadOnly} > - {eventTypes.map((eventType) => ( + {EVENT_TYPES.map((eventType) => ( diff --git a/client/src/views/facility/modals/FacilityForm.jsx b/client/src/views/facility/modals/FacilityForm.jsx index 7f62f49b..6ab207a5 100644 --- a/client/src/views/facility/modals/FacilityForm.jsx +++ b/client/src/views/facility/modals/FacilityForm.jsx @@ -24,56 +24,84 @@ import { CModalBody, CModalFooter, CFormTextarea, + CFormLabel, } from '@coreui/react' import React, { useContext, useEffect, useState } from 'react' import propTypes from 'prop-types' import { AppContext } from '../../../context/appContext' import { z } from 'zod' -import { useForm } from 'react-hook-form' +import { useFieldArray, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { post, put } from '../../../api/axios' import { config } from '../../../config' +import { AuthContext } from '../../../context/authContext' + +const RequirementSchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().optional(), +}) + +const formSchema = z.object({ + name: z.string().nonempty('Name is required'), + type: z.string().nonempty('Type is required'), + description: z.string().nonempty('Description is required'), + requirements: z.array(RequirementSchema).optional(), + location: z.string().nonempty('Location is required'), +}) const FacilityForm = ({ isVisible, onClose, isEdit, facilityData }) => { const { addToast } = useContext(AppContext) + const { userInformation } = useContext(AuthContext) const [isSubmitLoading, setIsSubmitLoading] = useState(false) - const formSchema = z.object({ - name: z.string().nonempty('Name is required'), - type: z.string().nonempty('Type is required'), - description: z.string().nonempty('Description is required'), - location: z.string().nonempty('Location is required'), - }) - const { register, + control, handleSubmit, + setValue, reset: formReset, formState: { errors }, } = useForm({ resolver: zodResolver(formSchema), + defaultValues: facilityData || { requirements: [] }, // Ensure default values are set on load + }) + + const { fields, append, remove } = useFieldArray({ + control, + name: 'requirements', }) const handleFormSubmit = async (data) => { console.log('Form data:', data) try { setIsSubmitLoading(true) - const formData = new FormData() - formData.append('name', data.name) - formData.append('type', data.type) - formData.append('description', data.description) - formData.append('location', data.location) + + const formData = { + name: data.name, + type: data.type, + description: data.description, + requirements: data.requirements || [], + location: data.location, + } const res = isEdit ? await put(`/facilities/update/${facilityData._id}`, formData) - : await post('/facilities/create', data) + : await post('/facilities/create', formData) + if (res.status === 200 || res.status === 201) { addToast('Success', 'Facility Added Successfully', 'success') } + + // Update form values with the response data + setValue('name', res.data.data.name) + setValue('type', res.data.data.type) + setValue('description', res.data.data.description) + setValue('requirements', res.data.data.requirements || []) + setValue('location', res.data.data.location) + setIsSubmitLoading(false) - formReset() onClose() } catch (error) { console.error(error) @@ -87,6 +115,7 @@ const FacilityForm = ({ isVisible, onClose, isEdit, facilityData }) => { type: 'Facility Type', description: 'Facility Description', location: 'Facility Location', + requirements: [{ title: 'Requirement 1', description: 'Description 1' }], }) } @@ -95,15 +124,16 @@ const FacilityForm = ({ isVisible, onClose, isEdit, facilityData }) => { formReset() } - if (isEdit) { + if (isEdit && facilityData) { formReset({ name: facilityData?.name, type: facilityData?.type, description: facilityData?.description, location: facilityData?.location, + requirements: facilityData?.requirements || [], // Default empty array if no requirements }) } - }, [isVisible, isEdit]) + }, [isVisible, isEdit, facilityData, formReset]) return ( { label="Facility Name" placeholder="Facility Name" {...register('name')} - defaultValue={facilityData?.name} invalid={!!errors?.name} /> {errors.name &&
{errors.name.message}
} @@ -142,7 +171,6 @@ const FacilityForm = ({ isVisible, onClose, isEdit, facilityData }) => { label="Type" placeholder="Type" {...register('type')} - defaultValue={facilityData?.type} invalid={!!errors?.type} /> {errors.type &&
{errors.type.message}
} @@ -156,7 +184,6 @@ const FacilityForm = ({ isVisible, onClose, isEdit, facilityData }) => { label="Description" placeholder="Description" {...register('description')} - defaultValue={facilityData?.description} invalid={!!errors?.description} /> {errors.description && ( @@ -164,6 +191,52 @@ const FacilityForm = ({ isVisible, onClose, isEdit, facilityData }) => { )} + + + Requirements + + + + + {fields.map((item, index) => ( +
+
+ Title + + + {['admin', 'manager', 'interviewer'].includes(userInformation.role) && ( + remove(index)}> + Remove + + )} + +
+
+ +
+
+ ))} + + {['admin', 'manager', 'interviewer'].includes(userInformation.role) && ( + append({ title: '', description: '' })} + > + Add Requirement + + )} +
+
{ label="Location" placeholder="Location" {...register('location')} - defaultValue={facilityData?.location} invalid={!!errors?.location} /> {errors.location &&
{errors.location.message}
} diff --git a/client/src/views/facility/modals/InterviewForm.jsx b/client/src/views/facility/modals/InterviewForm.jsx index 3d2e884f..8968e6e1 100644 --- a/client/src/views/facility/modals/InterviewForm.jsx +++ b/client/src/views/facility/modals/InterviewForm.jsx @@ -45,6 +45,7 @@ const interviewSchema = z.object({ date: z.string().nonempty('Date is required'), job: z.string().min(1, { message: 'Job is required' }), type: z.enum(['Phone', 'Video', 'In-Person']), + interviewType: z.string().optional(), general: z.object({ communication: z.coerce.number().min(1).max(5).default(1), technical: z.coerce.number().min(1).max(5).default(1), @@ -141,10 +142,30 @@ const InterviewForm = ({ isVisible, onClose, state, eventData, applicantData }) const onSubmit = async (data) => { try { + const formData = { + date: data.date, + job: data.job, + type: data.type, + interviewType: eventData.type, + general: { + communication: data.general.communication, + technical: data.general.technical, + problemSolving: data.general.problemSolving, + culturalFit: data.general.culturalFit, + workExperienceRelevance: data.general.workExperienceRelevance, + leadership: data.general.leadership, + }, + questions: data.questions || [], + salaryExpectation: data.salaryExpectation, + strength: data.strength, + weakness: data.weakness, + recommendation: data.recommendation, + finalComments: data.finalComments, + } const res = formState === 'edit' - ? await put(`/applicant/interview/${interview._id}`, data) - : await post(`/applicant/interview/${applicantData._id}/${eventData._id}`, data) + ? await put(`/applicant/interview/${interview._id}`, formData) + : await post(`/applicant/interview/${applicantData._id}/${eventData._id}`, formData) if (res.status === 200 || res.status === 201) { addToast('Success', 'Interview saved successfully', 'success') @@ -162,6 +183,7 @@ const InterviewForm = ({ isVisible, onClose, state, eventData, applicantData }) date: item.date ? item.date.split('T')[0] : '', job: item.job ? item.job : '', type: item.type || 'Phone', + interviewType: eventData?.type, general: { communication: item.general?.communication || 1, technical: item.general?.technical || 1, @@ -186,6 +208,7 @@ const InterviewForm = ({ isVisible, onClose, state, eventData, applicantData }) job: '', date: '', type: 'Phone', + interviewType: 'initialInterview', general: { communication: 1, technical: 1, diff --git a/client/src/views/facility/modals/ManageFacilityForm.jsx b/client/src/views/facility/modals/ManageFacilityForm.jsx index cd4bb109..28598786 100644 --- a/client/src/views/facility/modals/ManageFacilityForm.jsx +++ b/client/src/views/facility/modals/ManageFacilityForm.jsx @@ -100,9 +100,10 @@ const ManageFacilityForm = ({ isVisible, onClose, facility = {} }) => { console.log('Parsing States', JSON.stringify(facilityData, null, 2)) if (facilityData.timeslots) { const dates = facilityData.timeslots.map((slot) => new Date(slot.date)) - const hasSlots = dates.map( - (date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000), - ) + // const hasSlots = dates.map( + // (date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000), + // ) + const hasSlots = dates.map((date) => new Date(date)) setHasSlots(hasSlots) } } @@ -186,12 +187,20 @@ const ManageFacilityForm = ({ isVisible, onClose, facility = {} }) => { const handleTimeslotSubmit = async (data) => { try { setIsSubmitLoading(true) - const formdata = new FormData() - formdata.append('date', new Date(defaultDate)) - formdata.append('start', data.start) - formdata.append('end', data.end) + // const formData = new FormData() + // formData.append('date', new Date(defaultDate)) + // formData.append('start', data.start) + // formData.append('end', data.end) + + // console.info(formData) + + const formData = { + date: new Date(defaultDate), + start: data.start, + end: data.end, + } - const res = await post(`/facilities/timeslot/create/${facilityData._id}`, formdata) + const res = await post(`/facilities/timeslot/create/${facilityData._id}`, formData) if (res.status === 404) { setIsSubmitLoading(false) getFacilityData() diff --git a/client/src/views/job/Jobs.jsx b/client/src/views/job/Jobs.jsx index 8e169aac..4a27c85d 100644 --- a/client/src/views/job/Jobs.jsx +++ b/client/src/views/job/Jobs.jsx @@ -62,7 +62,7 @@ const JobPage = () => { setIsJobLoading(false) setJobs(res.data.data) setCurrentPage(res.data.currentPage) - setItemsPerPage(res.data.itemsPerPage) + setTotalItems(res.data.totalItems) setTotalPages(res.data.totalPages) break case 404: @@ -171,7 +171,7 @@ const JobPage = () => { ? job.author // If it's a string, display it as is : job.author && job.author.firstname // If it's an object, display full name ? `${job.author.firstname} ${job.author.lastname}` - : 'Unknown'}{' '} + : 'Unknown'}
diff --git a/client/src/views/recruitment/Jobposter.jsx b/client/src/views/recruitment/Jobposter.jsx index 12e246af..106fa614 100644 --- a/client/src/views/recruitment/Jobposter.jsx +++ b/client/src/views/recruitment/Jobposter.jsx @@ -45,7 +45,7 @@ import { faTwitter } from '@fortawesome/free-brands-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { AppContext } from '../../context/appContext' -import { formatCurency, formatDate, trimString } from '../../utils' +import { formatCurrency, formatDate, trimString } from '../../utils' const Jobposter = () => { const sectionRefs = {} @@ -280,13 +280,13 @@ const Jobposter = () => { @@ -382,7 +382,7 @@ const Jobposter = () => { id="twitterText" name="twitterText" placeholder="Twitter Post description.." - defaultValue={`NOW HIRING! \n${data.title}\nLocation: ${data.location}\nSalary: ${formatCurency(data.salary_min)} - ${formatCurency(data.salary_max)}\n=====\nPM me for more details!`} + defaultValue={`NOW HIRING! \n${data.title}\nLocation: ${data.location}\nSalary: ${formatCurrency(data.salary_min)} - ${formatCurrency(data.salary_max)}\n=====\nPM me for more details!`} {...twitterRegister('twitterText')} invalid={!!twitterErrors.twitterText} className="scalableCFormTextArea-200" @@ -424,7 +424,7 @@ const Jobposter = () => { name='facebookText' placeholder='Facebook Post description..' defaultValue={ - `NOW HIRING! \n${data.title}\nLocation: ${data.location}\nSalary: ${formatCurency(data.salary_min)} - ${formatCurency(data.salary_max)}\n=====\nPM me for more details!` + `NOW HIRING! \n${data.title}\nLocation: ${data.location}\nSalary: ${formatCurrency(data.salary_min)} - ${formatCurrency(data.salary_max)}\n=====\nPM me for more details!` } {...facebookRegister('facebookText')} invalid={!!errors.text} diff --git a/client/src/views/recruitment/Jobposting.jsx b/client/src/views/recruitment/Jobposting.jsx index 367b2c21..7aba5791 100644 --- a/client/src/views/recruitment/Jobposting.jsx +++ b/client/src/views/recruitment/Jobposting.jsx @@ -41,7 +41,7 @@ import { faCoins, } from '@fortawesome/free-solid-svg-icons' import AppPagination from '../../components/AppPagination' -import { formatDate, formatCurency, trimString } from '../../utils' +import { formatDate, formatCurrency, trimString } from '../../utils' const Jobposting = () => { const { addToast } = useContext(AppContext) @@ -643,7 +643,7 @@ const Jobposting = () => {
*/} - {formatCurency(data.salary_min)} - {formatCurency(data.salary_max)} + {formatCurrency(data.salary_min)} - {formatCurrency(data.salary_max)}

@@ -717,8 +717,8 @@ const Jobposting = () => {
*/} - {formatCurency(data.salary_min)} -{' '} - {formatCurency(data.salary_max)} + {formatCurrency(data.salary_min)} -{' '} + {formatCurrency(data.salary_max)}

diff --git a/server/package-lock.json b/server/package-lock.json index 7a823988..e61af0a2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,21 +11,25 @@ "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@types/bcryptjs": "^2.4.6", - "@types/express-session": "^1.18.0", + "@types/express-session": "^1.18.1", + "bcrypt": "^5.1.1", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0", "connect-mongo": "^5.1.0", "cors": "^2.8.5", - "dotenv": "^16.4.5", + "dotenv": "^16.5.0", "env-cmd": "^10.1.0", "express": "^4.19.2", "express-mongo-sanitize": "^2.2.0", "express-rate-limit": "^7.4.0", - "express-session": "^1.18.0", + "express-session": "^1.18.1", + "express-useragent": "^1.0.15", "express-validator": "^7.2.0", + "googleapis": "^148.0.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "mammoth": "^1.8.0", + "module-alias": "^2.2.3", "moment": "^2.30.1", "mongodb": "^6.8.0", "mongoose": "^8.5.4", @@ -45,6 +49,7 @@ "@types/cors": "^2.8.17", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", + "@types/express-useragent": "^1.0.5", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", "@types/multer": "^1.4.12", @@ -58,8 +63,10 @@ "javascript-obfuscator": "^4.1.1", "pino-pretty": "^11.2.2", "prettier": "3.3.3", + "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsc-alias": "^1.8.10", + "tsconfig-paths": "^4.2.0", "typescript": "^5.8.2", "typescript-eslint": "^8.8.0" } @@ -393,6 +400,42 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", @@ -561,9 +604,20 @@ } }, "node_modules/@types/express-session": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz", - "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/express-useragent": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/express-useragent/-/express-useragent-1.0.5.tgz", + "integrity": "sha512-G1zPW6jDj7oGJvMvB8UCIAWznCOafcksYzOg8LINfYmvrXlSGJ6nsJ7O7GlmYMjrgGYvobYaI8IhRCkAKslPJA==", + "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } @@ -1012,6 +1066,12 @@ "node": ">=10.0.0" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1068,6 +1128,41 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -1100,7 +1195,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -1138,6 +1232,40 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1239,8 +1367,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -1277,11 +1404,34 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, + "node_modules/bignumber.js": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.1.tgz", + "integrity": "sha512-+NzaKgOUvInq9TIUZ1+DRspzf/HApkCwD4btfuasFTdrfnOxqx853TgDpMolp+uv4RpRp7bPcEU2zKr9+fRmyw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1343,7 +1493,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1568,6 +1717,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/class-validator": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", @@ -1597,6 +1755,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1627,8 +1794,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "1.6.2", @@ -1708,6 +1874,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1879,6 +2051,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1896,6 +2074,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1978,9 +2165,10 @@ } }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -2032,6 +2220,12 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2598,6 +2792,7 @@ "version": "1.18.1", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "license": "MIT", "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.7", @@ -2625,6 +2820,15 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "node_modules/express-useragent": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/express-useragent/-/express-useragent-1.0.15.tgz", + "integrity": "sha512-eq5xMiYCYwFPoekffMjvEIk+NWdlQY9Y38OsTyl13IvA728vKT+q/CSERYWzcw93HGBJcIqMIsZC5CZGARPVdg==", + "license": "MIT", + "engines": { + "node": ">=4.5" + } + }, "node_modules/express-validator": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.0.tgz", @@ -2637,6 +2841,12 @@ "node": ">= 8.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-copy": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", @@ -2841,11 +3051,34 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2869,6 +3102,133 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2919,7 +3279,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2979,6 +3338,110 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "148.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-148.0.0.tgz", + "integrity": "sha512-8PDG5VItm6E1TdZWDqtRrUJSlBcNwz0/MwCa6AL81y/RxPGXJRUwKqGZfCoVX1ZBbfr3I4NkDxBmeTyOAZSWqw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2997,6 +3460,40 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3043,6 +3540,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3102,6 +3605,42 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -3185,7 +3724,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -3280,6 +3818,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", @@ -3341,6 +3888,18 @@ "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-typed-array": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", @@ -3471,6 +4030,15 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3489,6 +4057,19 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -3698,6 +4279,30 @@ "underscore": "^1.13.1" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -3838,7 +4443,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3854,11 +4458,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -3866,6 +4503,12 @@ "node": ">=10" } }, + "node_modules/module-alias": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.3.tgz", + "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -4101,6 +4744,12 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, "node_modules/node-cron": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", @@ -4187,6 +4836,21 @@ "node": ">=6.0.0" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4196,6 +4860,19 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -4283,7 +4960,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -4985,6 +5661,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5052,6 +5734,12 @@ "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5140,6 +5828,20 @@ "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==", "dev": true }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringz": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/stringz/-/stringz-2.1.0.tgz", @@ -5153,7 +5855,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -5225,6 +5926,23 @@ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dev": true }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -5305,6 +6023,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5415,6 +6134,21 @@ "strip-json-comments": "^2.0.0" } }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", @@ -5539,6 +6273,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -5690,6 +6430,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5702,8 +6451,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/xmlbuilder": { "version": "10.1.1", @@ -5721,6 +6469,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/server/package.json b/server/package.json index f4a7d195..208a7866 100644 --- a/server/package.json +++ b/server/package.json @@ -5,9 +5,9 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "ts-node-dev --respawn --transpile-only --ignore-watch node_modules src/index.ts", + "dev": "npx ts-node-dev --respawn --transpile-only --ignore-watch node_modules src/index.ts", "start": "env-cmd -f .env node dist/index.js", - "start:dev": "env-cmd -f .env.development ts-node-dev --respawn --transpile-only --ignore-watch node_modules src/index.ts", + "start:dev": "env-cmd -f .env.development npx ts-node-dev --respawn --transpile-only --ignore-watch node_modules src/index.ts", "build": "tsc", "build:alias": "tsc-alias -p tsconfig.json", "build:obfuscate": "ts-node --transpile-only ./src/obfuscate.ts", @@ -23,21 +23,25 @@ "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@types/bcryptjs": "^2.4.6", - "@types/express-session": "^1.18.0", + "@types/express-session": "^1.18.1", + "bcrypt": "^5.1.1", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0", "connect-mongo": "^5.1.0", "cors": "^2.8.5", - "dotenv": "^16.4.5", + "dotenv": "^16.5.0", "env-cmd": "^10.1.0", "express": "^4.19.2", "express-mongo-sanitize": "^2.2.0", "express-rate-limit": "^7.4.0", - "express-session": "^1.18.0", + "express-session": "^1.18.1", + "express-useragent": "^1.0.15", "express-validator": "^7.2.0", + "googleapis": "^148.0.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "mammoth": "^1.8.0", + "module-alias": "^2.2.3", "moment": "^2.30.1", "mongodb": "^6.8.0", "mongoose": "^8.5.4", @@ -57,6 +61,7 @@ "@types/cors": "^2.8.17", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", + "@types/express-useragent": "^1.0.5", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", "@types/multer": "^1.4.12", @@ -70,8 +75,10 @@ "javascript-obfuscator": "^4.1.1", "pino-pretty": "^11.2.2", "prettier": "3.3.3", + "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "tsc-alias": "^1.8.10", + "tsconfig-paths": "^4.2.0", "typescript": "^5.8.2", "typescript-eslint": "^8.8.0" } diff --git a/server/src/config.ts b/server/src/config.ts index 0cf9a58d..51c62775 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -42,6 +42,12 @@ export const config = { google: { key: process.env.GOOGLE_FORMS_KEY, + oauth2: { + id: process.env.GOOGLE_CLIENT_ID, + secret: process.env.GOOGLE_CLIENT_SECRET, + redirectURI: process.env.GOOGLE_REDIRECT_URI, + clientRedirect: process.env.GOOGLE_CLIENT_REDIRECT, + }, smtp: { host: process.env.SMTP_HOST, port: process.env.SMTP_PORT, diff --git a/server/src/config/v1/google.ts b/server/src/config/v1/google.ts new file mode 100644 index 00000000..81deb571 --- /dev/null +++ b/server/src/config/v1/google.ts @@ -0,0 +1,17 @@ +// config/google.ts +import { google } from 'googleapis'; +import { config } from '../../config'; + +export const oauth2Client = new google.auth.OAuth2( + config.google.oauth2.id, + config.google.oauth2.secret, + config.google.oauth2.redirectURI, + // 'http://localhost:8000/api/v1/auth/google/callback', +); + +// scopes: profile and email +export const googleAuthUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: ['profile', 'email'], +}); \ No newline at end of file diff --git a/server/src/database/v1/controllers/apiKeyController.ts b/server/src/database/v1/controllers/apiKeyController.ts index cb0d65eb..7812d247 100644 --- a/server/src/database/v1/controllers/apiKeyController.ts +++ b/server/src/database/v1/controllers/apiKeyController.ts @@ -1,14 +1,9 @@ -import { Request as res, Response as req } from "express"; +import { Request as req, Response as res } from "express"; import apiKey from "../models/apikeyModel"; import logger from "../../../middlewares/logger"; -interface CustomRequest extends res { - permissions?: string[]; - user?: { _id: string }; -} - -export const generateApikey = async (req: CustomRequest, res: req) => { +export const generateApikey = async (req: req, res: res) => { try { if (!req.user) { return res.status(400).json({ @@ -17,7 +12,9 @@ export const generateApikey = async (req: CustomRequest, res: req) => { message: "User not authenticated", }); } - const apiKeyData = await apiKey.find({ owner: req.user._id }); + + const userId = req.session.user?._id + const apiKeyData = await apiKey.find({ owner: userId }); return res.status(200).json({ statusCode: 200, @@ -34,7 +31,7 @@ export const generateApikey = async (req: CustomRequest, res: req) => { } }; -export const updateApikey = async (req: CustomRequest, res: req) => { +export const updateApikey = async (req: req, res: res) => { try { const { permissions, expiresAt } = req.body; const { id } = req.params; @@ -69,14 +66,16 @@ export const updateApikey = async (req: CustomRequest, res: req) => { } }; -export const createApikey = async (req: CustomRequest, res: req) => { +export const createApikey = async (req: req, res: res) => { try { const { permissions, expiresAt } = req.body; const key = Math.random().toString(36).substring(7); + const userId = req.session.user?._id + const apiKeyData = await apiKey.create({ key: key, - owner: req.user ? req.user._id : undefined, + owner: req.user ? userId : undefined, permissions: permissions || [], expiresAt: expiresAt || new Date(), }); diff --git a/server/src/database/v1/controllers/applicantController.ts b/server/src/database/v1/controllers/applicantController.ts index 3dcc7123..e8185541 100644 --- a/server/src/database/v1/controllers/applicantController.ts +++ b/server/src/database/v1/controllers/applicantController.ts @@ -5,39 +5,14 @@ import fs from "fs/promises"; import path from "path"; -import { Request, Response } from "express"; +import { Request as req, Response as res } from "express"; import logger from "../../../middlewares/logger"; -import { upload } from "../../../utils/fileUploadHandler"; - import Applicant, { IApplicant } from "../models/applicantModel"; import { config } from "../../../config"; +import { sendEmail } from "../../../utils/mailHandler"; -export const handleFileUpload = (req: Request, res: Response) => { - return new Promise((resolve, reject) => { - upload("applicants").single("file")(req, res, (err) => { - if (err) { - reject(err); - } - resolve(req.file); - }); - }); -}; - -export const addNewResume = async (req: Request, res: Response) => { +export const addApplicant = async (req: req, res: res) => { try { - console.log(req.body); - // const file = (await handleFileUpload(req, res)) as Express.Multer.File; - // if (!file) { - // return res.status(400).json({ - // statusCode: 400, - // success: false, - // message: "Please upload a file", - // }); - // } - - // const childDir = "applicants"; - // const filePath = `/${childDir}/${file.filename}`; - if (req.body.tags) { req.body.tags = req.body.tags.split(","); } @@ -53,7 +28,6 @@ export const addNewResume = async (req: Request, res: Response) => { preferredWorkLocation: req.body.preferredWorkLocation, linkedInProfile: req.body.linkedInProfile, portfolioLink: req.body.portfolioLink, - // resumeFileLoc: filePath, yearsOfExperience: req.body.yearsOfExperience, currentMostRecentJob: req.body.currentMostRecentJob, highestQualification: req.body.highestQualification, @@ -74,225 +48,242 @@ export const addNewResume = async (req: Request, res: Response) => { const newApplicant = await Applicant.create(data); return res.status(201).json({ - statusCode: 201, - success: true, message: "Applicant created successfully", data: newApplicant, }); } catch (error) { logger.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "An error occurred", error, }); } }; -export const updateResume = async (req: Request, res: Response) => { +export const updateApplicant = async (req: req, res: res) => { try { const applicant = await Applicant.findById(req.params.id); if (!applicant) { - return res.status(404).json({ - statusCode: 404, - success: false, - message: "Applicant not found", - }); + return res.status(404).json({ message: "Applicant not found" }); } - if (req.body.tags) { - req.body.tags = req.body.tags.split(","); + // Handle tags, if provided + if (req.body.tags && typeof req.body.tags === "string") { + req.body.tags = req.body.tags.split(",").map((tag: string) => tag.trim()); } - const data = { - firstname: req.body.firstname, - lastname: req.body.lastname, - middlename: req.body.middlename, - suffix: req.body.suffix, - email: req.body.email, - phone: req.body.phone, - address: req.body.address, - preferredWorkLocation: req.body.preferredWorkLocation, - linkedInProfile: req.body.linkedInProfile, - portfolioLink: req.body.portfolioLink, - // resumeFileLoc: req.body.resumeFileLoc, - yearsOfExperience: req.body.yearsOfExperience, - currentMostRecentJob: req.body.currentMostRecentJob, - highestQualification: req.body.highestQualification, - majorFieldOfStudy: req.body.majorFieldOfStudy, - institution: req.body.institution, - graduationYear: req.body.graduationYear, - keySkills: req.body.keySkills, - softwareProficiency: req.body.softwareProficiency, - certifications: req.body.certifications, - coverLetter: req.body.coverLetter, - salaryExpectation: req.body.salaryExpectation, - availability: req.body.availability, - jobAppliedFor: req.body.jobAppliedFor, - whyInterestedInRole: req.body.whyInterestedInRole, - tags: req.body.tags, + const files = req.files as { [key: string]: Express.Multer.File[] } | undefined; + + // If no files are uploaded, respond with an error + // if (!files) { + // return res.status(400).json({ message: "No files uploaded" }); + // } + + // Build file name map + const fileNames: Record = { + resume: files?.resume?.[0]?.filename, + medCert: files?.medCert?.[0]?.filename, + birthCert: files?.birthCert?.[0]?.filename, + NBIClearance: files?.NBIClearance?.[0]?.filename, + policeClearance: files?.policeClearance?.[0]?.filename, + TOR: files?.TOR?.[0]?.filename, + idPhoto: files?.idPhoto?.[0]?.filename, + }; + + // Merge new files with existing files (overwrite only if new files uploaded) + const mergedFiles = { + ...applicant.files, + ...Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + Object.entries(fileNames).filter(([_, val]) => val !== undefined) + ), }; - const updatedApplicant = await Applicant.findByIdAndUpdate(req.params.id, data, { new: true }); + // Prepare data for update + const updateData = { + ...req.body, + files: mergedFiles, + }; + + // Perform update operation + const updatedApplicant = await Applicant.findByIdAndUpdate( + req.params.id, + updateData, + { new: true } + ); return res.status(200).json({ - statusCode: 200, - success: true, message: "Applicant updated successfully", data: updatedApplicant, }); } catch (error) { - logger.error(error); - res.status(500).json({ - statusCode: 500, - success: false, - message: "An error occurred", - error, - }); + logger.error(error) + res.status(500).json({ message: "An error occurred", error }); } }; -export const updateStat = async (req: Request, res: Response) => { + +export const updateStat = async (req: req, res: res) => { try { const { applicantId, stat } = req.params; - if (!applicantId) { + if (!applicantId || !stat) { return res.status(400).json({ - statusCode: 400, - success: false, - message: "Applicant ID is required", + message: "Applicant ID and status field are required.", }); } const applicant = await Applicant.findById(applicantId); if (!applicant) { return res.status(404).json({ - statusCode: 404, - success: false, - message: "Applicant not found", + message: "Applicant not found.", }); } - // Define valid status fields - const validStatuses: (keyof IApplicant)[] = [ + // List of valid journey status fields + const validJourneyStatuses: (keyof IApplicant["statuses"]["journey"])[] = [ "isShortlisted", "isInitialInterview", + "isTechnicalInterview", + "isPanelInterview", + "isBehavioralInterview", "isFinalInterview", "isJobOffer", "isHired", ]; - if (!validStatuses.includes(stat as keyof IApplicant)) { + if (!validJourneyStatuses.includes(stat as keyof IApplicant["statuses"]["journey"])) { return res.status(400).json({ - statusCode: 400, - success: false, - message: "Invalid status field", + message: `Invalid status field '${stat}'.`, }); } - // Get the current value of the status - const currentStatus = applicant[stat as keyof IApplicant]; + const statusKey = stat as keyof IApplicant["statuses"]["journey"]; + const currentStatus = applicant.statuses.journey[statusKey]; - // Ensure the current status is a boolean if (typeof currentStatus !== "boolean") { return res.status(400).json({ - statusCode: 400, - success: false, - message: `Status field ${stat} is not a boolean.`, + message: `Status field '${stat}' is not a boolean.`, }); } - // Set the status to the opposite of its current value - applicant.set(stat as keyof IApplicant, !currentStatus); - + applicant.statuses.journey[statusKey] = !currentStatus; await applicant.save(); return res.status(200).json({ - statusCode: 200, - success: true, - message: `Applicant ${stat} status updated successfully`, + message: `Applicant status '${stat}' updated successfully.`, data: applicant, }); } catch (error) { + return res.status(500).json({ + message: "An error occurred while updating status.", + error, + }); + } +}; + +export const rejectApplicant = async (req: req, res: res) => { + try { + const applicantId = req.params.id; + + const applicant = await Applicant.findById(applicantId); + if (!applicant) { + return res.status(404).json({ + message: "Applicant not found", + }); + } + + const isCurrentlyRejected = applicant.status.toLowerCase() === 'Rejected'; + const newStatus = isCurrentlyRejected ? 'Active' : 'Rejected'; + + applicant.status = newStatus; + await applicant.save(); + + if (newStatus === 'Rejected') { + const fullName = `${applicant.firstname} ${applicant.lastname}`; + const templatePath = path.join(__dirname, '../../../public/templates/rejectionEmail.html'); + const emailTemplate = await fs.readFile(templatePath, 'utf-8'); + + const emailText = emailTemplate + .replace(/{{fullName}}/g, fullName) + .replace(/{{jobTitle}}/g, applicant.jobAppliedFor); + + const emailResult = await sendEmail( + 'Application Update', + applicant.email, + 'Your Application with AxleShift', + '', + emailText + ); + + if (!emailResult.success) { + return res.status(500).json({ + message: `Applicant rejected, but email failed to send to ${applicant.email}`, + error: emailResult.message, + data: applicant + }); + } + + return res.status(200).json({ + message: "Applicant rejected and email sent successfully", + data: applicant, + }); + + } else { + return res.status(200).json({ + message: "Applicant status reverted to Active", + data: applicant, + }); + } + + } catch (error) { + logger.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "An error occurred", error, }); } }; -export const getAllResumeData = async (req: Request, res: Response) => { + +export const getAllApplicant = async (req: req, res: res) => { try { const page = typeof req.query.page === "string" ? parseInt(req.query.page) : 1; const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit) : 9; const skip = (page - 1) * limit; + const showRejected = req.query.showRejected === "true"; + + const filter = showRejected + ? { status: { $in: ["Active", "Rejected"] } } + : { status: "Active" }; - const applicants = await Applicant.find().sort({ createdAt: -1 }).skip(skip).limit(limit); + const applicants = await Applicant.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); - const totalItems = await Applicant.countDocuments(); + const totalItems = await Applicant.countDocuments(filter); const totalPages = Math.ceil(totalItems / limit); + return res.status(200).json({ - statusCode: 200, - success: true, message: "Applicants found", data: applicants, totalItems, totalPages, currentPage: page, + showRejected, }); } catch (error) { logger.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "An error occurred", error, }); } }; -export const getResumeFile = async (req: Request, res: Response) => { - try { - // Find the applicant by ID - const applicant = await Applicant.findById(req.params.id); - if (!applicant) { - return res.status(404).json({ - statusCode: 404, - success: false, - message: "Applicant not found", - }); - } - - // Construct the file path - const filePath = `${config.fileServer.dir}/${applicant.resumeFileLoc}`; - logger.info(`Downloading file: ${filePath}`); - - // Check if file exists before attempting to download - res.download(filePath, (err) => { - if (err) { - logger.error(err); - return res.status(500).json({ - statusCode: 500, - success: false, - message: "Failed to download file", - }); - } - }); - } catch (error) { - logger.error(error); - res.status(500).json({ - statusCode: 500, - success: false, - message: "An error occurred", - error, - }); - } -}; -export const searchResume = async (req: Request, res: Response) => { +export const searchApplicant = async (req: req, res: res) => { try { logger.info("Searching for resumes..."); logger.info("Search Query:"); @@ -342,14 +333,10 @@ export const searchResume = async (req: Request, res: Response) => { const totalItems = await Applicant.countDocuments(searchCriteria); if (!applicants || applicants.length === 0) { return res.status(404).json({ - statusCode: 404, - success: false, message: "No applicants found", }); } return res.status(200).json({ - statusCode: 200, - success: true, message: "Applicants found", data: applicants, totalItems, @@ -360,70 +347,49 @@ export const searchResume = async (req: Request, res: Response) => { } catch (error) { logger.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "An error occurred", error, }); } }; -export const getApplicantByDocumentCategory = async (req: Request, res: Response) => { +export const getApplicantByDocumentCategory = async (req: req, res: res) => { try { const page = typeof req.query.page === "string" ? parseInt(req.query.page) : 1; const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit) : 9; const category = req.params.category as string; const skip = (page - 1) * limit; - console.log(req.params); if (!category) { - return res.status(400).json({ - statusCode: 400, - success: false, - message: "category is required", - }); + return res.status(400).json({ message: "Category is required" }); } - // Query applicants with the given status category completed as true - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let applicants: any[]; - switch (category) { - case "screening": - applicants = await Applicant.find({ "documentations.screening.completed": true }).skip(skip).limit(limit); - break; - case "shortlisted": - applicants = await Applicant.find({ isShortlisted: true }).skip(skip).limit(limit); - break; - case "interview": - applicants = await Applicant.find({ "documentations.interview.completed": true }).skip(skip).limit(limit); - break; - case "training": - applicants = await Applicant.find({ "documentations.training.completed": true }).skip(skip).limit(limit); - break; - case "others": - applicants = await Applicant.find({ "documentations.others.completed": true }).skip(skip).limit(limit); - break; - default: - return res.status(400).json({ - statusCode: 400, - success: false, - message: "Invalid DocCategory", - }); + // Define the filter mapping for categories + const categoryFilters: Record = { + screening: { "statuses.journey.screening": true }, + shortlisted: { "statuses.journey.isShortlisted": true }, + interview: { "statuses.journey.completed": true }, + training: { "documentations.training.completed": true }, + others: { "documentations.others.completed": true }, + }; + + // Check if the category is valid + const filter = categoryFilters[category]; + if (!filter) { + return res.status(400).json({ message: "Invalid document category" }); } + // Get applicants using the matching filter + const applicants = await Applicant.find(filter).skip(skip).limit(limit); + if (!applicants || applicants.length === 0) { - return res.status(404).json({ - statusCode: 404, - success: false, - message: "No applicants found", - }); + return res.status(404).json({ message: "No applicants found" }); } - const totalItems = await Applicant.countDocuments(); + // Count only documents matching the category filter + const totalItems = await Applicant.countDocuments(filter); + return res.status(200).json({ - statusCode: 200, - success: true, message: "Applicants found", data: applicants, totalItems, @@ -432,81 +398,236 @@ export const getApplicantByDocumentCategory = async (req: Request, res: Response }); } catch (error) { logger.error(error); - res.status(500).json({ - statusCode: 500, - success: false, + return res.status(500).json({ message: "An error occurred", error, }); } }; -export const getResumeById = async (req: Request, res: Response) => { +export const getApplicantById = async (req: req, res: res) => { try { const applicant = await Applicant.findById(req.params.id); if (!applicant) { return res.status(404).json({ - statusCode: 404, - success: false, message: "Applicant not found", }); } return res.status(200).json({ - statusCode: 200, - success: true, message: "Applicant found", data: applicant, }); } catch (error) { logger.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "An error occurred", error, }); } }; -export const deleteResume = async (req: Request, res: Response) => { +export const getEligibleForJobOffer = async (req: req, res: res) => { + try { + const page = typeof req.query.page === "string" ? parseInt(req.query.page) : 1; + const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit) : 10; + const skip = (page - 1) * limit; + + const filter = { + "statuses.journey.isFinalInterview": true, + "statuses.journey.isJobOffer": false, + }; + + const applicants = await Applicant.find(filter) + .skip(skip) + .limit(limit) + .sort({ updatedAt: -1 }); + + const totalItems = await Applicant.countDocuments(filter); + + return res.status(200).json({ + message: "Eligible applicants found", + data: applicants, + totalItems, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }); + } catch (error) { + logger.error(error); + res.status(500).json({ + message: "An error occurred", + error, + }); + } +} + +export const deleteApplicant = async (req: req, res: res) => { try { const applicant = await Applicant.findById(req.params.id); + if (!applicant) { - return res.status(404).json({ - statusCode: 404, - success: false, - message: "Applicant not found", - }); + return res.status(404).json({ message: "Applicant not found" }); } - const filePath = path.join(config.fileServer.dir, applicant.resumeFileLoc || ""); - - try { - await fs.access(filePath); // Check if the file exists - await fs.unlink(filePath); // Delete the file - } catch (fileError) { - logger.error("Error deleting file: ", fileError); - return res.status(500).json({ - statusCode: 500, - success: false, - message: "Error deleting the file", - error: fileError, - }); + // Delete all files in the applicant.files object + if (applicant.files) { + for (const [key, filePathValue] of Object.entries(applicant.files)) { + if (filePathValue) { + const filePath = path.join(config.fileServer.dir, filePathValue); + try { + await fs.access(filePath); + await fs.unlink(filePath); + logger.info(`Deleted file: ${filePath}`); + } catch (err) { + logger.warn(`Failed to delete ${key}: ${err}`); + } + } + } } - await Applicant.findByIdAndDelete(req.params.id); + // Overwrite PII fields with dummy or null values + applicant.firstname = "Deleted"; + applicant.lastname = "User"; + applicant.middlename = undefined; + applicant.suffix = undefined; + applicant.email = "deleted@example.com"; + applicant.phone = undefined; + applicant.address = undefined; + applicant.linkedInProfile = undefined; + applicant.portfolioLink = undefined; + + applicant.coverLetter = undefined; + applicant.whyInterestedInRole = undefined; + applicant.ids = {}; + applicant.files = {}; + + // Save the obfuscated reccord + await applicant.save(); + return res.status(200).json({ - statusCode: 200, - success: true, - message: "Applicant deleted successfully", + message: "Applicant data anonymized successfully", }); } catch (error) { logger.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "An error occurred", error, }); } }; + +const validFileFields = [ + 'resume', 'medCert', 'birthCert', + 'NBIClearance', 'policeClearance', 'TOR', 'idPhoto', +] as const; + +const validInterviewFields = [ + 'InitialInterview', 'TechnicalInterview', + 'PanelInterview', 'BehavioralInterview', 'FinalInterview', +] as const; + +type FileField = typeof validFileFields[number]; +type InterviewField = typeof validInterviewFields[number]; + +export const getFile = async (req: req, res: res) => { + try { + const { applicantId, fileType } = req.params; + + const applicant = await Applicant.findById(applicantId); + if (!applicant) { + return res.status(404).json({ message: "Applicant not found" }); + } + + let fileName: string | undefined; + let filePath: string; + + const baseDir = config.fileServer.dir; + + if ((validFileFields as readonly string[]).includes(fileType)) { + fileName = applicant.files[fileType as FileField]; + if (!fileName) return res.status(404).json({ error: 'File not uploaded' }); + + filePath = path.join(baseDir, 'applicants', fileType, fileName); + } else if ((validInterviewFields as readonly string[]).includes(fileType)) { + fileName = applicant.interviews[fileType as InterviewField]; + if (!fileName) return res.status(404).json({ error: 'File not uploaded' }); + + filePath = path.join(baseDir, 'applicants', 'file', fileName); + } else { + return res.status(400).json({ error: 'Invalid file type' }); + } + + res.download(filePath, fileName, (err) => { + logger.error(err); + if (!res.headersSent) { + res.status(500).end(); + } + }); + + } catch (error) { + logger.error(error); + res.status(500).json({ + message: "An error occurred", + error, + }); + } +}; + +export const uploadFile = async (req: req, res: res) => { + try { + const { applicantId, fileType } = req.params; + const applicant = await Applicant.findById(applicantId); + + if (!applicant) { + return res.status(404).json({ message: 'Applicant not found' }); + } + + const validFileFields = [ + 'resume', 'medCert', 'birthCert', + 'NBIClearance', 'policeClearance', 'TOR', 'idPhoto', + ] as const; + + const validInterviewFields = [ + 'InitialInterview', 'TechnicalInterview', + 'PanelInterview', 'BehavioralInterview', 'FinalInterview', + ] as const; + + const file = req.file; + if (!file) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + if ((validFileFields as readonly string[]).includes(fileType)) { + type FileKey = typeof validFileFields[number]; + applicant.files[fileType as FileKey] = file.filename; + } else if ((validInterviewFields as readonly string[]).includes(fileType)) { + type InterviewKey = typeof validInterviewFields[number]; + applicant.interviews[fileType as InterviewKey] = file.filename; + + // Update journey status + const interviewStatusMap: Record = { + InitialInterview: 'isInitialInterview', + TechnicalInterview: 'isTechnicalInterview', + PanelInterview: 'isPanelInterview', + BehavioralInterview: 'isBehavioralInterview', + FinalInterview: 'isFinalInterview', + }; + + const journeyKey = interviewStatusMap[fileType]; + if (journeyKey) { + applicant.statuses.journey[journeyKey] = true; + } + } else { + return res.status(400).json({ message: 'Invalid file type' }); + } + + await applicant.save(); + + res.status(200).json({ + message: 'File uploaded successfully', + fileName: file.filename, + }); + } catch (error) { + logger.error(error); + res.status(500).json({ message: 'An error occurred', error }); + } +}; diff --git a/server/src/database/v1/controllers/authController.ts b/server/src/database/v1/controllers/authController.ts index 78807340..f02d6f00 100644 --- a/server/src/database/v1/controllers/authController.ts +++ b/server/src/database/v1/controllers/authController.ts @@ -2,33 +2,28 @@ import { hasher } from "../../../utils/hasher"; import { Request as req, Response as res } from "express"; import User from "../models/userModel"; import bcrypt from "bcryptjs"; -import jwt from "jsonwebtoken"; import { config } from "../../../config"; import dotenv from "dotenv"; import logger from "../../../middlewares/logger"; +import { oauth2Client } from "../../../config/v1/google"; +import { google } from 'googleapis'; +import userModel from "../models/userModel"; +import { sendEmail } from "../../../utils/mailHandler"; +import fs from "fs/promises" +import path from "path"; + dotenv.config(); const salt = bcrypt.genSaltSync(10); export const createUser = async (req: req, res: res) => { - const { firstname, lastname, email, username, password } = req.body; - if (!firstname || !lastname || !email || !username || !password) { - return res.status(400).json({ - statusCode: 400, - success: false, - message: "Please provide all required fields", - }); - } - try { - // const user = await User.create({ - // firstname: await hasher(firstname, salt), - // lastname: await hasher(lastname, salt), - // email, - // username, - // password: await hasher(password, salt), - // rememberToken: salt, - // }); + const { firstname, lastname, email, username, password } = req.body; + if (!firstname || !lastname || !email || !username || !password) { + return res.status(400).json({ + message: "Please provide all required fields", + }); + } const generateVerificationCode = () => { const code = Math.floor(100000 + Math.random() * 900000); @@ -52,16 +47,13 @@ export const createUser = async (req: req, res: res) => { }); res.status(201).json({ - statusCode: 201, - success: true, message: "User created successfully", user, }); } catch (error) { console.error(error); res.status(500).json({ - statusCode: 500, - success: false, + message: "Error creating user", error, }); @@ -69,83 +61,226 @@ export const createUser = async (req: req, res: res) => { }; export const login = async (req: req, res: res) => { - const { username, password } = req.body; - logger.info(`User ${username} is trying to login`); - try { - const user = await User.findOne({ username }); + const { username, password } = req.body; + logger.info(`User ${username} is trying to login`); + + // Find user by username or email + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(username); + const user = await User.findOne(isEmail ? { email: username } : { username }); if (!user) { - return res.status(404).json({ - statusCode: 404, - success: false, - message: "User not found", - }); + return res.status(404).json({ message: "User not found" }); } - const isPasswordValid = bcrypt.compareSync(password, user.password); - + // Validate password + const userPass = user.password?.toString() as string + const isPasswordValid = bcrypt.compare(password, userPass); if (!isPasswordValid) { - return res.status(401).json({ - statusCode: 401, - success: false, - message: "Invalid credentials", + return res.status(401).json({ message: "Invalid credentials" }); + } + + // Create user session payload + const userID = user._id.toString(); + const userData = { + _id: userID, + firstname: user.firstname, + lastname: user.lastname, + username: user.username, + role: user.role, + email: user.email, + status: user.status, + emailVerifiedAt: user.emailVerifiedAt || null, + }; + + // Detect new device + const userAgent = req.headers['user-agent'] || 'unknown'; + const userIP = req.ip || req.connection.remoteAddress || 'unknown'; + const deviceFingerprint = `${userAgent}-${userIP}`; + const knownDevices = user.knownDevices || []; + + const templatePath = path.join(__dirname, '../../../public/templates/newDeviceAlert.html'); + const emailTemplate = await fs.readFile(templatePath, "utf-8"); + + const emailText = emailTemplate + .replace(/{{userAgent}}/g, userAgent) + .replace(/{{userIP}}/g, userIP) + .replace(/{{time}}/g, new Date().toLocaleString()) + + const isNewDevice = !knownDevices.includes(deviceFingerprint); + if (isNewDevice) { + await sendEmail( + 'New Device Alert', + user.email, + 'New Device Login', + "", + emailText + ) + // user.knownDevices.push(deviceFingerprint); + // await user.save(); + + req.session.pendingDevice = deviceFingerprint; + + return res.status(200).json({ + message: "New Device detected, OTP verification is required.", + data: userData, + isKnownDevice: false, + }) + } + + // Regenerate session + req.session.regenerate((err) => { + if (err) { + return res.status(500).json({ message: "Error regenerating session" }); + } + + req.session.user = userData; + req.session.save((saveErr) => { + if (saveErr) { + return res.status(500).json({ message: "Error saving session" }); + } + + res.status(200).json({ + message: "User logged in successfully", + data: userData, + isKnownDevice: true, + }); }); + }); + + } catch (error) { + logger.error("Login error:", error); + res.status(500).json({ message: "Error logging in", error }); + } +}; + +export const sendOTP = async (req: req, res: res) => { + try { + const { username } = req.body; + const user = await User.findOne({ username }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); } - const jwtSecret = config.server.jwt.secret as string; - const token: string = jwt.sign({ username, id: user._id }, jwtSecret, { - expiresIn: "1h", + const otp = Math.floor(100000 + Math.random() * 900000).toString(); // e.g., '489201' + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // 10 min + + user.otp = { + code: otp, + expiresAt, + }; + await user.save(); + + const templatePath = path.join(__dirname, '../../../public/templates/otpEmail.html'); + const emailTemplate = await fs.readFile(templatePath, "utf-8"); + + // Send OTP via email + // const msg = ` + // Your OTP for logging in is: ${otp} + // This OTP is valid for 10 minutes. + // `; + + const userAgent = req.headers['user-agent'] || 'unknown'; + const userIP = req.ip || req.connection.remoteAddress || 'unknown'; + + const emailText = emailTemplate + .replace(/{{userName}}/g, username) + .replace(/{{otp}}/g, otp) + .replace(/{{ipAddress}}/g, userIP) + .replace(/{{userAgent}}/g, userAgent) + + await sendEmail( + 'OTP Verification', + user.email, + 'Your OTP', + '', + emailText + ); + + res.status(200).json({ + message: 'OTP sent successfully', + redirectToOtpPage: true, }); + + } catch (error) { + logger.error(error) + res.status(500).json({ message: "Error sending OTP", error}) + } +} + +export const verifyOTP = async (req: req, res: res) => { + try { + const { username, otp } = req.body; + + if (!username || !otp) { + return res.status(400).json({ message: 'Please provide username and OTP' }); + } + + const user = await User.findOne({ username }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + // Check if OTP is present and not expired + if (!user.otp || new Date() > user.otp.expiresAt) { + return res.status(400).json({ message: 'OTP expired or not sent' }); + } + + // Check if the OTP is correct + if (user.otp.code !== otp) { + return res.status(400).json({ message: 'Invalid OTP' }); + } + + // OTP is valid, proceed with authentication + // Clear OTP after successful verification + user.otp = null; + + // Add new device fingerprint (stored in session during login) + const fingerprint = req.session.pendingDevice; + if (fingerprint && !user.knownDevices.includes(fingerprint)) { + user.knownDevices.push(fingerprint); + } + req.session.pendingDevice = null; // Clear it after use + await user.save(); + + // Prepare session user data + const userID = user._id.toString(); const userData = { - _id: user._id.toString(), + _id: userID, firstname: user.firstname, lastname: user.lastname, username: user.username, role: user.role, email: user.email, status: user.status, - token, emailVerifiedAt: user.emailVerifiedAt || null, }; - // ✅ Regenerate session to prevent fixation attack + // Regenerate session after OTP verification req.session.regenerate((err) => { if (err) { - return res.status(500).json({ - statusCode: 500, - success: false, - message: "Error regenerating session", - }); + return res.status(500).json({ message: "Error regenerating session" }); } req.session.user = userData; req.session.save((saveErr) => { if (saveErr) { - return res.status(500).json({ - statusCode: 500, - success: false, - message: "Error saving session", - }); + return res.status(500).json({ message: "Error saving session" }); } res.status(200).json({ - statusCode: 200, - success: true, - message: "User logged in successfully", + message: 'OTP verified successfully', data: userData, }); }); }); + } catch (error) { console.error(error); - res.status(500).json({ - statusCode: 500, - success: false, - message: "Error logging in", - error, - }); + res.status(500).json({ message: 'Error verifying OTP', error }); } }; @@ -154,23 +289,18 @@ export const verify = async (req: req, res: res) => { const user = req.session.user; if (user) { res.status(200).json({ - statusCode: 200, - success: true, message: "User verified successfully", data: user, }); } else { res.status(404).json({ - statusCode: 404, - success: false, message: "User not found", }); } } catch (error) { console.error(error); res.status(500).json({ - statusCode: 500, - success: false, + message: "Error verifying user", error, }); @@ -181,8 +311,6 @@ export const logout = async (req: req, res: res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ - statusCode: 500, - success: false, message: "Error destroying session", }); } @@ -195,8 +323,6 @@ export const logout = async (req: req, res: res) => { }); res.status(200).json({ - statusCode: 200, - success: true, message: "Logged out successfully", }); }); @@ -210,8 +336,6 @@ export const verifyEmail = async (req: req, res: res) => { if (!id || !code) { return res.status(400).json({ - statusCode: 400, - success: false, message: "Please provide all required fields", }); } @@ -221,8 +345,6 @@ export const verifyEmail = async (req: req, res: res) => { // check if user exists if (!user) { return res.status(404).json({ - statusCode: 404, - success: false, message: "User not found", }); } @@ -235,25 +357,91 @@ export const verifyEmail = async (req: req, res: res) => { }); res.status(200).json({ - statusCode: 200, - success: true, message: "Email verified successfully", data: updated, }); } else { return res.status(400).json({ - statusCode: 400, - success: false, message: "Invalid or expired verification code", }); } } catch (error) { console.error(error); res.status(500).json({ - statusCode: 500, - success: false, message: "Error verifying email", error, }); } }; + +export const googleAuth = async (req:req, res:res) => { + try { + const scopes = ['profile', 'email']; + const url = oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: scopes, + }); + res.redirect(url); + } catch (error) { + console.error(500) + res.status(500).json({ message: error}) + } +} + +export const googleCallback = async (req: req, res: res) => { + try { + const code = req.query.code as string; + + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + const oauth2 = google.oauth2({ + auth: oauth2Client, + version: 'v2', + }); + + const userInfo = await oauth2.userinfo.get(); + const googleUser = userInfo.data; + + let user = await userModel.findOne({ googleId: googleUser.id}) + + if (!user) { + user = new userModel({ + googleId: googleUser.id, + email: googleUser.email, + name: googleUser.name, + firstname: googleUser.given_name || 'Google', + lastname: googleUser.family_name || 'User', + username: googleUser.email?.split('@')[0] || `user${Date.now()}`, + role: 'user', + status: 'active', + emailVerifiedAt: new Date(), + }); + await user.save(); + } + + req.session.user = { + _id: user._id.toString(), + firstname: user.firstname, + lastname: user.lastname, + username: user.username, + role: user.role, + email: user.email, + status: user.status, + emailVerifiedAt: user.emailVerifiedAt, + }; + + const url = config.google.oauth2.clientRedirect as unknown as string + + if (!url) { + console.error("Missing Google OAuth client redirect URL"); + return res.status(500).json({ message: "Missing redirect URL" }); + } + + res.redirect(url); + + } catch (error) { + console.error(500) + res.status(500).json({ message: error}) + } +} \ No newline at end of file diff --git a/server/src/database/v1/controllers/facilityController.ts b/server/src/database/v1/controllers/facilityController.ts index bd66c0de..5b7ea9a8 100644 --- a/server/src/database/v1/controllers/facilityController.ts +++ b/server/src/database/v1/controllers/facilityController.ts @@ -11,9 +11,9 @@ import fs from "fs/promises" export const createFacility = async (req: req, res: res) => { try { - const { name, type, description, location } = req.body; + const { name, type, description, location, requirements } = req.body; - if (!name || !type || !description || !location) { + if (!name || !type || !location) { return res.status(400).json({ message: "All fields are required" }); } @@ -22,6 +22,7 @@ export const createFacility = async (req: req, res: res) => { type, description, location, + requirements: requirements || [], }; const newFacility = await Facility.create(facilityData); @@ -39,7 +40,7 @@ export const createFacility = async (req: req, res: res) => { export const updateFacility = async (req: req, res: res) => { try { const { facilityId } = req.params; - const { name, type, description, location } = req.body; + const { name, type, description, requirements, location } = req.body; if (!facilityId) { return res.status(400).json({ message: "Facility id is required" }); @@ -57,6 +58,7 @@ export const updateFacility = async (req: req, res: res) => { facility.name = name; facility.type = type; facility.description = description; + facility.requirements = requirements; facility.location = location; const updatedFacility = await facility.save(); @@ -232,9 +234,9 @@ export const createFacilityTimeslot = async (req: req, res: res) => { if (!facilityId) { return res.status(400).json({ message: "Facility id is required" }); } - + if (!date || !start || !end) { - return res.status(400).json({ message: "All fields are required" }); + return res.status(400).json({ message: "All fields are required", date, start, end, facilityId }); } const facility = await Facility.findById(facilityId); @@ -737,8 +739,7 @@ export const getUpcomingEvents = async (req: req, res: res) => { export const bookApplicantToEvent = async (req: req, res: res) => { try { - const { eventId } = req.params; - const { applicantId } = req.body; + const { eventId, applicantId } = req.params; // Validate input if (!eventId) { @@ -903,7 +904,7 @@ export const SendEmailToFacilityEventParticipants = async (req: req, res: res) = for (const participant of participants) { if (!participant.email) { - const id = participant._id as string + const id = participant._id as unknown as string failedEmails.push({ applicant: id, reason: "No email provided" }); participantUpdates.push({ applicant: participant._id, @@ -939,7 +940,7 @@ export const SendEmailToFacilityEventParticipants = async (req: req, res: res) = logger.info(JSON.stringify(emailResult, null, 2)); if (!emailResult.success) { - const id = participant._id as string + const id = participant._id as unknown as string failedEmails.push({ applicant: id, reason: emailResult.message }); participantUpdates.push({ applicant: participant._id, diff --git a/server/src/database/v1/controllers/interviewController.ts b/server/src/database/v1/controllers/interviewController.ts index a724c67e..9a50a639 100644 --- a/server/src/database/v1/controllers/interviewController.ts +++ b/server/src/database/v1/controllers/interviewController.ts @@ -36,7 +36,7 @@ export const createInterview = async (req: req, res: res) => { if (!interviewerId) { return res.status(400).json({ message: "Interviewer ID is required" }); } - + // Guard clause for missing interview data if (!data) { return res.status(400).json({ message: "Interview data is required" }); @@ -68,6 +68,7 @@ export const createInterview = async (req: req, res: res) => { date: data.date, interviewer: new mongoose.Types.ObjectId(interviewerId), type: data.type, + interviewType: data.interviewType, event: new mongoose.Types.ObjectId(eventId), general: data.general, questions: data.questions || [], @@ -81,11 +82,35 @@ export const createInterview = async (req: req, res: res) => { const interview = await Interview.create(newData) if (!interview) { - return res.status(400).json({ message: "Failed to save interview form"}) + return res.status(400).json({ message: "Failed to save interview form" }) } const interviewId = interview._id as mongoose.Types.ObjectId; applicant.documentations.interview.push(interviewId) + + switch (data.interviewType) { + case 'Initial Interview': + applicant.statuses.journey.isInitialInterview = true + break; + case 'Final Interview': + applicant.statuses.journey.isFinalInterview = true + break; + case 'Technical Interview': + applicant.statuses.journey.isTechnicalInterview = true + break; + case 'Panel Interview': + applicant.statuses.journey.isPanelInterview = true + break; + case 'Behavioral Interview': + applicant.statuses.journey.isBehavioralInterview = true + break; + case 'Orientation': + applicant.statuses.journey.isHired = true + break; + default: + break; + } + applicant.save() // Respond with success @@ -144,6 +169,7 @@ export const updateInterview = async (req: req, res: res) => { if (data.date) interview.date = data.date; if (data.job) interview.job = data.job; if (data.type) interview.type = data.type; + if (data.interviewType) interview.interviewType = data.interviewType; if (data.recommendation) interview.recommendation = data.recommendation; if (data.general) interview.general = data.general; if (data.questions) interview.questions = data.questions; @@ -177,7 +203,7 @@ export const getInterviewById = async (req: req, res: res) => { const { interviewId } = req.params; if (!interviewId) { - return res.status(400).json({ message: "Interview Id is required"}) + return res.status(400).json({ message: "Interview Id is required" }) } if (!mongoose.Types.ObjectId.isValid(interviewId)) { @@ -252,8 +278,8 @@ export const getAllInterview = async (req: req, res: res) => { }; // Initialize with required applicant filter - const searchFilter: InterviewSearchFilter = { - applicant: new mongoose.Types.ObjectId(applicantId) + const searchFilter: InterviewSearchFilter = { + applicant: new mongoose.Types.ObjectId(applicantId) }; // Add search conditions if query exists @@ -289,7 +315,7 @@ export const getAllInterview = async (req: req, res: res) => { .skip(skip) .limit(limit) .lean(), - + Interview.countDocuments(searchFilter) ]); diff --git a/server/src/database/v1/controllers/jobofferController.ts b/server/src/database/v1/controllers/jobofferController.ts index 991be2b7..6ac77df7 100644 --- a/server/src/database/v1/controllers/jobofferController.ts +++ b/server/src/database/v1/controllers/jobofferController.ts @@ -12,8 +12,7 @@ import fs from "fs/promises" export const createJoboffer = async (req: req, res: res) => { try { const { applicantId } = req.params; - const { position, salary, startDate, benefits, notes } = req.body; - + const { position, salary, startDate, benefits, notes, jobType, contractDuration, location } = req.body; if (!applicantId) { return res.status(400).json({ message: 'Applicant Id is required.', applicantId }) } @@ -31,14 +30,17 @@ export const createJoboffer = async (req: req, res: res) => { const data = { applicant: applicantId, - position: position, - salary: salary, - startDate: startDate, - benefits: benefits, + position, + salary, + startDate, + benefits, status: 'Pending', issuedBy: userId, issuedDate: new Date(), - notes: notes, + notes, + jobType, // New field for job type + contractDuration, // New field for contract duration (optional) + location, // New field for location } const joboffer = await Joboffer.create(data); @@ -48,7 +50,7 @@ export const createJoboffer = async (req: req, res: res) => { } const jobofferId = joboffer._id as mongoose.Types.ObjectId; - applicant.isJobOffer = true + applicant.statuses.journey.isJobOffer = true applicant.documentations.jobOffer = jobofferId await applicant.save(); @@ -57,7 +59,7 @@ export const createJoboffer = async (req: req, res: res) => { await joboffer.populate({ path: 'applicant', model: 'Applicant', - select: "_id firstname lastname isShortlisted isInitialInterview isFinalInterview isJobOffer isHired" + select: "_id firstname lastname statuses" }); res.status(201).json({ @@ -74,7 +76,7 @@ export const createJoboffer = async (req: req, res: res) => { export const updateJoboffer = async (req: req, res: res) => { try { const { jobofferId } = req.params; - const { position, salary, startDate, benefits, status, notes } = req.body; + const { position, salary, startDate, benefits, notes, jobType, contractDuration, location, status } = req.body; const { isApproved } = req.query; if (!jobofferId) { @@ -93,12 +95,30 @@ export const updateJoboffer = async (req: req, res: res) => { const userId = req.session.user?._id + // const data = { + // applicant: applicantId, + // position, + // salary, + // startDate, + // benefits, + // status: 'Pending', + // issuedBy: userId, + // issuedDate: new Date(), + // notes, + // jobType, // New field for job type + // contractDuration, // New field for contract duration (optional) + // location, // New field for location + // } + joboffer.position = position; joboffer.salary = salary; joboffer.startDate = new Date(startDate); joboffer.benefits = benefits; joboffer.status = status; joboffer.notes = notes; + joboffer.jobType = jobType; + joboffer.contractDuration = contractDuration; + joboffer.location = location; if (isApproved !== undefined) { joboffer.approvedBy = new mongoose.Types.ObjectId(userId); @@ -166,7 +186,7 @@ export const getJobofferById = async (req: req, res: res) => { } } -export const getAllJoboffer = async (req: req, res: res) => { +export const getApplicantJoboffer = async (req: req, res: res) => { try { const { applicantId } = req.params; @@ -214,7 +234,7 @@ export const getAllJoboffer = async (req: req, res: res) => { { path: "applicant", model: "Applicant", - select: "_id firstname lastname isShortlisted isInitialInterview isFinalInterview isJobOffer isHired" + select: "_id firstname lastname statuses" }, { path: "issuedBy", diff --git a/server/src/database/v1/controllers/screeningController.ts b/server/src/database/v1/controllers/screeningController.ts index 8c0944d0..e743f862 100644 --- a/server/src/database/v1/controllers/screeningController.ts +++ b/server/src/database/v1/controllers/screeningController.ts @@ -245,7 +245,6 @@ export const screenApplicantViaAI = async (req: req, res: res) => { } }; - export const createScreening = async (req: req, res: res) => { try { const { applicantId } = req.params; @@ -307,9 +306,9 @@ export const createScreening = async (req: req, res: res) => { }; if (status === 'shorlisted') { - applicant.isShortlisted = true; + applicant.statuses.journey.isShortlisted = true; } else { - applicant.isShortlisted = false; + applicant.statuses.journey.isShortlisted = false; } await applicant.save(); @@ -416,9 +415,9 @@ export const updateScreening = async (req: req, res: res) => { logger.info(updateData.status) if (updateData.status === 'shortlisted') { - applicant.isShortlisted = true; + applicant.statuses.journey.isShortlisted = true; } else { - applicant.isShortlisted = false; + applicant.statuses.journey.isShortlisted = false; } // Save the updated applicant data diff --git a/server/src/database/v1/controllers/statisticController.ts b/server/src/database/v1/controllers/statisticController.ts index e69de29b..236667fd 100644 --- a/server/src/database/v1/controllers/statisticController.ts +++ b/server/src/database/v1/controllers/statisticController.ts @@ -0,0 +1,404 @@ +/** + * @file applicantController.ts + * @description Controller for handling applicant data + */ + +import { Request as req, Response as res } from "express"; +import logger from "../../../middlewares/logger"; +import Applicant from "../models/applicantModel"; +import jobpostingModel from "../models/jobpostingModel"; +import FacilityEvent from "../models/facilityEventModel"; + +export const statistics = async (req: req, res: res) => { + try { + const now = new Date(); + const lastWeek = new Date(now); + lastWeek.setDate(now.getDate() - 7); + + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + + // Applicants + const totalApplicants = await Applicant.countDocuments(); + const recentApplicants = await Applicant.find({ + createdAt: { $gte: lastWeek } + }) + .sort({ createdAt: -1 }) + .limit(10) // optional: limit to top 10 most recent + .select('firstname lastname email phone jobAppliedFor yearsOfExperience highestQualification statuses createdAt'); + + const applicantsPerDay = await Applicant.aggregate([ + { + $match: { + createdAt: { + $gte: startOfMonth, + $lte: endOfMonth + } + } + }, + { + $group: { + _id: { + $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } + }, + count: { $sum: 1 } + } + }, + { $sort: { _id: 1 } } + ]); + + const applicantsThisMonth = await Applicant.countDocuments({ + createdAt: { + $gte: startOfMonth, + $lte: endOfMonth + } + }); + + const journeyProgression = await Applicant.aggregate([ + { + $match: { + createdAt: { + $gte: new Date(now.getFullYear(), now.getMonth(), 1), + $lte: new Date(now.getFullYear(), now.getMonth() + 1, 0), + }, + }, + }, + { + $project: { + createdAt: 1, + 'statuses.journey': 1, + date: { + $dateToString: { format: '%Y-%m-%d', date: '$createdAt' }, + }, + }, + }, + { + $group: { + _id: '$date', + isShortlisted: { + $sum: { + $cond: ['$statuses.journey.isShortlisted', 1, 0], + }, + }, + isInitialInterview: { + $sum: { + $cond: ['$statuses.journey.isInitialInterview', 1, 0], + }, + }, + isTechnicalInterview: { + $sum: { + $cond: ['$statuses.journey.isTechnicalInterview', 1, 0], + }, + }, + isPanelInterview: { + $sum: { + $cond: ['$statuses.journey.isPanelInterview', 1, 0], + }, + }, + isBehavioralInterview: { + $sum: { + $cond: ['$statuses.journey.isBehavioralInterview', 1, 0], + }, + }, + isFinalInterview: { + $sum: { + $cond: ['$statuses.journey.isFinalInterview', 1, 0], + }, + }, + isJobOffer: { + $sum: { + $cond: ['$statuses.journey.isJobOffer', 1, 0], + }, + }, + isHired: { + $sum: { + $cond: ['$statuses.journey.isHired', 1, 0], + }, + }, + }, + }, + { $sort: { _id: 1 } }, + ]) + + + const currentStages = await Applicant.aggregate([ + { + $project: { + currentStage: { + $switch: { + branches: [ + { case: { $eq: ["$statuses.journey.isHired", true] }, then: "Hired" }, + { case: { $eq: ["$statuses.journey.isJobOffer", true] }, then: "Job Offer" }, + { case: { $eq: ["$statuses.journey.isFinalInterview", true] }, then: "Final Interview" }, + { case: { $eq: ["$statuses.journey.isBehavioralInterview", true] }, then: "Behavioral Interview" }, + { case: { $eq: ["$statuses.journey.isPanelInterview", true] }, then: "Panel Interview" }, + { case: { $eq: ["$statuses.journey.isTechnicalInterview", true] }, then: "Technical Interview" }, + { case: { $eq: ["$statuses.journey.isInitialInterview", true] }, then: "Initial Interview" }, + { case: { $eq: ["$statuses.journey.isShortlisted", true] }, then: "Shortlisted" }, + ], + default: "Applied", + }, + } + } + }, + { + $group: { + _id: "$currentStage", + count: { $sum: 1 } + } + }, + { $sort: { count: -1 } } + ]) + + + const experienceHistogram = await Applicant.aggregate([ + { + $bucket: { + groupBy: "$yearsOfExperience", + boundaries: [0, 1, 3, 5, 10, 15, 20, 30], + default: "30+", + output: { count: { $sum: 1 } } + } + } + ]); + + const jobHistogram = await Applicant.aggregate([ + { + $group: { + _id: "$jobAppliedFor", + count: { $sum: 1 } + } + }, + { $sort: { count: -1 } }, + { $limit: 10 } // top 10 + ]); + + const qualificationHistogram = await Applicant.aggregate([ + { + $group: { + _id: "$highestQualification", + count: { $sum: 1 } + } + }, + { $sort: { count: -1 } }, + { $limit: 10 } + ]); + + const graduationHistogram = await Applicant.aggregate([ + { + $match: { graduationYear: { $ne: null } } + }, + { + $group: { + _id: "$graduationYear", + count: { $sum: 1 } + } + }, + { $sort: { _id: 1 } } + ]); + + const monthlyHistogram = await Applicant.aggregate([ + { + $match: { + createdAt: { + $gte: startOfMonth, + $lte: endOfMonth, + }, + }, + }, + { + $group: { + _id: { + $dateToString: { format: '%Y-%m-%d', date: '$createdAt' }, + }, + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]); + + // Additional statistics + const avgExperience = await Applicant.aggregate([ + { $group: { _id: null, avgExperience: { $avg: "$yearsOfExperience" } } } + ]); + + const genderStats = await Applicant.aggregate([ + { $group: { _id: "$gender", count: { $sum: 1 } } } + ]); + + const avgSalary = await Applicant.aggregate([ + { $group: { _id: null, avgSalary: { $avg: "$salaryExpectation" } } } + ]); + + const locationStats = await Applicant.aggregate([ + { $group: { _id: "$preferredWorkLocation", count: { $sum: 1 } } } + ]); + + const sourceStats = await Applicant.aggregate([ + { $group: { _id: "$source", count: { $sum: 1 } } } + ]); + + const timeToHire = await Applicant.aggregate([ + { $match: { "statuses.journey.isHired": true } }, + { $project: { timeToHire: { $subtract: ["$hiredAt", "$createdAt"] } } }, + { $group: { _id: null, avgTimeToHire: { $avg: "$timeToHire" } } } + ]); + + // jobpostings + const totalJobPostings = await jobpostingModel.countDocuments(); + + // Active job postings (not expired) + const activeJobPostings = await jobpostingModel.countDocuments({ + isExpired: false, + status: { $ne: "expired" }, + }); + + // Expired job postings + const expiredJobPostings = await jobpostingModel.countDocuments({ + isExpired: true, + }); + + const recentJobs = await jobpostingModel.find() + .sort({ createdAt: -1 }) + .limit(10) + .select('title type location salary_min salary_max schedule_start schedule_end status createdAt'); + + // Job postings grouped by type (e.g., full-time, part-time) + const jobTypes = await jobpostingModel.aggregate([ + { $group: { _id: "$type", count: { $sum: 1 } } }, + ]); + + // Average salary ranges (min and max) + + // Events + const totalEvents = await FacilityEvent.countDocuments(); + + const recentEvents = await FacilityEvent.find({ + createdAt: { $gte: lastWeek } + }) + .sort({ createdAt: -1 }) + .limit(10) // Optional: limit to top 10 most recent events + .select('name type date facility capacity isAvailable isApproved createdAt') + .populate({ + path: "facility", + model: "Facility" + }) + + const eventsPerDay = await FacilityEvent.aggregate([ + { + $match: { + createdAt: { + $gte: startOfMonth, + $lte: endOfMonth + } + } + }, + { + $group: { + _id: { + $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } + }, + count: { $sum: 1 } + } + }, + { $sort: { _id: 1 } } + ]); + + const eventsThisMonth = await FacilityEvent.countDocuments({ + createdAt: { + $gte: startOfMonth, + $lte: endOfMonth + } + }); + + const eventTypes = await FacilityEvent.aggregate([ + { + $group: { + _id: "$type", + count: { $sum: 1 } + } + }, + { $sort: { count: -1 } } + ]); + + const eventApprovalStatus = await FacilityEvent.aggregate([ + { + $group: { + _id: "$isApproved.status", + count: { $sum: 1 } + } + } + ]); + + const eventParticipants = await FacilityEvent.aggregate([ + { $unwind: "$participants" }, + { + $group: { + _id: "$participants.applicant", + count: { $sum: 1 } + } + } + ]); + + // Event capacity utilization (how many slots are filled) + const eventCapacityUtilization = await FacilityEvent.aggregate([ + { + $project: { + name: 1, + capacity: 1, + participantsCount: { $size: "$participants" }, + utilization: { + $divide: [{ $size: "$participants" }, "$capacity"] + } + } + } + ]); + + res.json({ + applicants: { + totalApplicants, + recentApplicants, + applicantsPerDay, + applicantsThisMonth, + journeyProgression, + currentStages, + histograms: { + yearsOfExperience: experienceHistogram, + jobAppliedFor: jobHistogram, + qualifications: qualificationHistogram, + graduationYear: graduationHistogram, + monthlyHistogram, + }, + additionalStats: { + avgExperience: avgExperience[0]?.avgExperience || 0, + genderStats, + avgSalary: avgSalary[0]?.avgSalary || 0, + locationStats, + sourceStats, + timeToHire: timeToHire[0]?.avgTimeToHire || 0, + } + }, + jobpostings: { + totalJobPostings, + recentJobs, + activeJobPostings, + expiredJobPostings, + jobTypes, + }, + events: { + totalEvents, + recentEvents, + eventsPerDay, + eventsThisMonth, + eventTypes, + eventApprovalStatus, + eventParticipants, + eventCapacityUtilization + } + }); + } catch (error) { + logger.error('[Applicant Stats Error]', error); + res.status(500).json({ message: 'Failed to fetch applicant statistics' }); + } +}; diff --git a/server/src/database/v1/models/applicantModel.ts b/server/src/database/v1/models/applicantModel.ts index 1ee5cff3..eb8adb93 100644 --- a/server/src/database/v1/models/applicantModel.ts +++ b/server/src/database/v1/models/applicantModel.ts @@ -3,9 +3,12 @@ * @description Applicant model schema */ -import mongoose, { Document } from "mongoose"; +const APPLICANT_STATUS = ['Rejected', 'Hired', 'Active'] -export interface IApplicant extends Document { +import mongoose, { Document, Types } from "mongoose"; + +export interface IApplicantBase extends Document { + status: string; /** * Basic Information */ @@ -23,7 +26,6 @@ export interface IApplicant extends Document { /** * Work Experience */ - resumeFileLoc?: string; yearsOfExperience: number; currentMostRecentJob?: string; @@ -51,25 +53,75 @@ export interface IApplicant extends Document { jobAppliedFor: string; whyInterestedInRole?: string; + /** + * Files location. These are all saved in the local filesystem. + * which is why they're all string. + * some of this are not actual files but numbers or strings.. so thats convenient. + * + * For several government-mandated pre-employment requirements in the Philippines, + * you typically only need to provide your identification numbers, + * not the physical IDs—unless the employer specifically requested them. + */ + files: { + resume?: string; + medCert?: string; + birthCert?: string; + NBIClearance?: string; + policeClearance?: string; + TOR?: string; // Transcript of record + idPhoto?: string; + } + + interviews: { + InitialInterview: string; + TechnicalInterview: string; + PanelInterview: string; + BehavioralInterview: string; + FinalInterview: string; + } + + ids: { + TIN?: string; + SSS?: string; + philHealth?: string; + pagIBIGFundNumber?: string; + } + /** * Statuses and Remarks for Each Stage */ statuses: { - isShortlisted: boolean; - isInitialInterview: boolean; - isFinalInterview: boolean; - isJobOffer: boolean; - isHired: boolean; + journey: { + isShortlisted: boolean; + isInitialInterview: boolean; + isTechnicalInterview: boolean; + isPanelInterview: boolean; + isBehavioralInterview: boolean; + isFinalInterview: boolean; + isJobOffer: boolean; + isHired: boolean; + withdrawn: boolean; + }, + // preemployment: { + // files: { + // medCert?: boolean; + // birthCert?: boolean; + // NBIClearance?: boolean; + // policeClearance?: boolean; + // TOR?: boolean; + // idPhoto?: boolean; + // }, + // ids: { + // TIN?: boolean; + // SSS?: boolean; + // philHealth?: boolean; + // pagIBIGFundNumber?: boolean; + // } + // } }, tags: string[]; emailSent: boolean; - isShortlisted: boolean; - isInitialInterview: boolean; - isFinalInterview: boolean; - isJobOffer: boolean; - isHired: boolean; - events: mongoose.Types.ObjectId[]; emails: mongoose.Types.ObjectId[]; @@ -82,11 +134,22 @@ export interface IApplicant extends Document { expiresAt: Date; } +export interface IApplicant extends IApplicantBase, Document { + _id: Types.ObjectId, +} + const applicantSchema = new mongoose.Schema( { /** * Basic Information */ + status: { + type: String, + enum: APPLICANT_STATUS, + default: 'Active', + required: true, + }, + firstname: { type: String, required: true, @@ -126,9 +189,6 @@ const applicantSchema = new mongoose.Schema( /** * Work Experience */ - resumeFileLoc: { - type: String, - }, yearsOfExperience: { type: Number, required: true, @@ -215,49 +275,154 @@ const applicantSchema = new mongoose.Schema( default: false, }, - statuses: { - isShortlisted: { - type: Boolean, - default: false, + files: { + resume: { + type: String, }, - isInitialInterview: { - type: Boolean, - default: false, + medCert: { + type: String, }, - isFinalInterview: { - type: Boolean, - default: false, + birthCert: { + type: String, }, - isJobOffer: { - type: Boolean, - default: false, + NBIClearance: { + type: String, }, - isHired: { - type: Boolean, - default: false, + policeClearance: { + type: String, + }, + TOR: { + type: String, + }, + idPhoto: { + type: String, }, }, - - isShortlisted: { - type: Boolean, - default: false, - }, - isInitialInterview: { - type: Boolean, - default: false, - }, - isFinalInterview: { - type: Boolean, - default: false, + + interviews: { + InitialInterview: { + type: String, + }, + TechnicalInterview: { + type: String, + }, + PanelInterview: { + type: String, + }, + BehavioralInterview: { + type: String, + }, + FinalInterview: { + type: String, + }, }, - isJobOffer: { - type: Boolean, - default: false, + + ids: { + TIN: { + type: String, + }, + SSS: { + type: String, + }, + philHealth: { + type: String, + }, + pagIBIGFundNumber: { + type: String, + }, }, - isHired: { - type: Boolean, - default: false, + + statuses: { + journey: { + isShortlisted: { + type: Boolean, + default: false, + }, + isInitialInterview: { + type: Boolean, + default: false, + }, + isTechnicalInterview: { + type: Boolean, + default: false, + }, + isPanelInterview: { + type: Boolean, + default: false, + }, + isBehavioralInterview: { + type: Boolean, + default: false, + }, + isFinalInterview: { + type: Boolean, + default: false, + }, + isJobOffer: { + type: Boolean, + default: false, + }, + isHired: { + type: Boolean, + default: false, + }, + withdrawn: { + type: Boolean, + default: false, + }, + }, + // preemployment: { + // files: { + // medCert: { + // type: Boolean, + // default: false, + // }, + // birthCert: { + // type: Boolean, + // default: false, + // }, + // NBIClearance: { + // type: Boolean, + // default: false, + // }, + // policeClearance: { + // type: Boolean, + // default: false, + // }, + // TOR: { + // type: Boolean, + // default: false, + // }, + // idPhoto: { + // type: Boolean, + // default: false, + // }, + // }, + // ids: { + // TIN: { + // type: Boolean, + // default: false, + // }, + // SSS: { + // type: Boolean, + // default: false, + // }, + // idPhoto: { + // type: Boolean, + // default: false, + // }, + // philHealth: { + // type: Boolean, + // default: false, + // }, + // pagIBIGFundNumber: { + // type: Boolean, + // default: false, + // }, + // } + // } }, + events: [{ type: mongoose.Schema.Types.ObjectId, ref: "Event", diff --git a/server/src/database/v1/models/facilityEventModel.ts b/server/src/database/v1/models/facilityEventModel.ts index 339ba700..bb53c13b 100644 --- a/server/src/database/v1/models/facilityEventModel.ts +++ b/server/src/database/v1/models/facilityEventModel.ts @@ -1,12 +1,10 @@ import mongoose, { Schema, Document } from 'mongoose'; - -// I hate that I need to do this -const EVENT_TYPES = ['Initial Interview', 'Final Interview', 'Other'] as const; +const EVENT_TYPES = ['Initial Interview', 'Final Interview', 'Technical Interview', 'Panel Interview', 'Behavioral Interview', 'Orientation', 'Other'] as const; interface IFacilityEvent extends Document { name: string; author: mongoose.Types.ObjectId; - type: 'Initial Interview' | 'Final Interview' | 'Other'; + type: 'Initial Interview' | 'Final Interview' | 'Technical Interview' | 'Panel Interview' | 'Behavioral Interview' | 'Orientation' | 'Other'; description?: string; date: Date; isAvailable: boolean; diff --git a/server/src/database/v1/models/facilityModel.ts b/server/src/database/v1/models/facilityModel.ts index 04b0c5eb..ff5ac4b1 100644 --- a/server/src/database/v1/models/facilityModel.ts +++ b/server/src/database/v1/models/facilityModel.ts @@ -6,15 +6,34 @@ import mongoose, { Schema, Document, Types } from 'mongoose'; // Interface representing a Facility document -interface IFacility extends Document { + +interface IRequirement { + title: string; + description?: string; +} + +interface IFacilityBase { name: string; type: string; description?: string; + requirements?: IRequirement[]; location: string; timeslots: Types.ObjectId[]; } -// Schema definition for the Facility model +export interface IFacility extends IFacilityBase, Document { + _id: Types.ObjectId; +} + +const requirementSchema = new Schema( + { + title: { type: String, required: true, trim: true }, + description: { type: String, trim: true }, + }, + // { _id: false } // This avoids adding _id to each requirement document. Re-enable if want. kek. +); + +// Facility schema definition const facilitySchema = new Schema( { name: { @@ -31,6 +50,7 @@ const facilitySchema = new Schema( type: String, trim: true, }, + requirements: [requirementSchema], location: { type: String, required: true, @@ -48,4 +68,4 @@ const facilitySchema = new Schema( } ); -export default mongoose.model('Facility', facilitySchema); \ No newline at end of file +export default mongoose.model('Facility', facilitySchema); diff --git a/server/src/database/v1/models/interviewFormModel.ts b/server/src/database/v1/models/interviewFormModel.ts index b1c2a9f4..f16daaf1 100644 --- a/server/src/database/v1/models/interviewFormModel.ts +++ b/server/src/database/v1/models/interviewFormModel.ts @@ -1,6 +1,6 @@ import mongoose, { Schema, Document } from 'mongoose'; -const INTERVIEW_TYPES = ['Phone', 'Video', 'In-Person']; +const INTERVIEW_MODE_TYPES = ['Phone', 'Video', 'In-Person']; const RECO_STATUS = ['yes', 'no', 'need further review'] interface IInterviewForm extends Document { @@ -10,6 +10,7 @@ interface IInterviewForm extends Document { date: Date; interviewer: mongoose.Types.ObjectId; type: 'Phone' | 'Video' | 'In-Person'; + interviewType: string; event: mongoose.Types.ObjectId; general: { communication: number; @@ -59,9 +60,12 @@ const interviewFormSchema = new Schema( }, type: { type: String, - enum: INTERVIEW_TYPES, + enum: INTERVIEW_MODE_TYPES, default: 'Phone', }, + interviewType: { + type: String, + }, event: { type: mongoose.Schema.Types.ObjectId, ref: 'facilityEvents', diff --git a/server/src/database/v1/models/jobOfferFormModel.ts b/server/src/database/v1/models/jobOfferFormModel.ts index 4fdb28de..fec836e7 100644 --- a/server/src/database/v1/models/jobOfferFormModel.ts +++ b/server/src/database/v1/models/jobOfferFormModel.ts @@ -1,6 +1,7 @@ import mongoose, { Schema, Document } from 'mongoose'; const OFFER_STATUSES = ['Pending', 'Accepted', 'Declined']; +const JOB_TYPES = ['Contractual', 'Regular', 'Temporary', 'Freelance']; // New Enum for job types interface IJobOfferForm extends Document { applicant: mongoose.Types.ObjectId; @@ -8,6 +9,11 @@ interface IJobOfferForm extends Document { salary: number; startDate: Date; benefits: string; + salaryType: 'Hourly' | 'Annual'; + contractDuration: string | null; + location: string; + workHours: string; + jobType: 'Contractual' | 'Regular' | 'Temporary' | 'Freelance'; // New field for job type status: 'Pending' | 'Accepted' | 'Declined'; issuedBy: mongoose.Types.ObjectId; issuedDate: Date; @@ -18,6 +24,7 @@ interface IJobOfferForm extends Document { emailSentDate: Date; expires: Date; notes: string; + probationPeriod: string | null; } const jobOfferFormSchema = new Schema( @@ -33,20 +40,40 @@ const jobOfferFormSchema = new Schema( validator(value) { return mongoose.Types.ObjectId.isValid(value) || typeof value === 'string'; }, - message: 'Author must be a valid ObjectId or a string.', + message: 'Position must be a valid ObjectId or a string.', }, }, salary: { type: Number, required: true, }, + salaryType: { + type: String, + enum: ['Hourly', 'Annual'], + default: 'Annual', + }, startDate: { type: Date, required: true, }, - benefits: - { + benefits: { + type: String, + }, + contractDuration: { type: String, + default: null, + }, + location: { + type: String, + required: true, + }, + workHours: { + type: String, + }, + jobType: { + type: String, + enum: JOB_TYPES, + required: true, // This field is now required }, status: { type: String, @@ -74,6 +101,7 @@ const jobOfferFormSchema = new Schema( }, emailsent: { type: Boolean, + default: false, }, emailSentDate: { type: Date, @@ -85,6 +113,10 @@ const jobOfferFormSchema = new Schema( type: String, default: '', }, + probationPeriod: { + type: String, + default: null, + }, }, { timestamps: true } ); diff --git a/server/src/database/v1/models/userModel.ts b/server/src/database/v1/models/userModel.ts index fb78c34a..2bae753b 100644 --- a/server/src/database/v1/models/userModel.ts +++ b/server/src/database/v1/models/userModel.ts @@ -1,6 +1,44 @@ -import mongoose from "mongoose"; +import mongoose, { Document, Types } from "mongoose"; -const verificationSchema = new mongoose.Schema({ +export interface IUserBase{ + firstname: string; + lastname: string; + email: string; + username: string; + password?: string; + emailVerifiedAt?: Date; + verification?: IVerification; + role: string; + status: string; + suspension: { + status: boolean; + reason?: string; + expiresAt?: Date; + }; + rememberToken?: string; + otp: IOtp | null; + knownDevices: string[]; + googleId?: string; // Google OAuth ID + displayName?: string; // Google display name + googleEmail?: string; // Google email + googleAvatar?: string; // Google avatar URL (optional) +} + +export interface IVerification extends Document { + code: string; + expiresAt: Date; +} + +export interface IUser extends IUserBase, Document { + _id: Types.ObjectId, +} + +export interface IOtp { + code: string; + expiresAt: Date; +} + +const verificationSchema = new mongoose.Schema({ code: { type: String, required: true, @@ -11,28 +49,16 @@ const verificationSchema = new mongoose.Schema({ }, }); -// const developerSchema = new mongoose.Schema( -// { -// apKey: { -// type: String, -// required: true, -// }, -// permissions: { -// type: [String], -// required: true, -// }, -// expiresAt: { -// type: Date, -// required: true, -// }, -// }, -// { -// timestamps: true, -// updateAt: true, -// } -// ); +const OtpSchema = new mongoose.Schema({ + code: { + type: String, + }, + expiresAt: { + type: Date + } +}) -const userSchema = new mongoose.Schema( +const userSchema = new mongoose.Schema( { firstname: { type: String, @@ -54,7 +80,7 @@ const userSchema = new mongoose.Schema( }, password: { type: String, - required: true, + required: false, // Password is optional for Google users }, emailVerifiedAt: { type: Date, @@ -81,18 +107,40 @@ const userSchema = new mongoose.Schema( type: String, }, expiresAt: { - type: Date - } + type: Date, + }, }, rememberToken: { type: String, required: false, }, + otp: { + type: OtpSchema + }, + knownDevices: [{ + type: String, + }], + // Google-related fields + googleId: { + type: String, + required: false, // Not required unless the user logged in with Google + }, + displayName: { + type: String, + required: false, // Not required unless the user logged in with Google + }, + googleEmail: { + type: String, + required: false, // Not required unless the user logged in with Google + }, + googleAvatar: { + type: String, + required: false, // Optional avatar URL for Google users + }, }, { timestamps: true, - updateAt: true, } ); -export default mongoose.model("User", userSchema); +export default mongoose.model("User", userSchema); diff --git a/server/src/index.ts b/server/src/index.ts index 08d06055..c3644d16 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ * @description Entry point for the server * @access public */ +import 'module-alias/register'; import express, { Application } from "express"; import session from "express-session"; @@ -23,6 +24,7 @@ import MongoStore from "connect-mongo"; import errorHandler from "./middlewares/errorHandler"; import verifyApiKey from "./middlewares/verifyApiKey"; import { verifyMailConn } from "./utils/mailHandler"; +import useragent from "express-useragent" const app: Application = express(); const host = config.server.host; @@ -64,15 +66,18 @@ app.use( resave: false, saveUninitialized: true, // Save new sessions cookie: { - secure: config.env === 'production', + secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000, - sameSite: "strict", + sameSite: "lax", }, store: mongoStore, }) ); + app.use(express.json()); +app.use(express.urlencoded({ extended: true})) +app.use(useragent.express()); app.use(helmet()); app.use(pinoHttp({ logger })); const env = config.env; @@ -92,6 +97,7 @@ const limiter = rateLimit({ }); app.use(limiter); app.use(sanitize); + const timestamp = new Date().toISOString(); process.on("unhandledRejection", (reason) => { logger.error(`Unhandled Rejection: ${reason}`); diff --git a/server/src/middlewares/errorHandler.ts b/server/src/middlewares/errorHandler.ts index df04198c..34152cee 100644 --- a/server/src/middlewares/errorHandler.ts +++ b/server/src/middlewares/errorHandler.ts @@ -5,11 +5,8 @@ interface CustomError extends Error { status?: number; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars const errorHandler = (err: CustomError, req: Request, res: Response, next: NextFunction) => { - if (!(res instanceof Response)) { - return next(new Error("Invalid response object")); - } - const status = err.status || 500; const message = err.message || "Internal Server Error"; @@ -17,6 +14,7 @@ const errorHandler = (err: CustomError, req: Request, res: Response, next: NextF res.status(status).json({ status, + success: false, message, }); }; diff --git a/server/src/middlewares/verifySession.ts b/server/src/middlewares/verifySession.ts index 75ddb3f7..7658a76a 100644 --- a/server/src/middlewares/verifySession.ts +++ b/server/src/middlewares/verifySession.ts @@ -1,12 +1,7 @@ -/** - * @file /middlewares/verifySession.ts - * @description Middleware to verify user session - */ - -import { Request, Response, NextFunction } from "express"; +import { Request as req, Response as res, NextFunction } from "express"; // sendError helper function -const sendError = (res: Response, statusCode: number, message: string) => { +const sendError = (res: res, statusCode: number, message: string) => { console.error(`Error ${statusCode}: ${message}`); return res.status(statusCode).json({ statusCode, @@ -19,38 +14,25 @@ interface Metadata { permissions: string[]; } -/** - * verifySession middleware - * @param metadata - Metadata object containing permissions - * @param allowGuest - Boolean to allow guest access without session (default: false) - * @returns Express middleware - * - * @description This middleware verifies the user session and permissions based on the metadata object provided - * It also validates the CSRF token if enabled - * It allows guest access if enabled - */ - const verifySession = (metadata: Metadata, allowGuest = false) => { - return (req: Request, res: Response, next: NextFunction) => { - if (!req.session) { - return sendError(res, 401, "Unauthorized: Session not initialized"); - } - - const user = req.session.user; + return (req: req, res: res, next: NextFunction) => { + const user = req.session.user; // Use Passport's way of accessing the authenticated user + console.info("User: ", user) + if (!user) { - if (allowGuest) { - return next(); // Allow access if guest access is enabled - } - return sendError(res, 401, "Unauthorized: Invalid or missing user session"); + if (allowGuest) return next(); + return sendError(res, 401, "Unauthorized: No user logged in"); } - if (!user.role || typeof user.role !== "string") { - return sendError(res, 401, "Unauthorized: User role is invalid"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userRole = (user as any).role; + if (!userRole || typeof userRole !== "string") { + return sendError(res, 401, "Unauthorized: Invalid user role"); } const permissions = metadata.permissions; - if (permissions.length > 0 && !permissions.includes(user.role)) { + if (permissions.length > 0 && !permissions.includes(userRole)) { return sendError(res, 403, "Forbidden: Insufficient permissions"); } @@ -58,4 +40,4 @@ const verifySession = (metadata: Metadata, allowGuest = false) => { }; }; -export default verifySession; \ No newline at end of file +export default verifySession; diff --git a/server/src/public/templates/applicationReceived.html b/server/src/public/templates/applicationReceived.html index 69274c8c..bb894213 100644 --- a/server/src/public/templates/applicationReceived.html +++ b/server/src/public/templates/applicationReceived.html @@ -1,34 +1,85 @@ - + + + Application Received + + diff --git a/server/src/public/templates/eventEmail.html b/server/src/public/templates/eventEmail.html index be5a82ff..1222039a 100644 --- a/server/src/public/templates/eventEmail.html +++ b/server/src/public/templates/eventEmail.html @@ -1,65 +1,115 @@ - + + + Event Invitation + - - -

Dear {{fullName}},

+ diff --git a/server/src/public/templates/jobofferEmail.html b/server/src/public/templates/jobofferEmail.html index 1980911e..55b4a38c 100644 --- a/server/src/public/templates/jobofferEmail.html +++ b/server/src/public/templates/jobofferEmail.html @@ -1,36 +1,65 @@ - + + + Job Offer + + diff --git a/server/src/public/templates/newDeviceAlert.html b/server/src/public/templates/newDeviceAlert.html new file mode 100644 index 00000000..08a8698d --- /dev/null +++ b/server/src/public/templates/newDeviceAlert.html @@ -0,0 +1,72 @@ + + + + + + New Login Alert + + + +
+

New Login Detected

+

We’ve noticed a login to your account from a new device.

+
+

Device: {{userAgent}}

+

IP Address: {{userIP}}

+

Time: {{time}}

+
+

If this wasn't you, please secure your account immediately.

+ +
+ + diff --git a/server/src/public/templates/otpEmail.html b/server/src/public/templates/otpEmail.html new file mode 100644 index 00000000..db2ba8c9 --- /dev/null +++ b/server/src/public/templates/otpEmail.html @@ -0,0 +1,91 @@ + + + + + + OTP Verification + + + + + + diff --git a/server/src/public/templates/rejectionEmail.html b/server/src/public/templates/rejectionEmail.html new file mode 100644 index 00000000..af06fa64 --- /dev/null +++ b/server/src/public/templates/rejectionEmail.html @@ -0,0 +1,61 @@ + + + + + + Application Status + + + + + + diff --git a/server/src/routes/v1/account.ts b/server/src/routes/v1/account.ts index 11add385..f9df13eb 100644 --- a/server/src/routes/v1/account.ts +++ b/server/src/routes/v1/account.ts @@ -16,27 +16,22 @@ router.get( "/all", verifySession({ permissions: ["admin"], - }, - true, - ), + }), getAllAccounts ); + router.get( "/:id", verifySession({ permissions: ["admin"], - }, - true, - ), + }), getAccountById ); router.put( "/:id", verifySession({ permissions: ["admin"], - }, - true, - ), + }), updateAccount ); export default { diff --git a/server/src/routes/v1/api.ts b/server/src/routes/v1/api.ts index a0ca3b62..5d817ce4 100644 --- a/server/src/routes/v1/api.ts +++ b/server/src/routes/v1/api.ts @@ -12,7 +12,6 @@ router.get( verifySession({ permissions: ["admin"], }, - true, ), updateApikey ); diff --git a/server/src/routes/v1/applicant.ts b/server/src/routes/v1/applicant.ts index 88312776..e36ff221 100644 --- a/server/src/routes/v1/applicant.ts +++ b/server/src/routes/v1/applicant.ts @@ -1,50 +1,72 @@ -import { Router } from "express"; +import { Request as req, Response as res, NextFunction as next, Router } from "express"; const router = Router(); import verifySession from "../../middlewares/verifySession"; import { - addNewResume, - updateResume, - getAllResumeData, + addApplicant, + updateApplicant, + getAllApplicant, getApplicantByDocumentCategory, - getResumeById, - deleteResume, - searchResume, - getResumeFile, + getApplicantById, + deleteApplicant, + searchApplicant, + getFile, updateStat, + getEligibleForJobOffer, + uploadFile, + rejectApplicant, } from "../../database/v1/controllers/applicantController"; import { createScreening, getAllScreening, screenApplicantViaAI, updateScreening } from "../../database/v1/controllers/screeningController"; import { createInterview, getAllInterview, getAllRecentInterviews, updateInterview } from "../../database/v1/controllers/interviewController"; import { getAllApplicantFacilityEvents } from "../../database/v1/controllers/facilityController"; -import { createJoboffer, getAllJoboffer, getAllRecentJoboffer, getJobofferById, sendJobOfferMail, updateJoboffer } from "../../database/v1/controllers/jobofferController"; +import { createJoboffer, getApplicantJoboffer, getAllRecentJoboffer, getJobofferById, sendJobOfferMail, updateJoboffer } from "../../database/v1/controllers/jobofferController"; +import { upload } from "../../utils/fileUploadHandler"; +import multer from "multer"; + +const uploader = upload('applicants') router.post( "/", verifySession({ permissions: ["applicant", "admin", "manager"], - }, - true, - ), - addNewResume + }), + addApplicant ); router.put( "/:id", verifySession({ permissions: ["applicant", "admin", "manager", "recruiter", "interviewer"], + }), + uploader.fields([ + { name: "resume", maxCount: 1 }, + { name: "medCert", maxCount: 1 }, + { name: "birthCert", maxCount: 1 }, + { name: "NBIClearance", maxCount: 1 }, + { name: "policeClearance", maxCount: 1 }, + { name: "TOR", maxCount: 1 }, + { name: "idPhoto", maxCount: 1 }, + ]), + (err: { message: unknown; }, req: req, res: res, next: next) => { + if (err) { + // Check for specific Multer errors + if (err instanceof multer.MulterError) { + // Handle Multer specific errors + return res.status(400).send({ message: err.message }); + } + // Handle general errors + return res.status(500).send({ message: "Something went wrong!" }); + } + next(); }, - true, - ), - updateResume + updateApplicant ); router.put( "/status/:applicantId/:stat", verifySession({ permissions: ["applicant", "admin", "manager", "recruiter", "interviewer"], - }, - true, - ), + }), updateStat ) @@ -52,60 +74,49 @@ router.get( "/all", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), - getAllResumeData + }), + getAllApplicant ); router.get( "/category/:category", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getApplicantByDocumentCategory ); router.get( - "/download/:id", + "/search", verifySession({ - permissions: ["admin", "manager", "recruiter", "interviewer"], - }, - true, - ), - getResumeFile + permissions: ["admin", "manager", "recruiter"], + }), + searchApplicant ); router.get( - "/search", + "/:id", verifySession({ - permissions: ["admin", "manager", "recruiter"], - }, - true, - ), - searchResume + permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], + }), + getApplicantById ); router.get( - "/:id", + "/:id/reject", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), - getResumeById + }), + rejectApplicant ); + router.delete( "/:id", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), - deleteResume + }), + deleteApplicant ); // Events @@ -113,9 +124,7 @@ router.get( "/events/:applicantId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), getAllApplicantFacilityEvents ) @@ -125,9 +134,7 @@ router.post( "/screen/:applicantId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), createScreening ) @@ -135,9 +142,7 @@ router.put( "/screen/:screeningId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), updateScreening ) @@ -145,9 +150,7 @@ router.get( "/screen/ai/:applicantId/:jobId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), screenApplicantViaAI ) @@ -155,9 +158,7 @@ router.get( "/screen/ai/:applicantId/:jobId/:screeningId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), screenApplicantViaAI ) @@ -165,9 +166,7 @@ router.get( "/screen/all/:applicantId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), getAllScreening ) @@ -177,9 +176,7 @@ router.post( "/interview/:applicantId/:eventId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), createInterview ) @@ -187,9 +184,7 @@ router.put( "/interview/:interviewId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), updateInterview ) @@ -197,9 +192,7 @@ router.get( "/interview/all/:applicantId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer", "applicant"], - }, - true, - ), + }), getAllInterview ) @@ -207,9 +200,7 @@ router.get( "/interview/recent", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer"], - }, - true, - ), + }), getAllRecentInterviews ) @@ -218,9 +209,7 @@ router.get( "/interview/:interviewId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer"], - }, - true, - ), + }), getAllInterview ) @@ -229,9 +218,7 @@ router.post( "/joboffer/:applicantId/", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), createJoboffer ) @@ -239,9 +226,7 @@ router.post( "/joboffer/send-email/:jobofferId", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), sendJobOfferMail ) @@ -249,29 +234,31 @@ router.put( "/joboffer/:jobofferId/", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), updateJoboffer ) +router.get( + "/joboffer/all/", + verifySession({ + permissions: ["admin", "manager", "recruiter"], + }), + getEligibleForJobOffer +) + router.get( "/joboffer/all/:applicantId/", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), - getAllJoboffer + }), + getApplicantJoboffer ) router.get( "/joboffer/recent/", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getAllRecentJoboffer ) @@ -279,12 +266,29 @@ router.get( "/joboffer/:applicantId", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getJobofferById ) +// + +router.get( + "/:applicantId/file/:fileType", + verifySession({ + permissions: ["admin", "manager", "recruiter", "interviewer"], + }), + getFile +); + +router.post( + "/file/:applicantId/interview/:fileType", + verifySession({ + permissions: ["admin", "manager", "recruiter", "interviewer"], + }), + upload('applicants').single('file'), + uploadFile +); + export default { metadata: { path: "/applicant", diff --git a/server/src/routes/v1/auth.ts b/server/src/routes/v1/auth.ts index 3b74f265..c52d0ece 100644 --- a/server/src/routes/v1/auth.ts +++ b/server/src/routes/v1/auth.ts @@ -1,44 +1,53 @@ -import { Router } from "express"; +import { Router } from 'express'; const router = Router(); -import dotenv from "dotenv"; +import dotenv from 'dotenv'; dotenv.config(); -import verifySession from "../../middlewares/verifySession"; -import { createUser, login, logout, verify } from "../../database/v1/controllers/authController"; - +// import verifySession from '../../middlewares/verifySession'; +import { createUser, logout, verify, googleAuth, googleCallback, login, sendOTP, verifyOTP } from '../../database/v1/controllers/authController'; router.post( - "/register", - verifySession({ - permissions: [] - },true), + '/register', createUser ); + router.post( - "/login", - verifySession({ - permissions: [] - },true), + '/login', login ); + router.get( - "/logout", - verifySession({ - permissions: [] - },true), + '/logout', logout ); + router.get( - "/verify", - verifySession({ - permissions: [] - },true), + '/me', verify ); +router.post( + '/send-otp', + sendOTP +) + +router.post( + '/verify-otp', + verifyOTP +) + +// Google OAuth +router.get('/google', + googleAuth +); + +router.get('/google/callback', + googleCallback +); + export default { metadata: { - path: "/auth", - description: "This route is used to register, login, logout and verify user", + path: '/auth', + description: 'This route is used to register, login, logout, and verify user', }, router, }; diff --git a/server/src/routes/v1/facilities.ts b/server/src/routes/v1/facilities.ts index ceee572d..cc662c63 100644 --- a/server/src/routes/v1/facilities.ts +++ b/server/src/routes/v1/facilities.ts @@ -38,9 +38,7 @@ router.post( "/create", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), createFacility ); @@ -48,9 +46,7 @@ router.put( "/update/:facilityId", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), updateFacility ); @@ -58,9 +54,7 @@ router.get( "/all", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getAllFacilities ); @@ -68,9 +62,7 @@ router.get( "/:facilityId", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getFacilityById ); @@ -78,9 +70,7 @@ router.delete( "/delete/:facilityId", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), removeFacility ); @@ -89,9 +79,7 @@ router.post( "/timeslot/create/:facilityId", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), createFacilityTimeslot ); @@ -108,9 +96,7 @@ router.delete( "/timeslot/delete/:timeslotId", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), removeFacilityTimeslot ); @@ -119,9 +105,7 @@ router.post( "/event/:timeslotId", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), createFacilityEvent ); @@ -129,9 +113,7 @@ router.put( "/event/:timeslotId", verifySession({ permissions: ["admin", "manager"] - }, - true, - ), + }), updateFacilityEvent ); @@ -139,9 +121,7 @@ router.delete( "/event/:timeslotId", verifySession({ permissions: ["admin", "manager"] - }, - true, - ), + }), deleteFacilityEvent ); @@ -149,9 +129,7 @@ router.get( "/events/:eventId/calendar-states", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer"], - }, - true, - ), + }), getFacilityCalendarStates ); @@ -159,9 +137,7 @@ router.get( "/event/:eventId", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer"] - }, - true, - ), + }), getFacilityEventByID ); @@ -169,9 +145,7 @@ router.get( "/events/:eventId/:date", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer"], - }, - true, - ), + }), getFacilityEventsForDate ); @@ -179,20 +153,16 @@ router.get( "/events/upcoming", verifySession({ permissions: ["admin", "manager", "recruiter", "interviewer"], - }, - true, - ), + }), getUpcomingEvents ); // Booking router.post( - "/events/:eventId/book", + "/events/:eventId/book/applicant/:applicantId", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), bookApplicantToEvent ); @@ -200,9 +170,7 @@ router.post( "/events/:eventId/send-email", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), SendEmailToFacilityEventParticipants ); @@ -210,9 +178,7 @@ router.delete( "/events/:eventId/unbook", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), unbookApplicantFromEvent ); diff --git a/server/src/routes/v1/health.ts b/server/src/routes/v1/health.ts index fb688784..281874b0 100644 --- a/server/src/routes/v1/health.ts +++ b/server/src/routes/v1/health.ts @@ -11,9 +11,7 @@ router.get( verifySession( { permissions: ["user", "admin"], - }, - true, - ), + }), (req, res) => { res.send("OK"); } diff --git a/server/src/routes/v1/job.ts b/server/src/routes/v1/job.ts index 79cb9f7e..97e71c76 100644 --- a/server/src/routes/v1/job.ts +++ b/server/src/routes/v1/job.ts @@ -14,9 +14,7 @@ router.post( verifySession( { permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), createJob ); @@ -25,9 +23,7 @@ router.put( verifySession( { permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), updateJob ); @@ -36,9 +32,7 @@ router.get( verifySession( { permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getAllJob ); @@ -47,9 +41,7 @@ router.get( verifySession( { permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getJobById ); @@ -58,9 +50,7 @@ router.delete( verifySession( { permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), (req, res) => { res.send("OK"); } diff --git a/server/src/routes/v1/jobposter.ts b/server/src/routes/v1/jobposter.ts index 50dbcbb0..5f336c2b 100644 --- a/server/src/routes/v1/jobposter.ts +++ b/server/src/routes/v1/jobposter.ts @@ -10,30 +10,25 @@ import { createJobposter, getJobposterByRefId, getAllJobposters, removeJobposter router.post("/:id/post", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), createJobposter); + router.get("/all", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getAllJobposters); + router.get("/:id", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getJobposterByRefId); + router.delete("/:id", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), removeJobposter); export default { diff --git a/server/src/routes/v1/jobposting.ts b/server/src/routes/v1/jobposting.ts index a323cfc0..2521d2c3 100644 --- a/server/src/routes/v1/jobposting.ts +++ b/server/src/routes/v1/jobposting.ts @@ -11,9 +11,7 @@ router.post( "/", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), createJobposting ); @@ -21,54 +19,42 @@ router.get( "/search", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), searchJobpostings ); router.get( "/", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getAllJobpostings ); router.get( "/scheduled", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getAllScheduledJobpostings ); router.get( "/:id", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), getJobpostingById ); router.put( "/:id", verifySession({ permissions: ["admin", "manager", "recruiter"], - }, - true, - ), + }), updateJobposting ); router.delete( "/:id", verifySession({ permissions: ["user", "admin"], - }, - true, - ), + }), deleteJobposting ); diff --git a/server/src/routes/v1/stats.ts b/server/src/routes/v1/stats.ts new file mode 100644 index 00000000..66893c5f --- /dev/null +++ b/server/src/routes/v1/stats.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +const router = Router(); + +import dotenv from "dotenv"; +import verifySession from "../../middlewares/verifySession"; +import { statistics } from "../../database/v1/controllers/statisticController"; +dotenv.config(); + +router.get( + "/", + verifySession({ + permissions: ["admin", "manager", "recruiter"], + }), + statistics +); + +export default { + metadata: { + path: "/stats", + description: "Test route", + }, + router, +}; diff --git a/server/src/routes/v1/tags.ts b/server/src/routes/v1/tags.ts index c8f739c7..281e63af 100644 --- a/server/src/routes/v1/tags.ts +++ b/server/src/routes/v1/tags.ts @@ -11,63 +11,55 @@ router.post( "/", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), createTag ); + router.get( "/all", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), getAllTags ); + router.get( "/search", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), searchTags ); + router.get( "/category/:category", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), getTagByCategory ); + router.get( "/:id", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), getTagById ); + router.put( "/:id", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), updateTag ); + router.delete( "/:id", verifySession({ permissions: ["admin", "manager"], - }, - true, - ), + }), deleteTag ); diff --git a/server/src/types/express-session.d.ts b/server/src/types/express-session.d.ts index a0ed07b2..99363a32 100644 --- a/server/src/types/express-session.d.ts +++ b/server/src/types/express-session.d.ts @@ -1,20 +1,23 @@ import "express-session"; export interface SessionUser { - _id: string; - username: string; - role: string; - email: string; - status: string; - token: string; - emailVerifiedAt: Date | null; - // permissions: string[]; + _id: string; + firstname: string; + lastname: string; + username: string; + role: string; + email: string; + status: string; + emailVerifiedAt?: Date | null; } +// Represents a pending device fingerprint (user-agent + IP hash or string) +export type PendingDevice = string | null; + // Extend the express-session SessionData interface declare module "express-session" { - interface SessionData { - user?: SessionUser; // Use `user?` to make it optional since it may not exist in some cases (e.g., if not logged in) - csrfToken?: string; // Optional CSRF token in session - } + interface SessionData { + user?: SessionUser; + pendingDevice?: PendingDevice; + } } diff --git a/server/src/utils/fileUploadHandler.ts b/server/src/utils/fileUploadHandler.ts index 24c28996..819ee0b7 100644 --- a/server/src/utils/fileUploadHandler.ts +++ b/server/src/utils/fileUploadHandler.ts @@ -11,38 +11,36 @@ import { config } from "../config"; // Create the main upload directory if it doesn't exist const dest = path.join(config.fileServer.dir); if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); + fs.mkdirSync(dest, { recursive: true }); } // Setup storage engine for uploaded files -export const upload = (childDir: string) => { - const storage = multer.diskStorage({ - destination: (req, file, cb) => { - const uploadPath = path.join(dest, childDir); +export const upload = (baseDir: string) => { + const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadPath = path.join(dest, baseDir, file.fieldname); + if (!fs.existsSync(uploadPath)) { + fs.mkdirSync(uploadPath, { recursive: true }); + } - // Create the child directory if it doesn't exist - if (!fs.existsSync(uploadPath)) { - fs.mkdirSync(uploadPath, { recursive: true }); - } + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + cb(null, `${Date.now()}-${file.originalname.replace(/\s/g, "-").toLowerCase()}`); + }, + }); - cb(null, uploadPath); - }, - filename: (req, file, cb) => { - cb(null, `${Date.now()}-${file.originalname.replace(/\s/g, "-").toLowerCase()}`); - }, - }); - - return multer({ - storage: storage, - fileFilter: (req, file, cb) => { - const filetypes = /pdf|doc|docx/; - const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); - const mimetype = filetypes.test(file.mimetype); - if (extname && mimetype) { - return cb(null, true); - } else { - cb(new Error("File type not supported")); - } - }, - }); + return multer({ + storage, + fileFilter: (req, file, cb) => { + const filetypes = /pdf|doc|docx|jpg|jpeg|png/; + const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = filetypes.test(file.mimetype); + if (extname && mimetype) { + return cb(null, true); + } else { + cb(new Error("File type not supported")); + } + }, + }); }; diff --git a/server/tsconfig.json b/server/tsconfig.json index bd678b28..a67a7054 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -28,17 +28,18 @@ "module": "commonjs" /* Specify what module code is generated. */, "rootDir": "./src" /* Specify the root folder within your source files. */, // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, + "baseUrl": "." /* Specify the base directory to resolve non-relative module names. */, "paths": { "*": ["node_modules/*"], "@src/*": ["./src/*"], "@types/*": ["./src/types/*"], "@middlewares/*": ["./src/middlewares/*"], "@v1/controllers/*": ["./src/database/v1/controllers/*"], - "@v1/models/*": ["./src/database/v1/models/*"] + "@v1/models/*": ["./src/database/v1/models/*"], + "@v1/config/*": ["./src/config/v1/*"] } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - "typeRoots": ["./node_modules/@types", "./src/types"] /* Specify multiple folders that act like './node_modules/@types'. */, + "typeRoots": ["./node_modules/@types", "./src/types", "./dist/types"] /* Specify multiple folders that act like './node_modules/@types'. */, // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */