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 @@
-
-
-
-
+
+
+
+
@@ -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 @@