feat: add guest answering logic

This commit is contained in:
2026-08-10 16:48:37 +03:30
parent 0c82d3426b
commit c368948c4b
7 changed files with 413 additions and 81 deletions
@@ -0,0 +1,5 @@
export interface GuestSubmissionAnswer {
questionId: number;
choiceId?: number;
numericValue?: number;
}
@@ -0,0 +1,6 @@
export interface GuestSubmissionResult {
score: number;
probability: number;
title: string;
description: string;
}
@@ -0,0 +1,9 @@
import { GuestSubmissionAnswer } from './guest-submission-answer.interface';
import { GuestSubmissionResult } from './guest-submission-result.interface';
export interface GuestSubmissionSession {
testId: number;
currentQuestionId: number | null;
answers: Array<GuestSubmissionAnswer>;
result?: GuestSubmissionResult;
}
@@ -1,9 +0,0 @@
export interface GuestSubmissionSession {
testId: number;
currentQuestionId: number;
answers: Array<{
questionId: number;
choiceId: number;
numericValue: number;
}>;
}
@@ -8,19 +8,19 @@ import { User } from '@/modules/users/entities/user.entity';
import { AppException } from '@/common/exceptions/app.exception';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
import { isUUID } from 'class-validator';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface';
import { Question } from '@/modules/questions/entities/question.entity';
import { AppResponse } from '@/common/responses';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface';
import { SubmissionAnswer } from '../entities/submission-answer.entity';
import { CalculateResultProvider } from './calculate-result.provider';
import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { SubmissionResult } from '../entities/submission-result.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
import { SubmissionStatus } from '../enums/submission-status.enum';
import { GuestSubmissionAnswer } from '../interfaces/guest-submission-answer.interface';
@Injectable()
export class AnswerSubmissionProvider {
@@ -49,9 +49,10 @@ export class AnswerSubmissionProvider {
private readonly submissionResultRepository: Repository<SubmissionResult>,
/**
* Inject Questions Service
* Inject Questions Repository
*/
private readonly questionsService: QuestionsService,
@InjectRepository(Question)
private readonly questionsRepository: Repository<Question>,
/**
* Inject CalculateResult Provider
@@ -200,7 +201,8 @@ export class AnswerSubmissionProvider {
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
);
const result = await this.calculateResultProvider.calculate(
const result =
await this.calculateResultProvider.calculateAuthenticated(
submission,
manager,
);
@@ -263,6 +265,95 @@ export class AnswerSubmissionProvider {
`guest_submission:${id}`,
);
if (!submission) {
throw new AppException(
'common.errors.entityNotFound',
HttpStatus.NOT_FOUND,
{ entity: '$t(submissions.fields.submission)' },
);
}
let currentQuestion: Question | null = null;
if (submission.currentQuestionId !== null) {
try {
currentQuestion = await this.questionsRepository.findOne({
where: { id: submission.currentQuestionId },
relations: { choices: true, outgoingTransitions: true },
});
} catch (err) {
throw new AppTimeoutException(err);
}
}
if (!currentQuestion) {
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result: submission.result
? {
score: submission.result.score,
probability: submission.result.probability,
title: submission.result.title,
description: submission.result.description,
}
: null,
});
}
this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
const newAnswer: GuestSubmissionAnswer = {
questionId: currentQuestion.id,
};
if (
currentQuestion.type === QuestionType.MULTI_CHOICE &&
answerSubmissionDto.choiceId !== undefined
) {
const choice = currentQuestion.choices.find(
(el) => el.id === answerSubmissionDto.choiceId,
);
if (!choice) {
throw new AppException(
'validation.wrongField',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.choiceId)' },
);
}
newAnswer.choiceId = choice.id;
} else if (
currentQuestion.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue !== undefined
) {
newAnswer.numericValue = answerSubmissionDto.numericValue;
}
const nextTransition = this.getNextTransition(
currentQuestion,
answerSubmissionDto,
);
const nextQuestion = nextTransition.toQuestion ?? null;
try {
if (nextTransition.type === QuestionTransitionType.END || !nextQuestion) {
await this.redisService.set<GuestSubmissionSession>(
`guest_submission:${id}`,
{
...submission,
currentQuestionId: null,
answers: [...submission.answers, newAnswer],
},
3600,
);
}
} catch (err) {
throw new AppTimeoutException(err);
}
return submission;
}
@@ -1,5 +1,13 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
EntityManager,
In,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { SubmissionResult } from '../entities/submission-result.entity';
import { Submission } from '../entities/submission.entity';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
@@ -7,13 +15,29 @@ import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { SubmissionAnswer } from '../entities/submission-answer.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface';
@Injectable()
export class CalculateResultProvider {
public async calculate(submission: Submission, manager: EntityManager) {
let score = 0;
constructor(
/**
* Inject Question Repository
*/
@InjectRepository(Question)
private readonly questionRepository: Repository<Question>,
let submissionAnswers: Array<SubmissionAnswer> = [];
/**
* Inject Result Range Repository
*/
@InjectRepository(ResultRange)
private readonly resultRangeRepository: Repository<ResultRange>,
) {}
public async calculateAuthenticated(
submission: Submission,
manager: EntityManager,
) {
let submissionAnswers: SubmissionAnswer[] = [];
try {
submissionAnswers = await manager.find(SubmissionAnswer, {
@@ -29,42 +53,145 @@ export class CalculateResultProvider {
throw new AppTimeoutException(err);
}
for (const answer of submissionAnswers) {
if (answer.question.type === QuestionType.MULTI_CHOICE && answer.choice) {
score += answer.question.weight * answer.choice.score;
const result = await this.calculateFromAnswers(
submissionAnswers.map((answer) => ({
questionId: answer.question.id,
question: answer.question,
choiceId: answer.choice?.id,
choice: answer.choice,
numericValue: answer.numericValue,
})),
async (questionIds) =>
manager.find(Question, {
where: { id: In(questionIds) },
relations: {
choices: true,
rangeRules: true,
},
}),
async (score) =>
manager.find(ResultRange, {
where: {
test: { id: submission.test.id },
minScore: LessThanOrEqual(score),
maxScore: MoreThanOrEqual(score),
},
}),
);
const submissionResult = manager.create(SubmissionResult, {
submission,
score: result.score,
probability: result.probability,
resultRange: result.resultRange,
});
try {
await manager.save(submissionResult);
} catch (err) {
throw new AppTimeoutException(err);
}
return {
score: result.score,
probability: result.probability,
title: result.resultRange?.title,
description: result.resultRange?.description,
};
}
public async calculateGuest(submission: GuestSubmissionSession) {
const result = await this.calculateFromAnswers(
submission.answers.map((answer) => ({
questionId: answer.questionId,
choiceId: answer.choiceId,
numericValue: answer.numericValue,
})),
async (questionIds) =>
this.questionRepository.find({
where: { id: In(questionIds) },
relations: {
choices: true,
rangeRules: true,
},
}),
async (score) =>
this.resultRangeRepository.find({
where: {
test: { id: submission.testId },
minScore: LessThanOrEqual(score),
maxScore: MoreThanOrEqual(score),
},
}),
);
submission.result = {
score: result.score,
probability: result.probability,
title: result.resultRange?.title || '',
description: result.resultRange?.description || '',
};
return submission.result;
}
private async calculateFromAnswers(
answers: Array<{
questionId: number;
question?: Question;
choiceId?: number;
choice?: { id: number; score: number };
numericValue?: number;
}>,
loadQuestions: (questionIds: number[]) => Promise<Question[]>,
loadResultRanges: (score: number) => Promise<ResultRange[]>,
) {
let score = 0;
const questionIds = answers.map((a) => a.questionId);
const questions = await loadQuestions(questionIds);
const questionsMap = new Map<number, Question>();
questions.forEach((q) => questionsMap.set(q.id, q));
for (const answer of answers) {
const question = answer.question ?? questionsMap.get(answer.questionId);
if (!question) continue;
if (
question.type === QuestionType.MULTI_CHOICE &&
answer.choiceId !== undefined
) {
const choice =
answer.choice ??
question.choices.find((c) => c.id === answer.choiceId);
if (choice) {
score += question.weight * choice.score;
}
} else if (
answer.question.type === QuestionType.NUMERIC &&
question.type === QuestionType.NUMERIC &&
answer.numericValue !== undefined
) {
question.rangeRules?.forEach((rule) => {
if (
answer.question.rangeRules &&
answer.question.rangeRules.length > 0
answer.numericValue! >= rule.min &&
answer.numericValue! <= rule.max
) {
answer.question.rangeRules.forEach((rule) => {
if (
answer.numericValue !== undefined &&
answer.numericValue >= rule.min &&
answer.numericValue <= rule.max
) {
score += answer.question.weight * rule.score;
score += question.weight * rule.score;
}
});
}
}
}
let totalScores = 0;
const testQuestions = await manager.find(Question, {
where: { test: { id: submission.test.id } },
relations: {
choices: true,
rangeRules: true,
},
});
questions.forEach((question) => {
const answerScores: number[] = [];
testQuestions.forEach((question) => {
const answerScores: Array<number> = [];
if (question.type === QuestionType.MULTI_CHOICE) {
question.choices.forEach((choice) => {
answerScores.push(choice.score);
@@ -84,32 +211,131 @@ export class CalculateResultProvider {
totalScores += question.weight * maxScore;
});
const resultRanges = await manager.find(ResultRange, {
where: {
test: { id: submission.test.id },
minScore: LessThanOrEqual(score),
maxScore: MoreThanOrEqual(score),
},
});
const submissionResult = manager.create(SubmissionResult, {
submission,
score,
probability: Math.ceil((score / totalScores) * 100),
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
});
try {
await manager.save(submissionResult);
} catch (err) {
throw new AppTimeoutException(err);
}
const resultRanges = await loadResultRanges(score);
return {
score,
probability: submissionResult.probability,
title: submissionResult.resultRange?.title,
description: submissionResult.resultRange?.description,
probability: totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0,
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
};
}
}
// import { Injectable } from '@nestjs/common';
// import { EntityManager, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
// import { SubmissionResult } from '../entities/submission-result.entity';
// import { Submission } from '../entities/submission.entity';
// import { QuestionType } from '@/modules/questions/enums/question-type.enum';
// import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
// 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()
// export class CalculateResultProvider {
// public async calculateAutenticated(
// submission: Submission,
// manager: EntityManager,
// ) {
// let score = 0;
// let submissionAnswers: Array<SubmissionAnswer> = [];
// try {
// submissionAnswers = await manager.find(SubmissionAnswer, {
// where: { submission: { id: submission.id } },
// relations: {
// question: {
// rangeRules: true,
// },
// choice: true,
// },
// });
// } catch (err) {
// throw new AppTimeoutException(err);
// }
// for (const answer of submissionAnswers) {
// 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 (
// answer.question.rangeRules &&
// answer.question.rangeRules.length > 0
// ) {
// answer.question.rangeRules.forEach((rule) => {
// if (
// answer.numericValue !== undefined &&
// answer.numericValue >= rule.min &&
// answer.numericValue <= rule.max
// ) {
// score += answer.question.weight * rule.score;
// }
// });
// }
// }
// }
// let totalScores = 0;
// const testQuestions = await manager.find(Question, {
// where: { test: { id: submission.test.id } },
// relations: {
// choices: true,
// rangeRules: true,
// },
// });
// testQuestions.forEach((question) => {
// const answerScores: Array<number> = [];
// if (question.type === QuestionType.MULTI_CHOICE) {
// question.choices.forEach((choice) => {
// answerScores.push(choice.score);
// });
// } else if (
// question.type === QuestionType.NUMERIC &&
// question.rangeRules &&
// question.rangeRules.length > 0
// ) {
// question.rangeRules.forEach((rule) => {
// answerScores.push(rule.score);
// });
// }
// const maxScore = answerScores.length > 0 ? Math.max(...answerScores) : 0;
// totalScores += question.weight * maxScore;
// });
// const resultRanges = await manager.find(ResultRange, {
// where: {
// test: { id: submission.test.id },
// minScore: LessThanOrEqual(score),
// maxScore: MoreThanOrEqual(score),
// },
// });
// const submissionResult = manager.create(SubmissionResult, {
// submission,
// score,
// probability: Math.ceil((score / totalScores) * 100),
// resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
// });
// try {
// await manager.save(submissionResult);
// } catch (err) {
// throw new AppTimeoutException(err);
// }
// return {
// score,
// probability: submissionResult.probability,
// title: submissionResult.resultRange?.title,
// description: submissionResult.resultRange?.description,
// };
// }
// }
@@ -16,7 +16,7 @@ import { SubmissionStatus } from '../enums/submission-status.enum';
import { Test } from '@/modules/tests/entities/test.entity';
import { randomUUID } from 'crypto';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface';
import { TestStatus } from '@/modules/tests/enums/test-status.enum';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
@@ -162,6 +162,7 @@ export class StartSubmissionProvider {
const guestSubmissionId = randomUUID();
try {
await this.redisService.set<GuestSubmissionSession>(
`guest_submission:${guestSubmissionId}`,
{
@@ -171,6 +172,9 @@ export class StartSubmissionProvider {
},
3600,
);
} catch (err) {
throw new AppTimeoutException(err);
}
return new AppResponse({
guestSubmissionId,