forked from hangshuo652/aurak
P3-02-03-04: audit log, batch ops, transactions
P3-02: audit-log.entity + service, manual logging in controller (startSession, submitAnswer, deleteSession, review, forceEnd) P3-03: POST batch-delete, POST batch-export endpoints + service methods P3-04: DataSource.transaction for deleteSession + reviewAssessment, graph state cleanup on session delete
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { AssessmentService } from './assessment.service';
|
import { AssessmentService } from './assessment.service';
|
||||||
import { ExportService } from './services/export.service';
|
import { ExportService } from './services/export.service';
|
||||||
|
import { AuditLogService } from './services/audit-log.service';
|
||||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||||
import { Public } from '../auth/public.decorator';
|
import { Public } from '../auth/public.decorator';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
@@ -28,6 +29,7 @@ export class AssessmentController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly assessmentService: AssessmentService,
|
private readonly assessmentService: AssessmentService,
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('start')
|
@Post('start')
|
||||||
@@ -41,13 +43,15 @@ export class AssessmentController {
|
|||||||
console.log(
|
console.log(
|
||||||
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.startSession(
|
const session = await this.assessmentService.startSession(
|
||||||
userId,
|
userId,
|
||||||
body.knowledgeBaseId,
|
body.knowledgeBaseId,
|
||||||
tenantId,
|
tenantId,
|
||||||
body.language,
|
body.language,
|
||||||
body.templateId,
|
body.templateId,
|
||||||
);
|
);
|
||||||
|
this.auditLog.log({ userId, tenantId, action: 'session.start', resourceType: 'assessment_session', resourceId: session.id });
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/answer')
|
@Post(':id/answer')
|
||||||
@@ -61,12 +65,14 @@ export class AssessmentController {
|
|||||||
console.log(
|
console.log(
|
||||||
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
`[AssessmentController] >>> submitAnswer CALLED: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
|
||||||
);
|
);
|
||||||
return this.assessmentService.submitAnswer(
|
const result = await this.assessmentService.submitAnswer(
|
||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
body.answer,
|
body.answer,
|
||||||
body.language,
|
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')
|
@Sse(':id/start-stream')
|
||||||
@@ -117,7 +123,9 @@ export class AssessmentController {
|
|||||||
console.log(
|
console.log(
|
||||||
`[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
|
`[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')
|
@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')
|
@Put(':id/review')
|
||||||
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
@ApiOperation({ summary: 'Review assessment - adjust final score' })
|
||||||
async review(
|
async review(
|
||||||
@@ -224,13 +252,15 @@ export class AssessmentController {
|
|||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
const { id: userId, tenantId } = req.user;
|
const { id: userId, tenantId } = req.user;
|
||||||
return this.assessmentService.reviewAssessment(
|
const result = await this.assessmentService.reviewAssessment(
|
||||||
sessionId,
|
sessionId,
|
||||||
body.newScore,
|
body.newScore,
|
||||||
body.comment,
|
body.comment,
|
||||||
userId,
|
userId,
|
||||||
tenantId,
|
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')
|
@Get(':id/time-check')
|
||||||
@@ -252,12 +282,14 @@ export class AssessmentController {
|
|||||||
@Param('id') sessionId: string,
|
@Param('id') sessionId: string,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { role } = req.user;
|
const { id: userId, tenantId, role } = req.user;
|
||||||
const isAdmin = role === 'super_admin' || role === 'admin';
|
const isAdmin = role === 'super_admin' || role === 'admin';
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
throw new ForbiddenException('Only admin can force end assessment');
|
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')
|
@Get(':id/export/excel')
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { ContentFilterService } from './services/content-filter.service';
|
|||||||
import { QuestionOutlineService } from './services/question-outline.service';
|
import { QuestionOutlineService } from './services/question-outline.service';
|
||||||
import { QuestionBankService } from './services/question-bank.service';
|
import { QuestionBankService } from './services/question-bank.service';
|
||||||
import { ExportService } from './services/export.service';
|
import { ExportService } from './services/export.service';
|
||||||
|
import { AuditLog } from './entities/audit-log.entity';
|
||||||
|
import { AuditLogService } from './services/audit-log.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -34,6 +36,7 @@ import { ExportService } from './services/export.service';
|
|||||||
AssessmentCertificate,
|
AssessmentCertificate,
|
||||||
QuestionBank,
|
QuestionBank,
|
||||||
QuestionBankItem,
|
QuestionBankItem,
|
||||||
|
AuditLog,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => KnowledgeBaseModule),
|
forwardRef(() => KnowledgeBaseModule),
|
||||||
forwardRef(() => KnowledgeGroupModule),
|
forwardRef(() => KnowledgeGroupModule),
|
||||||
@@ -51,6 +54,7 @@ import { ExportService } from './services/export.service';
|
|||||||
QuestionOutlineService,
|
QuestionOutlineService,
|
||||||
QuestionBankService,
|
QuestionBankService,
|
||||||
ExportService,
|
ExportService,
|
||||||
|
AuditLogService,
|
||||||
],
|
],
|
||||||
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
|
exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DeepPartial, In } from 'typeorm';
|
import { Repository, DeepPartial, In, DataSource } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ChatOpenAI } from '@langchain/openai';
|
import { ChatOpenAI } from '@langchain/openai';
|
||||||
import {
|
import {
|
||||||
@@ -78,6 +78,7 @@ export class AssessmentService {
|
|||||||
private chatService: ChatService,
|
private chatService: ChatService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private tenantService: TenantService,
|
private tenantService: TenantService,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
private async getModel(tenantId: string): Promise<ChatOpenAI> {
|
||||||
@@ -1138,16 +1139,25 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
const isAdmin = user.role === 'super_admin' || user.role === 'admin';
|
||||||
|
|
||||||
const deleteCondition: any = { id: sessionId };
|
await this.dataSource.transaction(async (manager) => {
|
||||||
if (!isAdmin) {
|
const deleteCondition: any = { id: sessionId };
|
||||||
deleteCondition.userId = userId;
|
if (!isAdmin) {
|
||||||
}
|
deleteCondition.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.sessionRepository.delete(deleteCondition);
|
const session = await manager.findOne(AssessmentSession, { where: { id: sessionId } });
|
||||||
if (result.affected === 0) {
|
if (!session) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException('Session not found or you do not have permission to delete it');
|
||||||
'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<EvaluationState> = {
|
|||||||
reviewerId: string,
|
reviewerId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<AssessmentSession> {
|
): Promise<AssessmentSession> {
|
||||||
const session = await this.sessionRepository.findOne({
|
return this.dataSource.transaction(async (manager) => {
|
||||||
where: { id: sessionId },
|
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<AssessmentSession[]> {
|
async getUserHistory(userId: string): Promise<AssessmentSession[]> {
|
||||||
@@ -1712,6 +1724,33 @@ const initialState: Partial<EvaluationState> = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchDeleteSessions(ids: string[], user: any): Promise<number> {
|
||||||
|
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<any[]> {
|
||||||
|
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<AssessmentSession> {
|
async forceEndAssessment(sessionId: string): Promise<AssessmentSession> {
|
||||||
const session = await this.sessionRepository.findOne({
|
const session = await this.sessionRepository.findOne({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<AuditLog>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(params: {
|
||||||
|
userId: string;
|
||||||
|
tenantId?: string;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: any;
|
||||||
|
}): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user