Compare commits
10
Commits
deaa479c80
...
55ddc7f7fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ddc7f7fe | ||
|
|
2e146e52b5 | ||
|
|
ca88e6994e | ||
|
|
ca8d02c531 | ||
|
|
f6a57454f4 | ||
|
|
c368948c4b | ||
|
|
0c82d3426b | ||
|
|
b4343bdb53 | ||
|
|
9742616f28 | ||
|
|
0f9543aaf2 |
Generated
+1182
-894
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REDIS } from '../constants/redis.constants';
|
||||
import Redis from 'ioredis';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService {
|
||||
@@ -38,6 +39,22 @@ export class RedisService {
|
||||
return await this.redis.del(key);
|
||||
}
|
||||
|
||||
async acquireLock(key: string, ttlSeconds = 5): Promise<string | null> {
|
||||
const token = randomUUID();
|
||||
|
||||
const result = await this.redis.set(key, token, 'EX', ttlSeconds, 'NX');
|
||||
|
||||
return result === 'OK' ? token : null;
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<void> {
|
||||
const value = await this.redis.get(key);
|
||||
|
||||
if (value === token) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.redis.quit();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"internalServerError": "Internal Server Error",
|
||||
"tooManyRequests": "Too many requests! Try again after {minutes} min.",
|
||||
"requestTimeout": "Unable to process your request at the moment.",
|
||||
"entityNotFound": "{entity} not found."
|
||||
"entityNotFound": "{entity} not found.",
|
||||
"sameProcessInProgress": "Same process is in progress."
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "{entity} created successfully.",
|
||||
|
||||
@@ -31,8 +31,12 @@
|
||||
"endMustNotHaveCondition": "End must not have condition.",
|
||||
"invalidTransition": "Invalid Transition!",
|
||||
"defaultMustNotHaveCondition": "Default must not have condition.",
|
||||
"fromQuestionMustBeSingleChoice": "fromQuestion must be single choice.",
|
||||
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
||||
"invalidChoiceCondition": "Invalid choice condition!",
|
||||
"invalidMultiChoiceCondition": "Invalid multi choice condition!",
|
||||
"invalidMultiChoiceOperator": "Invalid multi choice operator!",
|
||||
"duplicateChoiceIds": "Duplicate choice Ids!",
|
||||
"choiceNotBelongToQuestion": "Choice does not belong to the question.",
|
||||
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
||||
"invalidNumberRange": "Invalid number range!",
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
"questionId": "Question's Id",
|
||||
"submission": "Submission",
|
||||
"submissionId": "Submission's Id",
|
||||
"choiceId": "Choice's Id"
|
||||
"choiceId": "Choice's Id",
|
||||
"choiceIds": "Choice Ids list"
|
||||
},
|
||||
"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."
|
||||
"emptyChoiceId": "Choice Id should be provided as answer.",
|
||||
"emptyChoiceIds": "Choice Ids should be provided as answer.",
|
||||
"transitionNotFound": "No transition found for the test",
|
||||
"uniqueSubmissionQuestion": "Question's Answer already exists."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
},
|
||||
"errors": {
|
||||
"intPrice": "Price should be an integer.",
|
||||
"priceAccessConstraint": "Test's price depends on access type of 'Paid'."
|
||||
"priceAccessConstraint": "Test's price depends on access type of 'Paid'.",
|
||||
"mustHaveSingleStart": "Test must have single start.",
|
||||
"hasOrphanQuestions": "Test has some orphan questions.",
|
||||
"hasCycle": "Test has cycle.",
|
||||
"notEndingFlow": "Test include some flows which never end.",
|
||||
"notActiveTest": "Test is not active.",
|
||||
"multiChoiceMustHaveFallback": "Multi choice must have fallback.",
|
||||
"questionHasMultipleFallbacks": "Question has multiple fallbacks."
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "Test Created Successfully."
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"internalServerError": "خطایی رخ داد",
|
||||
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
||||
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
||||
"entityNotFound": "{entity} مورد نظر یافت نشد."
|
||||
"entityNotFound": "{entity} مورد نظر یافت نشد.",
|
||||
"sameProcessInProgress": "فرایند مشابهی در حال اجراست."
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
||||
|
||||
@@ -31,8 +31,12 @@
|
||||
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
||||
"invalidTransition": "مسیر اشتباه است!",
|
||||
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
||||
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.",
|
||||
"fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.",
|
||||
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.",
|
||||
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
||||
"invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!",
|
||||
"invalidMultiChoiceOperator": "عملگر شرط اشتباه است!",
|
||||
"duplicateChoiceIds": "شناسه گزینه ها تکراری است!",
|
||||
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
||||
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
||||
"invalidNumberRange": "بازه عددی اشتباه است!",
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
"questionId": "شناسه سوال",
|
||||
"submission": "Submission",
|
||||
"submissionId": "شناسه Submission",
|
||||
"choiceId": "شناسه گزینه"
|
||||
"choiceId": "شناسه گزینه",
|
||||
"choiceIds": "لیست شناسه گزینه ها"
|
||||
},
|
||||
"errors": {
|
||||
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
|
||||
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
||||
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
||||
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود."
|
||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
|
||||
"emptyChoiceIds": "لیستی از شناسه گزینه ها باید به عنوان پاسخ مشخص شود.",
|
||||
"transitionNotFound": "مسیری برای تست پیدا نشد.",
|
||||
"uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
},
|
||||
"errors": {
|
||||
"intPrice": "قیمت باید عدد صحیح باشد.",
|
||||
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است."
|
||||
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است.",
|
||||
"mustHaveSingleStart": "تست باید یک سوال شروع داشته باشد.",
|
||||
"hasOrphanQuestions": "تست شامل تعدادی سوال یتیم است.",
|
||||
"hasCycle": "تست شامل حلقه است.",
|
||||
"notEndingFlow": "تست شامل جریان بی پایان است.",
|
||||
"notActiveTest": "تست مورد نظر فعال نیست.",
|
||||
"multiChoiceMustHaveFallback": "سوال چند انتخابی باید یک مسیر جایگزین داشته باشد.",
|
||||
"questionHasMultipleFallbacks": "سوال چند جایگزین دارد!"
|
||||
},
|
||||
"messages": {
|
||||
"createSuccess": "تست با موفقیت ایجاد شد."
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SubmissionCurrentQuestion1786282238898 implements MigrationInterface {
|
||||
name = 'SubmissionCurrentQuestion1786282238898';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "public"."unq_test_start"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "public"."unq_question_end"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "public"."unq_question_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "public"."unq_question_choice"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "public"."unq_question_number_range"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question_transition" DROP CONSTRAINT "ex_question_number_range_overlap"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" RENAME COLUMN "currentQuestionOrder" TO "currentQuestionId"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" DROP NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ADD CONSTRAINT "FK_71b22c49b79a4eb32e9b1f05c76" FOREIGN KEY ("currentQuestionId") REFERENCES "question"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" DROP CONSTRAINT "FK_71b22c49b79a4eb32e9b1f05c76"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" SET DEFAULT '1'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission" RENAME COLUMN "currentQuestionId" TO "currentQuestionOrder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question_transition" ADD CONSTRAINT "ex_question_number_range_overlap" EXCLUDE USING gist ("fromQuestionId" WITH =, int4range(((condition ->> 'min'::text))::integer, ((condition ->> 'max'::text))::integer, '[]'::text) WITH &&) WHERE ((type = 'number-range'::question_transition_type_enum))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "unq_question_number_range" ON "question_transition" ("fromQuestionId") WHERE (type = 'number-range'::question_transition_type_enum)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "unq_question_choice" ON "question_transition" ("fromQuestionId") WHERE (type = 'choice'::question_transition_type_enum)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "unq_question_default" ON "question_transition" ("fromQuestionId") WHERE (type = 'default'::question_transition_type_enum)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "unq_question_end" ON "question_transition" ("fromQuestionId") WHERE (type = 'end'::question_transition_type_enum)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "unq_test_start" ON "question_transition" ("testId") WHERE (type = 'start'::question_transition_type_enum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MultiChoiceSubmission1787294625853 implements MigrationInterface {
|
||||
name = 'MultiChoiceSubmission1787294625853';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ea78aac2e76e03f1c2fc720f1d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "submission_answer_choices_choice" ("submissionAnswerId" integer NOT NULL, "choiceId" integer NOT NULL, CONSTRAINT "PK_d3154f459415dc8f97a4bb10a1e" PRIMARY KEY ("submissionAnswerId", "choiceId"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6f1902623cd8b3b52a5688a949" ON "submission_answer_choices_choice" ("submissionAnswerId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_2e699b5f339fb37917549c2fbf" ON "submission_answer_choices_choice" ("choiceId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "public"."question_transition_type_enum" RENAME TO "question_transition_type_enum_old"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."question_transition_type_enum" AS ENUM('start', 'default', 'choice', 'multi-choice', 'number-range', 'end')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question_transition" ALTER COLUMN "type" TYPE "public"."question_transition_type_enum" USING "type"::"text"::"public"."question_transition_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE "public"."question_transition_type_enum_old"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "public"."question_type_enum" RENAME TO "question_type_enum_old"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."question_type_enum" AS ENUM('single-choice', 'multi-choice', 'numeric', 'text')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question" ALTER COLUMN "type" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question" ALTER COLUMN "type" TYPE "public"."question_type_enum" USING "type"::"text"::"public"."question_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question" ALTER COLUMN "type" SET DEFAULT 'single-choice'`,
|
||||
);
|
||||
await queryRunner.query(`DROP TYPE "public"."question_type_enum_old"`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UNQ_SUBMISSION_QUESTION_IDX" ON "submission_answer" ("submissionId", "questionId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission_answer_choices_choice" ADD CONSTRAINT "FK_6f1902623cd8b3b52a5688a949b" FOREIGN KEY ("submissionAnswerId") REFERENCES "submission_answer"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission_answer_choices_choice" ADD CONSTRAINT "FK_2e699b5f339fb37917549c2fbfc" FOREIGN KEY ("choiceId") REFERENCES "choice"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission_answer_choices_choice" DROP CONSTRAINT "FK_2e699b5f339fb37917549c2fbfc"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "submission_answer_choices_choice" DROP CONSTRAINT "FK_6f1902623cd8b3b52a5688a949b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UNQ_SUBMISSION_QUESTION_IDX"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."question_type_enum_old" AS ENUM('multi-choice', 'numeric', 'text')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question" ALTER COLUMN "type" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question" ALTER COLUMN "type" TYPE "public"."question_type_enum_old" USING "type"::"text"::"public"."question_type_enum_old"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question" ALTER COLUMN "type" SET DEFAULT 'multi-choice'`,
|
||||
);
|
||||
await queryRunner.query(`DROP TYPE "public"."question_type_enum"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "public"."question_type_enum_old" RENAME TO "question_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "public"."question_transition_type_enum_old" AS ENUM('start', 'default', 'choice', 'number-range', 'end')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "question_transition" ALTER COLUMN "type" TYPE "public"."question_transition_type_enum_old" USING "type"::"text"::"public"."question_transition_type_enum_old"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE "public"."question_transition_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "public"."question_transition_type_enum_old" RENAME TO "question_transition_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_2e699b5f339fb37917549c2fbf"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6f1902623cd8b3b52a5688a949"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "submission_answer_choices_choice"`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_ea78aac2e76e03f1c2fc720f1d" ON "submission_answer" ("questionId", "submissionId") `,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { UNIQUE_QUESTION_MULTI_CHOICE } from '@/modules/questions/constants';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MultiChoiceTransitionUniqueIndex1786856474000 implements MigrationInterface {
|
||||
name = 'AddMultiChoiceTransitionUniqueIndex1750000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX ${UNIQUE_QUESTION_MULTI_CHOICE}
|
||||
ON "question_transition" (
|
||||
"fromQuestionId",
|
||||
"type",
|
||||
(("condition" ->> 'operator')),
|
||||
(("condition" -> 'choiceIds'))
|
||||
)
|
||||
WHERE "type" = '${QuestionTransitionType.MULTI_CHOICE}'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX ${UNIQUE_QUESTION_MULTI_CHOICE}
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class QuestionTransition {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
||||
@ManyToOne(() => Test, (test) => test.transitions, { onDelete: 'CASCADE' })
|
||||
test!: Test;
|
||||
|
||||
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
||||
|
||||
@@ -29,7 +29,7 @@ export class Question {
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: QuestionType,
|
||||
default: QuestionType.MULTI_CHOICE,
|
||||
default: QuestionType.SINGLE_CHOICE,
|
||||
})
|
||||
type!: QuestionType;
|
||||
|
||||
@@ -59,7 +59,7 @@ export class Question {
|
||||
outgoingTransitions!: Array<QuestionTransition>;
|
||||
|
||||
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
||||
incomingTransition!: Array<QuestionTransition>;
|
||||
incomingTransitions!: Array<QuestionTransition>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -60,6 +60,7 @@ import { QuestionTransitionService } from './providers/transition/question-trans
|
||||
ChoicesService,
|
||||
QuestionRangeRuleService,
|
||||
QuestionTransitionService,
|
||||
TypeOrmModule,
|
||||
],
|
||||
})
|
||||
export class QuestionsModule {}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -13,6 +13,6 @@ import { ResultRangeQueryProvider } from './providers/result-range-query.provide
|
||||
ResultRangeQueryProvider,
|
||||
ResultRangeService,
|
||||
],
|
||||
exports: [ResultRangeService],
|
||||
exports: [ResultRangeService, TypeOrmModule],
|
||||
})
|
||||
export class ScoringModule {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const UNIQUE_SUBMISSION_QUESTION_IDX = 'UNQ_SUBMISSION_QUESTION_IDX';
|
||||
@@ -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;
|
||||
|
||||
@@ -2,15 +2,20 @@ import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Submission } from './submission.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { Choice } from '@/modules/questions/entities/choice.entity';
|
||||
import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants';
|
||||
|
||||
@Entity()
|
||||
@Index(['submission', 'question'], { unique: true })
|
||||
@Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], {
|
||||
unique: true,
|
||||
})
|
||||
export class SubmissionAnswer {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
@@ -26,6 +31,10 @@ export class SubmissionAnswer {
|
||||
@ManyToOne(() => Choice, { nullable: true, onDelete: 'CASCADE' })
|
||||
choice?: Choice;
|
||||
|
||||
@ManyToMany(() => Choice)
|
||||
@JoinTable()
|
||||
choices?: Array<Choice>;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
numericValue?: number;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||
import { SubmissionAnswer } from './submission-answer.entity';
|
||||
import { SubmissionResult } from './submission-result.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
|
||||
@Entity()
|
||||
export class Submission {
|
||||
@@ -31,8 +32,8 @@ export class Submission {
|
||||
})
|
||||
status!: SubmissionStatus;
|
||||
|
||||
@Column({ type: 'int', nullable: false, default: 1 })
|
||||
currentQuestionOrder!: number;
|
||||
@ManyToOne(() => Question, { onDelete: 'SET NULL', nullable: true })
|
||||
currentQuestion!: Question | null;
|
||||
|
||||
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
||||
answers!: Array<SubmissionAnswer>;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
||||
import { BadRequestException, HttpStatus } from '@nestjs/common';
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
|
||||
export const checkAnswerCorrectness = (
|
||||
question: Question,
|
||||
answerSubmissionDto: AnswerSubmissionDTO,
|
||||
) => {
|
||||
if (!answerSubmissionDto) throw new BadRequestException();
|
||||
|
||||
const { choiceId, choiceIds, 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');
|
||||
}
|
||||
}
|
||||
|
||||
const questionChoiceIds = question.choices.map((el) => el.id);
|
||||
|
||||
if (question.type === QuestionType.SINGLE_CHOICE) {
|
||||
if (choiceId === undefined || choiceId === null) {
|
||||
throw new BadRequestException('submissions.errors.emptyChoiceId');
|
||||
}
|
||||
|
||||
if (!questionChoiceIds.some((c) => c === choiceId)) {
|
||||
throw new AppException('validation.wrongField', HttpStatus.BAD_REQUEST, {
|
||||
field: '$t(submissions.fields.choiceId)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (question.type === QuestionType.MULTI_CHOICE) {
|
||||
if (choiceIds === undefined || choiceIds === null) {
|
||||
throw new BadRequestException('submissions.errors.emptyChoiceId');
|
||||
}
|
||||
|
||||
if (
|
||||
choiceIds.length > 0 &&
|
||||
!choiceIds.every((el) => questionChoiceIds.includes(el))
|
||||
) {
|
||||
throw new AppException('validation.wrongField', HttpStatus.BAD_REQUEST, {
|
||||
field: '$t(submissions.fields.choiceIds)',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
||||
import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { MultiChoiceCondition } from '@/modules/questions/interfaces/multi-choice-condition.interface';
|
||||
import { MultiChoiceTConditionOperator } from '@/modules/questions/enums/multi-choice-tcondition-operator.enum';
|
||||
|
||||
export const getNextTransition = (
|
||||
question: Question,
|
||||
answerSubmissionDto: AnswerSubmissionDTO,
|
||||
) => {
|
||||
const transitions = question.outgoingTransitions ?? [];
|
||||
|
||||
const endTransition = transitions.find(
|
||||
(t) => t.type === QuestionTransitionType.END,
|
||||
);
|
||||
|
||||
if (
|
||||
question.type === QuestionType.SINGLE_CHOICE &&
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const transition = transitions.find(
|
||||
(t) =>
|
||||
t.type === QuestionTransitionType.CHOICE &&
|
||||
(t.condition as SingleChoiceCondition).choiceId ===
|
||||
answerSubmissionDto.choiceId,
|
||||
);
|
||||
|
||||
if (transition) return transition;
|
||||
}
|
||||
|
||||
if (
|
||||
question.type === QuestionType.MULTI_CHOICE &&
|
||||
answerSubmissionDto.choiceIds !== undefined
|
||||
) {
|
||||
const selectedChoiceIds = new Set(answerSubmissionDto.choiceIds);
|
||||
|
||||
const transition = transitions.find((t) => {
|
||||
if (t.type !== QuestionTransitionType.MULTI_CHOICE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const condition = t.condition as MultiChoiceCondition;
|
||||
|
||||
if (
|
||||
!condition ||
|
||||
!Array.isArray(condition.choiceIds) ||
|
||||
!condition.operator
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const conditionChoiceIds = new Set(condition.choiceIds);
|
||||
|
||||
switch (condition.operator) {
|
||||
case MultiChoiceTConditionOperator.ANY:
|
||||
return [...conditionChoiceIds].some((id) =>
|
||||
selectedChoiceIds.has(id),
|
||||
);
|
||||
|
||||
case MultiChoiceTConditionOperator.ALL:
|
||||
return [...conditionChoiceIds].every((id) =>
|
||||
selectedChoiceIds.has(id),
|
||||
);
|
||||
|
||||
case MultiChoiceTConditionOperator.EXACT:
|
||||
if (selectedChoiceIds.size !== conditionChoiceIds.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [...conditionChoiceIds].every((id) =>
|
||||
selectedChoiceIds.has(id),
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (transition) return transition;
|
||||
}
|
||||
|
||||
if (
|
||||
question.type === QuestionType.NUMERIC &&
|
||||
answerSubmissionDto.numericValue !== undefined
|
||||
) {
|
||||
const value = answerSubmissionDto.numericValue;
|
||||
|
||||
const transition = transitions.find((t) => {
|
||||
if (t.type !== QuestionTransitionType.NUMBER_RANGE) return false;
|
||||
|
||||
const condition = t.condition as NumericCondition;
|
||||
|
||||
return value >= condition.min && value <= condition.max;
|
||||
});
|
||||
|
||||
if (transition) return transition;
|
||||
}
|
||||
|
||||
const defaultTransition = transitions.find(
|
||||
(t) => t.type === QuestionTransitionType.DEFAULT,
|
||||
);
|
||||
|
||||
if (defaultTransition) return defaultTransition;
|
||||
|
||||
if (endTransition) return endTransition;
|
||||
|
||||
throw new AppException(
|
||||
'submissions.errors.nextQuestionNotFound',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface GuestSubmissionAnswer {
|
||||
questionId: number;
|
||||
choiceId?: number;
|
||||
choiceIds?: Array<number>;
|
||||
numericValue?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface GuestSubmissionResult {
|
||||
score: number;
|
||||
probability: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GuestSubmissionAnswer } from './guest-submission-answer.interface';
|
||||
import { GuestSubmissionResult } from './guest-submission-result.interface';
|
||||
|
||||
export interface GuestSubmissionSession {
|
||||
testId: number;
|
||||
currentQuestionId: number | null;
|
||||
answers: Array<GuestSubmissionAnswer>;
|
||||
result?: GuestSubmissionResult;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface GuestSubmissionSession {
|
||||
testId: number;
|
||||
currentQuestion: number;
|
||||
answers: Array<{
|
||||
questionId: number;
|
||||
choiceId: number;
|
||||
numericValue: number;
|
||||
}>;
|
||||
}
|
||||
+73
-128
@@ -1,25 +1,24 @@
|
||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, 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 { ConflictException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { Submission } from '../../entities/submission.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 { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { SubmissionResult } from '../../entities/submission-result.entity';
|
||||
import { AppResponse } from '@/common/responses';
|
||||
import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper';
|
||||
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 { getNextTransition } from '../../helpers/get-next-transition.helper';
|
||||
import { DataSource, QueryFailedError, Repository } from 'typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { SubmissionAnswer } from '../../entities/submission-answer.entity';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { SubmissionStatus } from '../../enums/submission-status.enum';
|
||||
import { CalculateResultProvider } from './calculate-result.provider';
|
||||
import { QuestionsService } from '@/modules/questions/providers/questions.service';
|
||||
import { SubmissionResult } from '../entities/submission-result.entity';
|
||||
import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../../constants';
|
||||
|
||||
@Injectable()
|
||||
export class AnswerSubmissionProvider {
|
||||
export class AnswerAuthenticatedProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Submission Repository
|
||||
@@ -27,11 +26,6 @@ export class AnswerSubmissionProvider {
|
||||
@InjectRepository(Submission)
|
||||
private readonly submissionRepository: Repository<Submission>,
|
||||
|
||||
/**
|
||||
* Inject Redis Service
|
||||
*/
|
||||
private readonly redisService: RedisService,
|
||||
|
||||
/**
|
||||
* Inject SubmisionAnswer Repository
|
||||
*/
|
||||
@@ -45,34 +39,17 @@ export class AnswerSubmissionProvider {
|
||||
private readonly submissionResultRepository: Repository<SubmissionResult>,
|
||||
|
||||
/**
|
||||
* Inject Questions Service
|
||||
* Inject DataSource
|
||||
*/
|
||||
private readonly questionsService: QuestionsService,
|
||||
private readonly dataSource: DataSource,
|
||||
|
||||
/**
|
||||
* Inject CalculateResult Provider
|
||||
*/
|
||||
private readonly calculateResultProvider: CalculateResultProvider,
|
||||
|
||||
/**
|
||||
* Inject DataSource
|
||||
*/
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
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,
|
||||
@@ -94,6 +71,14 @@ export class AnswerSubmissionProvider {
|
||||
where: { id: submissionId, user: { id: user.id } },
|
||||
relations: {
|
||||
test: true,
|
||||
currentQuestion: {
|
||||
choices: true,
|
||||
outgoingTransitions: {
|
||||
toQuestion: {
|
||||
choices: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -108,23 +93,7 @@ export class AnswerSubmissionProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const [currentQuestion, nextQuestion]: Array<Question | null> =
|
||||
await this.questionsService.findByOptionsForAdmin({
|
||||
where: {
|
||||
test: { id: submission.test.id },
|
||||
order: In([
|
||||
submission.currentQuestionOrder,
|
||||
submission.currentQuestionOrder + 1,
|
||||
]),
|
||||
},
|
||||
relations: {
|
||||
choices: true,
|
||||
rangeRules: true,
|
||||
},
|
||||
order: {
|
||||
order: 'ASC',
|
||||
},
|
||||
});
|
||||
const currentQuestion = submission.currentQuestion;
|
||||
|
||||
if (!currentQuestion) {
|
||||
let result: SubmissionResult | null;
|
||||
@@ -154,7 +123,7 @@ export class AnswerSubmissionProvider {
|
||||
});
|
||||
}
|
||||
|
||||
this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
|
||||
checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
|
||||
|
||||
const submissionAnswer = this.submissionAnswerRepository.create({
|
||||
submission,
|
||||
@@ -162,8 +131,8 @@ export class AnswerSubmissionProvider {
|
||||
});
|
||||
|
||||
if (
|
||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
||||
answerSubmissionDto.choiceId
|
||||
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const choice = currentQuestion.choices.find(
|
||||
(el) => el.id === answerSubmissionDto.choiceId,
|
||||
@@ -178,6 +147,15 @@ export class AnswerSubmissionProvider {
|
||||
}
|
||||
|
||||
submissionAnswer.choice = choice;
|
||||
} else if (
|
||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
||||
answerSubmissionDto.choiceIds !== undefined
|
||||
) {
|
||||
const choices = currentQuestion.choices.filter((el) =>
|
||||
answerSubmissionDto.choiceIds?.includes(el.id),
|
||||
);
|
||||
|
||||
submissionAnswer.choices = choices;
|
||||
} else if (
|
||||
currentQuestion.type === QuestionType.NUMERIC &&
|
||||
answerSubmissionDto.numericValue !== undefined
|
||||
@@ -185,14 +163,31 @@ export class AnswerSubmissionProvider {
|
||||
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
|
||||
}
|
||||
|
||||
const nextTransition = getNextTransition(
|
||||
currentQuestion,
|
||||
answerSubmissionDto,
|
||||
);
|
||||
|
||||
const nextQuestion = nextTransition.toQuestion ?? null;
|
||||
|
||||
let completedResponse: object | null = null;
|
||||
|
||||
try {
|
||||
completedResponse = await this.dataSource.transaction(async (manager) => {
|
||||
await manager.save(submissionAnswer);
|
||||
|
||||
if (!nextQuestion) {
|
||||
const result = await this.calculateResultProvider.calculate(
|
||||
if (
|
||||
nextTransition.type === QuestionTransitionType.END ||
|
||||
!nextQuestion
|
||||
) {
|
||||
await manager.update(
|
||||
Submission,
|
||||
{ id: submission.id },
|
||||
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
|
||||
);
|
||||
|
||||
const result =
|
||||
await this.calculateResultProvider.calculateAuthenticated(
|
||||
submission,
|
||||
manager,
|
||||
);
|
||||
@@ -204,16 +199,25 @@ export class AnswerSubmissionProvider {
|
||||
};
|
||||
}
|
||||
|
||||
await manager.increment(
|
||||
await manager.update(
|
||||
Submission,
|
||||
{ id: submission.id },
|
||||
'currentQuestionOrder',
|
||||
1,
|
||||
{ currentQuestion: nextQuestion },
|
||||
);
|
||||
|
||||
return null;
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof QueryFailedError &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
(err as any).constraint === UNIQUE_SUBMISSION_QUESTION_IDX
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'submissions.errors.uniqueSubmissionQuestion',
|
||||
);
|
||||
}
|
||||
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
@@ -225,11 +229,11 @@ export class AnswerSubmissionProvider {
|
||||
isCompleted: false,
|
||||
// isFinal: ,
|
||||
currentQuestion: {
|
||||
type: nextQuestion.type,
|
||||
content: nextQuestion.content,
|
||||
required: nextQuestion.required,
|
||||
metadata: nextQuestion.metadata,
|
||||
choices: nextQuestion.choices.map((el) => ({
|
||||
type: nextQuestion?.type,
|
||||
content: nextQuestion?.content,
|
||||
required: nextQuestion?.required,
|
||||
metadata: nextQuestion?.metadata,
|
||||
choices: nextQuestion?.choices.map((el) => ({
|
||||
id: el.id,
|
||||
text: el.text,
|
||||
order: el.order,
|
||||
@@ -237,63 +241,4 @@ export class AnswerSubmissionProvider {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 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,215 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { AppResponse } from '@/common/responses';
|
||||
import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper';
|
||||
import { GuestSubmissionAnswer } from '../../interfaces/guest-submission-answer.interface';
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { getNextTransition } from '../../helpers/get-next-transition.helper';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CalculateResultProvider } from './calculate-result.provider';
|
||||
|
||||
@Injectable()
|
||||
export class AnswerGuestProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Redis Service
|
||||
*/
|
||||
private readonly redisService: RedisService,
|
||||
|
||||
/**
|
||||
* Inject Questions Repository
|
||||
*/
|
||||
@InjectRepository(Question)
|
||||
private readonly questionsRepository: Repository<Question>,
|
||||
|
||||
/**
|
||||
* Inject CalculateResult Provider
|
||||
*/
|
||||
private readonly calculateResultProvider: CalculateResultProvider,
|
||||
) {}
|
||||
|
||||
public async answer(id: string, answerSubmissionDto: AnswerSubmissionDTO) {
|
||||
if (!isUUID(id)) {
|
||||
throw new AppException(
|
||||
'validation.wrongFieldFormat',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
{ field: '$t(submissions.fields.submissionId)' },
|
||||
);
|
||||
}
|
||||
|
||||
const lockKey = `lock:guest_submission:${id}`;
|
||||
const lockToken = await this.redisService.acquireLock(lockKey, 5);
|
||||
|
||||
if (!lockToken) {
|
||||
throw new AppException(
|
||||
'common.errors.processingInProgress',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.answerGuest(id, answerSubmissionDto);
|
||||
} finally {
|
||||
await this.redisService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async answerGuest(
|
||||
id: string,
|
||||
answerSubmissionDto: AnswerSubmissionDTO,
|
||||
) {
|
||||
let submission: GuestSubmissionSession | null = null;
|
||||
|
||||
try {
|
||||
submission = await this.redisService.get<GuestSubmissionSession>(
|
||||
`guest_submission:${id}`,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
throw new AppException(
|
||||
'common.errors.entityNotFound',
|
||||
HttpStatus.NOT_FOUND,
|
||||
{ entity: '$t(submissions.fields.submission)' },
|
||||
);
|
||||
}
|
||||
|
||||
let currentQuestion: Question | null = null;
|
||||
|
||||
if (submission.currentQuestionId !== null) {
|
||||
try {
|
||||
currentQuestion = await this.questionsRepository.findOne({
|
||||
where: { id: submission.currentQuestionId },
|
||||
relations: {
|
||||
choices: true,
|
||||
outgoingTransitions: {
|
||||
toQuestion: {
|
||||
choices: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentQuestion) {
|
||||
return new AppResponse({
|
||||
isCompleted: true,
|
||||
currentQuestion: null,
|
||||
result: submission.result
|
||||
? {
|
||||
score: submission.result.score,
|
||||
probability: submission.result.probability,
|
||||
title: submission.result.title,
|
||||
description: submission.result.description,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
|
||||
|
||||
const newAnswer: GuestSubmissionAnswer = {
|
||||
questionId: currentQuestion.id,
|
||||
};
|
||||
|
||||
if (
|
||||
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const choice = currentQuestion.choices.find(
|
||||
(el) => el.id === answerSubmissionDto.choiceId,
|
||||
);
|
||||
|
||||
if (!choice) {
|
||||
throw new AppException(
|
||||
'validation.wrongField',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
{ field: '$t(submissions.fields.choiceId)' },
|
||||
);
|
||||
}
|
||||
|
||||
newAnswer.choiceId = choice.id;
|
||||
} else if (
|
||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
||||
answerSubmissionDto.choiceIds !== undefined
|
||||
) {
|
||||
const choiceIds = currentQuestion.choices
|
||||
.filter((el) => answerSubmissionDto.choiceIds?.includes(el.id))
|
||||
.map((el) => el.id);
|
||||
|
||||
newAnswer.choiceIds = [...new Set(choiceIds)].sort((a, b) => a - b);
|
||||
} else if (
|
||||
currentQuestion.type === QuestionType.NUMERIC &&
|
||||
answerSubmissionDto.numericValue !== undefined
|
||||
) {
|
||||
newAnswer.numericValue = answerSubmissionDto.numericValue;
|
||||
}
|
||||
|
||||
const nextTransition = getNextTransition(
|
||||
currentQuestion,
|
||||
answerSubmissionDto,
|
||||
);
|
||||
|
||||
const nextQuestion = nextTransition.toQuestion ?? null;
|
||||
|
||||
submission.answers = [...submission.answers, newAnswer];
|
||||
|
||||
try {
|
||||
if (nextTransition.type === QuestionTransitionType.END || !nextQuestion) {
|
||||
submission.currentQuestionId = null;
|
||||
|
||||
const result =
|
||||
await this.calculateResultProvider.calculateGuest(submission);
|
||||
|
||||
await this.redisService.set<GuestSubmissionSession>(
|
||||
`guest_submission:${id}`,
|
||||
submission,
|
||||
3600,
|
||||
);
|
||||
|
||||
return new AppResponse({
|
||||
isCompleted: true,
|
||||
currentQuestion: null,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
await this.redisService.set<GuestSubmissionSession>(
|
||||
`guest_submission:${id}`,
|
||||
{ ...submission, currentQuestionId: nextQuestion.id },
|
||||
3600,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
return new AppResponse({
|
||||
isCompleted: false,
|
||||
// isFinal: ,
|
||||
currentQuestion: {
|
||||
type: nextQuestion?.type,
|
||||
content: nextQuestion?.content,
|
||||
required: nextQuestion?.required,
|
||||
metadata: nextQuestion?.metadata,
|
||||
choices: nextQuestion?.choices.map((el) => ({
|
||||
id: el.id,
|
||||
text: el.text,
|
||||
order: el.order,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { AnswerAuthenticatedProvider } from './answer-authenticated.provider';
|
||||
import { AnswerGuestProvider } from './answer-guest.provider';
|
||||
|
||||
@Injectable()
|
||||
export class AnswerSubmissionProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Answer Autenticated Provider
|
||||
*/
|
||||
private readonly answerAuthenticatedProvider: AnswerAuthenticatedProvider,
|
||||
|
||||
/**
|
||||
* Inject Answer Guest Provider
|
||||
*/
|
||||
private readonly answerGuestProvider: AnswerGuestProvider,
|
||||
) {}
|
||||
|
||||
public async answer(
|
||||
id: string,
|
||||
answerSubmissionDto: AnswerSubmissionDTO,
|
||||
user?: User,
|
||||
) {
|
||||
if (user) {
|
||||
return await this.answerAuthenticatedProvider.answer(
|
||||
id,
|
||||
answerSubmissionDto,
|
||||
user,
|
||||
);
|
||||
} else {
|
||||
return await this.answerGuestProvider.answer(id, answerSubmissionDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
EntityManager,
|
||||
In,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { SubmissionResult } from '../../entities/submission-result.entity';
|
||||
import { Submission } from '../../entities/submission.entity';
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { SubmissionAnswer } from '../../entities/submission-answer.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||
import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CalculateResultProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Question Repository
|
||||
*/
|
||||
@InjectRepository(Question)
|
||||
private readonly questionRepository: Repository<Question>,
|
||||
|
||||
/**
|
||||
* Inject Result Range Repository
|
||||
*/
|
||||
@InjectRepository(ResultRange)
|
||||
private readonly resultRangeRepository: Repository<ResultRange>,
|
||||
) {}
|
||||
|
||||
public async calculateAuthenticated(
|
||||
submission: Submission,
|
||||
manager: EntityManager,
|
||||
) {
|
||||
let submissionAnswers: SubmissionAnswer[] = [];
|
||||
|
||||
try {
|
||||
submissionAnswers = await manager.find(SubmissionAnswer, {
|
||||
where: { submission: { id: submission.id } },
|
||||
relations: {
|
||||
question: {
|
||||
rangeRules: true,
|
||||
},
|
||||
choice: true,
|
||||
choices: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
const result = await this.calculateFromAnswers(
|
||||
submissionAnswers.map((answer) => ({
|
||||
questionId: answer.question.id,
|
||||
question: answer.question,
|
||||
choiceId: answer.choice?.id,
|
||||
choice: answer.choice,
|
||||
choiceIds: answer.choices?.map((choice) => choice.id),
|
||||
choices: answer.choices,
|
||||
numericValue: answer.numericValue,
|
||||
})),
|
||||
async (questionIds) =>
|
||||
manager.find(Question, {
|
||||
where: { id: In(questionIds) },
|
||||
relations: {
|
||||
choices: true,
|
||||
rangeRules: true,
|
||||
},
|
||||
}),
|
||||
async (score) =>
|
||||
manager.find(ResultRange, {
|
||||
where: {
|
||||
test: { id: submission.test.id },
|
||||
minScore: LessThanOrEqual(score),
|
||||
maxScore: MoreThanOrEqual(score),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const submissionResult = manager.create(SubmissionResult, {
|
||||
submission,
|
||||
score: result.score,
|
||||
probability: result.probability,
|
||||
resultRange: result.resultRange,
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.save(submissionResult);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
return {
|
||||
score: result.score,
|
||||
probability: result.probability,
|
||||
title: result.resultRange?.title,
|
||||
description: result.resultRange?.description,
|
||||
};
|
||||
}
|
||||
|
||||
public async calculateGuest(submission: GuestSubmissionSession) {
|
||||
const result = await this.calculateFromAnswers(
|
||||
submission.answers.map((answer) => ({
|
||||
questionId: answer.questionId,
|
||||
choiceId: answer.choiceId,
|
||||
choiceIds: answer.choiceIds,
|
||||
numericValue: answer.numericValue,
|
||||
})),
|
||||
async (questionIds) =>
|
||||
this.questionRepository.find({
|
||||
where: { id: In(questionIds) },
|
||||
relations: {
|
||||
choices: true,
|
||||
rangeRules: true,
|
||||
},
|
||||
}),
|
||||
async (score) =>
|
||||
this.resultRangeRepository.find({
|
||||
where: {
|
||||
test: { id: submission.testId },
|
||||
minScore: LessThanOrEqual(score),
|
||||
maxScore: MoreThanOrEqual(score),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
submission.result = {
|
||||
score: result.score,
|
||||
probability: result.probability,
|
||||
title: result.resultRange?.title || '',
|
||||
description: result.resultRange?.description || '',
|
||||
};
|
||||
|
||||
return submission.result;
|
||||
}
|
||||
|
||||
private async calculateFromAnswers(
|
||||
answers: Array<{
|
||||
questionId: number;
|
||||
question?: Question;
|
||||
choiceId?: number;
|
||||
choice?: { id: number; score: number };
|
||||
choiceIds?: Array<number>;
|
||||
choices?: Array<{ id: number; score: number }>;
|
||||
numericValue?: number;
|
||||
}>,
|
||||
loadQuestions: (questionIds: number[]) => Promise<Question[]>,
|
||||
loadResultRanges: (score: number) => Promise<ResultRange[]>,
|
||||
) {
|
||||
let score = 0;
|
||||
|
||||
const questionIds = answers.map((a) => a.questionId);
|
||||
|
||||
const questions = await loadQuestions(questionIds);
|
||||
|
||||
const questionsMap = new Map<number, Question>();
|
||||
|
||||
questions.forEach((q) => questionsMap.set(q.id, q));
|
||||
|
||||
for (const answer of answers) {
|
||||
const question = answer.question ?? questionsMap.get(answer.questionId);
|
||||
|
||||
if (!question) continue;
|
||||
|
||||
if (
|
||||
question.type === QuestionType.SINGLE_CHOICE &&
|
||||
answer.choiceId !== undefined
|
||||
) {
|
||||
const choice =
|
||||
answer.choice ??
|
||||
question.choices.find((c) => c.id === answer.choiceId);
|
||||
|
||||
if (choice) {
|
||||
score += question.weight * choice.score;
|
||||
}
|
||||
} else if (
|
||||
question.type === QuestionType.MULTI_CHOICE &&
|
||||
answer.choiceIds !== undefined
|
||||
) {
|
||||
const selectedChoiceIds = new Set(answer.choiceIds);
|
||||
|
||||
const selectedChoices =
|
||||
answer.choices ??
|
||||
question.choices.filter((choice) => selectedChoiceIds.has(choice.id));
|
||||
|
||||
const multiChoiceScore = selectedChoices.reduce(
|
||||
(sum, choice) => sum + choice.score,
|
||||
0,
|
||||
);
|
||||
|
||||
score += question.weight * multiChoiceScore;
|
||||
} else if (
|
||||
question.type === QuestionType.NUMERIC &&
|
||||
answer.numericValue !== undefined
|
||||
) {
|
||||
question.rangeRules?.forEach((rule) => {
|
||||
if (
|
||||
answer.numericValue! >= rule.min &&
|
||||
answer.numericValue! <= rule.max
|
||||
) {
|
||||
score += question.weight * rule.score;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let totalScores = 0;
|
||||
|
||||
questions.forEach((question) => {
|
||||
if (question.type === QuestionType.SINGLE_CHOICE) {
|
||||
const maxScore =
|
||||
question.choices.length > 0
|
||||
? Math.max(...question.choices.map((choice) => choice.score))
|
||||
: 0;
|
||||
|
||||
totalScores += question.weight * maxScore;
|
||||
} else if (question.type === QuestionType.MULTI_CHOICE) {
|
||||
const maxScore = question.choices.reduce(
|
||||
(sum, choice) => sum + choice.score,
|
||||
0,
|
||||
);
|
||||
|
||||
totalScores += question.weight * maxScore;
|
||||
} else if (
|
||||
question.type === QuestionType.NUMERIC &&
|
||||
question.rangeRules &&
|
||||
question.rangeRules.length > 0
|
||||
) {
|
||||
const maxScore = Math.max(
|
||||
...question.rangeRules.map((rule) => rule.score),
|
||||
);
|
||||
|
||||
totalScores += question.weight * maxScore;
|
||||
}
|
||||
});
|
||||
|
||||
const resultRanges = await loadResultRanges(score);
|
||||
|
||||
return {
|
||||
score,
|
||||
probability: totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0,
|
||||
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { SubmissionResult } from '../entities/submission-result.entity';
|
||||
import { Submission } from '../entities/submission.entity';
|
||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { SubmissionAnswer } from '../entities/submission-answer.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CalculateResultProvider {
|
||||
public async calculate(submission: Submission, manager: EntityManager) {
|
||||
let score = 0;
|
||||
|
||||
let submissionAnswers: Array<SubmissionAnswer> = [];
|
||||
|
||||
try {
|
||||
submissionAnswers = await manager.find(SubmissionAnswer, {
|
||||
where: { submission: { id: submission.id } },
|
||||
relations: {
|
||||
question: {
|
||||
rangeRules: true,
|
||||
},
|
||||
choice: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
for (const answer of submissionAnswers) {
|
||||
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
|
||||
) {
|
||||
if (
|
||||
answer.question.rangeRules &&
|
||||
answer.question.rangeRules.length > 0
|
||||
) {
|
||||
answer.question.rangeRules.forEach((rule) => {
|
||||
if (
|
||||
answer.numericValue !== undefined &&
|
||||
answer.numericValue >= rule.min &&
|
||||
answer.numericValue <= rule.max
|
||||
) {
|
||||
score += answer.question.weight * rule.score;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalScores = 0;
|
||||
|
||||
const testQuestions = await manager.find(Question, {
|
||||
where: { test: { id: submission.test.id } },
|
||||
relations: {
|
||||
choices: true,
|
||||
rangeRules: true,
|
||||
},
|
||||
});
|
||||
|
||||
testQuestions.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 manager.find(ResultRange, {
|
||||
where: {
|
||||
test: { id: submission.test.id },
|
||||
minScore: LessThanOrEqual(score),
|
||||
maxScore: MoreThanOrEqual(score),
|
||||
},
|
||||
});
|
||||
|
||||
const submissionResult = manager.create(SubmissionResult, {
|
||||
submission,
|
||||
score,
|
||||
probability: Math.ceil((score / totalScores) * 100),
|
||||
resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.save(submissionResult);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
probability: submissionResult.probability,
|
||||
title: submissionResult.resultRange?.title,
|
||||
description: submissionResult.resultRange?.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Submission } from '../../entities/submission.entity';
|
||||
import { SubmissionAnswer } from '../../entities/submission-answer.entity';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { AppResponse } from '@/common/responses';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryAuthenticatedProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Submission Repository
|
||||
*/
|
||||
@InjectRepository(Submission)
|
||||
private readonly submissionRepository: Repository<Submission>,
|
||||
|
||||
/**
|
||||
* Inject Submission Answer Repository
|
||||
*/
|
||||
@InjectRepository(SubmissionAnswer)
|
||||
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
|
||||
) {}
|
||||
|
||||
public async getHistory(id: string, 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,
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
test: true,
|
||||
currentQuestion: {
|
||||
choices: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
throw new AppException(
|
||||
'common.errors.entityNotFound',
|
||||
HttpStatus.NOT_FOUND,
|
||||
{
|
||||
entity: '$t(submissions.fields.submission)',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let answers: SubmissionAnswer[] = [];
|
||||
|
||||
try {
|
||||
answers = await this.submissionAnswerRepository.find({
|
||||
where: {
|
||||
submission: {
|
||||
id: submission.id,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
question: {
|
||||
choices: true,
|
||||
},
|
||||
choice: true,
|
||||
choices: true,
|
||||
},
|
||||
order: {
|
||||
id: 'ASC',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
const messages = answers.map((answer) => ({
|
||||
question: {
|
||||
id: answer.question.id,
|
||||
type: answer.question.type,
|
||||
content: answer.question.content,
|
||||
required: answer.question.required,
|
||||
metadata: answer.question.metadata,
|
||||
},
|
||||
answer: {
|
||||
choiceId: answer.choice?.id,
|
||||
choice: answer.choice
|
||||
? {
|
||||
id: answer.choice.id,
|
||||
text: answer.choice.text,
|
||||
order: answer.choice.order,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
choiceIds: answer.choices?.map((choice) => choice.id),
|
||||
|
||||
choices: answer.choices?.map((choice) => ({
|
||||
id: choice.id,
|
||||
text: choice.text,
|
||||
order: choice.order,
|
||||
})),
|
||||
|
||||
numericValue: answer.numericValue,
|
||||
},
|
||||
}));
|
||||
|
||||
return new AppResponse({
|
||||
messages,
|
||||
|
||||
currentQuestion: submission.currentQuestion
|
||||
? {
|
||||
id: submission.currentQuestion.id,
|
||||
type: submission.currentQuestion.type,
|
||||
content: submission.currentQuestion.content,
|
||||
required: submission.currentQuestion.required,
|
||||
metadata: submission.currentQuestion.metadata,
|
||||
choices: submission.currentQuestion.choices?.map((choice) => ({
|
||||
id: choice.id,
|
||||
text: choice.text,
|
||||
order: choice.order,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
|
||||
isCompleted: submission.currentQuestion === null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
|
||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { AppResponse } from '@/common/responses';
|
||||
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
|
||||
import { isUUID } from 'class-validator';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryGuestProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Redis Service
|
||||
*/
|
||||
private readonly redisService: RedisService,
|
||||
|
||||
/**
|
||||
* Inject Questions Repository
|
||||
*/
|
||||
@InjectRepository(Question)
|
||||
private readonly questionsRepository: Repository<Question>,
|
||||
) {}
|
||||
|
||||
public async getHistory(id: string) {
|
||||
if (!isUUID(id)) {
|
||||
throw new AppException(
|
||||
'validation.wrongFieldFormat',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
{ field: '$t(submissions.fields.submissionId)' },
|
||||
);
|
||||
}
|
||||
|
||||
const key = `guest_submission:${id}`;
|
||||
|
||||
let submission: GuestSubmissionSession | null = null;
|
||||
|
||||
try {
|
||||
submission = await this.redisService.get<GuestSubmissionSession>(key);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
throw new AppException(
|
||||
'common.errors.entityNotFound',
|
||||
HttpStatus.NOT_FOUND,
|
||||
{
|
||||
entity: '$t(submissions.fields.submission)',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all question IDs that we need.
|
||||
*/
|
||||
const questionIds = new Set<number>();
|
||||
|
||||
for (const answer of submission.answers) {
|
||||
questionIds.add(answer.questionId);
|
||||
}
|
||||
|
||||
if (submission.currentQuestionId !== null) {
|
||||
questionIds.add(submission.currentQuestionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load questions from DB.
|
||||
*/
|
||||
let questions: Question[] = [];
|
||||
|
||||
if (questionIds.size > 0) {
|
||||
try {
|
||||
questions = await this.questionsRepository.find({
|
||||
where: {
|
||||
id: In([...questionIds]),
|
||||
},
|
||||
relations: {
|
||||
choices: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
}
|
||||
|
||||
const questionsMap = new Map<number, Question>();
|
||||
|
||||
for (const question of questions) {
|
||||
questionsMap.set(question.id, question);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build messages.
|
||||
*/
|
||||
const messages = submission.answers
|
||||
.map((answer) => {
|
||||
const question = questionsMap.get(answer.questionId);
|
||||
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedChoices = answer.choiceIds
|
||||
? question.choices.filter((choice) =>
|
||||
answer.choiceIds!.includes(choice.id),
|
||||
)
|
||||
: [];
|
||||
|
||||
const selectedChoice =
|
||||
answer.choiceId !== undefined
|
||||
? question.choices.find((choice) => choice.id === answer.choiceId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
question: {
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
content: question.content,
|
||||
required: question.required,
|
||||
metadata: question.metadata,
|
||||
},
|
||||
|
||||
answer: {
|
||||
choiceId: answer.choiceId,
|
||||
|
||||
choice: selectedChoice
|
||||
? {
|
||||
id: selectedChoice.id,
|
||||
text: selectedChoice.text,
|
||||
order: selectedChoice.order,
|
||||
}
|
||||
: undefined,
|
||||
|
||||
choiceIds: answer.choiceIds,
|
||||
|
||||
choices: selectedChoices.map((choice) => ({
|
||||
id: choice.id,
|
||||
text: choice.text,
|
||||
order: choice.order,
|
||||
})),
|
||||
|
||||
numericValue: answer.numericValue,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((message) => message !== null);
|
||||
|
||||
/**
|
||||
* Current question.
|
||||
*/
|
||||
const currentQuestion =
|
||||
submission.currentQuestionId !== null
|
||||
? questionsMap.get(submission.currentQuestionId)
|
||||
: null;
|
||||
|
||||
return new AppResponse({
|
||||
messages,
|
||||
|
||||
currentQuestion: currentQuestion
|
||||
? {
|
||||
id: currentQuestion.id,
|
||||
type: currentQuestion.type,
|
||||
content: currentQuestion.content,
|
||||
required: currentQuestion.required,
|
||||
metadata: currentQuestion.metadata,
|
||||
|
||||
choices: currentQuestion.choices?.map((choice) => ({
|
||||
id: choice.id,
|
||||
text: choice.text,
|
||||
order: choice.order,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
|
||||
isCompleted: submission.currentQuestionId === null,
|
||||
|
||||
result: submission.result
|
||||
? {
|
||||
score: submission.result.score,
|
||||
probability: submission.result.probability,
|
||||
title: submission.result.title,
|
||||
description: submission.result.description,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HistoryGuestProvider } from './history-guest.provider';
|
||||
import { HistoryAuthenticatedProvider } from './history-authenticated.provider';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SubmissionHistoryProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject History Guest Provider
|
||||
*/
|
||||
private readonly historyGuestProvider: HistoryGuestProvider,
|
||||
|
||||
/**
|
||||
* Inject History Authenticated Provider
|
||||
*/
|
||||
private readonly historyAuthenticatedProvider: HistoryAuthenticatedProvider,
|
||||
) {}
|
||||
|
||||
public async getHistory(id: string, user?: User) {
|
||||
if (user) {
|
||||
return await this.historyAuthenticatedProvider.getHistory(id, user);
|
||||
} else {
|
||||
return await this.historyGuestProvider.getHistory(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-18
@@ -5,17 +5,21 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Submission } from '../entities/submission.entity';
|
||||
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 { 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';
|
||||
import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface';
|
||||
import { TestStatus } from '@/modules/tests/enums/test-status.enum';
|
||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class StartSubmissionProvider {
|
||||
@@ -35,27 +39,57 @@ export class StartSubmissionProvider {
|
||||
* Inject Redis Service
|
||||
*/
|
||||
private readonly redisService: RedisService,
|
||||
|
||||
/**
|
||||
* Inject QuestionTransition Repository
|
||||
*/
|
||||
@InjectRepository(QuestionTransition)
|
||||
private readonly transitionsRepository: Repository<QuestionTransition>,
|
||||
) {}
|
||||
|
||||
public async start(testId: number, user?: User) {
|
||||
const test = await this.testsService.adminGetOne(testId, {
|
||||
const test = await this.testsService.adminGetOne(testId);
|
||||
|
||||
if (!test.isActive || test.status !== TestStatus.PUBLISHED) {
|
||||
throw new ForbiddenException('tests.errors.notActiveTest');
|
||||
}
|
||||
|
||||
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 getFirstQuestion(testId: number) {
|
||||
let transition: QuestionTransition | null = null;
|
||||
|
||||
try {
|
||||
transition = await this.transitionsRepository.findOne({
|
||||
where: {
|
||||
test: { id: testId },
|
||||
type: QuestionTransitionType.START,
|
||||
},
|
||||
relations: {
|
||||
questions: {
|
||||
toQuestion: {
|
||||
choices: true,
|
||||
rangeRules: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
questions: {
|
||||
order: 'ASC',
|
||||
toQuestion: {
|
||||
choices: {
|
||||
order: 'ASC',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
questions: {
|
||||
toQuestion: {
|
||||
id: true,
|
||||
order: true,
|
||||
type: true,
|
||||
content: true,
|
||||
required: true,
|
||||
@@ -68,16 +102,15 @@ export class StartSubmissionProvider {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (test.accessType !== AccessType.FREE && !user) {
|
||||
throw new ForbiddenException('auth.errors.accessDenied');
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return await this.startAuthenticated(test, user);
|
||||
if (!transition || !transition.toQuestion) {
|
||||
throw new NotFoundException('submissions.errors.transitionNotFound');
|
||||
}
|
||||
|
||||
return await this.startGuest(test);
|
||||
return transition.toQuestion;
|
||||
}
|
||||
|
||||
private async startAuthenticated(test: Test, user: User) {
|
||||
@@ -102,10 +135,13 @@ export class StartSubmissionProvider {
|
||||
throw new BadRequestException('submissions.errors.alreadyTookTest');
|
||||
}
|
||||
|
||||
const firstQuestion = await this.getFirstQuestion(test.id);
|
||||
|
||||
if (!submission) {
|
||||
submission = this.submissionsRepository.create({
|
||||
test,
|
||||
user: user || undefined,
|
||||
currentQuestion: firstQuestion,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -117,26 +153,32 @@ export class StartSubmissionProvider {
|
||||
|
||||
return new AppResponse({
|
||||
submissionId: submission.id,
|
||||
question: test.questions.length > 0 ? test.questions[0] : null,
|
||||
question: firstQuestion,
|
||||
});
|
||||
}
|
||||
|
||||
private async startGuest(test: Test) {
|
||||
const firstQuestion = await this.getFirstQuestion(test.id);
|
||||
|
||||
const guestSubmissionId = randomUUID();
|
||||
|
||||
try {
|
||||
await this.redisService.set<GuestSubmissionSession>(
|
||||
`guest_submission:${guestSubmissionId}`,
|
||||
{
|
||||
testId: test.id,
|
||||
currentQuestion: 0,
|
||||
currentQuestionId: firstQuestion.id,
|
||||
answers: [],
|
||||
},
|
||||
3600,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
return new AppResponse({
|
||||
guestSubmissionId,
|
||||
question: test.questions[0],
|
||||
question: firstQuestion,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StartSubmissionProvider } from './start-submission.provider';
|
||||
import { StartSubmissionProvider } from './start/start-submission.provider';
|
||||
import { User } from '@/modules/users/entities/user.entity';
|
||||
import { AnswerSubmissionProvider } from './answer-submission.provider';
|
||||
import { AnswerSubmissionProvider } from './answer/answer-submission.provider';
|
||||
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
||||
import { SubmissionHistoryProvider } from './history/submission-history.provider';
|
||||
|
||||
@Injectable()
|
||||
export class SubmissionsService {
|
||||
@@ -16,6 +17,11 @@ export class SubmissionsService {
|
||||
* Inject Answer Submission Provider
|
||||
*/
|
||||
private readonly answerSubmissionProvider: AnswerSubmissionProvider,
|
||||
|
||||
/**
|
||||
* Inject Submission History Provider
|
||||
*/
|
||||
private readonly submissionHistoryProvider: SubmissionHistoryProvider,
|
||||
) {}
|
||||
|
||||
public async startSubmission(testId: number, user?: User) {
|
||||
@@ -33,4 +39,8 @@ export class SubmissionsService {
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
public async getSubmissionHistory(id: string, user?: User) {
|
||||
return await this.submissionHistoryProvider.getHistory(id, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
@@ -53,4 +54,11 @@ export class SubmissionsController {
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id/history')
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
public async getHistory(@Param('id') id: string, @ActiveUser() user?: User) {
|
||||
return await this.submissionsService.getSubmissionHistory(id, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,19 @@ import { Submission } from './entities/submission.entity';
|
||||
import { SubmissionAnswer } from './entities/submission-answer.entity';
|
||||
import { SubmissionResult } from './entities/submission-result.entity';
|
||||
import { SubmissionsController } from './submissions.controller';
|
||||
import { StartSubmissionProvider } from './providers/start-submission.provider';
|
||||
import { StartSubmissionProvider } from './providers/start/start-submission.provider';
|
||||
import { SubmissionsService } from './providers/submissions.service';
|
||||
import { TestsModule } from '../tests/tests.module';
|
||||
import { AnswerSubmissionProvider } from './providers/answer-submission.provider';
|
||||
import { AnswerSubmissionProvider } from './providers/answer/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 { CalculateResultProvider } from './providers/answer/calculate-result.provider';
|
||||
import { ScoringModule } from '../scoring/scoring.module';
|
||||
import { AnswerGuestProvider } from './providers/answer/answer-guest.provider';
|
||||
import { AnswerAuthenticatedProvider } from './providers/answer/answer-authenticated.provider';
|
||||
import { HistoryAuthenticatedProvider } from './providers/history/history-authenticated.provider';
|
||||
import { HistoryGuestProvider } from './providers/history/history-guest.provider';
|
||||
import { SubmissionHistoryProvider } from './providers/history/submission-history.provider';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,8 +29,13 @@ import { ScoringModule } from '../scoring/scoring.module';
|
||||
controllers: [SubmissionsController],
|
||||
providers: [
|
||||
StartSubmissionProvider,
|
||||
AnswerSubmissionProvider,
|
||||
CalculateResultProvider,
|
||||
AnswerAuthenticatedProvider,
|
||||
AnswerGuestProvider,
|
||||
AnswerSubmissionProvider,
|
||||
HistoryAuthenticatedProvider,
|
||||
HistoryGuestProvider,
|
||||
SubmissionHistoryProvider,
|
||||
SubmissionsService,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AccessType } from '../enums/access-types.enum';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||
import { TestStatus } from '../enums/test-status.enum';
|
||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||
|
||||
@Entity()
|
||||
@Check(`
|
||||
@@ -70,6 +71,9 @@ export class Test {
|
||||
@OneToMany(() => ResultRange, (range) => range.test)
|
||||
resultRanges!: Array<ResultRange>;
|
||||
|
||||
@OneToMany(() => QuestionTransition, (transition) => transition.test)
|
||||
transitions!: Array<QuestionTransition>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Test } from '../entities/test.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
import { MultiChoiceTransitionValidator } from '../validators/multi-choice-transition.validator';
|
||||
|
||||
@Injectable()
|
||||
export class PublishTestValidatorProvider {
|
||||
constructor(
|
||||
/**
|
||||
* Inject Tests Repository
|
||||
*/
|
||||
@InjectRepository(Test)
|
||||
private readonly testsRepository: Repository<Test>,
|
||||
) {}
|
||||
|
||||
public async validate(testId: number) {
|
||||
let test: Test | null = null;
|
||||
|
||||
try {
|
||||
test = await this.testsRepository.findOne({
|
||||
where: {
|
||||
id: testId,
|
||||
},
|
||||
relations: {
|
||||
questions: true,
|
||||
transitions: {
|
||||
fromQuestion: true,
|
||||
toQuestion: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AppTimeoutException(err);
|
||||
}
|
||||
|
||||
if (!test) {
|
||||
throw new AppException(
|
||||
'common.errors.entityNotFound',
|
||||
HttpStatus.NOT_FOUND,
|
||||
{ entity: '$t(tests.fields.test)' },
|
||||
);
|
||||
}
|
||||
|
||||
const transitions = test.transitions;
|
||||
|
||||
const startTransitions = transitions.filter(
|
||||
(t) => t.type === QuestionTransitionType.START,
|
||||
);
|
||||
|
||||
if (startTransitions.length !== 1) {
|
||||
throw new AppException(
|
||||
'tests.errors.mustHaveSingleStart',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const startQuestionId = startTransitions[0].toQuestion!.id;
|
||||
|
||||
MultiChoiceTransitionValidator.validate(test.questions, transitions);
|
||||
|
||||
const transitionsGraph = new Map<number, QuestionTransition[]>();
|
||||
|
||||
this.createTransitionsGraph(transitions, transitionsGraph);
|
||||
|
||||
if (
|
||||
this.hasOrphanQuestions(startQuestionId, transitionsGraph, test.questions)
|
||||
) {
|
||||
throw new AppException(
|
||||
'tests.errors.hasOrphanQuestions',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const visiting = new Set<number>();
|
||||
const visitedCycle = new Set<number>();
|
||||
|
||||
if (
|
||||
this.graphHasCycle(
|
||||
startQuestionId,
|
||||
visiting,
|
||||
visitedCycle,
|
||||
transitionsGraph,
|
||||
)
|
||||
) {
|
||||
throw new AppException('tests.errors.hasCycle', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const endingQuestionsMemo = new Map<number, boolean>();
|
||||
|
||||
if (
|
||||
!this.allPathsReachEnd(
|
||||
startQuestionId,
|
||||
endingQuestionsMemo,
|
||||
transitionsGraph,
|
||||
)
|
||||
) {
|
||||
throw new AppException(
|
||||
'tests.errors.notEndingFlow',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createTransitionsGraph(
|
||||
transitions: QuestionTransition[],
|
||||
graph: Map<number, QuestionTransition[]>,
|
||||
) {
|
||||
for (const transition of transitions) {
|
||||
if (!transition.fromQuestion) continue;
|
||||
|
||||
const list = graph.get(transition.fromQuestion.id) ?? [];
|
||||
|
||||
list.push(transition);
|
||||
|
||||
graph.set(transition.fromQuestion.id, list);
|
||||
}
|
||||
}
|
||||
|
||||
private hasOrphanQuestions(
|
||||
startQuestionId: number,
|
||||
graph: Map<number, QuestionTransition[]>,
|
||||
allQuestions: Array<Question>,
|
||||
) {
|
||||
const visited = new Set<number>();
|
||||
const stack = [startQuestionId];
|
||||
|
||||
while (stack.length) {
|
||||
const id = stack.pop();
|
||||
if (!id) break;
|
||||
|
||||
if (visited.has(id)) continue;
|
||||
|
||||
visited.add(id);
|
||||
|
||||
const nextTransitions = graph.get(id) ?? [];
|
||||
|
||||
for (const transition of nextTransitions) {
|
||||
const toQuestion = transition.toQuestion;
|
||||
|
||||
if (toQuestion) {
|
||||
stack.push(toQuestion.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orphanQuestions = allQuestions.filter((q) => !visited.has(q.id));
|
||||
|
||||
return orphanQuestions.length > 0;
|
||||
}
|
||||
|
||||
private graphHasCycle(
|
||||
id: number,
|
||||
visiting: Set<number>,
|
||||
visitedCycle: Set<number>,
|
||||
graph: Map<number, QuestionTransition[]>,
|
||||
): boolean {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visitedCycle.has(id)) return false;
|
||||
|
||||
visiting.add(id);
|
||||
|
||||
for (const transition of graph.get(id) ?? []) {
|
||||
if (transition.toQuestion) {
|
||||
if (
|
||||
this.graphHasCycle(
|
||||
transition.toQuestion.id,
|
||||
visiting,
|
||||
visitedCycle,
|
||||
graph,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(id);
|
||||
visitedCycle.add(id);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private allPathsReachEnd(
|
||||
questionId: number,
|
||||
memo: Map<number, boolean>,
|
||||
graph: Map<number, QuestionTransition[]>,
|
||||
): boolean {
|
||||
if (memo.has(questionId)) {
|
||||
return memo.get(questionId)!;
|
||||
}
|
||||
|
||||
const outgoing = graph.get(questionId) ?? [];
|
||||
|
||||
if (outgoing.length === 0) {
|
||||
memo.set(questionId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasEndTransition = false;
|
||||
|
||||
for (const transition of outgoing) {
|
||||
if (transition.type === QuestionTransitionType.END) {
|
||||
hasEndTransition = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!transition.toQuestion ||
|
||||
!this.allPathsReachEnd(transition.toQuestion.id, memo, graph)
|
||||
) {
|
||||
memo.set(questionId, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const result =
|
||||
hasEndTransition ||
|
||||
outgoing.every(
|
||||
(t) =>
|
||||
t.type === QuestionTransitionType.END ||
|
||||
(t.toQuestion && memo.get(t.toQuestion.id) === true),
|
||||
);
|
||||
|
||||
memo.set(questionId, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { AppResponse } from '@/common/responses';
|
||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||
import { UpdateTestDTO } from '../dtos/update-test.dto';
|
||||
import { AccessType } from '../enums/access-types.enum';
|
||||
import { PublishTestValidatorProvider } from './publish-test-validator.provider';
|
||||
import { TestStatus } from '../enums/test-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class TestsCommandProvider {
|
||||
@@ -23,6 +25,11 @@ export class TestsCommandProvider {
|
||||
* Inject I18n Service
|
||||
*/
|
||||
private readonly i18nService: I18nService,
|
||||
|
||||
/**
|
||||
* Inject Publish Test Validator Provider
|
||||
*/
|
||||
private readonly publishTestValidatorProvider: PublishTestValidatorProvider,
|
||||
) {}
|
||||
|
||||
public async createOne(createTestDto: CreateTestDTO) {
|
||||
@@ -76,6 +83,10 @@ export class TestsCommandProvider {
|
||||
|
||||
Object.assign(test, updateTestDto);
|
||||
|
||||
if (updateTestDto.status === TestStatus.PUBLISHED) {
|
||||
await this.publishTestValidatorProvider.validate(test.id);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.testRepository.save(test);
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { filterTests } from '../helpers/filter-tests.helper';
|
||||
import Utils from '@/common/utils';
|
||||
import { Test } from '../entities/test.entity';
|
||||
import { AppException } from '@/common/exceptions/app.exception';
|
||||
import { TestStatus } from '../enums/test-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class TestsQueryProvider {
|
||||
@@ -49,7 +50,7 @@ export class TestsQueryProvider {
|
||||
public async findAllForUser() {
|
||||
try {
|
||||
return await this.testRepository.find({
|
||||
where: { isActive: true },
|
||||
where: { isActive: true, status: TestStatus.PUBLISHED },
|
||||
select: {
|
||||
id: true,
|
||||
accessType: true,
|
||||
|
||||
@@ -5,11 +5,17 @@ import { TestsService } from './providers/tests.service';
|
||||
import { TestsController } from './tests.controller';
|
||||
import { TestsQueryProvider } from './providers/tests-query.provider';
|
||||
import { TestsCommandProvider } from './providers/tests-command.provider';
|
||||
import { PublishTestValidatorProvider } from './providers/publish-test-validator.provider';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Test])],
|
||||
controllers: [TestsController],
|
||||
providers: [TestsService, TestsQueryProvider, TestsCommandProvider],
|
||||
providers: [
|
||||
TestsService,
|
||||
TestsQueryProvider,
|
||||
PublishTestValidatorProvider,
|
||||
TestsCommandProvider,
|
||||
],
|
||||
exports: [TestsService],
|
||||
})
|
||||
export class TestsModule {}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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 validate(
|
||||
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;
|
||||
}
|
||||
|
||||
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