update
This commit is contained in:
270
controllers/academicYearController.js
Normal file
270
controllers/academicYearController.js
Normal file
@@ -0,0 +1,270 @@
|
||||
const { AcademicYear } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Academic Year Controller - Quản lý năm học
|
||||
*/
|
||||
class AcademicYearController {
|
||||
/**
|
||||
* Get all academic years with pagination and caching
|
||||
*/
|
||||
async getAllAcademicYears(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, is_current } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `academic_years:list:${page}:${limit}:${is_current || 'all'}`;
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const where = {};
|
||||
if (is_current !== undefined) where.is_current = is_current === 'true';
|
||||
|
||||
// Query from database (through ProxySQL)
|
||||
const { count, rows } = await AcademicYear.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['start_date', 'DESC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
academicYears: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get academic year by ID with caching
|
||||
*/
|
||||
async getAcademicYearById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `academic_year:${id}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const academicYear = await AcademicYear.findByPk(id);
|
||||
|
||||
if (!academicYear) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Academic year not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, academicYear, 7200); // Cache for 2 hours
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: academicYear,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current academic year with caching
|
||||
*/
|
||||
async getCurrentAcademicYear(req, res, next) {
|
||||
try {
|
||||
const cacheKey = 'academic_year:current';
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const academicYear = await AcademicYear.findOne({
|
||||
where: { is_current: true },
|
||||
});
|
||||
|
||||
if (!academicYear) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'No current academic year found',
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, academicYear, 3600); // Cache for 1 hour
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: academicYear,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new academic year (async via BullMQ)
|
||||
*/
|
||||
async createAcademicYear(req, res, next) {
|
||||
try {
|
||||
const academicYearData = req.body;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('create', 'AcademicYear', academicYearData);
|
||||
|
||||
// Invalidate related caches
|
||||
await cacheUtils.deletePattern('academic_years:list:*');
|
||||
await cacheUtils.delete('academic_year:current');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Academic year creation job queued',
|
||||
jobId: job.id,
|
||||
data: academicYearData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update academic year (async via BullMQ)
|
||||
*/
|
||||
async updateAcademicYear(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('update', 'AcademicYear', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
// Invalidate related caches
|
||||
await cacheUtils.delete(`academic_year:${id}`);
|
||||
await cacheUtils.deletePattern('academic_years:list:*');
|
||||
await cacheUtils.delete('academic_year:current');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Academic year update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete academic year (async via BullMQ)
|
||||
*/
|
||||
async deleteAcademicYear(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('delete', 'AcademicYear', { id });
|
||||
|
||||
// Invalidate related caches
|
||||
await cacheUtils.delete(`academic_year:${id}`);
|
||||
await cacheUtils.deletePattern('academic_years:list:*');
|
||||
await cacheUtils.delete('academic_year:current');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Academic year deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set academic year as current
|
||||
*/
|
||||
async setCurrentAcademicYear(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Add to job queue to update current year
|
||||
const job = await addDatabaseWriteJob('update', 'AcademicYear', {
|
||||
id,
|
||||
updates: { is_current: true },
|
||||
// Will also set all other years to is_current: false
|
||||
});
|
||||
|
||||
// Invalidate all academic year caches
|
||||
await cacheUtils.deletePattern('academic_year*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Set current academic year job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get academic year datatypes
|
||||
*/
|
||||
async getAcademicYearDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = AcademicYear.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AcademicYearController();
|
||||
248
controllers/attendanceController.js
Normal file
248
controllers/attendanceController.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const { AttendanceLog, AttendanceDaily, UsersAuth, School } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob, addAttendanceProcessJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Attendance Controller - Quản lý điểm danh
|
||||
*/
|
||||
class AttendanceController {
|
||||
/**
|
||||
* Get attendance logs with pagination
|
||||
*/
|
||||
async getAttendanceLogs(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 50, user_id, school_id, date_from, date_to } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `attendance:logs:${page}:${limit}:${user_id || 'all'}:${school_id || 'all'}:${date_from || ''}:${date_to || ''}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (user_id) where.user_id = user_id;
|
||||
if (school_id) where.school_id = school_id;
|
||||
if (date_from && date_to) {
|
||||
where.check_time = {
|
||||
[require('sequelize').Op.between]: [date_from, date_to],
|
||||
};
|
||||
}
|
||||
|
||||
const { count, rows } = await AttendanceLog.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
include: [
|
||||
{
|
||||
model: UsersAuth,
|
||||
as: 'user',
|
||||
attributes: ['username', 'email'],
|
||||
},
|
||||
{
|
||||
model: School,
|
||||
as: 'school',
|
||||
attributes: ['school_code', 'school_name'],
|
||||
},
|
||||
],
|
||||
order: [['check_time', 'DESC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
logs: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 300); // Cache 5 minutes
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create attendance log (QR scan)
|
||||
*/
|
||||
async createAttendanceLog(req, res, next) {
|
||||
try {
|
||||
const logData = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('create', 'AttendanceLog', logData);
|
||||
|
||||
await cacheUtils.deletePattern('attendance:logs:*');
|
||||
await cacheUtils.deletePattern('attendance:daily:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Attendance log created',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily attendance summary
|
||||
*/
|
||||
async getDailyAttendance(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 50, school_id, class_id } = req.query;
|
||||
let { date } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Use today's date if not provided
|
||||
if (!date) {
|
||||
date = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD
|
||||
}
|
||||
|
||||
const cacheKey = `attendance:daily:${school_id || 'all'}:${date}:${class_id || 'all'}:${page}:${limit}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { attendance_date: date };
|
||||
if (school_id) where.school_id = school_id;
|
||||
if (class_id) where.class_id = class_id;
|
||||
|
||||
const { count, rows } = await AttendanceDaily.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
include: [{
|
||||
model: UsersAuth,
|
||||
as: 'user',
|
||||
attributes: ['username'],
|
||||
}],
|
||||
order: [['status', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
attendance: rows,
|
||||
date: date,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process daily attendance (aggregate from logs)
|
||||
*/
|
||||
async processAttendance(req, res, next) {
|
||||
try {
|
||||
const { school_id, date } = req.body;
|
||||
|
||||
const job = await addAttendanceProcessJob(school_id, date);
|
||||
|
||||
await cacheUtils.deletePattern('attendance:daily:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Attendance processing job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attendance statistics
|
||||
*/
|
||||
async getAttendanceStats(req, res, next) {
|
||||
try {
|
||||
const { school_id, date_from, date_to } = req.query;
|
||||
const cacheKey = `attendance:stats:${school_id}:${date_from}:${date_to}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await AttendanceDaily.findAll({
|
||||
where: {
|
||||
school_id,
|
||||
attendance_date: {
|
||||
[require('sequelize').Op.between]: [date_from, date_to],
|
||||
},
|
||||
},
|
||||
attributes: [
|
||||
'status',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
],
|
||||
group: ['status'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, stats, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attendance datatypes
|
||||
*/
|
||||
async getAttendanceDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = {
|
||||
AttendanceLog: AttendanceLog.rawAttributes,
|
||||
AttendanceDaily: AttendanceDaily.rawAttributes,
|
||||
};
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AttendanceController();
|
||||
351
controllers/authController.js
Normal file
351
controllers/authController.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const { UsersAuth, UserProfile } = require('../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// JWT Secret - nên lưu trong environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026';
|
||||
const JWT_EXPIRES_IN = '24h';
|
||||
|
||||
/**
|
||||
* Auth Controller - Xác thực và phân quyền
|
||||
*/
|
||||
class AuthController {
|
||||
/**
|
||||
* Login - Xác thực người dùng
|
||||
*/
|
||||
async login(req, res, next) {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username và password là bắt buộc',
|
||||
});
|
||||
}
|
||||
|
||||
// Tìm user theo username hoặc email
|
||||
const user = await UsersAuth.findOne({
|
||||
where: {
|
||||
[require('sequelize').Op.or]: [
|
||||
{ username: username },
|
||||
{ email: username },
|
||||
],
|
||||
},
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Username hoặc password không đúng',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra tài khoản có bị khóa không
|
||||
if (user.is_locked) {
|
||||
if (user.locked_until && new Date() < new Date(user.locked_until)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Tài khoản bị khóa đến ' + user.locked_until,
|
||||
});
|
||||
} else {
|
||||
// Mở khóa nếu hết thời gian khóa
|
||||
await user.update({
|
||||
is_locked: false,
|
||||
locked_until: null,
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Kiểm tra tài khoản có active không
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Tài khoản đã bị vô hiệu hóa',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordMatch = await bcrypt.compare(password + user.salt, user.password_hash);
|
||||
|
||||
if (!passwordMatch) {
|
||||
// Tăng số lần đăng nhập thất bại
|
||||
const failedAttempts = user.failed_login_attempts + 1;
|
||||
const updates = { failed_login_attempts: failedAttempts };
|
||||
|
||||
// Khóa tài khoản sau 5 lần thất bại
|
||||
if (failedAttempts >= 5) {
|
||||
updates.is_locked = true;
|
||||
updates.locked_until = new Date(Date.now() + 30 * 60 * 1000); // Khóa 30 phút
|
||||
}
|
||||
|
||||
await user.update(updates);
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Username hoặc password không đúng',
|
||||
attemptsLeft: Math.max(0, 5 - failedAttempts),
|
||||
});
|
||||
}
|
||||
|
||||
// Đăng nhập thành công - Reset failed attempts
|
||||
const sessionId = crypto.randomUUID();
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
|
||||
await user.update({
|
||||
failed_login_attempts: 0,
|
||||
login_count: user.login_count + 1,
|
||||
last_login: new Date(),
|
||||
last_login_ip: clientIp,
|
||||
current_session_id: sessionId,
|
||||
});
|
||||
|
||||
// Tạo JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
sessionId: sessionId,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
// Return success response
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Đăng nhập thành công',
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
last_login: user.last_login,
|
||||
login_count: user.login_count,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register - Tạo tài khoản mới
|
||||
*/
|
||||
async register(req, res, next) {
|
||||
try {
|
||||
const { username, email, password, full_name, phone, school_id } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username, email và password là bắt buộc',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra username đã tồn tại
|
||||
const existingUser = await UsersAuth.findOne({
|
||||
where: {
|
||||
[require('sequelize').Op.or]: [
|
||||
{ username },
|
||||
{ email },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Username hoặc email đã tồn tại',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(password + salt, 10);
|
||||
|
||||
// Tạo user mới
|
||||
const newUser = await UsersAuth.create({
|
||||
username,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
salt,
|
||||
qr_secret: crypto.randomBytes(32).toString('hex'),
|
||||
});
|
||||
|
||||
// Tạo profile nếu có thông tin
|
||||
if (full_name || phone || school_id) {
|
||||
await UserProfile.create({
|
||||
user_id: newUser.id,
|
||||
full_name: full_name || username,
|
||||
phone,
|
||||
school_id,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Đăng ký tài khoản thành công',
|
||||
data: {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Token - Xác thực JWT token
|
||||
*/
|
||||
async verifyToken(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không được cung cấp',
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Kiểm tra user còn tồn tại và session hợp lệ
|
||||
const user = await UsersAuth.findByPk(decoded.userId, {
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không hợp lệ hoặc tài khoản đã bị vô hiệu hóa',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.current_session_id !== decoded.sessionId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Phiên đăng nhập đã hết hạn',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Token hợp lệ',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không hợp lệ hoặc đã hết hạn',
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - Đăng xuất
|
||||
*/
|
||||
async logout(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không được cung cấp',
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Xóa session hiện tại
|
||||
await UsersAuth.update(
|
||||
{ current_session_id: null },
|
||||
{ where: { id: decoded.userId } }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Đăng xuất thành công',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info from token
|
||||
*/
|
||||
async me(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không được cung cấp',
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
const user = await UsersAuth.findByPk(decoded.userId, {
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
}],
|
||||
attributes: { exclude: ['password_hash', 'salt', 'qr_secret'] },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'User không tồn tại',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không hợp lệ hoặc đã hết hạn',
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
335
controllers/chapterController.js
Normal file
335
controllers/chapterController.js
Normal file
@@ -0,0 +1,335 @@
|
||||
const { Chapter, Subject, Lesson } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
|
||||
/**
|
||||
* Chapter Controller - Quản lý chương học
|
||||
*/
|
||||
class ChapterController {
|
||||
/**
|
||||
* Get all chapters with pagination
|
||||
*/
|
||||
async getAllChapters(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, subject_id, is_published } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `chapters:list:${page}:${limit}:${subject_id || 'all'}:${is_published || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (subject_id) where.subject_id = subject_id;
|
||||
if (is_published !== undefined) where.is_published = is_published === 'true';
|
||||
|
||||
const { count, rows } = await Chapter.findAndCountAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Subject,
|
||||
as: 'subject',
|
||||
attributes: ['id', 'subject_name', 'subject_code'],
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['display_order', 'ASC'], ['chapter_number', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
chapters: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chapter by ID
|
||||
*/
|
||||
async getChapterById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { include_lessons } = req.query;
|
||||
|
||||
const cacheKey = `chapter:${id}:${include_lessons || 'no'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const includeOptions = [
|
||||
{
|
||||
model: Subject,
|
||||
as: 'subject',
|
||||
attributes: ['id', 'subject_name', 'subject_code', 'description'],
|
||||
},
|
||||
];
|
||||
|
||||
if (include_lessons === 'true') {
|
||||
includeOptions.push({
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: { is_published: true },
|
||||
required: false,
|
||||
order: [['display_order', 'ASC']],
|
||||
});
|
||||
}
|
||||
|
||||
const chapter = await Chapter.findByPk(id, {
|
||||
include: includeOptions,
|
||||
});
|
||||
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, chapter, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: chapter,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new chapter
|
||||
*/
|
||||
async createChapter(req, res, next) {
|
||||
try {
|
||||
const {
|
||||
subject_id,
|
||||
chapter_number,
|
||||
chapter_title,
|
||||
chapter_description,
|
||||
duration_minutes,
|
||||
is_published,
|
||||
display_order,
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!subject_id || !chapter_number || !chapter_title) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'subject_id, chapter_number, and chapter_title are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if subject exists
|
||||
const subject = await Subject.findByPk(subject_id);
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found',
|
||||
});
|
||||
}
|
||||
|
||||
const chapter = await Chapter.create({
|
||||
subject_id,
|
||||
chapter_number,
|
||||
chapter_title,
|
||||
chapter_description,
|
||||
duration_minutes,
|
||||
is_published: is_published !== undefined ? is_published : false,
|
||||
display_order: display_order || 0,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('chapters:*');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: chapter,
|
||||
message: 'Chapter created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update chapter
|
||||
*/
|
||||
async updateChapter(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
chapter_number,
|
||||
chapter_title,
|
||||
chapter_description,
|
||||
duration_minutes,
|
||||
is_published,
|
||||
display_order,
|
||||
} = req.body;
|
||||
|
||||
const chapter = await Chapter.findByPk(id);
|
||||
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found',
|
||||
});
|
||||
}
|
||||
|
||||
await chapter.update({
|
||||
...(chapter_number !== undefined && { chapter_number }),
|
||||
...(chapter_title !== undefined && { chapter_title }),
|
||||
...(chapter_description !== undefined && { chapter_description }),
|
||||
...(duration_minutes !== undefined && { duration_minutes }),
|
||||
...(is_published !== undefined && { is_published }),
|
||||
...(display_order !== undefined && { display_order }),
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('chapters:*');
|
||||
await cacheUtils.deletePattern(`chapter:${id}*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: chapter,
|
||||
message: 'Chapter updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete chapter
|
||||
*/
|
||||
async deleteChapter(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const chapter = await Chapter.findByPk(id);
|
||||
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if chapter has lessons
|
||||
const lessonsCount = await Lesson.count({ where: { chapter_id: id } });
|
||||
if (lessonsCount > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Cannot delete chapter. It has ${lessonsCount} lesson(s). Delete lessons first.`,
|
||||
});
|
||||
}
|
||||
|
||||
await chapter.destroy();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('chapters:*');
|
||||
await cacheUtils.deletePattern(`chapter:${id}*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Chapter deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lessons in a chapter
|
||||
*/
|
||||
async getLessonsByChapter(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { page = 1, limit = 20, is_published } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `chapter:${id}:lessons:${page}:${limit}:${is_published || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const chapter = await Chapter.findByPk(id);
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found',
|
||||
});
|
||||
}
|
||||
|
||||
const where = { chapter_id: id };
|
||||
if (is_published !== undefined) where.is_published = is_published === 'true';
|
||||
|
||||
const { count, rows } = await Lesson.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['display_order', 'ASC'], ['lesson_number', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
chapter: {
|
||||
id: chapter.id,
|
||||
chapter_title: chapter.chapter_title,
|
||||
chapter_number: chapter.chapter_number,
|
||||
},
|
||||
lessons: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ChapterController();
|
||||
364
controllers/classController.js
Normal file
364
controllers/classController.js
Normal file
@@ -0,0 +1,364 @@
|
||||
const { Class } = require('../models');
|
||||
const { StudentDetail, UserProfile, UsersAuth, School } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Class Controller - Quản lý thông tin lớp học
|
||||
*/
|
||||
class ClassController {
|
||||
/**
|
||||
* Get all classes with pagination and caching
|
||||
*/
|
||||
async getAllClasses(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, school_id, grade_level, academic_year_id } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `classes:list:${page}:${limit}:${school_id || 'all'}:${grade_level || 'all'}:${academic_year_id || 'all'}`;
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const where = {};
|
||||
if (school_id) where.school_id = school_id;
|
||||
if (grade_level) where.grade_level = parseInt(grade_level);
|
||||
if (academic_year_id) where.academic_year_id = academic_year_id;
|
||||
|
||||
// Query from database (through ProxySQL)
|
||||
const { count, rows } = await Class.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['class_name', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
classes: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, result, 1800); // Cache for 30 minutes
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get class by ID with caching
|
||||
*/
|
||||
async getClassById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `class:${id}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const classData = await Class.findByPk(id);
|
||||
|
||||
if (!classData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Class not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, classData, 3600); // Cache for 1 hour
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: classData,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new class (async via BullMQ)
|
||||
*/
|
||||
async createClass(req, res, next) {
|
||||
try {
|
||||
const classData = req.body;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('create', 'Class', classData);
|
||||
|
||||
// Invalidate related caches
|
||||
await cacheUtils.deletePattern('classes:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Class creation job queued',
|
||||
jobId: job.id,
|
||||
data: classData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update class (async via BullMQ)
|
||||
*/
|
||||
async updateClass(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('update', 'Class', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
// Invalidate related caches
|
||||
await cacheUtils.delete(`class:${id}`);
|
||||
await cacheUtils.deletePattern('classes:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Class update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete class (async via BullMQ)
|
||||
*/
|
||||
async deleteClass(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('delete', 'Class', { id });
|
||||
|
||||
// Invalidate related caches
|
||||
await cacheUtils.delete(`class:${id}`);
|
||||
await cacheUtils.deletePattern('classes:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Class deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get class statistics
|
||||
*/
|
||||
async getClassStatistics(req, res, next) {
|
||||
try {
|
||||
const { school_id } = req.query;
|
||||
const cacheKey = `class:stats:${school_id || 'all'}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (school_id) where.school_id = school_id;
|
||||
|
||||
const stats = {
|
||||
totalClasses: await Class.count({ where }),
|
||||
byGradeLevel: await Class.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'grade_level',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
],
|
||||
group: ['grade_level'],
|
||||
raw: true,
|
||||
}),
|
||||
};
|
||||
|
||||
// Cache for 1 hour
|
||||
await cacheUtils.set(cacheKey, stats, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get class datatypes
|
||||
*/
|
||||
async getClassDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = Class.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get students in a class
|
||||
*/
|
||||
async getStudentsByClass(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { page = 1, limit = 20, status = 'active' } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `class:${id}:students:${page}:${limit}:${status}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if class exists and get school info
|
||||
const classData = await Class.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: School,
|
||||
as: 'school',
|
||||
attributes: ['school_name', 'school_code', 'address', 'city', 'district'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!classData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Class not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Query students
|
||||
const { count, rows: students } = await StudentDetail.findAndCountAll({
|
||||
where: {
|
||||
current_class_id: id,
|
||||
status: status,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
attributes: [
|
||||
'full_name',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'date_of_birth',
|
||||
'phone',
|
||||
'address',
|
||||
'city',
|
||||
'district',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: UsersAuth,
|
||||
as: 'auth',
|
||||
attributes: ['username', 'email', 'is_active'],
|
||||
},
|
||||
{
|
||||
model: School,
|
||||
as: 'school',
|
||||
attributes: ['school_name', 'school_code'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [[{ model: UserProfile, as: 'profile' }, 'full_name', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
class: {
|
||||
id: classData.id,
|
||||
class_name: classData.class_name,
|
||||
class_code: classData.class_code,
|
||||
grade_level: classData.grade_level,
|
||||
max_students: classData.max_students,
|
||||
current_students: classData.current_students,
|
||||
school: classData.school ? {
|
||||
school_name: classData.school.school_name,
|
||||
school_code: classData.school.school_code,
|
||||
address: classData.school.address,
|
||||
city: classData.school.city,
|
||||
district: classData.school.district,
|
||||
} : null,
|
||||
},
|
||||
students: students,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache for 10 minutes
|
||||
await cacheUtils.set(cacheKey, result, 600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClassController();
|
||||
374
controllers/gameController.js
Normal file
374
controllers/gameController.js
Normal file
@@ -0,0 +1,374 @@
|
||||
const { Game } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
|
||||
/**
|
||||
* Game Controller - Quản lý trò chơi giáo dục
|
||||
*/
|
||||
class GameController {
|
||||
/**
|
||||
* Get all games with pagination
|
||||
*/
|
||||
async getAllGames(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, type, is_active, is_premium, difficulty_level } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `games:list:${page}:${limit}:${type || 'all'}:${is_active || 'all'}:${is_premium || 'all'}:${difficulty_level || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (type) where.type = type;
|
||||
if (is_active !== undefined) where.is_active = is_active === 'true';
|
||||
if (is_premium !== undefined) where.is_premium = is_premium === 'true';
|
||||
if (difficulty_level) where.difficulty_level = difficulty_level;
|
||||
|
||||
const { count, rows } = await Game.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['display_order', 'ASC'], ['rating', 'DESC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
games: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get game by ID
|
||||
*/
|
||||
async getGameById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `game:${id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const game = await Game.findByPk(id);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, game, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: game,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new game
|
||||
*/
|
||||
async createGame(req, res, next) {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
thumbnail,
|
||||
type,
|
||||
config,
|
||||
is_active,
|
||||
is_premium,
|
||||
min_grade,
|
||||
max_grade,
|
||||
difficulty_level,
|
||||
display_order,
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!title || !url || !type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'title, url, and type are required',
|
||||
});
|
||||
}
|
||||
|
||||
const game = await Game.create({
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
thumbnail,
|
||||
type,
|
||||
config: config || {},
|
||||
is_active: is_active !== undefined ? is_active : true,
|
||||
is_premium: is_premium !== undefined ? is_premium : false,
|
||||
min_grade,
|
||||
max_grade,
|
||||
difficulty_level,
|
||||
display_order: display_order || 0,
|
||||
play_count: 0,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('games:*');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: game,
|
||||
message: 'Game created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update game
|
||||
*/
|
||||
async updateGame(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
thumbnail,
|
||||
type,
|
||||
config,
|
||||
is_active,
|
||||
is_premium,
|
||||
min_grade,
|
||||
max_grade,
|
||||
difficulty_level,
|
||||
display_order,
|
||||
rating,
|
||||
} = req.body;
|
||||
|
||||
const game = await Game.findByPk(id);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
await game.update({
|
||||
...(title !== undefined && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(url !== undefined && { url }),
|
||||
...(thumbnail !== undefined && { thumbnail }),
|
||||
...(type !== undefined && { type }),
|
||||
...(config !== undefined && { config }),
|
||||
...(is_active !== undefined && { is_active }),
|
||||
...(is_premium !== undefined && { is_premium }),
|
||||
...(min_grade !== undefined && { min_grade }),
|
||||
...(max_grade !== undefined && { max_grade }),
|
||||
...(difficulty_level !== undefined && { difficulty_level }),
|
||||
...(display_order !== undefined && { display_order }),
|
||||
...(rating !== undefined && { rating }),
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('games:*');
|
||||
await cacheUtils.deletePattern(`game:${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: game,
|
||||
message: 'Game updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete game
|
||||
*/
|
||||
async deleteGame(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const game = await Game.findByPk(id);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
await game.destroy();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('games:*');
|
||||
await cacheUtils.deletePattern(`game:${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Game deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment play count
|
||||
*/
|
||||
async incrementPlayCount(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const game = await Game.findByPk(id);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
await game.increment('play_count');
|
||||
await game.reload();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern(`game:${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: game.id,
|
||||
play_count: game.play_count,
|
||||
},
|
||||
message: 'Play count incremented',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get games by type (for lesson matching)
|
||||
*/
|
||||
async getGamesByType(req, res, next) {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const { only_active = 'true' } = req.query;
|
||||
|
||||
const cacheKey = `games:type:${type}:${only_active}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { type };
|
||||
if (only_active === 'true') where.is_active = true;
|
||||
|
||||
const games = await Game.findAll({
|
||||
where,
|
||||
order: [['display_order', 'ASC'], ['rating', 'DESC']],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, games, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: games,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get game statistics
|
||||
*/
|
||||
async getGameStats(req, res, next) {
|
||||
try {
|
||||
const cacheKey = 'games:stats';
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const [totalGames, activeGames, premiumGames, totalPlays] = await Promise.all([
|
||||
Game.count(),
|
||||
Game.count({ where: { is_active: true } }),
|
||||
Game.count({ where: { is_premium: true } }),
|
||||
Game.sum('play_count'),
|
||||
]);
|
||||
|
||||
const topGames = await Game.findAll({
|
||||
where: { is_active: true },
|
||||
order: [['play_count', 'DESC']],
|
||||
limit: 10,
|
||||
attributes: ['id', 'title', 'type', 'play_count', 'rating'],
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalGames,
|
||||
activeGames,
|
||||
premiumGames,
|
||||
totalPlays: totalPlays || 0,
|
||||
topGames,
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, stats, 600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GameController();
|
||||
264
controllers/gradeController.js
Normal file
264
controllers/gradeController.js
Normal file
@@ -0,0 +1,264 @@
|
||||
const { Grade, GradeItem, GradeSummary, StudentDetail, Subject, AcademicYear } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob, addGradeCalculationJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Grade Controller - Quản lý điểm số
|
||||
*/
|
||||
class GradeController {
|
||||
/**
|
||||
* Get grades with pagination
|
||||
*/
|
||||
async getAllGrades(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 50, student_id, subject_id, academic_year_id } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `grades:list:${page}:${limit}:${student_id || 'all'}:${subject_id || 'all'}:${academic_year_id || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (student_id) where.student_id = student_id;
|
||||
if (subject_id) where.subject_id = subject_id;
|
||||
if (academic_year_id) where.academic_year_id = academic_year_id;
|
||||
|
||||
const { count, rows } = await Grade.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
include: [
|
||||
{
|
||||
model: StudentDetail,
|
||||
as: 'student',
|
||||
attributes: ['student_code'],
|
||||
},
|
||||
{
|
||||
model: GradeItem,
|
||||
as: 'gradeItem',
|
||||
attributes: ['item_name', 'max_score'],
|
||||
},
|
||||
],
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
grades: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grade by ID
|
||||
*/
|
||||
async getGradeById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `grade:${id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const grade = await Grade.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: StudentDetail,
|
||||
as: 'student',
|
||||
},
|
||||
{
|
||||
model: GradeItem,
|
||||
as: 'gradeItem',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!grade) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Grade not found',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, grade, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: grade,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new grade (async via BullMQ)
|
||||
*/
|
||||
async createGrade(req, res, next) {
|
||||
try {
|
||||
const gradeData = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('create', 'Grade', gradeData);
|
||||
|
||||
await cacheUtils.deletePattern('grades:list:*');
|
||||
await cacheUtils.deletePattern(`grade:student:${gradeData.student_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Grade creation job queued',
|
||||
jobId: job.id,
|
||||
data: gradeData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update grade (async via BullMQ)
|
||||
*/
|
||||
async updateGrade(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'Grade', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`grade:${id}`);
|
||||
await cacheUtils.deletePattern('grades:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Grade update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete grade (async via BullMQ)
|
||||
*/
|
||||
async deleteGrade(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const job = await addDatabaseWriteJob('delete', 'Grade', { id });
|
||||
|
||||
await cacheUtils.delete(`grade:${id}`);
|
||||
await cacheUtils.deletePattern('grades:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Grade deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get student grade summary
|
||||
*/
|
||||
async getStudentGradeSummary(req, res, next) {
|
||||
try {
|
||||
const { student_id, academic_year_id } = req.params;
|
||||
const cacheKey = `grade:student:${student_id}:${academic_year_id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const summary = await GradeSummary.findOne({
|
||||
where: { student_id, academic_year_id },
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, summary, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate student GPA (async via BullMQ)
|
||||
*/
|
||||
async calculateGPA(req, res, next) {
|
||||
try {
|
||||
const { student_id, academic_year_id } = req.body;
|
||||
|
||||
const job = await addGradeCalculationJob(student_id, academic_year_id);
|
||||
|
||||
await cacheUtils.deletePattern(`grade:student:${student_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'GPA calculation job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grade datatypes
|
||||
*/
|
||||
async getGradeDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = Grade.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GradeController();
|
||||
391
controllers/parentTaskController.js
Normal file
391
controllers/parentTaskController.js
Normal file
@@ -0,0 +1,391 @@
|
||||
const { ParentAssignedTask, GradeItem, StudentDetail, UsersAuth, ParentStudentMap } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob, addNotificationJob } = require('../config/bullmq');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Parent Task Controller - Quản lý bài tập phụ huynh giao cho con
|
||||
*/
|
||||
class ParentTaskController {
|
||||
/**
|
||||
* POST /api/parent-tasks/assign - Phụ huynh giao bài tập cho con
|
||||
*/
|
||||
async assignTask(req, res, next) {
|
||||
try {
|
||||
const { parent_id, student_id, grade_item_id, due_date, message, priority } = req.body;
|
||||
|
||||
if (!parent_id || !student_id || !grade_item_id || !due_date) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: parent_id, student_id, grade_item_id, due_date',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra quyền của phụ huynh
|
||||
const parentMapping = await ParentStudentMap.findOne({
|
||||
where: {
|
||||
parent_id,
|
||||
student_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentMapping) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Parent-student relationship not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentMapping.can_assign_tasks) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Parent does not have permission to assign tasks',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra grade_item có tồn tại không
|
||||
const gradeItem = await GradeItem.findByPk(grade_item_id);
|
||||
if (!gradeItem) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Grade item not found',
|
||||
});
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
id: uuidv4(),
|
||||
parent_id,
|
||||
student_id,
|
||||
grade_item_id,
|
||||
assigned_at: new Date(),
|
||||
due_date: new Date(due_date),
|
||||
status: 'pending',
|
||||
message: message || null,
|
||||
priority: priority || 'normal',
|
||||
};
|
||||
|
||||
// Async write to DB via BullMQ
|
||||
await addDatabaseWriteJob('create', 'ParentAssignedTask', taskData);
|
||||
|
||||
// Gửi notification cho học sinh
|
||||
await addNotificationJob({
|
||||
user_id: student_id,
|
||||
type: 'parent_task_assigned',
|
||||
title: 'Bài tập mới từ phụ huynh',
|
||||
message: message || `Bạn có bài tập mới: ${gradeItem.item_name}`,
|
||||
data: taskData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`parent:tasks:${parent_id}`);
|
||||
await cacheUtils.del(`student:tasks:${student_id}`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Task assignment request queued',
|
||||
data: taskData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/parent-tasks/parent/:parent_id - Danh sách bài tập phụ huynh đã giao
|
||||
*/
|
||||
async getParentTasks(req, res, next) {
|
||||
try {
|
||||
const { parent_id } = req.params;
|
||||
const { status, student_id } = req.query;
|
||||
|
||||
const cacheKey = `parent:tasks:${parent_id}:${status || 'all'}:${student_id || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { parent_id };
|
||||
if (status) where.status = status;
|
||||
if (student_id) where.student_id = student_id;
|
||||
|
||||
const tasks = await ParentAssignedTask.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: StudentDetail,
|
||||
as: 'student',
|
||||
attributes: ['student_code', 'full_name'],
|
||||
},
|
||||
{
|
||||
model: GradeItem,
|
||||
as: 'gradeItem',
|
||||
attributes: ['item_name', 'max_score', 'description'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['priority', 'DESC'],
|
||||
['due_date', 'ASC'],
|
||||
],
|
||||
});
|
||||
|
||||
// Check for overdue tasks
|
||||
const now = new Date();
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'completed' && new Date(task.due_date) < now) {
|
||||
if (task.status !== 'overdue') {
|
||||
await addDatabaseWriteJob('update', 'ParentAssignedTask', {
|
||||
id: task.id,
|
||||
status: 'overdue',
|
||||
});
|
||||
task.status = 'overdue';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, tasks, 900); // Cache 15 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tasks,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/parent-tasks/student/:student_id - Danh sách bài tập của học sinh
|
||||
*/
|
||||
async getStudentTasks(req, res, next) {
|
||||
try {
|
||||
const { student_id } = req.params;
|
||||
const { status } = req.query;
|
||||
|
||||
const cacheKey = `student:tasks:${student_id}:${status || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { student_id };
|
||||
if (status) where.status = status;
|
||||
|
||||
const tasks = await ParentAssignedTask.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: UsersAuth,
|
||||
as: 'parent',
|
||||
attributes: ['username', 'full_name'],
|
||||
},
|
||||
{
|
||||
model: GradeItem,
|
||||
as: 'gradeItem',
|
||||
attributes: ['item_name', 'max_score', 'description'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['priority', 'DESC'],
|
||||
['due_date', 'ASC'],
|
||||
],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, tasks, 900); // Cache 15 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tasks,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/parent-tasks/:id/complete - Học sinh hoàn thành bài tập
|
||||
*/
|
||||
async completeTask(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { student_notes } = req.body;
|
||||
|
||||
const task = await ParentAssignedTask.findByPk(id);
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Task not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (task.status === 'completed') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Task already completed',
|
||||
});
|
||||
}
|
||||
|
||||
await addDatabaseWriteJob('update', 'ParentAssignedTask', {
|
||||
id,
|
||||
status: 'completed',
|
||||
completion_date: new Date(),
|
||||
student_notes: student_notes || null,
|
||||
});
|
||||
|
||||
// Gửi notification cho phụ huynh
|
||||
await addNotificationJob({
|
||||
user_id: task.parent_id,
|
||||
type: 'parent_task_completed',
|
||||
title: 'Bài tập đã hoàn thành',
|
||||
message: `Con bạn đã hoàn thành bài tập`,
|
||||
data: { task_id: id },
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`parent:tasks:${task.parent_id}:*`);
|
||||
await cacheUtils.del(`student:tasks:${task.student_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Task completion request queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/parent-tasks/:id/status - Cập nhật trạng thái bài tập
|
||||
*/
|
||||
async updateTaskStatus(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
const validStatuses = ['pending', 'in_progress', 'completed', 'overdue'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const task = await ParentAssignedTask.findByPk(id);
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Task not found',
|
||||
});
|
||||
}
|
||||
|
||||
await addDatabaseWriteJob('update', 'ParentAssignedTask', {
|
||||
id,
|
||||
status,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`parent:tasks:${task.parent_id}:*`);
|
||||
await cacheUtils.del(`student:tasks:${task.student_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Task status update queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/parent-tasks/:id - Xóa bài tập (chỉ phụ huynh mới xóa được)
|
||||
*/
|
||||
async deleteTask(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { parent_id } = req.body; // Xác thực parent_id
|
||||
|
||||
const task = await ParentAssignedTask.findByPk(id);
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Task not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (task.parent_id !== parent_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Only the parent who assigned the task can delete it',
|
||||
});
|
||||
}
|
||||
|
||||
await addDatabaseWriteJob('delete', 'ParentAssignedTask', { id });
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`parent:tasks:${task.parent_id}:*`);
|
||||
await cacheUtils.del(`student:tasks:${task.student_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Task deletion request queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/parent-tasks/stats - Thống kê bài tập
|
||||
*/
|
||||
async getTaskStats(req, res, next) {
|
||||
try {
|
||||
const { parent_id, student_id } = req.query;
|
||||
|
||||
const cacheKey = `parent:tasks:stats:${parent_id || 'all'}:${student_id || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (parent_id) where.parent_id = parent_id;
|
||||
if (student_id) where.student_id = student_id;
|
||||
|
||||
const stats = await ParentAssignedTask.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
'status',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
],
|
||||
group: ['status'],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ParentTaskController();
|
||||
219
controllers/roomController.js
Normal file
219
controllers/roomController.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const { Room, School } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Room Controller - Quản lý phòng học
|
||||
*/
|
||||
class RoomController {
|
||||
/**
|
||||
* Get all rooms with pagination and caching
|
||||
*/
|
||||
async getAllRooms(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 50, school_id, room_type } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `rooms:list:${page}:${limit}:${school_id || 'all'}:${room_type || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (school_id) where.school_id = school_id;
|
||||
if (room_type) where.room_type = room_type;
|
||||
|
||||
const { count, rows } = await Room.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['school_id', 'ASC'], ['floor', 'ASC'], ['room_code', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
rooms: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get room by ID
|
||||
*/
|
||||
async getRoomById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `room:${id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const room = await Room.findByPk(id);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Room not found',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, room, 7200);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: room,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new room (async via BullMQ)
|
||||
*/
|
||||
async createRoom(req, res, next) {
|
||||
try {
|
||||
const roomData = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('create', 'Room', roomData);
|
||||
|
||||
await cacheUtils.deletePattern('rooms:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Room creation job queued',
|
||||
jobId: job.id,
|
||||
data: roomData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update room (async via BullMQ)
|
||||
*/
|
||||
async updateRoom(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'Room', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`room:${id}`);
|
||||
await cacheUtils.deletePattern('rooms:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Room update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete room (async via BullMQ)
|
||||
*/
|
||||
async deleteRoom(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const job = await addDatabaseWriteJob('delete', 'Room', { id });
|
||||
|
||||
await cacheUtils.delete(`room:${id}`);
|
||||
await cacheUtils.deletePattern('rooms:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Room deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rooms by school
|
||||
*/
|
||||
async getRoomsBySchool(req, res, next) {
|
||||
try {
|
||||
const { school_id } = req.params;
|
||||
const cacheKey = `rooms:school:${school_id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const rooms = await Room.findAll({
|
||||
where: { school_id },
|
||||
order: [['floor', 'ASC'], ['room_code', 'ASC']],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, rooms, 7200);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: rooms,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get room datatypes
|
||||
*/
|
||||
async getRoomDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = Room.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RoomController();
|
||||
302
controllers/schoolController.js
Normal file
302
controllers/schoolController.js
Normal file
@@ -0,0 +1,302 @@
|
||||
const { School } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* School Controller - Quản lý thông tin trường học
|
||||
*/
|
||||
class SchoolController {
|
||||
/**
|
||||
* Get all schools with pagination and caching
|
||||
*/
|
||||
async getAllSchools(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, type, city, is_active } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `schools:list:${page}:${limit}:${type || 'all'}:${city || 'all'}:${is_active || 'all'}`;
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const where = {};
|
||||
if (type) where.school_type = type;
|
||||
if (city) where.city = city;
|
||||
if (is_active !== undefined) {
|
||||
where.is_active = is_active === 'true' || is_active === true;
|
||||
}
|
||||
|
||||
// Query from database (through ProxySQL)
|
||||
const { count, rows } = await School.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['school_name', 'ASC']],
|
||||
attributes: { exclude: ['config'] }, // Exclude sensitive data
|
||||
});
|
||||
|
||||
const result = {
|
||||
schools: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get school by ID with caching
|
||||
*/
|
||||
async getSchoolById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `school:${id}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const school = await School.findByPk(id);
|
||||
|
||||
if (!school) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'School not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, school, 7200); // Cache for 2 hours
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: school,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new school - Push to BullMQ
|
||||
*/
|
||||
async createSchool(req, res, next) {
|
||||
try {
|
||||
const schoolData = req.body;
|
||||
const userId = req.user?.id; // From auth middleware
|
||||
|
||||
// Validate required fields (you can use Joi for this)
|
||||
if (!schoolData.school_code || !schoolData.school_name || !schoolData.school_type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields',
|
||||
});
|
||||
}
|
||||
|
||||
// Add job to BullMQ queue
|
||||
const job = await addDatabaseWriteJob(
|
||||
'create',
|
||||
'School',
|
||||
schoolData,
|
||||
{ userId, priority: 3 }
|
||||
);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'School creation queued',
|
||||
jobId: job.id,
|
||||
data: {
|
||||
school_code: schoolData.school_code,
|
||||
school_name: schoolData.school_name,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update school - Push to BullMQ
|
||||
*/
|
||||
async updateSchool(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Check if school exists (from cache or DB)
|
||||
const cacheKey = `school:${id}`;
|
||||
let school = await cacheUtils.get(cacheKey);
|
||||
|
||||
if (!school) {
|
||||
school = await School.findByPk(id);
|
||||
if (!school) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'School not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add update job to BullMQ
|
||||
const job = await addDatabaseWriteJob(
|
||||
'update',
|
||||
'School',
|
||||
{ id, ...updateData },
|
||||
{ userId, priority: 3 }
|
||||
);
|
||||
|
||||
// Invalidate cache
|
||||
await cacheUtils.delete(cacheKey);
|
||||
await cacheUtils.deletePattern('schools:list:*');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'School update queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete school - Push to BullMQ
|
||||
*/
|
||||
async deleteSchool(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Check if school exists
|
||||
const school = await School.findByPk(id);
|
||||
if (!school) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'School not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete - just mark as inactive
|
||||
const job = await addDatabaseWriteJob(
|
||||
'update',
|
||||
'School',
|
||||
{ id, is_active: false },
|
||||
{ userId, priority: 3 }
|
||||
);
|
||||
|
||||
// Invalidate cache
|
||||
await cacheUtils.delete(`school:${id}`);
|
||||
await cacheUtils.deletePattern('schools:list:*');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'School deletion queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get school statistics
|
||||
*/
|
||||
async getSchoolStats(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `school:${id}:stats`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Get school with related data
|
||||
const school = await School.findByPk(id, {
|
||||
include: [
|
||||
{ association: 'classes', attributes: ['id'] },
|
||||
{ association: 'rooms', attributes: ['id'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!school) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'School not found',
|
||||
});
|
||||
}
|
||||
|
||||
const stats = {
|
||||
school_id: id,
|
||||
school_name: school.school_name,
|
||||
total_classes: school.classes?.length || 0,
|
||||
total_rooms: school.rooms?.length || 0,
|
||||
capacity: school.capacity,
|
||||
};
|
||||
|
||||
// Cache for 30 minutes
|
||||
await cacheUtils.set(cacheKey, stats, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get school datatypes
|
||||
*/
|
||||
async getSchoolDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = School.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SchoolController();
|
||||
211
controllers/studentController.js
Normal file
211
controllers/studentController.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const { StudentDetail, UserProfile, UsersAuth, Class } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Student Controller - Quản lý học sinh
|
||||
*/
|
||||
class StudentController {
|
||||
/**
|
||||
* Get all students with pagination and caching
|
||||
*/
|
||||
async getAllStudents(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, status, class_id } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `students:list:${page}:${limit}:${status || 'all'}:${class_id || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (status) where.status = status;
|
||||
if (class_id) where.current_class_id = class_id;
|
||||
|
||||
const { count, rows } = await StudentDetail.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
include: [
|
||||
{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
attributes: ['full_name', 'phone', 'date_of_birth', 'gender'],
|
||||
},
|
||||
{
|
||||
model: Class,
|
||||
as: 'currentClass',
|
||||
attributes: ['class_code', 'class_name', 'grade_level'],
|
||||
},
|
||||
],
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
students: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get student by ID
|
||||
*/
|
||||
async getStudentById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `student:${id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const student = await StudentDetail.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
},
|
||||
{
|
||||
model: Class,
|
||||
as: 'currentClass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!student) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Student not found',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, student, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: student,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new student (async via BullMQ)
|
||||
*/
|
||||
async createStudent(req, res, next) {
|
||||
try {
|
||||
const studentData = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('create', 'StudentDetail', studentData);
|
||||
|
||||
await cacheUtils.deletePattern('students:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Student creation job queued',
|
||||
jobId: job.id,
|
||||
data: studentData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update student (async via BullMQ)
|
||||
*/
|
||||
async updateStudent(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'StudentDetail', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`student:${id}`);
|
||||
await cacheUtils.deletePattern('students:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Student update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete student (update status to dropped)
|
||||
*/
|
||||
async deleteStudent(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'StudentDetail', {
|
||||
id,
|
||||
updates: { status: 'dropped' },
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`student:${id}`);
|
||||
await cacheUtils.deletePattern('students:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Student deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get student datatypes
|
||||
*/
|
||||
async getStudentDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = StudentDetail.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new StudentController();
|
||||
343
controllers/subjectController.js
Normal file
343
controllers/subjectController.js
Normal file
@@ -0,0 +1,343 @@
|
||||
const { Subject, Chapter } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
|
||||
/**
|
||||
* Subject Controller - Quản lý môn học
|
||||
*/
|
||||
class SubjectController {
|
||||
/**
|
||||
* Get all subjects with pagination and caching
|
||||
*/
|
||||
async getAllSubjects(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 50, is_active } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `subjects:list:${page}:${limit}:${is_active || 'all'}`;
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const where = {};
|
||||
if (is_active !== undefined) where.is_active = is_active === 'true';
|
||||
|
||||
// Query from database (through ProxySQL)
|
||||
const { count, rows } = await Subject.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['subject_code', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
subjects: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, result, 7200); // Cache for 2 hours
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subject by ID with caching
|
||||
*/
|
||||
async getSubjectById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `subject:${id}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const subject = await Subject.findByPk(id);
|
||||
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, subject, 7200); // Cache for 2 hours
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: subject,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subject by code with caching
|
||||
*/
|
||||
async getSubjectByCode(req, res, next) {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const cacheKey = `subject:code:${code}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const subject = await Subject.findOne({
|
||||
where: { subject_code: code },
|
||||
});
|
||||
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, subject, 7200); // Cache for 2 hours
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: subject,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new subject (async via BullMQ)
|
||||
*/
|
||||
async createSubject(req, res, next) {
|
||||
try {
|
||||
const subjectData = req.body;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('create', 'Subject', subjectData);
|
||||
|
||||
// Note: Cache will be invalidated by worker after successful creation
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Subject creation job queued',
|
||||
jobId: job.id,
|
||||
data: subjectData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subject (async via BullMQ)
|
||||
*/
|
||||
async updateSubject(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('update', 'Subject', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
// Note: Cache will be invalidated by worker after successful update
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Subject update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete subject (async via BullMQ)
|
||||
*/
|
||||
async deleteSubject(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Add to job queue for async processing
|
||||
const job = await addDatabaseWriteJob('delete', 'Subject', { id });
|
||||
|
||||
// Note: Cache will be invalidated by worker after successful deletion
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Subject deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active subjects (frequently used)
|
||||
*/
|
||||
async getActiveSubjects(req, res, next) {
|
||||
try {
|
||||
const cacheKey = 'subjects:active';
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Query from database
|
||||
const subjects = await Subject.findAll({
|
||||
where: { is_active: true },
|
||||
order: [['subject_code', 'ASC']],
|
||||
});
|
||||
|
||||
// Cache for 4 hours (this data changes infrequently)
|
||||
await cacheUtils.set(cacheKey, subjects, 14400);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: subjects,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subject datatypes
|
||||
*/
|
||||
async getSubjectDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = Subject.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chapters by subject ID
|
||||
*/
|
||||
async getChaptersBySubject(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { page = 1, limit = 50, is_published } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `subject:${id}:chapters:${page}:${limit}:${is_published || 'all'}`;
|
||||
|
||||
// Try cache first
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if subject exists
|
||||
const subject = await Subject.findByPk(id);
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const where = { subject_id: id };
|
||||
if (is_published !== undefined) where.is_published = is_published === 'true';
|
||||
|
||||
// Query chapters
|
||||
const { count, rows } = await Chapter.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['display_order', 'ASC'], ['chapter_number', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
subject: {
|
||||
id: subject.id,
|
||||
subject_code: subject.subject_code,
|
||||
subject_name: subject.subject_name,
|
||||
},
|
||||
chapters: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SubjectController();
|
||||
253
controllers/subscriptionController.js
Normal file
253
controllers/subscriptionController.js
Normal file
@@ -0,0 +1,253 @@
|
||||
const { SubscriptionPlan, UserSubscription, UsersAuth } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Subscription Controller - Quản lý gói thẻ tháng
|
||||
*/
|
||||
class SubscriptionController {
|
||||
/**
|
||||
* GET /api/subscriptions/plans - Danh sách các gói subscription
|
||||
*/
|
||||
async getPlans(req, res, next) {
|
||||
try {
|
||||
const { target_role } = req.query;
|
||||
const cacheKey = `subscription:plans:${target_role || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { is_active: true };
|
||||
if (target_role) where.target_role = target_role;
|
||||
|
||||
const plans = await SubscriptionPlan.findAll({
|
||||
where,
|
||||
order: [['price', 'ASC']],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, plans, 1800); // Cache 30 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: plans,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscriptions/purchase - Mua gói subscription
|
||||
*/
|
||||
async purchaseSubscription(req, res, next) {
|
||||
try {
|
||||
const { user_id, plan_id, payment_method, transaction_id } = req.body;
|
||||
|
||||
if (!user_id || !plan_id || !payment_method || !transaction_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: user_id, plan_id, payment_method, transaction_id',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra plan có tồn tại không
|
||||
const plan = await SubscriptionPlan.findByPk(plan_id);
|
||||
if (!plan || !plan.is_active) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subscription plan not found or inactive',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra user có subscription đang active không
|
||||
const existingSubscription = await UserSubscription.findOne({
|
||||
where: {
|
||||
user_id,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'User already has an active subscription',
|
||||
});
|
||||
}
|
||||
|
||||
const start_date = new Date();
|
||||
const end_date = new Date(start_date);
|
||||
end_date.setDate(end_date.getDate() + plan.duration_days);
|
||||
|
||||
const subscriptionData = {
|
||||
id: uuidv4(),
|
||||
user_id,
|
||||
plan_id,
|
||||
start_date,
|
||||
end_date,
|
||||
status: 'active',
|
||||
transaction_id,
|
||||
payment_method,
|
||||
payment_amount: plan.price,
|
||||
auto_renew: req.body.auto_renew || false,
|
||||
};
|
||||
|
||||
// Async write to DB via BullMQ
|
||||
await addDatabaseWriteJob('create', 'UserSubscription', subscriptionData);
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`subscription:user:${user_id}`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Subscription purchase request queued',
|
||||
data: subscriptionData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/subscriptions/user/:user_id - Kiểm tra subscription status của user
|
||||
*/
|
||||
async getUserSubscription(req, res, next) {
|
||||
try {
|
||||
const { user_id } = req.params;
|
||||
const cacheKey = `subscription:user:${user_id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await UserSubscription.findOne({
|
||||
where: { user_id },
|
||||
include: [
|
||||
{
|
||||
model: SubscriptionPlan,
|
||||
as: 'plan',
|
||||
attributes: ['name', 'price', 'features', 'duration_days'],
|
||||
},
|
||||
],
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'No subscription found for this user',
|
||||
});
|
||||
}
|
||||
|
||||
// Check nếu subscription đã hết hạn
|
||||
const now = new Date();
|
||||
if (subscription.status === 'active' && new Date(subscription.end_date) < now) {
|
||||
subscription.status = 'expired';
|
||||
await addDatabaseWriteJob('update', 'UserSubscription', {
|
||||
id: subscription.id,
|
||||
status: 'expired',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, subscription, 900); // Cache 15 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: subscription,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscriptions/:id/cancel - Hủy subscription
|
||||
*/
|
||||
async cancelSubscription(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const subscription = await UserSubscription.findByPk(id);
|
||||
|
||||
if (!subscription) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subscription not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.status === 'cancelled') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Subscription already cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
// Async update via BullMQ
|
||||
await addDatabaseWriteJob('update', 'UserSubscription', {
|
||||
id,
|
||||
status: 'cancelled',
|
||||
auto_renew: false,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`subscription:user:${subscription.user_id}`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Subscription cancellation request queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/subscriptions/stats - Thống kê subscription
|
||||
*/
|
||||
async getSubscriptionStats(req, res, next) {
|
||||
try {
|
||||
const cacheKey = 'subscription:stats';
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await UserSubscription.findAll({
|
||||
attributes: [
|
||||
'status',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
],
|
||||
group: ['status'],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SubscriptionController();
|
||||
274
controllers/teacherController.js
Normal file
274
controllers/teacherController.js
Normal file
@@ -0,0 +1,274 @@
|
||||
const { TeacherDetail, UserProfile, UsersAuth } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
const teacherProfileService = require('../services/teacherProfileService');
|
||||
|
||||
/**
|
||||
* Teacher Controller - Quản lý giáo viên
|
||||
*/
|
||||
class TeacherController {
|
||||
/**
|
||||
* Get all teachers with pagination and caching
|
||||
*/
|
||||
async getAllTeachers(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, status, teacher_type, school_id } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `teachers:list:${page}:${limit}:${status || 'all'}:${teacher_type || 'all'}:${school_id || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (status) where.status = status;
|
||||
if (teacher_type) where.teacher_type = teacher_type;
|
||||
|
||||
// Query teachers
|
||||
const { count, rows } = await TeacherDetail.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
// Manually fetch profiles for each teacher
|
||||
const teachersWithProfiles = await Promise.all(
|
||||
rows.map(async (teacher) => {
|
||||
const profile = await UserProfile.findOne({
|
||||
where: { user_id: teacher.user_id },
|
||||
attributes: ['id', 'full_name', 'phone', 'date_of_birth', 'gender', 'address', 'school_id', 'city', 'district', 'avatar_url'],
|
||||
});
|
||||
|
||||
// Filter by school_id if provided
|
||||
if (school_id && (!profile || profile.school_id !== school_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...teacher.toJSON(),
|
||||
profile: profile ? profile.toJSON() : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out nulls (teachers not matching school_id)
|
||||
const filteredTeachers = teachersWithProfiles.filter(t => t !== null);
|
||||
|
||||
const result = {
|
||||
teachers: filteredTeachers,
|
||||
pagination: {
|
||||
total: school_id ? filteredTeachers.length : count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil((school_id ? filteredTeachers.length : count) / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get teacher by ID with manual profile fetch
|
||||
*/
|
||||
async getTeacherById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `teacher:${id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const teacher = await TeacherDetail.findByPk(id);
|
||||
|
||||
if (!teacher) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Teacher not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Manually fetch profile
|
||||
const profile = await UserProfile.findOne({
|
||||
where: { user_id: teacher.user_id },
|
||||
});
|
||||
|
||||
const teacherData = {
|
||||
...teacher.toJSON(),
|
||||
profile: profile ? profile.toJSON() : null,
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, teacherData, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: teacherData,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new teacher with full profile (users_auth + user_profile + teacher_detail)
|
||||
*/
|
||||
async createTeacher(req, res, next) {
|
||||
try {
|
||||
const teacherData = req.body;
|
||||
|
||||
// Use service to create full teacher profile
|
||||
const result = await teacherProfileService.createTeacherWithProfile(teacherData);
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('teachers:list:*');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Teacher created successfully',
|
||||
data: {
|
||||
user_id: result.user_id,
|
||||
username: result.username,
|
||||
email: result.email,
|
||||
temporary_password: result.temporary_password, // null if user provided password
|
||||
teacher_code: result.teacher_detail.teacher_code,
|
||||
teacher_type: result.teacher_detail.teacher_type,
|
||||
profile: {
|
||||
full_name: result.profile.full_name,
|
||||
needs_update: result.profile.etc?.needs_profile_update || false,
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update teacher profile (cho phép user tự cập nhật)
|
||||
*/
|
||||
async updateTeacherProfile(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params; // user_id or teacher_id
|
||||
const profileData = req.body;
|
||||
|
||||
// Nếu id là teacher_id, tìm user_id
|
||||
let userId = id;
|
||||
if (profileData.teacher_id) {
|
||||
const teacher = await TeacherDetail.findByPk(profileData.teacher_id);
|
||||
if (!teacher) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Teacher not found',
|
||||
});
|
||||
}
|
||||
userId = teacher.user_id;
|
||||
}
|
||||
|
||||
// Update profile
|
||||
const updatedProfile = await teacherProfileService.updateTeacherProfile(userId, profileData);
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.delete(`teacher:${id}`);
|
||||
await cacheUtils.delete(`user:profile:${userId}`);
|
||||
await cacheUtils.deletePattern('teachers:list:*');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Teacher profile updated successfully',
|
||||
data: updatedProfile,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update teacher (async via BullMQ)
|
||||
*/
|
||||
async updateTeacher(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'TeacherDetail', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`teacher:${id}`);
|
||||
await cacheUtils.deletePattern('teachers:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Teacher update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete teacher (update status to resigned)
|
||||
*/
|
||||
async deleteTeacher(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'TeacherDetail', {
|
||||
id,
|
||||
updates: { status: 'resigned' },
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`teacher:${id}`);
|
||||
await cacheUtils.deletePattern('teachers:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Teacher deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get teacher datatypes
|
||||
*/
|
||||
async getTeacherDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = TeacherDetail.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TeacherController();
|
||||
318
controllers/trainingController.js
Normal file
318
controllers/trainingController.js
Normal file
@@ -0,0 +1,318 @@
|
||||
const { StaffTrainingAssignment, StaffAchievement, UsersAuth, Subject } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Training Controller - Quản lý đào tạo nhân sự
|
||||
*/
|
||||
class TrainingController {
|
||||
/**
|
||||
* GET /api/training/assignments/:staff_id - Danh sách khóa học bắt buộc của nhân viên
|
||||
*/
|
||||
async getStaffAssignments(req, res, next) {
|
||||
try {
|
||||
const { staff_id } = req.params;
|
||||
const { status, priority } = req.query;
|
||||
|
||||
const cacheKey = `training:assignments:${staff_id}:${status || 'all'}:${priority || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { staff_id };
|
||||
if (status) where.status = status;
|
||||
if (priority) where.priority = priority;
|
||||
|
||||
const assignments = await StaffTrainingAssignment.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Subject,
|
||||
as: 'subject',
|
||||
attributes: ['subject_name', 'subject_code', 'description'],
|
||||
},
|
||||
{
|
||||
model: UsersAuth,
|
||||
as: 'assignedBy',
|
||||
attributes: ['username', 'full_name'],
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['priority', 'DESC'],
|
||||
['deadline', 'ASC'],
|
||||
],
|
||||
});
|
||||
|
||||
// Check for overdue assignments
|
||||
const now = new Date();
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.status !== 'completed' && new Date(assignment.deadline) < now) {
|
||||
if (assignment.status !== 'overdue') {
|
||||
await addDatabaseWriteJob('update', 'StaffTrainingAssignment', {
|
||||
id: assignment.id,
|
||||
status: 'overdue',
|
||||
});
|
||||
assignment.status = 'overdue';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, assignments, 900); // Cache 15 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: assignments,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/training/assignments - Phân công đào tạo mới
|
||||
*/
|
||||
async assignTraining(req, res, next) {
|
||||
try {
|
||||
const { staff_id, course_name, subject_id, deadline, priority, notes, assigned_by } = req.body;
|
||||
|
||||
if (!staff_id || !course_name || !deadline || !assigned_by) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: staff_id, course_name, deadline, assigned_by',
|
||||
});
|
||||
}
|
||||
|
||||
const assignmentData = {
|
||||
id: uuidv4(),
|
||||
staff_id,
|
||||
course_name,
|
||||
subject_id: subject_id || null,
|
||||
assigned_by,
|
||||
assigned_date: new Date(),
|
||||
deadline: new Date(deadline),
|
||||
status: 'pending',
|
||||
priority: priority || 'normal',
|
||||
notes: notes || null,
|
||||
};
|
||||
|
||||
// Async write to DB via BullMQ
|
||||
await addDatabaseWriteJob('create', 'StaffTrainingAssignment', assignmentData);
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`training:assignments:${staff_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Training assignment request queued',
|
||||
data: assignmentData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/training/assignments/:id/status - Cập nhật trạng thái assignment
|
||||
*/
|
||||
async updateAssignmentStatus(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
const validStatuses = ['pending', 'in_progress', 'completed', 'overdue'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const assignment = await StaffTrainingAssignment.findByPk(id);
|
||||
if (!assignment) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Assignment not found',
|
||||
});
|
||||
}
|
||||
|
||||
await addDatabaseWriteJob('update', 'StaffTrainingAssignment', {
|
||||
id,
|
||||
status,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`training:assignments:${assignment.staff_id}:*`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Assignment status update queued',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/training/achievements - Ghi nhận hoàn thành khóa đào tạo
|
||||
*/
|
||||
async createAchievement(req, res, next) {
|
||||
try {
|
||||
const {
|
||||
staff_id,
|
||||
course_name,
|
||||
course_id,
|
||||
completion_date,
|
||||
certificate_url,
|
||||
certificate_code,
|
||||
type,
|
||||
score,
|
||||
total_hours,
|
||||
verified_by,
|
||||
} = req.body;
|
||||
|
||||
if (!staff_id || !course_name || !completion_date) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: staff_id, course_name, completion_date',
|
||||
});
|
||||
}
|
||||
|
||||
const achievementData = {
|
||||
id: uuidv4(),
|
||||
staff_id,
|
||||
course_name,
|
||||
course_id: course_id || null,
|
||||
completion_date: new Date(completion_date),
|
||||
certificate_url: certificate_url || null,
|
||||
certificate_code: certificate_code || null,
|
||||
type: type || 'mandatory',
|
||||
score: score || null,
|
||||
total_hours: total_hours || null,
|
||||
verified_by: verified_by || null,
|
||||
verified_at: verified_by ? new Date() : null,
|
||||
};
|
||||
|
||||
// Async write to DB via BullMQ
|
||||
await addDatabaseWriteJob('create', 'StaffAchievement', achievementData);
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.del(`training:achievements:${staff_id}`);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Achievement creation request queued',
|
||||
data: achievementData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/training/achievements/:staff_id - Danh sách chứng chỉ của nhân viên
|
||||
*/
|
||||
async getStaffAchievements(req, res, next) {
|
||||
try {
|
||||
const { staff_id } = req.params;
|
||||
const { type } = req.query;
|
||||
|
||||
const cacheKey = `training:achievements:${staff_id}:${type || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = { staff_id };
|
||||
if (type) where.type = type;
|
||||
|
||||
const achievements = await StaffAchievement.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Subject,
|
||||
as: 'course',
|
||||
attributes: ['subject_name', 'subject_code'],
|
||||
},
|
||||
{
|
||||
model: UsersAuth,
|
||||
as: 'verifiedBy',
|
||||
attributes: ['username', 'full_name'],
|
||||
},
|
||||
],
|
||||
order: [['completion_date', 'DESC']],
|
||||
});
|
||||
|
||||
await cacheUtils.set(cacheKey, achievements, 1800); // Cache 30 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: achievements,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/training/stats - Thống kê đào tạo
|
||||
*/
|
||||
async getTrainingStats(req, res, next) {
|
||||
try {
|
||||
const cacheKey = 'training:stats';
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const assignmentStats = await StaffTrainingAssignment.findAll({
|
||||
attributes: [
|
||||
'status',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
],
|
||||
group: ['status'],
|
||||
});
|
||||
|
||||
const achievementStats = await StaffAchievement.findAll({
|
||||
attributes: [
|
||||
'type',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
[require('sequelize').fn('SUM', require('sequelize').col('total_hours')), 'total_hours'],
|
||||
],
|
||||
group: ['type'],
|
||||
});
|
||||
|
||||
const stats = {
|
||||
assignments: assignmentStats,
|
||||
achievements: achievementStats,
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TrainingController();
|
||||
495
controllers/userController.js
Normal file
495
controllers/userController.js
Normal file
@@ -0,0 +1,495 @@
|
||||
const { UsersAuth, UserProfile } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
const { addDatabaseWriteJob } = require('../config/bullmq');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// JWT Secret - nên lưu trong environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026';
|
||||
const JWT_EXPIRES_IN = '24h';
|
||||
|
||||
/**
|
||||
* User Controller - Quản lý người dùng (Auth + Profile)
|
||||
*/
|
||||
class UserController {
|
||||
/**
|
||||
* Login - Xác thực người dùng
|
||||
*/
|
||||
async login(req, res, next) {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username và password là bắt buộc',
|
||||
});
|
||||
}
|
||||
|
||||
// Tìm user theo username hoặc email
|
||||
const user = await UsersAuth.findOne({
|
||||
where: {
|
||||
[require('sequelize').Op.or]: [
|
||||
{ username: username },
|
||||
{ email: username },
|
||||
],
|
||||
},
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Username hoặc password không đúng',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra tài khoản có bị khóa không
|
||||
if (user.is_locked) {
|
||||
if (user.locked_until && new Date() < new Date(user.locked_until)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Tài khoản bị khóa đến ' + user.locked_until,
|
||||
});
|
||||
} else {
|
||||
// Mở khóa nếu hết thời gian khóa
|
||||
await user.update({
|
||||
is_locked: false,
|
||||
locked_until: null,
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Kiểm tra tài khoản có active không
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Tài khoản đã bị vô hiệu hóa',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordMatch = await bcrypt.compare(password + user.salt, user.password_hash);
|
||||
|
||||
if (!passwordMatch) {
|
||||
// Tăng số lần đăng nhập thất bại
|
||||
const failedAttempts = user.failed_login_attempts + 1;
|
||||
const updates = { failed_login_attempts: failedAttempts };
|
||||
|
||||
// Khóa tài khoản sau 5 lần thất bại
|
||||
if (failedAttempts >= 5) {
|
||||
updates.is_locked = true;
|
||||
updates.locked_until = new Date(Date.now() + 30 * 60 * 1000); // Khóa 30 phút
|
||||
}
|
||||
|
||||
await user.update(updates);
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Username hoặc password không đúng',
|
||||
attemptsLeft: Math.max(0, 5 - failedAttempts),
|
||||
});
|
||||
}
|
||||
|
||||
// Đăng nhập thành công - Reset failed attempts
|
||||
const sessionId = crypto.randomUUID();
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
|
||||
await user.update({
|
||||
failed_login_attempts: 0,
|
||||
login_count: user.login_count + 1,
|
||||
last_login: new Date(),
|
||||
last_login_ip: clientIp,
|
||||
current_session_id: sessionId,
|
||||
});
|
||||
|
||||
// Tạo JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
sessionId: sessionId,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
|
||||
// Return success response
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Đăng nhập thành công',
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
last_login: user.last_login,
|
||||
login_count: user.login_count,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register - Tạo tài khoản mới
|
||||
*/
|
||||
async register(req, res, next) {
|
||||
try {
|
||||
const { username, email, password, full_name, phone, school_id } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Username, email và password là bắt buộc',
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra username đã tồn tại
|
||||
const existingUser = await UsersAuth.findOne({
|
||||
where: {
|
||||
[require('sequelize').Op.or]: [
|
||||
{ username },
|
||||
{ email },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Username hoặc email đã tồn tại',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(password + salt, 10);
|
||||
|
||||
// Tạo user mới
|
||||
const newUser = await UsersAuth.create({
|
||||
username,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
salt,
|
||||
qr_secret: crypto.randomBytes(32).toString('hex'),
|
||||
});
|
||||
|
||||
// Tạo profile nếu có thông tin
|
||||
if (full_name || phone || school_id) {
|
||||
await UserProfile.create({
|
||||
user_id: newUser.id,
|
||||
full_name: full_name || username,
|
||||
phone,
|
||||
school_id,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Đăng ký tài khoản thành công',
|
||||
data: {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Token - Xác thực JWT token
|
||||
*/
|
||||
async verifyToken(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không được cung cấp',
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Kiểm tra user còn tồn tại và session hợp lệ
|
||||
const user = await UsersAuth.findByPk(decoded.userId, {
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không hợp lệ hoặc tài khoản đã bị vô hiệu hóa',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.current_session_id !== decoded.sessionId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Phiên đăng nhập đã hết hạn',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Token hợp lệ',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không hợp lệ hoặc đã hết hạn',
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - Đăng xuất
|
||||
*/
|
||||
async logout(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token không được cung cấp',
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Xóa session hiện tại
|
||||
await UsersAuth.update(
|
||||
{ current_session_id: null },
|
||||
{ where: { id: decoded.userId } }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Đăng xuất thành công',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users with pagination and caching
|
||||
*/
|
||||
async getAllUsers(req, res, next) {
|
||||
try {
|
||||
const { page = 1, limit = 20, is_active, school_id } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `users:list:${page}:${limit}:${is_active || 'all'}:${school_id || 'all'}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const where = {};
|
||||
if (is_active !== undefined) where.is_active = is_active === 'true';
|
||||
|
||||
const { count, rows } = await UsersAuth.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
attributes: ['full_name', 'phone', 'school_id'],
|
||||
where: school_id ? { school_id } : undefined,
|
||||
}],
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
users: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID with profile
|
||||
*/
|
||||
async getUserById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const cacheKey = `user:${id}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await UsersAuth.findByPk(id, {
|
||||
include: [{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await cacheUtils.set(cacheKey, user, 3600);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user (async via BullMQ)
|
||||
*/
|
||||
async createUser(req, res, next) {
|
||||
try {
|
||||
const userData = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('create', 'UsersAuth', userData);
|
||||
|
||||
await cacheUtils.deletePattern('users:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'User creation job queued',
|
||||
jobId: job.id,
|
||||
data: userData,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user (async via BullMQ)
|
||||
*/
|
||||
async updateUser(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'UsersAuth', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`user:${id}`);
|
||||
await cacheUtils.deletePattern('users:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'User update job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (soft delete via is_active)
|
||||
*/
|
||||
async deleteUser(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const job = await addDatabaseWriteJob('update', 'UsersAuth', {
|
||||
id,
|
||||
updates: { is_active: false },
|
||||
});
|
||||
|
||||
await cacheUtils.delete(`user:${id}`);
|
||||
await cacheUtils.deletePattern('users:list:*');
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'User deletion job queued',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user datatypes
|
||||
*/
|
||||
async getUserDatatypes(req, res, next) {
|
||||
try {
|
||||
const datatypes = UsersAuth.rawAttributes;
|
||||
res.json({
|
||||
success: true,
|
||||
data: datatypes,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserController();
|
||||
Reference in New Issue
Block a user