feat: add user submission functionality
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
||||||
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';
|
||||||
@@ -17,16 +16,22 @@ export class RedisService {
|
|||||||
return this.redis;
|
return this.redis;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, val: string, ttl?: number) {
|
async set<T>(key: string, val: T, ttl?: number) {
|
||||||
|
const payload = JSON.stringify(val);
|
||||||
|
|
||||||
if (ttl) {
|
if (ttl) {
|
||||||
await this.redis.set(key, val, 'EX', ttl);
|
await this.redis.set(key, payload, 'EX', ttl);
|
||||||
} else {
|
} else {
|
||||||
await this.redis.set(key, val);
|
await this.redis.set(key, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(key: string) {
|
async get<T>(key: string): Promise<T | null> {
|
||||||
return await this.redis.get(key);
|
const value = await this.redis.get(key);
|
||||||
|
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
return JSON.parse(value) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async del(key: string) {
|
async del(key: string) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"resultRange": "Result Range"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"resultRangeOverlappingConstraint": "The specified score range has some overlaps with other records.",
|
||||||
|
"resultRangeMinMaxConstraint": "minScore value should be less than maxScore."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"testId": "Test's Id",
|
||||||
|
"questionId": "Question's Id",
|
||||||
|
"submission": "Submission",
|
||||||
|
"submissionId": "Submission's Id",
|
||||||
|
"choiceId": "Choice's Id"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"alreadyTookTest": "You already took this test.",
|
||||||
|
"choiceNumericConstraint": "You must send either choiceId or numericValue.",
|
||||||
|
"emptyNumericValue": "A numeric value should be provided as answer.",
|
||||||
|
"numericValueRange": "Numeric value is out of specified range.",
|
||||||
|
"emptyChoiceId": "Choice Id should be provided as answer."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"resultRange": "بازه نتیجه"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"resultRangeOverlappingConstraint": "بازه امتیاز مشخص شده با بازه های دیگر تداخل دارد.",
|
||||||
|
"resultRangeMinMaxConstraint": "minScore باید کمتر از maxScore باشد."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"testId": "شناسه آزمون",
|
||||||
|
"questionId": "شناسه سوال",
|
||||||
|
"submission": "Submission",
|
||||||
|
"submissionId": "شناسه Submission",
|
||||||
|
"choiceId": "شناسه گزینه"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
|
||||||
|
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
||||||
|
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
||||||
|
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
||||||
|
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { AdminAuthService } from './auth/providers/admin-auth.service';
|
|||||||
import { QuestionsModule } from '../questions/questions.module';
|
import { QuestionsModule } from '../questions/questions.module';
|
||||||
import { AdminQuestionsController } from './questions/admin-questions.controller';
|
import { AdminQuestionsController } from './questions/admin-questions.controller';
|
||||||
import { AdminChoicesController } from './choices/admin-choices.controller';
|
import { AdminChoicesController } from './choices/admin-choices.controller';
|
||||||
|
import { ScoringModule } from '../scoring/scoring.module';
|
||||||
|
import { AdminScoringController } from './scoring/admin-scoring.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,12 +19,14 @@ import { AdminChoicesController } from './choices/admin-choices.controller';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
HashingModule,
|
HashingModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
ScoringModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminTestsController,
|
AdminTestsController,
|
||||||
AdminAuthController,
|
AdminAuthController,
|
||||||
AdminQuestionsController,
|
AdminQuestionsController,
|
||||||
AdminChoicesController,
|
AdminChoicesController,
|
||||||
|
AdminScoringController,
|
||||||
],
|
],
|
||||||
providers: [AdminAuthService],
|
providers: [AdminAuthService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
GET {{baseUrl}}/admin/choices/
|
GET {{baseUrl}}/admin/choices/
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
GET {{baseUrl}}/admin/choices/1
|
GET {{baseUrl}}/admin/choices/3
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
PATCH {{baseUrl}}/admin/choices/1
|
PATCH {{baseUrl}}/admin/choices/3
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"question": 4
|
"text": "Option 1",
|
||||||
|
"order": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"question": 4,
|
"question": 4,
|
||||||
"text": "Option 2",
|
"text": "Option 2",
|
||||||
"value": 2,
|
"value": 1,
|
||||||
"order": 2
|
"order": 2,
|
||||||
|
"score": 2
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
GET {{baseUrl}}/admin/questions
|
GET {{baseUrl}}/admin/questions
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
GET {{baseUrl}}/admin/questions/1
|
GET {{baseUrl}}/admin/questions/4
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
GET {{baseUrl}}/admin/questions/range-rules?limit=1&page=2&sortBy=id&order=ASC
|
GET {{baseUrl}}/admin/questions/range-rules?limit=1&page=2&sortBy=id&order=ASC
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
PATCH {{baseUrl}}/admin/questions/4
|
PATCH {{baseUrl}}/admin/questions/1
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"test": 4
|
"type": "multi-choice"
|
||||||
}
|
}
|
||||||
|
|
||||||
PATCH {{baseUrl}}/admin/questions/range-rules/1
|
PATCH {{baseUrl}}/admin/questions/range-rules/1
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Role } from '@/common/enums/roles.enum';
|
||||||
|
import { Roles } from '@/modules/auth/decorators/roles.decorator';
|
||||||
|
import { CreateResultRangeDTO } from '@/modules/scoring/dtos/create-result-range.dto';
|
||||||
|
import { FindResultRangeDTO } from '@/modules/scoring/dtos/find-result-range.dto';
|
||||||
|
import { UpdateResultRangeDTO } from '@/modules/scoring/dtos/update-result-range.dto';
|
||||||
|
import { ResultRangeService } from '@/modules/scoring/providers/result-range.service';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('admin/scoring')
|
||||||
|
@Roles(Role.SUPERUSER, Role.ADMIN)
|
||||||
|
export class AdminScoringController {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject ResultRange Service
|
||||||
|
*/
|
||||||
|
private readonly resultRangeService: ResultRangeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('result-ranges')
|
||||||
|
public async createResultRanges(
|
||||||
|
@Body() createResultRangeDto: CreateResultRangeDTO,
|
||||||
|
) {
|
||||||
|
return await this.resultRangeService.createOne(createResultRangeDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('result-ranges')
|
||||||
|
public async getAllResultRanges(@Query() query: FindResultRangeDTO) {
|
||||||
|
return await this.resultRangeService.findAllForAdmin(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('result-ranges/:id')
|
||||||
|
public async getResultRangeById(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return await this.resultRangeService.findOneForAdmin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('result-ranges/:id')
|
||||||
|
public async updateResultRange(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updateResultRangeDto: UpdateResultRangeDTO,
|
||||||
|
) {
|
||||||
|
return await this.resultRangeService.updateOne(id, updateResultRangeDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('result-ranges/:id')
|
||||||
|
public async deleteResultRange(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return await this.resultRangeService.deleteOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
GET {{baseUrl}}/admin/scoring/result-ranges/
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
POST {{baseUrl}}/admin/scoring/result-ranges/
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"test": 4,
|
||||||
|
"title": "احتمال خیلی بالا",
|
||||||
|
"description": "احتمال ابتلا خیلی بالا است.",
|
||||||
|
"minScore": 21,
|
||||||
|
"maxScore": 40
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { User } from '@/modules/users/entities/user.entity';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalJwtGuard extends AuthGuard('jwt') {
|
||||||
|
handleRequest<TUser = User>(err: any, user: TUser): TUser | null {
|
||||||
|
return user ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ Content-Type: application/json
|
|||||||
lang: fa
|
lang: fa
|
||||||
|
|
||||||
{
|
{
|
||||||
"phone": "09121111111",
|
"phone": "09333026363",
|
||||||
"password": "Password1@"
|
"password": "Password1@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { LessThan, MoreThanOrEqual, Repository } from 'typeorm';
|
import { LessThan, MoreThanOrEqual, Repository } from 'typeorm';
|
||||||
import { OTP } from '../entities/otp.entity';
|
import { OTP } from '../entities/otp.entity';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider';
|
import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider';
|
||||||
import { AppException } from '@/common/exceptions/app.exception';
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
@@ -86,7 +86,7 @@ export class OtpService {
|
|||||||
return otp;
|
return otp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron('0 */10 * * * *')
|
@Cron(CronExpression.EVERY_10_MINUTES)
|
||||||
public async cleanupExpired(): Promise<void> {
|
public async cleanupExpired(): Promise<void> {
|
||||||
await this.otpRepository.delete({
|
await this.otpRepository.delete({
|
||||||
expiresAt: LessThan(new Date()),
|
expiresAt: LessThan(new Date()),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class Question {
|
|||||||
content!: string;
|
content!: string;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: false })
|
@Column({ type: 'int', nullable: false })
|
||||||
|
@Index()
|
||||||
order!: number;
|
order!: number;
|
||||||
|
|
||||||
@Column({ type: 'int', default: 1 })
|
@Column({ type: 'int', default: 1 })
|
||||||
@@ -51,7 +52,7 @@ export class Question {
|
|||||||
metadata!: QuestionMetadata;
|
metadata!: QuestionMetadata;
|
||||||
|
|
||||||
@OneToMany(() => Choice, (choice) => choice.question)
|
@OneToMany(() => Choice, (choice) => choice.question)
|
||||||
choices?: Array<Choice>;
|
choices!: Array<Choice>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|||||||
@@ -35,14 +35,6 @@ 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);
|
||||||
|
|
||||||
if (!test) {
|
|
||||||
throw new AppException(
|
|
||||||
'common.errors.entityNotFound',
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
{ entity: '$t(tests.fields.test)' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newQuestion = this.questionsRepository.create({
|
const newQuestion = this.questionsRepository.create({
|
||||||
...createQuestionDto,
|
...createQuestionDto,
|
||||||
test,
|
test,
|
||||||
@@ -81,15 +73,7 @@ export class QuestionsCommandProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updateQuestionDto.test) {
|
if (updateQuestionDto.test) {
|
||||||
const test = await this.testsService.adminGetOne(updateQuestionDto.test);
|
await this.testsService.adminGetOne(updateQuestionDto.test);
|
||||||
|
|
||||||
if (!test) {
|
|
||||||
throw new AppException(
|
|
||||||
'common.errors.entityNotFound',
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
{ entity: '$t(tests.fields.test)' },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const type =
|
const type =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { filterQuestions } from '../helpers/filter-questions.helper';
|
import { filterQuestions } from '../helpers/filter-questions.helper';
|
||||||
import Utils from '@/common/utils';
|
import Utils from '@/common/utils';
|
||||||
import { FindQuestionsDTO } from '../dtos/find-questions.dto';
|
import { FindQuestionsDTO } from '../dtos/find-questions.dto';
|
||||||
@@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Question } from '../entities/question.entity';
|
import { Question } from '../entities/question.entity';
|
||||||
import { Repository } from 'typeorm';
|
import { 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';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QuestionsQueryProvider {
|
export class QuestionsQueryProvider {
|
||||||
@@ -33,13 +34,13 @@ export class QuestionsQueryProvider {
|
|||||||
let selectedTest: Test | null = null;
|
let selectedTest: Test | null = null;
|
||||||
|
|
||||||
if (query.test) {
|
if (query.test) {
|
||||||
try {
|
selectedTest = await this.testsService.adminGetOne(query.test, {
|
||||||
selectedTest = await this.testsService.adminGetOne(query.test);
|
relations: { questions: true },
|
||||||
} catch (err) {
|
});
|
||||||
throw new AppTimeoutException(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(selectedTest);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [questions, total] = await this.questionsRepository.findAndCount({
|
const [questions, total] = await this.questionsRepository.findAndCount({
|
||||||
where: {
|
where: {
|
||||||
@@ -69,13 +70,25 @@ export class QuestionsQueryProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async findOneForAdmin(id: number) {
|
public async findOneForAdmin(id: number) {
|
||||||
|
let question: Question | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.questionsRepository.findOne({
|
question = await this.questionsRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: { test: true },
|
relations: { test: true, rangeRules: true, choices: true },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new AppTimeoutException(err);
|
throw new AppTimeoutException(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
throw new AppException(
|
||||||
|
'common.errors.entityNotFound',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
{ entity: '$t(questions.fields.question)' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return question;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Validate } from 'class-validator';
|
||||||
|
import { ResultRangeValidator } from '../validators/result-range.validator';
|
||||||
|
|
||||||
|
export const IsValidRange = () => {
|
||||||
|
return Validate(ResultRangeValidator);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||||
|
import { IsValidRange } from '../decorators/is-valid-range.decorator';
|
||||||
|
|
||||||
|
export class CreateResultRangeDTO {
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
test!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
minScore!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty()
|
||||||
|
maxScore!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@IsValidRange()
|
||||||
|
private readonly _resultRangeValidation!: never;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PaginationQueryDTO } from '@/common/dtos/pagination-query.dto';
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ResultRangeSortField } from '../enums/result-range-sort-field.enum';
|
||||||
|
|
||||||
|
export class FindResultRangeDTO extends PartialType(PaginationQueryDTO) {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
id?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
test?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
minScore?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
maxScore?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsEnum(ResultRangeSortField)
|
||||||
|
@IsOptional()
|
||||||
|
sortBy?: ResultRangeSortField;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateResultRangeDTO } from './create-result-range.dto';
|
||||||
|
|
||||||
|
export class UpdateResultRangeDTO extends PartialType(CreateResultRangeDTO) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum ResultRangeSortField {
|
||||||
|
ID = 'id',
|
||||||
|
TEST = 'test',
|
||||||
|
MIN_SCORE = 'minScore',
|
||||||
|
MAX_SCORE = 'maxScore',
|
||||||
|
TITLE = 'title',
|
||||||
|
DESCRIPTION = 'description',
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { FindOptionsWhere, ILike } from 'typeorm';
|
||||||
|
import { FindResultRangeDTO } from '../dtos/find-result-range.dto';
|
||||||
|
import { ResultRange } from '../entities/result-range.entity';
|
||||||
|
|
||||||
|
export const filterResultRanges = (query: FindResultRangeDTO) => {
|
||||||
|
const where: FindOptionsWhere<ResultRange> = {};
|
||||||
|
|
||||||
|
if (query.id) {
|
||||||
|
where.id = query.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.description) {
|
||||||
|
where.description = ILike(`%${query.description}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.title) {
|
||||||
|
where.title = ILike(`%${query.title}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.maxScore !== undefined) {
|
||||||
|
where.maxScore = query.maxScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.minScore !== undefined) {
|
||||||
|
where.minScore = query.minScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ResultRange } from '../entities/result-range.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { CreateResultRangeDTO } from '../dtos/create-result-range.dto';
|
||||||
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
|
import { TestsService } from '@/modules/tests/providers/tests.service';
|
||||||
|
import { AppResponse } from '@/common/responses';
|
||||||
|
import { I18nService } from 'nestjs-i18n';
|
||||||
|
import { UpdateResultRangeDTO } from '../dtos/update-result-range.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResultRangeCommandProvider {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject ResultRange Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(ResultRange)
|
||||||
|
private readonly resultRangeRepository: Repository<ResultRange>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Tests Service
|
||||||
|
*/
|
||||||
|
private readonly testsService: TestsService,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject I18n Service
|
||||||
|
*/
|
||||||
|
private readonly i18nService: I18nService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async checkRangeOverlap(
|
||||||
|
test: number,
|
||||||
|
minScore: number,
|
||||||
|
maxScore: number,
|
||||||
|
excludeSelf: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const queryBuilder = this.resultRangeRepository
|
||||||
|
.createQueryBuilder('resultRange')
|
||||||
|
.where('resultRange.testId = :test', { test })
|
||||||
|
.andWhere(
|
||||||
|
'(resultRange.minScore BETWEEN :minScore AND :maxScore) OR (resultRange.maxScore BETWEEN :minScore AND :maxScore) OR (:minScore BETWEEN resultRange.minScore AND resultRange.maxScore) OR (:maxScore BETWEEN resultRange.minScore AND resultRange.maxScore)',
|
||||||
|
{ minScore, maxScore },
|
||||||
|
);
|
||||||
|
|
||||||
|
let overlappingRanges: Array<ResultRange>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
overlappingRanges = await queryBuilder.getMany();
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlappingRanges.length > (excludeSelf ? 1 : 0)) {
|
||||||
|
throw new AppException(
|
||||||
|
'scoring.errors.resultRangeOverlappingConstraint',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkResultRangeExistance(resultRangeId: number) {
|
||||||
|
let resultRange: ResultRange | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resultRange = await this.resultRangeRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: resultRangeId,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
test: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resultRange) {
|
||||||
|
throw new AppException(
|
||||||
|
'common.errors.entityNotFound',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
{ entity: '$t(scoring.fields.resultRange)' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createOne(createResultRangeDto: CreateResultRangeDTO) {
|
||||||
|
const test = await this.testsService.adminGetOne(createResultRangeDto.test);
|
||||||
|
|
||||||
|
await this.checkRangeOverlap(
|
||||||
|
test.id,
|
||||||
|
createResultRangeDto.minScore,
|
||||||
|
createResultRangeDto.maxScore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newResultRange = this.resultRangeRepository.create({
|
||||||
|
...createResultRangeDto,
|
||||||
|
test,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.resultRangeRepository.save(newResultRange);
|
||||||
|
return new AppResponse(
|
||||||
|
newResultRange,
|
||||||
|
this.i18nService.translate('common.messages.createSuccess', {
|
||||||
|
args: { entity: '$t(scoring.fields.resultRange)' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateOne(
|
||||||
|
id: number,
|
||||||
|
updateResultRangeDto: UpdateResultRangeDTO,
|
||||||
|
) {
|
||||||
|
const resultRange = await this.checkResultRangeExistance(id);
|
||||||
|
|
||||||
|
if (updateResultRangeDto.test) {
|
||||||
|
await this.testsService.adminGetOne(updateResultRangeDto.test);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testId =
|
||||||
|
updateResultRangeDto.test !== undefined
|
||||||
|
? updateResultRangeDto.test
|
||||||
|
: resultRange.test.id;
|
||||||
|
|
||||||
|
const minScore =
|
||||||
|
updateResultRangeDto.minScore !== undefined
|
||||||
|
? updateResultRangeDto.minScore
|
||||||
|
: resultRange.minScore;
|
||||||
|
|
||||||
|
const maxScore =
|
||||||
|
updateResultRangeDto.maxScore !== undefined
|
||||||
|
? updateResultRangeDto.maxScore
|
||||||
|
: resultRange.maxScore;
|
||||||
|
|
||||||
|
if (minScore >= maxScore) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'scoring.errors.resultRangeMinMaxConstraint',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkRangeOverlap(testId, minScore, maxScore, true);
|
||||||
|
|
||||||
|
Object.assign(resultRange, updateResultRangeDto);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.resultRangeRepository.save(resultRange);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteOne(id: number) {
|
||||||
|
const resultRange = await this.checkResultRangeExistance(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.resultRangeRepository.delete(resultRange.id);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { ResultRange } from '../entities/result-range.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { TestsService } from '@/modules/tests/providers/tests.service';
|
||||||
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
|
import { FindResultRangeDTO } from '../dtos/find-result-range.dto';
|
||||||
|
import { filterResultRanges } from '../helpers/filter-result-ranges.helper';
|
||||||
|
import Utils from '@/common/utils';
|
||||||
|
import { Test } from '@/modules/tests/entities/test.entity';
|
||||||
|
import { PaginatedResponse } from '@/common/responses';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResultRangeQueryProvider {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject ResultRange Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(ResultRange)
|
||||||
|
private readonly resultRangeRepository: Repository<ResultRange>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Tests Service
|
||||||
|
*/
|
||||||
|
private readonly testsService: TestsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async findAllForAdmin(query: FindResultRangeDTO) {
|
||||||
|
const filters = filterResultRanges(query);
|
||||||
|
const { limit, page, skip, take } =
|
||||||
|
Utils.PaginationUtils.getPaginationOptions(query);
|
||||||
|
|
||||||
|
let selectedTest: Test | null = null;
|
||||||
|
|
||||||
|
if (query.test) {
|
||||||
|
selectedTest = await this.testsService.adminGetOne(query.test);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [resultRanges, total] =
|
||||||
|
await this.resultRangeRepository.findAndCount({
|
||||||
|
where: {
|
||||||
|
...filters,
|
||||||
|
...(selectedTest && {
|
||||||
|
test: selectedTest,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
loadRelationIds: true,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
order: query.sortBy
|
||||||
|
? {
|
||||||
|
[query.sortBy]: query.order ?? 'ASC',
|
||||||
|
}
|
||||||
|
: { id: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new PaginatedResponse(resultRanges, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOneForAdmin(id: number) {
|
||||||
|
try {
|
||||||
|
return await this.resultRangeRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { test: true },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByOptions(options?: FindManyOptions<ResultRange>) {
|
||||||
|
try {
|
||||||
|
return await this.resultRangeRepository.find(options);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ResultRangeCommandProvider } from './result-range-command.provider';
|
||||||
|
import { CreateResultRangeDTO } from '../dtos/create-result-range.dto';
|
||||||
|
import { UpdateResultRangeDTO } from '../dtos/update-result-range.dto';
|
||||||
|
import { FindResultRangeDTO } from '../dtos/find-result-range.dto';
|
||||||
|
import { ResultRangeQueryProvider } from './result-range-query.provider';
|
||||||
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ResultRange } from '../entities/result-range.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResultRangeService {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Inject ResultRangeCommand Provider
|
||||||
|
*/
|
||||||
|
private readonly resultRangeCommandProvider: ResultRangeCommandProvider,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject ResultRangeQuery Provider
|
||||||
|
*/
|
||||||
|
private readonly resultRangeQueryProvider: ResultRangeQueryProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async createOne(createResultRangeDto: CreateResultRangeDTO) {
|
||||||
|
return await this.resultRangeCommandProvider.createOne(
|
||||||
|
createResultRangeDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateOne(
|
||||||
|
id: number,
|
||||||
|
updateResultRangeDto: UpdateResultRangeDTO,
|
||||||
|
) {
|
||||||
|
return await this.resultRangeCommandProvider.updateOne(
|
||||||
|
id,
|
||||||
|
updateResultRangeDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteOne(id: number) {
|
||||||
|
return await this.resultRangeCommandProvider.deleteOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findAllForAdmin(query: FindResultRangeDTO) {
|
||||||
|
return await this.resultRangeQueryProvider.findAllForAdmin(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOneForAdmin(id: number) {
|
||||||
|
return await this.resultRangeQueryProvider.findOneForAdmin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByOptions(options: FindManyOptions<ResultRange>) {
|
||||||
|
return await this.resultRangeQueryProvider.findByOptions(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ResultRange } from './entities/result-range.entity';
|
import { ResultRange } from './entities/result-range.entity';
|
||||||
|
import { TestsModule } from '../tests/tests.module';
|
||||||
|
import { ResultRangeCommandProvider } from './providers/result-range-command.provider';
|
||||||
|
import { ResultRangeService } from './providers/result-range.service';
|
||||||
|
import { ResultRangeQueryProvider } from './providers/result-range-query.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ResultRange])],
|
imports: [TypeOrmModule.forFeature([ResultRange]), TestsModule],
|
||||||
|
providers: [
|
||||||
|
ResultRangeCommandProvider,
|
||||||
|
ResultRangeQueryProvider,
|
||||||
|
ResultRangeService,
|
||||||
|
],
|
||||||
|
exports: [ResultRangeService],
|
||||||
})
|
})
|
||||||
export class ScoringModule {}
|
export class ScoringModule {}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { I18nContext } from 'nestjs-i18n';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'QuestionRange', async: false })
|
||||||
|
export class ResultRangeValidator 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 { minScore, maxScore } = obj;
|
||||||
|
return minScore < maxScore;
|
||||||
|
}
|
||||||
|
defaultMessage?(): string {
|
||||||
|
const i18n = I18nContext.current();
|
||||||
|
|
||||||
|
return (
|
||||||
|
i18n?.t('scoring.errors.resultRangeMinMaxConstraint') ||
|
||||||
|
'minScore value should be less than maxScore.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@Index(['submission', 'question'], { unique: true })
|
||||||
export class SubmissionAnswer {
|
export class SubmissionAnswer {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Submission } from './submission.entity';
|
import { Submission } from './submission.entity';
|
||||||
@@ -15,7 +17,8 @@ export class SubmissionResult {
|
|||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@ManyToOne(() => Submission, { onDelete: 'CASCADE' })
|
@OneToOne(() => Submission, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn()
|
||||||
submission!: Submission;
|
submission!: Submission;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: false })
|
@Column({ type: 'int', nullable: false })
|
||||||
@@ -24,8 +27,8 @@ export class SubmissionResult {
|
|||||||
@Column({ type: 'int', nullable: false })
|
@Column({ type: 'int', nullable: false })
|
||||||
probability!: number;
|
probability!: number;
|
||||||
|
|
||||||
@ManyToOne(() => ResultRange, { onDelete: 'CASCADE' })
|
@ManyToOne(() => ResultRange, { nullable: true, onDelete: 'CASCADE' })
|
||||||
resultRange!: ResultRange;
|
resultRange?: ResultRange;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||||
import { SubmissionAnswer } from './submission-answer.entity';
|
import { SubmissionAnswer } from './submission-answer.entity';
|
||||||
|
import { SubmissionResult } from './submission-result.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Submission {
|
export class Submission {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
user?: User;
|
user!: User;
|
||||||
|
|
||||||
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
||||||
test!: Test;
|
test!: Test;
|
||||||
@@ -32,6 +34,9 @@ export class Submission {
|
|||||||
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
||||||
answers!: Array<SubmissionAnswer>;
|
answers!: Array<SubmissionAnswer>;
|
||||||
|
|
||||||
|
@OneToOne(() => SubmissionResult, (result) => result.submission)
|
||||||
|
result?: SubmissionResult;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
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 { 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 { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Submission, SubmissionAnswer, SubmissionResult]),
|
TypeOrmModule.forFeature([Submission, SubmissionAnswer, SubmissionResult]),
|
||||||
|
TestsModule,
|
||||||
|
QuestionsModule,
|
||||||
|
RedisModule,
|
||||||
|
ScoringModule,
|
||||||
|
],
|
||||||
|
controllers: [SubmissionsController],
|
||||||
|
providers: [
|
||||||
|
StartSubmissionProvider,
|
||||||
|
AnswerSubmissionProvider,
|
||||||
|
CalculateResultProvider,
|
||||||
|
SubmissionsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SubmissionsModule {}
|
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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { FindOneOptions, Repository } from 'typeorm';
|
||||||
import { PaginatedResponse } from '@/common/responses';
|
import { PaginatedResponse } from '@/common/responses';
|
||||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
import { FindTestsDTO } from '../dtos/find-tests.dto';
|
import { FindTestsDTO } from '../dtos/find-tests.dto';
|
||||||
@@ -64,12 +64,27 @@ export class TestsQueryProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findOneForAdmin(id: number) {
|
public async findOneForAdmin(id: number, options?: FindOneOptions<Test>) {
|
||||||
|
let test: Test | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.testRepository.findOneBy({ id });
|
test = await this.testRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new AppTimeoutException(err);
|
throw new AppTimeoutException(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!test) {
|
||||||
|
throw new AppException(
|
||||||
|
'common.errors.entityNotFound',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
{ entity: '$t(tests.fields.test)' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return test;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findOneForUser(id: number) {
|
public async findOneForUser(id: number) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { UpdateTestDTO } from '../dtos/update-test.dto';
|
|||||||
import { FindTestsDTO } from '../dtos/find-tests.dto';
|
import { FindTestsDTO } from '../dtos/find-tests.dto';
|
||||||
import { TestsQueryProvider } from './tests-query.provider';
|
import { TestsQueryProvider } from './tests-query.provider';
|
||||||
import { TestsCommandProvider } from './tests-command.provider';
|
import { TestsCommandProvider } from './tests-command.provider';
|
||||||
|
import { FindOneOptions } from 'typeorm';
|
||||||
|
import { Test } from '../entities/test.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TestsService {
|
export class TestsService {
|
||||||
@@ -31,8 +33,8 @@ export class TestsService {
|
|||||||
return await this.testsQueryProvider.findAllForUser();
|
return await this.testsQueryProvider.findAllForUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async adminGetOne(id: number) {
|
public async adminGetOne(id: number, options?: FindOneOptions<Test>) {
|
||||||
return await this.testsQueryProvider.findOneForAdmin(id);
|
return await this.testsQueryProvider.findOneForAdmin(id, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async userGetOne(id: number) {
|
public async userGetOne(id: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user