diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 152e5552..e7a09fd2 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,5 +1,6 @@ const express = require("express"); -const { registerUser, loginUser, verifyEmail, resendVerificationEmail, getUserProfile, updateUserProfile, changePassword, deleteUserAccount, refreshToken, logoutUser } = require("../controllers/authController"); +const { verifyEmail, resendVerificationEmail, getUserProfile, updateUserProfile, changePassword, deleteUserAccount, refreshToken, logoutUser } = require("../controllers/authController"); +const { authController: hexAuthController } = require("../src/auth/index"); const { protect } = require("../middlewares/authMiddleware"); const { upload, validateImageUpload } = require("../middlewares/uploadMiddleware"); const { validateUserLogin, validateUserSignup, validateRefreshToken, validateResendEmail } = require("../Input_validators/ValidateAuth"); @@ -19,8 +20,8 @@ const { // that authenticate off the ambient refreshToken cookie. // Auth Routes -router.post("/register", authLimiter, validateUserSignup, registerUser); -router.post("/login", loginLimiter, validateUserLogin, loginUser); +router.post("/register", authLimiter, validateUserSignup, hexAuthController.registerUser); +router.post("/login", loginLimiter, validateUserLogin, hexAuthController.loginUser); // Frontend should GET this once on app load to prime the XSRF-TOKEN cookie // before it ever needs to call /refresh or /logout. diff --git a/backend/src/auth/application/ports/IEmailService.js b/backend/src/auth/application/ports/IEmailService.js new file mode 100644 index 00000000..3658518a --- /dev/null +++ b/backend/src/auth/application/ports/IEmailService.js @@ -0,0 +1,6 @@ +class IEmailService { + async sendVerificationEmail(to, verificationUrl) { + throw new Error("Method not implemented."); + } +} +module.exports = IEmailService; diff --git a/backend/src/auth/application/ports/IUserRepository.js b/backend/src/auth/application/ports/IUserRepository.js new file mode 100644 index 00000000..2f172859 --- /dev/null +++ b/backend/src/auth/application/ports/IUserRepository.js @@ -0,0 +1,28 @@ +/** + * Interface representing the outbound port for User persistence. + * Note: Since JS does not have interfaces, this class serves as documentation + * and throws "Not Implemented" errors if methods are not overridden. + */ +class IUserRepository { + async findByEmail(email) { + throw new Error("Method not implemented."); + } + + async findByPrepPilotId(prepPilotId) { + throw new Error("Method not implemented."); + } + + async findById(id) { + throw new Error("Method not implemented."); + } + + async findByVerificationToken(token) { + throw new Error("Method not implemented."); + } + + async save(userEntity) { + throw new Error("Method not implemented."); + } +} + +module.exports = IUserRepository; diff --git a/backend/src/auth/application/useCases/LoginUser.js b/backend/src/auth/application/useCases/LoginUser.js new file mode 100644 index 00000000..b3d6d685 --- /dev/null +++ b/backend/src/auth/application/useCases/LoginUser.js @@ -0,0 +1,44 @@ +const bcrypt = require("bcryptjs"); +const { ValidationError } = require("../../../shared/domain/BaseError"); + +class LoginUser { + constructor(userRepository, tokenService) { + this.userRepository = userRepository; + this.tokenService = tokenService; + } + + async execute({ email, password }) { + if (!email || !password) { + throw new ValidationError("Email and password are required."); + } + + const cleanEmail = email.trim().toLowerCase(); + const user = await this.userRepository.findByEmail(cleanEmail); + + if (!user) { + return { success: false, reason: "invalid_credentials" }; + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return { success: false, reason: "invalid_credentials" }; + } + + if (!user.isEmailVerified) { + return { success: false, reason: "email_not_verified" }; + } + + // Generate tokens + const accessToken = this.tokenService.generateAccessToken(user.id, user.tokenVersion); + const refreshToken = this.tokenService.generateRefreshToken(user.id); + + user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); + user.refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + await this.userRepository.save(user); + + return { success: true, user, accessToken, refreshToken }; + } +} + +module.exports = LoginUser; diff --git a/backend/src/auth/application/useCases/LoginUser.unit.test.js b/backend/src/auth/application/useCases/LoginUser.unit.test.js new file mode 100644 index 00000000..7b2ab0b8 --- /dev/null +++ b/backend/src/auth/application/useCases/LoginUser.unit.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +const LoginUser = require('./LoginUser'); +const { ValidationError } = require('../../../shared/domain/BaseError'); +const bcrypt = require('bcryptjs'); + +describe('LoginUser Use Case', () => { + let userRepositoryMock; + let tokenServiceMock; + let loginUser; + + beforeEach(() => { + userRepositoryMock = { + findByEmail: vi.fn(), + save: vi.fn(), + }; + tokenServiceMock = { + generateAccessToken: vi.fn().mockReturnValue('access-token'), + generateRefreshToken: vi.fn().mockReturnValue('refresh-token'), + }; + loginUser = new LoginUser(userRepositoryMock, tokenServiceMock); + }); + + it('should throw ValidationError if email or password missing', async () => { + await expect(loginUser.execute({ email: 'a@b.com' })).rejects.toThrow(ValidationError); + await expect(loginUser.execute({ password: 'pwd' })).rejects.toThrow(ValidationError); + }); + + it('should return invalid_credentials if user not found', async () => { + userRepositoryMock.findByEmail.mockResolvedValue(null); + const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' }); + expect(res).toEqual({ success: false, reason: 'invalid_credentials' }); + }); + + it('should return invalid_credentials if password mismatch', async () => { + userRepositoryMock.findByEmail.mockResolvedValue({ password: 'hashed' }); + vi.spyOn(bcrypt, 'compare').mockResolvedValue(false); + const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' }); + expect(res).toEqual({ success: false, reason: 'invalid_credentials' }); + }); + + it('should return email_not_verified if not verified', async () => { + userRepositoryMock.findByEmail.mockResolvedValue({ password: 'hashed', isEmailVerified: false }); + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true); + const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' }); + expect(res).toEqual({ success: false, reason: 'email_not_verified' }); + }); + + it('should return success and tokens if verified', async () => { + const user = { id: '123', password: 'hashed', isEmailVerified: true, tokenVersion: 1 }; + userRepositoryMock.findByEmail.mockResolvedValue(user); + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true); + vi.spyOn(bcrypt, 'hash').mockResolvedValue('refresh-hash'); + + const res = await loginUser.execute({ email: 'a@b.com', password: 'pwd' }); + expect(res.success).toBe(true); + expect(res.accessToken).toBe('access-token'); + expect(res.refreshToken).toBe('refresh-token'); + expect(userRepositoryMock.save).toHaveBeenCalledWith(expect.objectContaining({ + refreshTokenHash: 'refresh-hash' + })); + }); +}); diff --git a/backend/src/auth/application/useCases/RegisterUser.js b/backend/src/auth/application/useCases/RegisterUser.js new file mode 100644 index 00000000..cf9821f9 --- /dev/null +++ b/backend/src/auth/application/useCases/RegisterUser.js @@ -0,0 +1,88 @@ +const crypto = require("crypto"); +const bcrypt = require("bcryptjs"); +const UserEntity = require("../../domain/entities/UserEntity"); +const { ConflictError, ValidationError } = require("../../../shared/domain/BaseError"); + +class RegisterUser { + constructor(userRepository, emailService) { + this.userRepository = userRepository; + this.emailService = emailService; + this.PASSWORD_SALT_ROUNDS = 10; // Ideally configurable via env + } + + async execute({ name, email, password, frontendUrl }) { + const cleanName = name.trim(); + const cleanEmail = email.trim().toLowerCase(); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/; + if (!emailRegex.test(cleanEmail)) { + throw new ValidationError("Please enter a valid email address."); + } + + // Check if user exists + let userExists = await this.userRepository.findByEmail(cleanEmail); + + if (userExists) { + // Existing user handling + if (!userExists.isEmailVerified) { + const rawToken = crypto.randomBytes(32).toString("hex"); + const hashedToken = crypto.createHash("sha256").update(rawToken).digest("hex"); + userExists.emailVerificationToken = hashedToken; + userExists.emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + await this.userRepository.save(userExists); + + // Outbox/version check to prevent delivery of superseded tokens + const latestUser = await this.userRepository.findByEmail(cleanEmail); + if (latestUser && latestUser.emailVerificationToken === hashedToken) { + const verificationUrl = `${frontendUrl}/verify-email?token=${rawToken}`; + await this.emailService.sendVerificationEmail(userExists.email, verificationUrl); + } + } + // Return true indicating process completed but email was already used (to avoid enumeration) + return { alreadyRegistered: true }; + } + + // New user creation + const hashedPassword = await bcrypt.hash(password, this.PASSWORD_SALT_ROUNDS); + + const nameParts = cleanName.split(/\s+/); + const firstName = nameParts[0] || ""; + const lastName = nameParts.slice(1).join(" ") || ""; + const defaultPrepPilotId = cleanEmail.split("@")[0] + Math.floor(1000 + Math.random() * 9000); + + const rawToken = crypto.randomBytes(32).toString("hex"); + const emailVerificationToken = crypto.createHash("sha256").update(rawToken).digest("hex"); + const emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const newUser = new UserEntity({ + name: cleanName, + email: cleanEmail, + password: hashedPassword, + firstName, + lastName, + prepPilotId: defaultPrepPilotId, + isEmailVerified: false, + emailVerificationToken, + emailVerificationExpires + }); + + try { + await this.userRepository.save(newUser); + } catch (error) { + if (error instanceof ConflictError && Object.keys(error.fields || {}).includes("email")) { + // If it fails due to a duplicate-email constraint race condition, + // fall back to the existing user response flow. + return { alreadyRegistered: true }; + } + throw error; + } + + const verificationUrl = `${frontendUrl}/verify-email?token=${rawToken}`; + await this.emailService.sendVerificationEmail(newUser.email, verificationUrl); + + return { alreadyRegistered: false }; + } +} + +module.exports = RegisterUser; diff --git a/backend/src/auth/application/useCases/RegisterUser.unit.test.js b/backend/src/auth/application/useCases/RegisterUser.unit.test.js new file mode 100644 index 00000000..18b593a5 --- /dev/null +++ b/backend/src/auth/application/useCases/RegisterUser.unit.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +const RegisterUser = require('./RegisterUser'); +const { ValidationError } = require('../../../shared/domain/BaseError'); + +describe('RegisterUser Use Case', () => { + let userRepositoryMock; + let emailServiceMock; + let registerUser; + + beforeEach(() => { + userRepositoryMock = { + findByEmail: vi.fn(), + save: vi.fn(), + }; + emailServiceMock = { + sendVerificationEmail: vi.fn(), + }; + registerUser = new RegisterUser(userRepositoryMock, emailServiceMock); + }); + + it('should throw ValidationError if email is invalid', async () => { + const req = { name: 'John', email: 'invalid-email', password: 'pwd', frontendUrl: 'http://test' }; + await expect(registerUser.execute(req)).rejects.toThrow(ValidationError); + }); + + it('should handle existing user without revealing it (alreadyRegistered: true)', async () => { + userRepositoryMock.findByEmail.mockResolvedValue({ isEmailVerified: true }); + const req = { name: 'John', email: 'john@example.com', password: 'pwd', frontendUrl: 'http://test' }; + + const result = await registerUser.execute(req); + + expect(result.alreadyRegistered).toBe(true); + expect(userRepositoryMock.save).not.toHaveBeenCalled(); + }); + + it('should resend verification if existing user is unverified', async () => { + const existingUser = { email: 'john@example.com', isEmailVerified: false }; + userRepositoryMock.findByEmail.mockResolvedValue(existingUser); + userRepositoryMock.save.mockResolvedValue(existingUser); + const req = { name: 'John', email: 'john@example.com', password: 'pwd', frontendUrl: 'http://test' }; + + const result = await registerUser.execute(req); + + expect(result.alreadyRegistered).toBe(true); + expect(userRepositoryMock.save).toHaveBeenCalled(); + expect(emailServiceMock.sendVerificationEmail).toHaveBeenCalled(); + }); + + it('should create new user and send email (alreadyRegistered: false)', async () => { + userRepositoryMock.findByEmail.mockResolvedValue(null); + userRepositoryMock.save.mockResolvedValue(); + const req = { name: 'John', email: 'john@example.com', password: 'pwd', frontendUrl: 'http://test' }; + + const result = await registerUser.execute(req); + + expect(result.alreadyRegistered).toBe(false); + expect(userRepositoryMock.save).toHaveBeenCalled(); + expect(emailServiceMock.sendVerificationEmail).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/auth/domain/entities/UserEntity.js b/backend/src/auth/domain/entities/UserEntity.js new file mode 100644 index 00000000..662514eb --- /dev/null +++ b/backend/src/auth/domain/entities/UserEntity.js @@ -0,0 +1,113 @@ +class UserEntity { + constructor({ + id, + name, + email, + password, + profileImageUrl = null, + refreshTokenHash = null, + refreshTokenExpiresAt = null, + tokenVersion = 0, + firstName = "", + lastName = "", + bio = "", + country = "", + educationDetails = {}, + profileDetails = {}, + visibility = "Public", + prepPilotId, + platformPreferences = {}, + unlockedAchievements = [], + currentStreak = 0, + longestStreak = 0, + lastPracticeDate = null, + isEmailVerified = false, + emailVerificationToken = null, + emailVerificationExpires = null, + createdAt, + updatedAt, + }) { + this.id = id; + this.name = name; + this.email = email; + this.password = password; + this.profileImageUrl = profileImageUrl; + this.refreshTokenHash = refreshTokenHash; + this.refreshTokenExpiresAt = refreshTokenExpiresAt; + this.tokenVersion = tokenVersion; + this.firstName = firstName; + this.lastName = lastName; + this.bio = bio; + this.country = country; + this.educationDetails = { + school: "", + degree: "", + branch: "", + graduationYear: "", + ...educationDetails, + }; + this.profileDetails = { + aboutMe: "", + education: "", + achievements: "", + workExperience: "", + ...profileDetails, + socials: { + github: "", + linkedin: "", + twitter: "", + portfolio: "", + ...(profileDetails?.socials || {}) + } + }; + this.visibility = visibility; + this.prepPilotId = prepPilotId; + this.platformPreferences = { + theme: "light", + notificationsEnabled: true, + ...platformPreferences, + }; + this.unlockedAchievements = unlockedAchievements; + this.currentStreak = currentStreak; + this.longestStreak = longestStreak; + this.lastPracticeDate = lastPracticeDate; + this.isEmailVerified = isEmailVerified; + this.emailVerificationToken = emailVerificationToken; + this.emailVerificationExpires = emailVerificationExpires; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + verifyEmail() { + this.isEmailVerified = true; + this.emailVerificationToken = null; + this.emailVerificationExpires = null; + } + + resetStreakIfMissed() { + if (this.lastPracticeDate && this.currentStreak > 0) { + const now = new Date(); + const d1 = new Date(this.lastPracticeDate); + const utc1 = Date.UTC(d1.getUTCFullYear(), d1.getUTCMonth(), d1.getUTCDate()); + const utc2 = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const diffDays = Math.floor((utc2 - utc1) / (1000 * 60 * 60 * 24)); + if (diffDays > 1) { + this.currentStreak = 0; + } + } + } + + // Prepare safe format to return to users + toPublicDTO() { + const dto = { ...this }; + delete dto.password; + delete dto.refreshTokenHash; + delete dto.refreshTokenExpiresAt; + delete dto.emailVerificationToken; + delete dto.emailVerificationExpires; + delete dto.tokenVersion; + return dto; + } +} + +module.exports = UserEntity; diff --git a/backend/src/auth/index.js b/backend/src/auth/index.js new file mode 100644 index 00000000..f706fc0f --- /dev/null +++ b/backend/src/auth/index.js @@ -0,0 +1,29 @@ +const UserRepositoryMongoImpl = require("./infrastructure/database/UserRepositoryMongoImpl"); +const EmailServiceImpl = require("./infrastructure/services/EmailServiceImpl"); +const TokenServiceImpl = require("./infrastructure/services/TokenServiceImpl"); + +const RegisterUser = require("./application/useCases/RegisterUser"); +const LoginUser = require("./application/useCases/LoginUser"); + +const AuthController = require("./presentation/controllers/AuthController"); +const createAuthRoutes = require("./presentation/routes/authRoutesHex"); + +// Instantiate adapters +const userRepository = new UserRepositoryMongoImpl(); +const emailService = new EmailServiceImpl(); +const tokenService = new TokenServiceImpl(); + +// Instantiate Use Cases +const registerUserUseCase = new RegisterUser(userRepository, emailService); +const loginUserUseCase = new LoginUser(userRepository, tokenService); + +// Instantiate Controller +const authController = new AuthController(registerUserUseCase, loginUserUseCase); + +// Export router +const authRouter = createAuthRoutes(authController); + +module.exports = { + authRouter, + authController, // exported for testing if needed +}; diff --git a/backend/src/auth/infrastructure/database/UserRepositoryMongoImpl.js b/backend/src/auth/infrastructure/database/UserRepositoryMongoImpl.js new file mode 100644 index 00000000..36baed99 --- /dev/null +++ b/backend/src/auth/infrastructure/database/UserRepositoryMongoImpl.js @@ -0,0 +1,66 @@ +const IUserRepository = require("../../application/ports/IUserRepository"); +const UserModel = require("../../../../models/User"); +const UserEntity = require("../../domain/entities/UserEntity"); +const { NotFoundError, ConflictError } = require("../../../shared/domain/BaseError"); + +class UserRepositoryMongoImpl extends IUserRepository { + + _mapToEntity(doc) { + if (!doc) return null; + // Mapping Mongoose document to domain entity + const obj = doc.toObject(); + obj.id = obj._id.toString(); + return new UserEntity(obj); + } + + async findByEmail(email) { + const doc = await UserModel.findOne({ email }); + return this._mapToEntity(doc); + } + + async findByPrepPilotId(prepPilotId) { + const doc = await UserModel.findOne({ prepPilotId }); + return this._mapToEntity(doc); + } + + async findById(id) { + const doc = await UserModel.findById(id); + return this._mapToEntity(doc); + } + + async findByVerificationToken(token) { + const doc = await UserModel.findOne({ + emailVerificationToken: token, + emailVerificationExpires: { $gt: new Date() }, + }); + return this._mapToEntity(doc); + } + + async save(userEntity) { + const data = { ...userEntity }; + // Clean up entity-specific fields for mongo + delete data.id; + + let doc; + try { + if (userEntity.id) { + // Update existing + doc = await UserModel.findByIdAndUpdate(userEntity.id, data, { new: true }); + if (!doc) { + throw new NotFoundError(`User with ID ${userEntity.id} not found.`); + } + } else { + // Create new + doc = await UserModel.create(data); + } + } catch (error) { + if (error.code === 11000) { + throw new ConflictError("Duplicate entry detected.", error.keyValue || {}); + } + throw error; + } + return this._mapToEntity(doc); + } +} + +module.exports = UserRepositoryMongoImpl; diff --git a/backend/src/auth/infrastructure/services/EmailServiceImpl.js b/backend/src/auth/infrastructure/services/EmailServiceImpl.js new file mode 100644 index 00000000..5e1e9ceb --- /dev/null +++ b/backend/src/auth/infrastructure/services/EmailServiceImpl.js @@ -0,0 +1,10 @@ +const IEmailService = require("../../application/ports/IEmailService"); +const { sendVerificationEmail } = require("../../../../utils/sendEmail"); + +class EmailServiceImpl extends IEmailService { + async sendVerificationEmail(to, verificationUrl) { + return await sendVerificationEmail(to, verificationUrl); + } +} + +module.exports = EmailServiceImpl; diff --git a/backend/src/auth/infrastructure/services/TokenServiceImpl.js b/backend/src/auth/infrastructure/services/TokenServiceImpl.js new file mode 100644 index 00000000..b27c03e1 --- /dev/null +++ b/backend/src/auth/infrastructure/services/TokenServiceImpl.js @@ -0,0 +1,12 @@ +const generateAccessToken = require("../../../../utils/generateAccessToken"); +const generateRefreshToken = require("../../../../utils/generateRefreshToken"); + +class TokenServiceImpl { + generateAccessToken(userId, tokenVersion) { + return generateAccessToken(userId, tokenVersion); + } + generateRefreshToken(userId) { + return generateRefreshToken(userId); + } +} +module.exports = TokenServiceImpl; diff --git a/backend/src/auth/presentation/controllers/AuthController.js b/backend/src/auth/presentation/controllers/AuthController.js new file mode 100644 index 00000000..d3a5a557 --- /dev/null +++ b/backend/src/auth/presentation/controllers/AuthController.js @@ -0,0 +1,84 @@ +const { DomainError, ValidationError } = require("../../../shared/domain/BaseError"); + +class AuthController { + constructor(registerUserUseCase, loginUserUseCase) { + this.registerUserUseCase = registerUserUseCase; + this.loginUserUseCase = loginUserUseCase; + this.frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + } + + /** + * Map domain errors to HTTP responses + */ + _handleError(res, error) { + if (error instanceof ValidationError) { + return res.status(400).json({ success: false, message: error.message }); + } + if (error instanceof DomainError) { + return res.status(400).json({ success: false, message: error.message }); + } + + console.error("Internal Server Error:", error); + return res.status(500).json({ success: false, message: "Internal server error occurred" }); + } + + registerUser = async (req, res) => { + try { + const { name, email, password } = req.body; + + await this.registerUserUseCase.execute({ + name, + email, + password, + frontendUrl: this.frontendUrl + }); + + // Even if the user was already registered, we return the same success message + // to prevent email enumeration, as per previous business requirements. + return res.status(201).json({ + success: true, + message: "If this email is not already registered, your account has been created. Please check your email to verify your account before logging in.", + }); + } catch (error) { + this._handleError(res, error); + } + }; + + loginUser = async (req, res) => { + try { + const { email, password } = req.body; + + const result = await this.loginUserUseCase.execute({ email, password }); + + if (!result.success) { + if (result.reason === "email_not_verified") { + return res.status(403).json({ + success: false, + message: "Please verify your email before logging in. Check your inbox for the verification link.", + }); + } + // Invalid credentials or not found + return res.status(401).json({ success: false, message: "Invalid email or password provided." }); + } + + res.cookie("refreshToken", result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + path: "/api/auth" + }); + + res.json({ + success: true, + user: result.user.toPublicDTO(), + accessToken: result.accessToken, + }); + + } catch (error) { + this._handleError(res, error); + } + }; +} + +module.exports = AuthController; diff --git a/backend/src/auth/presentation/routes/authRoutesHex.js b/backend/src/auth/presentation/routes/authRoutesHex.js new file mode 100644 index 00000000..3e88c7c2 --- /dev/null +++ b/backend/src/auth/presentation/routes/authRoutesHex.js @@ -0,0 +1,14 @@ +const express = require("express"); +const { validateUserSignup, validateUserLogin } = require("../../../../Input_validators/ValidateAuth"); + +module.exports = function createAuthRoutes(authController) { + const router = express.Router(); + + router.post("/register", validateUserSignup, authController.registerUser); + router.post("/login", validateUserLogin, authController.loginUser); + + // Remaining routes (verify-email, etc.) can be migrated incrementally + // or mounted here as well, pointing to the old controller for now + + return router; +}; diff --git a/backend/src/shared/domain/BaseError.js b/backend/src/shared/domain/BaseError.js new file mode 100644 index 00000000..d0588aa5 --- /dev/null +++ b/backend/src/shared/domain/BaseError.js @@ -0,0 +1,34 @@ +class DomainError extends Error { + constructor(message, code = "DOMAIN_ERROR") { + super(message); + this.name = this.constructor.name; + this.code = code; + Error.captureStackTrace(this, this.constructor); + } +} + +class ValidationError extends DomainError { + constructor(message) { + super(message, "VALIDATION_ERROR"); + } +} + +class NotFoundError extends DomainError { + constructor(message) { + super(message, "NOT_FOUND_ERROR"); + } +} + +class ConflictError extends DomainError { + constructor(message, fields = {}) { + super(message, "CONFLICT_ERROR"); + this.fields = fields; + } +} + +module.exports = { + DomainError, + ValidationError, + NotFoundError, + ConflictError, +};