feat: add user submission functionality

This commit is contained in:
2026-07-05 21:45:45 +03:30
parent afb5045cf0
commit 7b44542ce7
48 changed files with 1459 additions and 55 deletions
@@ -0,0 +1,321 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { 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.interfaces';
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 { ChoicesService } from '@/modules/questions/providers/choices.service';
import { CalculateResultProvider } from './calculate-result.provider';
@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 Choices Service
*/
private readonly choicesService: ChoicesService,
/**
* Inject CalculateResult Provider
*/
private readonly calculateResultProvider: CalculateResultProvider,
) {}
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 },
relations: {
user: 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) {
throw new AppTimeoutException(err);
}
if (!submission) {
throw new AppException(
'common.errors.entityNotFound',
HttpStatus.NOT_FOUND,
{ entity: '$t(submissions.fields.submission)' },
);
}
if (submission.user.id !== user.id) {
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,
);
if (!questionStats.current) {
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result: submission.result
? {
score: submission.result.score,
probability: submission.result.probability,
title: submission.result.resultRange?.title,
description: submission.result.resultRange?.description,
}
: null,
});
}
this.checkAnswerCorrectness(questionStats.current, answerSubmissionDto);
const submissionAnswer = this.submissionAnswerRepository.create({
submission,
question: questionStats.current,
});
if (
questionStats.current.type === QuestionType.MULTI_CHOICE &&
answerSubmissionDto.choiceId
) {
const choice = await this.choicesService.findOneForAdmin(
answerSubmissionDto.choiceId,
);
if (!choice) {
throw new AppException(
'validation.wrongField',
HttpStatus.BAD_REQUEST,
{ field: '$t(submissions.fields.choiceId)' },
);
}
submissionAnswer.choice = choice;
} else if (
questionStats.current.type === QuestionType.NUMERIC &&
answerSubmissionDto.numericValue
) {
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
}
try {
await this.submissionAnswerRepository.save(submissionAnswer);
} catch (err) {
throw new AppTimeoutException(err);
}
if (!questionStats.next) {
const result = await this.calculateResultProvider.calculate(submission);
return new AppResponse({
isCompleted: true,
currentQuestion: null,
result,
});
}
const nextQuestion = questionStats.next;
return new AppResponse({
isCompleted: false,
isFinal:
nextQuestion.id === allQuestions[allQuestions.length - 1].id
? true
: undefined,
currentQuestion: {
type: nextQuestion.type,
content: nextQuestion.content,
required: nextQuestion.required,
metadata: nextQuestion.metadata,
choices: nextQuestion.choices,
},
});
}
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}`,
);
return submission;
}
private getQuestionStats(
allQuestions: Array<Question>,
answeredQuestions: Array<Question>,
) {
const questionStats: Record<'prev' | 'current' | 'next', Question | null> =
{
prev: null,
current: null,
next: null,
};
if (allQuestions.length === 0) {
return questionStats;
}
if (answeredQuestions.length > 0) {
questionStats.prev = answeredQuestions[answeredQuestions.length - 1];
}
if (!questionStats.prev) {
questionStats.current = allQuestions[0];
} else {
const prevQuestionIdx = allQuestions.findIndex(
(val) => val.id === questionStats.prev?.id,
);
questionStats.current = allQuestions[prevQuestionIdx + 1];
}
if (questionStats.current) {
const currentQuestionIdx = allQuestions.findIndex(
(val) => val.id === questionStats.current?.id,
);
questionStats.next = allQuestions[currentQuestionIdx + 1];
}
return questionStats;
}
private checkAnswerCorrectness(
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)' },
);
}
}
}
}
@@ -0,0 +1,114 @@
import { Injectable } from '@nestjs/common';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { SubmissionResult } from '../entities/submission-result.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Submission } from '../entities/submission.entity';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { ResultRangeService } from '@/modules/scoring/providers/result-range.service';
@Injectable()
export class CalculateResultProvider {
constructor(
/**
* Inject SubmissionResult Repository
*/
@InjectRepository(SubmissionResult)
private readonly submissionResultRepository: Repository<SubmissionResult>,
/**
* Inject Questions Service
*/
private readonly questionsService: QuestionsService,
/**
* Inject ResultRange Service
*/
private readonly resultRangeService: ResultRangeService,
) {}
public async calculate(submission: Submission) {
let score = 0;
await Promise.all(
submission.answers.map(async (answer) => {
if (
answer.question.type === QuestionType.MULTI_CHOICE &&
answer.choice
) {
score += answer.question.weight * answer.choice.score;
} else if (
answer.question.type === QuestionType.NUMERIC &&
answer.numericValue !== undefined
) {
const question = await this.questionsService.findOneForAdmin(
answer.question.id,
);
if (question.rangeRules && question.rangeRules.length > 0) {
question.rangeRules.forEach((rule) => {
if (
answer.numericValue &&
answer.numericValue >= rule.min &&
answer.numericValue <= rule.max
) {
score += answer.question.weight * rule.score;
}
});
}
}
}),
);
let totalScores = 0;
submission.test.questions.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 this.resultRangeService.findByOptions({
where: {
minScore: LessThanOrEqual(score),
maxScore: MoreThanOrEqual(score),
},
});
const submissionResult = this.submissionResultRepository.create({
submission,
score,
probability: Math.ceil((score / totalScores) * 100),
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
});
try {
await this.submissionResultRepository.save(submissionResult);
} catch (err) {
throw new AppTimeoutException(err);
}
return {
score,
probability: submissionResult.probability,
title: submissionResult.resultRange?.title,
description: submissionResult.resultRange?.description,
};
}
}
@@ -0,0 +1,142 @@
import { AccessType } from '@/modules/tests/enums/access-types.enum';
import { TestsService } from '@/modules/tests/providers/tests.service';
import { User } from '@/modules/users/entities/user.entity';
import {
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Repository } from 'typeorm';
import { Submission } from '../entities/submission.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppResponse } from '@/common/responses';
import { SubmissionStatus } from '../enums/submission-status.enum';
import { Test } from '@/modules/tests/entities/test.entity';
import { randomUUID } from 'crypto';
import { RedisService } from '@/common/modules/redis/providers/redis.service';
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces';
@Injectable()
export class StartSubmissionProvider {
constructor(
/**
* Inject Tests Service
*/
private readonly testsService: TestsService,
/**
* Inject Submissions Repository
*/
@InjectRepository(Submission)
private readonly submissionsRepository: Repository<Submission>,
/**
* Inject Redis Service
*/
private readonly redisService: RedisService,
) {}
public async start(testId: number, user?: User) {
const test = await this.testsService.adminGetOne(testId, {
relations: {
questions: {
choices: true,
},
},
order: {
questions: {
order: 'ASC',
choices: {
order: 'ASC',
},
},
},
select: {
questions: {
id: true,
order: true,
type: true,
content: true,
required: true,
metadata: true,
choices: {
id: true,
order: true,
text: true,
},
},
},
});
if (test.accessType !== AccessType.FREE && !user) {
throw new ForbiddenException('auth.errors.accessDenied');
}
if (user) {
return await this.startAuthenticated(test, user);
}
return await this.startGuest(test);
}
private async startAuthenticated(test: Test, user: User) {
let submission: Submission | null = null;
try {
submission = await this.submissionsRepository.findOne({
where: {
test: {
id: test.id,
},
user: {
id: user.id,
},
},
});
} catch (err) {
throw new AppTimeoutException(err);
}
if (submission && submission.status === SubmissionStatus.COMPLETED) {
throw new BadRequestException('submissions.errors.alreadyTookTest');
}
if (!submission) {
submission = this.submissionsRepository.create({
test,
user: user || undefined,
});
try {
await this.submissionsRepository.save(submission);
} catch (err) {
throw new AppTimeoutException(err);
}
}
return new AppResponse({
submissionId: submission.id,
question: test.questions.length > 0 ? test.questions[0] : null,
});
}
private async startGuest(test: Test) {
const guestSubmissionId = randomUUID();
await this.redisService.set<GuestSubmissionSession>(
`guest_submission:${guestSubmissionId}`,
{
testId: test.id,
currentQuestion: 0,
answers: [],
},
3600,
);
return new AppResponse({
guestSubmissionId,
question: test.questions[0],
});
}
}
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { StartSubmissionProvider } from './start-submission.provider';
import { User } from '@/modules/users/entities/user.entity';
import { AnswerSubmissionProvider } from './answer-submission.provider';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
@Injectable()
export class SubmissionsService {
constructor(
/**
* Inject Start Submission Provider
*/
private readonly startSubmissionProvider: StartSubmissionProvider,
/**
* Inject Answer Submission Provider
*/
private readonly answerSubmissionProvider: AnswerSubmissionProvider,
) {}
public async startSubmission(testId: number, user?: User) {
return await this.startSubmissionProvider.start(testId, user);
}
public async answerSubmission(
id: string,
answerSubmissionDto: AnswerSubmissionDTO,
user?: User,
) {
return await this.answerSubmissionProvider.answer(
id,
answerSubmissionDto,
user,
);
}
}