diff --git a/server/src/assessment/assessment.controller.ts b/server/src/assessment/assessment.controller.ts index 16ded25..413353f 100644 --- a/server/src/assessment/assessment.controller.ts +++ b/server/src/assessment/assessment.controller.ts @@ -17,6 +17,7 @@ import { import { map } from 'rxjs/operators'; import { AssessmentService } from './assessment.service'; import { ExportService } from './services/export.service'; +import { AuditLogService } from './services/audit-log.service'; import { CombinedAuthGuard } from '../auth/combined-auth.guard'; import { Public } from '../auth/public.decorator'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; @@ -28,6 +29,7 @@ export class AssessmentController { constructor( private readonly assessmentService: AssessmentService, private readonly exportService: ExportService, + private readonly auditLog: AuditLogService, ) {} @Post('start') @@ -41,13 +43,15 @@ export class AssessmentController { console.log( `[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`, ); - return this.assessmentService.startSession( + const session = await this.assessmentService.startSession( userId, body.knowledgeBaseId, tenantId, body.language, body.templateId, ); + this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id }); + return session; } @Post(':id/answer') @@ -61,12 +65,14 @@ export class AssessmentController { console.log( `[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`, ); - return this.assessmentService.submitAnswer( + const result = await this.assessmentService.submitAnswer( sessionId, userId, body.answer, body.language, ); + this.auditLog.log({ userId, action: 'session.answer', resourceType: 'assessment_session', resourceId: sessionId, details: { answerLength: body.answer?.length } }); + return result; } @Sse(':id/start-stream') @@ -117,7 +123,9 @@ export class AssessmentController { console.log( `[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`, ); - return this.assessmentService.deleteSession(sessionId, user); + await this.assessmentService.deleteSession(sessionId, user); + this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.delete', resourceType: 'assessment_session', resourceId: sessionId }); + return { success: true }; } @Get(':id/certificate') @@ -216,6 +224,26 @@ export class AssessmentController { ); } + @Post('batch-delete') + @ApiOperation({ summary: 'Batch delete assessment sessions (admin only)' }) + async batchDelete(@Request() req: any, @Body() body: { ids: string[] }) { + const user = req.user; + const isAdmin = user.role === 'super_admin' || user.role === 'admin'; + if (!isAdmin) { + throw new ForbiddenException('Only admin can batch delete'); + } + const count = await this.assessmentService.batchDeleteSessions(body.ids, user); + this.auditLog.log({ userId: user.id, tenantId: user.tenantId, action: 'session.batch_delete', resourceType: 'assessment_session', details: { count, ids: body.ids } }); + return { deleted: count }; + } + + @Post('batch-export') + @ApiOperation({ summary: 'Batch export assessments as JSON array' }) + async batchExport(@Request() req: any, @Body() body: { ids: string[] }) { + const { id: userId } = req.user; + return this.assessmentService.batchExportSessions(body.ids, userId); + } + @Put(':id/review') @ApiOperation({ summary: 'Review assessment - adjust final score' }) async review( @@ -224,13 +252,15 @@ export class AssessmentController { @Req() req: any, ) { const { id: userId, tenantId } = req.user; - return this.assessmentService.reviewAssessment( + const result = await this.assessmentService.reviewAssessment( sessionId, body.newScore, body.comment, userId, tenantId, ); + this.auditLog.log({ userId, tenantId, action: 'session.review', resourceType: 'assessment_session', resourceId: sessionId, details: { newScore: body.newScore, comment: body.comment } }); + return result; } @Get(':id/time-check') @@ -252,12 +282,14 @@ export class AssessmentController { @Param('id') sessionId: string, @Request() req: any, ) { - const { role } = req.user; + const { id: userId, tenantId, role } = req.user; const isAdmin = role === 'super_admin' || role === 'admin'; if (!isAdmin) { throw new ForbiddenException('Only admin can force end assessment'); } - return this.assessmentService.forceEndAssessment(sessionId); + const result = await this.assessmentService.forceEndAssessment(sessionId); + this.auditLog.log({ userId, tenantId, action: 'session.force_end', resourceType: 'assessment_session', resourceId: sessionId }); + return result; } @Get(':id/export/excel') diff --git a/server/src/assessment/assessment.module.ts b/server/src/assessment/assessment.module.ts index 15389cf..ab66844 100644 --- a/server/src/assessment/assessment.module.ts +++ b/server/src/assessment/assessment.module.ts @@ -23,6 +23,8 @@ import { ContentFilterService } from './services/content-filter.service'; import { QuestionOutlineService } from './services/question-outline.service'; import { QuestionBankService } from './services/question-bank.service'; import { ExportService } from './services/export.service'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogService } from './services/audit-log.service'; @Module({ imports: [ @@ -34,6 +36,7 @@ import { ExportService } from './services/export.service'; AssessmentCertificate, QuestionBank, QuestionBankItem, + AuditLog, ]), forwardRef(() => KnowledgeBaseModule), forwardRef(() => KnowledgeGroupModule), @@ -51,6 +54,7 @@ import { ExportService } from './services/export.service'; QuestionOutlineService, QuestionBankService, ExportService, + AuditLogService, ], exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService], }) diff --git a/server/src/assessment/assessment.service.ts b/server/src/assessment/assessment.service.ts index 4ca451a..daad09d 100644 --- a/server/src/assessment/assessment.service.ts +++ b/server/src/assessment/assessment.service.ts @@ -8,7 +8,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DeepPartial, In } from 'typeorm'; +import { Repository, DeepPartial, In, DataSource } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import { ChatOpenAI } from '@langchain/openai'; import { @@ -78,6 +78,7 @@ export class AssessmentService { private chatService: ChatService, private i18nService: I18nService, private tenantService: TenantService, + private dataSource: DataSource, ) {} private async getModel(tenantId: string): Promise { @@ -1138,16 +1139,25 @@ const initialState: Partial = { const userId = user.id; const isAdmin = user.role === 'super_admin' || user.role === 'admin'; - const deleteCondition: any = { id: sessionId }; - if (!isAdmin) { - deleteCondition.userId = userId; - } + await this.dataSource.transaction(async (manager) => { + const deleteCondition: any = { id: sessionId }; + if (!isAdmin) { + deleteCondition.userId = userId; + } - const result = await this.sessionRepository.delete(deleteCondition); - if (result.affected === 0) { - throw new NotFoundException( - 'Session not found or you do not have permission to delete it', - ); + const session = await manager.findOne(AssessmentSession, { where: { id: sessionId } }); + if (!session) { + throw new NotFoundException('Session not found or you do not have permission to delete it'); + } + + await manager.delete(AssessmentCertificate, { sessionId }); + await manager.delete(AssessmentSession, { id: sessionId }); + }); + + try { + await this.graph.getState({ configurable: { thread_id: sessionId } }); + } catch { + this.logger.debug(`[deleteSession] No graph state to clean up for ${sessionId}`); } } @@ -1531,48 +1541,50 @@ const initialState: Partial = { reviewerId: string, tenantId: string, ): Promise { - const session = await this.sessionRepository.findOne({ - where: { id: sessionId }, + return this.dataSource.transaction(async (manager) => { + const session = await manager.findOne(AssessmentSession, { + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Assessment session not found'); + } + + if (session.status !== AssessmentStatus.COMPLETED) { + throw new ForbiddenException('Can only review completed assessments'); + } + + const reviewRecord = { + reviewedBy: reviewerId, + reviewedAt: new Date().toISOString(), + originalScore: session.finalScore, + newScore: newScore, + comment: comment || '', + }; + + const reviewHistory = session.reviewHistory || []; + reviewHistory.push(reviewRecord); + + if (!session.originalScore) { + session.originalScore = session.finalScore; + } + + session.finalScore = newScore; + const passingScore = session.templateJson?.passingScore || 90; + (session as any).passed = newScore >= passingScore; + session.reviewedBy = reviewerId; + session.reviewedAt = new Date(); + session.reviewComment = comment || null; + session.reviewHistory = reviewHistory; + + await manager.save(session); + + this.logger.log( + `[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`, + ); + + return session; }); - - if (!session) { - throw new NotFoundException('Assessment session not found'); - } - - if (session.status !== AssessmentStatus.COMPLETED) { - throw new ForbiddenException('Can only review completed assessments'); - } - - const reviewRecord = { - reviewedBy: reviewerId, - reviewedAt: new Date().toISOString(), - originalScore: session.finalScore, - newScore: newScore, - comment: comment || '', - }; - - const reviewHistory = session.reviewHistory || []; - reviewHistory.push(reviewRecord); - - if (!session.originalScore) { - session.originalScore = session.finalScore; - } - - session.finalScore = newScore; - const passingScore = session.templateJson?.passingScore || 90; - (session as any).passed = newScore >= passingScore; - session.reviewedBy = reviewerId; - session.reviewedAt = new Date(); - session.reviewComment = comment || null; - session.reviewHistory = reviewHistory; - - await this.sessionRepository.save(session); - - this.logger.log( - `[reviewAssessment] Session ${sessionId} reviewed by ${reviewerId}, score changed from ${reviewRecord.originalScore} to ${newScore}`, - ); - - return session; } async getUserHistory(userId: string): Promise { @@ -1712,6 +1724,33 @@ const initialState: Partial = { }; } + async batchDeleteSessions(ids: string[], user: any): Promise { + const isAdmin = user.role === 'super_admin' || user.role === 'admin'; + const queryBuilder = this.sessionRepository.createQueryBuilder().delete().whereInIds(ids); + if (!isAdmin) { + queryBuilder.andWhere('user_id = :userId', { userId: user.id }); + } + const result = await queryBuilder.execute(); + this.logger.log(`[batchDeleteSessions] Deleted ${result.affected} sessions`); + return result.affected || 0; + } + + async batchExportSessions(ids: string[], userId: string): Promise { + const sessions = await this.sessionRepository.find({ + where: { id: In(ids), userId }, + relations: ['questions'], + }); + return sessions.map((s) => ({ + id: s.id, + status: s.status, + finalScore: s.finalScore, + startedAt: s.startedAt, + createdAt: s.createdAt, + totalTimeLimit: s.totalTimeLimit, + questionCount: s.questions?.length || 0, + })); + } + async forceEndAssessment(sessionId: string): Promise { const session = await this.sessionRepository.findOne({ where: { id: sessionId }, diff --git a/server/src/assessment/entities/audit-log.entity.ts b/server/src/assessment/entities/audit-log.entity.ts new file mode 100644 index 0000000..4651746 --- /dev/null +++ b/server/src/assessment/entities/audit-log.entity.ts @@ -0,0 +1,28 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'text' }) + userId: string; + + @Column({ name: 'tenant_id', nullable: true, type: 'text' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + action: string; + + @Column({ name: 'resource_type', type: 'varchar', length: 50 }) + resourceType: string; + + @Column({ name: 'resource_id', nullable: true, type: 'text' }) + resourceId: string; + + @Column({ type: 'simple-json', nullable: true }) + details: any; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/server/src/assessment/services/audit-log.service.ts b/server/src/assessment/services/audit-log.service.ts new file mode 100644 index 0000000..4243a6c --- /dev/null +++ b/server/src/assessment/services/audit-log.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from '../entities/audit-log.entity'; + +@Injectable() +export class AuditLogService { + private readonly logger = new Logger(AuditLogService.name); + + constructor( + @InjectRepository(AuditLog) + private auditLogRepository: Repository, + ) {} + + async log(params: { + userId: string; + tenantId?: string; + action: string; + resourceType: string; + resourceId?: string; + details?: any; + }): Promise { + try { + const entry = this.auditLogRepository.create({ + userId: params.userId, + tenantId: params.tenantId, + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + details: params.details, + }); + await this.auditLogRepository.insert(entry); + } catch (error) { + this.logger.error(`Failed to write audit log: ${error.message}`); + } + } +}