feat: change answer submission architecture

This commit is contained in:
2026-07-12 17:55:09 +03:30
parent f399ec4a04
commit 4e4731dbde
11 changed files with 238 additions and 87 deletions
@@ -89,7 +89,7 @@ export class AdminQuestionsController {
@Get(':id') @Get(':id')
public async getQuestionById(@Param('id', ParseIntPipe) id: number) { public async getQuestionById(@Param('id', ParseIntPipe) id: number) {
return await this.questionsService.findOneForAdmin(id); return await this.questionsService.findOneByIdForAdmin(id);
} }
@Patch(':id') @Patch(':id')
@@ -38,17 +38,13 @@ export class CreateQuestionDTO {
}) })
content!: string; content!: string;
@IsPositive({ // @IsPositive({
message: t('validation.wrongField', { // message: t('validation.wrongField', {
field: '$t(questions.fields.order)', // field: '$t(questions.fields.order)',
}), // }),
}) // })
@IsNotEmpty({ // @IsOptional()
message: t('validation.requiredField', { // order?: number;
field: '$t(questions.fields.order)',
}),
})
order!: number;
@IsPositive({ @IsPositive({
message: t('validation.wrongField', { message: t('validation.wrongField', {
@@ -1,7 +1,7 @@
import { ConflictException, HttpStatus, Injectable } from '@nestjs/common'; import { ConflictException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Question } from '../entities/question.entity'; import { Question } from '../entities/question.entity';
import { QueryFailedError, Repository } from 'typeorm'; import { DataSource, QueryFailedError, Repository } from 'typeorm';
import { TestsService } from '@/modules/tests/providers/tests.service'; import { TestsService } from '@/modules/tests/providers/tests.service';
import { I18nService } from 'nestjs-i18n'; import { I18nService } from 'nestjs-i18n';
import { CreateQuestionDTO } from '../dtos/create-question.dto'; import { CreateQuestionDTO } from '../dtos/create-question.dto';
@@ -11,10 +11,16 @@ import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { UpdateQuestionDTO } from '../dtos/update-question.dto'; import { UpdateQuestionDTO } from '../dtos/update-question.dto';
import { validateMetadata } from '../helpers/validate-metadata.helper'; import { validateMetadata } from '../helpers/validate-metadata.helper';
import { AppException } from '@/common/exceptions/app.exception'; import { AppException } from '@/common/exceptions/app.exception';
import { Test } from '@/modules/tests/entities/test.entity';
@Injectable() @Injectable()
export class QuestionsCommandProvider { export class QuestionsCommandProvider {
constructor( constructor(
/**
* Inject DataSource
*/
private readonly dataSource: DataSource,
/** /**
* Inject Questions Repository * Inject Questions Repository
*/ */
@@ -35,13 +41,31 @@ export class QuestionsCommandProvider {
public async createOne(createQuestionDto: CreateQuestionDTO) { public async createOne(createQuestionDto: CreateQuestionDTO) {
const test = await this.testsService.adminGetOne(createQuestionDto.test); const test = await this.testsService.adminGetOne(createQuestionDto.test);
let maxOrder: { max: string } | undefined;
try {
maxOrder = await this.questionsRepository
.createQueryBuilder('q')
.select('MAX(q.order)', 'max')
.where('q.testId = :testId', { testId: test.id })
.getRawOne();
} catch (err) {
throw new AppTimeoutException(err);
}
const newQuestion = this.questionsRepository.create({ const newQuestion = this.questionsRepository.create({
...createQuestionDto, ...createQuestionDto,
test, test,
order: (Number(maxOrder?.max) || 0) + 1,
}); });
try { try {
await this.questionsRepository.save(newQuestion); await this.dataSource.transaction(async (manager) => {
await manager.save(newQuestion);
await manager.increment(Test, { id: test.id }, 'questionsCount', 1);
});
return new AppResponse( return new AppResponse(
newQuestion, newQuestion,
this.i18nService.translate('common.messages.createSuccess', { this.i18nService.translate('common.messages.createSuccess', {
@@ -117,7 +141,10 @@ export class QuestionsCommandProvider {
let question: Question | null = null; let question: Question | null = null;
try { try {
question = await this.questionsRepository.findOneBy({ id }); question = await this.questionsRepository.findOne({
where: { id },
relations: { test: true },
});
} catch (err) { } catch (err) {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
} }
@@ -130,7 +157,16 @@ export class QuestionsCommandProvider {
); );
try { try {
await this.questionsRepository.delete(question.id); await this.dataSource.transaction(async (manager) => {
await manager.delete(Question, question.id);
await manager.decrement(
Test,
{ id: question.test.id },
'questionsCount',
1,
);
});
} catch (err) { } catch (err) {
console.log(err); console.log(err);
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
@@ -7,7 +7,7 @@ import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { PaginatedResponse } from '@/common/responses'; import { PaginatedResponse } from '@/common/responses';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Question } from '../entities/question.entity'; import { Question } from '../entities/question.entity';
import { Repository } from 'typeorm'; import { FindManyOptions, FindOneOptions, Repository } from 'typeorm';
import { TestsService } from '@/modules/tests/providers/tests.service'; import { TestsService } from '@/modules/tests/providers/tests.service';
import { AppException } from '@/common/exceptions/app.exception'; import { AppException } from '@/common/exceptions/app.exception';
@@ -39,8 +39,6 @@ export class QuestionsQueryProvider {
}); });
} }
console.log(selectedTest);
try { try {
const [questions, total] = await this.questionsRepository.findAndCount({ const [questions, total] = await this.questionsRepository.findAndCount({
where: { where: {
@@ -69,7 +67,19 @@ export class QuestionsQueryProvider {
} }
} }
public async findOneForAdmin(id: number) { public async findByOptionsForAdmin(options: FindManyOptions<Question>) {
let questions: Array<Question> = [];
try {
questions = await this.questionsRepository.find(options);
} catch (err) {
throw new AppTimeoutException(err);
}
return questions;
}
public async findOneByIdForAdmin(id: number) {
let question: Question | null = null; let question: Question | null = null;
try { try {
@@ -91,4 +101,16 @@ export class QuestionsQueryProvider {
return question; return question;
} }
public async findOneForAdmin(options: FindOneOptions<Question>) {
let question: Question | null = null;
try {
question = await this.questionsRepository.findOne(options);
} catch (err) {
throw new AppTimeoutException(err);
}
return question;
}
} }
@@ -4,6 +4,8 @@ import { FindQuestionsDTO } from '../dtos/find-questions.dto';
import { UpdateQuestionDTO } from '../dtos/update-question.dto'; import { UpdateQuestionDTO } from '../dtos/update-question.dto';
import { QuestionsCommandProvider } from './questions-command.provider'; import { QuestionsCommandProvider } from './questions-command.provider';
import { QuestionsQueryProvider } from './questions-query.provider'; import { QuestionsQueryProvider } from './questions-query.provider';
import { FindManyOptions, FindOneOptions } from 'typeorm';
import { Question } from '../entities/question.entity';
@Injectable() @Injectable()
export class QuestionsService { export class QuestionsService {
@@ -27,8 +29,16 @@ export class QuestionsService {
return await this.questionsQueryProvider.findAllForAdmin(query); return await this.questionsQueryProvider.findAllForAdmin(query);
} }
public async findOneForAdmin(id: number) { public async findByOptionsForAdmin(options: FindManyOptions<Question>) {
return await this.questionsQueryProvider.findOneForAdmin(id); return await this.questionsQueryProvider.findByOptionsForAdmin(options);
}
public async findOneByIdForAdmin(id: number) {
return await this.questionsQueryProvider.findOneByIdForAdmin(id);
}
public async findOneForAdmin(options: FindOneOptions<Question>) {
return await this.questionsQueryProvider.findOneForAdmin(options);
} }
public async updateOne(id: number, updateQuestionDto: UpdateQuestionDTO) { public async updateOne(id: number, updateQuestionDto: UpdateQuestionDTO) {
@@ -31,6 +31,9 @@ export class Submission {
}) })
status!: SubmissionStatus; status!: SubmissionStatus;
@Column({ type: 'int', nullable: false, default: 1 })
currentQuestionOrder!: number;
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission) @OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
answers!: Array<SubmissionAnswer>; answers!: Array<SubmissionAnswer>;
@@ -1,9 +1,4 @@
import { import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
} 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';
@@ -21,6 +16,8 @@ import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata
import { SubmissionAnswer } from '../entities/submission-answer.entity'; import { SubmissionAnswer } from '../entities/submission-answer.entity';
import { ChoicesService } from '@/modules/questions/providers/choices.service'; import { ChoicesService } from '@/modules/questions/providers/choices.service';
import { CalculateResultProvider } from './calculate-result.provider'; import { CalculateResultProvider } from './calculate-result.provider';
import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { SubmissionResult } from '../entities/submission-result.entity';
@Injectable() @Injectable()
export class AnswerSubmissionProvider { export class AnswerSubmissionProvider {
@@ -42,6 +39,17 @@ export class AnswerSubmissionProvider {
@InjectRepository(SubmissionAnswer) @InjectRepository(SubmissionAnswer)
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>, private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
/**
* Inject SubmissionResult Repository
*/
@InjectRepository(SubmissionResult)
private readonly submissionResultRepository: Repository<SubmissionResult>,
/**
* Inject Questions Service
*/
private readonly questionsService: QuestionsService,
/** /**
* Inject Choices Service * Inject Choices Service
*/ */
@@ -84,35 +92,9 @@ export class AnswerSubmissionProvider {
try { try {
submission = await this.submissionRepository.findOne({ submission = await this.submissionRepository.findOne({
where: { id: submissionId }, where: { id: submissionId, user: { id: user.id } },
relations: { relations: {
user: true, test: true,
test: {
questions: {
choices: true,
rangeRules: true,
},
},
answers: {
question: true,
},
result: {
resultRange: true,
},
},
order: {
answers: {
question: {
order: 'ASC',
},
},
test: {
questions: {
choices: {
order: 'ASC',
},
},
},
}, },
}); });
} catch (err) { } catch (err) {
@@ -127,44 +109,69 @@ export class AnswerSubmissionProvider {
); );
} }
if (submission.user.id !== user.id) { // if (submission.user.id !== user.id) {
throw new ForbiddenException('auth.errors.accessDenied'); // throw new ForbiddenException('auth.errors.accessDenied');
// }
// const allQuestions = [...submission.test.questions].sort(
// (a, b) => a.order - b.order,
// );
// const answeredQuestions = submission.answers.map((el) => el.question);
// const questionStats = this.getQuestionStats(
// allQuestions,
// answeredQuestions,
// );
const currentQuestion: Question | null =
await this.questionsService.findOneForAdmin({
where: {
test: { id: submission.test.id },
order: submission.currentQuestionOrder,
},
relations: {
choices: true,
rangeRules: true,
},
});
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);
} }
const allQuestions = [...submission.test.questions].sort(
(a, b) => a.order - b.order,
);
const answeredQuestions = submission.answers.map((el) => el.question);
const questionStats = this.getQuestionStats(
allQuestions,
answeredQuestions,
);
if (!questionStats.current) {
return new AppResponse({ return new AppResponse({
isCompleted: true, isCompleted: true,
currentQuestion: null, currentQuestion: null,
result: submission.result result: result
? { ? {
score: submission.result.score, score: result.score,
probability: submission.result.probability, probability: result.probability,
title: submission.result.resultRange?.title, title: result.resultRange?.title,
description: submission.result.resultRange?.description, description: result.resultRange?.description,
} }
: null, : null,
}); });
} }
this.checkAnswerCorrectness(questionStats.current, answerSubmissionDto); this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
const submissionAnswer = this.submissionAnswerRepository.create({ const submissionAnswer = this.submissionAnswerRepository.create({
submission, submission,
question: questionStats.current, question: currentQuestion,
}); });
if ( if (
questionStats.current.type === QuestionType.MULTI_CHOICE && currentQuestion.type === QuestionType.MULTI_CHOICE &&
answerSubmissionDto.choiceId answerSubmissionDto.choiceId
) { ) {
const choice = await this.choicesService.findOneForAdmin( const choice = await this.choicesService.findOneForAdmin(
@@ -181,7 +188,7 @@ export class AnswerSubmissionProvider {
submissionAnswer.choice = choice; submissionAnswer.choice = choice;
} else if ( } else if (
questionStats.current.type === QuestionType.NUMERIC && currentQuestion.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue answerSubmissionDto.numericValue
) { ) {
submissionAnswer.numericValue = answerSubmissionDto.numericValue; submissionAnswer.numericValue = answerSubmissionDto.numericValue;
@@ -193,7 +200,19 @@ export class AnswerSubmissionProvider {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
} }
if (!questionStats.next) { const nextQuestion: Question | null =
await this.questionsService.findOneForAdmin({
where: {
test: { id: submission.test.id },
order: submission.currentQuestionOrder + 1,
},
relations: {
choices: true,
rangeRules: true,
},
});
if (!nextQuestion) {
const result = await this.calculateResultProvider.calculate(submission); const result = await this.calculateResultProvider.calculate(submission);
return new AppResponse({ return new AppResponse({
isCompleted: true, isCompleted: true,
@@ -202,14 +221,9 @@ export class AnswerSubmissionProvider {
}); });
} }
const nextQuestion = questionStats.next;
return new AppResponse({ return new AppResponse({
isCompleted: false, isCompleted: false,
isFinal: // isFinal: ,
nextQuestion.id === allQuestions[allQuestions.length - 1].id
? true
: undefined,
currentQuestion: { currentQuestion: {
type: nextQuestion.type, type: nextQuestion.type,
content: nextQuestion.content, content: nextQuestion.content,
@@ -7,6 +7,7 @@ import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { QuestionsService } from '@/modules/questions/providers/questions.service'; import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { ResultRangeService } from '@/modules/scoring/providers/result-range.service'; import { ResultRangeService } from '@/modules/scoring/providers/result-range.service';
import { SubmissionAnswer } from '../entities/submission-answer.entity';
@Injectable() @Injectable()
export class CalculateResultProvider { export class CalculateResultProvider {
@@ -26,13 +27,33 @@ export class CalculateResultProvider {
* Inject ResultRange Service * Inject ResultRange Service
*/ */
private readonly resultRangeService: ResultRangeService, private readonly resultRangeService: ResultRangeService,
/**
* Inject SubmissionAnswer Repository
*/
@InjectRepository(SubmissionAnswer)
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
) {} ) {}
public async calculate(submission: Submission) { public async calculate(submission: Submission) {
let score = 0; let score = 0;
let submissionAnswers: Array<SubmissionAnswer> = [];
try {
submissionAnswers = await this.submissionAnswerRepository.find({
where: { submission: { id: submission.id } },
relations: {
question: true,
choice: true,
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
await Promise.all( await Promise.all(
submission.answers.map(async (answer) => { submissionAnswers.map(async (answer) => {
if ( if (
answer.question.type === QuestionType.MULTI_CHOICE && answer.question.type === QuestionType.MULTI_CHOICE &&
answer.choice answer.choice
@@ -42,7 +63,7 @@ export class CalculateResultProvider {
answer.question.type === QuestionType.NUMERIC && answer.question.type === QuestionType.NUMERIC &&
answer.numericValue !== undefined answer.numericValue !== undefined
) { ) {
const question = await this.questionsService.findOneForAdmin( const question = await this.questionsService.findOneByIdForAdmin(
answer.question.id, answer.question.id,
); );
@@ -63,7 +84,15 @@ export class CalculateResultProvider {
let totalScores = 0; let totalScores = 0;
submission.test.questions.forEach((question) => { const testQuestions = await this.questionsService.findByOptionsForAdmin({
where: { test: { id: submission.test.id } },
relations: {
choices: true,
rangeRules: true,
},
});
testQuestions.forEach((question) => {
const answerScores: Array<number> = []; const answerScores: Array<number> = [];
if (question.type === QuestionType.MULTI_CHOICE) { if (question.type === QuestionType.MULTI_CHOICE) {
question.choices.forEach((choice) => { question.choices.forEach((choice) => {
@@ -56,6 +56,9 @@ export class Test {
@OneToMany(() => Question, (question) => question.test) @OneToMany(() => Question, (question) => question.test)
questions!: Array<Question>; questions!: Array<Question>;
@Column({ type: 'int', nullable: false, default: 0 })
questionsCount!: number;
@OneToMany(() => ResultRange, (range) => range.test) @OneToMany(() => ResultRange, (range) => range.test)
resultRanges!: Array<ResultRange>; resultRanges!: Array<ResultRange>;
@@ -105,4 +105,28 @@ export class TestsCommandProvider {
throw new AppTimeoutException(err); throw new AppTimeoutException(err);
} }
} }
public async incrementQuestionsCount(testId: number, count?: number) {
try {
await this.testRepository.increment(
{ id: testId },
'questionsCount',
count || 1,
);
} catch (err) {
throw new AppTimeoutException(err);
}
}
public async decrementQuestionsCount(testId: number, count?: number) {
try {
await this.testRepository.decrement(
{ id: testId },
'questionsCount',
count || 1,
);
} catch (err) {
throw new AppTimeoutException(err);
}
}
} }
@@ -48,4 +48,18 @@ export class TestsService {
public async deleteOne(id: number) { public async deleteOne(id: number) {
return await this.testsCommandProvider.deleteOne(id); return await this.testsCommandProvider.deleteOne(id);
} }
public async incrementQuestionsCount(testId: number, count?: number) {
return await this.testsCommandProvider.incrementQuestionsCount(
testId,
count,
);
}
public async decrementQuestionsCount(testId: number, count?: number) {
return await this.testsCommandProvider.decrementQuestionsCount(
testId,
count,
);
}
} }