feat: add user submission functionality

This commit is contained in:
2026-07-05 21:45:45 +03:30
parent afb5045cf0
commit 7b44542ce7
48 changed files with 1459 additions and 55 deletions
@@ -0,0 +1,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);
}
}
+11 -1
View File
@@ -1,8 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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({
imports: [TypeOrmModule.forFeature([ResultRange])],
imports: [TypeOrmModule.forFeature([ResultRange]), TestsModule],
providers: [
ResultRangeCommandProvider,
ResultRangeQueryProvider,
ResultRangeService,
],
exports: [ResultRangeService],
})
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.'
);
}
}