This commit is contained in:
Ken
2026-01-19 09:33:35 +07:00
parent 374dc12b2d
commit 70838a4bc1
103 changed files with 16929 additions and 2 deletions

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();