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:
@@ -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<ChatOpenAI> {
|
||||
@@ -1138,16 +1139,25 @@ const initialState: Partial<EvaluationState> = {
|
||||
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<EvaluationState> = {
|
||||
reviewerId: string,
|
||||
tenantId: string,
|
||||
): Promise<AssessmentSession> {
|
||||
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<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> {
|
||||
const session = await this.sessionRepository.findOne({
|
||||
where: { id: sessionId },
|
||||
|
||||
Reference in New Issue
Block a user