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:
JakkrapartXD 2026-02-05 17:35:37 +07:00
parent 923c8b727a
commit 108f1b73f2
10 changed files with 701 additions and 0 deletions

View file

@ -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'

View file

@ -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}`);

View file

@ -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',

View file

@ -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',

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

View file

@ -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'

View file

@ -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'

View file

@ -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'