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);
......@@ -13,9 +13,9 @@ datasource db {
// 1. Role
model Role {
id String @id @default(uuid()) @db.Uuid
name String @unique // ADMIN, LEADER, INTERN
users User[]
id String @id @default(uuid()) @db.Uuid
name String @unique // ADMIN, LEADER, INTERN
users User[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
......@@ -25,27 +25,28 @@ model Role {
// 2. User
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
password String
roleId String @db.Uuid @map("role_id")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
role Role @relation(fields: [roleId], references: [id])
id String @id @default(uuid()) @db.Uuid
email String @unique
password String
fullName String? @map("full_name")
roleId String @map("role_id") @db.Uuid
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
role Role @relation(fields: [roleId], references: [id])
// Relations
approvedApplications Application[] @relation("ApprovedApplications")
internProfile InternProfile? @relation("InternUser")
leadedInterns InternProfile[] @relation("LeaderInterns")
notificationSetting NotificationSetting?
createdTasks Task[] @relation("CreatedTasks")
assignedTasks TaskAssignment[] @relation("AssignedTasks")
reviewedSubmissions TaskSubmission[] @relation("ReviewedSubmissions")
leaderEvaluations WeeklyEvaluation[] @relation("LeaderEvaluations")
notifications Notification[]
refreshTokens RefreshToken[]
approvedApplications Application[] @relation("ApprovedApplications")
internProfile InternProfile? @relation("InternUser")
leadedInterns InternProfile[] @relation("LeaderInterns")
notificationSetting NotificationSetting?
createdTasks Task[] @relation("CreatedTasks")
assignedTasks TaskAssignment[] @relation("AssignedTasks")
reviewedSubmissions TaskSubmission[] @relation("ReviewedSubmissions")
leaderEvaluations WeeklyEvaluation[] @relation("LeaderEvaluations")
notifications Notification[]
refreshTokens RefreshToken[]
@@index([roleId])
@@map("users")
......@@ -59,21 +60,22 @@ enum ApplicationStatus {
}
model Application {
id String @id @default(uuid()) @db.Uuid
fullName String @map("full_name")
email String
phone String
department String
position String
startDate DateTime @map("start_date")
duration Int // Duration of internship (months or weeks)
status ApplicationStatus @default(PENDING)
approvedBy String? @db.Uuid @map("approved_by")
approvedAt DateTime? @map("approved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
approver User? @relation("ApprovedApplications", fields: [approvedBy], references: [id], onDelete: SetNull)
id String @id @default(uuid()) @db.Uuid
fullName String @map("full_name")
email String
phone String
department String
position String
startDate DateTime @map("start_date")
duration Int // Duration of internship (months or weeks)
status ApplicationStatus @default(PENDING)
approvedBy String? @map("approved_by") @db.Uuid
approvedAt DateTime? @map("approved_at")
deletedAt DateTime? @map("deleted_at") // Soft delete
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
approver User? @relation("ApprovedApplications", fields: [approvedBy], references: [id], onDelete: SetNull)
@@index([approvedBy])
@@map("applications")
......@@ -87,28 +89,28 @@ enum InternStatus {
}
model InternProfile {
id String @id @default(uuid()) @db.Uuid
userId String @unique @db.Uuid @map("user_id")
leaderId String? @db.Uuid @map("leader_id")
fullName String @map("full_name")
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
leaderId String? @map("leader_id") @db.Uuid
fullName String @map("full_name")
phone String
department String
position String
startDate DateTime @map("start_date")
duration Int // Duration of internship
discordUsername String? @map("discord_username")
discordRoleGranted Boolean @default(false) @map("discord_role_granted")
status InternStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("InternUser", fields: [userId], references: [id], onDelete: Cascade)
leader User? @relation("LeaderInterns", fields: [leaderId], references: [id], onDelete: SetNull)
startDate DateTime @map("start_date")
duration Int // Duration of internship
discordUsername String? @map("discord_username")
discordRoleGranted Boolean @default(false) @map("discord_role_granted")
status InternStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("InternUser", fields: [userId], references: [id], onDelete: Cascade)
leader User? @relation("LeaderInterns", fields: [leaderId], references: [id], onDelete: SetNull)
// Relations
assignments TaskAssignment[]
dailyReports DailyReport[]
weeklyEvaluations WeeklyEvaluation[]
assignments TaskAssignment[]
dailyReports DailyReport[]
weeklyEvaluations WeeklyEvaluation[]
@@index([leaderId])
@@map("intern_profiles")
......@@ -117,14 +119,14 @@ model InternProfile {
// 5. NotificationSetting
model NotificationSetting {
id String @id @default(uuid()) @db.Uuid
userId String @unique @db.Uuid @map("user_id")
userId String @unique @map("user_id") @db.Uuid
webEnabled Boolean @default(true) @map("web_enabled")
emailEnabled Boolean @default(true) @map("email_enabled")
discordEnabled Boolean @default(false) @map("discord_enabled")
emailEnabled Boolean @default(true) @map("email_enabled")
discordEnabled Boolean @default(false) @map("discord_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("notification_settings")
}
......@@ -137,17 +139,17 @@ enum TaskPriority {
}
model Task {
id String @id @default(uuid()) @db.Uuid
id String @id @default(uuid()) @db.Uuid
title String
description String?
deadline DateTime
priority TaskPriority @default(MEDIUM)
createdBy String @db.Uuid @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
priority TaskPriority @default(MEDIUM)
createdBy String @map("created_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
creator User @relation("CreatedTasks", fields: [createdBy], references: [id], onDelete: Cascade)
assignment TaskAssignment?
creator User @relation("CreatedTasks", fields: [createdBy], references: [id], onDelete: Cascade)
assignment TaskAssignment?
@@index([createdBy])
@@map("tasks")
......@@ -163,16 +165,16 @@ enum AssignmentStatus {
model TaskAssignment {
id String @id @default(uuid()) @db.Uuid
taskId String @unique @db.Uuid @map("task_id")
internId String @db.Uuid @map("intern_id")
assignedBy String @db.Uuid @map("assigned_by")
taskId String @unique @map("task_id") @db.Uuid
internId String @map("intern_id") @db.Uuid
assignedBy String @map("assigned_by") @db.Uuid
status AssignmentStatus @default(TODO)
assignedAt DateTime @default(now()) @map("assigned_at")
updatedAt DateTime @updatedAt @map("updated_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
assigner User @relation("AssignedTasks", fields: [assignedBy], references: [id], onDelete: Cascade)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
assigner User @relation("AssignedTasks", fields: [assignedBy], references: [id], onDelete: Cascade)
submissions TaskSubmission[]
@@index([internId])
......@@ -189,20 +191,20 @@ enum ReviewStatus {
model TaskSubmission {
id String @id @default(uuid()) @db.Uuid
assignmentId String @db.Uuid @map("assignment_id")
assignmentId String @map("assignment_id") @db.Uuid
attempt Int
prLink String? @map("pr_link")
videoDemo String? @map("video_demo")
note String?
reviewStatus ReviewStatus @default(PENDING) @map("review_status")
reviewComment String? @map("review_comment")
reviewedBy String? @db.Uuid @map("reviewed_by")
reviewedBy String? @map("reviewed_by") @db.Uuid
reviewedAt DateTime? @map("reviewed_at")
submittedAt DateTime @default(now()) @map("submitted_at")
updatedAt DateTime @updatedAt @map("updated_at")
assignment TaskAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
reviewer User? @relation("ReviewedSubmissions", fields: [reviewedBy], references: [id], onDelete: SetNull)
assignment TaskAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
reviewer User? @relation("ReviewedSubmissions", fields: [reviewedBy], references: [id], onDelete: SetNull)
@@index([assignmentId])
@@index([reviewedBy])
......@@ -211,15 +213,15 @@ model TaskSubmission {
// 9. DailyReport
model DailyReport {
id String @id @default(uuid()) @db.Uuid
internId String @db.Uuid @map("intern_id")
id String @id @default(uuid()) @db.Uuid
internId String @map("intern_id") @db.Uuid
content String
prLink String? @map("pr_link")
videoDemo String? @map("video_demo")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
prLink String? @map("pr_link")
videoDemo String? @map("video_demo")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
@@index([internId])
@@map("daily_reports")
......@@ -227,21 +229,21 @@ model DailyReport {
// 10. WeeklyEvaluation
model WeeklyEvaluation {
id String @id @default(uuid()) @db.Uuid
internId String @db.Uuid @map("intern_id")
leaderId String @db.Uuid @map("leader_id")
id String @id @default(uuid()) @db.Uuid
internId String @map("intern_id") @db.Uuid
leaderId String @map("leader_id") @db.Uuid
week Int
communication Float
attitude Float
learning Float
coding Float
totalScore Float @map("total_score")
totalScore Float @map("total_score")
comment String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
leader User @relation("LeaderEvaluations", fields: [leaderId], references: [id], onDelete: Cascade)
intern InternProfile @relation(fields: [internId], references: [id], onDelete: Cascade)
leader User @relation("LeaderEvaluations", fields: [leaderId], references: [id], onDelete: Cascade)
@@unique([internId, week])
@@index([leaderId])
......@@ -250,17 +252,17 @@ model WeeklyEvaluation {
// 11. Notification
model Notification {
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid @map("user_id")
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
title String
content String
type String
isRead Boolean @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
isRead Boolean @default(false) @map("is_read")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
logs NotificationLog[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
logs NotificationLog[]
@@index([userId])
@@map("notifications")
......@@ -280,12 +282,12 @@ enum NotificationLogStatus {
model NotificationLog {
id String @id @default(uuid()) @db.Uuid
notificationId String @db.Uuid @map("notification_id")
notificationId String @map("notification_id") @db.Uuid
channel NotificationChannel
status NotificationLogStatus
sentAt DateTime @default(now()) @map("sent_at")
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
@@index([notificationId])
@@map("notification_logs")
......@@ -295,13 +297,13 @@ model NotificationLog {
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
token String @unique
userId String @db.Uuid @map("user_id")
userId String @map("user_id") @db.Uuid
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("refresh_tokens")
......
......@@ -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;
import { SwaggerUiOptions } from 'swagger-ui-express';
export const swaggerOptions: SwaggerUiOptions = {
customCss: `
.swagger-ui .topbar { background-color: #1a1a2e; }
.swagger-ui .topbar-wrapper .link img { display: none; }
.swagger-ui .topbar-wrapper .link::after { content: 'NexCampus API'; color: white; font-size: 1.2rem; font-weight: bold; }
`,
customSiteTitle: 'NexCampus API Docs',
};
export const swaggerSpec = {
openapi: '3.0.0',
info: {
title: 'NexCampus API',
version: '1.0.0',
description:
'Backend API cho hệ thống quản lý thực tập sinh NexCampus. Bao gồm các module: Auth, Users, Applications.',
contact: { name: 'NexCampus Team' },
},
servers: [
{ url: '/api/v1', description: 'Development server' },
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Nhập Access Token nhận được từ POST /auth/login',
},
},
schemas: {
// ─── Common ───────────────────────────────────────────────────────────
SuccessResponse: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
},
},
PaginationMeta: {
type: 'object',
properties: {
total: { type: 'integer', example: 42 },
page: { type: 'integer', example: 1 },
limit: { type: 'integer', example: 20 },
totalPages: { type: 'integer', example: 3 },
},
},
ErrorResponse: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
message: { type: 'string', example: 'Error message' },
code: { type: 'string', example: 'NOT_FOUND' },
},
},
// ─── Role ─────────────────────────────────────────────────────────────
Role: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] },
},
},
// ─── User ─────────────────────────────────────────────────────────────
User: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
roleId: { type: 'string', format: 'uuid' },
role: { $ref: '#/components/schemas/Role' },
isActive: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
CreateUserBody: {
type: 'object',
required: ['email', 'password', 'roleId'],
properties: {
email: { type: 'string', format: 'email', example: 'intern@nexcampus.local' },
password: { type: 'string', minLength: 8, example: 'Intern@123456' },
roleId: { type: 'string', format: 'uuid', example: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
},
},
UpdateUserBody: {
type: 'object',
properties: {
isActive: { type: 'boolean' },
roleId: { type: 'string', format: 'uuid' },
},
},
// ─── Auth ─────────────────────────────────────────────────────────────
LoginBody: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email', example: 'admin@nexcampus.local' },
password: { type: 'string', example: 'Admin@123456' },
},
},
TokenPair: {
type: 'object',
properties: {
accessToken: { type: 'string' },
refreshToken: { type: 'string' },
},
},
RefreshBody: {
type: 'object',
required: ['refreshToken'],
properties: {
refreshToken: { type: 'string' },
},
},
LogoutBody: {
type: 'object',
required: ['refreshToken'],
properties: {
refreshToken: { type: 'string' },
},
},
// ─── Application ──────────────────────────────────────────────────────
Application: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
fullName: { type: 'string', example: 'Nguyễn Văn A' },
email: { type: 'string', format: 'email' },
phone: { type: 'string', example: '0912345678' },
department: { type: 'string', example: 'Engineering' },
position: { type: 'string', example: 'Frontend Developer' },
startDate: { type: 'string', format: 'date-time' },
duration: { type: 'integer', example: 3, description: 'Số tháng thực tập' },
status: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'] },
approvedBy: { type: 'string', format: 'uuid', nullable: true },
approvedAt: { type: 'string', format: 'date-time', nullable: true },
deletedAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
approver: {
nullable: true,
allOf: [{
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
},
}],
},
},
},
CreateApplicationBody: {
type: 'object',
required: ['fullName', 'email', 'phone', 'department', 'position', 'startDate', 'duration'],
properties: {
fullName: { type: 'string', example: 'Nguyễn Văn A' },
email: { type: 'string', format: 'email', example: 'vana@gmail.com' },
phone: { type: 'string', example: '0912345678' },
department: { type: 'string', example: 'Engineering' },
position: { type: 'string', example: 'Frontend Developer' },
startDate: { type: 'string', format: 'date', example: '2025-08-01' },
duration: { type: 'integer', example: 3, description: 'Số tháng thực tập' },
},
},
ReviewApplicationBody: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', enum: ['APPROVED', 'REJECTED'] },
},
},
},
// ─── Reusable parameters ─────────────────────────────────────────────────
parameters: {
PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } },
LimitParam: { in: 'query', name: 'limit', schema: { type: 'integer', default: 20, maximum: 100 } },
OrderParam: { in: 'query', name: 'order', schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } },
},
// ─── Reusable responses ───────────────────────────────────────────────────
responses: {
Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
Forbidden: { description: 'Không có quyền', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
NotFound: { description: 'Không tìm thấy', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
Validation: { description: 'Dữ liệu không hợp lệ', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
},
},
tags: [
{ name: 'Auth', description: 'Đăng nhập, làm mới token, đăng xuất' },
{ name: 'Users', description: 'Quản lý tài khoản người dùng (Admin only)' },
{ name: 'Applications', description: 'Đơn xin thực tập (Onboarding)' },
{ name: 'System', description: 'Health check' },
],
paths: {
// ─── System ─────────────────────────────────────────────────────────────
'/health': {
get: {
tags: ['System'],
summary: 'Health check',
responses: {
200: { description: 'Server đang chạy', content: { 'application/json': { schema: { type: 'object', properties: { status: { type: 'string', example: 'ok' }, timestamp: { type: 'string' } } } } } },
},
},
},
// ─── Auth ────────────────────────────────────────────────────────────────
'/auth/login': {
post: {
tags: ['Auth'],
summary: 'Đăng nhập',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginBody' } } } },
responses: {
200: {
description: 'Đăng nhập thành công',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/TokenPair' } } }] } } },
},
401: { description: 'Sai thông tin đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/me': {
get: {
tags: ['Auth'],
summary: 'Lấy thông tin người dùng hiện tại',
security: [{ BearerAuth: [] }],
responses: {
200: {
description: 'Thông tin tài khoản đang đăng nhập',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
},
},
},
'/auth/refresh': {
post: {
tags: ['Auth'],
summary: 'Làm mới Access Token',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/RefreshBody' } } } },
responses: {
200: {
description: 'Cặp token mới',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/TokenPair' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/auth/logout': {
post: {
tags: ['Auth'],
summary: 'Đăng xuất (thu hồi Refresh Token)',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/LogoutBody' } } } },
responses: {
200: { description: 'Đăng xuất thành công', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } },
400: { description: 'Token không tồn tại', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
},
},
},
// ─── Users ───────────────────────────────────────────────────────────────
'/users': {
get: {
tags: ['Users'],
summary: 'Danh sách tài khoản (có filter, sort, phân trang)',
security: [{ BearerAuth: [] }],
parameters: [
{ in: 'query', name: 'email', schema: { type: 'string' }, description: 'Tìm theo email (contains)' },
{ in: 'query', name: 'fullName', schema: { type: 'string' }, description: 'Tìm theo tên (contains)' },
{ in: 'query', name: 'roleName', schema: { type: 'string', enum: ['ADMIN', 'LEADER', 'INTERN'] }, description: 'Lọc theo role' },
{ in: 'query', name: 'isActive', schema: { type: 'string', enum: ['true', 'false'] }, description: 'Lọc theo trạng thái' },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'email', 'fullName'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Danh sách user có phân trang',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/User' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
},
},
post: {
tags: ['Users'],
summary: 'Tạo tài khoản mới (Admin only)',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateUserBody' } } } },
responses: {
201: {
description: 'Tài khoản đã tạo',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
409: { description: 'Email đã tồn tại', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/users/{id}': {
get: {
tags: ['Users'],
summary: 'Chi tiết tài khoản theo ID',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin user', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
put: {
tags: ['Users'],
summary: 'Cập nhật tài khoản (kích hoạt/đổi role)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateUserBody' } } } },
responses: {
200: { description: 'Cập nhật thành công', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/User' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
// ─── Applications ────────────────────────────────────────────────────────
'/applications': {
post: {
tags: ['Applications'],
summary: 'Nộp đơn xin thực tập (Public — không cần đăng nhập)',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateApplicationBody' } } } },
responses: {
201: {
description: 'Đơn đã được tiếp nhận (status = PENDING)',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Application' } } }] } } },
},
422: { $ref: '#/components/responses/Validation' },
},
},
get: {
tags: ['Applications'],
summary: 'Danh sách đơn (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [
{ in: 'query', name: 'status', schema: { type: 'string', enum: ['PENDING', 'APPROVED', 'REJECTED'] } },
{ in: 'query', name: 'department', schema: { type: 'string' }, description: 'Lọc bộ phận (contains)' },
{ in: 'query', name: 'position', schema: { type: 'string' }, description: 'Lọc vị trí (contains)' },
{ in: 'query', name: 'email', schema: { type: 'string' }, description: 'Tìm theo email (contains)' },
{ in: 'query', name: 'startDateFrom', schema: { type: 'string', format: 'date' }, description: 'Ngày bắt đầu từ' },
{ in: 'query', name: 'startDateTo', schema: { type: 'string', format: 'date' }, description: 'Ngày bắt đầu đến' },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'startDate', 'fullName', 'status'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Danh sách đơn có phân trang',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Application' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
},
},
},
'/applications/{id}': {
get: {
tags: ['Applications'],
summary: 'Chi tiết một đơn',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin đơn', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Application' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
delete: {
tags: ['Applications'],
summary: 'Xoá mềm đơn (Admin only — chỉ PENDING hoặc REJECTED)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Đã xoá mềm', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
409: { description: 'Không thể xoá đơn đã APPROVED', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
},
},
},
'/applications/{id}/review': {
patch: {
tags: ['Applications'],
summary: 'Phê duyệt hoặc từ chối đơn (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/ReviewApplicationBody' } } } },
responses: {
200: { description: 'Đơn đã được xử lý', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Application' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
409: { description: 'Đơn không còn ở trạng thái PENDING', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
422: { $ref: '#/components/responses/Validation' },
},
},
},
},
};
......@@ -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