feat: add user submission functionality
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { Validate } from 'class-validator';
|
||||
import { SubmissionAnswerValidator } from '../validators/submission-answer.validator';
|
||||
|
||||
export const SubmissionAnswer = () => {
|
||||
return Validate(SubmissionAnswerValidator);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsNumber, IsOptional } from 'class-validator';
|
||||
import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
|
||||
|
||||
export class AnswerSubmissionDTO {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
choiceId?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
numericValue?: number;
|
||||
|
||||
@SubmissionAnswer()
|
||||
private readonly _submissionAnswerValidation!: never;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsNotEmpty, IsNumber } from 'class-validator';
|
||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||
|
||||
export class StartSubmissionDTO {
|
||||
@IsNumber()
|
||||
@IsNotEmpty({
|
||||
message: t('validation.requiredField', {
|
||||
field: '$t(submissions.fields.testId)',
|
||||
}),
|
||||
})
|
||||
testId!: number;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Submission } from './submission.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { Choice } from '@/modules/questions/entities/choice.entity';
|
||||
|
||||
@Entity()
|
||||
@Index(['submission', 'question'], { unique: true })
|
||||
export class SubmissionAnswer {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Submission } from './submission.entity';
|
||||
@@ -15,7 +17,8 @@ export class SubmissionResult {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@ManyToOne(() => Submission, { onDelete: 'CASCADE' })
|
||||
@OneToOne(() => Submission, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
submission!: Submission;
|
||||
|
||||
@Column({ type: 'int', nullable: false })
|
||||
@@ -24,8 +27,8 @@ export class SubmissionResult {
|
||||
@Column({ type: 'int', nullable: false })
|
||||
probability!: number;
|
||||
|
||||
@ManyToOne(() => ResultRange, { onDelete: 'CASCADE' })
|
||||
resultRange!: ResultRange;
|
||||
@ManyToOne(() => ResultRange, { nullable: true, onDelete: 'CASCADE' })
|
||||
resultRange?: ResultRange;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -6,18 +6,20 @@ import {
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||
import { SubmissionAnswer } from './submission-answer.entity';
|
||||
import { SubmissionResult } from './submission-result.entity';
|
||||
|
||||
@Entity()
|
||||
export class Submission {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
|
||||
user?: User;
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
||||
test!: Test;
|
||||
@@ -32,6 +34,9 @@ export class Submission {
|
||||
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
||||
answers!: Array<SubmissionAnswer>;
|
||||
|
||||
@OneToOne(() => SubmissionResult, (result) => result.submission)
|
||||
result?: SubmissionResult;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
POST {{baseUrl}}/submissions/start
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"testId": 4
|
||||
}
|
||||
|
||||
POST {{baseUrl}}/submissions/10/answer
|
||||
Authorization: Bearer {{$global.accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
// "choiceId": 1
|
||||
"numericValue": 60
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface GuestSubmissionSession {
|
||||
testId: number;
|
||||
currentQuestion: number;
|
||||
answers: Array<{
|
||||
questionId: number;
|
||||
choiceId: number;
|
||||
numericValue: number;
|
||||
}>;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { OptionalJwtGuard } from '../auth/guards/optional-jwt.guard';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { ActiveUser } from '@/common/decorators/active-user.decorator';
|
||||
import { SubmissionsService } from './providers/submissions.service';
|
||||
import { StartSubmissionDTO } from './dtos/start-submission.dto';
|
||||
import { AnswerSubmissionDTO } from './dtos/answer-submission.dto';
|
||||
|
||||
@Controller('submissions')
|
||||
export class SubmissionsController {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Submissions Service
|
||||
*/
|
||||
private readonly submissionsService: SubmissionsService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('start')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
public async startTest(
|
||||
@Body() startSubmissionDto: StartSubmissionDTO,
|
||||
@ActiveUser() user?: User,
|
||||
) {
|
||||
return await this.submissionsService.startSubmission(
|
||||
startSubmissionDto.testId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post(':id/answer')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
public async answerSubmission(
|
||||
@Param('id') id: string,
|
||||
@Body() answerSubmissionDto: AnswerSubmissionDTO,
|
||||
@ActiveUser() user?: User,
|
||||
) {
|
||||
return await this.submissionsService.answerSubmission(
|
||||
id,
|
||||
answerSubmissionDto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,30 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Submission } from './entities/submission.entity';
|
||||
import { SubmissionAnswer } from './entities/submission-answer.entity';
|
||||
import { SubmissionResult } from './entities/submission-result.entity';
|
||||
import { SubmissionsController } from './submissions.controller';
|
||||
import { StartSubmissionProvider } from './providers/start-submission.provider';
|
||||
import { SubmissionsService } from './providers/submissions.service';
|
||||
import { TestsModule } from '../tests/tests.module';
|
||||
import { AnswerSubmissionProvider } from './providers/answer-submission.provider';
|
||||
import { RedisModule } from '@/common/modules/redis/redis.module';
|
||||
import { QuestionsModule } from '../questions/questions.module';
|
||||
import { CalculateResultProvider } from './providers/calculate-result.provider';
|
||||
import { ScoringModule } from '../scoring/scoring.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Submission, SubmissionAnswer, SubmissionResult]),
|
||||
TestsModule,
|
||||
QuestionsModule,
|
||||
RedisModule,
|
||||
ScoringModule,
|
||||
],
|
||||
controllers: [SubmissionsController],
|
||||
providers: [
|
||||
StartSubmissionProvider,
|
||||
AnswerSubmissionProvider,
|
||||
CalculateResultProvider,
|
||||
SubmissionsService,
|
||||
],
|
||||
})
|
||||
export class SubmissionsModule {}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { I18nContext } from 'nestjs-i18n';
|
||||
|
||||
@ValidatorConstraint({ name: 'SubmissionAnswer', async: false })
|
||||
export class SubmissionAnswerValidator implements ValidatorConstraintInterface {
|
||||
validate(
|
||||
value: any,
|
||||
validationArguments?: ValidationArguments,
|
||||
): Promise<boolean> | boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const obj = validationArguments?.object as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { choiceId, numericValue } = obj;
|
||||
|
||||
return choiceId !== undefined || numericValue !== undefined;
|
||||
}
|
||||
|
||||
defaultMessage?(): string {
|
||||
const i18n = I18nContext.current();
|
||||
|
||||
return (
|
||||
i18n?.t('submissions.errors.choiceNumericConstraint') ??
|
||||
'You must send either choiceId or numericValue.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user