Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<rootDir>/tests/jest.setup.ts'],
// Allow enough time for MongoMemoryServer to start (and download the binary
Expand Down
2 changes: 2 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
187 changes: 187 additions & 0 deletions src/controllers/userController.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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<void> => {
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<void> => {
const { id } = req.params;
const allowedFields = ['firstName', 'lastName', 'role', 'status', 'walletAddress', 'profilePicture', 'profilePictureKey'];
const updateInput: Record<string, unknown> = {};

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<void> => {
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<void> => {
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<void> => {
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<void> => {
const {
role,
status,
search,
page = '1',
limit = '10',
} = req.query as Record<string, unknown>;

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<typeof userService.getDeletedUsers>[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();
5 changes: 5 additions & 0 deletions src/interfaces/IDriverProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<this>;
restore(): Promise<this>;
}

export const TIER_THRESHOLDS: Record<ReputationTier, number> = {
Expand Down
5 changes: 5 additions & 0 deletions src/interfaces/IUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
softDelete(userId?: string): Promise<this>;
restore(): Promise<this>;
}

export interface ILoginPayload {
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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.',
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions src/models/DriverProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,43 @@ const driverProfileSchema = new Schema<IDriverProfile>(
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<IDriverProfile> {
this.isDeleted = true;
this.deletedAt = new Date();
if (userId) {
this.deletedBy = userId;
}
return this.save();
};

driverProfileSchema.methods.restore = async function (): Promise<IDriverProfile> {
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<IDriverProfile>('DriverProfile', driverProfileSchema);

export default DriverProfile;
32 changes: 32 additions & 0 deletions src/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ const userSchema = new Schema<IUser>(
type: String,
trim: true,
},
isDeleted: {
type: Boolean,
default: false,
},
deletedAt: {
type: Date,
default: null,
},
deletedBy: {
type: String,
},
},
{
timestamps: true,
Expand Down Expand Up @@ -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<IUser> {
this.isDeleted = true;
this.deletedAt = new Date();
if (userId) {
this.deletedBy = userId;
}
return this.save();
};

// Restore instance method
userSchema.methods.restore = async function (): Promise<IUser> {
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 });

Expand All @@ -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<IUser>('User', userSchema);

export default User;
1 change: 1 addition & 0 deletions src/routes/delivery.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Loading