feat: seperate single and multi choice questions
This commit is contained in:
@@ -8,3 +8,4 @@ export const UNIQUE_QUESTION_END = 'UNQ_QUESTION_END';
|
||||
export const UNIQUE_TEST_START = 'UNQ_TEST_START';
|
||||
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
||||
'EX_QUESTION_NUMBER_RANGE_OVERLAP';
|
||||
export const UNIQUE_QUESTION_MULTI_CHOICE = 'UNQ_QUESTION_MULTI_CHOICE';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
import { QuestionType } from '../enums/question-type.enum';
|
||||
import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto';
|
||||
import { SingleChoiceMetadataDTO } from './single-choice-metadata.dto';
|
||||
import { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||
@@ -60,5 +60,5 @@ export class CreateQuestionDTO {
|
||||
|
||||
@Validate(QuestionMetadataValidator)
|
||||
@IsOptional()
|
||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
||||
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class MultiChoiceMetadataDTO {
|
||||
export class SingleChoiceMetadataDTO {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allowOther?: boolean;
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'class-validator';
|
||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||
import { QuestionType } from '../enums/question-type.enum';
|
||||
import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto';
|
||||
import { SingleChoiceMetadataDTO } from './single-choice-metadata.dto';
|
||||
import { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||
|
||||
export class UpdateQuestionDTO {
|
||||
@@ -48,5 +48,5 @@ export class UpdateQuestionDTO {
|
||||
required?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
||||
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export class Question {
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: QuestionType,
|
||||
default: QuestionType.MULTI_CHOICE,
|
||||
default: QuestionType.SINGLE_CHOICE,
|
||||
})
|
||||
type!: QuestionType;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MultiChoiceTConditionOperator {
|
||||
ANY = 'any',
|
||||
ALL = 'all',
|
||||
EXACT = 'exact',
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export enum QuestionTransitionType {
|
||||
START = 'start',
|
||||
DEFAULT = 'default',
|
||||
CHOICE = 'choice',
|
||||
MULTI_CHOICE = 'multi-choice',
|
||||
NUMBER_RANGE = 'number-range',
|
||||
END = 'end',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum QuestionType {
|
||||
SINGLE_CHOICE = 'single-choice',
|
||||
MULTI_CHOICE = 'multi-choice',
|
||||
NUMERIC = 'numeric',
|
||||
TEXT = 'text',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { plainToInstance } from 'class-transformer';
|
||||
import { QuestionType } from '../enums/question-type.enum';
|
||||
import { QuestionMetadata } from '../types/question-metadata.type';
|
||||
import { NumericMetadataDTO } from '../dtos/numeric-metadata.dto';
|
||||
import { MultiChoiceMetadataDTO } from '../dtos/multi-choice-metadata.dto';
|
||||
import { SingleChoiceMetadataDTO } from '../dtos/single-choice-metadata.dto';
|
||||
import { validate } from 'class-validator';
|
||||
import { ValidationError } from '@nestjs/common';
|
||||
|
||||
@@ -22,8 +22,8 @@ export const validateMetadata = async (
|
||||
break;
|
||||
|
||||
default:
|
||||
case QuestionType.MULTI_CHOICE:
|
||||
validateDto = plainToInstance(MultiChoiceMetadataDTO, metadata);
|
||||
case QuestionType.SINGLE_CHOICE:
|
||||
validateDto = plainToInstance(SingleChoiceMetadataDTO, metadata);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { MultiChoiceTConditionOperator } from '../enums/multi-choice-tcondition-operator.enum';
|
||||
|
||||
export interface MultiChoiceCondition {
|
||||
choiceIds: number[];
|
||||
operator: MultiChoiceTConditionOperator;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface MultiChoiceMetadata {
|
||||
allowOther?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SingleChoiceMetadata {
|
||||
allowOther?: boolean;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { UpdateQuestionTransitionDTO } from '../../dtos/update-question-transition.dto';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { QuestionTransitionType } from '../../enums/question-transition-type.enum';
|
||||
import { MultiChoiceCondition } from '../../interfaces/multi-choice-condition.interface';
|
||||
|
||||
@Injectable()
|
||||
export class QuestionTransitionCommandProvider {
|
||||
@@ -44,7 +46,15 @@ export class QuestionTransitionCommandProvider {
|
||||
fromQuestion,
|
||||
toQuestion,
|
||||
type,
|
||||
condition: conditionValue,
|
||||
condition:
|
||||
type !== QuestionTransitionType.MULTI_CHOICE
|
||||
? conditionValue
|
||||
: {
|
||||
...conditionValue,
|
||||
choiceIds: [
|
||||
...(conditionValue as MultiChoiceCondition).choiceIds,
|
||||
].sort((a, b) => a - b),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
+15
@@ -1,6 +1,7 @@
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { MultiChoiceCondition } from '@/modules/questions/interfaces/multi-choice-condition.interface';
|
||||
import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
|
||||
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
||||
import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface';
|
||||
@@ -147,6 +148,20 @@ export class CheckTransitionDBProvider {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.type === QuestionTransitionType.MULTI_CHOICE) {
|
||||
const condition = options.conditionValue as MultiChoiceCondition;
|
||||
|
||||
qb.andWhere(`t.condition ->> 'operator' = :operator`, {
|
||||
operator: condition.operator,
|
||||
});
|
||||
|
||||
qb.andWhere(`t.condition -> 'choiceIds' = :choiceIds::jsonb`, {
|
||||
choiceIds: JSON.stringify(
|
||||
[...condition.choiceIds].sort((a, b) => a - b),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.type === QuestionTransitionType.NUMBER_RANGE) {
|
||||
qb.andWhere(`t.condition ->> 'min' = :min`, {
|
||||
min: String((options.conditionValue as NumericCondition).min),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MultiChoiceMetadata } from '../interfaces/multi-choice-metadata.interface';
|
||||
import { SingleChoiceMetadata } from '../interfaces/single-choice-metadata.interface';
|
||||
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
|
||||
|
||||
export type QuestionMetadata = NumericMetadata | MultiChoiceMetadata;
|
||||
export type QuestionMetadata = NumericMetadata | SingleChoiceMetadata;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MultiChoiceCondition } from '../interfaces/multi-choice-condition.interface';
|
||||
import { NumericCondition } from '../interfaces/numeric-condition.interface';
|
||||
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
|
||||
|
||||
export type TransitionConditionType =
|
||||
| SingleChoiceCondition
|
||||
| NumericCondition
|
||||
| null;
|
||||
SingleChoiceCondition | MultiChoiceCondition | NumericCondition | null;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-cho
|
||||
import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
|
||||
import { MultiChoiceCondition } from '../interfaces/multi-choice-condition.interface';
|
||||
import { MultiChoiceTConditionOperator } from '../enums/multi-choice-tcondition-operator.enum';
|
||||
|
||||
export class QuestionTransitionTypeValidator {
|
||||
public static validateTransitionType(options: TransitionValidateOptions) {
|
||||
@@ -26,6 +28,10 @@ export class QuestionTransitionTypeValidator {
|
||||
this.validateChoiceTransition(options);
|
||||
return;
|
||||
|
||||
case QuestionTransitionType.MULTI_CHOICE:
|
||||
this.validateMultiChoiceTransition(options);
|
||||
return;
|
||||
|
||||
case QuestionTransitionType.NUMBER_RANGE:
|
||||
this.validateNumberBetweenTransition(options);
|
||||
return;
|
||||
@@ -104,9 +110,9 @@ export class QuestionTransitionTypeValidator {
|
||||
);
|
||||
}
|
||||
|
||||
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
|
||||
if (fromQuestion.type !== QuestionType.SINGLE_CHOICE) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
|
||||
'questions.errors.questionTransition.fromQuestionMustBeSingleChoice',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
@@ -132,6 +138,83 @@ export class QuestionTransitionTypeValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static validateMultiChoiceTransition(
|
||||
options: TransitionValidateOptions,
|
||||
) {
|
||||
const { fromQuestion, toQuestion, conditionValue } = options;
|
||||
|
||||
if (!fromQuestion || !toQuestion) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.invalidTransition',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const condition = conditionValue as MultiChoiceCondition;
|
||||
|
||||
if (
|
||||
!condition ||
|
||||
!Array.isArray(condition.choiceIds) ||
|
||||
condition.choiceIds.length === 0
|
||||
) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.invalidMultiChoiceCondition',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(MultiChoiceTConditionOperator).includes(condition.operator)
|
||||
) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.invalidMultiChoiceOperator',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const choiceIdsAreValid = condition.choiceIds.every(
|
||||
(choiceId) => typeof choiceId === 'number' && Number.isInteger(choiceId),
|
||||
);
|
||||
|
||||
if (!choiceIdsAreValid) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.invalidMultiChoiceCondition',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueChoiceIds = new Set(condition.choiceIds);
|
||||
|
||||
if (uniqueChoiceIds.size !== condition.choiceIds.length) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.duplicateChoiceIds',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const questionChoiceIds = new Set(
|
||||
fromQuestion.choices?.map((choice) => choice.id) ?? [],
|
||||
);
|
||||
|
||||
const allChoicesBelongToQuestion = condition.choiceIds.every((choiceId) =>
|
||||
questionChoiceIds.has(choiceId),
|
||||
);
|
||||
|
||||
if (!allChoicesBelongToQuestion) {
|
||||
throw new AppException(
|
||||
'questions.errors.questionTransition.choiceNotBelongToQuestion',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static validateNumberBetweenTransition(
|
||||
options: TransitionValidateOptions,
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsNumber, IsOptional } from 'class-validator';
|
||||
import { IsArray, IsInt, IsNumber, IsOptional } from 'class-validator';
|
||||
import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
|
||||
|
||||
export class AnswerSubmissionDTO {
|
||||
@@ -6,6 +6,11 @@ export class AnswerSubmissionDTO {
|
||||
@IsOptional()
|
||||
choiceId?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsInt({ each: true })
|
||||
choiceIds?: number[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
numericValue?: number;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const checkAnswerCorrectness = (
|
||||
}
|
||||
}
|
||||
|
||||
if (question.type === QuestionType.MULTI_CHOICE) {
|
||||
if (question.type === QuestionType.SINGLE_CHOICE) {
|
||||
if (choiceId === undefined || choiceId === null) {
|
||||
throw new BadRequestException('submissions.errors.emptyChoiceId');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const getNextTransition = (
|
||||
);
|
||||
|
||||
if (
|
||||
question.type === QuestionType.MULTI_CHOICE &&
|
||||
question.type === QuestionType.SINGLE_CHOICE &&
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const transition = transitions.find(
|
||||
|
||||
@@ -131,7 +131,7 @@ export class AnswerAuthenticatedProvider {
|
||||
});
|
||||
|
||||
if (
|
||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
||||
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const choice = currentQuestion.choices.find(
|
||||
|
||||
@@ -126,7 +126,7 @@ export class AnswerGuestProvider {
|
||||
};
|
||||
|
||||
if (
|
||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
||||
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const choice = currentQuestion.choices.find(
|
||||
|
||||
@@ -162,7 +162,7 @@ export class CalculateResultProvider {
|
||||
if (!question) continue;
|
||||
|
||||
if (
|
||||
question.type === QuestionType.MULTI_CHOICE &&
|
||||
question.type === QuestionType.SINGLE_CHOICE &&
|
||||
answer.choiceId !== undefined
|
||||
) {
|
||||
const choice =
|
||||
@@ -192,7 +192,7 @@ export class CalculateResultProvider {
|
||||
questions.forEach((question) => {
|
||||
const answerScores: number[] = [];
|
||||
|
||||
if (question.type === QuestionType.MULTI_CHOICE) {
|
||||
if (question.type === QuestionType.SINGLE_CHOICE) {
|
||||
question.choices.forEach((choice) => {
|
||||
answerScores.push(choice.score);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { MultiChoiceTConditionOperator } from '@/modules/questions/enums/multi-choice-tcondition-operator.enum';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { MultiChoiceCondition } from '@/modules/questions/interfaces/multi-choice-condition.interface';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class MultiChoiceTransitionValidator {
|
||||
public static validateMultiChoiceTransitions(
|
||||
questions: Question[],
|
||||
transitions: QuestionTransition[],
|
||||
) {
|
||||
for (const question of questions) {
|
||||
if (question.type !== QuestionType.MULTI_CHOICE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const outgoing = transitions.filter(
|
||||
(t) => t.fromQuestion?.id === question.id,
|
||||
);
|
||||
|
||||
const conditional = outgoing.filter(
|
||||
(t) => t.type === QuestionTransitionType.MULTI_CHOICE,
|
||||
);
|
||||
|
||||
if (conditional.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// اگر transition شرطی داریم، باید fallback داشته باشیم.
|
||||
const hasDefault = outgoing.some(
|
||||
(t) => t.type === QuestionTransitionType.DEFAULT,
|
||||
);
|
||||
|
||||
const hasEnd = outgoing.some(
|
||||
(t) => t.type === QuestionTransitionType.END,
|
||||
);
|
||||
|
||||
if (!hasDefault && !hasEnd) {
|
||||
throw new AppException(
|
||||
'tests.errors.multiChoiceMustHaveFallback',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasDefault && hasEnd) {
|
||||
throw new AppException(
|
||||
'tests.errors.questionHasMultipleFallbacks',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < conditional.length; i++) {
|
||||
for (let j = i + 1; j < conditional.length; j++) {
|
||||
if (
|
||||
this.multiChoiceConditionsOverlap(
|
||||
conditional[i].condition as MultiChoiceCondition,
|
||||
conditional[j].condition as MultiChoiceCondition,
|
||||
)
|
||||
) {
|
||||
throw new AppException(
|
||||
'tests.errors.multiChoiceTransitionsOverlap',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static multiChoiceConditionsOverlap(
|
||||
a: MultiChoiceCondition,
|
||||
b: MultiChoiceCondition,
|
||||
): boolean {
|
||||
const aIds = new Set(a.choiceIds);
|
||||
const bIds = new Set(b.choiceIds);
|
||||
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.EXACT &&
|
||||
b.operator === MultiChoiceTConditionOperator.EXACT
|
||||
) {
|
||||
return this.sameSet(aIds, bIds);
|
||||
}
|
||||
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.EXACT &&
|
||||
b.operator === MultiChoiceTConditionOperator.ALL
|
||||
) {
|
||||
return this.isSubset(bIds, aIds);
|
||||
}
|
||||
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.ALL &&
|
||||
b.operator === MultiChoiceTConditionOperator.EXACT
|
||||
) {
|
||||
return this.isSubset(aIds, bIds);
|
||||
}
|
||||
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.EXACT &&
|
||||
b.operator === MultiChoiceTConditionOperator.ANY
|
||||
) {
|
||||
return this.hasIntersection(aIds, bIds);
|
||||
}
|
||||
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.ANY &&
|
||||
b.operator === MultiChoiceTConditionOperator.EXACT
|
||||
) {
|
||||
return this.hasIntersection(aIds, bIds);
|
||||
}
|
||||
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.ANY &&
|
||||
b.operator === MultiChoiceTConditionOperator.ANY
|
||||
) {
|
||||
return this.hasIntersection(aIds, bIds);
|
||||
}
|
||||
|
||||
// ALL + ALL
|
||||
if (
|
||||
a.operator === MultiChoiceTConditionOperator.ALL &&
|
||||
b.operator === MultiChoiceTConditionOperator.ALL
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ANY + ALL
|
||||
return true;
|
||||
}
|
||||
|
||||
public static sameSet(a: Set<number>, b: Set<number>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
|
||||
for (const id of a) {
|
||||
if (!b.has(id)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static isSubset(subset: Set<number>, superset: Set<number>): boolean {
|
||||
for (const id of subset) {
|
||||
if (!superset.has(id)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static hasIntersection(a: Set<number>, b: Set<number>): boolean {
|
||||
for (const id of a) {
|
||||
if (b.has(id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user