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) => {
return res.sendOk({
data: {
user: {
id: loginResult.user.id,
id: loginResult.user._id,
email: loginResult.user.email,
username: loginResult.user.username,
first_name: loginResult.user.first_name,
last_name: loginResult.user.last_name,
roles: (loginResult.user as any).roles,
permissions: (loginResult.user as any).permissions,
roles: (loginResult.user.roles as any[]).map(r => r.name),
status: loginResult.user.status,
last_login_at: loginResult.user_auth.last_login_at,
last_login_at: loginResult.user.last_login,
},
session: {
id: loginResult.session.id,
id: loginResult.session._id,
expires_at: loginResult.session.expires_at,
refresh_expires_at: loginResult.session.refresh_expires_at,
},
......
......@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Logout user
* description: Logout user and invalidate current session
* security:
* - Bearer: []
* - BearerAuth: []
* responses:
* 200:
* description: Logged out successfully
......
......@@ -2,7 +2,7 @@ import { Application } from "express";
import { Resource } from "express-automatic-routes";
import { Req, Res } from "#interfaces/IApi";
import { authenticate } from "#middlewares/auth";
import { User } from "#models/User";
import { User } from "#models/mongodb/User";
export default (_express: Application) => {
return <Resource>{
......@@ -14,7 +14,7 @@ export default (_express: Application) => {
* summary: Get user profile
* description: Get current authenticated user's profile information
* security:
* - Bearer: []
* - BearerAuth: []
* responses:
* 200:
* description: Profile retrieved successfully
......@@ -25,48 +25,47 @@ export default (_express: Application) => {
* properties:
* success:
* type: boolean
* example: true
* data:
* type: object
* properties:
* id:
* type: string
* example: "uuid-string"
* email:
* type: string
* example: "user@example.com"
* username:
* type: string
* example: "john_doe"
* role:
* first_name:
* type: string
* enum: [user, admin, system_admin]
* example: "user"
* permissions:
* last_name:
* type: string
* status:
* type: string
* roles:
* type: array
* items:
* type: string
* example: ["read:profile"]
* 401:
* $ref: '#/components/responses/Unauthorized'
* example: ["admin"]
*/
get: {
middleware: [authenticate],
handler: async (req: Req, res: Res) => {
try {
// Get user with roles and permissions
const user = await User.findByPk(req.user?.id, {
attributes: ["id", "email", "username", "first_name", "last_name", "status"],
});
const user = await User.findById(req.user?.id)
.select("-password_hash")
.populate("roles", "name");
if (!user) {
return res.error({ message: "User not found", status: 404 });
}
const userData = {
...user.toJSON(),
roles: req.user?.roles,
permissions: req.user?.permissions,
id: user._id,
email: user.email,
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 });
......
......@@ -18,14 +18,7 @@ export default (_express: Application) => {
* content:
* application/json:
* schema:
* type: object
* properties:
* refresh_token:
* type: string
* description: Refresh token (optional if sent via cookie)
* device_info:
* type: object
* description: Device information
* $ref: '#/components/schemas/RefreshRequest'
* responses:
* 200:
* 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) => {
* type: string
* description: Filter by course ID
* - in: query
* name: semester
* name: termId
* schema:
* type: string
* description: Filter by semester
* description: Filter by term ID
* - in: query
* name: search
* 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";
import { AuthService, JWTPayload } from "../services/authService";
import { GenericError } from "#interfaces/error/generic";
import { console } from "inspector";
type UserRole = "user" | "admin" | "system_admin";
type UserRole = "admin" | "mentor" | "student";
// Extend Request interface
declare module "express" {
......@@ -113,14 +113,14 @@ export function authorize(allowedRoles: UserRole[] = []) {
* Admin only middleware
*/
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 {
return authorize(["system_admin"])(req, res, next);
export function requireMentor(req: Req, res: Res, next: NextFunction): void {
return authorize(["mentor", "admin"])(req, res, next);
}
/**
......
......@@ -35,6 +35,9 @@ export const authSchemas = {
first_name: Joi.string().optional(),
last_name: 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(),
query: Joi.object(),
......
......@@ -31,25 +31,15 @@ const studentSchema = Joi.object({
"any.required": "Course name is required",
}),
semester: Joi.string().required().messages({
"string.empty": "Semester is required",
"any.required": "Semester is required",
termId: Joi.string().required().messages({
"string.empty": "Term ID is required",
"any.required": "Term ID is required",
}),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"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({
"array.base": "Schedule IDs must be an array",
}),
......@@ -76,22 +66,14 @@ const studentUpdateSchema = Joi.object({
"string.max": "Course name cannot exceed 200 characters",
}),
semester: Joi.string().optional().messages({
"string.empty": "Semester cannot be empty",
termId: Joi.string().optional().messages({
"string.empty": "Term ID cannot be empty",
}),
status: Joi.string().valid("active", "inactive", "completed").optional().messages({
"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({
"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 {
phone: string;
courseId: string;
courseName: string;
semester: string;
termId: string;
status: 'active' | 'inactive' | 'completed';
startDate: Date;
endDate: Date;
scheduleIds: string[];
createdAt: Date;
updatedAt: Date;
......@@ -65,10 +63,10 @@ const StudentSchema: Schema = new Schema({
required: [true, 'Týn khßa hýc lý bßt buýc'],
trim: true
},
semester: {
termId: {
type: String,
required: [true, 'Ký hýc lý bßt buýc'],
trim: true
required: [true, 'ID ký hýc lý bßt buýc'],
ref: 'Term'
},
status: {
type: String,
......@@ -76,20 +74,6 @@ const StudentSchema: Schema = new Schema({
default: 'active',
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: [{
type: String,
ref: 'Schedule'
......@@ -110,7 +94,7 @@ const StudentSchema: Schema = new Schema({
// Index for better query performance
StudentSchema.index({ email: 1 });
StudentSchema.index({ courseId: 1, semester: 1 });
StudentSchema.index({ courseId: 1, termId: 1 });
StudentSchema.index({ status: 1 });
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();
import dayjs from "dayjs";
import * as bcrypt from "bcryptjs";
import { sign, verify, SignOptions } from "jsonwebtoken";
import { QueryTypes } from "sequelize";
import sequelize from "#services/database/sequelize/service";
import { User, UserAttributes } from "../models/User";
import { UserAuth } from "../models/UserAuth";
import { Role } from "../models/Role";
import { UserRole as UserRoleModel } from "../models/UserRole";
import { RolePermission } from "../models/RolePermission";
import { Permission } from "../models/Permission";
import { UserSession } from "../models/UserSession";
import { User, IUser } from "../models/mongodb/User";
import { Role, IRole } from "../models/mongodb/Role";
import { UserSession, IUserSession } from "../models/mongodb/UserSession";
import { GenericError } from "#interfaces/error/generic";
import {
isUserLocked,
incrementUserLoginAttempts,
resetUserLoginAttempts,
updateUserLastLogin,
checkAndUnlockExpiredLockout,
isSessionExpired,
isSessionRefreshExpired,
deactivateUserSession,
updateSessionActivity,
canUserChangePassword,
updateUserPasswordChanged,
isPasswordRecentlyUsed,
isUserLocked,
incrementUserLoginAttempts,
resetUserLoginAttempts,
updateUserLastLogin,
checkAndUnlockExpiredLockout,
} from "../utils/authUtils";
enum UserStatus {
ACTIVE = "active",
INACTIVE = "inactive",
SUSPENDED = "suspended",
PENDING_VERIFICATION = "pending_verification",
}
enum UserRole {
USER = "user",
ADMIN = "admin",
SYSTEM_ADMIN = "system_admin",
}
export interface LoginCredentials {
email: string;
password: string;
device_info?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
email: string;
password: string;
device_info?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
}
export interface RegisterData {
email: string;
password: string;
username?: string;
first_name?: string;
last_name?: string;
phone?: string;
email: string;
password: string;
username?: string;
first_name?: string;
last_name?: string;
phone?: string;
roles?: string[]; // Array of role names
}
export interface TokenPair {
access_token: string;
refresh_token: string;
expires_in: number;
refresh_expires_in: number;
token_type: string;
access_token: string;
refresh_token: string;
expires_in: number;
refresh_expires_in: number;
token_type: string;
}
export interface LoginResult extends TokenPair {
user: User;
user_auth: UserAuth;
session: UserSession;
}
export interface CookieOptions {
httpOnly: boolean;
secure: boolean;
sameSite: "strict" | "lax" | "none";
maxAge: number;
path: string;
domain?: string;
user: IUser;
session: IUserSession;
}
export interface JWTPayload {
id: string;
email: string;
username?: string;
roles?: string[];
permissions?: string[];
type?: string;
iat?: number;
exp?: number;
id: string;
email: string;
username?: string;
roles?: string[];
type?: string;
iat?: number;
exp?: number;
}
export class AuthService {
private static readonly JWT_SECRET = process.env.JWT_SECRET!;
private static readonly JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
private static readonly JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m";
private static readonly JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
private static readonly BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS || "12");
private static readonly TOKEN_ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY!;
// Validate required secrets
private static validateSecrets() {
if (!this.JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
if (!this.JWT_REFRESH_SECRET) {
throw new Error("JWT_REFRESH_SECRET environment variable is required");
}
if (!this.TOKEN_ENCRYPTION_KEY) {
throw new Error("TOKEN_ENCRYPTION_KEY environment variable is required for token encryption");
}
}
/**
* Get secure cookie options for tokens
*/
static getAccessTokenCookieOptions(): CookieOptions {
const isProduction = process.env.NODE_ENV === "production";
const maxAge = this.parseTimeToSeconds(this.JWT_EXPIRES_IN) * 1000;
return {
httpOnly: true, // Prevent XSS attacks
secure: isProduction, // HTTPS only in production
sameSite: "lax", // Allow cross-subdomain requests
maxAge,
path: "/api",
...(isProduction && process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
};
}
/**
* Get secure cookie options for refresh tokens
*/
static getRefreshTokenCookieOptions(): CookieOptions {
const isProduction = process.env.NODE_ENV === "production";
const maxAge = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN) * 1000;
return {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
maxAge,
path: "/api/v1.0/auth/refresh",
...(isProduction && process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
};
}
/**
* Hash password using bcrypt
*/
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.BCRYPT_ROUNDS);
}
/**
* Verify password against hash
*/
static async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Encrypt token using PostgreSQL pgcrypto
*/
private static async encryptToken(plainToken: string): Promise<Buffer> {
const [result] = await sequelize.query("SELECT encrypt_token(:token, :key) as encrypted", {
replacements: {
token: plainToken,
key: this.TOKEN_ENCRYPTION_KEY,
},
type: QueryTypes.SELECT,
});
return (result as any).encrypted;
}
/**
* Decrypt token using PostgreSQL pgcrypto
*/
private static async decryptToken(encryptedToken: Buffer): Promise<string> {
const [result] = await sequelize.query("SELECT decrypt_token(:encrypted_token, :key) as decrypted", {
replacements: {
encrypted_token: encryptedToken,
key: this.TOKEN_ENCRYPTION_KEY,
},
type: QueryTypes.SELECT,
});
return (result as any).decrypted;
}
/**
* Generate JWT access token
*/
static generateAccessToken(user: User): string {
this.validateSecrets();
const primaryUserRole = (user as any).user_user_roles?.find((ur: any) => ur.is_primary);
const payload: JWTPayload = {
id: user.id,
email: user.email,
roles: (user as any).roles || [], // All role names
permissions: (user as any).permissions || [], // Permissions collected from all roles
};
if (user.username) {
payload.username = user.username;
}
return sign(payload, this.JWT_SECRET as any, {
expiresIn: this.JWT_EXPIRES_IN as any,
issuer: "backend-template",
audience: "api-users",
});
}
/**
* Generate JWT refresh token
*/
static generateRefreshToken(user: User): string {
this.validateSecrets();
const payload = {
id: user.id,
email: user.email,
roles: (user as any).roles || [],
permissions: (user as any).permissions || [],
type: "refresh",
};
const options: SignOptions = {
expiresIn: this.JWT_REFRESH_EXPIRES_IN as any,
issuer: "backend-template",
audience: "api-users",
};
return sign(payload, this.JWT_REFRESH_SECRET as any, options);
}
/**
* Verify JWT access token
*/
static verifyAccessToken(token: string): JWTPayload {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_SECRET, {
issuer: "backend-template",
audience: "api-users",
}) as unknown as JWTPayload;
return decoded;
} catch (error) {
throw new GenericError({ vi: "Token không hợp lệ", en: "Invalid token" }, "UNAUTHORIZED", 401);
}
}
/**
* Verify JWT refresh token
*/
static verifyRefreshToken(token: string): JWTPayload {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_REFRESH_SECRET, {
issuer: "backend-template",
audience: "api-users",
}) as unknown as JWTPayload;
if (decoded.type !== "refresh") {
throw new Error("Invalid token type");
}
return decoded;
} catch (error) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
}
/**
* Register new user
*/
static async register(data: RegisterData): Promise<User> {
return sequelize.transaction(async (transaction) => {
const { email, password, username, first_name, last_name, phone } = data;
// Check if user already exists
const existingUser = await User.findOne({
where: { email },
paranoid: false, // Include soft deleted users
transaction,
});
if (existingUser) {
if (existingUser.deleted_at) {
// Reactivate soft deleted user
await existingUser.restore({ transaction });
// Update UserAuth for reactivated user
const existingUserAuth = await UserAuth.findOne({
where: { user_id: existingUser.id },
transaction,
});
if (existingUserAuth) {
existingUserAuth.password_hash = await this.hashPassword(password);
await existingUserAuth.save({ transaction });
}
existingUser.status = "pending_verification";
return existingUser.save({ transaction });
} else {
throw new GenericError({ vi: "Người dùng đã tồn tại", en: "User already exists" }, "CONFLICT", 409);
}
}
// Hash password
const password_hash = await this.hashPassword(password);
// Get default user role
const defaultRole = await Role.findOne({ where: { name: "user" } });
if (!defaultRole) {
throw new Error("Default user role not found");
}
// Create user
const userData: Partial<UserAttributes> = {
email,
status: UserStatus.PENDING_VERIFICATION,
};
if (username) userData.username = username;
if (first_name) userData.first_name = first_name;
if (last_name) userData.last_name = last_name;
if (phone) userData.phone = phone;
const user = await User.create(userData as any, { transaction });
// Create UserAuth
await UserAuth.create(
{
user_id: user.id,
password_hash,
},
{ transaction },
);
// Assign default role
await UserRoleModel.create(
{
user_id: user.id,
role_id: defaultRole.id,
is_primary: true,
},
{ transaction },
);
return user;
});
}
/**
* Authenticate user login
*/
static async login(credentials: LoginCredentials): Promise<LoginResult> {
const { email, password, device_info, ip_address, user_agent } = credentials;
// Find user with auth data
const userInstance = await User.findOne({
where: { email },
include: [{ model: UserAuth, as: "user_auth" }],
});
if (!userInstance) {
throw new GenericError(
{ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" },
"UNAUTHORIZED",
401,
);
}
// Get plain data values
const user = userInstance.dataValues as User;
let userAuth = userInstance.user_auth;
// If UserAuth doesn't exist, this is an error (every user should have UserAuth)
if (!userAuth) {
throw new GenericError(
{ vi: "Tài khoản chưa được thiết lập đầy đủ", en: "Account not fully set up" },
"UNAUTHORIZED",
401,
);
}
// Fetch user roles with permissions
const userRoles = await UserRoleModel.findAll({
where: { user_id: user.id },
include: [
{
model: Role,
as: "role",
include: [
{
model: RolePermission,
as: "role_permissions",
include: [
{
model: Permission,
as: "permission",
attributes: ["name"],
},
],
},
],
},
],
});
// Collect permissions and roles from all roles (unique)
const permissionsSet = new Set<string>();
const rolesSet = new Set<string>();
if (userRoles) {
for (const userRole of userRoles) {
if (userRole.role) {
rolesSet.add(userRole.role.name);
if (userRole.role.role_permissions) {
for (const rp of userRole.role.role_permissions) {
if (rp.permission && rp.permission.name) {
permissionsSet.add(rp.permission.name);
}
}
}
}
}
}
(user as any).permissions = Array.from(permissionsSet);
(user as any).roles = Array.from(rolesSet);
// Check if account lockout has expired and unlock if needed
if (userAuth) await checkAndUnlockExpiredLockout(userAuth);
// Check if account is locked
if (userAuth && isUserLocked(userAuth)) {
throw new GenericError({ vi: "Tài khoản bị khóa tạm thời", en: "Account is temporarily locked" }, "LOCKED", 423);
}
// Check if account is active
if (user.status !== UserStatus.ACTIVE) {
throw new GenericError({ vi: "Tài khoản chưa được kích hoạt", en: "Account is not active" }, "FORBIDDEN", 403);
}
// Check if password_hash exists
if (!userAuth?.password_hash) {
throw new GenericError(
{ vi: "Tài khoản chưa được thiết lập mật khẩu", en: "Account password not set" },
"UNAUTHORIZED",
401,
);
}
// Verify password
const isValidPassword = await this.verifyPassword(password, userAuth.password_hash);
if (!isValidPassword) {
if (userAuth) await incrementUserLoginAttempts(userAuth);
throw new GenericError(
{ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" },
"UNAUTHORIZED",
401,
);
}
// Reset login attempts on successful login
if (userAuth) await resetUserLoginAttempts(userAuth);
if (userAuth) await updateUserLastLogin(userAuth);
// Generate tokens
const access_token = this.generateAccessToken(user);
const refresh_token = this.generateRefreshToken(user);
// Encrypt tokens before storing
const encrypted_access_token = await this.encryptToken(access_token);
const encrypted_refresh_token = await this.encryptToken(refresh_token);
// Calculate expiration times
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
// Create session with encrypted tokens
const sessionData: any = {
user_id: user.id,
session_token: encrypted_access_token,
refresh_token: encrypted_refresh_token,
device_info: device_info || {},
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
is_active: true,
permissions: (user as any).permissions || [],
roles: (user as any).roles || [],
};
if (ip_address) sessionData.ip_address = ip_address;
if (user_agent) sessionData.user_agent = user_agent;
await UserSession.create(sessionData as any);
// Get the created session
const createdSession = await UserSession.findOne({
where: { session_token: encrypted_access_token },
});
return {
access_token,
refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer",
user,
user_auth: userAuth.dataValues as UserAuth,
session: createdSession?.dataValues as UserSession,
};
}
/**
* Refresh access token
*/
static async refreshToken(refreshToken: string, device_info?: Record<string, unknown>): Promise<TokenPair> {
// Find active session by decrypting stored refresh tokens
const sessions = await sequelize.query(
`
SELECT us.*
FROM user_sessions us
WHERE us.is_active = true
AND us.refresh_expires_at > NOW()
`,
{
type: QueryTypes.SELECT,
},
);
// Find matching session by decrypting refresh tokens
let matchingSession: any = null;
for (const session of sessions as any[]) {
try {
const decryptedRefreshToken = await this.decryptToken(session.refresh_token);
if (decryptedRefreshToken === refreshToken) {
matchingSession = session;
break;
}
} catch (error) {
// Skip invalid encrypted tokens
continue;
}
}
if (!matchingSession) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
// Verify refresh token JWT
const decoded = this.verifyRefreshToken(refreshToken);
// Create user object from token data
const user = {
id: decoded.id,
email: decoded.email,
roles: decoded.roles || [],
permissions: decoded.permissions || [],
} as any;
// Generate new tokens
const access_token = this.generateAccessToken(user);
const new_refresh_token = this.generateRefreshToken(user);
// Encrypt new tokens
const encrypted_access_token = await this.encryptToken(access_token);
const encrypted_refresh_token = await this.encryptToken(new_refresh_token);
// Calculate expiration times
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
// Update session with encrypted tokens
await sequelize.query(
`
UPDATE user_sessions
SET session_token = :access_token,
refresh_token = :refresh_token,
expires_at = :expires_at,
refresh_expires_at = :refresh_expires_at,
device_info = :device_info,
last_activity_at = NOW()
WHERE id = :session_id
`,
{
replacements: {
access_token: encrypted_access_token,
refresh_token: encrypted_refresh_token,
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
device_info: device_info && Object.keys(device_info).length > 0 ? device_info : null,
session_id: matchingSession.id,
},
type: QueryTypes.UPDATE,
},
);
return {
access_token,
refresh_token: new_refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer",
};
}
/**
* Logout user (deactivate session)
*/
static async logout(accessToken: string): Promise<void> {
// Find session by decrypting access tokens
const sessions = await sequelize.query(
`
SELECT id, session_token
FROM user_sessions
WHERE is_active = true
`,
{
type: QueryTypes.SELECT,
},
);
// Find matching session
let sessionId: string | null = null;
for (const session of sessions as any[]) {
try {
const decryptedToken = await this.decryptToken(session.session_token);
if (decryptedToken === accessToken) {
sessionId = session.id;
break;
}
} catch (error) {
continue;
}
}
if (sessionId) {
await sequelize.query("UPDATE user_sessions SET is_active = false WHERE id = :session_id", {
replacements: { session_id: sessionId },
type: QueryTypes.UPDATE,
});
}
}
/**
* Logout from all devices
*/
static async logoutAll(userId: string): Promise<void> {
await UserSession.update(
{ is_active: false },
{
where: {
user_id: userId,
is_active: true,
},
},
);
}
/**
* Get user by ID with sessions
*/
static async getUserWithSessions(userId: string): Promise<User | null> {
return User.findByPk(userId, {
include: [
{
model: UserSession,
as: "sessions",
where: { is_active: true },
required: false,
},
],
});
}
/**
* Validate session token
*/
static async validateSession(sessionToken: string): Promise<User | null> {
let sessions: any[];
try {
// Find sessions and decrypt tokens to match
sessions = await sequelize.query(
`
SELECT us.*, u.email, u.username, u.first_name, u.last_name, u.status
FROM user_sessions us
JOIN users u ON us.user_id = u.id
WHERE us.is_active = true
AND us.expires_at > NOW()
`,
{
type: QueryTypes.SELECT,
},
);
} catch (queryError) {
throw queryError;
}
// Find matching session by decrypting access tokens
let matchingSession: any = null;
let decryptedToken: string | null = null;
for (const session of sessions as any[]) {
try {
const decryptedAccessToken = await this.decryptToken(session.session_token);
if (decryptedAccessToken === sessionToken) {
matchingSession = session;
decryptedToken = decryptedAccessToken;
break;
}
} catch (error) {
// Skip invalid encrypted tokens
continue;
}
}
if (!matchingSession || !decryptedToken) {
return null;
}
// Verify JWT token
try {
this.verifyAccessToken(decryptedToken);
} catch (error) {
return null;
}
// Create user object
const user = {
id: matchingSession.user_id,
email: matchingSession.email,
username: matchingSession.username,
first_name: matchingSession.first_name,
last_name: matchingSession.last_name,
status: matchingSession.status,
} as any;
// Update last activity
try {
await sequelize.query("UPDATE user_sessions SET last_activity_at = NOW() WHERE id = :session_id", {
replacements: { session_id: matchingSession.id },
type: QueryTypes.UPDATE,
});
} catch (updateError) {
// Don't fail validation just because we can't update activity
}
return user;
}
/**
* Change user password
*/
static async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const user = await User.findByPk(userId, {
include: [{ model: UserAuth, as: "user_auth" }],
});
if (!user) {
throw new GenericError({ vi: "Người dùng không tồn tại", en: "User not found" }, "NOT_FOUND", 404);
}
const userAuth = user.user_auth;
if (!userAuth) {
throw new GenericError(
{ vi: "Dữ liệu xác thực không tồn tại", en: "Authentication data not found" },
"NOT_FOUND",
404,
);
}
// Check if password can be changed (not too recently)
if (!canUserChangePassword(userAuth)) {
throw new GenericError(
{ vi: "Mật khẩu chỉ có thể thay đổi sau 24 giờ", en: "Password can only be changed after 24 hours" },
"FORBIDDEN",
403,
);
}
// Verify old password
const isValidOldPassword = await this.verifyPassword(oldPassword, userAuth.password_hash);
if (!isValidOldPassword) {
throw new GenericError({ vi: "Mật khẩu cũ không đúng", en: "Invalid old password" }, "UNAUTHORIZED", 401);
}
// Hash new password
const newPasswordHash = await this.hashPassword(newPassword);
// Check if password was recently used
const passwordHistory = (userAuth.password_history as string[]) || [];
if (isPasswordRecentlyUsed(userAuth, passwordHistory, newPasswordHash)) {
throw new GenericError(
{ vi: "Mật khẩu đã được sử dụng gần đây", en: "Password was recently used" },
"FORBIDDEN",
403,
);
}
// Update password history
passwordHistory.unshift(userAuth.password_hash); // Add old hash
if (passwordHistory.length > 5) passwordHistory.pop(); // Keep last 5
userAuth.password_history = passwordHistory;
// Update password
userAuth.password_hash = newPasswordHash;
await updateUserPasswordChanged(userAuth);
// Logout from all other sessions
await this.logoutAll(userId);
}
/**
* Parse time string to seconds (e.g., '15m' -> 900)
*/
private static parseTimeToSeconds(timeStr: string | undefined): number {
if (!timeStr) return 900; // 15 minutes default
const regex = /^(\d+)([smhd])$/;
const match = timeStr.match(regex);
if (!match) {
throw new Error(`Invalid time format: ${timeStr || "undefined"}`);
}
const value = parseInt(match[1] || "0");
const unit = match[2] || "m";
switch (unit) {
case "s":
return value;
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
throw new Error(`Invalid time unit: ${unit}`);
}
}
private static readonly JWT_SECRET = process.env.JWT_SECRET!;
private static readonly JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
private static readonly JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
private static readonly JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
private static readonly BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS || "12");
private static validateSecrets() {
if (!this.JWT_SECRET || !this.JWT_REFRESH_SECRET) {
throw new Error("JWT secrets are missing in environment variables");
}
}
static parseTimeToSeconds(timeStr: string): number {
const unit = timeStr.slice(-1);
const value = parseInt(timeStr.slice(0, -1));
switch (unit) {
case 'h': return value * 3600;
case 'd': return value * 86400;
case 'm': return value * 60;
case 's': return value;
default: return value;
}
}
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.BCRYPT_ROUNDS);
}
static async verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
static generateAccessToken(user: any, roles: string[]): string {
this.validateSecrets();
const payload: JWTPayload = {
id: user._id.toString(),
email: user.email,
username: user.username,
roles: roles
};
return sign(payload, this.JWT_SECRET, { expiresIn: this.JWT_EXPIRES_IN as any });
}
static generateRefreshToken(user: any): string {
this.validateSecrets();
const payload = {
id: user._id.toString(),
type: "refresh"
};
return sign(payload, this.JWT_REFRESH_SECRET, { expiresIn: this.JWT_REFRESH_EXPIRES_IN as any });
}
static verifyAccessToken(token: string): JWTPayload {
this.validateSecrets();
try {
return verify(token, this.JWT_SECRET) as JWTPayload;
} catch (error) {
throw new GenericError({ vi: "Token không hợp lệ", en: "Invalid token" }, "UNAUTHORIZED", 401);
}
}
static verifyRefreshToken(token: string): any {
this.validateSecrets();
try {
const decoded = verify(token, this.JWT_REFRESH_SECRET) as any;
if (decoded.type !== "refresh") throw new Error("Invalid token type");
return decoded;
} catch (error) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
}
static async register(data: RegisterData): Promise<IUser> {
const { email, password, username, first_name, last_name, phone, roles } = data;
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new GenericError({ vi: "Người dùng đã tồn tại", en: "User already exists" }, "CONFLICT", 409);
}
const password_hash = await this.hashPassword(password);
// Find roles
const roleNames = roles && roles.length > 0 ? roles : ['student'];
const dbRoles = await Role.find({ name: { $in: roleNames } });
const userRoleIds = dbRoles.map(r => r._id);
const userDataObj: any = {
email,
password_hash,
status: 'active',
roles: userRoleIds
};
if (username) userDataObj.username = username;
if (first_name) userDataObj.first_name = first_name;
if (last_name) userDataObj.last_name = last_name;
if (phone) userDataObj.phone = phone;
const user = await User.create(userDataObj);
return user;
}
static async login(credentials: LoginCredentials): Promise<LoginResult> {
const { email, password, device_info, ip_address, user_agent } = credentials;
const user = await User.findOne({ email }).populate('roles');
if (!user) {
throw new GenericError({ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" }, "UNAUTHORIZED", 401);
}
await checkAndUnlockExpiredLockout(user);
if (isUserLocked(user)) {
throw new GenericError({ vi: "Tài khoản bị khóa tạm thời", en: "Account is temporarily locked" }, "LOCKED", 423);
}
const isValidPassword = await this.verifyPassword(password, user.password_hash);
if (!isValidPassword) {
await incrementUserLoginAttempts(user);
throw new GenericError({ vi: "Email hoặc mật khẩu không đúng", en: "Invalid email or password" }, "UNAUTHORIZED", 401);
}
await resetUserLoginAttempts(user);
await updateUserLastLogin(user);
const roleNames = (user.roles as any[]).map(r => r.name);
const access_token = this.generateAccessToken(user, roleNames);
const refresh_token = this.generateRefreshToken(user);
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
const sessionDataObj: any = {
user: user._id,
session_token: access_token,
refresh_token: refresh_token,
device_info: device_info || {},
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
is_active: true
};
if (ip_address) sessionDataObj.ip_address = ip_address;
if (user_agent) sessionDataObj.user_agent = user_agent;
const session = await UserSession.create(sessionDataObj);
return {
access_token,
refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer",
user,
session
};
}
static async logout(token: string): Promise<void> {
await UserSession.updateOne({ session_token: token }, { is_active: false });
}
static async validateSession(token: string): Promise<any> {
const session = await UserSession.findOne({ session_token: token, is_active: true }).populate('user');
if (!session || dayjs().isAfter(dayjs(session.expires_at))) {
return null;
}
await session.updateOne({ last_activity_at: new Date() });
return session.user;
}
static async refreshToken(refreshToken: string, device_info?: Record<string, unknown>): Promise<TokenPair> {
const session = await UserSession.findOne({ refresh_token: refreshToken, is_active: true }).populate('user');
if (!session || dayjs().isAfter(dayjs(session.refresh_expires_at))) {
throw new GenericError({ vi: "Refresh token không hợp lệ", en: "Invalid refresh token" }, "UNAUTHORIZED", 401);
}
const decoded = this.verifyRefreshToken(refreshToken);
const user = session.user as any;
const roleNames = user.roles ? await Role.find({ _id: { $in: user.roles } }).then(roles => roles.map(r => r.name)) : [];
const new_access_token = this.generateAccessToken(user, roleNames);
const new_refresh_token = this.generateRefreshToken(user);
const expires_in = this.parseTimeToSeconds(this.JWT_EXPIRES_IN);
const refresh_expires_in = this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN);
await session.updateOne({
session_token: new_access_token,
refresh_token: new_refresh_token,
expires_at: new Date(Date.now() + expires_in * 1000),
refresh_expires_at: new Date(Date.now() + refresh_expires_in * 1000),
device_info: device_info || session.device_info,
last_activity_at: new Date()
});
return {
access_token: new_access_token,
refresh_token: new_refresh_token,
expires_in,
refresh_expires_in,
token_type: "Bearer"
};
}
static getAccessTokenCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: this.parseTimeToSeconds(this.JWT_EXPIRES_IN) * 1000,
path: '/api'
};
}
static getRefreshTokenCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: this.parseTimeToSeconds(this.JWT_REFRESH_EXPIRES_IN) * 1000,
path: '/api/v1.0/auth/refresh'
};
}
}
......@@ -6,23 +6,67 @@ module.exports = {
email: {
type: "string",
format: "email",
example: "user@example.com",
example: "admin@admin.com",
},
password: {
type: "string",
example: "password123",
example: "admin123",
},
device_info: {
type: "object",
description: "Device information for session tracking",
},
ip_address: {
},
},
RegisterRequest: {
type: "object",
required: ["email", "password"],
properties: {
email: {
type: "string",
description: "IP address for security logging",
format: "email",
example: "mentor1@example.com",
},
user_agent: {
password: {
type: "string",
example: "password123",
},
username: {
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 = {
},
},
},
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 = {
Bearer: {
name: "Authorization",
in: "header",
type: "apiKey",
BearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
};
module.exports = {
Student: {
type: "object",
required: ["fullName", "email", "phone", "courseId", "courseName", "semester", "startDate", "endDate"],
required: ["fullName", "email", "phone", "courseId", "courseName", "termId"],
properties: {
_id: {
type: "string",
......@@ -34,10 +34,10 @@ module.exports = {
description: "Course name",
example: "Advanced JavaScript"
},
semester: {
termId: {
type: "string",
description: "Semester",
example: "Fall 2024"
description: "Term ID (reference to Term collection)",
example: "507f1f77bcf86cd799439013"
},
status: {
type: "string",
......@@ -45,18 +45,6 @@ module.exports = {
default: "active",
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: {
type: "array",
items: {
......@@ -86,7 +74,7 @@ module.exports = {
},
StudentMutate: {
type: "object",
required: ["fullName", "email", "phone", "courseId", "courseName", "semester", "startDate", "endDate"],
required: ["fullName", "email", "phone", "courseId", "courseName", "termId"],
properties: {
fullName: {
type: "string",
......@@ -114,10 +102,10 @@ module.exports = {
description: "Course name",
example: "Advanced JavaScript"
},
semester: {
termId: {
type: "string",
description: "Semester",
example: "Fall 2024"
description: "Term ID (reference to Term collection)",
example: "507f1f77bcf86cd799439013"
},
status: {
type: "string",
......@@ -125,18 +113,6 @@ module.exports = {
default: "active",
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: {
type: "array",
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 { UserAuth } from "../models/UserAuth";
import { UserSession } from "../models/UserSession";
import { IUser } from "../models/mongodb/User";
import { IUserSession } from "../models/mongodb/UserSession";
import dayjs from "dayjs";
/**
* Authentication utility functions
* These functions provide the logic that would normally be instance methods
* but are kept separate to avoid conflicts with auto-generated models
* Authentication utility functions for Mongoose models
*/
/**
* Maximum number of login attempts before account lockout
*/
const MAX_LOGIN_ATTEMPTS = 5;
/**
* Check if user account is locked due to too many failed login attempts
*/
export const isUserLocked = (userAuth: UserAuth): boolean => {
if (userAuth.locked_until) {
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 isUserLocked = (user: IUser): boolean => {
if (user.lock_until) {
return dayjs().isBefore(dayjs(user.lock_until));
}
return false;
};
/**
* Deactivate a user session
*/
export const deactivateUserSession = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ is_active: false });
export const incrementUserLoginAttempts = async (user: IUser): Promise<void> => {
const currentAttempts = (user.login_attempts || 0) + 1;
const updates: any = { login_attempts: currentAttempts };
if (currentAttempts >= MAX_LOGIN_ATTEMPTS) {
updates.lock_until = dayjs().add(15, "minutes").toDate();
}
await user.updateOne(updates);
};
/**
* Update session activity timestamp
*/
export const updateSessionActivity = async (sessionInstance: UserSession): Promise<void> => {
await sessionInstance.update({ last_activity_at: new Date() });
export const resetUserLoginAttempts = async (user: IUser): Promise<void> => {
await user.updateOne({
login_attempts: 0,
lock_until: new Date(0),
});
};
/**
* Check if user can change password (not recently changed)
*/
export const canUserChangePassword = (userAuth: UserAuth, minDaysBetweenChanges: number = 1): boolean => {
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 unlockUserAccount = async (user: IUser): Promise<void> => {
await user.updateOne({
lock_until: new Date(0),
login_attempts: 0,
});
};
/**
* Update user's password changed timestamp
*/
export const updateUserPasswordChanged = async (userAuthInstance: UserAuth): Promise<void> => {
await userAuthInstance.update({
password_changed_at: new Date(),
});
export const checkAndUnlockExpiredLockout = async (user: IUser): Promise<boolean> => {
if (user.lock_until && dayjs().isAfter(dayjs(user.lock_until))) {
await unlockUserAccount(user);
return true;
}
return false;
};
/**
* Check if password was used recently (prevent reuse)
*/
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 updateUserLastLogin = async (user: IUser): Promise<void> => {
await user.updateOne({
last_login: new Date(),
});
};
/**
* Get user full name
*/
export const getUserFullName = (user: User): string => {
const firstName = user.first_name || "";
const lastName = user.last_name || "";
return `${firstName} ${lastName}`.trim();
export const isSessionExpired = (session: IUserSession): boolean => {
return dayjs().isAfter(dayjs(session.expires_at));
};
/**
* Check if user has specific role
*/
export const hasUserRole = (user: User, role: string): boolean => {
return (user as any).role?.name === role;
export const isSessionRefreshExpired = (session: IUserSession): boolean => {
return dayjs().isAfter(dayjs(session.refresh_expires_at));
};
/**
* Check if user has admin privileges
*/
export const isUserAdmin = (user: User): boolean => {
return (user as any).role?.name === "admin" || (user as any).role?.name === "system_admin";
export const deactivateUserSession = async (session: IUserSession): Promise<void> => {
await session.updateOne({ is_active: false });
};
/**
* Check if user is system admin
*/
export const isUserSystemAdmin = (user: User): boolean => {
return (user as any).role?.name === "system_admin";
export const updateSessionActivity = async (session: IUserSession): Promise<void> => {
await session.updateOne({ last_activity_at: new Date() });
};
/**
* Get user status display text
*/
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" };
export const canUserChangePassword = (user: IUser, minDaysBetweenChanges: number = 1): boolean => {
if (!user.password_changed_at) return true;
return dayjs().diff(dayjs(user.password_changed_at), "day") >= minDaysBetweenChanges;
};
/**
* Get role display text
*/
export const getRoleText = (role: string): { vi: string; en: string } => {
const roleMap: Record<string, { vi: string; en: string }> = {
user: { vi: "Người dùng", en: "User" },
admin: { vi: "Quản trị viên", en: "Administrator" },
system_admin: { vi: "Quản trị hệ thống", en: "System Administrator" },
};
return roleMap[role] || { vi: "Không xác định", en: "Unknown" };
const roleMap: Record<string, { vi: string; en: string }> = {
student: { vi: "Sinh viên", en: "Student" },
mentor: { vi: "Người hướng dẫn", en: "Mentor" },
admin: { vi: "Quản trị viên", en: "Administrator" },
};
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