diff --git a/jest.config.js b/jest.config.js index 7c9de1e..6815210 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,10 +2,14 @@ module.exports = { testEnvironment: 'node', transform: { - // Disable ts-jest type-checking diagnostics so pre-existing type errors in - // unrelated source files do not block the test runner. - // Type safety is still enforced separately by `pnpm run build` (tsc). - '^.+\\.tsx?$': ['ts-jest', { diagnostics: false }], + ...tsJestTransformCfg, + '^.+\\.tsx?$': [ + 'ts-jest', + { + ...tsJestTransformCfg['^.+\\.tsx?$'][1], + isolatedModules: true, + }, + ], }, setupFiles: ['/tests/jest.setup.ts'], // Allow enough time for MongoMemoryServer to start (and download the binary diff --git a/src/config/env.ts b/src/config/env.ts index 5a23ae4..d0d4821 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -22,6 +22,7 @@ interface EnvConfig { REDIS_LOCK_TTL_MS: number; REDIS_LOCK_RETRY_COUNT: number; REDIS_LOCK_RETRY_DELAY_MS: number; + IDEMPOTENCY_TTL_SECONDS: number; PROFILE_PICTURE_MAX_SIZE_MB?: string; PROFILE_PICTURE_WIDTH?: string; PROFILE_PICTURE_HEIGHT?: string; @@ -57,6 +58,7 @@ const envSchema = z.object({ REDIS_LOCK_TTL_MS: z.coerce.number().int().min(1000).default(10000), REDIS_LOCK_RETRY_COUNT: z.coerce.number().int().min(0).default(3), REDIS_LOCK_RETRY_DELAY_MS: z.coerce.number().int().min(50).default(200), + IDEMPOTENCY_TTL_SECONDS: z.coerce.number().int().min(60).default(86400), PROFILE_PICTURE_MAX_SIZE_MB: z.string().optional(), PROFILE_PICTURE_WIDTH: z.string().optional(), PROFILE_PICTURE_HEIGHT: z.string().optional(), diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index f1e7e14..1a99a30 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -1,10 +1,12 @@ import { Request, Response, NextFunction } from 'express'; import { StatusCodes } from 'http-status-codes'; import User from '../models/User'; +import { userService } from '../services/userService'; import AppError from '../utils/AppError'; import asyncHandler from '../utils/asyncHandler'; import { sendSuccess } from '../utils/responseWrapper'; import type { AuthenticatedRequest } from '../middlewares/authMiddleware'; +import { UserRole, UserStatus } from '../interfaces/IUser'; class UserController { /** @@ -49,6 +51,191 @@ class UserController { ); }, ); + + /** + * GET /api/v1/users/:id + * + * Retrieve a single user by ID. + * Protected — requires authentication. + */ + public getUserById = asyncHandler( + async (req: Request, res: Response, _next: NextFunction): Promise => { + const { id } = req.params; + const user = await userService.getUserById(id); + + res.status(StatusCodes.OK).json({ + status: 'success', + data: { user }, + }); + }, + ); + + /** + * PUT /api/v1/users/:id + * + * Update user profile fields. + * Protected — requires authentication and admin role. + */ + public updateUser = asyncHandler( + async (req: Request, res: Response, _next: NextFunction): Promise => { + const { id } = req.params; + const allowedFields = ['firstName', 'lastName', 'role', 'status', 'walletAddress', 'profilePicture', 'profilePictureKey']; + const updateInput: Record = {}; + + for (const key of allowedFields) { + if (req.body[key] !== undefined) { + updateInput[key] = req.body[key]; + } + } + + if (Object.keys(updateInput).length === 0) { + throw new AppError('No valid fields provided for update.', StatusCodes.BAD_REQUEST); + } + + const user = await userService.updateUser(id, updateInput); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'User updated successfully', + data: { user }, + }); + }, + ); + + /** + * DELETE /api/v1/users/:id + * + * Soft delete a user and cascade to related records. + * Protected — requires authentication and admin role. + */ + public deleteUser = asyncHandler( + async (req: Request, res: Response, _next: NextFunction): Promise => { + const { user: authUser } = req as AuthenticatedRequest; + const { id } = req.params; + const adminId = authUser?.userId || authUser?.id; + + const result = await userService.softDeleteUser(id, adminId); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'User deleted successfully', + data: { + user: result.user, + cascaded: result.cascaded, + }, + }); + }, + ); + + /** + * POST /api/v1/users/:id/restore + * + * Restore a soft-deleted user. + * Protected — requires authentication and admin role. + */ + public restoreUser = asyncHandler( + async (req: Request, res: Response, _next: NextFunction): Promise => { + const { id } = req.params; + const user = await userService.restoreUser(id); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'User restored successfully', + data: { user }, + }); + }, + ); + + /** + * PUT /api/v1/users/:id/password + * + * Update user password. + * Protected — requires authentication. Users can only update their own password. + */ + public updatePassword = asyncHandler( + async (req: Request, res: Response, _next: NextFunction): Promise => { + const { user: authUser } = req as AuthenticatedRequest; + const currentUserId = authUser?.userId || authUser?.id; + const { id } = req.params; + + if (currentUserId !== id) { + throw new AppError( + 'You can only update your own password.', + StatusCodes.FORBIDDEN, + ); + } + + const { currentPassword, newPassword } = req.body as { + currentPassword: string; + newPassword: string; + }; + + if (!currentPassword || !newPassword) { + throw new AppError( + 'Both currentPassword and newPassword are required.', + StatusCodes.BAD_REQUEST, + ); + } + + if (newPassword.length < 8) { + throw new AppError( + 'New password must be at least 8 characters.', + StatusCodes.BAD_REQUEST, + ); + } + + const user = await userService.updatePassword(id, { currentPassword, newPassword }); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'Password updated successfully', + data: { user }, + }); + }, + ); + + /** + * GET /api/v1/users/deleted + * + * List soft-deleted users. + * Protected — requires authentication and admin role. + */ + public listDeletedUsers = asyncHandler( + async (req: Request, res: Response, _next: NextFunction): Promise => { + const { + role, + status, + search, + page = '1', + limit = '10', + } = req.query as Record; + + const parsedPage = Math.max(1, parseInt(page as string, 10) || 1); + const parsedLimit = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 10)); + + const filters: Parameters[0] = { + page: parsedPage, + limit: parsedLimit, + }; + + if (role) filters.role = role as UserRole; + if (status) filters.status = status as UserStatus; + if (search) filters.search = search as string; + + const result = await userService.getDeletedUsers(filters); + + res.status(StatusCodes.OK).json({ + status: 'success', + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + }, + ); } export default new UserController(); diff --git a/src/interfaces/IDriverProfile.ts b/src/interfaces/IDriverProfile.ts index b0b9f04..7804e32 100644 --- a/src/interfaces/IDriverProfile.ts +++ b/src/interfaces/IDriverProfile.ts @@ -22,8 +22,13 @@ export interface IDriverProfile extends Document { totalDeliveries: number; completedDeliveries: number; vehicleDetails?: IVehicleDetails; + isDeleted?: boolean; + deletedAt?: Date | null; + deletedBy?: string; createdAt: Date; updatedAt: Date; + softDelete(userId?: string): Promise; + restore(): Promise; } export const TIER_THRESHOLDS: Record = { diff --git a/src/interfaces/IUser.ts b/src/interfaces/IUser.ts index 83c5191..ce7f62f 100644 --- a/src/interfaces/IUser.ts +++ b/src/interfaces/IUser.ts @@ -26,9 +26,14 @@ export interface IUser extends Document { suspendedAt?: Date; profilePicture?: string; profilePictureKey?: string; + isDeleted?: boolean; + deletedAt?: Date | null; + deletedBy?: string; createdAt: Date; updatedAt: Date; comparePassword(candidatePassword: string): Promise; + softDelete(userId?: string): Promise; + restore(): Promise; } export interface ILoginPayload { diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index 307ce33..d382165 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -4,6 +4,7 @@ import { StatusCodes } from 'http-status-codes'; import User from '../models/User'; import type { IUser } from '../interfaces/IUser'; import AppError from '../utils/AppError'; +import env from '../config/env'; // ─── JWT payload shape ──────────────────────────────────────────────────────── @@ -38,7 +39,7 @@ const authenticate = async (req: Request, _res: Response, next: NextFunction): P const token = authHeader.split(' ')[1]; // 2. Verify and decode the JWT - const secret = process.env.JWT_SECRET; + const secret = env.JWT_SECRET; if (!secret) { throw new AppError( 'Server misconfiguration: JWT secret not set.', diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 149401d..433cb1f 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -77,7 +77,7 @@ const errorHandler = ( `${error.statusCode} - ${error.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`, ); - if (env.NODE_ENV === 'development') { + if (env.NODE_ENV === 'development' || env.NODE_ENV === 'test') { sendErrorDev(error, req, res); } else { sendErrorProd(error, req, res); diff --git a/src/models/DriverProfile.ts b/src/models/DriverProfile.ts index 1a585de..91ad4c1 100644 --- a/src/models/DriverProfile.ts +++ b/src/models/DriverProfile.ts @@ -64,13 +64,43 @@ const driverProfileSchema = new Schema( type: vehicleDetailsSchema, required: false, }, + isDeleted: { + type: Boolean, + default: false, + }, + deletedAt: { + type: Date, + default: null, + }, + deletedBy: { + type: String, + }, }, { timestamps: true }, ); +driverProfileSchema.methods.softDelete = async function (userId?: string): Promise { + this.isDeleted = true; + this.deletedAt = new Date(); + if (userId) { + this.deletedBy = userId; + } + return this.save(); +}; + +driverProfileSchema.methods.restore = async function (): Promise { + this.isDeleted = false; + this.deletedAt = null; + this.deletedBy = undefined; + return this.save(); +}; + // Index for leaderboard queries: descending reputation points driverProfileSchema.index({ reputationPoints: -1 }); +// Compound index for filtering by user and deletion status +driverProfileSchema.index({ userId: 1, isDeleted: 1 }); + const DriverProfile = mongoose.model('DriverProfile', driverProfileSchema); export default DriverProfile; diff --git a/src/models/User.ts b/src/models/User.ts index 0a8c4c6..b5b2a73 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -65,6 +65,17 @@ const userSchema = new Schema( type: String, trim: true, }, + isDeleted: { + type: Boolean, + default: false, + }, + deletedAt: { + type: Date, + default: null, + }, + deletedBy: { + type: String, + }, }, { timestamps: true, @@ -101,6 +112,24 @@ userSchema.methods.comparePassword = async function (candidatePassword: string): return bcrypt.compare(candidatePassword, this.password); }; +// Soft delete instance method +userSchema.methods.softDelete = async function (userId?: string): Promise { + this.isDeleted = true; + this.deletedAt = new Date(); + if (userId) { + this.deletedBy = userId; + } + return this.save(); +}; + +// Restore instance method +userSchema.methods.restore = async function (): Promise { + this.isDeleted = false; + this.deletedAt = null; + this.deletedBy = undefined; + return this.save(); +}; + // Index for efficient email lookups (login, registration duplicate checks). userSchema.index({ email: 1 }); @@ -110,6 +139,9 @@ userSchema.index({ email: 1 }); // users by role and/or status, e.g. an admin listing all suspended drivers. userSchema.index({ role: 1, status: 1 }); +// Compound index for filtering active/non-deleted users +userSchema.index({ isDeleted: 1, status: 1 }); + const User = mongoose.model('User', userSchema); export default User; diff --git a/src/routes/delivery.routes.ts b/src/routes/delivery.routes.ts index 220a46e..8cd0062 100644 --- a/src/routes/delivery.routes.ts +++ b/src/routes/delivery.routes.ts @@ -10,6 +10,7 @@ import { import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import { UserRole } from '../interfaces/IUser'; +import { requireIdempotencyKey } from '../middlewares/idempotency'; const router = Router(); diff --git a/src/routes/index.ts b/src/routes/index.ts index 786a7b6..e817959 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,3 @@ -// @ts-ignore: express types may be missing in this project setup import { Router } from 'express'; import authRoutes from './authRoutes'; import deliveryCrudRoutes from './delivery.routes'; @@ -11,6 +10,7 @@ import disputeRoutes from './disputeRoutes'; import eventLogRoutes from './eventLogRoutes'; import profileRoutes from './profileRoutes'; import healthRoutes from './healthRoutes'; +import userRoutes from './userRoutes'; const router = Router(); @@ -25,5 +25,6 @@ router.use('/v1/disputes', disputeRoutes); router.use('/v1/eventlog', eventLogRoutes); router.use('/v1/profile', profileRoutes); router.use('/v1/health', healthRoutes); +router.use('/v1/users', userRoutes); export default router; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 09fbe76..3a9dffa 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -1,8 +1,10 @@ import { Router } from 'express'; import userController from '../controllers/userController'; import { authMiddleware } from '../middlewares/authMiddleware'; +import requireRole from '../middleware/requireRole'; import { validateRequest } from '../middlewares/validateRequest'; import { updateWalletSchema } from '../validators/userValidator'; +import { UserRole } from '../interfaces/IUser'; const router = Router(); @@ -18,4 +20,74 @@ router.put( userController.updateWallet, ); +/** + * @route GET /api/v1/users/deleted + * @desc List soft-deleted users + * @access Private (Admin only) + */ +router.get( + '/deleted', + authMiddleware, + requireRole(UserRole.ADMIN), + userController.listDeletedUsers, +); + +/** + * @route GET /api/v1/users/:id + * @desc Get user by ID + * @access Private + */ +router.get( + '/:id', + authMiddleware, + userController.getUserById, +); + +/** + * @route PUT /api/v1/users/:id + * @desc Update user profile + * @access Private (Admin only) + */ +router.put( + '/:id', + authMiddleware, + requireRole(UserRole.ADMIN), + userController.updateUser, +); + +/** + * @route DELETE /api/v1/users/:id + * @desc Soft delete user with cascading to related records + * @access Private (Admin only) + */ +router.delete( + '/:id', + authMiddleware, + requireRole(UserRole.ADMIN), + userController.deleteUser, +); + +/** + * @route POST /api/v1/users/:id/restore + * @desc Restore a soft-deleted user + * @access Private (Admin only) + */ +router.post( + '/:id/restore', + authMiddleware, + requireRole(UserRole.ADMIN), + userController.restoreUser, +); + +/** + * @route PUT /api/v1/users/:id/password + * @desc Update user password + * @access Private (own user only) + */ +router.put( + '/:id/password', + authMiddleware, + userController.updatePassword, +); + export default router; diff --git a/src/services/authService.ts b/src/services/authService.ts index b2b4083..3682ec9 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -4,6 +4,7 @@ import User from '../models/User'; import { IAuthResponse, ILoginPayload, IUser } from '../interfaces/IUser'; import AppError from '../utils/AppError'; import logger from '../config/logger'; +import env from '../config/env'; class AuthService { /** @@ -64,13 +65,13 @@ class AuthService { * Generate a signed JWT token containing the user's ID and role. */ private generateToken(userId: string, role: string): string { - const secret = process.env.JWT_SECRET; + const secret = env.JWT_SECRET; if (!secret) { throw new AppError('JWT secret is not configured', StatusCodes.INTERNAL_SERVER_ERROR, false); } - const expiresIn = process.env.JWT_EXPIRES_IN || '7d'; + const expiresIn = env.JWT_EXPIRES_IN; return jwt.sign({ userId, role }, secret, { expiresIn, @@ -78,7 +79,7 @@ class AuthService { } public verifyToken(token: string): { userId: string } { - const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_prod'; + const JWT_SECRET = env.JWT_SECRET; try { const decoded = jwt.verify(token, JWT_SECRET) as { sub?: string; diff --git a/src/services/idempotency.service.ts b/src/services/idempotency.service.ts index 6a946bc..e63f8b0 100644 --- a/src/services/idempotency.service.ts +++ b/src/services/idempotency.service.ts @@ -1,5 +1,5 @@ import httpStatus from 'http-status-codes'; -import redisClient from '../config/redis'; +import { redisClient } from '../config/redis'; import IdempotencyRecord, { IdempotencyStatus } from '../models/IdempotencyRecord'; import env from '../config/env'; import logger from '../config/logger'; diff --git a/src/services/userService.ts b/src/services/userService.ts new file mode 100644 index 0000000..d7793ac --- /dev/null +++ b/src/services/userService.ts @@ -0,0 +1,296 @@ +import { StatusCodes } from 'http-status-codes'; +import mongoose from 'mongoose'; +import User from '../models/User'; +import DriverProfile from '../models/DriverProfile'; +import Delivery, { IDelivery } from '../models/Delivery'; +import { IUser, UserRole, UserStatus } from '../interfaces/IUser'; +import { AppError } from '../utils/AppError'; +import logger from '../config/logger'; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface UpdateUserInput { + firstName?: string; + lastName?: string; + role?: UserRole; + status?: UserStatus; + walletAddress?: string; + profilePicture?: string; + profilePictureKey?: string; +} + +export interface UpdatePasswordInput { + currentPassword: string; + newPassword: string; +} + +export interface UserFilter { + role?: UserRole; + status?: UserStatus; + search?: string; + page?: number; + limit?: number; + includeDeleted?: boolean; +} + +export interface PaginatedUserResult { + data: IUser[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface SoftDeleteResult { + user: IUser; + cascaded: { + driverProfile: boolean; + deliveries: number; + }; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class UserService { + /** + * Retrieve a single user by ID. + * By default, excludes soft-deleted users. + */ + async getUserById(id: string): Promise { + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new AppError('Invalid user ID format.', StatusCodes.BAD_REQUEST); + } + + const user = await User.findOne({ _id: id, isDeleted: { $ne: true } }); + if (!user) { + throw new AppError('User not found.', StatusCodes.NOT_FOUND); + } + + return user; + } + + /** + * List users with optional filtering and pagination. + */ + async getUsers(filters: UserFilter): Promise { + const { + role, + status, + search, + page = 1, + limit = 10, + includeDeleted = false, + } = filters; + + const query: Record = {}; + + if (!includeDeleted) { + query.isDeleted = { $ne: true }; + } + + if (role) { + query.role = role; + } + + if (status) { + query.status = status; + } + + if (search) { + query.$or = [ + { email: { $regex: search, $options: 'i' } }, + { firstName: { $regex: search, $options: 'i' } }, + { lastName: { $regex: search, $options: 'i' } }, + ]; + } + + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + User.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(), + User.countDocuments(query).exec(), + ]); + + return { + data: data as IUser[], + total, + page, + limit, + totalPages: Math.ceil(total / limit) || 0, + }; + } + + /** + * Update user profile fields. + */ + async updateUser(id: string, input: UpdateUserInput): Promise { + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new AppError('Invalid user ID format.', StatusCodes.BAD_REQUEST); + } + + const user = await User.findOneAndUpdate( + { _id: id, isDeleted: { $ne: true } }, + { $set: input }, + { new: true, runValidators: true }, + ); + + if (!user) { + throw new AppError('User not found.', StatusCodes.NOT_FOUND); + } + + logger.info(`User updated: ${user.email}`); + return user; + } + + /** + * Update user password. Requires the current password for verification. + */ + async updatePassword(id: string, input: UpdatePasswordInput): Promise { + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new AppError('Invalid user ID format.', StatusCodes.BAD_REQUEST); + } + + const user = await User.findOne({ _id: id, isDeleted: { $ne: true } }).select('+password'); + if (!user) { + throw new AppError('User not found.', StatusCodes.NOT_FOUND); + } + + const isCurrentPasswordValid = await user.comparePassword(input.currentPassword); + if (!isCurrentPasswordValid) { + throw new AppError('Current password is incorrect.', StatusCodes.UNAUTHORIZED); + } + + user.password = input.newPassword; + await user.save(); + + logger.info(`Password updated for user: ${user.email}`); + return user; + } + + /** + * Soft delete a user and cascade to related DriverProfile and Delivery records. + * + * Cascading rules: + * - DriverProfile belonging to the user is soft-deleted. + * - Deliveries where the user is driverId, userId, sender, or recipient are soft-deleted. + */ + async softDeleteUser(id: string, userId?: string): Promise { + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new AppError('Invalid user ID format.', StatusCodes.BAD_REQUEST); + } + + const user = await User.findById(id); + if (!user) { + throw new AppError('User not found.', StatusCodes.NOT_FOUND); + } + + if (user.isDeleted) { + throw new AppError('User is already deleted.', StatusCodes.CONFLICT); + } + + let driverProfileDeleted = false; + let deliveriesDeleted = 0; + + // Cascade: soft-delete driver profile + const driverProfile = await DriverProfile.findOne({ userId: id }); + if (driverProfile) { + await (driverProfile as unknown as { softDelete(userId?: string): Promise }).softDelete(userId); + driverProfileDeleted = true; + } + + // Cascade: soft-delete related deliveries + const deliveryQuery: Record = { + isDeleted: { $ne: true }, + $or: [ + { driverId: id }, + { userId: id }, + { sender: new mongoose.Types.ObjectId(id) }, + { recipient: new mongoose.Types.ObjectId(id) }, + ], + }; + + const deliveries = await Delivery.find(deliveryQuery).setOptions({ includeDeleted: true }).exec(); + for (const delivery of deliveries) { + await (delivery as unknown as IDelivery).softDelete(userId); + deliveriesDeleted++; + } + + // Soft-delete the user + await user.softDelete(userId); + + logger.info(`User soft-deleted: ${user.email}. Cascaded to ${driverProfileDeleted ? 'driver profile, ' : ''}${deliveriesDeleted} deliveries.`); + + return { + user, + cascaded: { + driverProfile: driverProfileDeleted, + deliveries: deliveriesDeleted, + }, + }; + } + + /** + * Restore a soft-deleted user. + * Note: Related DriverProfile and Deliveries are NOT automatically restored + * to avoid inconsistent state — they must be restored individually if needed. + */ + async restoreUser(id: string): Promise { + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new AppError('Invalid user ID format.', StatusCodes.BAD_REQUEST); + } + + const user = await User.findById(id); + if (!user) { + throw new AppError('User not found.', StatusCodes.NOT_FOUND); + } + + if (!user.isDeleted) { + throw new AppError('User is not deleted.', StatusCodes.CONFLICT); + } + + await user.restore(); + + logger.info(`User restored: ${user.email}`); + return user; + } + + /** + * List soft-deleted users. + */ + async getDeletedUsers(filters: Omit): Promise { + const { page = 1, limit = 10, ...rest } = filters; + + const query: Record = { isDeleted: true }; + + if (rest.role) { + query.role = rest.role; + } + + if (rest.status) { + query.status = rest.status; + } + + if (rest.search) { + query.$or = [ + { email: { $regex: rest.search, $options: 'i' } }, + { firstName: { $regex: rest.search, $options: 'i' } }, + { lastName: { $regex: rest.search, $options: 'i' } }, + ]; + } + + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + User.find(query).sort({ deletedAt: -1 }).skip(skip).limit(limit).exec(), + User.countDocuments(query).exec(), + ]); + + return { + data: data as IUser[], + total, + page, + limit, + totalPages: Math.ceil(total / limit) || 0, + }; + } +} + +export const userService = new UserService(); diff --git a/tests/user.schema.hooks.test.ts b/tests/user.schema.hooks.test.ts new file mode 100644 index 0000000..477ebbe --- /dev/null +++ b/tests/user.schema.hooks.test.ts @@ -0,0 +1,579 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Express } from 'express'; + +import User from '../src/models/User'; +import DriverProfile from '../src/models/DriverProfile'; +import Delivery, { IDelivery } from '../src/models/Delivery'; +import { IUser } from '../src/interfaces/IUser'; + +jest.mock('../src/config/database', () => ({ + connectDatabase: jest.fn(), +})); + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +})); + +let app: Express; +let mongoServer: MongoMemoryServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + const mod = await import('../src/app'); + app = mod.default; +}); + +afterEach(async () => { + await mongoose.connection.collection('users').deleteMany({}); + await mongoose.connection.collection('driverprofiles').deleteMany({}); + await mongoose.connection.collection('deliveries').deleteMany({}); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +const registerAndLogin = async (email: string, password: string, role = 'user') => { + const registerRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Test', + lastName: 'User', + email, + password, + }); + + expect(registerRes.status).toBe(201); + + // Promote to admin if needed for protected routes + if (role === 'admin') { + const userId = registerRes.body.data.user.id; + await User.findByIdAndUpdate(userId, { role: 'admin' }); + } + + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email, + password, + }); + + expect(loginRes.status).toBe(200); + return loginRes.body.data.token as string; +}; + +describe('POST /api/v1/auth/register - Password Hashing', () => { + it('hashes the password before persisting to the database', async () => { + const password = 'SecurePass123!'; + await request(app).post('/api/v1/auth/register').send({ + firstName: 'Hash', + lastName: 'Test', + email: 'hash.test@swiftchain.com', + password, + }); + + const stored = await mongoose.connection.collection('users').findOne({ email: 'hash.test@swiftchain.com' }); + + expect(stored).not.toBeNull(); + expect(stored?.password).toBeDefined(); + expect(stored?.password).not.toBe(password); + expect(stored?.password).toMatch(/^\$2[aby]\$/); + }); + + it('never returns the password hash in the API response', async () => { + const res = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Hash', + lastName: 'Test', + email: 'hash2.test@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(201); + expect(res.body.data.user).not.toHaveProperty('password'); + }); + + it('can verify the hashed password via login', async () => { + const password = 'SecurePass123!'; + await request(app).post('/api/v1/auth/register').send({ + firstName: 'Hash', + lastName: 'Test', + email: 'hash3.test@swiftchain.com', + password, + }); + + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'hash3.test@swiftchain.com', + password, + }); + + expect(loginRes.status).toBe(200); + }); +}); + +describe('PUT /api/v1/users/:id/password - Password Hashing on Update', () => { + it('re-hashes the password when it is updated', async () => { + const token = await registerAndLogin('updatepass@swiftchain.com', 'OldPass123!', 'user'); + + // Get the user ID from login response — re-login to get it + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'updatepass@swiftchain.com', + password: 'OldPass123!', + }); + + const userId = loginRes.body.data.user.id; + + const newPassword = 'NewPass456!'; + const updateRes = await request(app) + .put(`/api/v1/users/${userId}/password`) + .set('Authorization', `Bearer ${token}`) + .send({ + currentPassword: 'OldPass123!', + newPassword, + }); + + expect(updateRes.status).toBe(200); + + const stored = await mongoose.connection.collection('users').findOne({ email: 'updatepass@swiftchain.com' }); + expect(stored?.password).toBeDefined(); + expect(stored?.password).not.toBe(newPassword); + expect(stored?.password).toMatch(/^\$2[aby]\$/); + + // Verify new password works + const newLoginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'updatepass@swiftchain.com', + password: newPassword, + }); + expect(newLoginRes.status).toBe(200); + }); + + it('rejects update with incorrect current password', async () => { + const token = await registerAndLogin('wrongpass@swiftchain.com', 'CorrectPass123!', 'user'); + + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'wrongpass@swiftchain.com', + password: 'CorrectPass123!', + }); + + const userId = loginRes.body.data.user.id; + + const updateRes = await request(app) + .put(`/api/v1/users/${userId}/password`) + .set('Authorization', `Bearer ${token}`) + .send({ + currentPassword: 'WrongCurrentPass!', + newPassword: 'NewPass456!', + }); + + expect(updateRes.status).toBe(401); + }); + + it('prevents users from updating other users passwords', async () => { + const token1 = await registerAndLogin('user1@swiftchain.com', 'Pass12345!', 'user'); + const token2 = await registerAndLogin('user2@swiftchain.com', 'Pass12345!', 'user'); + + const loginRes2 = await request(app).post('/api/v1/auth/login').send({ + email: 'user2@swiftchain.com', + password: 'Pass12345!', + }); + + const userId2 = loginRes2.body.data.user.id; + + const updateRes = await request(app) + .put(`/api/v1/users/${userId2}/password`) + .set('Authorization', `Bearer ${token1}`) + .send({ + currentPassword: 'Pass12345!', + newPassword: 'NewPass456!', + }); + + expect(updateRes.status).toBe(403); + }); +}); + +describe('DELETE /api/v1/users/:id - Soft Delete Cascading', () => { + it('soft-deletes the user and cascades to related DriverProfile and Deliveries', async () => { + const adminToken = await registerAndLogin('cascade.admin@swiftchain.com', 'AdminPass123!', 'admin'); + + // Register a driver user + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Driver', + lastName: 'User', + email: 'cascade.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + expect(driverRes.status).toBe(201); + const driverId = driverRes.body.data.user.id; + + // Create driver profile + await DriverProfile.create({ + userId: new mongoose.Types.ObjectId(driverId), + reputationPoints: 100, + tier: 'silver', + totalDeliveries: 5, + completedDeliveries: 3, + vehicleDetails: { + make: 'Toyota', + model: 'Camry', + year: 2022, + plateNumber: 'ABC123', + capacityKg: 500, + }, + }); + + // Create deliveries where driver is sender, recipient, or driverId + const delivery1 = await Delivery.create({ + deliveryId: 'DEL-CASCADE-1', + driverId: driverId, + userId: driverId, + sender: new mongoose.Types.ObjectId(driverId), + recipient: new mongoose.Types.ObjectId(driverId), + status: 'pending', + pickupCoordinates: { lat: 0, lng: 0, address: 'A' }, + dropoffCoordinates: { lat: 1, lng: 1, address: 'B' }, + }); + + const delivery2 = await Delivery.create({ + deliveryId: 'DEL-CASCADE-2', + driverId: driverId, + userId: driverId, + sender: new mongoose.Types.ObjectId(driverId), + recipient: new mongoose.Types.ObjectId(driverId), + status: 'assigned', + pickupCoordinates: { lat: 0, lng: 0, address: 'A' }, + dropoffCoordinates: { lat: 1, lng: 1, address: 'B' }, + }); + + // Soft delete the user + const deleteRes = await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(deleteRes.status).toBe(200); + expect(deleteRes.body.data.user.isDeleted).toBe(true); + expect(deleteRes.body.data.cascaded.driverProfile).toBe(true); + expect(deleteRes.body.data.cascaded.deliveries).toBe(2); + + // Verify user is soft-deleted in DB + const deletedUser = await User.findById(driverId).setOptions({ includeDeleted: true }); + expect(deletedUser?.isDeleted).toBe(true); + expect(deletedUser?.deletedAt).toBeDefined(); + + // Verify driver profile is soft-deleted + const deletedProfile = await DriverProfile.findOne({ userId: driverId }).setOptions({ includeDeleted: true }); + expect(deletedProfile?.isDeleted).toBe(true); + + // Verify deliveries are soft-deleted + const deletedDelivery1 = await Delivery.findById(delivery1._id).setOptions({ includeDeleted: true }); + expect(deletedDelivery1?.isDeleted).toBe(true); + + const deletedDelivery2 = await Delivery.findById(delivery2._id).setOptions({ includeDeleted: true }); + expect(deletedDelivery2?.isDeleted).toBe(true); + + // Verify user is excluded from normal queries + const normalQuery = await User.findOne({ _id: driverId, isDeleted: { $ne: true } }); + expect(normalQuery).toBeNull(); + }); + + it('soft-deletes only the user when no related records exist', async () => { + const adminToken = await registerAndLogin('cascade.admin2@swiftchain.com', 'AdminPass123!', 'admin'); + + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Solo', + lastName: 'Driver', + email: 'solo.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + const driverId = driverRes.body.data.user.id; + + const deleteRes = await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(deleteRes.status).toBe(200); + expect(deleteRes.body.data.cascaded.driverProfile).toBe(false); + expect(deleteRes.body.data.cascaded.deliveries).toBe(0); + }); + + it('returns 409 when deleting an already deleted user', async () => { + const adminToken = await registerAndLogin('cascade.admin3@swiftchain.com', 'AdminPass123!', 'admin'); + + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Twice', + lastName: 'Driver', + email: 'twice.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + const driverId = driverRes.body.data.user.id; + + // First delete + await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + // Second delete + const secondDeleteRes = await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(secondDeleteRes.status).toBe(409); + }); +}); + +describe('POST /api/v1/users/:id/restore - Restore Soft Deleted User', () => { + it('restores a soft-deleted user', async () => { + const adminToken = await registerAndLogin('restore.admin@swiftchain.com', 'AdminPass123!', 'admin'); + + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Restore', + lastName: 'Driver', + email: 'restore.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + const driverId = driverRes.body.data.user.id; + + // Soft delete + await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + // Restore + const restoreRes = await request(app) + .post(`/api/v1/users/${driverId}/restore`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(restoreRes.status).toBe(200); + expect(restoreRes.body.data.user.isDeleted).toBe(false); + expect(restoreRes.body.data.user.deletedAt).toBeNull(); + + // Verify user is accessible again + const user = await User.findById(driverId); + expect(user).not.toBeNull(); + expect((user as IUser).isDeleted).toBe(false); + }); + + it('returns 409 when restoring a non-deleted user', async () => { + const adminToken = await registerAndLogin('restore.admin2@swiftchain.com', 'AdminPass123!', 'admin'); + + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Active', + lastName: 'Driver', + email: 'active.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + const driverId = driverRes.body.data.user.id; + + const restoreRes = await request(app) + .post(`/api/v1/users/${driverId}/restore`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(restoreRes.status).toBe(409); + }); +}); + +describe('GET /api/v1/users/:id - Timestamps and Indexing', () => { + it('returns timestamps on user creation', async () => { + const beforeCreate = new Date(); + const res = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Timestamp', + lastName: 'User', + email: 'timestamp.user@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(201); + + const stored = await mongoose.connection.collection('users').findOne({ email: 'timestamp.user@swiftchain.com' }); + expect(stored?.createdAt).toBeDefined(); + expect(stored?.updatedAt).toBeDefined(); + + const afterCreate = new Date(); + const createdAt = new Date(stored!.createdAt); + expect(createdAt.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(createdAt.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + }); + + it('updates the updatedAt timestamp on modification', async () => { + const token = await registerAndLogin('ts.update@swiftchain.com', 'Pass12345!', 'admin'); + + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'ts.update@swiftchain.com', + password: 'Pass12345!', + }); + + const userId = loginRes.body.data.user.id; + + // Get initial updatedAt + const initialUser = await User.findById(userId); + const initialUpdatedAt = new Date((initialUser as IUser).updatedAt); + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Update the user + const updateRes = await request(app) + .put(`/api/v1/users/${userId}`) + .set('Authorization', `Bearer ${token}`) + .send({ firstName: 'Updated' }); + + expect(updateRes.status).toBe(200); + + const updatedUser = await User.findById(userId); + const newUpdatedAt = new Date((updatedUser as IUser).updatedAt); + + expect(newUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + + it('enforces unique email index', async () => { + await request(app).post('/api/v1/auth/register').send({ + firstName: 'Unique', + lastName: 'User', + email: 'unique.email@swiftchain.com', + password: 'SecurePass123!', + }); + + const duplicateRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Another', + lastName: 'User', + email: 'unique.email@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(duplicateRes.status).toBe(409); + }); + + it('supports filtering by role and status via compound index', async () => { + const adminToken = await registerAndLogin('idx.admin@swiftchain.com', 'AdminPass123!', 'admin'); + + // Create users with different roles and statuses + await request(app).post('/api/v1/auth/register').send({ + firstName: 'Admin', + lastName: 'One', + email: 'admin1@swiftchain.com', + password: 'SecurePass123!', + role: 'admin', + }); + + await request(app).post('/api/v1/auth/register').send({ + firstName: 'User', + lastName: 'One', + email: 'user1@swiftchain.com', + password: 'SecurePass123!', + }); + + // Query with role filter + const roleRes = await request(app) + .get('/api/v1/users/deleted?role=admin') + .set('Authorization', `Bearer ${adminToken}`); + + expect(roleRes.status).toBe(200); + expect(roleRes.body.data.every((u: IUser) => u.role === 'admin')).toBe(true); + }); + + it('excludes soft-deleted users from normal queries', async () => { + const adminToken = await registerAndLogin('idx.admin2@swiftchain.com', 'AdminPass123!', 'admin'); + + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Filter', + lastName: 'Driver', + email: 'filter.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + const driverId = driverRes.body.data.user.id; + + // Verify user is in normal query + const beforeDelete = await request(app) + .get(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(beforeDelete.status).toBe(200); + + // Soft delete + await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + // Verify user is excluded from normal queries + const afterDelete = await request(app) + .get(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(afterDelete.status).toBe(404); + }); + + it('returns soft-deleted users via the deleted endpoint', async () => { + const adminToken = await registerAndLogin('idx.admin3@swiftchain.com', 'AdminPass123!', 'admin'); + + const driverRes = await request(app).post('/api/v1/auth/register').send({ + firstName: 'Deleted', + lastName: 'Driver', + email: 'deleted.driver@swiftchain.com', + password: 'DriverPass123!', + }); + + const driverId = driverRes.body.data.user.id; + + // Soft delete + await request(app) + .delete(`/api/v1/users/${driverId}`) + .set('Authorization', `Bearer ${adminToken}`); + + // Query deleted users + const deletedRes = await request(app) + .get('/api/v1/users/deleted') + .set('Authorization', `Bearer ${adminToken}`); + + expect(deletedRes.status).toBe(200); + expect(deletedRes.body.data.some((u: IUser) => u.id === driverId)).toBe(true); + }); +}); + +describe('Authentication and Authorization', () => { + it('requires authentication for user endpoints', async () => { + const res = await request(app).get('/api/v1/users/123456789012345678901234'); + expect(res.status).toBe(401); + }); + + it('requires admin role for delete endpoint', async () => { + const userToken = await registerAndLogin('auth.user@swiftchain.com', 'Pass12345!', 'user'); + + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'auth.user@swiftchain.com', + password: 'Pass12345!', + }); + + const userId = loginRes.body.data.user.id; + + const deleteRes = await request(app) + .delete(`/api/v1/users/${userId}`) + .set('Authorization', `Bearer ${userToken}`); + + expect(deleteRes.status).toBe(403); + }); + + it('requires admin role for update endpoint', async () => { + const userToken = await registerAndLogin('auth.user2@swiftchain.com', 'Pass12345!', 'user'); + + const loginRes = await request(app).post('/api/v1/auth/login').send({ + email: 'auth.user2@swiftchain.com', + password: 'Pass12345!', + }); + + const userId = loginRes.body.data.user.id; + + const updateRes = await request(app) + .put(`/api/v1/users/${userId}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ firstName: 'Hacker' }); + + expect(updateRes.status).toBe(403); + }); +});