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,
|
ApproveCourseResponse,
|
||||||
RejectCourseResponse,
|
RejectCourseResponse,
|
||||||
} from '../types/AdminCourseApproval.types';
|
} from '../types/AdminCourseApproval.types';
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
export class AdminCourseApprovalService {
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Course approved successfully'
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Course rejected successfully'
|
message: 'Course rejected successfully'
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ import {
|
||||||
QuizData,
|
QuizData,
|
||||||
} from "../types/ChaptersLesson.typs";
|
} from "../types/ChaptersLesson.typs";
|
||||||
import { CoursesInstructorService } from './CoursesInstructor.service';
|
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ตรวจสอบสิทธิ์เข้าถึง Course (สำหรับทั้ง Instructor และ Student)
|
* ตรวจสอบสิทธิ์เข้าถึง Course (สำหรับทั้ง Instructor และ Student)
|
||||||
|
|
@ -127,6 +129,16 @@ export class ChaptersLessonService {
|
||||||
throw new ForbiddenError('You are not permitted to create chapter');
|
throw new ForbiddenError('You are not permitted to create chapter');
|
||||||
}
|
}
|
||||||
const chapter = await prisma.chapter.create({ data: { course_id, title, description, sort_order } });
|
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 };
|
return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error creating chapter: ${error}`);
|
logger.error(`Error creating chapter: ${error}`);
|
||||||
|
|
@ -170,6 +182,15 @@ export class ChaptersLessonService {
|
||||||
}
|
}
|
||||||
await prisma.chapter.delete({ where: { id: chapter_id } });
|
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)
|
// Normalize sort_order for remaining chapters (fill gaps)
|
||||||
await this.normalizeChapterSortOrder(course_id);
|
await this.normalizeChapterSortOrder(course_id);
|
||||||
|
|
||||||
|
|
@ -308,9 +329,28 @@ export class ChaptersLessonService {
|
||||||
where: { id: lesson.id },
|
where: { id: lesson.id },
|
||||||
include: { quiz: true }
|
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 };
|
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 };
|
return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error creating lesson: ${error}`);
|
logger.error(`Error creating lesson: ${error}`);
|
||||||
|
|
@ -621,6 +661,15 @@ export class ChaptersLessonService {
|
||||||
// Based on Prisma schema: onDelete: Cascade
|
// Based on Prisma schema: onDelete: Cascade
|
||||||
await prisma.lesson.delete({ where: { id: lesson_id } });
|
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)
|
// Normalize sort_order for remaining lessons (fill gaps)
|
||||||
await this.normalizeLessonSortOrder(chapterId);
|
await this.normalizeLessonSortOrder(chapterId);
|
||||||
|
|
||||||
|
|
@ -683,6 +732,15 @@ export class ChaptersLessonService {
|
||||||
// Get presigned URL
|
// Get presigned URL
|
||||||
const video_url = await getPresignedUrl(videoPath, 3600);
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Video uploaded successfully',
|
message: 'Video uploaded successfully',
|
||||||
|
|
@ -909,6 +967,15 @@ export class ChaptersLessonService {
|
||||||
// Get presigned URL
|
// Get presigned URL
|
||||||
const presigned_url = await getPresignedUrl(attachmentPath, 3600);
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Attachment uploaded successfully',
|
message: 'Attachment uploaded successfully',
|
||||||
|
|
@ -972,6 +1039,15 @@ export class ChaptersLessonService {
|
||||||
// Delete attachment record from database
|
// Delete attachment record from database
|
||||||
await prisma.lessonAttachment.delete({ where: { id: attachment_id } });
|
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' };
|
return { code: 200, message: 'Attachment deleted successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error deleting attachment: ${error}`);
|
logger.error(`Error deleting attachment: ${error}`);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import {
|
||||||
GetEnrolledStudentDetailInput,
|
GetEnrolledStudentDetailInput,
|
||||||
GetEnrolledStudentDetailResponse,
|
GetEnrolledStudentDetailResponse,
|
||||||
} from "../types/CoursesInstructor.types";
|
} from "../types/CoursesInstructor.types";
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
export class CoursesInstructorService {
|
export class CoursesInstructorService {
|
||||||
static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise<createCourseResponse> {
|
static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise<createCourseResponse> {
|
||||||
|
|
@ -81,6 +83,15 @@ export class CoursesInstructorService {
|
||||||
return courseCreated;
|
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 {
|
return {
|
||||||
code: 201,
|
code: 201,
|
||||||
message: 'Course created successfully',
|
message: 'Course created successfully',
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import {
|
||||||
GetQuizAttemptsResponse,
|
GetQuizAttemptsResponse,
|
||||||
} from "../types/CoursesStudent.types";
|
} from "../types/CoursesStudent.types";
|
||||||
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
|
|
||||||
export class CoursesStudentService {
|
export class CoursesStudentService {
|
||||||
|
|
@ -162,6 +164,16 @@ export class CoursesStudentService {
|
||||||
enrolled_at: new Date(),
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Enrollment successful',
|
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 { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { getPresignedUrl } from '../config/minio';
|
import { getPresignedUrl } from '../config/minio';
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,6 +59,15 @@ export class AuthService {
|
||||||
|
|
||||||
logger.info('User logged in successfully', { userId: user.id, email: user.email });
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
|
|
@ -138,6 +149,15 @@ export class AuthService {
|
||||||
|
|
||||||
logger.info('New user registered', { userId: user.id, username: user.username });
|
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 {
|
return {
|
||||||
user: this.formatUserResponseSync(user),
|
user: this.formatUserResponseSync(user),
|
||||||
message: 'Registration successful'
|
message: 'Registration successful'
|
||||||
|
|
@ -211,6 +231,15 @@ export class AuthService {
|
||||||
|
|
||||||
logger.info('New user registered', { userId: user.id, username: user.username });
|
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 {
|
return {
|
||||||
user: this.formatUserResponseSync(user),
|
user: this.formatUserResponseSync(user),
|
||||||
message: 'Registration successful'
|
message: 'Registration successful'
|
||||||
|
|
@ -341,6 +370,16 @@ export class AuthService {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Password reset successfully', { userId: user.id });
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Password reset successfully'
|
message: 'Password reset successfully'
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||||
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
|
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
async getUserProfile(token: string): Promise<UserResponse> {
|
async getUserProfile(token: string): Promise<UserResponse> {
|
||||||
|
|
@ -109,6 +111,16 @@ export class UserService {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Password changed successfully', { userId: user.id });
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Password changed successfully'
|
message: 'Password changed successfully'
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
import { UserResponse } from '../types/user.types';
|
import { UserResponse } from '../types/user.types';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||||
import { getPresignedUrl } from '../config/minio';
|
import { getPresignedUrl } from '../config/minio';
|
||||||
|
import { auditService } from './audit.service';
|
||||||
|
import { AuditAction } from '@prisma/client';
|
||||||
|
|
||||||
export class UserManagementService {
|
export class UserManagementService {
|
||||||
async listUsers(): Promise<ListUsersResponse> {
|
async listUsers(): Promise<ListUsersResponse> {
|
||||||
|
|
@ -71,11 +73,22 @@ export class UserManagementService {
|
||||||
const role = await prisma.role.findUnique({ where: { id: role_id } });
|
const role = await prisma.role.findUnique({ where: { id: role_id } });
|
||||||
if (!role) throw new UnauthorizedError('Role not found');
|
if (!role) throw new UnauthorizedError('Role not found');
|
||||||
|
|
||||||
|
const oldRoleId = user.role_id;
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { role_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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'User role updated successfully'
|
message: 'User role updated successfully'
|
||||||
|
|
@ -122,6 +135,17 @@ export class UserManagementService {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Account deactivated successfully', { userId: user.id });
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Account deactivated successfully'
|
message: 'Account deactivated successfully'
|
||||||
|
|
@ -158,6 +182,17 @@ export class UserManagementService {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Account activated successfully', { userId: user.id });
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Account activated successfully'
|
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