Commit 448f03b2 authored by Đoàn Vũ Bình Dương's avatar Đoàn Vũ Bình Dương

Merge branch 'feat/authentication' into 'develop'

Feat/authentication

See merge request !3
parents d34264f1 d505ed0d
# KHOÁN HOCH DUNG TERM API
## I. HIUU V CAU TRC MOI
### 1. Cau tru du lieu truoc khi thay i:
```
Student Collection:
{
"_id": "student123",
"fullName": "Nguyen Van A",
"email": "a@example.com",
"courseId": "course789",
"courseName": "Node.js",
"semester": "2026-1", // String thuan
"startDate": "2026-01-15", // Ngay bat dau hoc ky
"endDate": "2026-05-31", // Ngay ket thuc hoc ky
"status": "active"
}
```
### 2. Cau tru du lieu SAU khi thay i:
```
Student Collection:
{
"_id": "student123",
"fullName": "Nguyen Van A",
"email": "a@example.com",
"courseId": "course789",
"courseName": "Node.js",
"termId": "term456", // Reference n Term collection
"status": "active"
}
Term Collection:
{
"_id": "term456",
"name": "2026-1",
"description": "Hoc ky 1 nam 2026",
"startDate": "2026-01-15", // Chuyen len Term
"endDate": "2026-05-31", // Chuyen len Term
"status": "active",
"courseIds": ["course789"] // Danh sach khoa hoc trong ky
}
```
## II. KHOANH S DUNG
### Bc 1: Quan ly Hoc ky (Admin)
```bash
# 1. Tao hoc ky moi
POST /api/v1.0/terms
{
"name": "2026-2",
"description": "Hoc ky 2 nam 2026",
"startDate": "2026-06-01",
"endDate": "2026-10-31",
"status": "active"
}
# 2. L danh sach hoc ky
GET /api/v1.0/terms
# 3. Cap nhat hoc ky
PUT /api/v1.0/terms/{termId}
{
"status": "completed"
}
```
### Bc 2: ng ky Sinh vien (Admin/Staff)
```bash
# 1. Lay danh sach hoc ky dang active
GET /api/v1.0/terms?status=active
# 2. Chon hoc ky va lay termId
# Response: [{"_id": "term456", "name": "2026-1", ...}]
# 3. Tao sinh vien voi termId
POST /api/v1.0/students
{
"fullName": "Nguyen Van B",
"email": "b@example.com",
"phone": "0912345678",
"courseId": "course789",
"courseName": "Node.js",
"termId": "term456", // <-- QUAN TRNG: Lay tu Bc 1
"status": "active"
}
```
### Bc 3: Xem thng tin Sinh vien kèm Term
```bash
# Lay sinh vien voi populate term
GET /api/v1.0/students/{studentId}
# Response se bao gm thng tin term:
{
"_id": "student123",
"fullName": "Nguyen Van A",
"termId": "term456",
"term": { // <-- Term info du populate
"_id": "term456",
"name": "2026-1",
"startDate": "2026-01-15",
"endDate": "2026-05-31"
}
}
```
## III. WORKFLOW NG K SINH VIEN
### Frontend Workflow:
1. **Form ng ky sinh vien:**
- [ ] Thng tin c bn: Tn, Email, Phone
- [ ] Chn kho hc: Dropdown (Course API)
- [ ] Chn hc ky: Dropdown (Term API)
- [ ] Submit
2. **Backend Workflow:**
```javascript
// 1. Frontend gi form data
{
fullName: "Nguyen Van B",
email: "b@example.com",
phone: "0912345678",
courseId: "course789",
courseName: "Node.js",
termId: "term456" // <-- User chn hc ky
}
// 2. Backend luu student
await studentProvider.create({
fullName, email, phone,
courseId, courseName,
termId, // <-- Luu reference
status: 'active'
});
```
## IV. L ICH GI SAU KHI THAY I
### 1. Cch truy vn truoc i:
```javascript
// Lay sinh vien theo hoc ky
Student.find({ semester: "2026-1" })
// Lay thng tin hc ky
// Phai lu trong memory ho query lai
```
### 2. Cch truy vqn sau i:
```javascript
// Lay sinh vien theo hc ky
Student.find({ termId: "term456" })
// Lay thng tin hc ky
const term = await Term.findById(termId);
// Ho populate trong 1 query
Student.find({ termId: "term456" }).populate('termId');
```
## V. CAC API CN CN NH
### 1. Student API cn cp nht:
```javascript
// Trong StudentProvider.ts
async getAll(opts) {
// Them filter theo termId
if (opts.termId) filter.termId = opts.termId;
// Populate term info khi cn
const query = Student.find(filter).populate('termId');
}
// Trong Student validation
const studentSchema = Joi.object({
// ... cacs field khc
termId: Joi.string().required().messages({
"any.required": "Term ID is required"
}),
// Xoa semester, startDate, endDate
});
```
### 2. Frontend cn thay i:
- Form ng ky: Thm dropdown chn hc ky
- List sinh vien: Hi thng tin hc ky
- Filter: Thm filter theo hc ky
## VI. MIGRATION PROCESS
### 1. Chy migration script:
```bash
npm run build
node scripts/migrate-student-semester-to-term.js
```
### 2. Script se lm:
1. Scan c student records
2. Tao Term cho mi unique semester
3. Cp nh student.termId = term._id
4. Xoa semester, startDate, endDate
### 3. Kt qu:
- Student collection: Ch c termId
- Term collection: C thng tin hc ky
- Dng tin: 100%
## VII. FAQ
### Q: Tm sao khng gi semester string?
A:
- Semester string khng normalize ("2026-1", "2026-HK1", "Fall2026")
- Khng lu thng tin chi tit (ngy bt/u, ktt)
- Khi cn thay i, ph update N student
### Q: Lm sao ly danh sch hc ky?
A:
```bash
GET /api/v1.0/terms?status=active
# Response: danh sch hc ky dang active
```
### Q: Lm sao bt student thuoc hc ky no?
A:
```bash
GET /api/v1.0/students?termId=term456
# Ho populate:
GET /api/v1.0/students/{id} // se co term info
```
## VIII. KET LUN
1. **Term l collection rieng bi:** Qun ly hc ky trung tam
2. **Student ch reference termId:** Lin kt qua termId
3. **Workflow ng ky:** Chn hc ky trc, sau i ng ky sinh vien
4. **Benefit:** Dng tin, d quun ly, d m rng
**VIU C: Khi dng form ng ký sinh viên, bn s có dropdown chn hc ký!**
const mongoose = require('mongoose');
const { Student } = require('../dist/models/Student');
const { Term } = require('../dist/models/Term');
/**
* Migration script to convert student semester strings to term references
* This script will:
* 1. Create Term records from unique semester values in students
* 2. Update student records to reference termId instead of semester string
* 3. Remove semester, startDate, endDate fields from student records
*/
async function migrateStudentSemesterToTerm() {
try {
console.log('Starting migration: Student semester to Term reference...');
// Connect to database
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/trainee-schedule');
console.log('Connected to database');
// Step 1: Get all unique semesters from students
console.log('Step 1: Finding unique semesters...');
const uniqueSemesters = await Student.distinct('semester');
console.log(`Found ${uniqueSemesters.length} unique semesters:`, uniqueSemesters);
// Step 2: Create Term records for each unique semester
console.log('Step 2: Creating Term records...');
const termMap = new Map(); // semester -> termId mapping
for (const semesterName of uniqueSemesters) {
// Extract year and semester number from semester string (e.g., "2026-1")
const match = semesterName.match(/(\d{4})-?(\d+)/);
if (match) {
const [, year, semesterNum] = match;
// Create default dates for the term
const startDate = new Date(`${year}-${semesterNum === '1' ? '01' : semesterNum === '2' ? '06' : '09'}-15`);
const endDate = new Date(`${year}-${semesterNum === '1' ? '05' : semesterNum === '2' ? '10' : '12'}-31`);
// Check if term already exists
let term = await Term.findOne({ name: semesterName });
if (!term) {
term = new Term({
name: semesterName,
description: `Hoc ky ${semesterNum} nam ${year}`,
startDate,
endDate,
status: 'active'
});
await term.save();
console.log(`Created term: ${semesterName} with ID: ${term._id}`);
} else {
console.log(`Term already exists: ${semesterName} with ID: ${term._id}`);
}
termMap.set(semesterName, term._id.toString());
}
}
// Step 3: Update student records
console.log('Step 3: Updating student records...');
let updatedCount = 0;
for (const [semesterName, termId] of termMap) {
const result = await Student.updateMany(
{ semester: semesterName },
{
$set: { termId },
$unset: { semester: 1, startDate: 1, endDate: 1 }
}
);
updatedCount += result.modifiedCount;
console.log(`Updated ${result.modifiedCount} students for semester ${semesterName}`);
}
console.log(`Migration completed! Updated ${updatedCount} student records.`);
console.log('Created/verified terms:', Array.from(termMap.keys()));
} catch (error) {
console.error('Migration failed:', error);
throw error;
} finally {
await mongoose.disconnect();
console.log('Disconnected from database');
}
}
// Run migration if this file is executed directly
if (require.main === module) {
migrateStudentSemesterToTerm()
.then(() => {
console.log('Migration completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});
}
module.exports = { migrateStudentSemesterToTerm };
...@@ -102,18 +102,17 @@ export default (_express: Application) => { ...@@ -102,18 +102,17 @@ export default (_express: Application) => {
return res.sendOk({ return res.sendOk({
data: { data: {
user: { user: {
id: loginResult.user.id, id: loginResult.user._id,
email: loginResult.user.email, email: loginResult.user.email,
username: loginResult.user.username, username: loginResult.user.username,
first_name: loginResult.user.first_name, first_name: loginResult.user.first_name,
last_name: loginResult.user.last_name, last_name: loginResult.user.last_name,
roles: (loginResult.user as any).roles, roles: (loginResult.user.roles as any[]).map(r => r.name),
permissions: (loginResult.user as any).permissions,
status: loginResult.user.status, status: loginResult.user.status,
last_login_at: loginResult.user_auth.last_login_at, last_login_at: loginResult.user.last_login,
}, },
session: { session: {
id: loginResult.session.id, id: loginResult.session._id,
expires_at: loginResult.session.expires_at, expires_at: loginResult.session.expires_at,
refresh_expires_at: loginResult.session.refresh_expires_at, refresh_expires_at: loginResult.session.refresh_expires_at,
}, },
......
...@@ -14,7 +14,7 @@ export default (_express: Application) => { ...@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Logout user * summary: Logout user
* description: Logout user and invalidate current session * description: Logout user and invalidate current session
* security: * security:
* - Bearer: [] * - BearerAuth: []
* responses: * responses:
* 200: * 200:
* description: Logged out successfully * description: Logged out successfully
......
...@@ -2,7 +2,7 @@ import { Application } from "express"; ...@@ -2,7 +2,7 @@ import { Application } from "express";
import { Resource } from "express-automatic-routes"; import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi"; import { Req, Res } from "#interfaces/IApi";
import { authenticate } from "#middlewares/auth"; import { authenticate } from "#middlewares/auth";
import { User } from "#models/User"; import { User } from "#models/mongodb/User";
export default (_express: Application) => { export default (_express: Application) => {
return <Resource>{ return <Resource>{
...@@ -14,7 +14,7 @@ export default (_express: Application) => { ...@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Get user profile * summary: Get user profile
* description: Get current authenticated user's profile information * description: Get current authenticated user's profile information
* security: * security:
* - Bearer: [] * - BearerAuth: []
* responses: * responses:
* 200: * 200:
* description: Profile retrieved successfully * description: Profile retrieved successfully
...@@ -25,48 +25,47 @@ export default (_express: Application) => { ...@@ -25,48 +25,47 @@ export default (_express: Application) => {
* properties: * properties:
* success: * success:
* type: boolean * type: boolean
* example: true
* data: * data:
* type: object * type: object
* properties: * properties:
* id: * id:
* type: string * type: string
* example: "uuid-string"
* email: * email:
* type: string * type: string
* example: "user@example.com"
* username: * username:
* type: string * type: string
* example: "john_doe" * first_name:
* role:
* type: string * type: string
* enum: [user, admin, system_admin] * last_name:
* example: "user" * type: string
* permissions: * status:
* type: string
* roles:
* type: array * type: array
* items: * items:
* type: string * type: string
* example: ["read:profile"] * example: ["admin"]
* 401:
* $ref: '#/components/responses/Unauthorized'
*/ */
get: { get: {
middleware: [authenticate], middleware: [authenticate],
handler: async (req: Req, res: Res) => { handler: async (req: Req, res: Res) => {
try { try {
// Get user with roles and permissions const user = await User.findById(req.user?.id)
const user = await User.findByPk(req.user?.id, { .select("-password_hash")
attributes: ["id", "email", "username", "first_name", "last_name", "status"], .populate("roles", "name");
});
if (!user) { if (!user) {
return res.error({ message: "User not found", status: 404 }); return res.error({ message: "User not found", status: 404 });
} }
const userData = { const userData = {
...user.toJSON(), id: user._id,
roles: req.user?.roles, email: user.email,
permissions: req.user?.permissions, username: user.username,
first_name: user.first_name,
last_name: user.last_name,
status: user.status,
roles: (user.roles as any[]).map(r => r.name),
}; };
return res.sendOk({ data: userData }); return res.sendOk({ data: userData });
......
...@@ -18,14 +18,7 @@ export default (_express: Application) => { ...@@ -18,14 +18,7 @@ export default (_express: Application) => {
* content: * content:
* application/json: * application/json:
* schema: * schema:
* type: object * $ref: '#/components/schemas/RefreshRequest'
* properties:
* refresh_token:
* type: string
* description: Refresh token (optional if sent via cookie)
* device_info:
* type: object
* description: Device information
* responses: * responses:
* 200: * 200:
* description: Token refreshed successfully * description: Token refreshed successfully
......
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { AuthService } from "#services/authService";
import { authenticate, requireAdmin } from "#middlewares/auth";
import { validateRegister } from "#middlewares/validators/auth";
export default (_express: Application) => {
return <Resource>{
/**
* @openapi
* /auth/register:
* post:
* tags: [Authentication]
* summary: Register a new user (Admin only)
* description: Create a new account. Only accessible by administrators.
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/RegisterRequest'
* responses:
* 201:
* description: User created successfully
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
* 409:
* description: User already exists
*/
post: {
middleware: [authenticate, requireAdmin, validateRegister],
handler: async (req: Req, res: Res) => {
try {
const userData = req.body;
const user = await AuthService.register(userData);
await user.populate("roles", "name");
return res.sendOk({
data: {
id: user._id,
email: user.email,
username: user.username,
status: user.status,
roles: (user.roles as any[]).map(r => r.name)
},
message: "Người dùng đã được tạo thành công",
message_en: "User created successfully",
statusCode: 201
});
} catch (error) {
return res.error(error);
}
},
},
};
};
...@@ -33,10 +33,10 @@ export default (_express: Application) => { ...@@ -33,10 +33,10 @@ export default (_express: Application) => {
* type: string * type: string
* description: Filter by course ID * description: Filter by course ID
* - in: query * - in: query
* name: semester * name: termId
* schema: * schema:
* type: string * type: string
* description: Filter by semester * description: Filter by term ID
* - in: query * - in: query
* name: search * name: search
* schema: * schema:
......
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { TermProvider, TermFindManyOptions } from "#providers/TermProvider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import { validateTermCreate, validateTermUpdate } from "#middlewares/validators/term";
export default (_express: Application) => {
const termProvider = new TermProvider();
return <Resource>{
/**
* @openapi
* /terms:
* get:
* tags: [Terms]
* summary: Get all terms
* description: Retrieve a list of terms with pagination, filtering and sorting
* parameters:
* - $ref: '#/components/parameters/filters'
* - $ref: '#/components/parameters/sortField'
* - $ref: '#/components/parameters/sortOrder'
* - $ref: '#/components/parameters/page'
* - $ref: '#/components/parameters/pageSize'
* - in: query
* name: status
* schema:
* type: string
* enum: [active, inactive, completed]
* description: Filter by status
* - in: query
* name: year
* schema:
* type: string
* description: Filter by year (e.g., "2026")
* - in: query
* name: search
* schema:
* type: string
* description: Search by name or description
* responses:
* 200:
* description: Successfully retrieved terms
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/responseGetAllData"
*/
get: {
middleware: [queryModifier],
handler: async (req: Req, res: Res) => {
try {
const options: TermFindManyOptions = {
...req.payload,
sortOrder: (req.payload?.sortOrder as 'asc' | 'desc' | undefined) || 'desc'
};
const data = await termProvider.getAll(options);
return res.sendOk({ data });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /terms:
* post:
* tags: [Terms]
* summary: Create a term
* description: Create a new term record
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/TermMutate"
* responses:
* 200:
* description: Term created successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/Term"
*/
post: {
middleware: [queryModifier, validateTermCreate],
handler: async (req: Req, res: Res) => {
try {
const createOptions = req.user?.id ? { createdBy: req.user.id } : {};
const data = await termProvider.create({ ...req.body }, createOptions);
return res.sendOk({ data, message: "Term created successfully" });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
};
};
import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { TermProvider } from "#providers/TermProvider";
import { Req, Res } from "#interfaces/IApi";
import { queryModifier } from "#middlewares/query-modifier";
import { validateId } from "#middlewares/validators";
import { validateTermUpdate } from "#middlewares/validators/term";
export default (_express: Application) => {
const termProvider = new TermProvider();
return <Resource>{
/**
* @openapi
* /terms/{id}:
* get:
* tags: [Terms]
* summary: Get term by ID
* description: Retrieve a single term record by its ID
* parameters:
* - name: id
* in: path
* required: true
* description: Term ID
* schema:
* type: string
* responses:
* 200:
* description: Term retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/Term"
* 404:
* description: Term not found
*/
get: {
middleware: [queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await termProvider.getById(req.params.id!);
return res.sendOk({ data });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /terms/{id}:
* put:
* tags: [Terms]
* summary: Update term by ID
* description: Update a single term record by its ID
* parameters:
* - name: id
* in: path
* required: true
* description: Term ID
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/TermMutate"
* responses:
* 200:
* description: Term updated successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* $ref: "#/components/schemas/Term"
* 404:
* description: Term not found
*/
put: {
middleware: [queryModifier, validateId, validateTermUpdate],
handler: async (req: Req, res: Res) => {
try {
const updateOptions = req.user?.id ? { updatedBy: req.user.id } : {};
const data = await termProvider.update(req.params.id!, { ...req.body }, updateOptions);
return res.sendOk({ data, message: "Term updated successfully" });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
/**
* @openapi
* /terms/{id}:
* delete:
* tags: [Terms]
* summary: Delete term by ID
* description: Delete a single term record by its ID
* parameters:
* - name: id
* in: path
* required: true
* description: Term ID
* schema:
* type: string
* responses:
* 200:
* description: Term deleted successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: "#/components/schemas/ApiResponse"
* - type: object
* properties:
* responseData:
* type: boolean
* example: true
* 404:
* description: Term not found
*/
delete: {
middleware: [queryModifier, validateId],
handler: async (req: Req, res: Res) => {
try {
const data = await termProvider.delete(req.params.id!);
return res.sendOk({ data, message: "Term deleted successfully" });
} catch (error) {
await termProvider.logError(error as Error);
return res.error(error);
}
},
},
};
};
...@@ -3,7 +3,7 @@ import { NextFunction } from "express"; ...@@ -3,7 +3,7 @@ import { NextFunction } from "express";
import { AuthService, JWTPayload } from "../services/authService"; import { AuthService, JWTPayload } from "../services/authService";
import { GenericError } from "#interfaces/error/generic"; import { GenericError } from "#interfaces/error/generic";
import { console } from "inspector"; import { console } from "inspector";
type UserRole = "user" | "admin" | "system_admin"; type UserRole = "admin" | "mentor" | "student";
// Extend Request interface // Extend Request interface
declare module "express" { declare module "express" {
...@@ -113,14 +113,14 @@ export function authorize(allowedRoles: UserRole[] = []) { ...@@ -113,14 +113,14 @@ export function authorize(allowedRoles: UserRole[] = []) {
* Admin only middleware * Admin only middleware
*/ */
export function requireAdmin(req: Req, res: Res, next: NextFunction): void { export function requireAdmin(req: Req, res: Res, next: NextFunction): void {
return authorize(["admin", "system_admin"])(req, res, next); return authorize(["admin"])(req, res, next);
} }
/** /**
* Super admin only middleware * Mentor only middleware
*/ */
export function requireSuperAdmin(req: Req, res: Res, next: NextFunction): void { export function requireMentor(req: Req, res: Res, next: NextFunction): void {
return authorize(["system_admin"])(req, res, next); return authorize(["mentor", "admin"])(req, res, next);
} }
/** /**
......
...@@ -35,6 +35,9 @@ export const authSchemas = { ...@@ -35,6 +35,9 @@ export const authSchemas = {
first_name: Joi.string().optional(), first_name: Joi.string().optional(),
last_name: Joi.string().optional(), last_name: Joi.string().optional(),
phone: Joi.string().optional(), phone: Joi.string().optional(),
roles: Joi.array().items(Joi.string().valid('admin', 'mentor', 'student')).optional().messages({
"any.only": "Role không hợp lệ",
}),
}), }),
params: Joi.object(), params: Joi.object(),
query: Joi.object(), query: Joi.object(),
......
...@@ -31,25 +31,15 @@ const studentSchema = Joi.object({ ...@@ -31,25 +31,15 @@ const studentSchema = Joi.object({
"any.required": "Course name is required", "any.required": "Course name is required",
}), }),
semester: Joi.string().required().messages({ termId: Joi.string().required().messages({
"string.empty": "Semester is required", "string.empty": "Term ID is required",
"any.required": "Semester is required", "any.required": "Term ID is required",
}), }),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({ status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed", "any.only": "Status must be active, inactive, or completed",
}), }),
startDate: Joi.date().iso().required().messages({
"date.format": "Invalid start date format",
"any.required": "Start date is required",
}),
endDate: Joi.date().iso().required().messages({
"date.format": "Invalid end date format",
"any.required": "End date is required",
}),
scheduleIds: Joi.array().items(Joi.string()).optional().messages({ scheduleIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Schedule IDs must be an array", "array.base": "Schedule IDs must be an array",
}), }),
...@@ -76,22 +66,14 @@ const studentUpdateSchema = Joi.object({ ...@@ -76,22 +66,14 @@ const studentUpdateSchema = Joi.object({
"string.max": "Course name cannot exceed 200 characters", "string.max": "Course name cannot exceed 200 characters",
}), }),
semester: Joi.string().optional().messages({ termId: Joi.string().optional().messages({
"string.empty": "Semester cannot be empty", "string.empty": "Term ID cannot be empty",
}), }),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({ status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed", "any.only": "Status must be active, inactive, or completed",
}), }),
startDate: Joi.date().iso().optional().messages({
"date.format": "Invalid start date format",
}),
endDate: Joi.date().iso().optional().messages({
"date.format": "Invalid end date format",
}),
scheduleIds: Joi.array().items(Joi.string()).optional().messages({ scheduleIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Schedule IDs must be an array", "array.base": "Schedule IDs must be an array",
}), }),
......
import Joi from "joi";
import { validateJoi } from ".";
const termSchema = Joi.object({
name: Joi.string().required().max(50).messages({
"string.empty": "Term name is required",
"string.max": "Term name cannot exceed 50 characters",
"any.required": "Term name is required",
}),
description: Joi.string().max(500).optional().allow("").messages({
"string.max": "Description cannot exceed 500 characters",
}),
startDate: Joi.date().iso().required().messages({
"date.format": "Invalid start date format",
"any.required": "Start date is required",
}),
endDate: Joi.date().iso().required().messages({
"date.format": "Invalid end date format",
"any.required": "End date is required",
}),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed",
}),
courseIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Course IDs must be an array",
}),
}).custom((value, helpers) => {
// Custom validation: endDate must be after startDate
if (value.startDate && value.endDate) {
const start = new Date(value.startDate);
const end = new Date(value.endDate);
if (end <= start) {
return helpers.error('custom.endDateAfterStart');
}
}
return value;
}).messages({
'custom.endDateAfterStart': 'End date must be after start date',
});
const termUpdateSchema = Joi.object({
name: Joi.string().max(50).optional().messages({
"string.max": "Term name cannot exceed 50 characters",
}),
description: Joi.string().max(500).optional().allow("").messages({
"string.max": "Description cannot exceed 500 characters",
}),
startDate: Joi.date().iso().optional().messages({
"date.format": "Invalid start date format",
}),
endDate: Joi.date().iso().optional().messages({
"date.format": "Invalid end date format",
}),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"any.only": "Status must be active, inactive, or completed",
}),
courseIds: Joi.array().items(Joi.string()).optional().messages({
"array.base": "Course IDs must be an array",
}),
}).custom((value, helpers) => {
// Custom validation for update: if both dates are provided, endDate must be after startDate
if (value.startDate && value.endDate) {
const start = new Date(value.startDate);
const end = new Date(value.endDate);
if (end <= start) {
return helpers.error('custom.endDateAfterStart');
}
}
return value;
}).messages({
'custom.endDateAfterStart': 'End date must be after start date',
});
export const validateTermCreate = validateJoi(
Joi.object({
body: termSchema,
params: Joi.object(),
query: Joi.object(),
})
);
export const validateTermUpdate = validateJoi(
Joi.object({
body: termUpdateSchema,
params: Joi.object({
id: Joi.string().required().messages({
"any.required": "Term ID is required",
}),
}),
query: Joi.object(),
})
);
export const validateTermGetById = validateJoi(
Joi.object({
body: Joi.object(),
params: Joi.object({
id: Joi.string().required().messages({
"any.required": "Term ID is required",
}),
}),
query: Joi.object(),
})
);
...@@ -24,10 +24,8 @@ export interface IStudent extends Document { ...@@ -24,10 +24,8 @@ export interface IStudent extends Document {
phone: string; phone: string;
courseId: string; courseId: string;
courseName: string; courseName: string;
semester: string; termId: string;
status: 'active' | 'inactive' | 'completed'; status: 'active' | 'inactive' | 'completed';
startDate: Date;
endDate: Date;
scheduleIds: string[]; scheduleIds: string[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
...@@ -65,10 +63,10 @@ const StudentSchema: Schema = new Schema({ ...@@ -65,10 +63,10 @@ const StudentSchema: Schema = new Schema({
required: [true, 'Týn khßa hýc lý bßt buýc'], required: [true, 'Týn khßa hýc lý bßt buýc'],
trim: true trim: true
}, },
semester: { termId: {
type: String, type: String,
required: [true, 'Ký hýc lý bßt buýc'], required: [true, 'ID ký hýc lý bßt buýc'],
trim: true ref: 'Term'
}, },
status: { status: {
type: String, type: String,
...@@ -76,20 +74,6 @@ const StudentSchema: Schema = new Schema({ ...@@ -76,20 +74,6 @@ const StudentSchema: Schema = new Schema({
default: 'active', default: 'active',
required: true required: true
}, },
startDate: {
type: Date,
required: [true, 'Ngýy bßt áßu lý bßt buýc']
},
endDate: {
type: Date,
required: [true, 'Ngýy kýt thýc lý bßt buýc'],
validate: {
validator: function (this: IStudent, value: Date) {
return value > this.startDate;
},
message: 'Ngýy kýt thýc phßi sau ngýy bßt áßu'
}
},
scheduleIds: [{ scheduleIds: [{
type: String, type: String,
ref: 'Schedule' ref: 'Schedule'
...@@ -110,7 +94,7 @@ const StudentSchema: Schema = new Schema({ ...@@ -110,7 +94,7 @@ const StudentSchema: Schema = new Schema({
// Index for better query performance // Index for better query performance
StudentSchema.index({ email: 1 }); StudentSchema.index({ email: 1 });
StudentSchema.index({ courseId: 1, semester: 1 }); StudentSchema.index({ courseId: 1, termId: 1 });
StudentSchema.index({ status: 1 }); StudentSchema.index({ status: 1 });
StudentSchema.index({ fullName: 'text' }); StudentSchema.index({ fullName: 'text' });
......
import mongoose, { Document, Schema } from 'mongoose';
/**
* @typedef {Object} Term
* @property {string} name - Tên ký hýc (ví dß: "2026-1", "2026-HK1")
* @property {string} description - Mô t ký hýc
* @property {Date} startDate - Ngýy bßt áßu ký hýc
* @property {Date} endDate - Ngýy kýt thýc ký hýc
* @property {string} status - Trýng thýi ký hýc (active, inactive, completed)
* @property {string[]} courseIds - Danh sých ID các khóa hýc trong ký
* @property {Date} createdAt - Ngýy tßo
* @property {Date} updatedAt - Ngýy cßp nhßt
* @property {string} createdBy - Ngýi tßo
* @property {string} updatedBy - Ngýi cßp nhßt
*/
export interface ITerm extends Document {
name: string;
description?: string;
startDate: Date;
endDate: Date;
status: 'active' | 'inactive' | 'completed';
courseIds: string[];
createdAt: Date;
updatedAt: Date;
createdBy?: string;
updatedBy?: string;
}
const TermSchema: Schema = new Schema({
name: {
type: String,
required: [true, 'Tên ký hýc lý bßt buýc'],
trim: true,
unique: true,
maxlength: [50, 'Tên ký hýc khýng áßß výt quß 50 ký tß']
},
description: {
type: String,
trim: true,
maxlength: [500, 'Mô t ký hýc khýng áßß výt quß 500 ký tß']
},
startDate: {
type: Date,
required: [true, 'Ngýy bßt áßu lý bßt buýc']
},
endDate: {
type: Date,
required: [true, 'Ngýy kýt thýc lý bßt buýc'],
validate: {
validator: function (this: ITerm, value: Date) {
return value > this.startDate;
},
message: 'Ngýy kýt thýc phßi sau ngýy bßt áßu'
}
},
status: {
type: String,
enum: ['active', 'inactive', 'completed'],
default: 'active',
required: true
},
courseIds: [{
type: String,
ref: 'Course'
}],
createdBy: {
type: String,
ref: 'User'
},
updatedBy: {
type: String,
ref: 'User'
}
}, {
timestamps: true,
toJSON: { virtuals: false },
toObject: { virtuals: false }
});
// Index for better query performance
TermSchema.index({ name: 1 }, { unique: true });
TermSchema.index({ status: 1 });
TermSchema.index({ startDate: 1, endDate: 1 });
TermSchema.index({ name: 'text' });
export const Term = mongoose.model<ITerm>('Term', TermSchema);
import mongoose, { Schema, Document } from 'mongoose';
export interface IRole extends Document {
name: string;
description?: string;
permissions: string[];
createdAt: Date;
updatedAt: Date;
}
const RoleSchemaEntity: Schema = new Schema(
{
name: {
type: String,
required: true,
unique: true,
enum: ['admin', 'mentor', 'student']
},
description: { type: String },
permissions: [{ type: String }],
},
{
timestamps: true,
versionKey: false
}
);
export const Role = mongoose.models.Role || mongoose.model<IRole>('Role', RoleSchemaEntity);
import mongoose, { Schema, Document } from 'mongoose';
export interface IUser extends Document {
email: string;
password_hash: string;
username?: string;
first_name?: string;
last_name?: string;
phone?: string;
avatar_url?: string;
status: 'active' | 'inactive' | 'suspended' | 'pending';
roles: mongoose.Types.ObjectId[] | string[];
last_login?: Date;
password_changed_at?: Date;
login_attempts: number;
lock_until?: Date;
createdAt: Date;
updatedAt: Date;
}
const UserSchemaEntity: Schema = new Schema(
{
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password_hash: {
type: String,
required: true
},
username: {
type: String,
unique: true,
sparse: true
},
first_name: { type: String },
last_name: { type: String },
phone: { type: String },
avatar_url: { type: String },
status: {
type: String,
enum: ['active', 'inactive', 'suspended', 'pending'],
default: 'active'
},
roles: [{
type: Schema.Types.ObjectId,
ref: 'Role'
}],
last_login: { type: Date },
password_changed_at: { type: Date },
login_attempts: {
type: Number,
required: true,
default: 0
},
lock_until: { type: Date }
},
{
timestamps: true,
versionKey: false
}
);
export const User = mongoose.models.User || mongoose.model<IUser>('User', UserSchemaEntity);
import mongoose, { Schema, Document } from 'mongoose';
export interface IUserSession extends Document {
user: mongoose.Types.ObjectId;
session_token: string;
refresh_token: string;
ip_address?: string;
user_agent?: string;
device_info?: any;
expires_at: Date;
refresh_expires_at: Date;
is_active: boolean;
last_activity_at: Date;
createdAt: Date;
updatedAt: Date;
}
const UserSessionSchemaEntity: Schema = new Schema(
{
user: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true
},
session_token: {
type: String,
required: true,
unique: true
},
refresh_token: {
type: String,
required: true
},
ip_address: { type: String },
user_agent: { type: String },
device_info: { type: Schema.Types.Mixed },
expires_at: { type: Date, required: true },
refresh_expires_at: { type: Date, required: true },
is_active: {
type: Boolean,
default: true
},
last_activity_at: {
type: Date,
default: Date.now
}
},
{
timestamps: true,
versionKey: false
}
);
// TTL index to automatically remove expired sessions
UserSessionSchemaEntity.index({ refresh_expires_at: 1 }, { expireAfterSeconds: 0 });
UserSessionSchemaEntity.index({ session_token: 1 });
UserSessionSchemaEntity.index({ user: 1 });
export const UserSession = mongoose.models.UserSession || mongoose.model<IUserSession>('UserSession', UserSessionSchemaEntity);
import { Term, ITerm } from "#models/Term";
import LoggingService from "#services/file-system-handlers/logService";
import { MeUError } from "#interfaces/IApi";
export interface TermFindManyOptions {
page?: number;
pageSize?: number;
sortField?: string;
sortOrder?: 'asc' | 'desc';
status?: string;
search?: string;
year?: string;
}
export interface TermFindManyReturnModel {
count: number;
rows: ITerm[];
page?: number;
pageSize?: number;
}
export class TermProvider {
private logger: LoggingService;
constructor() {
this.logger = new LoggingService();
}
async getAll(opts: TermFindManyOptions): Promise<TermFindManyReturnModel> {
try {
const { page, pageSize, sortField, sortOrder, status, search, year, ...baseQuery } = opts;
// Build filter
const filter: any = {};
if (status) filter.status = status;
if (year) {
// Filter by year in term name (e.g., "2026-1", "2026-HK1")
filter.name = { $regex: `^${year}`, $options: 'i' };
}
if (search) {
filter.$or = [
{ name: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// Build query
let query = Term.find(filter);
// Add sorting
if (sortField && sortOrder) {
const sort: any = {};
sort[sortField] = sortOrder === 'asc' ? 1 : -1;
query = query.sort(sort);
} else {
query = query.sort({ startDate: -1 });
}
// Add pagination
if (page !== undefined && pageSize !== undefined && pageSize > 0) {
const skip = ((page || 1) - 1) * pageSize;
query = query.skip(skip).limit(pageSize);
}
// Execute query
const [rows, count] = await Promise.all([
query.exec(),
Term.countDocuments(filter)
]);
const result: TermFindManyReturnModel = {
count,
rows,
};
if (page !== undefined) result.page = page;
if (pageSize !== undefined) result.pageSize = pageSize;
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getById(id: string): Promise<ITerm> {
try {
const result = await Term.findById(id);
if (!result) {
throw new MeUError(404, "DB", `Term with id ${id} not found`);
}
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getOne(where: any): Promise<ITerm | null> {
try {
return await Term.findOne(where);
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async create(body: Partial<ITerm>, options?: { createdBy?: string | undefined }): Promise<ITerm> {
try {
const termData = {
...body,
createdBy: options?.createdBy
};
const result = new Term(termData);
const saved = await result.save();
await this.logger.logInfoAsync("TermProvider", new Error(`Created term with id ${saved._id}`), null);
return saved;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async update(id: string, body: Partial<ITerm>, options?: { updatedBy?: string }): Promise<ITerm> {
try {
const updateData = {
...body,
updatedBy: options?.updatedBy,
updatedAt: new Date()
};
const result = await Term.findByIdAndUpdate(
id,
updateData,
{ new: true, runValidators: true }
);
if (!result) {
throw new MeUError(404, "DB", `Term with id ${id} not found`);
}
await this.logger.logInfoAsync("TermProvider", new Error(`Updated term with id ${id}`), null);
return result;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async delete(id: string): Promise<string> {
try {
const result = await Term.findByIdAndDelete(id);
if (!result) {
throw new MeUError(404, "DB", `Term with id ${id} not found`);
}
await this.logger.logInfoAsync("TermProvider", new Error(`Deleted term with id ${id}`), null);
return "Successfully deleted term";
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async count(where?: any): Promise<number> {
try {
return await Term.countDocuments(where || {});
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async exists(where: any): Promise<boolean> {
try {
const count = await Term.countDocuments(where);
return count > 0;
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getActiveTerms(): Promise<ITerm[]> {
try {
return await Term.find({ status: 'active' }).sort({ startDate: -1 });
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async getTermsByDateRange(startDate: Date, endDate: Date): Promise<ITerm[]> {
try {
return await Term.find({
$or: [
{ startDate: { $gte: startDate, $lte: endDate } },
{ endDate: { $gte: startDate, $lte: endDate } },
{ startDate: { $lte: startDate }, endDate: { $gte: endDate } }
]
}).sort({ startDate: 1 });
} catch (error) {
await this.logError(error as Error);
throw error;
}
}
async logError(err: MeUError | Error) {
await this.logger.logErrorAsync("TermProvider", err, null);
return;
}
}
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import { Role } from '../models/mongodb/Role';
import { User } from '../models/mongodb/User';
import * as bcrypt from 'bcryptjs';
dotenv.config();
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/trainee-schedule';
const seed = async () => {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Create Roles
const roles = [
{ name: 'admin', description: 'System Administrator' },
{ name: 'mentor', description: 'Instructor/Mentor' },
{ name: 'student', description: 'Trainee/Student' }
];
for (const roleData of roles) {
await Role.findOneAndUpdate(
{ name: roleData.name },
roleData,
{ upsert: true, new: true }
);
console.log(`Role ${roleData.name} initialized`);
}
const adminRole = await Role.findOne({ name: 'admin' });
if (!adminRole) throw new Error('Admin role not found');
// Create Default Admin User
const adminEmail = 'admin@admin.com';
const existingAdmin = await User.findOne({ email: adminEmail });
if (!existingAdmin) {
const hashedPassword = await bcrypt.hash('admin123', 12);
await User.create({
email: adminEmail,
password_hash: hashedPassword,
username: 'admin',
first_name: 'System',
last_name: 'Admin',
status: 'active',
roles: [adminRole._id] as any
});
console.log('Default admin user created: admin@admin.com / admin123');
} else {
console.log('Admin user already exists');
}
await mongoose.disconnect();
console.log('Seeding completed');
process.exit(0);
} catch (error) {
console.error('Seeding failed:', error);
process.exit(1);
}
};
seed();
This diff is collapsed.
...@@ -6,23 +6,67 @@ module.exports = { ...@@ -6,23 +6,67 @@ module.exports = {
email: { email: {
type: "string", type: "string",
format: "email", format: "email",
example: "user@example.com", example: "admin@admin.com",
}, },
password: { password: {
type: "string", type: "string",
example: "password123", example: "admin123",
}, },
device_info: { device_info: {
type: "object", type: "object",
description: "Device information for session tracking", description: "Device information for session tracking",
}, },
ip_address: { },
},
RegisterRequest: {
type: "object",
required: ["email", "password"],
properties: {
email: {
type: "string", type: "string",
description: "IP address for security logging", format: "email",
example: "mentor1@example.com",
}, },
user_agent: { password: {
type: "string",
example: "password123",
},
username: {
type: "string", type: "string",
description: "User agent string", example: "mentor_john",
},
first_name: {
type: "string",
example: "John",
},
last_name: {
type: "string",
example: "Doe",
},
phone: {
type: "string",
example: "0912345678",
},
roles: {
type: "array",
items: {
type: "string",
enum: ["admin", "mentor", "student"],
},
example: ["mentor"],
},
},
},
RefreshRequest: {
type: "object",
properties: {
refresh_token: {
type: "string",
description: "Refresh token (optional if sent via cookie)",
},
device_info: {
type: "object",
description: "Device information",
}, },
}, },
}, },
......
...@@ -129,4 +129,69 @@ module.exports = { ...@@ -129,4 +129,69 @@ module.exports = {
}, },
}, },
}, },
Forbidden: {
description: "Forbidden",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
example: false,
},
error: {
type: "object",
properties: {
code: {
type: "string",
example: "FORBIDDEN",
},
message: {
type: "object",
properties: {
vi: {
type: "string",
example: "Bạn không có quyền thực hiện hành động này",
},
en: {
type: "string",
example: "You do not have permission to perform this action",
},
},
},
},
},
message: {
type: "string",
nullable: true,
},
message_en: {
type: "string",
nullable: true,
},
responseData: {
type: "object",
nullable: true,
},
status: {
type: "string",
example: "fail",
},
timeStamp: {
type: "string",
example: "2025-12-07 10:00:00",
},
violation: {
type: "array",
items: {
type: "object",
},
nullable: true,
},
},
},
},
},
},
}; };
module.exports = { module.exports = {
Bearer: { BearerAuth: {
name: "Authorization", type: "http",
in: "header", scheme: "bearer",
type: "apiKey", bearerFormat: "JWT",
}, },
}; };
module.exports = { module.exports = {
Student: { Student: {
type: "object", type: "object",
required: ["fullName", "email", "phone", "courseId", "courseName", "semester", "startDate", "endDate"], required: ["fullName", "email", "phone", "courseId", "courseName", "termId"],
properties: { properties: {
_id: { _id: {
type: "string", type: "string",
...@@ -34,10 +34,10 @@ module.exports = { ...@@ -34,10 +34,10 @@ module.exports = {
description: "Course name", description: "Course name",
example: "Advanced JavaScript" example: "Advanced JavaScript"
}, },
semester: { termId: {
type: "string", type: "string",
description: "Semester", description: "Term ID (reference to Term collection)",
example: "Fall 2024" example: "507f1f77bcf86cd799439013"
}, },
status: { status: {
type: "string", type: "string",
...@@ -45,18 +45,6 @@ module.exports = { ...@@ -45,18 +45,6 @@ module.exports = {
default: "active", default: "active",
description: "Student status" description: "Student status"
}, },
startDate: {
type: "string",
format: "date",
description: "Course start date",
example: "2024-09-01"
},
endDate: {
type: "string",
format: "date",
description: "Course end date",
example: "2024-12-31"
},
scheduleIds: { scheduleIds: {
type: "array", type: "array",
items: { items: {
...@@ -86,7 +74,7 @@ module.exports = { ...@@ -86,7 +74,7 @@ module.exports = {
}, },
StudentMutate: { StudentMutate: {
type: "object", type: "object",
required: ["fullName", "email", "phone", "courseId", "courseName", "semester", "startDate", "endDate"], required: ["fullName", "email", "phone", "courseId", "courseName", "termId"],
properties: { properties: {
fullName: { fullName: {
type: "string", type: "string",
...@@ -114,10 +102,10 @@ module.exports = { ...@@ -114,10 +102,10 @@ module.exports = {
description: "Course name", description: "Course name",
example: "Advanced JavaScript" example: "Advanced JavaScript"
}, },
semester: { termId: {
type: "string", type: "string",
description: "Semester", description: "Term ID (reference to Term collection)",
example: "Fall 2024" example: "507f1f77bcf86cd799439013"
}, },
status: { status: {
type: "string", type: "string",
...@@ -125,18 +113,6 @@ module.exports = { ...@@ -125,18 +113,6 @@ module.exports = {
default: "active", default: "active",
description: "Student status" description: "Student status"
}, },
startDate: {
type: "string",
format: "date",
description: "Course start date",
example: "2024-09-01"
},
endDate: {
type: "string",
format: "date",
description: "Course end date",
example: "2024-12-31"
},
scheduleIds: { scheduleIds: {
type: "array", type: "array",
items: { items: {
......
module.exports = {
Term: {
type: "object",
required: ["name", "startDate", "endDate"],
properties: {
_id: {
type: "string",
description: "Term ID",
example: "507f1f77bcf86cd799439011"
},
name: {
type: "string",
description: "Term name (e.g., '2026-1', '2026-HK1')",
example: "2026-1"
},
description: {
type: "string",
description: "Term description",
example: "Hoc ky 1 nam 2026"
},
startDate: {
type: "string",
format: "date",
description: "Term start date",
example: "2026-01-15"
},
endDate: {
type: "string",
format: "date",
description: "Term end date",
example: "2026-05-31"
},
status: {
type: "string",
enum: ["active", "inactive", "completed"],
default: "active",
description: "Term status"
},
courseIds: {
type: "array",
items: {
type: "string"
},
description: "Array of course IDs in this term"
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp"
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp"
},
createdBy: {
type: "string",
description: "User who created this record"
},
updatedBy: {
type: "string",
description: "User who last updated this record"
}
}
},
TermMutate: {
type: "object",
required: ["name", "startDate", "endDate"],
properties: {
name: {
type: "string",
description: "Term name (e.g., '2026-1', '2026-HK1')",
example: "2026-1"
},
description: {
type: "string",
description: "Term description",
example: "Hoc ky 1 nam 2026"
},
startDate: {
type: "string",
format: "date",
description: "Term start date",
example: "2026-01-15"
},
endDate: {
type: "string",
format: "date",
description: "Term end date",
example: "2026-05-31"
},
status: {
type: "string",
enum: ["active", "inactive", "completed"],
default: "active",
description: "Term status"
},
courseIds: {
type: "array",
items: {
type: "string"
},
description: "Array of course IDs in this term"
}
}
}
};
import { User } from "../models/User"; import { IUser } from "../models/mongodb/User";
import { UserAuth } from "../models/UserAuth"; import { IUserSession } from "../models/mongodb/UserSession";
import { UserSession } from "../models/UserSession";
import dayjs from "dayjs"; import dayjs from "dayjs";
/** /**
* Authentication utility functions * Authentication utility functions for Mongoose models
* These functions provide the logic that would normally be instance methods
* but are kept separate to avoid conflicts with auto-generated models
*/ */
/**
* Maximum number of login attempts before account lockout
*/
const MAX_LOGIN_ATTEMPTS = 5; const MAX_LOGIN_ATTEMPTS = 5;
/** export const isUserLocked = (user: IUser): boolean => {
* Check if user account is locked due to too many failed login attempts if (user.lock_until) {
*/ return dayjs().isBefore(dayjs(user.lock_until));
export const isUserLocked = (userAuth: UserAuth): boolean => { }
if (userAuth.locked_until) { return false;
const now = dayjs();
const lockedUntil = dayjs(userAuth.locked_until);
return now.isBefore(lockedUntil);
}
return false;
};
/**
* Increment login attempts for a user
*/
export const incrementUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
const currentAttempts = (userAuthInstance.login_attempts || 0) + 1;
// Lock account if too many attempts
const updates: any = { login_attempts: currentAttempts };
if (currentAttempts >= MAX_LOGIN_ATTEMPTS) {
updates.locked_until = dayjs().add(15, "minutes").toDate();
}
await userAuthInstance.update(updates);
};
/**
* Reset login attempts for a user (on successful login)
*/
export const resetUserLoginAttempts = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
login_attempts: 0,
locked_until: new Date(0),
});
};
/**
* Unlock user account (reset lockout)
*/
export const unlockUserAccount = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
locked_until: new Date(0),
login_attempts: 0,
});
};
/**
* Check if user account lockout has expired and unlock if needed
*/
export const checkAndUnlockExpiredLockout = async (userAuthInstance: UserAuth): Promise<boolean> => {
if (userAuthInstance.locked_until) {
const now = dayjs();
const lockedUntil = dayjs(userAuthInstance.locked_until);
if (now.isAfter(lockedUntil)) {
await unlockUserAccount(userAuthInstance);
return true; // Account was unlocked
}
}
return false; // Account was not locked or still locked
};
/**
* Update user's last login timestamp
*/
export const updateUserLastLogin = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
last_login_at: new Date(),
});
};
/**
* Check if user session is expired
*/
export const isSessionExpired = (session: UserSession): boolean => {
const now = dayjs();
const expiresAt = dayjs(session.expires_at);
return now.isAfter(expiresAt);
};
/**
* Check if user session refresh token is expired
*/
export const isSessionRefreshExpired = (session: UserSession): boolean => {
const now = dayjs();
const refreshExpiresAt = dayjs(session.refresh_expires_at);
return now.isAfter(refreshExpiresAt);
}; };
/** export const incrementUserLoginAttempts = async (user: IUser): Promise<void> => {
* Deactivate a user session const currentAttempts = (user.login_attempts || 0) + 1;
*/ const updates: any = { login_attempts: currentAttempts };
export const deactivateUserSession = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ is_active: false }); if (currentAttempts >= MAX_LOGIN_ATTEMPTS) {
updates.lock_until = dayjs().add(15, "minutes").toDate();
}
await user.updateOne(updates);
}; };
/** export const resetUserLoginAttempts = async (user: IUser): Promise<void> => {
* Update session activity timestamp await user.updateOne({
*/ login_attempts: 0,
export const updateSessionActivity = async (sessionInstance: UserSession): Promise<void> => { lock_until: new Date(0),
await sessionInstance.update({ last_activity_at: new Date() }); });
}; };
/** export const unlockUserAccount = async (user: IUser): Promise<void> => {
* Check if user can change password (not recently changed) await user.updateOne({
*/ lock_until: new Date(0),
export const canUserChangePassword = (userAuth: UserAuth, minDaysBetweenChanges: number = 1): boolean => { login_attempts: 0,
if (!userAuth.password_changed_at) return true; });
const lastChange = dayjs(userAuth.password_changed_at);
const now = dayjs();
const daysSinceChange = now.diff(lastChange, "day");
return daysSinceChange >= minDaysBetweenChanges;
}; };
/** export const checkAndUnlockExpiredLockout = async (user: IUser): Promise<boolean> => {
* Update user's password changed timestamp if (user.lock_until && dayjs().isAfter(dayjs(user.lock_until))) {
*/ await unlockUserAccount(user);
export const updateUserPasswordChanged = async (userAuthInstance: UserAuth): Promise<void> => { return true;
await userAuthInstance.update({ }
password_changed_at: new Date(), return false;
});
}; };
/** export const updateUserLastLogin = async (user: IUser): Promise<void> => {
* Check if password was used recently (prevent reuse) await user.updateOne({
*/ last_login: new Date(),
export const isPasswordRecentlyUsed = ( });
userAuth: UserAuth,
passwordHistory: string[],
newPasswordHash: string,
): boolean => {
// Check if new password hash matches any in recent history
return passwordHistory.includes(newPasswordHash);
}; };
/** export const isSessionExpired = (session: IUserSession): boolean => {
* Get user full name return dayjs().isAfter(dayjs(session.expires_at));
*/
export const getUserFullName = (user: User): string => {
const firstName = user.first_name || "";
const lastName = user.last_name || "";
return `${firstName} ${lastName}`.trim();
}; };
/** export const isSessionRefreshExpired = (session: IUserSession): boolean => {
* Check if user has specific role return dayjs().isAfter(dayjs(session.refresh_expires_at));
*/
export const hasUserRole = (user: User, role: string): boolean => {
return (user as any).role?.name === role;
}; };
/** export const deactivateUserSession = async (session: IUserSession): Promise<void> => {
* Check if user has admin privileges await session.updateOne({ is_active: false });
*/
export const isUserAdmin = (user: User): boolean => {
return (user as any).role?.name === "admin" || (user as any).role?.name === "system_admin";
}; };
/** export const updateSessionActivity = async (session: IUserSession): Promise<void> => {
* Check if user is system admin await session.updateOne({ last_activity_at: new Date() });
*/
export const isUserSystemAdmin = (user: User): boolean => {
return (user as any).role?.name === "system_admin";
}; };
/** export const canUserChangePassword = (user: IUser, minDaysBetweenChanges: number = 1): boolean => {
* Get user status display text if (!user.password_changed_at) return true;
*/ return dayjs().diff(dayjs(user.password_changed_at), "day") >= minDaysBetweenChanges;
export const getUserStatusText = (status: string): { vi: string; en: string } => {
const statusMap: Record<string, { vi: string; en: string }> = {
active: { vi: "Hoạt động", en: "Active" },
inactive: { vi: "Không hoạt động", en: "Inactive" },
suspended: { vi: "Đã tạm ngừng", en: "Suspended" },
pending_verification: { vi: "Chờ xác minh", en: "Pending Verification" },
};
return statusMap[status] || { vi: "Không xác định", en: "Unknown" };
}; };
/**
* Get role display text
*/
export const getRoleText = (role: string): { vi: string; en: string } => { export const getRoleText = (role: string): { vi: string; en: string } => {
const roleMap: Record<string, { vi: string; en: string }> = { const roleMap: Record<string, { vi: string; en: string }> = {
user: { vi: "Người dùng", en: "User" }, student: { vi: "Sinh viên", en: "Student" },
admin: { vi: "Quản trị viên", en: "Administrator" }, mentor: { vi: "Người hướng dẫn", en: "Mentor" },
system_admin: { vi: "Quản trị hệ thống", en: "System Administrator" }, admin: { vi: "Quản trị viên", en: "Administrator" },
}; };
return roleMap[role] || { vi: "Không xác định", en: "Unknown" };
return roleMap[role] || { vi: "Không xác định", en: "Unknown" };
}; };
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