feat: add transactions

This commit is contained in:
2026-07-19 17:39:43 +03:30
parent 4e400d79e8
commit 4075fcf5bf
3 changed files with 89 additions and 153 deletions
@@ -3,14 +3,14 @@ Authorization: Bearer {{$global.accessToken}}
Content-Type: application/json Content-Type: application/json
{ {
"testId": 4 "testId": 1
} }
POST {{baseUrl}}/submissions/10/answer POST {{baseUrl}}/submissions/2/answer
Authorization: Bearer {{$global.accessToken}} Authorization: Bearer {{$global.accessToken}}
Content-Type: application/json Content-Type: application/json
{ {
// "choiceId": 1 // "choiceId": 2
"numericValue": 60 "numericValue": 8
} }
@@ -1,5 +1,5 @@
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
import { Repository } from 'typeorm'; import { DataSource, In, Repository } from 'typeorm';
import { Submission } from '../entities/submission.entity'; import { Submission } from '../entities/submission.entity';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
@@ -14,7 +14,6 @@ import { AppResponse } from '@/common/responses';
import { QuestionType } from '@/modules/questions/enums/question-type.enum'; import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface'; import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface';
import { SubmissionAnswer } from '../entities/submission-answer.entity'; import { SubmissionAnswer } from '../entities/submission-answer.entity';
import { ChoicesService } from '@/modules/questions/providers/choices.service';
import { CalculateResultProvider } from './calculate-result.provider'; import { CalculateResultProvider } from './calculate-result.provider';
import { QuestionsService } from '@/modules/questions/providers/questions.service'; import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { SubmissionResult } from '../entities/submission-result.entity'; import { SubmissionResult } from '../entities/submission-result.entity';
@@ -50,15 +49,15 @@ export class AnswerSubmissionProvider {
*/ */
private readonly questionsService: QuestionsService, private readonly questionsService: QuestionsService,
/**
* Inject Choices Service
*/
private readonly choicesService: ChoicesService,
/** /**
* Inject CalculateResult Provider * Inject CalculateResult Provider
*/ */
private readonly calculateResultProvider: CalculateResultProvider, private readonly calculateResultProvider: CalculateResultProvider,
/**
* Inject DataSource
*/
private readonly dataSource: DataSource,
) {} ) {}
public async answer( public async answer(
@@ -109,30 +108,22 @@ export class AnswerSubmissionProvider {
); );
} }
// if (submission.user.id !== user.id) { const [currentQuestion, nextQuestion]: Array<Question | null> =
// throw new ForbiddenException('auth.errors.accessDenied'); await this.questionsService.findByOptionsForAdmin({
// }
// const allQuestions = [...submission.test.questions].sort(
// (a, b) => a.order - b.order,
// );
// const answeredQuestions = submission.answers.map((el) => el.question);
// const questionStats = this.getQuestionStats(
// allQuestions,
// answeredQuestions,
// );
const currentQuestion: Question | null =
await this.questionsService.findOneForAdmin({
where: { where: {
test: { id: submission.test.id }, test: { id: submission.test.id },
order: submission.currentQuestionOrder, order: In([
submission.currentQuestionOrder,
submission.currentQuestionOrder + 1,
]),
}, },
relations: { relations: {
choices: true, choices: true,
rangeRules: true, rangeRules: true,
}, },
order: {
order: 'ASC',
},
}); });
if (!currentQuestion) { if (!currentQuestion) {
@@ -174,8 +165,8 @@ export class AnswerSubmissionProvider {
currentQuestion.type === QuestionType.MULTI_CHOICE && currentQuestion.type === QuestionType.MULTI_CHOICE &&
answerSubmissionDto.choiceId answerSubmissionDto.choiceId
) { ) {
const choice = await this.choicesService.findOneForAdmin( const choice = currentQuestion.choices.find(
answerSubmissionDto.choiceId, (el) => el.id === answerSubmissionDto.choiceId,
); );
if (!choice) { if (!choice) {
@@ -189,36 +180,45 @@ export class AnswerSubmissionProvider {
submissionAnswer.choice = choice; submissionAnswer.choice = choice;
} else if ( } else if (
currentQuestion.type === QuestionType.NUMERIC && currentQuestion.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue answerSubmissionDto.numericValue !== undefined
) { ) {
submissionAnswer.numericValue = answerSubmissionDto.numericValue; submissionAnswer.numericValue = answerSubmissionDto.numericValue;
} }
let completedResponse: object | null = null;
try { try {
await this.submissionAnswerRepository.save(submissionAnswer); completedResponse = await this.dataSource.transaction(async (manager) => {
await manager.save(submissionAnswer);
if (!nextQuestion) {
const result = await this.calculateResultProvider.calculate(
submission,
manager,
);
return {
isCompleted: true,
currentQuestion: null,
result,
};
}
await manager.increment(
Submission,
{ id: submission.id },
'currentQuestionOrder',
1,
);
return null;
});
} catch (err) { } catch (err) {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
} }
const nextQuestion: Question | null = if (completedResponse) {
await this.questionsService.findOneForAdmin({ return new AppResponse(completedResponse);
where: {
test: { id: submission.test.id },
order: submission.currentQuestionOrder + 1,
},
relations: {
choices: true,
rangeRules: true,
},
});
if (!nextQuestion) {
const result = await this.calculateResultProvider.calculate(submission);
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result,
});
} }
return new AppResponse({ return new AppResponse({
@@ -229,7 +229,11 @@ export class AnswerSubmissionProvider {
content: nextQuestion.content, content: nextQuestion.content,
required: nextQuestion.required, required: nextQuestion.required,
metadata: nextQuestion.metadata, metadata: nextQuestion.metadata,
choices: nextQuestion.choices, choices: nextQuestion.choices.map((el) => ({
id: el.id,
text: el.text,
order: el.order,
})),
}, },
}); });
} }
@@ -255,46 +259,6 @@ export class AnswerSubmissionProvider {
return submission; return submission;
} }
private getQuestionStats(
allQuestions: Array<Question>,
answeredQuestions: Array<Question>,
) {
const questionStats: Record<'prev' | 'current' | 'next', Question | null> =
{
prev: null,
current: null,
next: null,
};
if (allQuestions.length === 0) {
return questionStats;
}
if (answeredQuestions.length > 0) {
questionStats.prev = answeredQuestions[answeredQuestions.length - 1];
}
if (!questionStats.prev) {
questionStats.current = allQuestions[0];
} else {
const prevQuestionIdx = allQuestions.findIndex(
(val) => val.id === questionStats.prev?.id,
);
questionStats.current = allQuestions[prevQuestionIdx + 1];
}
if (questionStats.current) {
const currentQuestionIdx = allQuestions.findIndex(
(val) => val.id === questionStats.current?.id,
);
questionStats.next = allQuestions[currentQuestionIdx + 1];
}
return questionStats;
}
private checkAnswerCorrectness( private checkAnswerCorrectness(
question: Question, question: Question,
answerSubmissionDto: AnswerSubmissionDTO, answerSubmissionDto: AnswerSubmissionDTO,
@@ -1,50 +1,27 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { EntityManager, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { SubmissionResult } from '../entities/submission-result.entity'; import { SubmissionResult } from '../entities/submission-result.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Submission } from '../entities/submission.entity'; import { Submission } from '../entities/submission.entity';
import { QuestionType } from '@/modules/questions/enums/question-type.enum'; import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { ResultRangeService } from '@/modules/scoring/providers/result-range.service';
import { SubmissionAnswer } from '../entities/submission-answer.entity'; import { SubmissionAnswer } from '../entities/submission-answer.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
@Injectable() @Injectable()
export class CalculateResultProvider { export class CalculateResultProvider {
constructor( public async calculate(submission: Submission, manager: EntityManager) {
/**
* Inject SubmissionResult Repository
*/
@InjectRepository(SubmissionResult)
private readonly submissionResultRepository: Repository<SubmissionResult>,
/**
* Inject Questions Service
*/
private readonly questionsService: QuestionsService,
/**
* Inject ResultRange Service
*/
private readonly resultRangeService: ResultRangeService,
/**
* Inject SubmissionAnswer Repository
*/
@InjectRepository(SubmissionAnswer)
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
) {}
public async calculate(submission: Submission) {
let score = 0; let score = 0;
let submissionAnswers: Array<SubmissionAnswer> = []; let submissionAnswers: Array<SubmissionAnswer> = [];
try { try {
submissionAnswers = await this.submissionAnswerRepository.find({ submissionAnswers = await manager.find(SubmissionAnswer, {
where: { submission: { id: submission.id } }, where: { submission: { id: submission.id } },
relations: { relations: {
question: true, question: {
rangeRules: true,
},
choice: true, choice: true,
}, },
}); });
@@ -52,39 +29,33 @@ export class CalculateResultProvider {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
} }
await Promise.all( for (const answer of submissionAnswers) {
submissionAnswers.map(async (answer) => { if (answer.question.type === QuestionType.MULTI_CHOICE && answer.choice) {
score += answer.question.weight * answer.choice.score;
} else if (
answer.question.type === QuestionType.NUMERIC &&
answer.numericValue !== undefined
) {
if ( if (
answer.question.type === QuestionType.MULTI_CHOICE && answer.question.rangeRules &&
answer.choice answer.question.rangeRules.length > 0
) { ) {
score += answer.question.weight * answer.choice.score; answer.question.rangeRules.forEach((rule) => {
} else if ( if (
answer.question.type === QuestionType.NUMERIC && answer.numericValue !== undefined &&
answer.numericValue !== undefined answer.numericValue >= rule.min &&
) { answer.numericValue <= rule.max
const question = await this.questionsService.findOneByIdForAdmin( ) {
answer.question.id, score += answer.question.weight * rule.score;
); }
});
if (question.rangeRules && question.rangeRules.length > 0) {
question.rangeRules.forEach((rule) => {
if (
answer.numericValue &&
answer.numericValue >= rule.min &&
answer.numericValue <= rule.max
) {
score += answer.question.weight * rule.score;
}
});
}
} }
}), }
); }
let totalScores = 0; let totalScores = 0;
const testQuestions = await this.questionsService.findByOptionsForAdmin({ const testQuestions = await manager.find(Question, {
where: { test: { id: submission.test.id } }, where: { test: { id: submission.test.id } },
relations: { relations: {
choices: true, choices: true,
@@ -113,14 +84,15 @@ export class CalculateResultProvider {
totalScores += question.weight * maxScore; totalScores += question.weight * maxScore;
}); });
const resultRanges = await this.resultRangeService.findByOptions({ const resultRanges = await manager.find(ResultRange, {
where: { where: {
test: { id: submission.test.id },
minScore: LessThanOrEqual(score), minScore: LessThanOrEqual(score),
maxScore: MoreThanOrEqual(score), maxScore: MoreThanOrEqual(score),
}, },
}); });
const submissionResult = this.submissionResultRepository.create({ const submissionResult = manager.create(SubmissionResult, {
submission, submission,
score, score,
probability: Math.ceil((score / totalScores) * 100), probability: Math.ceil((score / totalScores) * 100),
@@ -128,7 +100,7 @@ export class CalculateResultProvider {
}); });
try { try {
await this.submissionResultRepository.save(submissionResult); await manager.save(submissionResult);
} catch (err) { } catch (err) {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
} }