diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 3c3629e..0000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -node_modules diff --git a/README.md b/README.md index 3eb21ff..35b8e37 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@

-CI Status -CodeQL Status - license - last-commit +CI Status +CodeQL Status + license + last-commit

@@ -34,6 +34,7 @@ - [☑️ Prerequisites](#️-prerequisites) - [⚙️ Installation](#️-installation) - [🤖 Usage](#-usage) + - [🔐 Auth API](#-auth-api) - [📌 Project Roadmap](#-project-roadmap) - [🔰 Contributing](#-contributing) - [🎗 License](#-license) @@ -65,7 +66,7 @@ Install work-whiz using one of the following methods: 1. Clone the work-whiz repository: ```sh -❯ git clone https://github.com/the-berufegoru/work-whiz +❯ git clone https://github.com/iamkabelomoobi/work-whiz ``` 2. Navigate to the project directory: @@ -85,7 +86,7 @@ Install work-whiz using one of the following methods: **Using `docker`**   [](https://www.docker.com/) ```sh -❯ docker build -t the-berufegoru/work-whiz . +❯ docker build -t iamkabelomoobi/work-whiz . ``` ### 🤖 Usage @@ -114,6 +115,128 @@ Run the test suite using the following command: ❯ npm run test ```` +### 🔐 Auth API + +Auth routes are handled by Better Auth and mounted under `/api/auth`. + +#### Main endpoints + +| Method | Endpoint | +| --- | --- | +| `POST` | `/api/auth/sign-up/email` | +| `POST` | `/api/auth/sign-in/email` | +| `POST` | `/api/auth/sign-out` | +| `GET`, `POST` | `/api/auth/get-session` | +| `GET` | `/api/auth/verify-email?token=...` | +| `POST` | `/api/auth/send-verification-email` | +| `POST` | `/api/auth/request-password-reset` | +| `POST` | `/api/auth/reset-password` | +| `GET` | `/api/auth/reset-password/:token` | +| `POST` | `/api/auth/change-password` | +| `POST` | `/api/auth/verify-password` | + +#### Sign-up request bodies + +Candidate: + +```json +{ + "name": "Candidate User", + "email": "candidate@example.com", + "password": "StrongPass123!", + "phone": "+270000000001", + "role": "candidate", + "title": "Mr" +} +``` + +Employer: + +```json +{ + "name": "Orbit Talent", + "email": "employer@example.com", + "password": "StrongPass123!", + "phone": "+270000000002", + "role": "employer", + "industry": "Technology", + "websiteUrl": "https://example.com", + "location": "Remote", + "description": "Hiring platform team", + "size": 25, + "foundedIn": 2020 +} +``` + +Admin: + +```json +{ + "name": "Admin User", + "email": "admin@example.com", + "password": "StrongPass123!", + "phone": "+270000000003", + "role": "admin" +} +``` + +#### Common request bodies + +Sign in: + +```json +{ + "email": "candidate@example.com", + "password": "StrongPass123!" +} +``` + +Request password reset: + +```json +{ + "email": "candidate@example.com", + "redirectTo": "http://localhost:4200/reset-password" +} +``` + +Reset password: + +```json +{ + "token": "reset-token-from-email", + "newPassword": "NewStrongPass123!" +} +``` + +#### Session and account endpoints + +| Method | Endpoint | +| --- | --- | +| `GET` | `/api/auth/list-sessions` | +| `POST` | `/api/auth/revoke-session` | +| `POST` | `/api/auth/revoke-sessions` | +| `POST` | `/api/auth/revoke-other-sessions` | +| `POST` | `/api/auth/update-session` | +| `POST` | `/api/auth/update-user` | +| `POST` | `/api/auth/delete-user` | +| `GET` | `/api/auth/delete-user/callback` | +| `GET` | `/api/auth/list-accounts` | +| `POST` | `/api/auth/unlink-account` | +| `GET` | `/api/auth/account-info` | + +#### OAuth and social endpoints + +| Method | Endpoint | +| --- | --- | +| `POST` | `/api/auth/sign-in/social` | +| `GET`, `POST` | `/api/auth/callback/:id` | +| `POST` | `/api/auth/link-social` | +| `POST` | `/api/auth/refresh-token` | +| `POST` | `/api/auth/get-access-token` | + +The current app configuration enables email/password auth and email verification. Social provider endpoints are exposed by Better Auth, but no social providers are configured yet. + --- ## 📌 Project Roadmap @@ -128,9 +251,9 @@ Run the test suite using the following command: ## 🔰 Contributing -- **💬 [Join the Discussions](https://github.com/the-berufegoru/work-whiz/discussions)**: Share your insights, provide feedback, or ask questions. -- **🐛 [Report Issues](https://github.com/the-berufegoru/work-whiz/issues)**: Submit bugs found or log feature requests for the `work-whiz` project. -- **💡 [Submit Pull Requests](https://github.com/the-berufegoru/work-whiz/blob/main/CONTRIBUTING.md)**: Review open PRs, and submit your own PRs. +- **💬 [Join the Discussions](https://github.com/iamkabelomoobi/work-whiz/discussions)**: Share your insights, provide feedback, or ask questions. +- **🐛 [Report Issues](https://github.com/iamkabelomoobi/work-whiz/issues)**: Submit bugs found or log feature requests for the `work-whiz` project. +- **💡 [Submit Pull Requests](https://github.com/iamkabelomoobi/work-whiz/blob/main/CONTRIBUTING.md)**: Review open PRs, and submit your own PRs.

Contributing Guidelines @@ -139,7 +262,7 @@ Run the test suite using the following command: 2. **Clone Locally**: Clone the forked repository to your local machine using a git client. ```sh - git clone https://github.com/the-berufegoru/work-whiz + git clone https://github.com/iamkabelomoobi/work-whiz ``` 3. **Create a New Branch**: Always work on a new branch, giving it a descriptive name. @@ -170,8 +293,8 @@ Run the test suite using the following command: Contributor Graph

- - + +

diff --git a/docker-compose.yml b/docker-compose.yml index bce30e9..3bdc50b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,7 @@ services: elasticsearch: image: ${ELASTICSEARCH_IMAGE:-docker.elastic.co/elasticsearch/elasticsearch:8.15.3} - container_name: ${ELASTICSEARCH_CONTAINER_DEV:-estate-grid-elasticsearch-dev} + container_name: ${ELASTICSEARCH_CONTAINER_DEV:-work-whiz-elasticsearch-dev} restart: unless-stopped ports: - "${ELASTICSEARCH_PORT:-9200}:9200" @@ -65,7 +65,7 @@ services: kibana: image: ${KIBANA_IMAGE:-docker.elastic.co/kibana/kibana:8.15.3} - container_name: ${KIBANA_CONTAINER_DEV:-estate-grid-kibana-dev} + container_name: ${KIBANA_CONTAINER_DEV:-work-whiz-kibana-dev} restart: unless-stopped ports: - "${KIBANA_PORT:-5601}:5601" diff --git a/e2e/jest.config.ts b/e2e/jest.config.ts index 776ea12..66dac06 100644 --- a/e2e/jest.config.ts +++ b/e2e/jest.config.ts @@ -11,6 +11,7 @@ export default { 'ts-jest', { tsconfig: '/tsconfig.spec.json', + diagnostics: false, }, ], }, diff --git a/e2e/src/auth/auth.e2e.spec.ts b/e2e/src/auth/auth.e2e.spec.ts new file mode 100644 index 0000000..54235f6 --- /dev/null +++ b/e2e/src/auth/auth.e2e.spec.ts @@ -0,0 +1,301 @@ +import axios from 'axios'; +import { randomUUID } from 'crypto'; +import { prisma } from '../../../src/libs/database'; + +const baseURL = process.env.E2E_BASE_URL || axios.defaults.baseURL; +const mailBaseURL = process.env.MAILDEV_WEB_URL || 'http://127.0.0.1:1080'; + +if (!baseURL) { + throw new Error('E2E base URL is not configured'); +} + +const api = axios.create({ + baseURL, + validateStatus: () => true, +}); + +const mailApi = axios.create({ + baseURL: mailBaseURL, + validateStatus: () => true, +}); + +const runId = randomUUID(); +const emailPrefix = `e2e-auth-${runId}`; +const password = 'AuthTest!12345'; +const newPassword = 'AuthTest!67890'; + +type SignupRole = 'admin' | 'candidate' | 'employer'; + +const buildEmail = (role: SignupRole): string => + `${emailPrefix}.${role}@example.com`; + +const cookieHeader = (response: { headers: { 'set-cookie'?: string[] } }): string => + (response.headers['set-cookie'] || []) + .map(value => value.split(';')[0]) + .join('; '); + +const sleep = async (ms: number): Promise => { + await new Promise(resolve => setTimeout(resolve, ms)); +}; + +const deleteTestData = async (): Promise => { + await prisma.user.deleteMany({ + where: { + email: { + startsWith: emailPrefix, + }, + }, + }); + + await prisma.verification.deleteMany({ + where: { + identifier: { startsWith: `reset-password:` }, + }, + }); +}; + +const getVerificationTokenFromEmail = async ( + email: string, +): Promise => { + for (let attempt = 0; attempt < 30; attempt++) { + const emailsResponse = await mailApi.get('/email', { + params: { 'to.address': email }, + }); + + expect(emailsResponse.status).toBe(200); + + const emails = Array.isArray(emailsResponse.data) ? emailsResponse.data : []; + const latest = emails[0]; + + if (latest?.id) { + const htmlResponse = await mailApi.get(`/email/${latest.id}/html`); + expect(htmlResponse.status).toBe(200); + + const html = String(htmlResponse.data || ''); + const match = html.match(/verify-email\?token=([^"&]+)/); + if (match?.[1]) { + return decodeURIComponent(match[1]); + } + } + + await sleep(1000); + } + + throw new Error(`Timed out waiting for verification email for ${email}`); +}; + +const signUpUser = async ( + role: SignupRole, +): Promise<{ email: string; cookie: string }> => { + const email = buildEmail(role); + + const rolePayload = + role === 'admin' + ? {} + : role === 'candidate' + ? { title: 'Mr' } + : { + industry: 'Technology', + websiteUrl: 'https://example.com', + location: 'Remote', + description: 'Test employer', + size: 25, + foundedIn: 2020, + }; + + const response = await api.post('/api/auth/sign-up/email', { + name: `${role} user`, + email, + password, + phone: `+2700000${Math.floor(Math.random() * 1000000) + .toString() + .padStart(6, '0')}`, + role, + ...rolePayload, + }); + + expect(response.status).toBe(200); + + return { + email, + cookie: cookieHeader(response), + }; +}; + +const verifyEmail = async (email: string): Promise => { + const token = await getVerificationTokenFromEmail(email); + const response = await api.get('/api/auth/verify-email', { + params: { token }, + }); + + expect(response.status).toBe(200); +}; + +const signInUser = async ( + email: string, + currentPassword: string, +): Promise<{ cookie: string }> => { + const response = await api.post('/api/auth/sign-in/email', { + email, + password: currentPassword, + }); + + expect(response.status).toBe(200); + expect(response.headers['set-cookie']).toBeDefined(); + + return { cookie: cookieHeader(response) }; +}; + +const requestPasswordResetToken = async (email: string): Promise => { + const response = await api.post('/api/auth/request-password-reset', { + email, + redirectTo: 'http://localhost:4200/reset-password', + }); + + expect(response.status).toBe(200); + + for (let attempt = 0; attempt < 30; attempt++) { + const verification = await prisma.verification.findFirst({ + where: { + identifier: { + startsWith: 'reset-password:', + }, + value: { + not: '', + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (verification?.identifier) { + return verification.identifier.replace('reset-password:', ''); + } + + await sleep(1000); + } + + throw new Error(`Timed out waiting for password reset token for ${email}`); +}; + +describe('Auth e2e', () => { + beforeAll(async () => { + await deleteTestData(); + }); + + afterAll(async () => { + await deleteTestData(); + await prisma.$disconnect(); + }); + + it.each<[ + SignupRole, + string, + string, + ]>([ + ['candidate', '/api/candidates/me', '/api/admins/me'], + ['admin', '/api/admins/me', '/api/candidates/me'], + ['employer', '/api/employers/me', '/api/admins/me'], + ])( + 'creates a %s user through Better Auth and authorizes protected routes', + async (role, allowedRoute, forbiddenRoute) => { + const { email } = await signUpUser(role); + await verifyEmail(email); + + const session = await signInUser(email, password); + + const allowedResponse = await api.get(allowedRoute, { + headers: { + Cookie: session.cookie, + }, + }); + expect(allowedResponse.status).toBe(200); + + const forbiddenResponse = await api.get(forbiddenRoute, { + headers: { + Cookie: session.cookie, + }, + }); + expect(forbiddenResponse.status).toBe(403); + + const user = await prisma.user.findFirst({ + where: { email }, + }); + expect(user).toBeTruthy(); + expect(user?.emailVerified).toBe(true); + expect(user?.isVerified).toBe(true); + expect(user?.role).toBe(role); + + if (role === 'candidate') { + const profile = await prisma.candidate.findFirst({ + where: { userId: user!.id }, + }); + expect(profile).toMatchObject({ + title: 'Mr', + }); + } + + if (role === 'admin') { + const profile = await prisma.admin.findFirst({ + where: { userId: user!.id }, + }); + expect(profile).toBeTruthy(); + } + + if (role === 'employer') { + const profile = await prisma.employer.findFirst({ + where: { userId: user!.id }, + }); + expect(profile).toMatchObject({ + industry: 'Technology', + }); + } + }, + ); + + it('resets a password, revokes the old session, and allows the new password', async () => { + const { email } = await signUpUser('candidate'); + await verifyEmail(email); + + const session = await signInUser(email, password); + + const resetToken = await requestPasswordResetToken(email); + const resetResponse = await api.post('/api/auth/reset-password', { + newPassword, + token: resetToken, + }); + + expect(resetResponse.status).toBe(200); + + const oldSessionResponse = await api.get('/api/candidates/me', { + headers: { + Cookie: session.cookie, + }, + }); + expect(oldSessionResponse.status).toBe(401); + + const oldPasswordLogin = await api.post('/api/auth/sign-in/email', { + email, + password, + }); + expect(oldPasswordLogin.status).toBe(401); + + const newLogin = await api.post('/api/auth/sign-in/email', { + email, + password: newPassword, + }); + + expect(newLogin.status).toBe(200); + expect(newLogin.headers['set-cookie']).toBeDefined(); + + const signedOut = await api.post( + '/api/auth/sign-out', + {}, + { + headers: { + Cookie: cookieHeader(newLogin), + }, + }, + ); + expect(signedOut.status).toBe(200); + }); +}); diff --git a/e2e/src/server/server.spec.ts b/e2e/src/server/server.spec.ts deleted file mode 100644 index 51717c7..0000000 --- a/e2e/src/server/server.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -describe('GET /', () => { - it('should return a message', async () => { - const res = await axios.get(`/`); - - expect(res.status).toBe(200); - expect(res.data).toEqual({ message: 'Hello API' }); - }); -}); diff --git a/e2e/src/support/global-setup.ts b/e2e/src/support/global-setup.ts index c1f5144..8aaa499 100644 --- a/e2e/src/support/global-setup.ts +++ b/e2e/src/support/global-setup.ts @@ -1,10 +1,206 @@ /* eslint-disable */ -var __TEARDOWN_MESSAGE__: string; +const { spawn, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const net = require('node:net'); +const axios = require('axios'); + +const TEST_PORT = 3101; +const TEST_HOST = '127.0.0.1'; +const STATE_FILE = path.join(process.cwd(), 'e2e/.tmp/auth-e2e-state.json'); +const MAILDEV_BIN = path.join( + process.cwd(), + 'node_modules/.bin/maildev', +); +const PRISMA_BIN = path.join( + process.cwd(), + 'node_modules/.bin/prisma', +); +const NODE_BIN = process.execPath; + +const isPortOpen = (host: string, port: number): Promise => + new Promise(resolve => { + const socket = net.createConnection({ host, port }); + + socket.setTimeout(500); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.on('error', () => { + resolve(false); + }); + }); + +const resolveServicePort = async ( + host: string, + candidates: number[], + serviceName: string, +): Promise => { + for (const port of candidates) { + if (await isPortOpen(host, port)) { + return port; + } + } + + throw new Error( + `No ${serviceName} instance is listening on any of: ${candidates.join(', ')}`, + ); +}; + +const waitForHealth = async (server: { + exitCode: number | null; + signalCode: NodeJS.Signals | null; +}) => { + const baseURL = `http://${TEST_HOST}:${TEST_PORT}`; + + for (let attempt = 0; attempt < 60; attempt++) { + if (server.exitCode !== null || server.signalCode !== null) { + throw new Error( + `Auth e2e server exited before becoming ready: code=${server.exitCode} signal=${server.signalCode}`, + ); + } + + try { + const response = await axios.get(`${baseURL}/health`, { + validateStatus: () => true, + }); + + if (response.status === 200) { + return; + } + } catch (error) { + // ignore and retry + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error(`Timed out waiting for ${TEST_HOST}:${TEST_PORT}`); +}; + +const waitForMaildev = async () => { + const baseURL = 'http://127.0.0.1:1080'; + + for (let attempt = 0; attempt < 60; attempt++) { + try { + const response = await axios.get(`${baseURL}/healthz`, { + validateStatus: () => true, + }); + + if (response.status === 200) { + return; + } + } catch (error) { + // ignore and retry + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + throw new Error('Timed out waiting for Maildev on 127.0.0.1:1080'); +}; module.exports = async function () { - // Start services that that the app needs to run (e.g. database, docker-compose, etc.). console.log('\nSetting up...\n'); - // Hint: Use `globalThis` to pass variables to global teardown. - globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; + const postgresPort = await resolveServicePort(TEST_HOST, [5432, 5433], 'Postgres'); + const redisPort = await resolveServicePort(TEST_HOST, [6379, 6380], 'Redis'); + + const runtimeEnv = { + ...process.env, + NODE_ENV: 'development', + HOST: TEST_HOST, + PORT: String(TEST_PORT), + BETTER_AUTH_URL: `http://${TEST_HOST}:${TEST_PORT}`, + POSTGRES_HOST: TEST_HOST, + POSTGRES_PORT: String(postgresPort), + DATABASE_URL: `postgresql://postgres:postgres@${TEST_HOST}:${postgresPort}/work_whiz`, + REDIS_HOST: TEST_HOST, + REDIS_PORT: String(redisPort), + REDIS_URL: `redis://${TEST_HOST}:${redisPort}`, + }; + + const migrate = spawnSync(PRISMA_BIN, ['db', 'push', '--force-reset'], { + stdio: 'inherit', + cwd: process.cwd(), + env: runtimeEnv, + }); + + if (migrate.status !== 0) { + throw new Error('Prisma db push failed before auth e2e setup'); + } + + const generate = spawnSync(PRISMA_BIN, ['generate'], { + stdio: 'inherit', + cwd: process.cwd(), + env: runtimeEnv, + }); + + if (generate.status !== 0) { + throw new Error('Prisma generate failed before auth e2e setup'); + } + + const tmpDir = path.dirname(STATE_FILE); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + + const maildev = spawn(MAILDEV_BIN, ['--smtp', '1025', '--web', '1080'], { + cwd: process.cwd(), + env: { + ...runtimeEnv, + NODE_ENV: 'development', + MAILDEV_HOST: TEST_HOST, + MAILDEV_PORT: '1025', + }, + stdio: 'ignore', + detached: false, + }); + + await waitForMaildev(); + + const server = spawn(NODE_BIN, ['dist/work-whiz/main.js'], { + cwd: process.cwd(), + env: runtimeEnv, + stdio: 'ignore', + detached: false, + }); + + try { + await waitForHealth(server); + } catch (error) { + for (const child of [server, maildev]) { + if (!child?.pid) continue; + + try { + child.kill('SIGTERM'); + } catch (_) { + // ignore + } + } + + throw error; + } + + fs.writeFileSync( + STATE_FILE, + JSON.stringify( + { + port: TEST_PORT, + postgresPort, + redisPort, + serverPid: server.pid, + maildevPid: maildev.pid, + }, + null, + 2, + ), + ); }; + +export {}; diff --git a/e2e/src/support/global-teardown.ts b/e2e/src/support/global-teardown.ts index 32ea345..b066e4a 100644 --- a/e2e/src/support/global-teardown.ts +++ b/e2e/src/support/global-teardown.ts @@ -1,7 +1,42 @@ /* eslint-disable */ +const fs = require('node:fs'); +const path = require('node:path'); + +const STATE_FILE = path.join(process.cwd(), 'e2e/.tmp/auth-e2e-state.json'); module.exports = async function () { - // Put clean up logic here (e.g. stopping services, docker-compose, etc.). - // Hint: `globalThis` is shared between setup and teardown. - console.log(globalThis.__TEARDOWN_MESSAGE__); + if (!fs.existsSync(STATE_FILE)) { + console.log('\nTearing down...\n'); + return; + } + + const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); + + for (const pid of [state.serverPid, state.maildevPid]) { + if (!pid) continue; + + try { + process.kill(pid, 'SIGTERM'); + } catch (error) { + // ignore missing processes + } + } + + await new Promise(resolve => setTimeout(resolve, 2000)); + + for (const pid of [state.serverPid, state.maildevPid]) { + if (!pid) continue; + + try { + process.kill(pid, 0); + process.kill(pid, 'SIGKILL'); + } catch (error) { + // ignore missing processes + } + } + + fs.unlinkSync(STATE_FILE); + console.log('\nTearing down...\n'); }; + +export {}; diff --git a/e2e/src/support/test-setup.ts b/e2e/src/support/test-setup.ts index 07f2870..85ddf89 100644 --- a/e2e/src/support/test-setup.ts +++ b/e2e/src/support/test-setup.ts @@ -1,10 +1,32 @@ /* eslint-disable */ +import fs from 'node:fs'; +import path from 'node:path'; import axios from 'axios'; module.exports = async function () { // Configure axios for tests to use. - const host = process.env.HOST ?? 'localhost'; - const port = process.env.PORT ?? '3000'; - axios.defaults.baseURL = `http://${host}:${port}`; + const host = process.env.HOST ?? '127.0.0.1'; + const port = process.env.PORT ?? '3101'; + const baseURL = `http://${host}:${port}`; + + const stateFile = path.join(process.cwd(), 'e2e/.tmp/auth-e2e-state.json'); + if (fs.existsSync(stateFile)) { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + + if (state.postgresPort) { + process.env.POSTGRES_HOST = host; + process.env.POSTGRES_PORT = String(state.postgresPort); + process.env.DATABASE_URL = `postgresql://postgres:postgres@${host}:${state.postgresPort}/work_whiz`; + } + + if (state.redisPort) { + process.env.REDIS_HOST = host; + process.env.REDIS_PORT = String(state.redisPort); + process.env.REDIS_URL = `redis://${host}:${state.redisPort}`; + } + } + + process.env.E2E_BASE_URL = baseURL; + axios.defaults.baseURL = baseURL; }; diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..dcab23c --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,50 @@ +const nx = require('@nx/eslint-plugin'); +const tsParser = require('@typescript-eslint/parser'); +const tsPlugin = require('@typescript-eslint/eslint-plugin'); + +module.exports = [ + { + ignores: ['node_modules/**'], + }, + { + plugins: { + '@nx': nx, + '@typescript-eslint': tsPlugin, + }, + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parser: tsParser, + ecmaVersion: 2020, + sourceType: 'module', + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + { + files: ['**/*.js', '**/*.jsx'], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + }, + { + files: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.spec.js', '**/*.spec.jsx'], + languageOptions: { + globals: { + afterAll: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + beforeEach: 'readonly', + describe: 'readonly', + expect: 'readonly', + it: 'readonly', + jest: 'readonly', + }, + }, + }, +]; diff --git a/nx.json b/nx.json index fc7d611..e982794 100644 --- a/nx.json +++ b/nx.json @@ -36,5 +36,6 @@ "exclude": ["e2e/**/*"] } ], - "nxCloudAccessToken": "ZjZmMWI0MjYtNTlmMC00NGNhLWEwYmYtOGJmODIzYjJlMGJhfHJlYWQtd3JpdGU=" + "nxCloudAccessToken": "ZjZmMWI0MjYtNTlmMC00NGNhLWEwYmYtOGJmODIzYjJlMGJhfHJlYWQtd3JpdGU=", + "analytics": true } diff --git a/package-lock.json b/package-lock.json index c619b53..061c3bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@prisma/client": "^7.8.0", "argon2": "^0.44.0", "axios": "^1.16.1", + "better-auth": "^1.6.11", "bull": "^4.16.5", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", @@ -2087,6 +2088,138 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@better-auth/core": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.11.tgz", + "integrity": "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.5", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.11.tgz", + "integrity": "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.11.tgz", + "integrity": "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "kysely": "^0.28.17" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.11.tgz", + "integrity": "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.11.tgz", + "integrity": "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.11.tgz", + "integrity": "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.11.tgz", + "integrity": "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.11", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@bull-board/api": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz", @@ -4726,6 +4859,30 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nx/devkit": { "version": "22.7.5", "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.5.tgz", @@ -5570,6 +5727,15 @@ "yargs-parser": "21.1.1" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.19.1", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", @@ -8416,6 +8582,131 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/better-auth": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.11.tgz", + "integrity": "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.11", + "@better-auth/drizzle-adapter": "1.6.11", + "@better-auth/kysely-adapter": "1.6.11", + "@better-auth/memory-adapter": "1.6.11", + "@better-auth/mongo-adapter": "1.6.11", + "@better-auth/prisma-adapter": "1.6.11", + "@better-auth/telemetry": "1.6.11", + "@better-auth/utils": "0.4.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.5", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz", + "integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/better-result": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", @@ -15178,6 +15469,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jquery": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", @@ -15496,6 +15796,15 @@ "license": "MIT", "peer": true }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -16381,6 +16690,21 @@ "node": ">=8.0.0" } }, + "node_modules/nanostores": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", + "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -18063,6 +18387,12 @@ "node": ">= 4" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -18246,6 +18576,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -20166,6 +20502,15 @@ "grammex": "^3.1.11", "graphmatch": "^1.1.0" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index cb27a91..6025244 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,11 @@ "name": "@work-whiz/source", "version": "0.0.0", "license": "MIT", + "packageManager": "npm@11.12.1", "scripts": { "start": "mkdir -p .nx/cache/terminalOutputs && npx env-cmd -f ./.env nx serve", "dev": "npm run start", + "dv": "npm run dev", "build": "nx build", "format": "nx format", "lint": "nx lint", @@ -25,6 +27,7 @@ "@prisma/client": "^7.8.0", "argon2": "^0.44.0", "axios": "^1.16.1", + "better-auth": "^1.6.11", "bull": "^4.16.5", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", diff --git a/prisma/migrations/20260606110417_remove_profile_name_columns/migration.sql b/prisma/migrations/20260606110417_remove_profile_name_columns/migration.sql new file mode 100644 index 0000000..2808297 --- /dev/null +++ b/prisma/migrations/20260606110417_remove_profile_name_columns/migration.sql @@ -0,0 +1,75 @@ +/* + Warnings: + + - You are about to drop the column `avatarUrl` on the `Users` table. All the data in the column will be lost. + - Added the required column `name` to the `Users` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Users" DROP COLUMN "avatarUrl", +ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "image" TEXT, +ADD COLUMN "name" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" UUID NOT NULL, + + CONSTRAINT "session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" UUID NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "session_userId_idx" ON "session"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- CreateIndex +CREATE INDEX "account_userId_idx" ON "account"("userId"); + +-- CreateIndex +CREATE INDEX "verification_identifier_idx" ON "verification"("identifier"); + +-- AddForeignKey +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260606120000_remove_profile_name_columns/migration.sql b/prisma/migrations/20260606120000_remove_profile_name_columns/migration.sql new file mode 100644 index 0000000..1f46026 --- /dev/null +++ b/prisma/migrations/20260606120000_remove_profile_name_columns/migration.sql @@ -0,0 +1,6 @@ +-- Drop duplicated profile-level name columns. User.name is the canonical name. +ALTER TABLE "Admins" DROP COLUMN IF EXISTS "firstName"; +ALTER TABLE "Admins" DROP COLUMN IF EXISTS "lastName"; +ALTER TABLE "Candidates" DROP COLUMN IF EXISTS "firstName"; +ALTER TABLE "Candidates" DROP COLUMN IF EXISTS "lastName"; +ALTER TABLE "Employers" DROP COLUMN IF EXISTS "name"; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eae470a..54b02bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,28 +39,31 @@ enum ApplicationStatus { } model User { - id String @id @default(uuid()) @db.Uuid - avatarUrl String? @db.VarChar(512) - email String @unique @db.VarChar(255) - phone String @unique @db.VarChar(20) - password String? @db.VarChar(255) - role Role @default(candidate) - isVerified Boolean @default(false) - isActive Boolean @default(true) - isLocked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - admin Admin? - candidate Candidate? - employer Employer? + id String @id @default(uuid()) @db.Uuid + name String + emailVerified Boolean @default(false) + image String? + email String @unique @db.VarChar(255) + phone String @unique @db.VarChar(20) + password String? @db.VarChar(255) + role Role @default(candidate) + isVerified Boolean @default(false) + isActive Boolean @default(true) + isLocked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + admin Admin? + candidate Candidate? + employer Employer? + + sessions Session[] + accounts Account[] @@map("Users") } model Admin { id String @id @default(uuid()) @db.Uuid - firstName String - lastName String permissions String[] @default(["READ"]) userId String @unique @db.Uuid user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@ -72,8 +75,6 @@ model Admin { model Candidate { id String @id @default(uuid()) @db.Uuid - firstName String - lastName String title Title? skills String[] @default([]) isEmployed Boolean? @default(false) @@ -88,7 +89,6 @@ model Candidate { model Employer { id String @id @default(uuid()) @db.Uuid - name String @unique industry String websiteUrl String? location String? @@ -143,3 +143,51 @@ model Application { @@index([id, jobId, candidateId, status, createdAt, updatedAt], name: "application_search_index") @@map("Applications") } + +model Session { + id String @id @default(uuid()) + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@index([userId]) + @@map("session") +} + +model Account { + id String @id @default(uuid()) + accountId String + providerId String + userId String @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("account") +} + +model Verification { + id String @id @default(uuid()) + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verification") +} diff --git a/src/__tests__/utils/getUserRole.test.ts b/src/__tests__/utils/getUserRole.test.ts index 5d80f87..873f9ba 100644 --- a/src/__tests__/utils/getUserRole.test.ts +++ b/src/__tests__/utils/getUserRole.test.ts @@ -1,9 +1,9 @@ -import { getUserRole } from '@work-whiz/utils'; +import { getUserRole } from '@work-whiz/utils/getUserRole.util'; import { Request } from 'express'; import { Role } from '@work-whiz/types'; describe('getUserRole', () => { - const createMockRequest = (host?: string): Partial => ({ + const createMockRequest = (host?: string | null): Partial => ({ get: jest.fn().mockReturnValue(host), }); diff --git a/src/__tests__/validators/admin-register.spec.ts b/src/__tests__/validators/admin-register.spec.ts index 665de79..43f39ad 100644 --- a/src/__tests__/validators/admin-register.spec.ts +++ b/src/__tests__/validators/admin-register.spec.ts @@ -1,93 +1,55 @@ import { adminRegisterValidator } from '@work-whiz/validators/admin-register.validator'; +const validPayload = { + name: 'Admin User', + email: 'user@example.com', + password: 'StrongPass123!', + phone: '+254712345678', +}; + describe('adminRegisterValidator', () => { it('should pass with valid data', () => { - const result = adminRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - email: 'user@example.com', - phone: '+254712345678', - }); + const result = adminRegisterValidator(validPayload); expect(result).toBeUndefined(); }); - it('should fail with missing firstName', () => { - const result = adminRegisterValidator({ - firstName: '', - lastName: 'Doe', - email: 'user@example.com', - phone: '+254712345678', - }); - - expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('First name cannot be empty'); - }); - - it('should fail with invalid firstName (non-alphabetical)', () => { - const result = adminRegisterValidator({ - firstName: 'John123', - lastName: 'Doe', - email: 'user@example.com', - phone: '+254712345678', - }); + it('should fail with missing name', () => { + const result = adminRegisterValidator({ ...validPayload, name: '' }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('First name can only contain letters'); - }); - - it('should fail with missing lastName', () => { - const result = adminRegisterValidator({ - firstName: 'John', - lastName: '', - email: 'user@example.com', - phone: '+254712345678', - }); - - expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Last name cannot be empty'); + expect(result?.details.map(d => d.message)).toContain('Name cannot be empty'); }); it('should fail with invalid email', () => { const result = adminRegisterValidator({ - firstName: 'John', - lastName: 'Doe', + ...validPayload, email: 'invalid-email', - phone: '+254712345678', }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Please enter a valid email address.'); + expect(result?.details.map(d => d.message)).toContain( + 'Please enter a valid email address.', + ); }); it('should fail with missing phone', () => { - const result = adminRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - email: 'user@example.com', - phone: '', - }); + const result = adminRegisterValidator({ ...validPayload, phone: '' }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Phone number cannot be empty.'); + expect(result?.details.map(d => d.message)).toContain( + 'Phone number cannot be empty.', + ); }); - it('should fail with invalid phone (no country code)', () => { + it('should fail with invalid phone', () => { const result = adminRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - email: 'user@example.com', + ...validPayload, phone: '0712345678', }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain( + expect(result?.details.map(d => d.message)).toContain( 'Please enter a valid phone number with country code.', ); }); diff --git a/src/__tests__/validators/admin.spec.ts b/src/__tests__/validators/admin.spec.ts index 2b26554..e0410ad 100644 --- a/src/__tests__/validators/admin.spec.ts +++ b/src/__tests__/validators/admin.spec.ts @@ -1,112 +1,27 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { adminValidator } from '@work-whiz/validators/admin.validator'; import { IAdmin } from '@work-whiz/interfaces'; describe('adminValidator', () => { - describe('firstName validation', () => { - it('should return error when firstName contains numbers', () => { - const invalidAdmin: Partial = { firstName: 'John123' }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details[0].message).toBe( - 'First name can only contain letters', - ); - }); + it('should not return error when permissions are valid', () => { + const validAdmin: Partial = { permissions: ['READ'] }; - it('should return error when firstName is empty string', () => { - const invalidAdmin: Partial = { firstName: '' }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details[0].message).toBe('First name cannot be empty'); - }); + const result = adminValidator(validAdmin); - it('should return error when firstName is not a string', () => { - const invalidAdmin: Partial = { firstName: 123 as any }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details[0].message).toBe('First name should be a string'); - }); - - it('should not return error when firstName is valid', () => { - const validAdmin: Partial = { firstName: 'John' }; - const result = adminValidator(validAdmin); - expect(result).toBeUndefined(); - }); - - it('should not return error when firstName is undefined', () => { - const validAdmin: Partial = { firstName: undefined }; - const result = adminValidator(validAdmin); - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined(); }); - describe('lastName validation', () => { - it('should return error when lastName contains numbers', () => { - const invalidAdmin: Partial = { lastName: 'Doe123' }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details[0].message).toBe( - 'Last name can only contain letters', - ); - }); - - it('should return error when lastName is empty string', () => { - const invalidAdmin: Partial = { lastName: '' }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details[0].message).toBe('Last name cannot be empty'); - }); - - it('should return error when lastName is not a string', () => { - const invalidAdmin: Partial = { lastName: 123 as any }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details[0].message).toBe('Last name should be a string'); - }); - - it('should not return error when lastName is valid', () => { - const validAdmin: Partial = { lastName: 'Doe' }; - const result = adminValidator(validAdmin); - expect(result).toBeUndefined(); - }); + it('should not return error when profile update is empty', () => { + const result = adminValidator({}); - it('should not return error when lastName is undefined', () => { - const validAdmin: Partial = { lastName: undefined }; - const result = adminValidator(validAdmin); - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined(); }); - describe('combined validation', () => { - it('should return multiple errors when both firstName and lastName are invalid', () => { - const invalidAdmin: Partial = { - firstName: 'John123', - lastName: 'Doe456', - }; - const result = adminValidator(invalidAdmin); - expect(result).toBeDefined(); - expect(result?.details).toHaveLength(2); - expect(result?.details[0].message).toBe( - 'First name can only contain letters', - ); - expect(result?.details[1].message).toBe( - 'Last name can only contain letters', - ); - }); + it('should return error when permissions are not strings', () => { + const invalidAdmin = { permissions: [123] }; - it('should not return error when both firstName and lastName are valid', () => { - const validAdmin: Partial = { - firstName: 'John', - lastName: 'Doe', - }; - const result = adminValidator(validAdmin); - expect(result).toBeUndefined(); - }); + const result = adminValidator(invalidAdmin as unknown as Partial); - it('should not return error when both firstName and lastName are undefined', () => { - const validAdmin: Partial = {}; - const result = adminValidator(validAdmin); - expect(result).toBeUndefined(); - }); + expect(result).toBeDefined(); + expect(result?.details[0].message).toBe('Each permission should be a string'); }); }); diff --git a/src/__tests__/validators/base-register.spec.ts b/src/__tests__/validators/base-register.spec.ts index d641600..cd6e901 100644 --- a/src/__tests__/validators/base-register.spec.ts +++ b/src/__tests__/validators/base-register.spec.ts @@ -1,10 +1,16 @@ import { baseRegisterSchema } from '@work-whiz/validators/schemas/base-register.schema'; +const validPayload = { + name: 'User Name', + email: 'user@example.com', + password: 'StrongPass123!', + phone: '+254712345678', +}; + describe('baseRegisterSchema', () => { - it('should pass validation with valid email and phone', () => { + it('should pass validation with valid base registration data', () => { const result = baseRegisterSchema.validate({ - email: 'user@example.com', - phone: '+254712345678', + ...validPayload, }); expect(result.error).toBeUndefined(); @@ -12,7 +18,8 @@ describe('baseRegisterSchema', () => { it('should fail when email is missing', () => { const result = baseRegisterSchema.validate({ - phone: '+254712345678', + ...validPayload, + email: undefined, }); expect(result.error).toBeDefined(); @@ -21,7 +28,8 @@ describe('baseRegisterSchema', () => { it('should fail when phone is missing', () => { const result = baseRegisterSchema.validate({ - email: 'user@example.com', + ...validPayload, + phone: undefined, }); expect(result.error).toBeDefined(); @@ -32,8 +40,8 @@ describe('baseRegisterSchema', () => { it('should fail with invalid email', () => { const result = baseRegisterSchema.validate({ + ...validPayload, email: 'invalid-email', - phone: '+254712345678', }); expect(result.error).toBeDefined(); @@ -44,7 +52,7 @@ describe('baseRegisterSchema', () => { it('should fail with invalid phone (no country code)', () => { const result = baseRegisterSchema.validate({ - email: 'user@example.com', + ...validPayload, phone: '0712345678', }); @@ -60,7 +68,9 @@ describe('baseRegisterSchema', () => { expect(result.error).toBeDefined(); const messages = result.error?.details.map(d => d.message); + expect(messages).toContain('Name is required'); expect(messages).toContain('Email is required and cannot be empty.'); expect(messages).toContain('Phone number is required.'); + expect(messages).toContain('Password is required.'); }); }); diff --git a/src/__tests__/validators/candidate-register.spec.ts b/src/__tests__/validators/candidate-register.spec.ts index 8b16272..4ec1871 100644 --- a/src/__tests__/validators/candidate-register.spec.ts +++ b/src/__tests__/validators/candidate-register.spec.ts @@ -1,115 +1,64 @@ import { candidateRegisterValidator } from '@work-whiz/validators/candidate-register.validator'; +const validPayload = { + name: 'Candidate User', + title: 'Mr', + email: 'user@example.com', + password: 'StrongPass123!', + phone: '+254712345678', +}; + describe('candidateRegisterValidator', () => { it('should pass with valid data', () => { - const result = candidateRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - title: 'Mr', - email: 'user@example.com', - phone: '+254712345678', - }); + const result = candidateRegisterValidator(validPayload); - expect(result).toBeUndefined(); // No errors expected + expect(result).toBeUndefined(); }); - it('should fail with missing firstName', () => { - const result = candidateRegisterValidator({ - firstName: '', - lastName: 'Doe', - title: 'Mr', - email: 'user@example.com', - phone: '+254712345678', - }); + it('should fail with missing name', () => { + const result = candidateRegisterValidator({ ...validPayload, name: '' }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('First name cannot be empty'); + expect(result?.details.map(d => d.message)).toContain('Name cannot be empty'); }); - it('should fail with invalid firstName (non-alphabetical)', () => { - const result = candidateRegisterValidator({ - firstName: 'John123', - lastName: 'Doe', - title: 'Mr', - email: 'user@example.com', - phone: '+254712345678', - }); - - expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('First name can only contain letters'); - }); - - it('should fail with missing lastName', () => { - const result = candidateRegisterValidator({ - firstName: 'John', - lastName: '', - title: 'Mr', - email: 'user@example.com', - phone: '+254712345678', - }); + it('should fail with missing title', () => { + const result = candidateRegisterValidator({ ...validPayload, title: '' }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Last name cannot be empty'); + expect(result?.details.map(d => d.message)).toContain('Title cannot be empty'); }); it('should fail with invalid email', () => { const result = candidateRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - title: 'Mr', + ...validPayload, email: 'invalid-email', - phone: '+254712345678', }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Please enter a valid email address.'); + expect(result?.details.map(d => d.message)).toContain( + 'Please enter a valid email address.', + ); }); it('should fail with missing phone', () => { - const result = candidateRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - title: 'Mr', - email: 'user@example.com', - phone: '', - }); + const result = candidateRegisterValidator({ ...validPayload, phone: '' }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Phone number cannot be empty.'); + expect(result?.details.map(d => d.message)).toContain( + 'Phone number cannot be empty.', + ); }); - it('should fail with invalid phone (no country code)', () => { + it('should fail with invalid phone', () => { const result = candidateRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - title: 'Mr', - email: 'user@example.com', + ...validPayload, phone: '0712345678', }); expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain( + expect(result?.details.map(d => d.message)).toContain( 'Please enter a valid phone number with country code.', ); }); - - it('should fail with missing title', () => { - const result = candidateRegisterValidator({ - firstName: 'John', - lastName: 'Doe', - title: '', - email: 'user@example.com', - phone: '+254712345678', - }); - - expect(result).toBeDefined(); - const messages = result?.details.map(d => d.message); - expect(messages).toContain('Title cannot be empty'); - }); }); diff --git a/src/__tests__/validators/candidate.spec.ts b/src/__tests__/validators/candidate.spec.ts index 4ed9b0a..991de6a 100644 --- a/src/__tests__/validators/candidate.spec.ts +++ b/src/__tests__/validators/candidate.spec.ts @@ -6,101 +6,76 @@ import { ValidationError } from 'joi'; type TestCandidate = Partial>; describe('candidateValidator', () => { - describe('successful validation', () => { - it('should return undefined for valid candidate', () => { - const validCandidate: Partial = { - firstName: 'John', - lastName: 'Doe', - title: 'Software Engineer', - skills: ['JavaScript', 'TypeScript'], - isEmployed: true, - }; - - const result = candidateValidator(validCandidate); - expect(result).toBeUndefined(); - }); - - it('should handle partial valid candidate', () => { - const partialCandidate: Partial = { - firstName: 'Jane', - isEmployed: false, - }; - - const result = candidateValidator(partialCandidate); - expect(result).toBeUndefined(); - }); + it('should return undefined for valid candidate data', () => { + const validCandidate: Partial = { + title: 'Software Engineer', + skills: ['JavaScript', 'TypeScript'], + isEmployed: true, + }; + + const result = candidateValidator(validCandidate); + + expect(result).toBeUndefined(); }); - describe('validation errors', () => { - it('should return error for invalid firstName', () => { - const invalidCandidate: TestCandidate = { - firstName: 'John123', - lastName: 'Doe', - }; - - const result = candidateValidator(invalidCandidate); - expect(result).toBeInstanceOf(ValidationError); - expect(result?.message).toContain('First name can only contain letters'); - }); - - it('should return error for invalid lastName', () => { - const invalidCandidate: TestCandidate = { - firstName: 'John', - lastName: 'Doe-Smith', - }; - - const result = candidateValidator(invalidCandidate); - expect(result).toBeInstanceOf(ValidationError); - expect(result?.message).toContain('Last name can only contain letters'); - }); - - it('should return error for non-string title', () => { - const invalidCandidate: TestCandidate = { - title: 12345, - }; - - const result = candidateValidator(invalidCandidate); - expect(result).toBeInstanceOf(ValidationError); - expect(result?.message).toContain('Title should be a string'); - }); - - it('should return error for invalid skills array', () => { - const invalidCandidate: TestCandidate = { - skills: ['JavaScript', 12345], - }; - - const result = candidateValidator(invalidCandidate); - expect(result).toBeInstanceOf(ValidationError); - expect(result?.message).toContain('Each skill should be a string'); - }); - - it('should return error for non-boolean isEmployed', () => { - const invalidCandidate: TestCandidate = { - isEmployed: 'yes', - }; - - const result = candidateValidator(invalidCandidate); - expect(result).toBeInstanceOf(ValidationError); - expect(result?.message).toContain('isEmployed should be a boolean'); - }); - - it('should return multiple errors for multiple invalid fields', () => { - const invalidCandidate: TestCandidate = { - firstName: 'John1', - lastName: 'Doe2', - isEmployed: 'maybe', - }; - - const result = candidateValidator(invalidCandidate); - expect(result).toBeInstanceOf(ValidationError); - expect(result?.details).toHaveLength(3); - }); + it('should handle partial valid candidate data', () => { + const partialCandidate: Partial = { + isEmployed: false, + }; + + const result = candidateValidator(partialCandidate); + + expect(result).toBeUndefined(); }); - describe('edge cases', () => { - it('should handle empty object', () => { - const result = candidateValidator({}); - expect(result).toBeUndefined(); - }); + it('should return error for non-string title', () => { + const invalidCandidate: TestCandidate = { + title: 12345, + }; + + const result = candidateValidator(invalidCandidate); + + expect(result).toBeInstanceOf(ValidationError); + expect(result?.message).toContain('Title should be a string'); + }); + + it('should return error for invalid skills array', () => { + const invalidCandidate: TestCandidate = { + skills: ['JavaScript', 12345], + }; + + const result = candidateValidator(invalidCandidate); + + expect(result).toBeInstanceOf(ValidationError); + expect(result?.message).toContain('Each skill should be a string'); + }); + + it('should return error for non-boolean isEmployed', () => { + const invalidCandidate: TestCandidate = { + isEmployed: 'yes', + }; + + const result = candidateValidator(invalidCandidate); + + expect(result).toBeInstanceOf(ValidationError); + expect(result?.message).toContain('isEmployed should be a boolean'); + }); + + it('should return multiple errors for multiple invalid fields', () => { + const invalidCandidate: TestCandidate = { + skills: ['JavaScript', 12345], + isEmployed: 'maybe', + }; + + const result = candidateValidator(invalidCandidate); + + expect(result).toBeInstanceOf(ValidationError); + expect(result?.details).toHaveLength(2); + }); + + it('should handle empty object', () => { + const result = candidateValidator({}); + + expect(result).toBeUndefined(); }); }); diff --git a/src/__tests__/validators/employer.validator.spec.ts b/src/__tests__/validators/employer.validator.spec.ts index 2fe5da0..39cc12e 100644 --- a/src/__tests__/validators/employer.validator.spec.ts +++ b/src/__tests__/validators/employer.validator.spec.ts @@ -4,7 +4,6 @@ import { employerValidator } from '@work-whiz/validators'; describe('employerValidator', () => { it('should return undefined for valid employer data', () => { const employer = { - name: 'Tech Corp', industry: 'Technology', websiteUrl: 'https://techcorp.com', location: 'San Francisco', @@ -27,11 +26,6 @@ describe('employerValidator', () => { expect(result?.details[0].message).toBe('Website URL must be a valid URL'); }); - it('should return error for non-string name', () => { - const result = employerValidator({ name: 123 as any }); - expect(result?.details[0].message).toBe('Company name should be a string'); - }); - it('should return error for non-integer size', () => { const result = employerValidator({ size: 10.5 }); expect(result?.details[0].message).toBe('Size should be an integer'); diff --git a/src/__tests__/validators/job.validator.spec.ts b/src/__tests__/validators/job.validator.spec.ts index 7667c0d..f7ad9bb 100644 --- a/src/__tests__/validators/job.validator.spec.ts +++ b/src/__tests__/validators/job.validator.spec.ts @@ -2,6 +2,9 @@ import { validateJob } from '@work-whiz/validators'; describe('Job Schema Validation', () => { + const futureDeadline = new Date(); + futureDeadline.setFullYear(futureDeadline.getFullYear() + 1); + const validJobData = { title: 'Software Engineer', description: 'Develop and maintain software applications.', @@ -11,7 +14,7 @@ describe('Job Schema Validation', () => { location: 'Remote', type: 'Full-time' as 'Full-time' | 'Part-time' | 'Contract' | 'Internship', vacancy: 2, - deadline: new Date('2025-12-31T00:00:00.000Z'), + deadline: futureDeadline, tags: ['Engineering', 'Software', 'TypeScript'], isPublic: true, }; @@ -23,7 +26,7 @@ describe('Job Schema Validation', () => { }); it('should fail if required fields are missing', () => { - const invalidData = { ...validJobData }; + const invalidData: Partial = { ...validJobData }; delete invalidData.title; delete invalidData.description; diff --git a/src/configs/config.ts b/src/configs/config.ts index 4c6ff18..bbabd1a 100644 --- a/src/configs/config.ts +++ b/src/configs/config.ts @@ -60,6 +60,34 @@ const { EMPLOYER_FRONTEND, } = process.env; +const requireEnv = (name: string, value: string | undefined): string => { + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +}; + +const optionalEnv = (value: string | undefined): string => value ?? ''; + +const parsePort = ( + name: string, + value: string | undefined, + defaultValue?: number, +): number => { + if (!value) { + if (defaultValue !== undefined) return defaultValue; + throw new Error(`Missing required environment variable: ${name}`); + } + + const port = parseInt(value, 10); + if (Number.isNaN(port)) { + throw new Error(`Invalid port in environment variable: ${name}`); + } + + return port; +}; + /** * Application configuration object. * @type {IConfig} @@ -67,82 +95,112 @@ const { export const config: IConfig = { authentication: { api: { - secret: API_SECRET_KEY, + secret: requireEnv('API_SECRET_KEY', API_SECRET_KEY), }, argon: { admin: { - pepper: ADMIN_ARGON2_PEPPER, + pepper: requireEnv('ADMIN_ARGON2_PEPPER', ADMIN_ARGON2_PEPPER), }, employer: { - pepper: EMPLOYER_ARGON2_PEPPER, + pepper: requireEnv('EMPLOYER_ARGON2_PEPPER', EMPLOYER_ARGON2_PEPPER), }, candidate: { - pepper: CANDIDATE_ARGON2_PEPPER, + pepper: requireEnv('CANDIDATE_ARGON2_PEPPER', CANDIDATE_ARGON2_PEPPER), }, }, jwt: { admin: { - access: ADMIN_ACCESS_KEY, - refresh: ADMIN_REFRESH_ACCESS_KEY, - password_setup: ADMIN_PASSWORD_SETUP, - password_reset: ADMIN_PASSWORD_RESET, + access: requireEnv('ADMIN_ACCESS_KEY', ADMIN_ACCESS_KEY), + refresh: requireEnv( + 'ADMIN_REFRESH_ACCESS_KEY', + ADMIN_REFRESH_ACCESS_KEY, + ), + password_setup: requireEnv( + 'ADMIN_PASSWORD_SETUP', + ADMIN_PASSWORD_SETUP, + ), + password_reset: requireEnv( + 'ADMIN_PASSWORD_RESET', + ADMIN_PASSWORD_RESET, + ), }, employer: { - access: EMPLOYER_ACCESS_KEY, - refresh: EMPLOYER_REFRESH_ACCESS_KEY, - password_setup: EMPLOYER_PASSWORD_SETUP, - password_reset: EMPLOYER_PASSWORD_RESET, + access: requireEnv('EMPLOYER_ACCESS_KEY', EMPLOYER_ACCESS_KEY), + refresh: requireEnv( + 'EMPLOYER_REFRESH_ACCESS_KEY', + EMPLOYER_REFRESH_ACCESS_KEY, + ), + password_setup: requireEnv( + 'EMPLOYER_PASSWORD_SETUP', + EMPLOYER_PASSWORD_SETUP, + ), + password_reset: requireEnv( + 'EMPLOYER_PASSWORD_RESET', + EMPLOYER_PASSWORD_RESET, + ), }, candidate: { - access: CANDIDATE_ACCESS_KEY, - refresh: CANDIDATE_REFRESH_ACCESS_KEY, - password_setup: CANDIDATE_PASSWORD_SETUP, - password_reset: CANDIDATE_PASSWORD_RESET, + access: requireEnv('CANDIDATE_ACCESS_KEY', CANDIDATE_ACCESS_KEY), + refresh: requireEnv( + 'CANDIDATE_REFRESH_ACCESS_KEY', + CANDIDATE_REFRESH_ACCESS_KEY, + ), + password_setup: requireEnv( + 'CANDIDATE_PASSWORD_SETUP', + CANDIDATE_PASSWORD_SETUP, + ), + password_reset: requireEnv( + 'CANDIDATE_PASSWORD_RESET', + CANDIDATE_PASSWORD_RESET, + ), }, }, }, database: { postgres: { - databaseName: POSTGRES_DATABASE_NAME, - username: POSTGRES_USERNAME, - password: POSTGRES_PASSWORD, - host: POSTGRES_HOST, - port: POSTGRES_PORT ? parseInt(POSTGRES_PORT, 10) : 5432, + databaseName: requireEnv('POSTGRES_DATABASE_NAME', POSTGRES_DATABASE_NAME), + username: requireEnv('POSTGRES_USERNAME', POSTGRES_USERNAME), + password: requireEnv('POSTGRES_PASSWORD', POSTGRES_PASSWORD), + host: requireEnv('POSTGRES_HOST', POSTGRES_HOST), + port: parsePort('POSTGRES_PORT', POSTGRES_PORT, 5432), }, redis: { - host: REDIS_HOST, - port: REDIS_PORT ? parseInt(REDIS_PORT, 10) : 6379, - password: REDIS_PASSWORD, + host: requireEnv('REDIS_HOST', REDIS_HOST), + port: parsePort('REDIS_PORT', REDIS_PORT, 6379), + password: optionalEnv(REDIS_PASSWORD), }, }, frontend: { - admin: ADMIN_FRONTEND, - candidate: CANDIDATE_FRONTEND, - employer: EMPLOYER_FRONTEND, + admin: requireEnv('ADMIN_FRONTEND', ADMIN_FRONTEND), + candidate: requireEnv('CANDIDATE_FRONTEND', CANDIDATE_FRONTEND), + employer: requireEnv('EMPLOYER_FRONTEND', EMPLOYER_FRONTEND), }, logger: { logtail: { - accessToken: LOGTAIL_ACCESS_TOKEN, + accessToken: optionalEnv(LOGTAIL_ACCESS_TOKEN), }, }, notification: { mailgen: { - theme: MAILGEN_PRODUCT_THEME, + theme: requireEnv('MAILGEN_PRODUCT_THEME', MAILGEN_PRODUCT_THEME), product: { - name: MAILGEN_PRODUCT_NAME, - link: MAILGEN_PRODUCT_LINK, - logo: MAILGEN_PRODUCT_LOGO, - copyright: MAILGEN_PRODUCT_COPYRIGHT, + name: requireEnv('MAILGEN_PRODUCT_NAME', MAILGEN_PRODUCT_NAME), + link: requireEnv('MAILGEN_PRODUCT_LINK', MAILGEN_PRODUCT_LINK), + logo: optionalEnv(MAILGEN_PRODUCT_LOGO), + copyright: requireEnv( + 'MAILGEN_PRODUCT_COPYRIGHT', + MAILGEN_PRODUCT_COPYRIGHT, + ), }, }, nodemailer: { - service: NODEMAILER_SERVICE, - host: NODEMAILER_HOST, - port: NODEMAILER_PORT ? parseInt(NODEMAILER_PORT) : undefined, + service: optionalEnv(NODEMAILER_SERVICE), + host: requireEnv('NODEMAILER_HOST', NODEMAILER_HOST), + port: parsePort('NODEMAILER_PORT', NODEMAILER_PORT), secure: true, auth: { - user: NODEMAILER_USERNAME, - pass: NODEMAILER_PASSWORD, + user: optionalEnv(NODEMAILER_USERNAME), + pass: optionalEnv(NODEMAILER_PASSWORD), }, }, }, diff --git a/src/controllers/application.controller.ts b/src/controllers/application.controller.ts index 6376719..21182dd 100644 --- a/src/controllers/application.controller.ts +++ b/src/controllers/application.controller.ts @@ -116,7 +116,7 @@ class ApplicationController { ): Promise => { try { const { applicationId } = req.params; - if (!applicationId) { + if (typeof applicationId !== 'string' || !applicationId) { responseUtil.sendError(res, { message: 'Application ID is required', statusCode: StatusCodes.BAD_REQUEST, @@ -177,7 +177,7 @@ class ApplicationController { try { const { applicationId } = req.params; const data = req.body as Partial; - if (!applicationId) { + if (typeof applicationId !== 'string' || !applicationId) { responseUtil.sendError(res, { message: 'Application ID is required', statusCode: StatusCodes.BAD_REQUEST, @@ -212,7 +212,7 @@ class ApplicationController { ): Promise => { try { const { applicationId } = req.params; - if (!applicationId) { + if (typeof applicationId !== 'string' || !applicationId) { responseUtil.sendError(res, { message: 'Application ID is required', statusCode: StatusCodes.BAD_REQUEST, diff --git a/src/controllers/authentication.controller.ts b/src/controllers/authentication.controller.ts deleted file mode 100644 index 8fda1a3..0000000 --- a/src/controllers/authentication.controller.ts +++ /dev/null @@ -1,529 +0,0 @@ -import { StatusCodes } from 'http-status-codes'; -import { Request, Response } from 'express'; -import { authenticationService } from '@work-whiz/services'; -import { responseUtil, getUserRole } from '@work-whiz/utils'; -import { - IAdminRegister, - ICandidateRegister, - IEmployerRegister, -} from '@work-whiz/interfaces'; -import { emailValidator } from '@work-whiz/validators/email.validator'; -import { passwordValidator } from '@work-whiz/validators/password.validator'; -import { - adminRegisterValidator, - candidateRegisterValidator, - employerRegisterValidator, - validateInput, -} from '@work-whiz/validators'; -import { Role } from '@work-whiz/types'; - -const SESSION_EXPIRED_MESSAGE = 'Session has expired'; - -/** - * Controller handling all authentication-related operations. - * Implements singleton pattern to ensure a single instance throughout the application. - */ -export class AuthenticationController { - private static instance: AuthenticationController; - - /** - * Configuration for refresh token cookie. - * Secure, HTTP-only cookie with 7-day expiration. - */ - private readonly REFRESH_TOKEN_COOKIE = { - httpOnly: true, - secure: true, - sameSite: 'strict' as const, - maxAge: 7 * 24 * 60 * 60 * 1000, - path: '/auth/refresh-token', - }; - - /** - * Configuration for access token cookie. - * Secure, HTTP-only cookie with 15-minute expiration. - */ - private readonly ACCESS_TOKEN_COOKIE = { - httpOnly: true, - secure: true, - sameSite: 'strict' as const, - maxAge: 15 * 60 * 1000, // 15 minutes - path: '/', - }; - - private constructor() { - // - } - - /** - * Singleton pattern for controller instantiation. - * @returns {AuthenticationController} The singleton instance of AuthenticationController - */ - public static getInstance(): AuthenticationController { - if (!AuthenticationController.instance) { - AuthenticationController.instance = new AuthenticationController(); - } - return AuthenticationController.instance; - } - - /** - * Sets authentication cookies (refresh + access token) in the response. - * Uses secure, HTTP-only cookies with appropriate expiration times. - * - * @param {Response} res - Express response object - * @param {string} refreshToken - JWT refresh token (7-day expiry) - * @param {string} accessToken - JWT access token (15-minute expiry) - * @returns {void} - */ - private setAuthCookies( - res: Response, - refreshToken: string, - accessToken: string, - ): void { - res - .cookie('refresh_token', refreshToken, this.REFRESH_TOKEN_COOKIE) - .cookie('access_token', accessToken, this.ACCESS_TOKEN_COOKIE); - } - - /** - * Clears authentication cookies from the response. - * Removes both access and refresh token cookies with proper path specification. - * - * @param {Response} res - Express response object - * @returns {void} - */ - private clearAuthCookies(res: Response): void { - res.clearCookie('refresh_token', { path: this.REFRESH_TOKEN_COOKIE.path }); - res.clearCookie('access_token', { path: this.ACCESS_TOKEN_COOKIE.path }); - } - - /** - * Handles user registration based on role (admin, candidate, or employer). - * Validates input data according to role-specific requirements. - * - * @param {Request} req - Express request object containing registration data in body - * @param {Response} res - Express response object - * @returns {Promise} - * - * @throws {Error} When role is invalid or not provided - * @throws {Error} When validation fails for role-specific registration data - * @throws {Error} When registration service encounters an error - */ - public register = async (req: Request, res: Response): Promise => { - try { - const role: Role = getUserRole(req); - - const registerData = req.body as - | IAdminRegister - | ICandidateRegister - | IEmployerRegister; - - let registerErrors = null; - - switch (role) { - case Role.ADMIN: - registerErrors = adminRegisterValidator( - registerData as IAdminRegister, - ); - break; - case Role.CANDIDATE: - registerErrors = candidateRegisterValidator( - registerData as ICandidateRegister, - ); - break; - case Role.EMPLOYER: - registerErrors = employerRegisterValidator( - registerData as IEmployerRegister, - ); - break; - default: - return responseUtil.sendError(res, { - message: 'Invalid role provided for registration.', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - if (registerErrors) { - return responseUtil.sendError(res, { - message: registerErrors.message, - statusCode: StatusCodes.UNPROCESSABLE_ENTITY, - }); - } - - const response = await authenticationService.register(role, registerData); - responseUtil.sendSuccess(res, response, String(StatusCodes.CREATED)); - } catch (error) { - responseUtil.sendError(res, { - message: error.message, - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Verifies a user's account using email and OTP (One-Time Password). - * This is typically called after registration to confirm email ownership. - * - * @param {Request} req - Express request object with email and otp in body - * @param {Request} req.body - Request body - * @param {string} req.body.email - User's email address - * @param {string} req.body.otp - 6-digit verification code sent to email - * @param {Response} res - Express response object - * @returns {Promise} - * - * @throws {Error} When email or OTP is missing (400 Bad Request) - * @throws {Error} When email format is invalid (422 Unprocessable Entity) - * @throws {Error} When OTP is not a 6-digit number (400 Bad Request) - * @throws {Error} When verification fails or OTP is expired/invalid - */ - public verifyAccount = async (req: Request, res: Response): Promise => { - try { - const { email, otp } = req.body; - - if (!email || !otp) { - return responseUtil.sendError(res, { - message: 'Email and OTP are required.', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - const emailError = emailValidator(email); - if (emailError) { - return responseUtil.sendError(res, { - message: emailError.details[0].message, - statusCode: StatusCodes.UNPROCESSABLE_ENTITY, - }); - } - - if (!/^\d{6}$/.test(otp)) { - return responseUtil.sendError(res, { - message: 'OTP must be a 6-digit number.', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - const { accessToken, refreshToken } = - await authenticationService.verifyAccountOtp(email, otp); - - this.setAuthCookies(res, refreshToken, accessToken); - - responseUtil.sendSuccess( - res, - { message: 'Account verified successfully' }, - String(StatusCodes.OK), - ); - } catch (error) { - responseUtil.sendError(res, { - message: error.message, - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Handles user login with email and password credentials. - * Sets authentication cookies upon successful login. - * - * @param {Request} req - Express request object containing email and password in body - * @param {Request} req.body - Request body - * @param {string} req.body.email - User's email address - * @param {string} req.body.password - User's password - * @param {Response} res - Express response object - * @returns {Promise} - * - * @throws {Error} When email or password is missing (400 Bad Request) - * @throws {Error} When credentials are invalid (401 Unauthorized) - * @throws {Error} When user account is not verified or disabled - */ - public login = async (req: Request, res: Response): Promise => { - try { - const { email, password } = req.body; - if (!email || !password) { - return responseUtil.sendError(res, { - message: 'Email and password are required', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - const { accessToken, refreshToken } = await authenticationService.login( - email, - password, - ); - - this.setAuthCookies(res, refreshToken, accessToken); - - responseUtil.sendSuccess( - res, - { message: 'Login successful' }, - String(StatusCodes.OK), - ); - } catch (error) { - this.clearAuthCookies(res); - responseUtil.sendError(res, { - message: error.message, - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Handles user logout by invalidating tokens and clearing cookies. - * Removes all active sessions for the user. - * - * @param {Request} req - Express request object with userId in app.locals - * @param {Request} req.app.locals - Express app locals object - * @param {string} req.app.locals.userId - Current authenticated user's ID - * @param {Response} res - Express response object - * @returns {Promise} - */ - public logout = async (req: Request, res: Response): Promise => { - try { - const { userId } = req.app.locals; - - await authenticationService.logout(userId); - - this.clearAuthCookies(res); - responseUtil.sendSuccess( - res, - { message: 'Logout successful' }, - String(StatusCodes.OK), - ); - } catch (error) { - this.clearAuthCookies(res); - responseUtil.sendError(res, { - message: error.message, - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Handles token refresh using a valid refresh token. - * Issues new access and refresh tokens upon successful validation. - */ - public refreshToken = async (req: Request, res: Response): Promise => { - try { - const refreshToken = req.cookies.refresh_token; - const { userId } = req.app.locals; - - if (!refreshToken || !userId) { - return responseUtil.sendError(res, { - message: SESSION_EXPIRED_MESSAGE, - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - const { accessToken, refreshToken: newRefreshToken } = - await authenticationService.refreshToken(userId, refreshToken); - - this.setAuthCookies(res, newRefreshToken, accessToken); - - responseUtil.sendSuccess( - res, - { message: 'Token refreshed successfully' }, - String(StatusCodes.OK), - ); - } catch (error) { - this.clearAuthCookies(res); - responseUtil.sendError(res, { - message: - error.message || 'An error occurred while refreshing the token', - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Handles forgot password request by sending OTP to user's email. - * Validates email format before processing and triggers OTP generation. - * - * @param {Request} req - Express request object containing email in body - * @param {Request} req.body - Request body - * @param {string} req.body.email - User's email address for password reset - * @param {Response} res - Express response object - * @returns {Promise} - */ - public forgotPassword = async ( - req: Request, - res: Response, - ): Promise => { - try { - const { email } = req.body; - - const emailError = emailValidator(email); - if (emailError) { - return responseUtil.sendError(res, { - message: emailError.details[0].message, - statusCode: StatusCodes.UNPROCESSABLE_ENTITY, - }); - } - - await authenticationService.forgotPassword(email); - responseUtil.sendSuccess( - res, - { message: 'Password reset instructions sent' }, - String(StatusCodes.OK), - ); - } catch (error) { - responseUtil.sendError(res, { - message: error.message, - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Verifies OTP sent to user's email during password reset flow. - * OTP must be a 6-digit number and match the one sent via email. - * - * @param {Request} req - Express request object containing email and otp in body - * @param {Request} req.body - Request body - * @param {string} req.body.email - User's email address - * @param {string} req.body.otp - 6-digit verification code sent to email - * @param {Response} res - Express response object - * @returns {Promise} - * - * @throws {Error} When email or OTP is missing (400 Bad Request) - * @throws {Error} When email format is invalid (422 Unprocessable Entity) - * @throws {Error} When OTP format is invalid - not 6 digits (400 Bad Request) - * @throws {Error} When OTP is incorrect, expired, or already used (401 Unauthorized) - * - * @remarks - * - OTP must be exactly 6 numeric digits - * - OTP typically expires after 10-15 minutes - * - Each OTP can only be used once - * - Returns 200 OK on successful verification - * - User must call resetPassword endpoint after successful verification - * - * @security - * - Rate limiting prevents brute force attacks - * - OTP is invalidated after verification - * - Failed attempts should be logged for security monitoring - */ - public verifyOtp = async (req: Request, res: Response): Promise => { - try { - const { email, otp } = req.body; - - if (!email || !otp) { - return responseUtil.sendError(res, { - message: 'Email and OTP are required', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - const emailError = emailValidator(email); - if (emailError) { - return responseUtil.sendError(res, { - message: emailError.details[0].message, - statusCode: StatusCodes.UNPROCESSABLE_ENTITY, - }); - } - - if (!/^\d{6}$/.test(otp)) { - return responseUtil.sendError(res, { - message: 'OTP must be a 6-digit number.', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - await authenticationService.verifyOtp(email, otp); - responseUtil.sendSuccess( - res, - { message: 'OTP verified successfully' }, - String(StatusCodes.OK), - ); - } catch (error) { - responseUtil.sendError(res, { - message: error.message, - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; - - /** - * Resets user password after successful OTP verification. - * Logs password reset activity with device and IP information for audit trail. - * - * @param {Request} req - Express request object containing password in body, userId and userAgent in app.locals - * @param {Request} req.body - Request body - * @param {string} req.body.newPassword - New password to set - * @param {string} req.body.confirmPassword - Password confirmation (must match newPassword) - * @param {Request} req.app.locals - Express app locals object - * @param {string} req.app.locals.userId - User ID from OTP verification - * @param {object} req.app.locals.userAgent - Parsed user agent information - * @param {string} req.app.locals.userAgent.browser - Browser name and version - * @param {string} req.app.locals.userAgent.os - Operating system - * @param {Request} req.headers - Request headers - * @param {string} [req.headers['x-real-ip']] - Real IP from reverse proxy - * @param {string} [req.headers['x-forwarded-for']] - Forwarded IP address - * @param {string} req.ip - Remote IP address - * @param {Response} res - Express response object - * @returns {Promise} - * - * @throws {Error} When passwords don't match (400 Bad Request) - * @throws {Error} When password doesn't meet security requirements (422 Unprocessable Entity) - * @throws {Error} When password reset service fails - * - * @remarks - * - Requires prior OTP verification (userId must be in app.locals) - * - Both newPassword and confirmPassword must match - * - Password must meet complexity requirements (handled by validator) - * - Logs reset activity with browser, OS, IP, and timestamp - * - Invalidates all existing sessions/tokens after reset - * - Returns 200 OK on successful password reset - * - User must login again with new password - * - * @security - * - Password is hashed before storage - * - Activity logging helps detect unauthorized password resets - * - IP address is extracted from headers (considering proxy) - * - All existing tokens are revoked to force re-authentication - */ - public resetPassword = async (req: Request, res: Response): Promise => { - try { - const { userId, userAgent } = req.app.locals; - const { newPassword, confirmPassword } = req.body; - - if (!validateInput(newPassword, confirmPassword)) { - return responseUtil.sendError(res, { - message: 'Passwords do not match', - statusCode: StatusCodes.BAD_REQUEST, - }); - } - const passwordError = passwordValidator(confirmPassword); - if (passwordError) { - return responseUtil.sendError(res, { - message: passwordError.details[0].message, - statusCode: StatusCodes.UNPROCESSABLE_ENTITY, - }); - } - - const ipHeader = - req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.ip; - - const ip = Array.isArray(ipHeader) - ? ipHeader[0].trim() - : ipHeader.split(',')[0].trim(); - - await authenticationService.resetPassword(userId, confirmPassword, { - browser: userAgent.browser, - os: userAgent.os, - ip: ip as string, - timestamp: new Date().toISOString(), - }); - - responseUtil.sendSuccess( - res, - { message: 'Password reset successful' }, - String(StatusCodes.OK), - ); - } catch (error) { - responseUtil.sendError(res, { - message: - error.message || 'An error occurred while resetting the password', - statusCode: error.statusCode || StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - }; -} - -export const authenticationController = AuthenticationController.getInstance(); diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 636da67..e96089a 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -2,8 +2,6 @@ export { adminController } from './admin.controller'; export { applicationController } from './application.controller'; -export { authenticationController } from './authentication.controller'; - export { employerController } from './employer.controller'; export { jobController } from './job.controller'; diff --git a/src/controllers/job.controller.ts b/src/controllers/job.controller.ts index 2af706d..75975ff 100644 --- a/src/controllers/job.controller.ts +++ b/src/controllers/job.controller.ts @@ -120,7 +120,7 @@ class JobController { public readJob = async (req: Request, res: Response): Promise => { try { const { jobId } = req.params; - if (!jobId) { + if (typeof jobId !== 'string' || !jobId) { responseUtil.sendError(res, { message: 'Job ID is required', statusCode: StatusCodes.BAD_REQUEST, @@ -170,7 +170,7 @@ class JobController { try { const { jobId } = req.params; const data = req.body as Omit; - if (!jobId) { + if (typeof jobId !== 'string' || !jobId) { responseUtil.sendError(res, { message: 'Job ID is required', statusCode: StatusCodes.BAD_REQUEST, @@ -206,7 +206,7 @@ class JobController { public deleteJob = async (req: Request, res: Response): Promise => { try { const { jobId } = req.params; - if (!jobId) { + if (typeof jobId !== 'string' || !jobId) { responseUtil.sendError(res, { message: 'Job ID is required', statusCode: StatusCodes.BAD_REQUEST, diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 2c88050..1e4a4e4 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,11 +1,7 @@ import { StatusCodes } from 'http-status-codes'; import { Request, Response } from 'express'; import { responseUtil } from '@work-whiz/utils'; -import { - emailValidator, - passwordValidator, - phoneValidator, -} from '@work-whiz/validators'; +import { emailValidator, phoneValidator } from '@work-whiz/validators'; import { userService } from '@work-whiz/services'; interface IUpdateContactRequest { @@ -13,11 +9,6 @@ interface IUpdateContactRequest { phone: string; } -interface IUpdatePasswordRequest { - currentPassword: string; - newPassword: string; -} - /** * @swagger * tags: @@ -108,46 +99,6 @@ class UserController { } }; - /** - * @swagger - * /users/password: - * patch: - * summary: Update user password - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/UpdatePasswordRequest' - * responses: - * 200: - * description: Password updated - * 400: - * description: Invalid input data - */ - public updatePassword = async ( - req: Request, - res: Response, - ): Promise => { - try { - const { currentPassword, newPassword } = - req.body as IUpdatePasswordRequest; - const userId = req.app.locals?.userId; - - if (!this.validateInput(res, passwordValidator, newPassword)) { - return; - } - - const response = await userService.updatePassword(userId, { - currentPassword, - newPassword, - }); - responseUtil.sendSuccess(res, response, String(StatusCodes.OK)); - } catch (error) { - this.handleError(res, error); - } - }; - /** * @swagger * /users/account: diff --git a/src/dtos/admin.dto.ts b/src/dtos/admin.dto.ts index 3569c3f..eaf4b11 100644 --- a/src/dtos/admin.dto.ts +++ b/src/dtos/admin.dto.ts @@ -3,14 +3,14 @@ import { IAdmin } from '@work-whiz/interfaces'; export const toIAdminDTO = (admin: IAdmin): IAdmin => { return { id: admin.id, - firstName: admin.firstName, - lastName: admin.lastName, permissions: admin.permissions, user: admin.user ? { id: admin.user.id, - avatarUrl: admin.user.avatarUrl, + name: admin.user.name, + image: admin.user.image, email: admin.user.email, + emailVerified: admin.user.emailVerified, phone: admin.user.phone, role: admin.user.role, isVerified: admin.user.isVerified, @@ -19,6 +19,6 @@ export const toIAdminDTO = (admin: IAdmin): IAdmin => { createdAt: admin.user.createdAt || new Date(), updatedAt: admin.user.updatedAt || new Date(), } - : null, + : undefined, }; }; diff --git a/src/dtos/candidate.dto.ts b/src/dtos/candidate.dto.ts index 30d2be4..70c157e 100644 --- a/src/dtos/candidate.dto.ts +++ b/src/dtos/candidate.dto.ts @@ -3,16 +3,16 @@ import { ICandidate } from '@work-whiz/interfaces'; export const toICandidateDTO = (candidate: ICandidate): ICandidate => { return { id: candidate.id, - firstName: candidate.firstName, - lastName: candidate.lastName, title: candidate.title, skills: candidate.skills, isEmployed: candidate.isEmployed, user: candidate.user ? { id: candidate.user.id, - avatarUrl: candidate.user.avatarUrl, + name: candidate.user.name, + image: candidate.user.image, email: candidate.user.email, + emailVerified: candidate.user.emailVerified, phone: candidate.user.phone, role: candidate.user.role, isVerified: candidate.user.isVerified, @@ -21,6 +21,6 @@ export const toICandidateDTO = (candidate: ICandidate): ICandidate => { createdAt: candidate.user.createdAt || new Date(), updatedAt: candidate.user.updatedAt || new Date(), } - : null, + : undefined, }; }; diff --git a/src/dtos/employer.dto.ts b/src/dtos/employer.dto.ts index 2521f9f..1811758 100644 --- a/src/dtos/employer.dto.ts +++ b/src/dtos/employer.dto.ts @@ -3,7 +3,6 @@ import { IEmployer } from '@work-whiz/interfaces'; export const toIEmployerDTO = (employer: IEmployer): IEmployer => { return { id: employer.id, - name: employer.name, industry: employer.industry, websiteUrl: employer.websiteUrl, location: employer.location, @@ -14,8 +13,10 @@ export const toIEmployerDTO = (employer: IEmployer): IEmployer => { user: employer.user ? { id: employer.user.id, - avatarUrl: employer.user.avatarUrl, + name: employer.user.name, + image: employer.user.image, email: employer.user.email, + emailVerified: employer.user.emailVerified, phone: employer.user.phone, role: employer.user.role, isVerified: employer.user.isVerified, @@ -24,6 +25,6 @@ export const toIEmployerDTO = (employer: IEmployer): IEmployer => { createdAt: employer.user.createdAt || new Date(), updatedAt: employer.user.updatedAt || new Date(), } - : null, + : undefined, }; }; diff --git a/src/dtos/job.dto.ts b/src/dtos/job.dto.ts index 82d8264..5fcbf21 100644 --- a/src/dtos/job.dto.ts +++ b/src/dtos/job.dto.ts @@ -13,11 +13,11 @@ export const toIJobDTO = (job: IJob): IJob => { vacancy: job.vacancy, deadline: job.deadline, tags: job.tags, - employer: job.employer + employer: job.employer?.user ? { id: job.employer.id, - avatarUrl: job.employer.user.avatarUrl, - name: job.employer.name, + image: job.employer.user.image, + name: job.employer.user.name, email: job.employer.user.email, phone: job.employer.user.phone, industry: job.employer.industry, @@ -27,7 +27,7 @@ export const toIJobDTO = (job: IJob): IJob => { foundedIn: job.employer.foundedIn, isVerified: job.employer.isVerified, } - : null, + : undefined, views: job.views, isPublic: job.isPublic, createdAt: job.createdAt || new Date(), diff --git a/src/dtos/user.dto.ts b/src/dtos/user.dto.ts index 7f8ffa8..ee441b2 100644 --- a/src/dtos/user.dto.ts +++ b/src/dtos/user.dto.ts @@ -2,8 +2,10 @@ import { IUser } from '../interfaces'; const toIUserDTO = (user: IUser): IUser => ({ id: user.id, - avatarUrl: user.avatarUrl, + name: user.name, + image: user.image, email: user.email, + emailVerified: user.emailVerified, phone: user.phone, password: user.password, role: user.role, diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 3e1fc91..fe8f51a 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -6,8 +6,6 @@ export * from './models'; export * from './queries'; -export * from './queries'; - export * from './repositories'; export * from './services'; diff --git a/src/interfaces/models/admin.model.ts b/src/interfaces/models/admin.model.ts index 40fe5c5..76a244b 100644 --- a/src/interfaces/models/admin.model.ts +++ b/src/interfaces/models/admin.model.ts @@ -3,8 +3,6 @@ import { IUser } from './user.model'; interface IAdmin { readonly id: string; - firstName?: string; - lastName?: string; permissions?: Array; userId?: string; user?: Partial; diff --git a/src/interfaces/models/candidate.model.ts b/src/interfaces/models/candidate.model.ts index 12e5c77..2f81447 100644 --- a/src/interfaces/models/candidate.model.ts +++ b/src/interfaces/models/candidate.model.ts @@ -2,8 +2,6 @@ import { IUser } from './user.model'; interface ICandidate { readonly id?: string; - firstName?: string; - lastName?: string; title?: string; skills?: Array; isEmployed?: boolean; diff --git a/src/interfaces/models/employer.model.ts b/src/interfaces/models/employer.model.ts index cb55fd2..6bebaea 100644 --- a/src/interfaces/models/employer.model.ts +++ b/src/interfaces/models/employer.model.ts @@ -2,7 +2,6 @@ import { IUser } from './user.model'; interface IEmployer { readonly id?: string; - name?: string; industry?: string; websiteUrl?: string; location?: string; diff --git a/src/interfaces/models/index.ts b/src/interfaces/models/index.ts index 77f2495..c01ada2 100644 --- a/src/interfaces/models/index.ts +++ b/src/interfaces/models/index.ts @@ -6,8 +6,6 @@ export { IEmployer } from './employer.model'; export { ICandidate } from './candidate.model'; -export { IModelDictionary } from './model'; - export { IJob } from './job.model'; export { IUser } from './user.model'; diff --git a/src/interfaces/models/job.model.ts b/src/interfaces/models/job.model.ts index 43894aa..56e5ba3 100644 --- a/src/interfaces/models/job.model.ts +++ b/src/interfaces/models/job.model.ts @@ -13,7 +13,8 @@ export interface IJob { tags: string[]; employerId?: string; employer?: Partial & { - avatarUrl?: string; + name?: string; + image?: string; email?: string; phone?: string; }; diff --git a/src/interfaces/models/model.ts b/src/interfaces/models/model.ts deleted file mode 100644 index 4e7334e..0000000 --- a/src/interfaces/models/model.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - Admin, - Application, - Candidate, - Employer, - Job, - User, -} from '@prisma/client'; - -export interface IModelDictionary { - AdminModel: Admin; - ApplicationModel: Application; - CandidateModel: Candidate; - EmployerModel: Employer; - JobModel: Job; - UserModel: User; -} diff --git a/src/interfaces/models/user.model.ts b/src/interfaces/models/user.model.ts index a161ec0..b03eaba 100644 --- a/src/interfaces/models/user.model.ts +++ b/src/interfaces/models/user.model.ts @@ -2,8 +2,10 @@ import { Role } from '@work-whiz/types/roles.type'; interface IUser { readonly id: string; - avatarUrl: string; + name: string; + image: string; email: string; + emailVerified: boolean; phone: string; password: string; role: Role; diff --git a/src/interfaces/queries/admin.query.ts b/src/interfaces/queries/admin.query.ts index 8355dce..0bfdcb9 100644 --- a/src/interfaces/queries/admin.query.ts +++ b/src/interfaces/queries/admin.query.ts @@ -2,8 +2,6 @@ import { Permissions } from '@work-whiz/types'; interface IAdminQuery { id?: string; - firstName?: string; - lastName?: string; permissions?: Array; userId?: string; } diff --git a/src/interfaces/queries/candidate.query.ts b/src/interfaces/queries/candidate.query.ts index c4ad65e..2dc8d6e 100644 --- a/src/interfaces/queries/candidate.query.ts +++ b/src/interfaces/queries/candidate.query.ts @@ -22,8 +22,6 @@ interface DateRange { interface ICandidateQuery { id?: string; userId?: string; - firstName?: string | StringSearch; - lastName?: string | StringSearch; title?: (typeof TITLE_ENUM)[number] | Array<(typeof TITLE_ENUM)[number]>; skills?: ArraySearch; isEmployed?: boolean; diff --git a/src/interfaces/queries/employer.query.ts b/src/interfaces/queries/employer.query.ts index 27ca73b..f158c38 100644 --- a/src/interfaces/queries/employer.query.ts +++ b/src/interfaces/queries/employer.query.ts @@ -8,7 +8,6 @@ interface StringSearch { interface IEmployerQuery { id?: string; userId?: string; - name?: string | StringSearch; industry?: string | string[]; location?: string | StringSearch; size?: string | string[]; @@ -31,7 +30,7 @@ interface IEmployerQuery { after?: Date; between?: [Date, Date]; }; - orderBy?: 'name' | 'size' | 'createdAt'; + orderBy?: 'size' | 'createdAt'; orderDirection?: 'ASC' | 'DESC'; } diff --git a/src/interfaces/queries/pagination.query.ts b/src/interfaces/queries/pagination.query.ts index 72f3bb0..007150d 100644 --- a/src/interfaces/queries/pagination.query.ts +++ b/src/interfaces/queries/pagination.query.ts @@ -10,7 +10,7 @@ * limit: 10, * sort: { * createdAt: 'DESC', - * lastName: 'ASC' + * updatedAt: 'ASC' * } * } */ @@ -36,7 +36,7 @@ export interface IPaginationQueryOptions { * @example * { * createdAt: 'DESC', - * lastName: 'ASC' + * updatedAt: 'ASC' * } */ sort?: Record; diff --git a/src/interfaces/services/authentication.ts b/src/interfaces/services/authentication.ts index e6cacf6..50dfebf 100644 --- a/src/interfaces/services/authentication.ts +++ b/src/interfaces/services/authentication.ts @@ -1,21 +1,17 @@ export interface IBaseRegister { + name: string; email: string; phone: string; password: string; } export interface IAdminRegister extends IBaseRegister { - firstName: string; - lastName: string; } export interface ICandidateRegister extends IBaseRegister { - firstName: string; - lastName: string; title: string; } export interface IEmployerRegister extends IBaseRegister { - name: string; industry: string; } diff --git a/src/interfaces/services/user.ts b/src/interfaces/services/user.ts index 276a141..a57fe0d 100644 --- a/src/interfaces/services/user.ts +++ b/src/interfaces/services/user.ts @@ -6,14 +6,6 @@ export interface IUserService { */ updateContact: (id: string, data: Partial) => Promise; - /** - * Update a user's password - */ - updatePassword: ( - id: string, - passwords: { currentPassword: string; newPassword: string }, - ) => Promise; - /** * Delete a user by ID */ diff --git a/src/libs/auth.ts b/src/libs/auth.ts new file mode 100644 index 0000000..6822978 --- /dev/null +++ b/src/libs/auth.ts @@ -0,0 +1,196 @@ +import { betterAuth } from 'better-auth'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { authenticationTemplate } from '@work-whiz/templates'; +import { notificationUtil } from '../utils/notification.util'; +import { prisma } from './database'; +import { Role } from '@prisma/client'; + +const authBaseUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3000'; + +type SignUpProfileBody = { + role?: string; + title?: string; + industry?: string; + websiteUrl?: string; + location?: string; + description?: string; + size?: number; + foundedIn?: number; +}; + +const createUserbyRole = async ( + user: { id: string; name?: string; role?: unknown }, + body: SignUpProfileBody = {}, +) => { + const role = String(user.role || body.role || 'candidate'); + + if (role === Role.admin) { + throw new Error('Admin role cannot be assigned during sign up'); + } + + if (role === Role.candidate) { + await prisma.candidate.create({ + data: { + userId: user.id, + title: body.title as never, + }, + }); + } + if (role === Role.employer) { + await prisma.employer.create({ + data: { + userId: user.id, + industry: body.industry || '', + websiteUrl: body.websiteUrl, + location: body.location, + description: body.description, + size: body.size, + foundedIn: body.foundedIn, + }, + }); + } +}; + +export const auth = betterAuth({ + appName: process.env.APP_NAME || 'WorkWhiz', + baseURL: authBaseUrl, + basePath: '/api/auth', + database: prismaAdapter(prisma, { + provider: 'postgresql', + }), + user: { + additionalFields: { + phone: { + type: 'string', + required: true, + input: true, + }, + role: { + type: 'string', + required: true, + input: true, + defaultValue: 'candidate', + }, + isVerified: { + type: 'boolean', + required: false, + input: false, + defaultValue: false, + }, + isActive: { + type: 'boolean', + required: false, + input: false, + defaultValue: true, + }, + isLocked: { + type: 'boolean', + required: false, + input: false, + defaultValue: false, + }, + }, + }, + emailVerification: { + sendOnSignUp: true, + sendOnSignIn: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + try { + const verificationUrl = new URL(url); + const callbackUrl = new URL('/auth/verified', authBaseUrl).toString(); + + verificationUrl.searchParams.set('callbackURL', callbackUrl); + + void notificationUtil.sendEmail( + user.email, + `Verify your ${process.env.APP_NAME || 'WorkWhiz'} account`, + authenticationTemplate.emailVerification( + verificationUrl.toString(), + user.name || user.email, + ), + ); + } catch (error) { + console.error('Failed to send email verification', { error }); + } + }, + afterEmailVerification: async user => { + await prisma.user.update({ + where: { id: user.id }, + data: { isVerified: true }, + }); + + void notificationUtil.sendEmail( + user.email, + `Welcome to ${process.env.APP_NAME || 'WorkWhiz'}`, + authenticationTemplate.welcome(user.name || user.email), + ); + }, + }, + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + revokeSessionsOnPasswordReset: true, + sendResetPassword: async ({ user, url, token }) => { + try { + void notificationUtil.sendEmail( + user.email, + 'Password reset request', + authenticationTemplate.passwordReset(url, user.name || user.email), + ); + } catch (error) { + console.error('Error sending reset password email', { error, token }); + } + }, + onPasswordReset: async ({ user }) => { + try { + void notificationUtil.sendEmail( + user.email, + 'Your password was changed', + authenticationTemplate.passwordUpdateNotice(user.name || user.email), + ); + } catch (error) { + console.error('Error sending password update email', { error }); + } + }, + }, + databaseHooks: { + user: { + create: { + after: async (user, context) => { + await createUserbyRole( + user, + (context?.body || {}) as SignUpProfileBody, + ); + }, + }, + update: { + after: async user => { + if (user.emailVerified) { + await prisma.user.update({ + where: { id: user.id }, + data: { isVerified: true }, + }); + } + }, + }, + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 7, + updateAge: 60 * 60 * 24, + }, + advanced: { + database: { + generateId: 'uuid', + }, + ipAddress: { + ipv6Subnet: 64, + }, + }, + rateLimit: { + enabled: process.env.NODE_ENV === 'production', + window: 60, + max: 100, + }, +}); diff --git a/src/libs/index.ts b/src/libs/index.ts index d8def72..b4a9c37 100644 --- a/src/libs/index.ts +++ b/src/libs/index.ts @@ -1,3 +1,4 @@ export { prisma, redis } from './database'; +export { auth } from './auth'; export { notificationLib } from './notification.lib'; diff --git a/src/main.ts b/src/main.ts index d899030..4de817a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,11 @@ */ import 'reflect-metadata'; import express, { Application } from 'express'; -import dotenv from 'dotenv'; import { cleanEnv, num } from 'envalid'; import { configureMiddlewares } from '@work-whiz/middlewares'; import { startServer } from './server'; -const env = cleanEnv(dotenv.config().parsed || process.env, { +const env = cleanEnv(process.env, { PORT: num({ default: 3000 }), RATE_LIMIT_WINDOW_MS: num({ default: 60 * 1000 }), RATE_LIMIT_MAX_REQUESTS: num({ default: 60 }), diff --git a/src/middlewares/app.middleware.ts b/src/middlewares/app.middleware.ts index 42dc943..5af0c3c 100644 --- a/src/middlewares/app.middleware.ts +++ b/src/middlewares/app.middleware.ts @@ -10,12 +10,13 @@ import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter'; import { ExpressAdapter } from '@bull-board/express'; import swaggerUi from 'swagger-ui-express'; +import { toNodeHandler } from 'better-auth/node'; import { swaggerSpec } from '@work-whiz/configs/swagger'; +import { auth } from '@work-whiz/libs'; import { AdminRoutes, ApplicationRoutes, - AuthenticationRoutes, CandidateRoutes, EmployerRoutes, JobRoutes, @@ -62,6 +63,8 @@ export const configureMiddlewares = (app: Application): void => { }), ); + app.use(/^\/api\/auth\/.*/, toNodeHandler(auth)); + app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '10kb' })); app.use( express.urlencoded({ @@ -104,11 +107,7 @@ export const configureMiddlewares = (app: Application): void => { app.use(limiter); } - // Authentication middleware - // app.use(authenticationMiddleware.isAuthenticated); - // API Routes - app.use(`/api/auth`, new AuthenticationRoutes().init()); app.use(`/api/admins`, new AdminRoutes().init()); app.use(`/api/candidates`, new CandidateRoutes().init()); app.use(`/api/employers`, new EmployerRoutes().init()); diff --git a/src/middlewares/authentication.middleware.ts b/src/middlewares/authentication.middleware.ts deleted file mode 100644 index 2579e64..0000000 --- a/src/middlewares/authentication.middleware.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; -import { StatusCodes } from 'http-status-codes'; -import { responseUtil } from '@work-whiz/utils'; -import { validateInput } from '@work-whiz/validators'; -import { config } from '@work-whiz/configs/config'; - -class AuthenticationMiddleware { - private static instance: AuthenticationMiddleware; - - private constructor() { - // - } - - public static getInstance(): AuthenticationMiddleware { - if (!AuthenticationMiddleware.instance) { - AuthenticationMiddleware.instance = new AuthenticationMiddleware(); - } - return AuthenticationMiddleware.instance; - } - - public isAuthenticated = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - try { - const authHeader = req.headers.authorization; - if (!authHeader) { - return responseUtil.sendError(res, { - message: 'Authorization header is missing', - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - if (!authHeader.startsWith('Bearer ')) { - return responseUtil.sendError(res, { - message: 'Invalid authorization header format', - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - const apiKey = authHeader.split(' ')[1]; - if (!apiKey) { - return responseUtil.sendError(res, { - message: 'API key is missing', - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - if (!validateInput(apiKey, config.authentication.api.secret)) { - return responseUtil.sendError(res, { - message: 'Invalid API key', - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - next(); - } catch (error) { - next(error); - } - }; -} - -export const authenticationMiddleware = AuthenticationMiddleware.getInstance(); diff --git a/src/middlewares/authorization.middleware.ts b/src/middlewares/authorization.middleware.ts index fca5b53..26eb944 100644 --- a/src/middlewares/authorization.middleware.ts +++ b/src/middlewares/authorization.middleware.ts @@ -1,10 +1,11 @@ -import { getUserRole, jwtUtil, responseUtil } from '@work-whiz/utils'; import { Request, Response, NextFunction } from 'express'; import { StatusCodes } from 'http-status-codes'; -import { IDecodedJwtToken } from '@work-whiz/interfaces'; +import { auth } from '@work-whiz/libs'; +import { responseUtil } from '@work-whiz/utils'; +import { fromNodeHeaders } from 'better-auth/node'; /** - * Authorization middleware for handling role-based access control and password operations + * Authorization middleware for role checks backed by Better Auth sessions. * Implements the Singleton pattern to ensure only one instance exists */ class AuthorizationMiddleware { @@ -25,46 +26,10 @@ class AuthorizationMiddleware { return AuthorizationMiddleware.instance; }; - /** - * Handles password-related operations (setup or reset) - * @private - * @param {Request} req - Express request object - * @param {Response} res - Express response object - * @param {NextFunction} next - Express next function - * @param {'password_setup' | 'password_reset'} tokenType - Type of password operation - * @returns {Promise} - */ - private handlePasswordOperation = async ( - req: Request, - res: Response, - next: NextFunction, - tokenType: 'password_setup' | 'password_reset', - ): Promise => { - try { - const role = getUserRole(req); - const { token } = req.body; - - const verified = await jwtUtil.verify({ - role, - token, - type: tokenType, - }); - - req.app.locals.userId = verified.id; - next(); - } catch (error) { - responseUtil.sendError(res, { - message: 'Authorization failed', - statusCode: StatusCodes.UNAUTHORIZED, - code: 'AUTHORIZATION_FAILED', - }); - } - }; - /** * Middleware factory function for role-based authorization * @param {string[]} allowedRoles - Array of role names permitted to access the route - * @returns {Function} Express middleware function that validates access tokens and user roles + * @returns {Function} Express middleware function that validates Better Auth session roles * @example * // Usage in route definition * router.get('/admin', @@ -76,83 +41,35 @@ class AuthorizationMiddleware { (allowedRoles: string[]) => async (req: Request, res: Response, next: NextFunction): Promise => { try { - const accessToken = req.cookies['access_token']; - - if (!accessToken) { - return responseUtil.sendError(res, { - message: 'Access token is missing', - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - const decodedToken: IDecodedJwtToken = jwtUtil.decode( - accessToken, - ) as IDecodedJwtToken; - - if (!decodedToken) { - return responseUtil.sendError(res, { - message: 'Invalid access token', - statusCode: StatusCodes.UNAUTHORIZED, - }); - } - - const verifiedToken = await jwtUtil.verify({ - role: decodedToken.role, - token: accessToken, - type: decodedToken.type, + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), }); - if (!verifiedToken) { + if (!session) { return responseUtil.sendError(res, { - message: 'Access token verification failed', + message: 'Authentication required', statusCode: StatusCodes.UNAUTHORIZED, }); } - if (!allowedRoles.includes(decodedToken.role)) { + const user = session.user as typeof session.user & { role?: string }; + + if (!user.role || !allowedRoles.includes(user.role)) { return responseUtil.sendError(res, { message: 'Access denied: insufficient permissions', statusCode: StatusCodes.FORBIDDEN, }); } + req.app.locals.userId = user.id; + req.app.locals.session = session.session; + req.app.locals.user = user; + next(); } catch (error) { next(error); } }; - - /** - * Middleware to authorize password setup operations - * Validates password setup token and required fields - * @param {Request} req - Express request object - * @param {Response} res - Express response object - * @param {NextFunction} next - Express next function - * @returns {Promise} - */ - public authorizePasswordSetup = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - await this.handlePasswordOperation(req, res, next, 'password_setup'); - }; - - /** - * Middleware to authorize password reset operations - * Validates password reset token and required fields - * @param {Request} req - Express request object - * @param {Response} res - Express response object - * @param {NextFunction} next - Express next function - * @returns {Promise} - */ - public authorizePasswordReset = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - await this.handlePasswordOperation(req, res, next, 'password_reset'); - }; } /** diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index 65d4bc8..3e3a11f 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -1,7 +1,5 @@ export { configureMiddlewares } from './app.middleware'; -export { authenticationMiddleware } from './authentication.middleware'; - export { authorizationMiddleware } from './authorization.middleware'; export { diff --git a/src/queues/workers/application.worker.ts b/src/queues/workers/application.worker.ts index 00aa643..aaeb6f7 100644 --- a/src/queues/workers/application.worker.ts +++ b/src/queues/workers/application.worker.ts @@ -10,7 +10,7 @@ interface ApplicationJobPayload { jobTitle?: string; candidate?: { email?: string; - firstName?: string; + name?: string; }; } diff --git a/src/repositories/admin.repository.ts b/src/repositories/admin.repository.ts index e3ba219..1f137cd 100644 --- a/src/repositories/admin.repository.ts +++ b/src/repositories/admin.repository.ts @@ -29,12 +29,6 @@ class AdminRepository implements IAdminRepository { const where: Prisma.AdminWhereInput = {}; if (query.id) where.id = query.id; - if (query.firstName) { - where.firstName = { contains: query.firstName, mode: 'insensitive' }; - } - if (query.lastName) { - where.lastName = { contains: query.lastName, mode: 'insensitive' }; - } if (query.permissions) { where.permissions = { hasSome: Array.isArray(query.permissions) diff --git a/src/repositories/application.repository.ts b/src/repositories/application.repository.ts index 8778f82..471288f 100644 --- a/src/repositories/application.repository.ts +++ b/src/repositories/application.repository.ts @@ -10,7 +10,10 @@ import { } from '@work-whiz/interfaces'; import { IApplicationRepository } from '@work-whiz/interfaces/repositories'; import { Pagination } from '@work-whiz/utils'; -import { PrismaRepositoryClient } from './prisma.repository'; +import { + PrismaRepositoryClient, + userSelectWithoutPassword, +} from './prisma.repository'; class ApplicationRepository implements IApplicationRepository { private static instance: ApplicationRepository; @@ -37,7 +40,11 @@ class ApplicationRepository implements IApplicationRepository { private readonly includeRelations = { job: true, - candidate: true, + candidate: { + include: { + user: { select: userSelectWithoutPassword }, + }, + }, } as const; private toDtoInput(application: unknown): IApplication { @@ -62,7 +69,7 @@ class ApplicationRepository implements IApplicationRepository { ? jobTypeMap[dtoApplication.job.type as JobType] : dtoApplication.job.type, } - : null, + : undefined, }; } diff --git a/src/repositories/candidate.repository.ts b/src/repositories/candidate.repository.ts index b699f5a..867f328 100644 --- a/src/repositories/candidate.repository.ts +++ b/src/repositories/candidate.repository.ts @@ -30,12 +30,6 @@ class CandidateRepository implements ICandidateRepository { if (query.id) where.id = query.id; if (query.userId) where.userId = query.userId; - if (typeof query.firstName === 'string') { - where.firstName = { contains: query.firstName, mode: 'insensitive' }; - } - if (typeof query.lastName === 'string') { - where.lastName = { contains: query.lastName, mode: 'insensitive' }; - } if (typeof query.title === 'string') where.title = query.title; if (Array.isArray(query.title)) where.title = { in: query.title }; if (query.skills) { diff --git a/src/repositories/employer.repository.ts b/src/repositories/employer.repository.ts index d0391f7..96b8ce5 100644 --- a/src/repositories/employer.repository.ts +++ b/src/repositories/employer.repository.ts @@ -30,9 +30,6 @@ class EmployerRepository implements IEmployerRepository { if (query.id) where.id = query.id; if (query.userId) where.userId = query.userId; - if (typeof query.name === 'string') { - where.name = { contains: query.name, mode: 'insensitive' }; - } if (typeof query.industry === 'string') { where.industry = { contains: query.industry, mode: 'insensitive' }; } diff --git a/src/repositories/job.repository.ts b/src/repositories/job.repository.ts index abbd62c..85dbb29 100644 --- a/src/repositories/job.repository.ts +++ b/src/repositories/job.repository.ts @@ -93,7 +93,12 @@ class JobRepository implements IJobRepository { if (query.tags?.length) where.tags = { hasSome: query.tags }; if (query.employerName) { where.employer = { - name: { contains: query.employerName, mode: 'insensitive' }, + user: { + name: { + contains: query.employerName, + mode: 'insensitive', + }, + }, }; } diff --git a/src/repositories/prisma.repository.ts b/src/repositories/prisma.repository.ts index c7bd404..bf79857 100644 --- a/src/repositories/prisma.repository.ts +++ b/src/repositories/prisma.repository.ts @@ -16,8 +16,10 @@ export const getPrismaOrderBy = ( export const userSelectWithoutPassword = { id: true, - avatarUrl: true, + name: true, + image: true, email: true, + emailVerified: true, phone: true, role: true, isVerified: true, @@ -26,4 +28,3 @@ export const userSelectWithoutPassword = { createdAt: true, updatedAt: true, } as const; - diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index 0aeb5a9..a615015 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -9,10 +9,7 @@ import { } from '@work-whiz/interfaces'; import { RepositoryError } from '@work-whiz/errors'; import { Pagination } from '@work-whiz/utils'; -import { - getPrismaOrderBy, - PrismaRepositoryClient, -} from './prisma.repository'; +import { getPrismaOrderBy, PrismaRepositoryClient } from './prisma.repository'; class UserRepository implements IUserRepository { private static instance: UserRepository; @@ -22,7 +19,9 @@ class UserRepository implements IUserRepository { this.client = client; } - private readonly buildWhereClause = (query: IUserQuery): Prisma.UserWhereInput => { + private readonly buildWhereClause = ( + query: IUserQuery, + ): Prisma.UserWhereInput => { const where: Prisma.UserWhereInput = {}; if (query.id) where.id = query.id; @@ -30,7 +29,8 @@ class UserRepository implements IUserRepository { if (query.phone) where.phone = query.phone; if (query.role) where.role = query.role; if (typeof query.isActive === 'boolean') where.isActive = query.isActive; - if (typeof query.isVerified === 'boolean') where.isVerified = query.isVerified; + if (typeof query.isVerified === 'boolean') + where.isVerified = query.isVerified; if (typeof query.isLocked === 'boolean') where.isLocked = query.isLocked; return where; @@ -41,12 +41,16 @@ class UserRepository implements IUserRepository { return { ...dtoUser, - avatarUrl: dtoUser.avatarUrl || '', + image: dtoUser.image || '', + name: dtoUser.name || '', + emailVerified: dtoUser.emailVerified || false, password: dtoUser.password || '', } as IUser; }; - public withTransaction(transaction: Prisma.TransactionClient): UserRepository { + public withTransaction( + transaction: Prisma.TransactionClient, + ): UserRepository { return new UserRepository(transaction); } @@ -69,13 +73,17 @@ class UserRepository implements IUserRepository { } } - public async read(query: IUserQuery): Promise { + public async read(query: IUserQuery): Promise { try { const user = await this.client.user.findFirst({ where: this.buildWhereClause(query), }); - return user ? toIUserDTO(this.toDtoInput(user)) : null; + if (!user) { + throw new RepositoryError('User not found'); + } + + return toIUserDTO(this.toDtoInput(user)); } catch (error) { throw new RepositoryError('Failed to retrieve user', error); } @@ -106,9 +114,7 @@ class UserRepository implements IUserRepository { ]); return { - users: users.map(user => - toIUserDTO(this.toDtoInput(user)), - ), + users: users.map(user => toIUserDTO(this.toDtoInput(user))), total: count, totalPages: pagination.getTotalPages(count), currentPage: pagination.page, @@ -119,7 +125,7 @@ class UserRepository implements IUserRepository { } } - public async update(id: string, data: Partial): Promise { + public async update(id: string, data: Partial): Promise { try { const updatedUser = await this.client.user.update({ where: { id }, @@ -132,7 +138,7 @@ class UserRepository implements IUserRepository { error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025' ) { - return null; + throw new RepositoryError('User not found', error); } throw new RepositoryError('Failed to update user', error); } diff --git a/src/routes/admin.route.ts b/src/routes/admin.route.ts index 27ee8e9..fb12db4 100644 --- a/src/routes/admin.route.ts +++ b/src/routes/admin.route.ts @@ -140,42 +140,6 @@ export class AdminRoutes { userController.updateContact, ); - /** - * @swagger - * /api/admins/me/password: - * patch: - * summary: Update admin password - * tags: [Admin] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * currentPassword: - * type: string - * example: "currentpassword123" - * newPassword: - * type: string - * example: "newpassword123" - * responses: - * 200: - * description: Password updated successfully - * 400: - * description: Validation error - * 401: - * description: Unauthorized - */ - this.router.patch( - '/me/password', - profileLimiter, - authorizationMiddleware.isAuthorized(['admin']), - userController.updatePassword, - ); - /** * @swagger * /api/admins/me: diff --git a/src/routes/authentication.route.ts b/src/routes/authentication.route.ts deleted file mode 100644 index 60478d3..0000000 --- a/src/routes/authentication.route.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Router } from 'express'; -import { authenticationController } from '@work-whiz/controllers'; -import { - authenticationMiddleware, - authorizationMiddleware, - userAgentParser, - registerLimiter, - loginLimiter, - logoutLimiter, - forgotPasswordLimiter, - resetPasswordLimiter, -} from '@work-whiz/middlewares'; -import { verifyAccountLimiter } from '@work-whiz/middlewares/rate-limiter.middleware'; - -/** - * @swagger - * tags: - * name: Auth - * description: Endpoints related to user authentication - */ - -/** - * @swagger - * components: - * schemas: - * AuthSuccessResponse: - * type: object - * properties: - * accessToken: - * type: string - * csrfToken: - * type: string - * ErrorResponse: - * type: object - * properties: - * message: - * type: string - * RegisterRequest: - * type: object - * properties: - * email: - * type: string - * password: - * type: string - * role: - * type: string - * enum: [admin, candidate, employer] - * userData: - * type: object - * description: Role-specific data for the user - * oneOf: - * - $ref: '#/components/schemas/IAdminRegister' - * - $ref: '#/components/schemas/ICandidateRegister' - * - $ref: '#/components/schemas/IEmployerRegister' - * PasswordRequest: - * type: object - * properties: - * password: - * type: string - * EmailRequest: - * type: object - * properties: - * email: - * type: string - * IAdminRegister: - * type: object - * properties: - * firstName: - * type: string - * lastName: - * type: string - * required: - * - firstName - * - lastName - * ICandidateRegister: - * type: object - * properties: - * firstName: - * type: string - * lastName: - * type: string - * title: - * type: string - * required: - * - firstName - * - lastName - * - title - * IEmployerRegister: - * type: object - * properties: - * name: - * type: string - * industry: - * type: string - * required: - * - name - * - industry - */ - -export class AuthenticationRoutes { - constructor(private readonly router: Router = Router()) {} - - /** - * Initializes the authentication routes - * @returns {Router} The configured router with all authentication routes - */ - public init(): Router { - return ( - this.router - /** - * @swagger - * /api/v1/auth/register: - * post: - * summary: Register a new user (admin, candidate, or employer) - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/RegisterRequest' - * responses: - * 200: - * description: User registered successfully - * content: - * application/json: - * schema: - * type: object - * 400: - * description: Bad Request - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ErrorResponse' - * 422: - * description: Validation error - */ - - .post('/register', registerLimiter, authenticationController.register) - - /** - * @swagger - * /api/v1/auth/verify-account: - * post: - * summary: Verify account with OTP - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * otp: - * type: string - * pattern: '^\\d{6}$' - * responses: - * 200: - * description: Account verified successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * 400: - * description: Invalid or expired OTP - */ - .post( - '/verify-account', - verifyAccountLimiter, - authenticationController.verifyAccount, - ) - - /** - * @swagger - * /api/v1/auth/login: - * post: - * summary: Login with email and password - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * password: - * type: string - * responses: - * 200: - * description: Successful login - * 401: - * description: Unauthorized - */ - - .post('/login', loginLimiter, authenticationController.login) - - /** - * @swagger - * /api/v1/auth/refresh-token: - * post: - * summary: Refresh access token using refresh token and CSRF token - * tags: [Auth] - * responses: - * 200: - * description: Access token refreshed - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/AuthSuccessResponse' - * 401: - * description: Session expired - * 403: - * description: Invalid CSRF token - */ - .post( - '/refresh-token', - loginLimiter, - authenticationController.refreshToken, - ) - - /** - * @swagger - * /api/v1/auth/logout: - * delete: - * summary: Log out the current user - * tags: [Auth] - * responses: - * 200: - * description: Logout successful - * 500: - * description: Internal server error - */ - - .delete( - '/logout', - logoutLimiter, - authenticationMiddleware.isAuthenticated, - authenticationController.logout, - ) - - /** - * @swagger - * /api/v1/auth/forgot-password: - * post: - * summary: Send a password reset OTP to user's email - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/EmailRequest' - * responses: - * 200: - * description: Password reset OTP sent - * 422: - * description: Invalid email format - */ - .post( - '/forgot-password', - forgotPasswordLimiter, - authenticationController.forgotPassword, - ) - - /** - * @swagger - * /api/v1/auth/forgot-password/verify-otp: - * post: - * summary: Verify OTP and receive password reset token - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email: - * type: string - * otp: - * type: string - * pattern: '^\d{6}$' - * responses: - * 200: - * description: OTP verified, reset token returned - * content: - * application/json: - * schema: - * type: object - * properties: - * token: - * type: string - * message: - * type: string - * 400: - * description: Invalid or expired OTP - * 422: - * description: Invalid email or OTP format - */ - .post( - '/forgot-password/verify-otp', - resetPasswordLimiter, - authenticationController.verifyOtp, - ) - - /** - * @swagger - * /api/v1/auth/password-reset: - * patch: - * summary: Reset password using token - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/PasswordRequest' - * responses: - * 200: - * description: Password reset successful - * 422: - * description: Invalid password - */ - .patch( - '/password-reset', - resetPasswordLimiter, - authorizationMiddleware.authorizePasswordReset, - userAgentParser, - authenticationController.resetPassword, - ) - ); - } -} diff --git a/src/routes/candidate.route.ts b/src/routes/candidate.route.ts index d8be7dc..94b3f1e 100644 --- a/src/routes/candidate.route.ts +++ b/src/routes/candidate.route.ts @@ -153,42 +153,6 @@ export class CandidateRoutes { userController.updateContact, ); - /** - * @swagger - * /api/candidates/me/password: - * patch: - * summary: Update candidate password - * tags: [Candidate] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * currentPassword: - * type: string - * example: "currentpassword123" - * newPassword: - * type: string - * example: "newpassword123" - * responses: - * 200: - * description: Password updated successfully - * 400: - * description: Validation error - * 401: - * description: Unauthorized - */ - this.router.patch( - '/me/password', - profileLimiter, - authorizationMiddleware.isAuthorized(['candidate']), - userController.updatePassword, - ); - /** * @swagger * /api/candidates/me: diff --git a/src/routes/employer.route.ts b/src/routes/employer.route.ts index 8be2d87..8f8e3bd 100644 --- a/src/routes/employer.route.ts +++ b/src/routes/employer.route.ts @@ -129,42 +129,6 @@ export class EmployerRoutes { userController.updateContact, ); - /** - * @swagger - * /employers/me/password: - * patch: - * summary: Update employer password - * tags: [Employers] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * currentPassword: - * type: string - * example: "currentpassword123" - * newPassword: - * type: string - * example: "newpassword123" - * responses: - * 200: - * description: Password updated successfully - * 400: - * description: Validation error - * 401: - * description: Unauthorized - */ - this.router.patch( - '/me/password', - profileLimiter, - authorizationMiddleware.isAuthorized(['employer']), - userController.updatePassword, - ); - /** * @swagger * /employers/me: diff --git a/src/routes/index.ts b/src/routes/index.ts index 691cc20..df6be26 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,8 +2,6 @@ export { AdminRoutes } from './admin.route'; export { ApplicationRoutes } from './application.routes'; -export { AuthenticationRoutes } from './authentication.route'; - export { CandidateRoutes } from './candidate.route'; export { EmployerRoutes } from './employer.route'; diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts index f4bb039..94be756 100644 --- a/src/services/admin.service.ts +++ b/src/services/admin.service.ts @@ -43,8 +43,10 @@ class AdminService extends BaseService implements IAdminService { */ public findOne = async (query: IAdminQuery): Promise => this.handleErrors(async () => { - const cacheKey = this.generateCacheKey(query.userId); - const cachedAdmin = await cacheUtil.get(cacheKey); + const cacheKey = query.userId + ? this.generateCacheKey(query.userId) + : undefined; + const cachedAdmin = cacheKey ? await cacheUtil.get(cacheKey) : null; if (cachedAdmin) { return cachedAdmin as IAdmin; @@ -59,7 +61,9 @@ class AdminService extends BaseService implements IAdminService { }); } - await cacheUtil.set(cacheKey, admin, 3600); + if (cacheKey) { + await cacheUtil.set(cacheKey, admin, 3600); + } return admin; }, this.findOne.name); @@ -79,7 +83,8 @@ class AdminService extends BaseService implements IAdminService { pagination: { page: number; limit: number; total: number }; }> => this.handleErrors(async () => { - const payload = await adminRepository.readAll(query, options); + const paginationOptions = options ?? { page: 1, limit: 10 }; + const payload = await adminRepository.readAll(query, paginationOptions); if (payload.admins.length === 0) { throw new ServiceError(StatusCodes.NOT_FOUND, { @@ -95,7 +100,7 @@ class AdminService extends BaseService implements IAdminService { admins: payload.admins, pagination: { page: payload.currentPage, - limit: options.limit ?? 10, + limit: paginationOptions.limit, total: payload.total, }, }; @@ -133,7 +138,7 @@ class AdminService extends BaseService implements IAdminService { }); } - const cacheKey = this.generateCacheKey(admin.userId); + const cacheKey = this.generateCacheKey(id); await cacheUtil.delete(cacheKey); return { message: 'Admin account updated successfully.' }; diff --git a/src/services/application.service.ts b/src/services/application.service.ts index 7f43660..2196b14 100644 --- a/src/services/application.service.ts +++ b/src/services/application.service.ts @@ -45,6 +45,7 @@ class ApplicationService extends BaseService implements IApplicationService { } private async cacheSingleApplication(app: IApplication) { + if (!app.id) return; const cacheKey = this.generateCacheKey(app.id); await cacheUtil.set(cacheKey, app, this.CACHE_TTL.SINGLE_APPLICATION); } @@ -98,7 +99,7 @@ class ApplicationService extends BaseService implements IApplicationService { status: newApplication.status, jobTitle: newApplication.job?.title, candidate: { - firstName: candidate?.firstName, + name: candidate?.user?.name, email: candidate?.user?.email, }, }; @@ -219,7 +220,7 @@ class ApplicationService extends BaseService implements IApplicationService { status: updatedApplication.status, jobTitle: updatedApplication.job?.title, candidate: { - firstName: updatedApplication.candidate?.firstName, + name: updatedApplication.candidate?.user?.name, email: updatedApplication.candidate?.user?.email, }, }; diff --git a/src/services/authentication.service.ts b/src/services/authentication.service.ts deleted file mode 100644 index bbbd063..0000000 --- a/src/services/authentication.service.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { StatusCodes } from 'http-status-codes'; -import { BaseService } from './base.service'; -import { - getLocationFromIp, - jwtUtil, - passwordUtil, - cacheUtil, -} from '@work-whiz/utils'; -import { - adminRepository, - candidateRepository, - employerRepository, - userRepository, -} from '@work-whiz/repositories'; -import { ServiceError } from '@work-whiz/errors'; -import { Role } from '@work-whiz/types'; -import { - IAdmin, - ICandidate, - IEmployer, - IAdminRegister, - ICandidateRegister, - IEmployerRegister, -} from '@work-whiz/interfaces'; -import { authenticationQueue } from '@work-whiz/queues'; - -const AuthenticationErrorMessages = { - login: 'Invalid username or password..', - register: - 'If registration was successful, you will receive an activation email.', - setupPassword: 'An unexpected error occurred while setting up your password.', - logout: 'You have been logged out.', - forgotPassword: - 'If the request is valid, you will receive an email with further instructions.', - resetPassword: 'Your password reset request has been processed.', - requestActivation: - 'If the request is valid, an activation email will be sent.', - confirmActivation: 'Your account activation request has been processed.', -}; - -class AuthenticationService extends BaseService { - private static instance: AuthenticationService; - - private createRoleSpecificUser = async ( - role: Role, - user: Partial, - ): Promise => { - switch (role) { - case 'admin': - await adminRepository.create({ - ...(user as IAdmin), - userId: user.userId, - }); - break; - case 'candidate': - await candidateRepository.create({ - ...(user as ICandidate), - userId: user.userId, - }); - break; - case 'employer': - await employerRepository.create({ - ...(user as IEmployer), - userId: user.userId, - }); - break; - default: - throw new ServiceError(StatusCodes.INTERNAL_SERVER_ERROR, { - message: AuthenticationErrorMessages.register, - trace: { - method: this.createRoleSpecificUser.name, - context: { - error: `Failed to create role specific user with role: ${role}`, - }, - }, - }); - } - }; - - /** - * Generates a 6-digit OTP - */ - private generateOtp = (): string => { - return Math.floor(100000 + Math.random() * 900000).toString(); - }; - - private constructor() { - super(); - } - - public static getInstance() { - if (!AuthenticationService.instance) { - AuthenticationService.instance = new AuthenticationService(); - } - return AuthenticationService.instance; - } - - /** - * Registers a new user with a specific role. - * Prevents duplicate users, stores a temporary password setup token, - * and queues an email for account completion. - * - * @param role - Role of the user (Admin, Candidate, or Employer) - * @param data - Registration form data - * @returns A success message - */ - public register = async ( - role: Role, - data: IAdminRegister | ICandidateRegister | IEmployerRegister, - ): Promise<{ message: string }> => - this.handleErrors(async () => { - const { email, phone } = data; - const errorMessage = AuthenticationErrorMessages.register; - - const existingUser = await userRepository.read({ email }); - - if (existingUser) { - throw new ServiceError(StatusCodes.BAD_REQUEST, { - message: errorMessage, - trace: { - method: this.register.name, - context: { email }, - }, - }); - } - - const hashedPassword = await passwordUtil.hashSync(role, data.password); - - const newUser = await userRepository.create({ - email, - phone, - role, - password: hashedPassword, - }); - - await this.createRoleSpecificUser(role, { - ...data, - userId: newUser.id, - }); - - const otp = this.generateOtp(); - const OTP_EXPIRATION = 10 * 60; // 10 minutes - const cacheKey = `account_verification_otp:${newUser.email}`; - await cacheUtil.set(cacheKey, otp, OTP_EXPIRATION); - - // Send verification email - await authenticationQueue.add({ - email, - subject: `Verify your ${process.env.APP_NAME} account`, - template: { - name: 'account_verification_otp', - content: { - email, - otp, - expiration: '10 minutes', - appName: process.env.APP_NAME, - role, - }, - }, - }); - - return { - message: 'Account created successfully. Please verify your email.', - }; - }, this.register.name); - - /** - * Verifies account OTP and activates user, returns tokens - * @param {string} email - User email - * @param {string} otp - OTP code - * @returns {Promise<{ accessToken: string; refreshToken: string }>} - */ - public verifyAccountOtp = async ( - email: string, - otp: string, - ): Promise<{ accessToken: string; refreshToken: string }> => - this.handleErrors(async () => { - const user = await userRepository.read({ email }); - if (!user) { - throw new ServiceError(StatusCodes.NOT_FOUND, { - message: 'User not found.', - trace: { - method: this.verifyAccountOtp.name, - context: { email }, - }, - }); - } - - const cacheKey = `account_verification_otp:${email}`; - const cachedOtp = await cacheUtil.get(cacheKey); - - if (!cachedOtp || cachedOtp !== otp) { - throw new ServiceError(StatusCodes.UNAUTHORIZED, { - message: 'Invalid or expired OTP.', - trace: { - method: this.verifyAccountOtp.name, - context: { email }, - }, - }); - } - - await userRepository.update(user.id, { isVerified: true }); - await cacheUtil.delete(cacheKey); - - const [accessToken, refreshToken] = await Promise.all([ - jwtUtil.generate({ id: user.id, role: user.role, type: 'access' }), - jwtUtil.generate({ id: user.id, role: user.role, type: 'refresh' }), - ]); - - return { accessToken, refreshToken }; - }, this.verifyAccountOtp.name); - - /** - * Logs in a user by validating credentials and generating tokens. - * - * @param email - The user's email - * @param password - The user's password - * @returns Access and refresh tokens - * - * @throws {ServiceError} - If credentials are invalid or account is locked - */ - public login = async ( - email: string, - password: string, - ): Promise<{ accessToken: string; refreshToken: string }> => - this.handleErrors(async () => { - const INVALID_CREDENTIALS_MSG = 'Invalid username or password.'; - - const user = await userRepository.read({ email }); - - if (!user) { - throw new ServiceError(StatusCodes.UNAUTHORIZED, { - message: INVALID_CREDENTIALS_MSG, - trace: { - method: this.login.name, - context: { email, error: 'User not found' }, - }, - }); - } - - if (user.isLocked) { - throw new ServiceError(StatusCodes.FORBIDDEN, { - message: 'Account locked. Contact support at support@example.com.', - trace: { - method: this.login.name, - context: { email, error: 'User account is locked' }, - }, - }); - } - - const isPasswordValid = await passwordUtil.compareSync( - user.role, - password, - user.password, - ); - - if (!isPasswordValid) { - // TODO: track failed attempts - throw new ServiceError(StatusCodes.UNAUTHORIZED, { - message: INVALID_CREDENTIALS_MSG, - trace: { - method: this.login.name, - context: { email, error: 'Invalid password' }, - }, - }); - } - await userRepository.update(user.id, { isActive: true }); - - const [accessToken, refreshToken] = await Promise.all([ - jwtUtil.generate({ id: user.id, role: user.role, type: 'access' }), - jwtUtil.generate({ id: user.id, role: user.role, type: 'refresh' }), - ]); - - return { accessToken, refreshToken }; - }, this.login.name); - - /** - * Logs out the user by setting their account as inactive and deleting associated tokens from the cache. - * - * @param {string} userId - The user ID of the user logging out. - * @returns {Promise<{ message: string }>} - A message indicating the result of the logout operation. - * - * @throws {ServiceError} - Throws an error if the user could not be updated or cache deletion fails. - * - * @example - * const result = await authService.logout('user-id'); - */ - public logout = async (userId: string): Promise<{ message: string }> => - this.handleErrors(async () => { - const user = await userRepository.read({ id: userId }); - if (!user) { - throw new ServiceError(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'An error occurred while logging out. Please try again.', - trace: { - method: this.logout.name, - context: { userId }, - }, - }); - } - - const updatedUser = await userRepository.update(userId, { - isActive: false, - }); - - if (!updatedUser) { - throw new ServiceError(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'An error occurred while logging out. Please try again.', - trace: { - method: this.logout.name, - context: { userId }, - }, - }); - } - - return { - message: - 'Logged out successfully. All active sessions have been terminated.', - }; - }, this.logout.name); - - /** - * Refreshes the user's access and refresh tokens. - * - * @param {string} userId - The ID of the user whose tokens are being refreshed. - * @param {string} refreshToken - The refresh token provided by the user. - * @returns {Promise<{ accessToken: string; refreshToken: string }>} - The new access and refresh tokens. - * - * @throws {ServiceError} - Throws an error if the user account is not found, inactive, or the refresh token is invalid. - * - * @example - * const result = await authService.refreshToken('user-id', 'existing-refresh-token'); - */ - public refreshToken = async ( - userId: string, - refreshToken: string, - ): Promise<{ accessToken: string; refreshToken: string }> => - this.handleErrors(async () => { - const user = await userRepository.read({ id: userId }); - if (!user) { - throw new ServiceError(StatusCodes.NOT_FOUND, { - message: 'User account not found. Please log in again.', - trace: { - method: this.refreshToken.name, - context: { - userId, - error: 'User record does not exist', - }, - }, - }); - } - - await jwtUtil.verify({ - role: user.role, - token: refreshToken, - type: 'refresh', - }); - - const [newAccessToken, newRefreshToken] = await Promise.all([ - jwtUtil.generate({ - id: user.id, - role: user.role, - type: 'access', - }), - jwtUtil.generate({ - id: user.id, - role: user.role, - type: 'refresh', - }), - ]); - - return { - accessToken: newAccessToken, - refreshToken: newRefreshToken, - }; - }, this.refreshToken.name); - - /** - * Handles password reset request by sending an OTP to the user's email. - * - * @param email - The user's email address. - * @returns A success message whether the email exists or not. - * - * @throws {ServiceError} - If the account is unverified or locked. - */ - public forgotPassword = async (email: string): Promise<{ message: string }> => - this.handleErrors(async () => { - const genericSuccessMessage = - 'If an account exists with this email, an OTP has been sent for password reset.'; - - const user = await userRepository.read({ email }); - if (!user) { - return { message: genericSuccessMessage }; - } - - if (!user.isVerified) { - throw new ServiceError(StatusCodes.FORBIDDEN, { - message: 'Please verify your email before requesting password reset.', - trace: { - method: this.forgotPassword.name, - context: { - email, - userId: user.id, - error: 'Unverified account', - }, - }, - }); - } - - if (user.isLocked) { - throw new ServiceError(StatusCodes.FORBIDDEN, { - message: 'Account locked. Contact support at support@example.com.', - trace: { - method: this.forgotPassword.name, - context: { - email, - userId: user.id, - error: 'Locked account', - }, - }, - }); - } - - const otp = this.generateOtp(); - const OTP_EXPIRATION = 10 * 60; // 10 minutes - const OTP_EXPIRATION_TEXT = '10 minutes'; - - const cacheKey = `password_reset_otp:${user.id}`; - await cacheUtil.set(cacheKey, otp, OTP_EXPIRATION); - - await authenticationQueue.add({ - email: user.email, - subject: 'Password Reset OTP', - template: { - name: 'password_reset_otp', - content: { - email: user.email, - otp, - expiration: OTP_EXPIRATION_TEXT, - appName: process.env.APP_NAME, - }, - }, - }); - - return { message: genericSuccessMessage }; - }, this.forgotPassword.name); - - /** - * Verifies the OTP and generates a password reset token. - * - * @param {string} email - The user's email address. - * @param {string} otp - The OTP entered by the user. - * @returns {Promise<{ token: string; message: string }>} - Password reset token and success message. - * - * @throws {ServiceError} - If the OTP is invalid or expired. - */ - public verifyOtp = async ( - email: string, - otp: string, - ): Promise<{ token: string; message: string }> => - this.handleErrors(async () => { - const user = await userRepository.read({ email }); - if (!user) { - throw new ServiceError(StatusCodes.BAD_REQUEST, { - message: 'Invalid or expired OTP', - trace: { - method: this.verifyOtp.name, - context: { - email, - error: 'User not found', - }, - }, - }); - } - - const cacheKey = `password_reset_otp:${user.id}`; - const cachedOtp = await cacheUtil.get(cacheKey); - - if (!cachedOtp || cachedOtp !== otp) { - throw new ServiceError(StatusCodes.BAD_REQUEST, { - message: 'Invalid or expired OTP', - trace: { - method: this.verifyOtp.name, - context: { - email, - userId: user.id, - error: 'OTP mismatch or not found', - }, - }, - }); - } - - await cacheUtil.delete(cacheKey); - - const passwordResetToken = await jwtUtil.generate({ - id: user.id, - role: user.role, - type: 'password_reset', - }); - - const TOKEN_EXPIRATION = 15 * 60; // 15 minutes - const tokenCacheKey = `password_reset_token:${user.id}`; - await cacheUtil.set(tokenCacheKey, passwordResetToken, TOKEN_EXPIRATION); - - return { - token: passwordResetToken, - message: 'OTP verified successfully.', - }; - }, this.verifyOtp.name); - - /** - * Completes the password reset process by validating the reset token and updating the user's password. - * - * @param {string} userId - ID of the user resetting their password - * @param {string} password - New password to set - * @param {Object} device - Device information where reset was initiated - * @param {string} device.browser - User's browser/agent - * @param {string} device.os - Operating system - * @param {string} device.ip - IP address where request originated - * @param {string} device.timestamp - Time of the request - * @returns {Promise<{message: string}>} Success message - */ - public resetPassword = async ( - userId: string, - password: string, - device: { browser: string; os: string; ip: string; timestamp: string }, - ): Promise<{ message: string }> => - this.handleErrors(async () => { - const user = await userRepository.read({ id: userId }); - if (!user) { - throw new ServiceError(StatusCodes.NOT_FOUND, { - message: 'User account not found', - trace: { - method: this.resetPassword.name, - context: { - userId, - error: 'User record does not exist', - }, - }, - }); - } - - if (user.isLocked) { - throw new ServiceError(StatusCodes.FORBIDDEN, { - message: 'Account is locked. Please contact support.', - trace: { - method: this.resetPassword.name, - context: { - userId, - error: 'Locked account', - }, - }, - }); - } - - const tokenCacheKey = `password_reset_token:${user.id}`; - const cachedToken = await cacheUtil.get(tokenCacheKey); - - if (!cachedToken) { - throw new ServiceError(StatusCodes.BAD_REQUEST, { - message: 'Invalid or expired password reset token', - trace: { - method: this.resetPassword.name, - context: { - userId, - error: 'Token not found in cache', - }, - }, - }); - } - - const isSamePassword = await passwordUtil.compareSync( - user.role, - password, - user.password, - ); - if (isSamePassword) { - throw new ServiceError(StatusCodes.BAD_REQUEST, { - message: 'New password cannot be the same as your current password', - trace: { - method: this.resetPassword.name, - context: { - userId, - error: 'Password reuse detected', - }, - }, - }); - } - - const hashedPassword = await passwordUtil.hashSync(user.role, password); - await userRepository.update(user.id, { - password: hashedPassword, - }); - - await cacheUtil.delete(tokenCacheKey); - - await authenticationQueue.add({ - email: user.email, - subject: 'Your Password Was Changed', - template: { - name: 'password_update', - content: { - email: user.email, - appName: process.env.APP_NAME, - device: { - browser: device.browser, - os: device.os, - location: getLocationFromIp('24.48.0.1'), - timestamp: new Date().toISOString(), - ip: device.ip, - }, - }, - }, - }); - - return { - message: - 'Password has been reset successfully. Please log in with your new password.', - }; - }, this.resetPassword.name); -} - -export const authenticationService = AuthenticationService.getInstance(); diff --git a/src/services/base.service.ts b/src/services/base.service.ts index 2b58fbd..426e42d 100644 --- a/src/services/base.service.ts +++ b/src/services/base.service.ts @@ -18,18 +18,20 @@ export class BaseService { ): Promise { try { return await fn(); - } catch (error) { + } catch (error: unknown) { console.error(`[Service Error] ${method}:`, error); if (error instanceof ServiceError) throw error; + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; throw new ServiceError(StatusCodes.INTERNAL_SERVER_ERROR, { message: 'An unexpected error occurred.', trace: { method, context: { - error: error.message, - stack: error.stack, + error: errorMessage, + stack: errorStack, }, }, }); diff --git a/src/services/candidate.service.ts b/src/services/candidate.service.ts index 1917637..b93c6ee 100644 --- a/src/services/candidate.service.ts +++ b/src/services/candidate.service.ts @@ -51,8 +51,10 @@ class CandidateService extends BaseService implements ICandidateService { query: ICandidateQuery, ): Promise => { return this.handleErrors(async () => { - const cacheKey = this.generateCacheKey(query.userId); - const cachedCandidate = await cacheUtil.get(cacheKey); + const cacheKey = query.userId + ? this.generateCacheKey(query.userId) + : undefined; + const cachedCandidate = cacheKey ? await cacheUtil.get(cacheKey) : null; if (cachedCandidate) { return cachedCandidate as ICandidate; @@ -88,7 +90,11 @@ class CandidateService extends BaseService implements ICandidateService { }; }> => { return this.handleErrors(async () => { - const payload = await candidateRepository.readAll(query, options); + const paginationOptions = options ?? { page: 1, limit: 10 }; + const payload = await candidateRepository.readAll( + query, + paginationOptions, + ); if (payload.total === 0) { throw new ServiceError(StatusCodes.NOT_FOUND, { message: 'No candidates found matching the provided query.', @@ -129,7 +135,7 @@ class CandidateService extends BaseService implements ICandidateService { await candidateRepository.update(userId, data); - const cacheKey = this.generateCacheKey(candidate.userId); + const cacheKey = this.generateCacheKey(userId); await cacheUtil.delete(cacheKey); diff --git a/src/services/employer.service.ts b/src/services/employer.service.ts index 90a960a..d05b641 100644 --- a/src/services/employer.service.ts +++ b/src/services/employer.service.ts @@ -46,8 +46,10 @@ class EmployerService extends BaseService implements IEmployerService { */ public async findOne(query: IEmployerQuery): Promise { return this.handleErrors(async () => { - const cacheKey = this.generateCacheKey(query.userId); - const cachedEmployer = await cacheUtil.get(cacheKey); + const cacheKey = query.userId + ? this.generateCacheKey(query.userId) + : undefined; + const cachedEmployer = cacheKey ? await cacheUtil.get(cacheKey) : null; if (cachedEmployer) { return cachedEmployer as IEmployer; @@ -62,7 +64,9 @@ class EmployerService extends BaseService implements IEmployerService { }); } - await cacheUtil.set(cacheKey, employer, 3600); + if (cacheKey) { + await cacheUtil.set(cacheKey, employer, 3600); + } return employer; }, this.findOne.name); } @@ -86,7 +90,11 @@ class EmployerService extends BaseService implements IEmployerService { }; }> { return this.handleErrors(async () => { - const payload = await employerRepository.readAll(query, options); + const paginationOptions = options ?? { page: 1, limit: 10 }; + const payload = await employerRepository.readAll( + query, + paginationOptions, + ); if (payload.employers.length === 0) { throw new ServiceError(StatusCodes.NOT_FOUND, { message: 'No employers found matching the provided criteria.', @@ -128,7 +136,7 @@ class EmployerService extends BaseService implements IEmployerService { await employerRepository.update(userId, data); - const cacheKey = this.generateCacheKey(employer.userId); + const cacheKey = this.generateCacheKey(userId); await cacheUtil.delete(cacheKey); return { message: 'Employer account updated successfully.' }; diff --git a/src/services/index.ts b/src/services/index.ts index 1a15200..bbf9495 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -2,8 +2,6 @@ export { adminService } from './admin.service'; export { applicationService } from './application.service'; -export { authenticationService } from './authentication.service'; - export { BaseService } from './base.service'; export { candidateService } from './candidate.service'; diff --git a/src/services/job.service.ts b/src/services/job.service.ts index 4bed5de..4c70b1b 100644 --- a/src/services/job.service.ts +++ b/src/services/job.service.ts @@ -137,8 +137,10 @@ class JobService extends BaseService implements IJobService { }); } - const cacheKey = this.generateCacheKey(newJob.id); - await cacheUtil.set(cacheKey, newJob, this.CACHE_TTL.SINGLE_JOB); + if (newJob.id) { + const cacheKey = this.generateCacheKey(newJob.id); + await cacheUtil.set(cacheKey, newJob, this.CACHE_TTL.SINGLE_JOB); + } return { message: 'Job created successfully.', job: newJob }; }, this.createJob.name); @@ -224,11 +226,13 @@ class JobService extends BaseService implements IJobService { await Promise.all( payload.jobs.map(job => - cacheUtil.set( - this.generateCacheKey(job.id), - job, - this.CACHE_TTL.SINGLE_JOB, - ), + job.id + ? cacheUtil.set( + this.generateCacheKey(job.id), + job, + this.CACHE_TTL.SINGLE_JOB, + ) + : Promise.resolve(), ), ); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 39d4251..8fe1dad 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -2,13 +2,12 @@ import { ServiceError } from '@work-whiz/errors'; import { IUserService, IUser } from '@work-whiz/interfaces'; import { redis } from '@work-whiz/libs'; import { userRepository } from '@work-whiz/repositories'; -import { passwordUtil } from '@work-whiz/utils'; import { StatusCodes } from 'http-status-codes'; import { BaseService } from './base.service'; /** - * User service handling user-related operations like contact update, - * password update, and account deletion. + * User service handling app profile operations like contact updates + * and account deletion. Authentication concerns are handled by Better Auth. */ class Userservice extends BaseService implements IUserService { private static instance: Userservice; @@ -84,68 +83,6 @@ class Userservice extends BaseService implements IUserService { await userRepository.update(id, payload); }, this.updateContact.name); - /** - * Updates a user's password after validating the current password. - * @param id - User ID - * @param passwords - Object containing current and new password - */ - public updatePassword = async ( - id: string, - passwords: { currentPassword: string; newPassword: string }, - ): Promise => - this.handleErrors(async () => { - const user = await userRepository.read({ id }); - if (!user) { - throw new ServiceError(StatusCodes.NOT_FOUND, { - message: 'The requested user could not be found in the system.', - trace: { - method: this.updatePassword.name, - context: { userId: id }, - }, - }); - } - - const isCurrentValid = await passwordUtil.compareSync( - user.role, - passwords.currentPassword, - user.password, - ); - - if (!isCurrentValid) { - throw new ServiceError(StatusCodes.UNAUTHORIZED, { - message: 'The current password is incorrect.', - trace: { - method: this.updatePassword.name, - context: { userId: id }, - }, - }); - } - - const isSamePassword = await passwordUtil.compareSync( - user.role, - passwords.newPassword, - user.password, - ); - - if (isSamePassword) { - throw new ServiceError(StatusCodes.BAD_REQUEST, { - message: - 'The new password cannot be the same as the current password.', - trace: { - method: this.updatePassword.name, - context: { userId: id }, - }, - }); - } - - const hashedPassword = await passwordUtil.hashSync( - user.role, - passwords.newPassword, - ); - - await userRepository.update(id, { password: hashedPassword }); - }, this.updatePassword.name); - /** * Deletes a user account and removes their refresh token from Redis. * @param id - User ID diff --git a/src/templates/application/application-created.ejs b/src/templates/application/application-created.ejs index 9a9566f..73f6c78 100644 --- a/src/templates/application/application-created.ejs +++ b/src/templates/application/application-created.ejs @@ -8,7 +8,7 @@

Thank you for your application!

- Dear <%= application?.candidate?.firstName || 'Candidate' %>, + Dear <%= application?.candidate?.name || 'Candidate' %>,

Your application for the job <%= application?.jobTitle || 'N/A' %> has been submitted successfully. @@ -18,4 +18,4 @@

- \ No newline at end of file + diff --git a/src/templates/application/application-rejected.ejs b/src/templates/application/application-rejected.ejs index 0802563..f7a7484 100644 --- a/src/templates/application/application-rejected.ejs +++ b/src/templates/application/application-rejected.ejs @@ -8,7 +8,7 @@

Application Status: Rejected

- Dear <%= application?.candidateName || 'Candidate' %>, + Dear <%= application?.candidate?.name || 'Candidate' %>,

We regret to inform you that your application for the job <%= application?.jobTitle || 'N/A' %> has been rejected. @@ -18,4 +18,4 @@

- \ No newline at end of file + diff --git a/src/templates/application/application-updated.ejs b/src/templates/application/application-updated.ejs index 6a06783..c0a95ca 100644 --- a/src/templates/application/application-updated.ejs +++ b/src/templates/application/application-updated.ejs @@ -23,7 +23,7 @@ " >

Your application has been updated

-

Dear <%= application?.candidateName || 'Candidate' %>,

+

Dear <%= application?.candidate?.name || 'Candidate' %>,

Your application for the job <%= application?.jobTitle || 'N/A' %> diff --git a/src/templates/authentication.template.ts b/src/templates/authentication.template.ts index 360977a..69e1b80 100644 --- a/src/templates/authentication.template.ts +++ b/src/templates/authentication.template.ts @@ -1,5 +1,5 @@ import { config } from '@work-whiz/configs/config'; -import { notificationLib } from '@work-whiz/libs'; +import { notificationLib } from '../libs/notification.lib'; class AuthenticationTemplate { private static instance: AuthenticationTemplate; @@ -34,6 +34,43 @@ class AuthenticationTemplate { }); }; + public emailVerification = (url: string, username: string): string => { + return notificationLib.getMailgenInstance('salted').generate({ + body: { + title: `Verify your email, ${username}.`, + intro: `Please verify your ${config?.notification?.mailgen?.product?.name} account email address.`, + action: { + instructions: 'Click the button below to verify your email:', + button: { + text: 'Verify Email', + color: '#28214c', + link: url, + }, + }, + outro: `If you did not create this account, please ignore this email. Having troubles? Copy this link into your browser instead: ${url}`, + }, + }); + }; + + public welcome = (username: string): string => { + return notificationLib.getMailgenInstance('salted').generate({ + body: { + title: `Welcome, ${username}.`, + intro: `Your ${config?.notification?.mailgen?.product?.name} account is ready.`, + }, + }); + }; + + public passwordUpdateNotice = (username: string): string => { + return notificationLib.getMailgenInstance('salted').generate({ + body: { + title: `Hi, ${username}.`, + intro: `Your ${config?.notification?.mailgen?.product?.name} password has been successfully updated.`, + outro: `If you did not make this change, please contact our support team at support@${config?.notification?.mailgen?.product?.link}.`, + }, + }); + }; + passwordSetup = (url: string, username: string): string => { return notificationLib.getMailgenInstance('salted').generate({ body: { diff --git a/src/utils/index.ts b/src/utils/index.ts index 48f4b5c..16f58cd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,8 +4,6 @@ export { createFrontendUrl } from './frontend.util'; export { getUserRole } from './getUserRole.util'; -export { jwtUtil } from './jwt.util'; - export { getLocationFromIp } from './location.util'; export { logger } from './logger'; @@ -14,6 +12,4 @@ export { notificationUtil } from './notification.util'; export { Pagination } from './pagination.util'; -export { passwordUtil } from './password.util'; - export { responseUtil } from './response.util'; diff --git a/src/utils/jwt.util.ts b/src/utils/jwt.util.ts deleted file mode 100644 index 2038e49..0000000 --- a/src/utils/jwt.util.ts +++ /dev/null @@ -1,97 +0,0 @@ -import * as jwt from 'jsonwebtoken'; - -import { config } from '@work-whiz/configs/config'; -import { IJwtToken, IDecodedJwtToken } from '@work-whiz/interfaces'; -import { JwtType } from '@work-whiz/types'; - -const signAsync = ( - payload: string | object | Buffer, - secretOrPrivateKey: jwt.Secret, - options?: jwt.SignOptions, -): Promise => { - return new Promise((resolve, reject) => { - jwt.sign(payload, secretOrPrivateKey, options || {}, (err, token) => { - if (err || !token) { - return reject(err || new Error('Token generation failed')); - } - resolve(token); - }); - }); -}; - -const verifyAsync = ( - token: string, - secretOrPublicKey: jwt.Secret, - options?: jwt.VerifyOptions, -): Promise => { - return new Promise((resolve, reject) => { - jwt.verify(token, secretOrPublicKey, options || {}, (err, decoded) => { - if (err || !decoded) { - return reject(err || new Error('Token verification failed')); - } - resolve(decoded as T); - }); - }); -}; - -export default class JwtUtil { - private static instance: JwtUtil; - private readonly expirationTimes: Record; - - private constructor() { - this.expirationTimes = { - account_verification: 10 * 60, // 10 minutes in seconds - access: 15 * 60, // 15 minutes in seconds - refresh: 7 * 24 * 60 * 60, // 7 days in seconds - password_setup: 30 * 60, // 15 minutes in seconds - password_reset: 30 * 60, // 15 minutes in seconds - }; - } - - public static getInstance(): JwtUtil { - if (!JwtUtil.instance) { - JwtUtil.instance = new JwtUtil(); - } - return JwtUtil.instance; - } - - private getJwtKey(role: string, secretKeyType: JwtType): string { - const jwtKey = config?.authentication?.jwt?.[role]?.[secretKeyType]; - if (!jwtKey) { - throw new Error( - `JWT secret key not found for role: ${role}, type: ${secretKeyType}`, - ); - } - return jwtKey; - } - - private getTokenExpiration(jwtType: JwtType): number { - return this.expirationTimes[jwtType] ?? 0; - } - - public async generate(payload: IJwtToken): Promise { - const jwtKey = this.getJwtKey(payload.role, payload.type); - const expiresIn = this.getTokenExpiration(payload.type); - - if (!expiresIn) { - throw new Error(`Invalid JWT token type: ${payload.type}`); - } - - return signAsync(payload, jwtKey, { expiresIn }); - } - - public async verify(payload: { - role: string; - token: string; - type: JwtType; - }): Promise { - const jwtKey = this.getJwtKey(payload.role, payload.type); - return verifyAsync(payload.token, jwtKey); - } - - public decode(token: string): IDecodedJwtToken | null { - return jwt.decode(token) as IDecodedJwtToken | null; - } -} - -export const jwtUtil = JwtUtil.getInstance(); diff --git a/src/utils/location.util.ts b/src/utils/location.util.ts index 8107ebb..dbd729b 100644 --- a/src/utils/location.util.ts +++ b/src/utils/location.util.ts @@ -62,5 +62,7 @@ export const getLocationFromIp = async ( } throw new Error('Unable to fetch location data'); } + + throw error; } }; diff --git a/src/utils/notification.util.ts b/src/utils/notification.util.ts index b91d9ec..b1c5abf 100644 --- a/src/utils/notification.util.ts +++ b/src/utils/notification.util.ts @@ -2,7 +2,7 @@ * Notification utility class for sending emails. */ import { config } from '@work-whiz/configs/config'; -import { notificationLib } from '@work-whiz/libs'; +import { notificationLib } from '../libs/notification.lib'; import { logger } from './logger'; export default class NotificationUtil { @@ -39,7 +39,7 @@ export default class NotificationUtil { await new Promise((resolve, reject) => { notificationLib .createNodemailerTransport() - .sendMail(mail_options, (error: { message: string }) => { + .sendMail(mail_options, (error: Error | null) => { if (error) { logger.error( `Error sending email to ${receiver} with subject "${subject}":`, diff --git a/src/utils/password.util.ts b/src/utils/password.util.ts deleted file mode 100644 index b653fcb..0000000 --- a/src/utils/password.util.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as argon2 from 'argon2'; -import axios from 'axios'; -import { config } from '@work-whiz/configs/config'; - -class PasswordUtil { - private static instance: PasswordUtil; - - private constructor() { - // - } - - public static getInstance(): PasswordUtil { - if (!PasswordUtil.instance) { - PasswordUtil.instance = new PasswordUtil(); - } - return PasswordUtil.instance; - } - - public async hashSync(role: string, password: string): Promise { - try { - const pepper = config?.authentication?.argon[role]?.pepper; - if (!pepper) { - throw new Error(`Missing secret (pepper).`); - } - const secret = Buffer.from(pepper); - - return await argon2.hash(password, { secret }); - } catch (error) { - throw new Error(`Password hashing failed: ${error.message || error}`); - } - } - - public async compareSync( - role: string, - plain: string, - hashed: string, - ): Promise { - try { - const secret = Buffer.from(config?.authentication?.argon[role]?.pepper); - if (!secret) { - throw new Error(`Missing secret (pepper).`); - } - - return await argon2.verify(hashed, plain, { secret }); - } catch (error) { - throw new Error(`Password validation failed: ${error.message || error}`); - } - } - - public async checkLeakedPassword( - role: string, - password: string, - ): Promise { - try { - const hashed_password = await this.hashSync(role, password); - - const prefix = hashed_password.substring(0, 5); - const suffix = hashed_password.substring(5); - - const response = await axios.get( - `https://api.pwnedpasswords.com/range/${prefix}`, - ); - - const is_leaked = response.data - .split('\n') - .some((line: string) => line.startsWith(suffix)); - - return is_leaked; - } catch (error) { - console.error('Error checking password:', error); - return false; - } - } -} - -export const passwordUtil = PasswordUtil.getInstance(); diff --git a/src/validators/candidate-register.validator.ts b/src/validators/candidate-register.validator.ts index 08416e8..25d8759 100644 --- a/src/validators/candidate-register.validator.ts +++ b/src/validators/candidate-register.validator.ts @@ -6,10 +6,11 @@ import { candidateRegisterSchema } from './schemas/candidate-register.schema'; export const candidateRegisterValidator = (data: { - firstName: string; - lastName: string; + name: string; email: string; phone: string; + password: string; + title: string; }) => { const { error } = candidateRegisterSchema.validate(data, { abortEarly: false, diff --git a/src/validators/schemas/admin-register.schema.ts b/src/validators/schemas/admin-register.schema.ts index 24a56fb..fa60330 100644 --- a/src/validators/schemas/admin-register.schema.ts +++ b/src/validators/schemas/admin-register.schema.ts @@ -1,22 +1,3 @@ -import Joi from 'joi'; import { baseRegisterSchema } from './base-register.schema'; -export const adminRegisterSchema = baseRegisterSchema.keys({ - firstName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .required() - .messages({ - 'string.base': 'First name should be a string', - 'string.empty': 'First name cannot be empty', - 'string.required': 'First name is required', - 'string.pattern.base': 'First name can only contain letters', - }), - lastName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .required() - .messages({ - 'string.base': 'Last name should be a string', - 'string.empty': 'Last name cannot be empty', - 'string.pattern.base': 'Last name can only contain letters', - }), -}); +export const adminRegisterSchema = baseRegisterSchema; diff --git a/src/validators/schemas/admin.schema.ts b/src/validators/schemas/admin.schema.ts index d5a4456..2ad2252 100644 --- a/src/validators/schemas/admin.schema.ts +++ b/src/validators/schemas/admin.schema.ts @@ -1,20 +1,8 @@ import Joi from 'joi'; export const adminSchema = Joi.object({ - firstName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .optional() - .messages({ - 'string.base': 'First name should be a string', - 'string.empty': 'First name cannot be empty', - 'string.pattern.base': 'First name can only contain letters', - }), - lastName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .optional() - .messages({ - 'string.base': 'Last name should be a string', - 'string.empty': 'Last name cannot be empty', - 'string.pattern.base': 'Last name can only contain letters', - }), + permissions: Joi.array().items(Joi.string()).optional().messages({ + 'array.base': 'Permissions should be an array of strings', + 'string.base': 'Each permission should be a string', + }), }); diff --git a/src/validators/schemas/base-register.schema.ts b/src/validators/schemas/base-register.schema.ts index 76d6392..ee283f0 100644 --- a/src/validators/schemas/base-register.schema.ts +++ b/src/validators/schemas/base-register.schema.ts @@ -4,6 +4,12 @@ import { phoneSchema } from './phone.schema'; import { passwordSchema } from './password.schema'; export const baseRegisterSchema = Joi.object({ + name: Joi.string().required().messages({ + 'string.base': 'Name should be a string', + 'string.empty': 'Name cannot be empty', + 'any.required': 'Name is required', + 'string.required': 'Name is required', + }), email: emailSchema, phone: phoneSchema, password: passwordSchema, diff --git a/src/validators/schemas/candidate-register.schema.ts b/src/validators/schemas/candidate-register.schema.ts index cf14d41..6706d39 100644 --- a/src/validators/schemas/candidate-register.schema.ts +++ b/src/validators/schemas/candidate-register.schema.ts @@ -2,21 +2,9 @@ import Joi from 'joi'; import { baseRegisterSchema } from './base-register.schema'; export const candidateRegisterSchema = baseRegisterSchema.keys({ - firstName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .required() - .messages({ - 'string.base': 'First name should be a string', - 'string.empty': 'First name cannot be empty', - 'string.required': 'First name is required', - 'string.pattern.base': 'First name can only contain letters', - }), - lastName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .required() - .messages({ - 'string.base': 'Last name should be a string', - 'string.empty': 'Last name cannot be empty', - 'string.pattern.base': 'Last name can only contain letters', - }), + title: Joi.string().required().messages({ + 'string.base': 'Title should be a string', + 'string.empty': 'Title cannot be empty', + 'string.required': 'Title is required', + }), }); diff --git a/src/validators/schemas/candidate.schema.ts b/src/validators/schemas/candidate.schema.ts index 1b3a7da..7c3d398 100644 --- a/src/validators/schemas/candidate.schema.ts +++ b/src/validators/schemas/candidate.schema.ts @@ -1,22 +1,6 @@ import Joi from 'joi'; export const candidateSchema = Joi.object({ - firstName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .optional() - .messages({ - 'string.base': 'First name should be a string', - 'string.empty': 'First name cannot be empty', - 'string.pattern.base': 'First name can only contain letters', - }), - lastName: Joi.string() - .pattern(/^[A-Za-z]+$/) - .optional() - .messages({ - 'string.base': 'Last name should be a string', - 'string.empty': 'Last name cannot be empty', - 'string.pattern.base': 'Last name can only contain letters', - }), title: Joi.string().optional().messages({ 'string.base': 'Title should be a string', 'string.empty': 'Title cannot be empty', diff --git a/src/validators/schemas/employer-register.schema.ts b/src/validators/schemas/employer-register.schema.ts index dc4c8be..bbd2cf3 100644 --- a/src/validators/schemas/employer-register.schema.ts +++ b/src/validators/schemas/employer-register.schema.ts @@ -2,11 +2,6 @@ import Joi from 'joi'; import { baseRegisterSchema } from './base-register.schema'; export const employerRegisterSchema = baseRegisterSchema.keys({ - name: Joi.string().required().messages({ - 'string.base': 'Company name should be a string', - 'string.empty': 'Company name cannot be empty', - 'string.required': 'Company name is required', - }), industry: Joi.string().required().messages({ 'string.base': 'Industry should be a string', 'string.empty': 'Industry cannot be empty', diff --git a/src/validators/schemas/employer.schema.ts b/src/validators/schemas/employer.schema.ts index 5df1a58..d0dca6f 100644 --- a/src/validators/schemas/employer.schema.ts +++ b/src/validators/schemas/employer.schema.ts @@ -1,10 +1,6 @@ import Joi from 'joi'; export const employerSchema = Joi.object({ - name: Joi.string().optional().messages({ - 'string.base': 'Company name should be a string', - 'string.empty': 'Company name cannot be empty', - }), industry: Joi.string().optional().messages({ 'string.base': 'Industry should be a string', 'string.empty': 'Industry cannot be empty', diff --git a/tsconfig.json b/tsconfig.json index cbf8b50..777fc16 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "sourceMap": true, "declaration": false, "moduleResolution": "node", + "ignoreDeprecations": "6.0", "emitDecoratorMetadata": true, "experimentalDecorators": true, "importHelpers": true,