feat: integrate audit logging across authentication, course management, and user operations
Add comprehensive audit trail tracking by integrating auditService throughout the application. Track user authentication (LOGIN, REGISTER), course lifecycle (CREATE, APPROVE_COURSE, REJECT_COURSE, ENROLL), content management (CREATE/DELETE Chapter/Lesson), file operations (UPLOAD_FILE, DELETE_FILE for videos and attachments), password management (CHANGE_PASSWORD, RESET_PASSWORD), user role updates (UPDATE
This commit is contained in:
parent
923c8b727a
commit
108f1b73f2
10 changed files with 701 additions and 0 deletions
182
Backend/src/controllers/AuditController.ts
Normal file
182
Backend/src/controllers/AuditController.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { Get, Path, Query, Request, Response, Route, Security, SuccessResponse, Tags, Delete } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import { auditService } from '../services/audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
import {
|
||||
ListAuditLogsResponse,
|
||||
AuditLogResponse,
|
||||
AuditLogStats,
|
||||
} from '../types/audit.types';
|
||||
|
||||
@Route('api/admin/audit-logs')
|
||||
@Tags('Admin/AuditLogs')
|
||||
export class AuditController {
|
||||
|
||||
/**
|
||||
* ดึงรายการ audit logs ทั้งหมด
|
||||
* Get all audit logs with filters and pagination
|
||||
* @param userId - กรองตาม user ID
|
||||
* @param action - กรองตาม action type
|
||||
* @param entityType - กรองตาม entity type (Course, User, Lesson, etc.)
|
||||
* @param entityId - กรองตาม entity ID
|
||||
* @param startDate - วันที่เริ่มต้น (ISO format)
|
||||
* @param endDate - วันที่สิ้นสุด (ISO format)
|
||||
* @param page - หน้าที่ต้องการ
|
||||
* @param limit - จำนวนรายการต่อหน้า
|
||||
*/
|
||||
@Get('')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Audit logs retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
public async getAuditLogs(
|
||||
@Request() request: any,
|
||||
@Query() userId?: number,
|
||||
@Query() action?: AuditAction,
|
||||
@Query() entityType?: string,
|
||||
@Query() entityId?: number,
|
||||
@Query() startDate?: string,
|
||||
@Query() endDate?: string,
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number
|
||||
): Promise<ListAuditLogsResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
return await auditService.getLogs({
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
page: page || 1,
|
||||
limit: limit || 50,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง audit log by ID
|
||||
* Get audit log detail by ID
|
||||
* @param logId - รหัส audit log
|
||||
*/
|
||||
@Get('{logId}')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Audit log retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
@Response('404', 'Audit log not found')
|
||||
public async getAuditLogById(
|
||||
@Request() request: any,
|
||||
@Path() logId: number
|
||||
): Promise<AuditLogResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
const log = await auditService.getLogById(logId);
|
||||
if (!log) {
|
||||
throw new ValidationError('Audit log not found');
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงสถิติ audit logs สำหรับ dashboard
|
||||
* Get audit log statistics for admin dashboard
|
||||
*/
|
||||
@Get('stats/summary')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Audit stats retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
public async getAuditStats(@Request() request: any): Promise<AuditLogStats> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
return await auditService.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง history ของ entity เฉพาะ
|
||||
* Get audit history for a specific entity
|
||||
* @param entityType - ประเภท entity (Course, User, Lesson, etc.)
|
||||
* @param entityId - รหัส entity
|
||||
*/
|
||||
@Get('entity/{entityType}/{entityId}')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Entity history retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
public async getEntityHistory(
|
||||
@Request() request: any,
|
||||
@Path() entityType: string,
|
||||
@Path() entityId: number
|
||||
): Promise<AuditLogResponse[]> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
return await auditService.getEntityHistory(entityType, entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง activity ของ user เฉพาะ
|
||||
* Get activity logs for a specific user
|
||||
* @param userId - รหัส user
|
||||
* @param limit - จำนวนรายการ
|
||||
*/
|
||||
@Get('user/{userId}/activity')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'User activity retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
public async getUserActivity(
|
||||
@Request() request: any,
|
||||
@Path() userId: number,
|
||||
@Query() limit?: number
|
||||
): Promise<AuditLogResponse[]> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
return await auditService.getUserActivity(userId, limit || 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ audit logs เก่า (maintenance)
|
||||
* Delete old audit logs for maintenance
|
||||
* @param days - ลบ logs ที่เก่ากว่ากี่วัน
|
||||
*/
|
||||
@Delete('cleanup')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Old logs deleted successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
public async deleteOldLogs(
|
||||
@Request() request: any,
|
||||
@Query() days: number = 90
|
||||
): Promise<{ deleted: number; message: string }> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
|
||||
if (days < 30) {
|
||||
throw new ValidationError('Cannot delete logs newer than 30 days');
|
||||
}
|
||||
|
||||
const deleted = await auditService.deleteOldLogs(days);
|
||||
return {
|
||||
deleted,
|
||||
message: `Deleted ${deleted} audit logs older than ${days} days`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
ApproveCourseResponse,
|
||||
RejectCourseResponse,
|
||||
} from '../types/AdminCourseApproval.types';
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
export class AdminCourseApprovalService {
|
||||
|
||||
|
|
@ -235,6 +237,17 @@ export class AdminCourseApprovalService {
|
|||
})
|
||||
]);
|
||||
|
||||
// Audit log - APPROVE_COURSE
|
||||
await auditService.logSync({
|
||||
userId: decoded.id,
|
||||
action: AuditAction.APPROVE_COURSE,
|
||||
entityType: 'Course',
|
||||
entityId: courseId,
|
||||
oldValue: { status: 'PENDING' },
|
||||
newValue: { status: 'APPROVED' },
|
||||
metadata: { comment: comment || null }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Course approved successfully'
|
||||
|
|
@ -290,6 +303,17 @@ export class AdminCourseApprovalService {
|
|||
})
|
||||
]);
|
||||
|
||||
// Audit log - REJECT_COURSE
|
||||
await auditService.logSync({
|
||||
userId: decoded.id,
|
||||
action: AuditAction.REJECT_COURSE,
|
||||
entityType: 'Course',
|
||||
entityId: courseId,
|
||||
oldValue: { status: 'PENDING' },
|
||||
newValue: { status: 'DRAFT' },
|
||||
metadata: { comment: comment }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Course rejected successfully'
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import {
|
|||
QuizData,
|
||||
} from "../types/ChaptersLesson.typs";
|
||||
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* ตรวจสอบสิทธิ์เข้าถึง Course (สำหรับทั้ง Instructor และ Student)
|
||||
|
|
@ -127,6 +129,16 @@ export class ChaptersLessonService {
|
|||
throw new ForbiddenError('You are not permitted to create chapter');
|
||||
}
|
||||
const chapter = await prisma.chapter.create({ data: { course_id, title, description, sort_order } });
|
||||
|
||||
// Audit log - CREATE Chapter
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.CREATE,
|
||||
entityType: 'Chapter',
|
||||
entityId: chapter.id,
|
||||
newValue: { course_id, title, sort_order }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
|
||||
} catch (error) {
|
||||
logger.error(`Error creating chapter: ${error}`);
|
||||
|
|
@ -170,6 +182,15 @@ export class ChaptersLessonService {
|
|||
}
|
||||
await prisma.chapter.delete({ where: { id: chapter_id } });
|
||||
|
||||
// Audit log - DELETE Chapter
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.DELETE,
|
||||
entityType: 'Chapter',
|
||||
entityId: chapter_id,
|
||||
oldValue: { course_id, chapter_id }
|
||||
});
|
||||
|
||||
// Normalize sort_order for remaining chapters (fill gaps)
|
||||
await this.normalizeChapterSortOrder(course_id);
|
||||
|
||||
|
|
@ -308,9 +329,28 @@ export class ChaptersLessonService {
|
|||
where: { id: lesson.id },
|
||||
include: { quiz: true }
|
||||
});
|
||||
|
||||
// Audit log - CREATE Lesson (QUIZ)
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.CREATE,
|
||||
entityType: 'Lesson',
|
||||
entityId: lesson.id,
|
||||
newValue: { chapter_id, title, type: 'QUIZ', sort_order }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Lesson created successfully', data: completeLesson as LessonData };
|
||||
}
|
||||
|
||||
// Audit log - CREATE Lesson
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.CREATE,
|
||||
entityType: 'Lesson',
|
||||
entityId: lesson.id,
|
||||
newValue: { chapter_id, title, type, sort_order }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
|
||||
} catch (error) {
|
||||
logger.error(`Error creating lesson: ${error}`);
|
||||
|
|
@ -621,6 +661,15 @@ export class ChaptersLessonService {
|
|||
// Based on Prisma schema: onDelete: Cascade
|
||||
await prisma.lesson.delete({ where: { id: lesson_id } });
|
||||
|
||||
// Audit log - DELETE Lesson
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.DELETE,
|
||||
entityType: 'Lesson',
|
||||
entityId: lesson_id,
|
||||
oldValue: { chapter_id: chapterId, title: lesson.title, type: lesson.type }
|
||||
});
|
||||
|
||||
// Normalize sort_order for remaining lessons (fill gaps)
|
||||
await this.normalizeLessonSortOrder(chapterId);
|
||||
|
||||
|
|
@ -683,6 +732,15 @@ export class ChaptersLessonService {
|
|||
// Get presigned URL
|
||||
const video_url = await getPresignedUrl(videoPath, 3600);
|
||||
|
||||
// Audit log - UPLOAD_FILE (Video)
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.UPLOAD_FILE,
|
||||
entityType: 'Lesson',
|
||||
entityId: lesson_id,
|
||||
newValue: { file_name: video.originalname, file_size: video.size, mime_type: video.mimetype }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Video uploaded successfully',
|
||||
|
|
@ -909,6 +967,15 @@ export class ChaptersLessonService {
|
|||
// Get presigned URL
|
||||
const presigned_url = await getPresignedUrl(attachmentPath, 3600);
|
||||
|
||||
// Audit log - UPLOAD_FILE (Attachment)
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.UPLOAD_FILE,
|
||||
entityType: 'LessonAttachment',
|
||||
entityId: newAttachment.id,
|
||||
newValue: { lesson_id, file_name: attachment.originalname, file_size: attachment.size }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Attachment uploaded successfully',
|
||||
|
|
@ -972,6 +1039,15 @@ export class ChaptersLessonService {
|
|||
// Delete attachment record from database
|
||||
await prisma.lessonAttachment.delete({ where: { id: attachment_id } });
|
||||
|
||||
// Audit log - DELETE_FILE (Attachment)
|
||||
auditService.log({
|
||||
userId: decodedToken.id,
|
||||
action: AuditAction.DELETE_FILE,
|
||||
entityType: 'LessonAttachment',
|
||||
entityId: attachment_id,
|
||||
oldValue: { lesson_id, file_name: attachment.file_name, file_path: attachment.file_path }
|
||||
});
|
||||
|
||||
return { code: 200, message: 'Attachment deleted successfully' };
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting attachment: ${error}`);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import {
|
|||
GetEnrolledStudentDetailInput,
|
||||
GetEnrolledStudentDetailResponse,
|
||||
} from "../types/CoursesInstructor.types";
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
export class CoursesInstructorService {
|
||||
static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise<createCourseResponse> {
|
||||
|
|
@ -81,6 +83,15 @@ export class CoursesInstructorService {
|
|||
return courseCreated;
|
||||
});
|
||||
|
||||
// Audit log - CREATE Course
|
||||
auditService.log({
|
||||
userId: userId,
|
||||
action: AuditAction.CREATE,
|
||||
entityType: 'Course',
|
||||
entityId: result.id,
|
||||
newValue: { title: courseData.title, slug: courseData.slug, status: 'DRAFT' }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 201,
|
||||
message: 'Course created successfully',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
GetQuizAttemptsResponse,
|
||||
} from "../types/CoursesStudent.types";
|
||||
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
|
||||
export class CoursesStudentService {
|
||||
|
|
@ -162,6 +164,16 @@ export class CoursesStudentService {
|
|||
enrolled_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log - ENROLL
|
||||
auditService.log({
|
||||
userId: decoded.id,
|
||||
action: AuditAction.ENROLL,
|
||||
entityType: 'Enrollment',
|
||||
entityId: enrollment.id,
|
||||
newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Enrollment successful',
|
||||
|
|
|
|||
245
Backend/src/services/audit.service.ts
Normal file
245
Backend/src/services/audit.service.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { prisma } from '../config/database';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
import { logger } from '../config/logger';
|
||||
import {
|
||||
CreateAuditLogParams,
|
||||
AuditLogFilters,
|
||||
ListAuditLogsResponse,
|
||||
AuditLogResponse,
|
||||
AuditLogStats,
|
||||
} from '../types/audit.types';
|
||||
|
||||
export class AuditService {
|
||||
/**
|
||||
* สร้าง audit log entry
|
||||
*/
|
||||
async log(params: CreateAuditLogParams): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
user_id: params.userId,
|
||||
action: params.action,
|
||||
entity_type: params.entityType,
|
||||
entity_id: params.entityId,
|
||||
old_value: params.oldValue,
|
||||
new_value: params.newValue,
|
||||
ip_address: params.ipAddress,
|
||||
user_agent: params.userAgent,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to create audit log', { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log พร้อม await (สำหรับ critical actions)
|
||||
*/
|
||||
async logSync(params: CreateAuditLogParams): Promise<void> {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
user_id: params.userId,
|
||||
action: params.action,
|
||||
entity_type: params.entityType,
|
||||
entity_id: params.entityId,
|
||||
old_value: params.oldValue,
|
||||
new_value: params.newValue,
|
||||
ip_address: params.ipAddress,
|
||||
user_agent: params.userAgent,
|
||||
metadata: params.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง audit logs พร้อม filters และ pagination
|
||||
*/
|
||||
async getLogs(filters: AuditLogFilters): Promise<ListAuditLogsResponse> {
|
||||
const { page = 1, limit = 50 } = filters;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (filters.userId) {
|
||||
where.user_id = filters.userId;
|
||||
}
|
||||
if (filters.action) {
|
||||
where.action = filters.action;
|
||||
}
|
||||
if (filters.entityType) {
|
||||
where.entity_type = filters.entityType;
|
||||
}
|
||||
if (filters.entityId) {
|
||||
where.entity_id = filters.entityId;
|
||||
}
|
||||
if (filters.startDate || filters.endDate) {
|
||||
where.created_at = {};
|
||||
if (filters.startDate) {
|
||||
where.created_at.gte = filters.startDate;
|
||||
}
|
||||
if (filters.endDate) {
|
||||
where.created_at.lte = filters.endDate;
|
||||
}
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: logs as AuditLogResponse[],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง audit log by ID
|
||||
*/
|
||||
async getLogById(id: number): Promise<AuditLogResponse | null> {
|
||||
const log = await prisma.auditLog.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return log as AuditLogResponse | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง logs ของ entity เฉพาะ (เช่น ดู history ของ course)
|
||||
*/
|
||||
async getEntityHistory(entityType: string, entityId: number): Promise<AuditLogResponse[]> {
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
|
||||
return logs as AuditLogResponse[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง logs ของ user เฉพาะ
|
||||
*/
|
||||
async getUserActivity(userId: number, limit: number = 50): Promise<AuditLogResponse[]> {
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
where: { user_id: userId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return logs as AuditLogResponse[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงสถิติ audit logs สำหรับ dashboard
|
||||
*/
|
||||
async getStats(): Promise<AuditLogStats> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalLogs, todayLogs, actionCounts, recentActivity] = await Promise.all([
|
||||
prisma.auditLog.count(),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
created_at: { gte: today },
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.groupBy({
|
||||
by: ['action'],
|
||||
_count: { action: true },
|
||||
orderBy: { _count: { action: 'desc' } },
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalLogs,
|
||||
todayLogs,
|
||||
actionSummary: actionCounts.map((item) => ({
|
||||
action: item.action,
|
||||
count: item._count.action,
|
||||
})),
|
||||
recentActivity: recentActivity as AuditLogResponse[],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ logs เก่า (สำหรับ maintenance)
|
||||
*/
|
||||
async deleteOldLogs(olderThanDays: number): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||
|
||||
const result = await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
created_at: { lt: cutoffDate },
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Deleted ${result.count} old audit logs older than ${olderThanDays} days`);
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
|
||||
export const auditService = new AuditService();
|
||||
|
|
@ -17,6 +17,8 @@ import { UserResponse } from '../types/user.types';
|
|||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getPresignedUrl } from '../config/minio';
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
|
|
@ -57,6 +59,15 @@ export class AuthService {
|
|||
|
||||
logger.info('User logged in successfully', { userId: user.id, email: user.email });
|
||||
|
||||
// Audit log - LOGIN
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.LOGIN,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
metadata: { email: user.email, role: user.role.code }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Login successful',
|
||||
|
|
@ -138,6 +149,15 @@ export class AuthService {
|
|||
|
||||
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||
|
||||
// Audit log - REGISTER (Student)
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.CREATE,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
newValue: { username: user.username, email: user.email, role: 'STUDENT' }
|
||||
});
|
||||
|
||||
return {
|
||||
user: this.formatUserResponseSync(user),
|
||||
message: 'Registration successful'
|
||||
|
|
@ -211,6 +231,15 @@ export class AuthService {
|
|||
|
||||
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||
|
||||
// Audit log - REGISTER (Instructor)
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.CREATE,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
newValue: { username: user.username, email: user.email, role: 'INSTRUCTOR' }
|
||||
});
|
||||
|
||||
return {
|
||||
user: this.formatUserResponseSync(user),
|
||||
message: 'Registration successful'
|
||||
|
|
@ -341,6 +370,16 @@ export class AuthService {
|
|||
});
|
||||
|
||||
logger.info('Password reset successfully', { userId: user.id });
|
||||
|
||||
// Audit log - RESET_PASSWORD
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.RESET_PASSWORD,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
metadata: { email: user.email }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Password reset successfully'
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
import nodemailer from 'nodemailer';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
export class UserService {
|
||||
async getUserProfile(token: string): Promise<UserResponse> {
|
||||
|
|
@ -109,6 +111,16 @@ export class UserService {
|
|||
});
|
||||
|
||||
logger.info('Password changed successfully', { userId: user.id });
|
||||
|
||||
// Audit log - CHANGE_PASSWORD
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.CHANGE_PASSWORD,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
metadata: { email: user.email }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Password changed successfully'
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
import { UserResponse } from '../types/user.types';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||
import { getPresignedUrl } from '../config/minio';
|
||||
import { auditService } from './audit.service';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
export class UserManagementService {
|
||||
async listUsers(): Promise<ListUsersResponse> {
|
||||
|
|
@ -71,11 +73,22 @@ export class UserManagementService {
|
|||
const role = await prisma.role.findUnique({ where: { id: role_id } });
|
||||
if (!role) throw new UnauthorizedError('Role not found');
|
||||
|
||||
const oldRoleId = user.role_id;
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role_id }
|
||||
});
|
||||
|
||||
// Audit log - UPDATE (role change)
|
||||
auditService.log({
|
||||
userId: id,
|
||||
action: AuditAction.UPDATE,
|
||||
entityType: 'User',
|
||||
entityId: id,
|
||||
oldValue: { role_id: oldRoleId },
|
||||
newValue: { role_id: role_id }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'User role updated successfully'
|
||||
|
|
@ -122,6 +135,17 @@ export class UserManagementService {
|
|||
});
|
||||
|
||||
logger.info('Account deactivated successfully', { userId: user.id });
|
||||
|
||||
// Audit log - DEACTIVATE_USER
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.DEACTIVATE_USER,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
oldValue: { is_deactivated: false },
|
||||
newValue: { is_deactivated: true }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Account deactivated successfully'
|
||||
|
|
@ -158,6 +182,17 @@ export class UserManagementService {
|
|||
});
|
||||
|
||||
logger.info('Account activated successfully', { userId: user.id });
|
||||
|
||||
// Audit log - ACTIVATE_USER
|
||||
auditService.log({
|
||||
userId: user.id,
|
||||
action: AuditAction.ACTIVATE_USER,
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
oldValue: { is_deactivated: true },
|
||||
newValue: { is_deactivated: false }
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Account activated successfully'
|
||||
|
|
|
|||
65
Backend/src/types/audit.types.ts
Normal file
65
Backend/src/types/audit.types.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { AuditAction } from '@prisma/client';
|
||||
|
||||
export interface CreateAuditLogParams {
|
||||
userId?: number;
|
||||
action: AuditAction;
|
||||
entityType: string;
|
||||
entityId?: number;
|
||||
oldValue?: any;
|
||||
newValue?: any;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
userId?: number;
|
||||
action?: AuditAction;
|
||||
entityType?: string;
|
||||
entityId?: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
action: AuditAction;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
old_value: any;
|
||||
new_value: any;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
metadata: any;
|
||||
created_at: Date;
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ListAuditLogsResponse {
|
||||
data: AuditLogResponse[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditLogSummary {
|
||||
action: AuditAction;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AuditLogStats {
|
||||
totalLogs: number;
|
||||
todayLogs: number;
|
||||
actionSummary: AuditLogSummary[];
|
||||
recentActivity: AuditLogResponse[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue