fix: fix bugs and structure

This commit is contained in:
2026-08-13 09:40:21 +03:30
parent c368948c4b
commit f6a57454f4
18 changed files with 630 additions and 590 deletions
@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REDIS } from '../constants/redis.constants'; import { REDIS } from '../constants/redis.constants';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { randomUUID } from 'crypto';
@Injectable() @Injectable()
export class RedisService { export class RedisService {
@@ -38,6 +39,22 @@ export class RedisService {
return await this.redis.del(key); return await this.redis.del(key);
} }
async acquireLock(key: string, ttlSeconds = 5): Promise<string | null> {
const token = randomUUID();
const result = await this.redis.set(key, token, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? token : null;
}
async releaseLock(key: string, token: string): Promise<void> {
const value = await this.redis.get(key);
if (value === token) {
await this.redis.del(key);
}
}
async onModuleDestroy() { async onModuleDestroy() {
await this.redis.quit(); await this.redis.quit();
} }
+2 -1
View File
@@ -3,7 +3,8 @@
"internalServerError": "Internal Server Error", "internalServerError": "Internal Server Error",
"tooManyRequests": "Too many requests! Try again after {minutes} min.", "tooManyRequests": "Too many requests! Try again after {minutes} min.",
"requestTimeout": "Unable to process your request at the moment.", "requestTimeout": "Unable to process your request at the moment.",
"entityNotFound": "{entity} not found." "entityNotFound": "{entity} not found.",
"sameProcessInProgress": "Same process is in progress."
}, },
"messages": { "messages": {
"createSuccess": "{entity} created successfully.", "createSuccess": "{entity} created successfully.",
+2 -1
View File
@@ -12,6 +12,7 @@
"emptyNumericValue": "A numeric value should be provided as answer.", "emptyNumericValue": "A numeric value should be provided as answer.",
"numericValueRange": "Numeric value is out of specified range.", "numericValueRange": "Numeric value is out of specified range.",
"emptyChoiceId": "Choice Id should be provided as answer.", "emptyChoiceId": "Choice Id should be provided as answer.",
"transitionNotFound": "No transition found for the test" "transitionNotFound": "No transition found for the test",
"uniqueSubmissionQuestion": "Question's Answer already exists."
} }
} }
+2 -1
View File
@@ -3,7 +3,8 @@
"internalServerError": "خطایی رخ داد", "internalServerError": "خطایی رخ داد",
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.", "tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.", "requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
"entityNotFound": "{entity} مورد نظر یافت نشد." "entityNotFound": "{entity} مورد نظر یافت نشد.",
"sameProcessInProgress": "فرایند مشابهی در حال اجراست."
}, },
"messages": { "messages": {
"createSuccess": "{entity} با موفقیت ایجاد شد.", "createSuccess": "{entity} با موفقیت ایجاد شد.",
+2 -1
View File
@@ -12,6 +12,7 @@
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.", "emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.", "numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.", "emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
"transitionNotFound": "مسیری برای تست پیدا نشد." "transitionNotFound": "مسیری برای تست پیدا نشد.",
"uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است."
} }
} }
+1 -1
View File
@@ -13,6 +13,6 @@ import { ResultRangeQueryProvider } from './providers/result-range-query.provide
ResultRangeQueryProvider, ResultRangeQueryProvider,
ResultRangeService, ResultRangeService,
], ],
exports: [ResultRangeService], exports: [ResultRangeService, TypeOrmModule],
}) })
export class ScoringModule {} export class ScoringModule {}
@@ -0,0 +1 @@
export const UNIQUE_SUBMISSION_QUESTION_IDX = 'UNQ_SUBMISSION_QUESTION_IDX';
@@ -8,9 +8,12 @@ import {
import { Submission } from './submission.entity'; import { Submission } from './submission.entity';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
import { Choice } from '@/modules/questions/entities/choice.entity'; import { Choice } from '@/modules/questions/entities/choice.entity';
import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants';
@Entity() @Entity()
@Index(['submission', 'question'], { unique: true }) @Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], {
unique: true,
})
export class SubmissionAnswer { export class SubmissionAnswer {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@@ -0,0 +1,42 @@
import { Question } from '@/modules/questions/entities/question.entity';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
import { BadRequestException, HttpStatus } from '@nestjs/common';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface';
import { AppException } from '@/common/exceptions/app.exception';
export const checkAnswerCorrectness = (
question: Question,
answerSubmissionDto: AnswerSubmissionDTO,
) => {
if (!answerSubmissionDto) throw new BadRequestException();
const { choiceId, numericValue } = answerSubmissionDto;
if (question.type === QuestionType.NUMERIC) {
if (numericValue === undefined) {
throw new BadRequestException('submissions.errors.emptyNumericValue');
}
const metadata = question.metadata as NumericMetadata;
if (
metadata &&
(numericValue < metadata.min || numericValue > metadata.max)
) {
throw new BadRequestException('submissions.errors.numericValueRange');
}
}
if (question.type === QuestionType.MULTI_CHOICE) {
if (choiceId === undefined || choiceId === null) {
throw new BadRequestException('submissions.errors.emptyChoiceId');
}
if (!question.choices?.some((c) => c.id === choiceId)) {
throw new AppException('validation.wrongField', HttpStatus.BAD_REQUEST, {
field: '$t(submissions.fields.choiceId)',
});
}
}
};
@@ -0,0 +1,63 @@
import { Question } from '@/modules/questions/entities/question.entity';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
import { AppException } from '@/common/exceptions/app.exception';
import { HttpStatus } from '@nestjs/common';
export const getNextTransition = (
question: Question,
answerSubmissionDto: AnswerSubmissionDTO,
) => {
const transitions = question.outgoingTransitions ?? [];
const endTransition = transitions.find(
(t) => t.type === QuestionTransitionType.END,
);
if (
question.type === QuestionType.MULTI_CHOICE &&
answerSubmissionDto.choiceId !== undefined
) {
const transition = transitions.find(
(t) =>
t.type === QuestionTransitionType.CHOICE &&
(t.condition as SingleChoiceCondition).choiceId ===
answerSubmissionDto.choiceId,
);
if (transition) return transition;
}
if (
question.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue !== undefined
) {
const value = answerSubmissionDto.numericValue;
const transition = transitions.find((t) => {
if (t.type !== QuestionTransitionType.NUMBER_RANGE) return false;
const condition = t.condition as NumericCondition;
return value >= condition.min && value <= condition.max;
});
if (transition) return transition;
}
const defaultTransition = transitions.find(
(t) => t.type === QuestionTransitionType.DEFAULT,
);
if (defaultTransition) return defaultTransition;
if (endTransition) return endTransition;
throw new AppException(
'submissions.errors.nextQuestionNotFound',
HttpStatus.BAD_REQUEST,
);
};
@@ -1,452 +0,0 @@
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Submission } from '../entities/submission.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
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.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 { 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 {
constructor(
/**
* Inject Submission Repository
*/
@InjectRepository(Submission)
private readonly submissionRepository: Repository<Submission>,
/**
* Inject Redis Service
*/
private readonly redisService: RedisService,
/**
* Inject SubmisionAnswer Repository
*/
@InjectRepository(SubmissionAnswer)
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
/**
* Inject SubmissionResult Repository
*/
@InjectRepository(SubmissionResult)
private readonly submissionResultRepository: Repository<SubmissionResult>,
/**
* Inject Questions Repository
*/
@InjectRepository(Question)
private readonly questionsRepository: Repository<Question>,
/**
* Inject CalculateResult Provider
*/
private readonly calculateResultProvider: CalculateResultProvider,
/**
* Inject DataSource
*/
private readonly dataSource: DataSource,
) {}
public async answer(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
user?: User,
) {
if (user) {
return await this.answerAuthenticated(id, answerSubmissionDto, user);
} else {
return await this.answerGuest(id, answerSubmissionDto);
}
}
private async answerAuthenticated(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
user: User,
) {
let submission: Submission | null = null;
const submissionId = Number(id);
if (Number.isNaN(submissionId)) {
throw new AppException(
'validation.wrongFieldFormat',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.submissionId)' },
);
}
try {
submission = await this.submissionRepository.findOne({
where: { id: submissionId, user: { id: user.id } },
relations: {
test: true,
currentQuestion: {
choices: true,
outgoingTransitions: true,
},
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (!submission) {
throw new AppException(
'common.errors.entityNotFound',
HttpStatus.NOT_FOUND,
{ entity: '$t(submissions.fields.submission)' },
);
}
const currentQuestion = submission.currentQuestion;
if (!currentQuestion) {
let result: SubmissionResult | null;
try {
result = await this.submissionResultRepository.findOne({
where: { submission: { id: submission.id } },
relations: {
resultRange: true,
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result: result
? {
score: result.score,
probability: result.probability,
title: result.resultRange?.title,
description: result.resultRange?.description,
}
: null,
});
}
this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
const submissionAnswer = this.submissionAnswerRepository.create({
submission,
question: currentQuestion,
});
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)' },
);
}
submissionAnswer.choice = choice;
} else if (
currentQuestion.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue !== undefined
) {
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
}
const nextTransition = this.getNextTransition(
currentQuestion,
answerSubmissionDto,
);
const nextQuestion = nextTransition.toQuestion ?? null;
let completedResponse: object | null = null;
try {
completedResponse = await this.dataSource.transaction(async (manager) => {
await manager.save(submissionAnswer);
if (
nextTransition.type === QuestionTransitionType.END ||
!nextQuestion
) {
await manager.update(
Submission,
{ id: submission.id },
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
);
const result =
await this.calculateResultProvider.calculateAuthenticated(
submission,
manager,
);
return {
isCompleted: true,
currentQuestion: null,
result,
};
}
await manager.update(
Submission,
{ id: submission.id },
{ currentQuestion: nextQuestion },
);
return null;
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (completedResponse) {
return new AppResponse(completedResponse);
}
return new AppResponse({
isCompleted: false,
// isFinal: ,
currentQuestion: {
type: nextQuestion?.type,
content: nextQuestion?.content,
required: nextQuestion?.required,
metadata: nextQuestion?.metadata,
choices: nextQuestion?.choices.map((el) => ({
id: el.id,
text: el.text,
order: el.order,
})),
},
});
}
private async answerGuest(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
) {
console.log(id, answerSubmissionDto);
if (!isUUID(id)) {
throw new AppException(
'validation.wrongFieldFormat',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.submissionId)' },
);
}
const submission = await this.redisService.get<GuestSubmissionSession>(
`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;
}
private checkAnswerCorrectness(
question: Question,
answerSubmissionDto: AnswerSubmissionDTO,
) {
if (!answerSubmissionDto) throw new BadRequestException();
const { choiceId, numericValue } = answerSubmissionDto;
if (question.type === QuestionType.NUMERIC) {
if (numericValue === undefined) {
throw new BadRequestException('submissions.errors.emptyNumericValue');
}
const metadata = question.metadata as NumericMetadata;
if (
metadata &&
(numericValue < metadata.min || numericValue > metadata.max)
) {
throw new BadRequestException('submissions.errors.numericValueRange');
}
}
if (question.type === QuestionType.MULTI_CHOICE) {
if (!choiceId) {
throw new BadRequestException('submissions.errors.emptyChoiceId');
}
if (!question.choices.some((c) => c.id === choiceId)) {
throw new AppException(
'validation.wrongField',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.choiceId)' },
);
}
}
}
private getNextTransition(
question: Question,
answerSubmissionDto: AnswerSubmissionDTO,
) {
const transitions = question.outgoingTransitions ?? [];
const endTransition = transitions.find(
(t) => t.type === QuestionTransitionType.END,
);
if (
question.type === QuestionType.MULTI_CHOICE &&
answerSubmissionDto.choiceId !== undefined
) {
const transition = transitions.find(
(t) =>
t.type === QuestionTransitionType.CHOICE &&
(t.condition as SingleChoiceCondition).choiceId ===
answerSubmissionDto.choiceId,
);
if (transition) return transition;
}
if (
question.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue !== undefined
) {
const value = answerSubmissionDto.numericValue;
const transition = transitions.find((t) => {
if (t.type !== QuestionTransitionType.NUMBER_RANGE) return false;
const condition = t.condition as NumericCondition;
return value >= condition.min && value <= condition.max;
});
if (transition) return transition;
}
const defaultTransition = transitions.find(
(t) => t.type === QuestionTransitionType.DEFAULT,
);
if (defaultTransition) return defaultTransition;
if (endTransition) return endTransition;
throw new AppException(
'submissions.errors.nextQuestionNotFound',
HttpStatus.BAD_REQUEST,
);
}
}
@@ -0,0 +1,235 @@
import { ConflictException, HttpStatus, Injectable } from '@nestjs/common';
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
import { User } from '@/modules/users/entities/user.entity';
import { Submission } from '../../entities/submission.entity';
import { AppException } from '@/common/exceptions/app.exception';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { SubmissionResult } from '../../entities/submission-result.entity';
import { AppResponse } from '@/common/responses';
import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { getNextTransition } from '../../helpers/get-next-transition.helper';
import { DataSource, QueryFailedError, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { SubmissionAnswer } from '../../entities/submission-answer.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { SubmissionStatus } from '../../enums/submission-status.enum';
import { CalculateResultProvider } from './calculate-result.provider';
import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../../constants';
@Injectable()
export class AnswerAuthenticatedProvider {
constructor(
/**
* Inject Submission Repository
*/
@InjectRepository(Submission)
private readonly submissionRepository: Repository<Submission>,
/**
* Inject SubmisionAnswer Repository
*/
@InjectRepository(SubmissionAnswer)
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
/**
* Inject SubmissionResult Repository
*/
@InjectRepository(SubmissionResult)
private readonly submissionResultRepository: Repository<SubmissionResult>,
/**
* Inject DataSource
*/
private readonly dataSource: DataSource,
/**
* Inject CalculateResult Provider
*/
private readonly calculateResultProvider: CalculateResultProvider,
) {}
public async answer(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
user: User,
) {
let submission: Submission | null = null;
const submissionId = Number(id);
if (Number.isNaN(submissionId)) {
throw new AppException(
'validation.wrongFieldFormat',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.submissionId)' },
);
}
try {
submission = await this.submissionRepository.findOne({
where: { id: submissionId, user: { id: user.id } },
relations: {
test: true,
currentQuestion: {
choices: true,
outgoingTransitions: {
toQuestion: {
choices: true,
},
},
},
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (!submission) {
throw new AppException(
'common.errors.entityNotFound',
HttpStatus.NOT_FOUND,
{ entity: '$t(submissions.fields.submission)' },
);
}
const currentQuestion = submission.currentQuestion;
if (!currentQuestion) {
let result: SubmissionResult | null;
try {
result = await this.submissionResultRepository.findOne({
where: { submission: { id: submission.id } },
relations: {
resultRange: true,
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result: result
? {
score: result.score,
probability: result.probability,
title: result.resultRange?.title,
description: result.resultRange?.description,
}
: null,
});
}
checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
const submissionAnswer = this.submissionAnswerRepository.create({
submission,
question: currentQuestion,
});
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)' },
);
}
submissionAnswer.choice = choice;
} else if (
currentQuestion.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue !== undefined
) {
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
}
const nextTransition = getNextTransition(
currentQuestion,
answerSubmissionDto,
);
const nextQuestion = nextTransition.toQuestion ?? null;
let completedResponse: object | null = null;
try {
completedResponse = await this.dataSource.transaction(async (manager) => {
await manager.save(submissionAnswer);
if (
nextTransition.type === QuestionTransitionType.END ||
!nextQuestion
) {
await manager.update(
Submission,
{ id: submission.id },
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
);
const result =
await this.calculateResultProvider.calculateAuthenticated(
submission,
manager,
);
return {
isCompleted: true,
currentQuestion: null,
result,
};
}
await manager.update(
Submission,
{ id: submission.id },
{ currentQuestion: nextQuestion },
);
return null;
});
} catch (err) {
if (
err instanceof QueryFailedError &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(err as any).constraint === UNIQUE_SUBMISSION_QUESTION_IDX
) {
throw new ConflictException(
'submissions.errors.uniqueSubmissionQuestion',
);
}
throw new AppTimeoutException(err);
}
if (completedResponse) {
return new AppResponse(completedResponse);
}
return new AppResponse({
isCompleted: false,
// isFinal: ,
currentQuestion: {
type: nextQuestion?.type,
content: nextQuestion?.content,
required: nextQuestion?.required,
metadata: nextQuestion?.metadata,
choices: nextQuestion?.choices.map((el) => ({
id: el.id,
text: el.text,
order: el.order,
})),
},
});
}
}
@@ -0,0 +1,206 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
import { isUUID } from 'class-validator';
import { AppException } from '@/common/exceptions/app.exception';
import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { Question } from '@/modules/questions/entities/question.entity';
import { AppResponse } from '@/common/responses';
import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper';
import { GuestSubmissionAnswer } from '../../interfaces/guest-submission-answer.interface';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { getNextTransition } from '../../helpers/get-next-transition.helper';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { InjectRepository } from '@nestjs/typeorm';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
import { Repository } from 'typeorm';
import { CalculateResultProvider } from './calculate-result.provider';
@Injectable()
export class AnswerGuestProvider {
constructor(
/**
* Inject Redis Service
*/
private readonly redisService: RedisService,
/**
* Inject Questions Repository
*/
@InjectRepository(Question)
private readonly questionsRepository: Repository<Question>,
/**
* Inject CalculateResult Provider
*/
private readonly calculateResultProvider: CalculateResultProvider,
) {}
public async answer(id: string, answerSubmissionDto: AnswerSubmissionDTO) {
if (!isUUID(id)) {
throw new AppException(
'validation.wrongFieldFormat',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.submissionId)' },
);
}
const lockKey = `lock:guest_submission:${id}`;
const lockToken = await this.redisService.acquireLock(lockKey, 5);
if (!lockToken) {
throw new AppException(
'common.errors.processingInProgress',
HttpStatus.CONFLICT,
);
}
try {
return this.answerGuest(id, answerSubmissionDto);
} finally {
await this.redisService.releaseLock(lockKey, lockToken);
}
}
private async answerGuest(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
) {
let submission: GuestSubmissionSession | null = null;
try {
submission = await this.redisService.get<GuestSubmissionSession>(
`guest_submission:${id}`,
);
} catch (err) {
throw new AppTimeoutException(err);
}
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: {
toQuestion: {
choices: 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,
});
}
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 = getNextTransition(
currentQuestion,
answerSubmissionDto,
);
const nextQuestion = nextTransition.toQuestion ?? null;
submission.answers = [...submission.answers, newAnswer];
try {
if (nextTransition.type === QuestionTransitionType.END || !nextQuestion) {
submission.currentQuestionId = null;
const result =
await this.calculateResultProvider.calculateGuest(submission);
await this.redisService.set<GuestSubmissionSession>(
`guest_submission:${id}`,
submission,
3600,
);
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result,
});
}
await this.redisService.set<GuestSubmissionSession>(
`guest_submission:${id}`,
{ ...submission, currentQuestionId: nextQuestion.id },
3600,
);
} catch (err) {
throw new AppTimeoutException(err);
}
return new AppResponse({
isCompleted: false,
// isFinal: ,
currentQuestion: {
type: nextQuestion?.type,
content: nextQuestion?.content,
required: nextQuestion?.required,
metadata: nextQuestion?.metadata,
choices: nextQuestion?.choices.map((el) => ({
id: el.id,
text: el.text,
order: el.order,
})),
},
});
}
}
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
import { User } from '@/modules/users/entities/user.entity';
import { AnswerAuthenticatedProvider } from './answer-authenticated.provider';
import { AnswerGuestProvider } from './answer-guest.provider';
@Injectable()
export class AnswerSubmissionProvider {
constructor(
/**
* Inject Answer Autenticated Provider
*/
private readonly answerAuthenticatedProvider: AnswerAuthenticatedProvider,
/**
* Inject Answer Guest Provider
*/
private readonly answerGuestProvider: AnswerGuestProvider,
) {}
public async answer(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
user?: User,
) {
if (user) {
return await this.answerAuthenticatedProvider.answer(
id,
answerSubmissionDto,
user,
);
} else {
return await this.answerGuestProvider.answer(id, answerSubmissionDto);
}
}
}
@@ -8,14 +8,14 @@ import {
} from 'typeorm'; } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { SubmissionResult } from '../entities/submission-result.entity'; import { SubmissionResult } from '../../entities/submission-result.entity';
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 { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { SubmissionAnswer } from '../entities/submission-answer.entity'; import { SubmissionAnswer } from '../../entities/submission-answer.entity';
import { Question } from '@/modules/questions/entities/question.entity'; import { Question } from '@/modules/questions/entities/question.entity';
import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface'; import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
@Injectable() @Injectable()
export class CalculateResultProvider { export class CalculateResultProvider {
@@ -220,122 +220,3 @@ export class CalculateResultProvider {
}; };
} }
} }
// 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,
// };
// }
// }
@@ -8,15 +8,15 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Repository } from 'typeorm'; import { 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 { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppResponse } from '@/common/responses'; import { AppResponse } from '@/common/responses';
import { SubmissionStatus } from '../enums/submission-status.enum'; import { SubmissionStatus } from '../../enums/submission-status.enum';
import { Test } from '@/modules/tests/entities/test.entity'; import { Test } from '@/modules/tests/entities/test.entity';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { RedisService } from '@/common/modules/redis/providers/redis.service'; import { RedisService } from '@/common/modules/redis/providers/redis.service';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface'; import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
import { TestStatus } from '@/modules/tests/enums/test-status.enum'; import { TestStatus } from '@/modules/tests/enums/test-status.enum';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity'; import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { StartSubmissionProvider } from './start-submission.provider'; import { StartSubmissionProvider } from './start/start-submission.provider';
import { User } from '@/modules/users/entities/user.entity'; import { User } from '@/modules/users/entities/user.entity';
import { AnswerSubmissionProvider } from './answer-submission.provider'; import { AnswerSubmissionProvider } from './answer/answer-submission.provider';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
@Injectable() @Injectable()
@@ -4,14 +4,16 @@ import { Submission } from './entities/submission.entity';
import { SubmissionAnswer } from './entities/submission-answer.entity'; import { SubmissionAnswer } from './entities/submission-answer.entity';
import { SubmissionResult } from './entities/submission-result.entity'; import { SubmissionResult } from './entities/submission-result.entity';
import { SubmissionsController } from './submissions.controller'; import { SubmissionsController } from './submissions.controller';
import { StartSubmissionProvider } from './providers/start-submission.provider'; import { StartSubmissionProvider } from './providers/start/start-submission.provider';
import { SubmissionsService } from './providers/submissions.service'; import { SubmissionsService } from './providers/submissions.service';
import { TestsModule } from '../tests/tests.module'; import { TestsModule } from '../tests/tests.module';
import { AnswerSubmissionProvider } from './providers/answer-submission.provider'; import { AnswerSubmissionProvider } from './providers/answer/answer-submission.provider';
import { RedisModule } from '@/common/modules/redis/redis.module'; import { RedisModule } from '@/common/modules/redis/redis.module';
import { QuestionsModule } from '../questions/questions.module'; import { QuestionsModule } from '../questions/questions.module';
import { CalculateResultProvider } from './providers/calculate-result.provider'; import { CalculateResultProvider } from './providers/answer/calculate-result.provider';
import { ScoringModule } from '../scoring/scoring.module'; import { ScoringModule } from '../scoring/scoring.module';
import { AnswerGuestProvider } from './providers/answer/answer-guest.provider';
import { AnswerAuthenticatedProvider } from './providers/answer/answer-authenticated.provider';
@Module({ @Module({
imports: [ imports: [
@@ -24,8 +26,10 @@ import { ScoringModule } from '../scoring/scoring.module';
controllers: [SubmissionsController], controllers: [SubmissionsController],
providers: [ providers: [
StartSubmissionProvider, StartSubmissionProvider,
AnswerSubmissionProvider,
CalculateResultProvider, CalculateResultProvider,
AnswerAuthenticatedProvider,
AnswerGuestProvider,
AnswerSubmissionProvider,
SubmissionsService, SubmissionsService,
], ],
}) })