feat: add user submission functionality
This commit is contained in:
@@ -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 { 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user