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

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

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'

View 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[];
}