Skip to content
Open
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
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
allowBuilds:
mongodb-memory-server: true
unrs-resolver: true
mongodb-memory-server: set this to true or false
unrs-resolver: set this to true or false
29 changes: 29 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@ import dotenv from 'dotenv';

dotenv.config();

interface EnvConfig {
NODE_ENV: string;
PORT: number;
MONGODB_URI: string;
JWT_SECRET: string;
JWT_EXPIRES_IN: string;
BCRYPT_ROUNDS: number;
LOG_LEVEL: string;
CORS_ORIGIN: string;
}

const getEnvVar = (key: string, fallback?: string): string => {
const value = process.env[key] || fallback;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};

const env: EnvConfig = {
NODE_ENV: getEnvVar('NODE_ENV', 'development'),
PORT: parseInt(getEnvVar('PORT', '3000'), 10),
MONGODB_URI: getEnvVar('MONGODB_URI', 'mongodb://localhost:27017/swiftchain'),
JWT_SECRET: getEnvVar('JWT_SECRET'),
JWT_EXPIRES_IN: getEnvVar('JWT_EXPIRES_IN', '7d'),
BCRYPT_ROUNDS: parseInt(getEnvVar('BCRYPT_ROUNDS', '10'), 10),
LOG_LEVEL: getEnvVar('LOG_LEVEL', 'debug'),
CORS_ORIGIN: getEnvVar('CORS_ORIGIN', '*'),
};
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
Expand Down
30 changes: 30 additions & 0 deletions src/controllers/authController.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
import { Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import authService from '../services/authService';
import asyncHandler from '../utils/asyncHandler';
import { ILoginPayload } from '../interfaces/IUser';

class AuthController {
/**
* POST /api/v1/auth/login
*
* Authenticate a user with email and password.
* Returns a JWT token and sanitized user data on success.
*/
login = asyncHandler(async (req: Request, res: Response): Promise<void> => {
const loginPayload: ILoginPayload = {
email: req.body.email,
password: req.body.password,
};

const result = await authService.login(loginPayload);

res.status(StatusCodes.OK).json({
status: 'success',
message: 'Login successful',
data: result,
});
});
}

export default new AuthController();
import type { Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import { registerUser } from '../services/authService';
Expand Down
35 changes: 35 additions & 0 deletions src/interfaces/IUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Document } from 'mongoose';

export enum UserRole {
USER = 'user',
DRIVER = 'driver',
ADMIN = 'admin',
}

export interface IUser extends Document {
email: string;
password: string;
firstName: string;
lastName: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
comparePassword(candidatePassword: string): Promise<boolean>;
}

export interface ILoginPayload {
email: string;
password: string;
}

export interface IAuthResponse {
user: {
id: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
};
token: string;
}
76 changes: 76 additions & 0 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,65 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import logger from '../config/logger';
import AppError from '../utils/AppError';

interface MongooseValidationError extends Error {
errors: Record<string, { message: string }>;
}

interface MongooseDuplicateKeyError extends Error {
code: number;
keyValue: Record<string, unknown>;
}

const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction): void => {
let statusCode = 500;
let message = 'Internal Server Error';
let errors: Array<{ field: string; message: string }> | undefined;

// Custom application errors
if (err instanceof AppError) {
statusCode = err.statusCode;
message = err.message;
}

// Zod validation errors
else if (err instanceof z.ZodError) {
statusCode = 400;
message = 'Validation failed';
errors = err.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
}

// Mongoose validation errors
else if (err.name === 'ValidationError') {
statusCode = 400;
message = 'Validation failed';
const mongooseErr = err as MongooseValidationError;
errors = Object.entries(mongooseErr.errors).map(([field, detail]) => ({
field,
message: detail.message,
}));
}

// MongoDB duplicate key error
else if ((err as MongooseDuplicateKeyError).code === 11000) {
statusCode = 409;
const duplicateErr = err as MongooseDuplicateKeyError;
const field = Object.keys(duplicateErr.keyValue)[0];
message = `An account with this ${field} already exists`;
}

// JWT errors
else if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
} else if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token has expired';
}
import { Error as MongooseError } from 'mongoose';
import logger from '../config/logger';
import env from '../config/env';
Expand All @@ -21,6 +82,21 @@ const handleValidationErrorDB = (err: MongooseError.ValidationError): AppError =
return new AppError(message, 400);
};

const response: Record<string, unknown> = {
status: 'error',
statusCode,
message,
};

if (errors) {
response.errors = errors;
}

if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}

res.status(statusCode).json(response);
const sendErrorDev = (err: AppError, _req: Request, res: Response): void => {
res.status(err.statusCode).json({
status: 'error',
Expand Down
33 changes: 33 additions & 0 deletions src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { StatusCodes } from 'http-status-codes';

/**
* Express middleware factory that validates req.body against a Zod schema.
* Returns structured validation errors on failure.
*/
const validate =
(schema: z.ZodType) =>
(req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);

if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));

res.status(StatusCodes.BAD_REQUEST).json({
status: 'error',
statusCode: StatusCodes.BAD_REQUEST,
message: 'Validation failed',
errors,
});
return;
}

req.body = result.data;
next();
};

export default validate;
81 changes: 81 additions & 0 deletions src/models/User.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,84 @@
import mongoose, { Schema } from 'mongoose';
import bcrypt from 'bcryptjs';
import { IUser, UserRole } from '../interfaces/IUser';

const userSchema = new Schema<IUser>(
{
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email address'],
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false,
},
firstName: {
type: String,
required: [true, 'First name is required'],
trim: true,
maxlength: [50, 'First name cannot exceed 50 characters'],
},
lastName: {
type: String,
required: [true, 'Last name is required'],
trim: true,
maxlength: [50, 'Last name cannot exceed 50 characters'],
},
role: {
type: String,
enum: Object.values(UserRole),
default: UserRole.USER,
},
isActive: {
type: Boolean,
default: true,
},
},
{
timestamps: true,
toJSON: {
transform(_doc, ret): any {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
delete ret.password;
return ret;
},
},
},
);

// Hash password before saving
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next();
}

try {
const rounds = parseInt(process.env.BCRYPT_ROUNDS || '10', 10);
const salt = await bcrypt.genSalt(rounds);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error as Error);
}
});

// Instance method to compare passwords
userSchema.methods.comparePassword = async function (candidatePassword: string): Promise<boolean> {
return bcrypt.compare(candidatePassword, this.password);
};

// Index for efficient email lookups
userSchema.index({ email: 1 });

const User = mongoose.model<IUser>('User', userSchema);
import mongoose, { Document, Model } from 'mongoose';

export interface IUser extends Document {
Expand Down
8 changes: 8 additions & 0 deletions src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { Router } from 'express';
import authController from '../controllers/authController';
import validate from '../middleware/validate';
import { loginSchema } from '../validators/authValidator';
import { register } from '../controllers/authController';

const router = Router();

/**
* @route POST /api/v1/auth/login
* @desc Authenticate user and return JWT token
* @access Public
*/
router.post('/login', validate(loginSchema), authController.login);
* @route POST /api/v1/auth/register
* @desc Register a new user
* @access Public
Expand Down
6 changes: 6 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Router } from 'express';
import authRoutes from './authRoutes';

const router = Router();

// Auth routes
router.use('/auth', authRoutes);
import deliveryRoutes from './deliveryRoutes';

const router = Router();
Expand Down
Loading