fix: fix bugs and structure
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"internalServerError": "خطایی رخ داد",
|
"internalServerError": "خطایی رخ داد",
|
||||||
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
||||||
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
||||||
"entityNotFound": "{entity} مورد نظر یافت نشد."
|
"entityNotFound": "{entity} مورد نظر یافت نشد.",
|
||||||
|
"sameProcessInProgress": "فرایند مشابهی در حال اجراست."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
||||||
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
||||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
|
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
|
||||||
"transitionNotFound": "مسیری برای تست پیدا نشد."
|
"transitionNotFound": "مسیری برای تست پیدا نشد.",
|
||||||
|
"uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-123
@@ -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,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
+3
-3
@@ -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,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user