- In this blog, we will see how to organize files and folders in a way so that they can be manageable as well as scalable. This is the second blog of a series of blogs about how to create industry-standard projects. You can read the first blog from the following link.
https://dev.to/md_enayeturrahman_2560e3/how-to-set-up-eslint-and-prettier-1nk6
- The structure can be visually shown as follows
my-express-app/
│
├── .env
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── package.json
├── tsconfig.json
│
├── node_modules/
│
├── src/
│ ├── app/
│ │ ├── builder/
│ │ │ └── QueryBuilder.ts
│ │ ├── config/
│ │ │ └── index.ts
│ │ ├── errors/
│ │ │ ├── AppError.ts
│ │ │ ├── handleCastError.ts
│ │ │ ├── handleDuplicateError.ts
│ │ │ ├── handleValidationError.ts
│ │ │ └── handleZodError.ts
│ │ ├── interface/
│ │ │ ├── error.ts
│ │ │ └── index.d.ts
│ │ ├── middleware/
│ │ │ ├── auth.ts
│ │ │ ├── globalErrorhandler.ts
│ │ │ ├── notFound.ts
│ │ │ └── validateRequest.ts
│ │ ├── modules/
│ │ │ ├── Admin/
│ │ │ │ ├── AdminConstant.ts
│ │ │ │ ├── AdminController.ts
│ │ │ │ ├── AdminInterface.ts
│ │ │ │ ├── AdminModel.ts
│ │ │ │ ├── AdminRoute.ts
│ │ │ │ └── AdminValidation.ts
│ │ │ ├── Auth/
│ │ │ │ ├── AuthConstant.ts
│ │ │ │ ├── AuthController.ts
│ │ │ │ ├── AuthInterface.ts
│ │ │ │ ├── AuthModel.ts
│ │ │ │ ├── AuthRoute.ts
│ │ │ │ └── AuthValidation.ts
│ │ │ ├── Course/
│ │ │ │ ├── CourseConstant.ts
│ │ │ │ ├── CourseController.ts
│ │ │ │ ├── CourseInterface.ts
│ │ │ │ ├── CourseModel.ts
│ │ │ │ ├── CourseRoute.ts
│ │ │ │ └── CourseValidation.ts
│ │ │ ├── Faculty/
│ │ │ │ ├── FacultyConstant.ts
│ │ │ │ ├── FacultyController.ts
│ │ │ │ ├── FacultyInterface.ts
│ │ │ │ ├── FacultyModel.ts
│ │ │ │ ├── FacultyRoute.ts
│ │ │ │ └── FacultyValidation.ts
│ │ │ ├── OfferedCourse/
│ │ │ │ ├── OfferedCourseConstant.ts
│ │ │ │ ├── OfferedCourseController.ts
│ │ │ │ ├── OfferedCourseInterface.ts
│ │ │ │ ├── OfferedCourseModel.ts
│ │ │ │ ├── OfferedCourseRoute.ts
│ │ │ │ └── OfferedCourseValidation.ts
│ │ │ ├── AcademicDepartment/
│ │ │ │ ├── AcademicDepartmentConstant.ts
│ │ │ │ ├── AcademicDepartmentController.ts
│ │ │ │ ├── AcademicDepartmentInterface.ts
│ │ │ │ ├── AcademicDepartmentModel.ts
│ │ │ │ ├── AcademicDepartmentRoute.ts
│ │ │ │ └── AcademicDepartmentValidation.ts
│ │ │ ├── AcademicFaculty/
│ │ │ │ ├── AcademicFacultyConstant.ts
│ │ │ │ ├── AcademicFacultyController.ts
│ │ │ │ ├── AcademicFacultyInterface.ts
│ │ │ │ ├── AcademicFacultyModel.ts
│ │ │ │ ├── AcademicFacultyRoute.ts
│ │ │ │ └── AcademicFacultyValidation.ts
│ │ │ ├── AcademicSemester/
│ │ │ │ ├── AcademicSemesterConstant.ts
│ │ │ │ ├── AcademicSemesterController.ts
│ │ │ │ ├── AcademicSemesterInterface.ts
│ │ │ │ ├── AcademicSemesterModel.ts
│ │ │ │ ├── AcademicSemesterRoute.ts
│ │ │ │ └── AcademicSemesterValidation.ts
│ │ │ ├── SemesterRegistration/
│ │ │ │ ├── SemesterRegistrationConstant.ts
│ │ │ │ ├── SemesterRegistrationController.ts
│ │ │ │ ├── SemesterRegistrationInterface.ts
│ │ │ │ ├── SemesterRegistrationModel.ts
│ │ │ │ ├── SemesterRegistrationRoute.ts
│ │ │ │ └── SemesterRegistrationValidation.ts
│ │ │ ├── Student/
│ │ │ │ ├── StudentConstant.ts
│ │ │ │ ├── StudentController.ts
│ │ │ │ ├── StudentInterface.ts
│ │ │ │ ├── StudentModel.ts
│ │ │ │ ├── StudentRoute.ts
│ │ │ │ └── StudentValidation.ts
│ │ │ ├── User/
│ │ │ │ ├── UserConstant.ts
│ │ │ │ ├── UserController.ts
│ │ │ │ ├── UserInterface.ts
│ │ │ │ ├── UserModel.ts
│ │ │ │ ├── UserRoute.ts
│ │ │ │ └── UserValidation.ts
│ │ ├── routes/
│ │ │ └── index.ts
│ │ ├── utils/
│ │ │ ├── catchAsync.ts
│ │ │ └── sendResponse.ts
│ ├── app.ts
│ └── server.js
In your root directory there should be two folders- src and dist. The content of the dist folder will be created automatically when the typescript file is converted to a javascript file. So you need not have to add anything to this folder. Just create it.
The src folder is where you should organize all your code files. We will discuss it later first let's look at what files you should keep in the root folder.
The root folder should contain various configuration files like .eslintignore, .eslintrc.json, .gitignore, .prettierrc.json, jackage.json, tsconfig.json, etc.
Inside the src folder there should be app.ts and server.ts files. The server.ts file will be the entry point of your project. It will mainly contain the database connection and server close logic. A sample of code will be as follows
import { Server } from 'http';
import mongoose from 'mongoose';
import app from './app';
import config from './app/config';
let server: Server;
async function main() {
try {
await mongoose.connect(config.database_url as string);
server = app.listen(config.port, () => {
console.log(`app is listening on port ${config.port}`);
});
} catch (err) {
console.log(err);
}
}
main();
process.on('unhandledRejection', () => {
console.log(`😈 unahandledRejection is detected , shutting down ...`);
if (server) {
server.close(() => {
process.exit(1);
});
}
process.exit(1);
});
process.on('uncaughtException', () => {
console.log(`😈 uncaughtException is detected , shutting down ...`);
process.exit(1);
});
- The app.ts file will initiate the express, containing code related to body-parser, cookie parser, cors, base route, global error handler, and not found route handler. A sample code will be as follows:
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express, { Application } from 'express';
import globalErrorHandler from './app/middlewares/globalErrorhandler';
import notFound from './app/middlewares/notFound';
import router from './app/routes';
const app: Application = express();
//parsers
app.use(express.json());
app.use(cookieParser());
app.use(cors({ origin: ['http://localhost:5173'] }));
// application routes
app.use('/api/v1', router);
app.use(globalErrorHandler);
//Not Found
app.use(notFound);
export default app;
Inside the src folder there should be an app folder that will hold all the remaining codes of the projects. We will alphabetically explain them below:
The first folder inside the app folder will be the builder folder. This will contain files such as QueryBuilder.ts. This file contains all the code related to the query e.g. search, filter, sort, paginate, fields, etc. This file is typically connected with the service file of a given route. Its sample code will be as follows
import { FilterQuery, Query } from 'mongoose';
class QueryBuilder<T> {
public modelQuery: Query<T[], T>;
public query: Record<string, unknown>;
constructor(modelQuery: Query<T[], T>, query: Record<string, unknown>) {
this.modelQuery = modelQuery;
this.query = query;
}
search(searchableFields: string[]) {
const searchTerm = this?.query?.searchTerm;
if (searchTerm) {
this.modelQuery = this.modelQuery.find({
$or: searchableFields.map(
(field) =>
({
[field]: { $regex: searchTerm, $options: 'i' },
}) as FilterQuery<T>,
),
});
}
return this;
}
filter() {
const queryObj = { ...this.query }; // copy
// Filtering
const excludeFields = ['searchTerm', 'sort', 'limit', 'page', 'fields'];
excludeFields.forEach((el) => delete queryObj[el]);
this.modelQuery = this.modelQuery.find(queryObj as FilterQuery<T>);
return this;
}
sort() {
const sort =
(this?.query?.sort as string)?.split(',')?.join(' ') || '-createdAt';
this.modelQuery = this.modelQuery.sort(sort as string);
return this;
}
paginate() {
const page = Number(this?.query?.page) || 1;
const limit = Number(this?.query?.limit) || 10;
const skip = (page - 1) * limit;
this.modelQuery = this.modelQuery.skip(skip).limit(limit);
return this;
}
fields() {
const fields =
(this?.query?.fields as string)?.split(',')?.join(' ') || '-__v';
this.modelQuery = this.modelQuery.select(fields);
return this;
}
}
export default QueryBuilder;
- Then comes the config folder that contains index.ts file. This file is used to organize all the environmental variables from the .env file. It acts as a single point that exports all the environmental variables. Its content looks as follows:
import dotenv from "dotenv";
dotenv.config();
export default {
NODE_ENV: process.env.NODE_ENV,
port: process.env.PORT,
database_url: process.env.DATABASE_URL,
bcrypt_salt_rounds: process.env.BCRYPT_SALT_ROUNDS,
default_password: process.env.DEFAULT_PASS,
jwt_access_secret: process.env.JWT_ACCESS_SECRET,
jwt_refresh_secret: process.env.JWT_REFRESH_SECRET,
jwt_access_expires_in: process.env.JWT_ACCESS_EXPIRES_IN,
jwt_refresh_expires_in: process.env.JWT_REFRESH_EXPIRES_IN,
};
Then comes the errors folder. It is very important in an industry-grade project. Handling errors is crucial for the proper functioning of an app. There are several files that deal with different types of errors. Different type of error files that can be used in a project is explained below:
The AppError.ts file is normally used to deal status code and stack of an error. For proper managing and maintaining of code, it uses Class to handle errors. Its content could be as follows
class AppError extends Error {
public statusCode: number;
constructor(statusCode: number, message: string, stack = '') {
super(message);
this.statusCode = statusCode;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
export default AppError;
- Then comes handleCastError.ts file that deals with cast errors as suggested by the name. Its sample content will be as follows:
import mongoose from 'mongoose';
import { TErrorSources, TGenericErrorResponse } from '../interface/error';
const handleCastError = (
err: mongoose.Error.CastError,
): TGenericErrorResponse => {
const errorSources: TErrorSources = [
{
path: err.path,
message: err.message,
},
];
const statusCode = 400;
return {
statusCode,
message: 'Invalid ID',
errorSources,
};
};
export default handleCastError;
- Then comes handleDuplicateError.ts file and from the name we can understand what kind of error it deals it. Its sample content could be as follows:
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TErrorSources, TGenericErrorResponse } from '../interface/error';
const handleDuplicateError = (err: any): TGenericErrorResponse => {
// Extract value within double quotes using regex
const match = err.message.match(/"([^"]*)"/);
// The extracted value will be in the first capturing group
const extractedMessage = match && match[1];
const errorSources: TErrorSources = [
{
path: '',
message: `${extractedMessage} is already exists`,
},
];
const statusCode = 400;
return {
statusCode,
message: 'Invalid ID',
errorSources,
};
};
export default handleDuplicateError;
- After that handleValidationError.ts file comes. This file typically deals with the mongoose error. Its typical content will be as follows:
import mongoose from 'mongoose';
import { TErrorSources, TGenericErrorResponse } from '../interface/error';
const handleValidationError = (
err: mongoose.Error.ValidationError,
): TGenericErrorResponse => {
const errorSources: TErrorSources = Object.values(err.errors).map(
(val: mongoose.Error.ValidatorError | mongoose.Error.CastError) => {
return {
path: val?.path,
message: val?.message,
};
},
);
const statusCode = 400;
return {
statusCode,
message: 'Validation Error',
errorSources,
};
};
export default handleValidationError;
- At last comes handleZodError.ts file that deals with errors sent by Zod. Its typical content will be as follows:
import { ZodError, ZodIssue } from 'zod';
import { TErrorSources, TGenericErrorResponse } from '../interface/error';
const handleZodError = (err: ZodError): TGenericErrorResponse => {
const errorSources: TErrorSources = err.issues.map((issue: ZodIssue) => {
return {
path: issue?.path[issue.path.length - 1],
message: issue.message,
};
});
const statusCode = 400;
return {
statusCode,
message: 'Validation Error',
errorSources,
};
};
export default handleZodError;
All the above file is used not only to catch errors but also managed them in a uniform way so that the front end receives the same message irrespective of the type and message of the error.
There will be another blog in this series that will explain how to manage errors in an industry-grade project with an explanation and beginning to the end.
Now comes the interface file. Interfaces related to each module are kept in their respective folder. This folder hosts an interface that is general in nature. e.g. error.ts file host interface related to error message. index.d.ts contain an interface related to jwt. The sample code is given below:
export type TErrorSources = {
path: string | number;
message: string;
}[];
export type TGenericErrorResponse = {
statusCode: number;
message: string;
errorSources: TErrorSources;
};
import { JwtPayload } from 'jsonwebtoken';
declare global {
namespace Express {
interface Request {
user: JwtPayload;
}
}
}
- Now comes the middleware folder that holds all the middleware functions of the project. In our case, we have four files inside this folder. The auth.ts file holds token verification logic. Example code is as follows
import { NextFunction, Request, Response } from 'express';
import httpStatus from 'http-status';
import jwt, { JwtPayload } from 'jsonwebtoken';
import config from '../config';
import AppError from '../errors/AppError';
import { TUserRole } from '../modules/user/user.interface';
import { User } from '../modules/user/user.model';
import catchAsync from '../utils/catchAsync';
const auth = (...requiredRoles: TUserRole[]) => {
return catchAsync(async (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization;
// checking if the token is missing
if (!token) {
throw new AppError(httpStatus.UNAUTHORIZED, 'You are not authorized!');
}
// checking if the given token is valid
const decoded = jwt.verify(
token,
config.jwt_access_secret as string,
) as JwtPayload;
const { role, userId, iat } = decoded;
// checking if the user exists
const user = await User.isUserExistsByCustomId(userId);
if (!user) {
throw new AppError(httpStatus.NOT_FOUND, 'This user is not found !');
}
// checking if the user has already deleted
const isDeleted = user?.isDeleted;
if (isDeleted) {
throw new AppError(httpStatus.FORBIDDEN, 'This user is deleted !');
}
// checking if the user is blocked
const userStatus = user?.status;
if (userStatus === 'blocked') {
throw new AppError(httpStatus.FORBIDDEN, 'This user is blocked ! !');
}
if (
user.passwordChangedAt &&
User.isJWTIssuedBeforePasswordChanged(
user.passwordChangedAt,
iat as number,
)
) {
throw new AppError(httpStatus.UNAUTHORIZED, 'You are not authorized !');
}
if (requiredRoles && !requiredRoles.includes(role)) {
throw new AppError(
httpStatus.UNAUTHORIZED,
'You are not authorized hi!',
);
}
req.user = decoded as JwtPayload;
next();
});
};
export default auth;
- Then we have globalErrorhandler.ts file that holds all the error-related logic. Its content is as follows:
import { ErrorRequestHandler } from 'express';
import { ZodError } from 'zod';
import config from '../config';
import AppError from '../errors/AppError';
import handleCastError from '../errors/handleCastError';
import handleDuplicateError from '../errors/handleDuplicateError';
import handleValidationError from '../errors/handleValidationError';
import handleZodError from '../errors/handleZodError';
import { TErrorSources } from '../interface/error';
const globalErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
//setting default values
let statusCode = 500;
let message = 'Something went wrong!';
let errorSources: TErrorSources = [
{
path: '',
message: 'Something went wrong',
},
];
if (err instanceof ZodError) {
const simplifiedError = handleZodError(err);
statusCode = simplifiedError?.statusCode;
message = simplifiedError?.message;
errorSources = simplifiedError?.errorSources;
} else if (err?.name === 'ValidationError') {
const simplifiedError = handleValidationError(err);
statusCode = simplifiedError?.statusCode;
message = simplifiedError?.message;
errorSources = simplifiedError?.errorSources;
} else if (err?.name === 'CastError') {
const simplifiedError = handleCastError(err);
statusCode = simplifiedError?.statusCode;
message = simplifiedError?.message;
errorSources = simplifiedError?.errorSources;
} else if (err?.code === 11000) {
const simplifiedError = handleDuplicateError(err);
statusCode = simplifiedError?.statusCode;
message = simplifiedError?.message;
errorSources = simplifiedError?.errorSources;
} else if (err instanceof AppError) {
statusCode = err?.statusCode;
message = err.message;
errorSources = [
{
path: '',
message: err?.message,
},
];
} else if (err instanceof Error) {
message = err.message;
errorSources = [
{
path: '',
message: err?.message,
},
];
}
//ultimate return
return res.status(statusCode).json({
success: false,
message,
errorSources,
err,
stack: config.NODE_ENV === 'development' ? err?.stack : null,
});
};
export default globalErrorHandler;
- After that there is notFound.ts file that holds the logic for notFound route and it is connected to the app.ts file.
import { NextFunction, Request, Response } from 'express';
import httpStatus from 'http-status';
const notFound = (req: Request, res: Response, next: NextFunction) => {
return res.status(httpStatus.NOT_FOUND).json({
success: false,
message: 'API Not Found !!',
error: '',
});
};
export default notFound;
- Then comes validateRequest.ts file which contains zod related validation code.
import { NextFunction, Request, Response } from 'express';
import { AnyZodObject } from 'zod';
import catchAsync from '../utils/catchAsync';
const validateRequest = (schema: AnyZodObject) => {
return catchAsync(async (req: Request, res: Response, next: NextFunction) => {
await schema.parseAsync({
body: req.body,
cookies: req.cookies,
});
next();
});
};
export default validateRequest;
The modules folder is the main folder that holds interface, model, schema, controller, validation, service, etc code. In our modules folder, we have sub-folders for different routes such as Admin, Auth, Course, Faculty, OfferedCourse, AcademicDepartment, AcademicFaculty, AcademicSemester, Student, and User. In your project, these folders will vary depending on your project requirement.
Typically inside a module folder following files are kept. A route.ts file that contains all routes related to that module. A controller.ts file that contains the controller for each route. A service.ts file that contains all the business logic. An interface.ts file that holds the interface. A model.ts file that contains mongoose schema and model. A validation.ts file that contains zod validation-related code. A constant.ts file that contains constant code. The sample code for each file is given below:
// student.constant.ts
export const studentSearchableFields = [
'email',
'name.firstName',
'presentAddress',
];
// student.controller.ts
import { RequestHandler } from 'express';
import httpStatus from 'http-status';
import catchAsync from '../../utils/catchAsync';
import sendResponse from '../../utils/sendResponse';
import { StudentServices } from './student.service';
const getSingleStudent = catchAsync(async (req, res) => {
const { id } = req.params;
const result = await StudentServices.getSingleStudentFromDB(id);
sendResponse(res, {
statusCode: httpStatus.OK,
success: true,
message: 'Student is retrieved succesfully',
data: result,
});
});
const getAllStudents: RequestHandler = catchAsync(async (req, res) => {
const result = await StudentServices.getAllStudentsFromDB(req.query);
sendResponse(res, {
statusCode: httpStatus.OK,
success: true,
message: 'Student are retrieved succesfully',
data: result,
});
});
const updateStudent = catchAsync(async (req, res) => {
const { id } = req.params;
const { student } = req.body;
const result = await StudentServices.updateStudentIntoDB(id, student);
sendResponse(res, {
statusCode: httpStatus.OK,
success: true,
message: 'Student is updated succesfully',
data: result,
});
});
const deleteStudent = catchAsync(async (req, res) => {
const { id } = req.params;
const result = await StudentServices.deleteStudentFromDB(id);
sendResponse(res, {
statusCode: httpStatus.OK,
success: true,
message: 'Student is deleted succesfully',
data: result,
});
});
export const StudentControllers = {
getAllStudents,
getSingleStudent,
deleteStudent,
updateStudent,
};
// student.interface.ts
import { Model, Types } from 'mongoose';
export type TUserName = {
firstName: string;
middleName: string;
lastName: string;
};
export type TGuardian = {
fatherName: string;
fatherOccupation: string;
fatherContactNo: string;
motherName: string;
motherOccupation: string;
motherContactNo: string;
};
export type TLocalGuardian = {
name: string;
occupation: string;
contactNo: string;
address: string;
};
export type TStudent = {
id: string;
user: Types.ObjectId;
name: TUserName;
gender: 'male' | 'female' | 'other';
dateOfBirth?: Date;
email: string;
contactNo: string;
emergencyContactNo: string;
bloogGroup?: 'A+' | 'A-' | 'B+' | 'B-' | 'AB+' | 'AB-' | 'O+' | 'O-';
presentAddress: string;
permanentAddress: string;
guardian: TGuardian;
localGuardian: TLocalGuardian;
profileImg?: string;
admissionSemester: Types.ObjectId;
academicDepartment: Types.ObjectId;
isDeleted: boolean;
};
//for creating static
export interface StudentModel extends Model<TStudent> {
isUserExists(id: string): Promise<TStudent | null>;
}
// for creating an instance
// export interface StudentMethods {
// isUserExists(id: string): Promise<TStudent | null>;
// }
// export type StudentModel = Model<
// TStudent,
// Record<string, never>,
// StudentMethods
// >;
// student.model.ts
import { Schema, model } from 'mongoose';
import {
StudentModel,
TGuardian,
TLocalGuardian,
TStudent,
TUserName,
} from './student.interface';
const userNameSchema = new Schema<TUserName>({
firstName: {
type: String,
required: [true, 'First Name is required'],
trim: true,
maxlength: [20, 'Name can not be more than 20 characters'],
},
middleName: {
type: String,
trim: true,
},
lastName: {
type: String,
trim: true,
required: [true, 'Last Name is required'],
maxlength: [20, 'Name can not be more than 20 characters'],
},
});
const guardianSchema = new Schema<TGuardian>({
fatherName: {
type: String,
trim: true,
required: [true, 'Father Name is required'],
},
fatherOccupation: {
type: String,
trim: true,
required: [true, 'Father occupation is required'],
},
fatherContactNo: {
type: String,
required: [true, 'Father Contact No is required'],
},
motherName: {
type: String,
required: [true, 'Mother Name is required'],
},
motherOccupation: {
type: String,
required: [true, 'Mother occupation is required'],
},
motherContactNo: {
type: String,
required: [true, 'Mother Contact No is required'],
},
});
const localGuradianSchema = new Schema<TLocalGuardian>({
name: {
type: String,
required: [true, 'Name is required'],
},
occupation: {
type: String,
required: [true, 'Occupation is required'],
},
contactNo: {
type: String,
required: [true, 'Contact number is required'],
},
address: {
type: String,
required: [true, 'Address is required'],
},
});
const studentSchema = new Schema<TStudent, StudentModel>(
{
id: {
type: String,
required: [true, 'ID is required'],
unique: true,
},
user: {
type: Schema.Types.ObjectId,
required: [true, 'User id is required'],
unique: true,
ref: 'User',
},
name: {
type: userNameSchema,
required: [true, 'Name is required'],
},
gender: {
type: String,
enum: {
values: ['male', 'female', 'other'],
message: '{VALUE} is not a valid gender',
},
required: [true, 'Gender is required'],
},
dateOfBirth: { type: Date },
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
},
contactNo: { type: String, required: [true, 'Contact number is required'] },
emergencyContactNo: {
type: String,
required: [true, 'Emergency contact number is required'],
},
bloogGroup: {
type: String,
enum: {
values: ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'],
message: '{VALUE} is not a valid blood group',
},
},
presentAddress: {
type: String,
required: [true, 'Present address is required'],
},
permanentAddress: {
type: String,
required: [true, 'Permanent address is required'],
},
guardian: {
type: guardianSchema,
required: [true, 'Guardian information is required'],
},
localGuardian: {
type: localGuradianSchema,
required: [true, 'Local guardian information is required'],
},
profileImg: { type: String },
admissionSemester: {
type: Schema.Types.ObjectId,
ref: 'AcademicSemester',
},
isDeleted: {
type: Boolean,
default: false,
},
academicDepartment: {
type: Schema.Types.ObjectId,
ref: 'AcademicDepartment',
},
},
{
toJSON: {
virtuals: true,
},
},
);
//virtual
studentSchema.virtual('fullName').get(function () {
return this?.name?.firstName + this?.name?.middleName + this?.name?.lastName;
});
// Query Middleware
studentSchema.pre('find', function (next) {
this.find({ isDeleted: { $ne: true } });
next();
});
studentSchema.pre('findOne', function (next) {
this.find({ isDeleted: { $ne: true } });
next();
});
studentSchema.pre('aggregate', function (next) {
this.pipeline().unshift({ $match: { isDeleted: { $ne: true } } });
next();
});
//creating a custom static method
studentSchema.statics.isUserExists = async function (id: string) {
const existingUser = await Student.findOne({ id });
return existingUser;
};
export const Student = model<TStudent, StudentModel>('Student', studentSchema);
// student.route.ts
import express from 'express';
import validateRequest from '../../middlewares/validateRequest';
import { StudentControllers } from './student.controller';
import { updateStudentValidationSchema } from './student.validation';
const router = express.Router();
router.get('/', StudentControllers.getAllStudents);
router.get('/:id', StudentControllers.getSingleStudent);
router.patch(
'/:id',
validateRequest(updateStudentValidationSchema),
StudentControllers.updateStudent,
);
router.delete('/:id', StudentControllers.deleteStudent);
export const StudentRoutes = router;
// student.service.ts
import httpStatus from 'http-status';
import mongoose from 'mongoose';
import QueryBuilder from '../../builder/QueryBuilder';
import AppError from '../../errors/AppError';
import { User } from '../user/user.model';
import { studentSearchableFields } from './student.constant';
import { TStudent } from './student.interface';
import { Student } from './student.model';
const getAllStudentsFromDB = async (query: Record<string, unknown>) => {
/*
const queryObj = { ...query }; // copying req.query object so that we can mutate the copy object
let searchTerm = ''; // SET DEFAULT VALUE
// IF searchTerm IS GIVEN SET IT
if (query?.searchTerm) {
searchTerm = query?.searchTerm as string;
}
// HOW OUR FORMAT SHOULD BE FOR PARTIAL MATCH :
{ email: { $regex : query.searchTerm , $options: i}}
{ presentAddress: { $regex : query.searchTerm , $options: i}}
{ 'name.firstName': { $regex : query.searchTerm , $options: i}}
// WE ARE DYNAMICALLY DOING IT USING LOOP
const searchQuery = Student.find({
$or: studentSearchableFields.map((field) => ({
[field]: { $regex: searchTerm, $options: 'i' },
})),
});
// FILTERING fUNCTIONALITY:
const excludeFields = ['searchTerm', 'sort', 'limit', 'page', 'fields'];
excludeFields.forEach((el) => delete queryObj[el]); // DELETING THE FIELDS SO THAT IT CAN'T MATCH OR FILTER EXACTLY
const filterQuery = searchQuery
.find(queryObj)
.populate('admissionSemester')
.populate({
path: 'academicDepartment',
populate: {
path: 'academicFaculty',
},
});
// SORTING FUNCTIONALITY:
let sort = '-createdAt'; // SET DEFAULT VALUE
// IF sort IS GIVEN SET IT
if (query.sort) {
sort = query.sort as string;
}
const sortQuery = filterQuery.sort(sort);
// PAGINATION FUNCTIONALITY:
let page = 1; // SET DEFAULT VALUE FOR PAGE
let limit = 1; // SET DEFAULT VALUE FOR LIMIT
let skip = 0; // SET DEFAULT VALUE FOR SKIP
// IF limit IS GIVEN SET IT
if (query.limit) {
limit = Number(query.limit);
}
// IF page IS GIVEN SET IT
if (query.page) {
page = Number(query.page);
skip = (page - 1) * limit;
}
const paginateQuery = sortQuery.skip(skip);
const limitQuery = paginateQuery.limit(limit);
// FIELDS LIMITING FUNCTIONALITY:
// HOW OUR FORMAT SHOULD BE FOR PARTIAL MATCH
fields: 'name,email'; // WE ARE ACCEPTING FROM REQUEST
fields: 'name email'; // HOW IT SHOULD BE
let fields = '-__v'; // SET DEFAULT VALUE
if (query.fields) {
fields = (query.fields as string).split(',').join(' ');
}
const fieldQuery = await limitQuery.select(fields);
return fieldQuery;
*/
const studentQuery = new QueryBuilder(
Student.find()
.populate('user')
.populate('admissionSemester')
.populate({
path: 'academicDepartment',
populate: {
path: 'academicFaculty',
},
}),
query,
)
.search(studentSearchableFields)
.filter()
.sort()
.paginate()
.fields();
const result = await studentQuery.modelQuery;
return result;
};
const getSingleStudentFromDB = async (id: string) => {
const result = await Student.findById(id)
.populate('admissionSemester')
.populate({
path: 'academicDepartment',
populate: {
path: 'academicFaculty',
},
});
return result;
};
const updateStudentIntoDB = async (id: string, payload: Partial<TStudent>) => {
const { name, guardian, localGuardian, ...remainingStudentData } = payload;
const modifiedUpdatedData: Record<string, unknown> = {
...remainingStudentData,
};
/*
guardain: {
fatherOccupation:"Teacher"
}
guardian.fatherOccupation = Teacher
name.firstName = 'Mezba'
name.lastName = 'Abedin'
*/
if (name && Object.keys(name).length) {
for (const [key, value] of Object.entries(name)) {
modifiedUpdatedData[`name.${key}`] = value;
}
}
if (guardian && Object.keys(guardian).length) {
for (const [key, value] of Object.entries(guardian)) {
modifiedUpdatedData[`guardian.${key}`] = value;
}
}
if (localGuardian && Object.keys(localGuardian).length) {
for (const [key, value] of Object.entries(localGuardian)) {
modifiedUpdatedData[`localGuardian.${key}`] = value;
}
}
const result = await Student.findByIdAndUpdate(id, modifiedUpdatedData, {
new: true,
runValidators: true,
});
return result;
};
const deleteStudentFromDB = async (id: string) => {
const session = await mongoose.startSession();
try {
session.startTransaction();
const deletedStudent = await Student.findByIdAndUpdate(
id,
{ isDeleted: true },
{ new: true, session },
);
if (!deletedStudent) {
throw new AppError(httpStatus.BAD_REQUEST, 'Failed to delete student');
}
// get user _id from deletedStudent
const userId = deletedStudent.user;
const deletedUser = await User.findByIdAndUpdate(
userId,
{ isDeleted: true },
{ new: true, session },
);
if (!deletedUser) {
throw new AppError(httpStatus.BAD_REQUEST, 'Failed to delete user');
}
await session.commitTransaction();
await session.endSession();
return deletedStudent;
} catch (err) {
await session.abortTransaction();
await session.endSession();
throw new Error('Failed to delete student');
}
};
export const StudentServices = {
getAllStudentsFromDB,
getSingleStudentFromDB,
updateStudentIntoDB,
deleteStudentFromDB,
};
// student.validation.ts
import { z } from 'zod';
const createUserNameValidationSchema = z.object({
firstName: z
.string()
.min(1)
.max(20)
.refine((value) => /^[A-Z]/.test(value), {
message: 'First Name must start with a capital letter',
}),
middleName: z.string(),
lastName: z.string(),
});
const createGuardianValidationSchema = z.object({
fatherName: z.string(),
fatherOccupation: z.string(),
fatherContactNo: z.string(),
motherName: z.string(),
motherOccupation: z.string(),
motherContactNo: z.string(),
});
const createLocalGuardianValidationSchema = z.object({
name: z.string(),
occupation: z.string(),
contactNo: z.string(),
address: z.string(),
});
export const createStudentValidationSchema = z.object({
body: z.object({
password: z.string().max(20),
student: z.object({
name: createUserNameValidationSchema,
gender: z.enum(['male', 'female', 'other']),
dateOfBirth: z.string().optional(),
email: z.string().email(),
contactNo: z.string(),
emergencyContactNo: z.string(),
bloogGroup: z.enum(['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']),
presentAddress: z.string(),
permanentAddress: z.string(),
guardian: createGuardianValidationSchema,
localGuardian: createLocalGuardianValidationSchema,
admissionSemester: z.string(),
profileImg: z.string(),
academicDepartment: z.string(),
}),
}),
});
const updateUserNameValidationSchema = z.object({
firstName: z.string().min(1).max(20).optional(),
middleName: z.string().optional(),
lastName: z.string().optional(),
});
const updateGuardianValidationSchema = z.object({
fatherName: z.string().optional(),
fatherOccupation: z.string().optional(),
fatherContactNo: z.string().optional(),
motherName: z.string().optional(),
motherOccupation: z.string().optional(),
motherContactNo: z.string().optional(),
});
const updateLocalGuardianValidationSchema = z.object({
name: z.string().optional(),
occupation: z.string().optional(),
contactNo: z.string().optional(),
address: z.string().optional(),
});
export const updateStudentValidationSchema = z.object({
body: z.object({
student: z.object({
name: updateUserNameValidationSchema,
gender: z.enum(['male', 'female', 'other']).optional(),
dateOfBirth: z.string().optional(),
email: z.string().email().optional(),
contactNo: z.string().optional(),
emergencyContactNo: z.string().optional(),
bloogGroup: z
.enum(['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'])
.optional(),
presentAddress: z.string().optional(),
permanentAddress: z.string().optional(),
guardian: updateGuardianValidationSchema.optional(),
localGuardian: updateLocalGuardianValidationSchema.optional(),
admissionSemester: z.string().optional(),
profileImg: z.string().optional(),
academicDepartment: z.string().optional(),
}),
}),
});
export const studentValidations = {
createStudentValidationSchema,
updateStudentValidationSchema,
};
The same structure will be followed for all other modules.
Then comes the routes folder that contains index.ts file that contains all the paths and route names and acts as a single point where all paths will be declared and it will be connected to the app.ts file. Its typical content will be as follows
import { Router } from 'express';
import { AdminRoutes } from '../modules/Admin/admin.route';
import { AuthRoutes } from '../modules/Auth/auth.route';
import { CourseRoutes } from '../modules/Course/course.route';
import { FacultyRoutes } from '../modules/Faculty/faculty.route';
import { offeredCourseRoutes } from '../modules/OfferedCourse/OfferedCourse.route';
import { AcademicDepartmentRoutes } from '../modules/academicDepartment/academicDepartment.route';
import { AcademicFacultyRoutes } from '../modules/academicFaculty/academicFaculty.route';
import { AcademicSemesterRoutes } from '../modules/academicSemester/academicSemester.route';
import { semesterRegistrationRoutes } from '../modules/semesterRegistration/semesterRegistration.route';
import { StudentRoutes } from '../modules/student/student.route';
import { UserRoutes } from '../modules/user/user.route';
const router = Router();
const moduleRoutes = [
{
path: '/users',
route: UserRoutes,
},
{
path: '/students',
route: StudentRoutes,
},
{
path: '/faculties',
route: FacultyRoutes,
},
{
path: '/admins',
route: AdminRoutes,
},
{
path: '/academic-semesters',
route: AcademicSemesterRoutes,
},
{
path: '/academic-faculties',
route: AcademicFacultyRoutes,
},
{
path: '/academic-departments',
route: AcademicDepartmentRoutes,
},
{
path: '/courses',
route: CourseRoutes,
},
{
path: '/semester-registrations',
route: semesterRegistrationRoutes,
},
{
path: '/offered-courses',
route: offeredCourseRoutes,
},
{
path: '/auth',
route: AuthRoutes,
},
];
moduleRoutes.forEach((route) => router.use(route.path, route.route));
export default router;
- At last, we have a utils folder that contains various files related to utility functions. In our case, we have files for the utility function catchAsync and send response. The catchAsync.ts file is used for reducing the duplication of the try-catch block. Its content is as follows
import { NextFunction, Request, RequestHandler, Response } from 'express';
const catchAsync = (fn: RequestHandler) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};
};
export default catchAsync;
- The sendResponse.ts file holds the response format for all routes. Its code is as follows:
import { Response } from 'express';
type TResponse<T> = {
statusCode: number;
success: boolean;
message?: string;
data: T;
};
const sendResponse = <T>(res: Response, data: TResponse<T>) => {
res.status(data?.statusCode).json({
success: data.success,
message: data.message,
data: data.data,
});
};
export default sendResponse;
- There could be many other folders and files included in an industry grade. The files and folders mentioned in this blog are some that can be used in any project.
Top comments (0)