Commit 54175469 authored by ThinhNC's avatar ThinhNC

feat: CURD application + query when get + swagger

parent a1e1b9d4
......@@ -8,18 +8,14 @@
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prisma:generate": "node scripts/prisma-run.js generate",
"prisma:studio": "node scripts/prisma-run.js studio",
"db:migrate": "node scripts/prisma-run.js migrate dev",
"db:migrate:init": "node scripts/prisma-run.js migrate dev --name init",
"db:migrate:deploy": "node scripts/prisma-run.js migrate deploy",
"db:migrate:reset": "node scripts/prisma-run.js migrate reset",
"db:migrate:status": "node scripts/prisma-run.js migrate status",
"db:seed": "node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts",
"lint": "eslint .",
"format": "prettier --write ."
},
......@@ -32,6 +28,7 @@
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
......@@ -41,6 +38,7 @@
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/node": "^22.10.2",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
......
......@@ -32,6 +32,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.11.0
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@4.22.2)
zod:
specifier: ^3.24.1
version: 3.25.76
......@@ -54,6 +57,9 @@ importers:
'@types/node':
specifier: ^22.10.2
version: 22.20.0
'@types/swagger-ui-express':
specifier: ^4.1.8
version: 4.1.8
eslint:
specifier: ^9.17.0
version: 9.39.4
......@@ -330,6 +336,9 @@ packages:
'@prisma/get-platform@5.22.0':
resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@tsconfig/node10@1.0.12':
resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
......@@ -405,6 +414,9 @@ packages:
'@types/strip-json-comments@0.0.30':
resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==}
'@types/swagger-ui-express@4.1.8':
resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
......@@ -1124,6 +1136,15 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
swagger-ui-dist@5.32.8:
resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==}
swagger-ui-express@5.0.1:
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
engines: {node: '>= v0.10.32'}
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
......@@ -1410,6 +1431,8 @@ snapshots:
dependencies:
'@prisma/debug': 5.22.0
'@scarf/scarf@1.4.0': {}
'@tsconfig/node10@1.0.12': {}
'@tsconfig/node12@1.0.11': {}
......@@ -1493,6 +1516,11 @@ snapshots:
'@types/strip-json-comments@0.0.30': {}
'@types/swagger-ui-express@4.1.8':
dependencies:
'@types/express': 4.17.25
'@types/serve-static': 1.15.10
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
......@@ -2257,6 +2285,15 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
swagger-ui-dist@5.32.8:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@4.22.2):
dependencies:
express: 4.22.2
swagger-ui-dist: 5.32.8
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
......
-- AlterTable
ALTER TABLE "users" ADD COLUMN "full_name" TEXT;
-- AlterTable
ALTER TABLE "applications" ADD COLUMN "deleted_at" TIMESTAMP(3);
This diff is collapsed.
......@@ -2,22 +2,31 @@ import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import swaggerUi from 'swagger-ui-express';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config';
const app = express();
app.set('trust proxy', true);
app.use(helmet());
app.use(
helmet({
contentSecurityPolicy: false, // Tắt CSP để Swagger UI tải được stylesheet
}),
);
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
app.use('/api/v1', routes);
app.use(notFoundMiddleware);
app.use(errorMiddleware);
export default app;
This diff is collapsed.
......@@ -3,9 +3,12 @@ import { ZodSchema } from 'zod';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
export function validate(schema: ZodSchema) {
type ValidateTarget = 'body' | 'query';
export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
const input = target === 'query' ? req.query : req.body;
const result = schema.safeParse(input);
if (!result.success) {
const messages = result.error.errors
......@@ -15,7 +18,12 @@ export function validate(schema: ZodSchema) {
return;
}
req.body = result.data;
if (target === 'query') {
req.query = result.data;
} else {
req.body = result.data;
}
next();
};
}
import { Request, Response, NextFunction } from 'express';
import { ApplicationService } from './application.service';
import { ApplicationQueryDto } from './application.dto';
export class ApplicationController {
private readonly service = new ApplicationService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as ApplicationQueryDto;
const result = await this.service.findAll(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findById(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.create(req.body);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
review = async (req: Request, res: Response, next: NextFunction) => {
try {
const approverId = req.user.id;
const result = await this.service.review(req.params.id, req.body, approverId);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.service.delete(req.params.id);
res.json({ success: true, message: 'Application deleted successfully' });
} catch (error) {
next(error);
}
};
}
import { ApplicationStatus } from '@prisma/client';
export interface ApplicationQueryDto {
status?: ApplicationStatus;
department?: string;
position?: string;
email?: string;
startDateFrom?: string;
startDateTo?: string;
sortBy?: 'createdAt' | 'startDate' | 'fullName' | 'status';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateApplicationDto {
fullName: string;
email: string;
phone: string;
department: string;
position: string;
startDate: string;
duration: number;
}
export interface ReviewApplicationDto {
status: 'APPROVED' | 'REJECTED';
}
export interface ApplicationResponseDto {
id: string;
fullName: string;
email: string;
phone: string;
department: string;
position: string;
startDate: Date;
duration: number;
status: ApplicationStatus;
approvedBy: string | null;
approvedAt: Date | null;
createdAt: Date;
updatedAt: Date;
approver?: {
id: string;
email: string;
fullName: string | null;
} | null;
}
import { ApplicationStatus, Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { ApplicationQueryDto } from './application.dto';
const approverSelect = {
id: true,
email: true,
fullName: true,
};
export class ApplicationRepository {
async findAll(query: ApplicationQueryDto) {
const {
status,
department,
position,
email,
startDateFrom,
startDateTo,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.ApplicationWhereInput = {
deletedAt: null,
...(status ? { status } : {}),
...(department ? { department: { contains: department, mode: 'insensitive' } } : {}),
...(position ? { position: { contains: position, mode: 'insensitive' } } : {}),
...(email ? { email: { contains: email, mode: 'insensitive' } } : {}),
...(startDateFrom || startDateTo
? {
startDate: {
...(startDateFrom ? { gte: new Date(startDateFrom) } : {}),
...(startDateTo ? { lte: new Date(startDateTo) } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.application.findMany({
where,
include: { approver: { select: approverSelect } },
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.application.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
findById(id: string) {
return prisma.application.findFirst({
where: { id, deletedAt: null },
include: { approver: { select: approverSelect } },
});
}
create(data: {
fullName: string;
email: string;
phone: string;
department: string;
position: string;
startDate: Date;
duration: number;
}) {
return prisma.application.create({
data,
});
}
review(
id: string,
status: 'APPROVED' | 'REJECTED',
approverId: string,
) {
return prisma.application.update({
where: { id },
data: {
status,
approvedBy: approverId,
approvedAt: new Date(),
},
include: { approver: { select: approverSelect } },
});
}
softDelete(id: string) {
return prisma.application.update({
where: { id },
data: { deletedAt: new Date() },
});
}
}
import { Router } from 'express';
import { ApplicationController } from './application.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import {
createApplicationSchema,
findAllApplicationSchema,
reviewApplicationSchema,
} from './application.validation';
const router = Router();
const controller = new ApplicationController();
router.post('/', validate(createApplicationSchema), controller.create);
// GET /applications?status=...&department=...&page=...&limit=...&sortBy=...&order=...
router.get('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(findAllApplicationSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), controller.findById);
router.patch('/:id/review', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(reviewApplicationSchema), controller.review);
router.delete('/:id', authMiddleware, requireRole('ADMIN'), controller.delete);
export default router;
import { ApplicationRepository } from './application.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { ApplicationQueryDto, CreateApplicationDto, ReviewApplicationDto } from './application.dto';
export class ApplicationService {
private readonly repository = new ApplicationRepository();
async findAll(query: ApplicationQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
const application = await this.repository.findById(id);
if (!application) {
throw new AppError('Application not found', 404, ERROR_CODE.NOT_FOUND);
}
return application;
}
async create(data: CreateApplicationDto) {
return this.repository.create({
fullName: data.fullName,
email: data.email,
phone: data.phone,
department: data.department,
position: data.position,
startDate: new Date(data.startDate),
duration: data.duration,
});
}
async review(id: string, dto: ReviewApplicationDto, approverId: string) {
const application = await this.findById(id);
if (application.status !== 'PENDING') {
throw new AppError(
`Application is already ${application.status.toLowerCase()}`,
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
return this.repository.review(id, dto.status, approverId);
}
async delete(id: string) {
const application = await this.findById(id);
if (application.status === 'APPROVED') {
throw new AppError(
'Cannot delete an approved application',
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
return this.repository.softDelete(id);
}
}
import { z } from 'zod';
export const findAllApplicationSchema = z.object({
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
department: z.string().optional(),
position: z.string().optional(),
email: z.string().email('Invalid email').optional(),
startDateFrom: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'startDateFrom must be a valid ISO date' })
.optional(),
startDateTo: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'startDateTo must be a valid ISO date' })
.optional(),
sortBy: z.enum(['createdAt', 'startDate', 'fullName', 'status']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createApplicationSchema = z.object({
fullName: z.string().min(1, 'Full name is required').max(100),
email: z.string().min(1, 'Email is required').email('Invalid email format'),
phone: z.string().min(9, 'Phone must be at least 9 digits').max(15),
department: z.string().min(1, 'Department is required'),
position: z.string().min(1, 'Position is required'),
startDate: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: 'startDate must be a valid ISO date string',
}),
duration: z
.number({ invalid_type_error: 'Duration must be a number' })
.int()
.positive('Duration must be a positive integer'),
});
export const reviewApplicationSchema = z.object({
status: z.enum(['APPROVED', 'REJECTED'], {
errorMap: () => ({ message: 'status must be APPROVED or REJECTED' }),
}),
});
......@@ -45,6 +45,7 @@ export class AuthService {
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role.name,
},
};
......@@ -113,6 +114,7 @@ export class AuthService {
return {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role.name,
isActive: user.isActive,
createdAt: user.createdAt,
......
import { Request, Response, NextFunction } from 'express';
import { UserService } from './user.service';
import { UserQueryDto } from './user.dto';
export class UserController {
private readonly service = new UserService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findAll();
const query = req.query as unknown as UserQueryDto;
const result = await this.service.findAll(query);
res.json({
success: true,
data: result,
});
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
......
export interface UserQueryDto {
email?: string;
fullName?: string;
roleName?: string;
isActive?: boolean;
sortBy?: 'createdAt' | 'email' | 'fullName';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateUserDto {
email: string;
password: string;
......
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { UserQueryDto } from './user.dto';
export class UserRepository {
findAll() {
return prisma.user.findMany({
include: { role: true },
orderBy: { createdAt: 'desc' },
});
async findAll(query: UserQueryDto) {
const {
email,
fullName,
roleName,
isActive,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.UserWhereInput = {
...(email ? { email: { contains: email, mode: 'insensitive' } } : {}),
...(fullName ? { fullName: { contains: fullName, mode: 'insensitive' } } : {}),
...(isActive !== undefined ? { isActive } : {}),
...(roleName ? { role: { name: roleName } } : {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.user.findMany({
where,
include: { role: true },
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.user.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
findById(id: string) {
......
......@@ -3,12 +3,13 @@ import { UserController } from './user.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { createUserSchema, updateUserSchema } from './user.validation';
import { createUserSchema, findAllUserSchema, updateUserSchema } from './user.validation';
const router = Router();
const controller = new UserController();
router.get('/', authMiddleware, requireRole('ADMIN'), controller.findAll);
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole('ADMIN'), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN'), controller.findById);
router.post('/', authMiddleware, requireRole('ADMIN'), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole('ADMIN'), validate(updateUserSchema), controller.update);
......
......@@ -2,12 +2,13 @@ import bcrypt from 'bcryptjs';
import { UserRepository } from './user.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { UserQueryDto } from './user.dto';
export class UserService {
private readonly repository = new UserRepository();
async findAll() {
return this.repository.findAll();
async findAll(query: UserQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
......
import { z } from 'zod';
export const findAllUserSchema = z.object({
email: z.string().optional(),
fullName: z.string().optional(),
roleName: z.enum(['ADMIN', 'LEADER', 'INTERN']).optional(),
isActive: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.optional(),
sortBy: z.enum(['createdAt', 'email', 'fullName']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createUserSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
email: z.string().min(1, 'Email is required').email('Invalid email format'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^a-zA-Z0-9]/, 'Password must contain at least one special character'),
roleId: z.string().uuid('Invalid roleId format'),
});
......
import { Router } from 'express';
import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route';
import applicationRoute from '../modules/applications/application.route';
const router = Router();
......@@ -10,5 +11,6 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/applications', applicationRoute);
export default router;
......@@ -5,5 +5,5 @@ import { envConfig } from './config/env.config';
const PORT = envConfig.port;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${envConfig.nodeEnv} mode`);
console.log(`Server running on http://localhost:${PORT} in ${envConfig.nodeEnv} mode`);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment