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 { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REDIS } from '../constants/redis.constants';
|
import { REDIS } from '../constants/redis.constants';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisService {
|
export class RedisService {
|
||||||
@@ -38,6 +39,22 @@ export class RedisService {
|
|||||||
return await this.redis.del(key);
|
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() {
|
async onModuleDestroy() {
|
||||||
await this.redis.quit();
|
await this.redis.quit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"internalServerError": "Internal Server Error",
|
"internalServerError": "Internal Server Error",
|
||||||
"tooManyRequests": "Too many requests! Try again after {minutes} min.",
|
"tooManyRequests": "Too many requests! Try again after {minutes} min.",
|
||||||
"requestTimeout": "Unable to process your request at the moment.",
|
"requestTimeout": "Unable to process your request at the moment.",
|
||||||
"entityNotFound": "{entity} not found."
|
"entityNotFound": "{entity} not found.",
|
||||||
|
"sameProcessInProgress": "Same process is in progress."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "{entity} created successfully.",
|
"createSuccess": "{entity} created successfully.",
|
||||||
|
|||||||
@@ -31,8 +31,12 @@
|
|||||||
"endMustNotHaveCondition": "End must not have condition.",
|
"endMustNotHaveCondition": "End must not have condition.",
|
||||||
"invalidTransition": "Invalid Transition!",
|
"invalidTransition": "Invalid Transition!",
|
||||||
"defaultMustNotHaveCondition": "Default must not have condition.",
|
"defaultMustNotHaveCondition": "Default must not have condition.",
|
||||||
|
"fromQuestionMustBeSingleChoice": "fromQuestion must be single choice.",
|
||||||
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
||||||
"invalidChoiceCondition": "Invalid choice condition!",
|
"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.",
|
"choiceNotBelongToQuestion": "Choice does not belong to the question.",
|
||||||
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
||||||
"invalidNumberRange": "Invalid number range!",
|
"invalidNumberRange": "Invalid number range!",
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
"questionId": "Question's Id",
|
"questionId": "Question's Id",
|
||||||
"submission": "Submission",
|
"submission": "Submission",
|
||||||
"submissionId": "Submission's Id",
|
"submissionId": "Submission's Id",
|
||||||
"choiceId": "Choice's Id"
|
"choiceId": "Choice's Id",
|
||||||
|
"choiceIds": "Choice Ids list"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"alreadyTookTest": "You already took this test.",
|
"alreadyTookTest": "You already took this test.",
|
||||||
"choiceNumericConstraint": "You must send either choiceId or numericValue.",
|
"choiceNumericConstraint": "You must send either choiceId or numericValue.",
|
||||||
"emptyNumericValue": "A numeric value should be provided as answer.",
|
"emptyNumericValue": "A numeric value should be provided as answer.",
|
||||||
"numericValueRange": "Numeric value is out of specified range.",
|
"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": {
|
"errors": {
|
||||||
"intPrice": "Price should be an integer.",
|
"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": {
|
"messages": {
|
||||||
"createSuccess": "Test Created Successfully."
|
"createSuccess": "Test Created Successfully."
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"internalServerError": "خطایی رخ داد",
|
"internalServerError": "خطایی رخ داد",
|
||||||
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
|
||||||
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
|
||||||
"entityNotFound": "{entity} مورد نظر یافت نشد."
|
"entityNotFound": "{entity} مورد نظر یافت نشد.",
|
||||||
|
"sameProcessInProgress": "فرایند مشابهی در حال اجراست."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
"createSuccess": "{entity} با موفقیت ایجاد شد.",
|
||||||
|
|||||||
@@ -31,8 +31,12 @@
|
|||||||
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
||||||
"invalidTransition": "مسیر اشتباه است!",
|
"invalidTransition": "مسیر اشتباه است!",
|
||||||
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
||||||
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.",
|
"fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.",
|
||||||
|
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.",
|
||||||
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
||||||
|
"invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!",
|
||||||
|
"invalidMultiChoiceOperator": "عملگر شرط اشتباه است!",
|
||||||
|
"duplicateChoiceIds": "شناسه گزینه ها تکراری است!",
|
||||||
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
||||||
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
||||||
"invalidNumberRange": "بازه عددی اشتباه است!",
|
"invalidNumberRange": "بازه عددی اشتباه است!",
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
"questionId": "شناسه سوال",
|
"questionId": "شناسه سوال",
|
||||||
"submission": "Submission",
|
"submission": "Submission",
|
||||||
"submissionId": "شناسه Submission",
|
"submissionId": "شناسه Submission",
|
||||||
"choiceId": "شناسه گزینه"
|
"choiceId": "شناسه گزینه",
|
||||||
|
"choiceIds": "لیست شناسه گزینه ها"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
|
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
|
||||||
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
||||||
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
||||||
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
||||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود."
|
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
|
||||||
|
"emptyChoiceIds": "لیستی از شناسه گزینه ها باید به عنوان پاسخ مشخص شود.",
|
||||||
|
"transitionNotFound": "مسیری برای تست پیدا نشد.",
|
||||||
|
"uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"intPrice": "قیمت باید عدد صحیح باشد.",
|
"intPrice": "قیمت باید عدد صحیح باشد.",
|
||||||
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است."
|
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است.",
|
||||||
|
"mustHaveSingleStart": "تست باید یک سوال شروع داشته باشد.",
|
||||||
|
"hasOrphanQuestions": "تست شامل تعدادی سوال یتیم است.",
|
||||||
|
"hasCycle": "تست شامل حلقه است.",
|
||||||
|
"notEndingFlow": "تست شامل جریان بی پایان است.",
|
||||||
|
"notActiveTest": "تست مورد نظر فعال نیست.",
|
||||||
|
"multiChoiceMustHaveFallback": "سوال چند انتخابی باید یک مسیر جایگزین داشته باشد.",
|
||||||
|
"questionHasMultipleFallbacks": "سوال چند جایگزین دارد!"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "تست با موفقیت ایجاد شد."
|
"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 UNIQUE_TEST_START = 'UNQ_TEST_START';
|
||||||
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
||||||
'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,
|
Validate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { QuestionType } from '../enums/question-type.enum';
|
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 { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||||
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
||||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
@@ -60,5 +60,5 @@ export class CreateQuestionDTO {
|
|||||||
|
|
||||||
@Validate(QuestionMetadataValidator)
|
@Validate(QuestionMetadataValidator)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { IsBoolean, IsOptional } from 'class-validator';
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class MultiChoiceMetadataDTO {
|
export class SingleChoiceMetadataDTO {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
allowOther?: boolean;
|
allowOther?: boolean;
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
import { QuestionType } from '../enums/question-type.enum';
|
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 { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||||
|
|
||||||
export class UpdateQuestionDTO {
|
export class UpdateQuestionDTO {
|
||||||
@@ -48,5 +48,5 @@ export class UpdateQuestionDTO {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class QuestionTransition {
|
|||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Test, (test) => test.transitions, { onDelete: 'CASCADE' })
|
||||||
test!: Test;
|
test!: Test;
|
||||||
|
|
||||||
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class Question {
|
|||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: QuestionType,
|
enum: QuestionType,
|
||||||
default: QuestionType.MULTI_CHOICE,
|
default: QuestionType.SINGLE_CHOICE,
|
||||||
})
|
})
|
||||||
type!: QuestionType;
|
type!: QuestionType;
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export class Question {
|
|||||||
outgoingTransitions!: Array<QuestionTransition>;
|
outgoingTransitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
||||||
incomingTransition!: Array<QuestionTransition>;
|
incomingTransitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum MultiChoiceTConditionOperator {
|
||||||
|
ANY = 'any',
|
||||||
|
ALL = 'all',
|
||||||
|
EXACT = 'exact',
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export enum QuestionTransitionType {
|
|||||||
START = 'start',
|
START = 'start',
|
||||||
DEFAULT = 'default',
|
DEFAULT = 'default',
|
||||||
CHOICE = 'choice',
|
CHOICE = 'choice',
|
||||||
|
MULTI_CHOICE = 'multi-choice',
|
||||||
NUMBER_RANGE = 'number-range',
|
NUMBER_RANGE = 'number-range',
|
||||||
END = 'end',
|
END = 'end',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export enum QuestionType {
|
export enum QuestionType {
|
||||||
|
SINGLE_CHOICE = 'single-choice',
|
||||||
MULTI_CHOICE = 'multi-choice',
|
MULTI_CHOICE = 'multi-choice',
|
||||||
NUMERIC = 'numeric',
|
NUMERIC = 'numeric',
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { plainToInstance } from 'class-transformer';
|
|||||||
import { QuestionType } from '../enums/question-type.enum';
|
import { QuestionType } from '../enums/question-type.enum';
|
||||||
import { QuestionMetadata } from '../types/question-metadata.type';
|
import { QuestionMetadata } from '../types/question-metadata.type';
|
||||||
import { NumericMetadataDTO } from '../dtos/numeric-metadata.dto';
|
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 { validate } from 'class-validator';
|
||||||
import { ValidationError } from '@nestjs/common';
|
import { ValidationError } from '@nestjs/common';
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export const validateMetadata = async (
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
case QuestionType.MULTI_CHOICE:
|
case QuestionType.SINGLE_CHOICE:
|
||||||
validateDto = plainToInstance(MultiChoiceMetadataDTO, metadata);
|
validateDto = plainToInstance(SingleChoiceMetadataDTO, metadata);
|
||||||
break;
|
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 { UpdateQuestionTransitionDTO } from '../../dtos/update-question-transition.dto';
|
||||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
import { AppException } from '@/common/exceptions/app.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()
|
@Injectable()
|
||||||
export class QuestionTransitionCommandProvider {
|
export class QuestionTransitionCommandProvider {
|
||||||
@@ -44,7 +46,15 @@ export class QuestionTransitionCommandProvider {
|
|||||||
fromQuestion,
|
fromQuestion,
|
||||||
toQuestion,
|
toQuestion,
|
||||||
type,
|
type,
|
||||||
condition: conditionValue,
|
condition:
|
||||||
|
type !== QuestionTransitionType.MULTI_CHOICE
|
||||||
|
? conditionValue
|
||||||
|
: {
|
||||||
|
...conditionValue,
|
||||||
|
choiceIds: [
|
||||||
|
...(conditionValue as MultiChoiceCondition).choiceIds,
|
||||||
|
].sort((a, b) => a - b),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+15
@@ -1,6 +1,7 @@
|
|||||||
import { AppException } from '@/common/exceptions/app.exception';
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
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 { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
|
||||||
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
||||||
import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.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) {
|
if (options.type === QuestionTransitionType.NUMBER_RANGE) {
|
||||||
qb.andWhere(`t.condition ->> 'min' = :min`, {
|
qb.andWhere(`t.condition ->> 'min' = :min`, {
|
||||||
min: String((options.conditionValue as NumericCondition).min),
|
min: String((options.conditionValue as NumericCondition).min),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { QuestionTransitionService } from './providers/transition/question-trans
|
|||||||
ChoicesService,
|
ChoicesService,
|
||||||
QuestionRangeRuleService,
|
QuestionRangeRuleService,
|
||||||
QuestionTransitionService,
|
QuestionTransitionService,
|
||||||
|
TypeOrmModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class QuestionsModule {}
|
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';
|
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 { NumericCondition } from '../interfaces/numeric-condition.interface';
|
||||||
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
|
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
|
||||||
|
|
||||||
export type TransitionConditionType =
|
export type TransitionConditionType =
|
||||||
| SingleChoiceCondition
|
SingleChoiceCondition | MultiChoiceCondition | NumericCondition | null;
|
||||||
| 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 { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface';
|
||||||
import { HttpStatus } from '@nestjs/common';
|
import { HttpStatus } from '@nestjs/common';
|
||||||
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
|
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 {
|
export class QuestionTransitionTypeValidator {
|
||||||
public static validateTransitionType(options: TransitionValidateOptions) {
|
public static validateTransitionType(options: TransitionValidateOptions) {
|
||||||
@@ -26,6 +28,10 @@ export class QuestionTransitionTypeValidator {
|
|||||||
this.validateChoiceTransition(options);
|
this.validateChoiceTransition(options);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case QuestionTransitionType.MULTI_CHOICE:
|
||||||
|
this.validateMultiChoiceTransition(options);
|
||||||
|
return;
|
||||||
|
|
||||||
case QuestionTransitionType.NUMBER_RANGE:
|
case QuestionTransitionType.NUMBER_RANGE:
|
||||||
this.validateNumberBetweenTransition(options);
|
this.validateNumberBetweenTransition(options);
|
||||||
return;
|
return;
|
||||||
@@ -104,9 +110,9 @@ export class QuestionTransitionTypeValidator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
|
if (fromQuestion.type !== QuestionType.SINGLE_CHOICE) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
|
'questions.errors.questionTransition.fromQuestionMustBeSingleChoice',
|
||||||
HttpStatus.BAD_REQUEST,
|
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(
|
private static validateNumberBetweenTransition(
|
||||||
options: TransitionValidateOptions,
|
options: TransitionValidateOptions,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ import { ResultRangeQueryProvider } from './providers/result-range-query.provide
|
|||||||
ResultRangeQueryProvider,
|
ResultRangeQueryProvider,
|
||||||
ResultRangeService,
|
ResultRangeService,
|
||||||
],
|
],
|
||||||
exports: [ResultRangeService],
|
exports: [ResultRangeService, TypeOrmModule],
|
||||||
})
|
})
|
||||||
export class ScoringModule {}
|
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';
|
import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
|
||||||
|
|
||||||
export class AnswerSubmissionDTO {
|
export class AnswerSubmissionDTO {
|
||||||
@@ -6,6 +6,11 @@ export class AnswerSubmissionDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
choiceId?: number;
|
choiceId?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
choiceIds?: number[];
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
numericValue?: number;
|
numericValue?: number;
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Submission } from './submission.entity';
|
import { Submission } from './submission.entity';
|
||||||
import { Question } from '@/modules/questions/entities/question.entity';
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
import { Choice } from '@/modules/questions/entities/choice.entity';
|
import { Choice } from '@/modules/questions/entities/choice.entity';
|
||||||
|
import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index(['submission', 'question'], { unique: true })
|
@Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
export class SubmissionAnswer {
|
export class SubmissionAnswer {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
@@ -26,6 +31,10 @@ export class SubmissionAnswer {
|
|||||||
@ManyToOne(() => Choice, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => Choice, { nullable: true, onDelete: 'CASCADE' })
|
||||||
choice?: Choice;
|
choice?: Choice;
|
||||||
|
|
||||||
|
@ManyToMany(() => Choice)
|
||||||
|
@JoinTable()
|
||||||
|
choices?: Array<Choice>;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: true })
|
@Column({ type: 'int', nullable: true })
|
||||||
numericValue?: number;
|
numericValue?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||||
import { SubmissionAnswer } from './submission-answer.entity';
|
import { SubmissionAnswer } from './submission-answer.entity';
|
||||||
import { SubmissionResult } from './submission-result.entity';
|
import { SubmissionResult } from './submission-result.entity';
|
||||||
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Submission {
|
export class Submission {
|
||||||
@@ -31,8 +32,8 @@ export class Submission {
|
|||||||
})
|
})
|
||||||
status!: SubmissionStatus;
|
status!: SubmissionStatus;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: false, default: 1 })
|
@ManyToOne(() => Question, { onDelete: 'SET NULL', nullable: true })
|
||||||
currentQuestionOrder!: number;
|
currentQuestion!: Question | null;
|
||||||
|
|
||||||
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
||||||
answers!: Array<SubmissionAnswer>;
|
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;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
+75
-130
@@ -1,25 +1,24 @@
|
|||||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
import { ConflictException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { DataSource, In, Repository } from 'typeorm';
|
import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto';
|
||||||
import { Submission } from '../entities/submission.entity';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
|
||||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
|
||||||
import { User } from '@/modules/users/entities/user.entity';
|
import { User } from '@/modules/users/entities/user.entity';
|
||||||
|
import { Submission } from '../../entities/submission.entity';
|
||||||
import { AppException } from '@/common/exceptions/app.exception';
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
import { isUUID } from 'class-validator';
|
import { SubmissionResult } from '../../entities/submission-result.entity';
|
||||||
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces';
|
|
||||||
import { Question } from '@/modules/questions/entities/question.entity';
|
|
||||||
import { AppResponse } from '@/common/responses';
|
import { AppResponse } from '@/common/responses';
|
||||||
|
import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper';
|
||||||
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
|
||||||
import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface';
|
import { getNextTransition } from '../../helpers/get-next-transition.helper';
|
||||||
import { SubmissionAnswer } from '../entities/submission-answer.entity';
|
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 { CalculateResultProvider } from './calculate-result.provider';
|
||||||
import { QuestionsService } from '@/modules/questions/providers/questions.service';
|
import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../../constants';
|
||||||
import { SubmissionResult } from '../entities/submission-result.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AnswerSubmissionProvider {
|
export class AnswerAuthenticatedProvider {
|
||||||
constructor(
|
constructor(
|
||||||
/**
|
/**
|
||||||
* Inject Submission Repository
|
* Inject Submission Repository
|
||||||
@@ -27,11 +26,6 @@ export class AnswerSubmissionProvider {
|
|||||||
@InjectRepository(Submission)
|
@InjectRepository(Submission)
|
||||||
private readonly submissionRepository: Repository<Submission>,
|
private readonly submissionRepository: Repository<Submission>,
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject Redis Service
|
|
||||||
*/
|
|
||||||
private readonly redisService: RedisService,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject SubmisionAnswer Repository
|
* Inject SubmisionAnswer Repository
|
||||||
*/
|
*/
|
||||||
@@ -45,34 +39,17 @@ export class AnswerSubmissionProvider {
|
|||||||
private readonly submissionResultRepository: Repository<SubmissionResult>,
|
private readonly submissionResultRepository: Repository<SubmissionResult>,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject Questions Service
|
* Inject DataSource
|
||||||
*/
|
*/
|
||||||
private readonly questionsService: QuestionsService,
|
private readonly dataSource: DataSource,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject CalculateResult Provider
|
* Inject CalculateResult Provider
|
||||||
*/
|
*/
|
||||||
private readonly calculateResultProvider: CalculateResultProvider,
|
private readonly calculateResultProvider: CalculateResultProvider,
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject DataSource
|
|
||||||
*/
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async answer(
|
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,
|
id: string,
|
||||||
answerSubmissionDto: AnswerSubmissionDTO,
|
answerSubmissionDto: AnswerSubmissionDTO,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -94,6 +71,14 @@ export class AnswerSubmissionProvider {
|
|||||||
where: { id: submissionId, user: { id: user.id } },
|
where: { id: submissionId, user: { id: user.id } },
|
||||||
relations: {
|
relations: {
|
||||||
test: true,
|
test: true,
|
||||||
|
currentQuestion: {
|
||||||
|
choices: true,
|
||||||
|
outgoingTransitions: {
|
||||||
|
toQuestion: {
|
||||||
|
choices: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -108,23 +93,7 @@ export class AnswerSubmissionProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentQuestion, nextQuestion]: Array<Question | null> =
|
const currentQuestion = submission.currentQuestion;
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentQuestion) {
|
if (!currentQuestion) {
|
||||||
let result: SubmissionResult | null;
|
let result: SubmissionResult | null;
|
||||||
@@ -154,7 +123,7 @@ export class AnswerSubmissionProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
|
checkAnswerCorrectness(currentQuestion, answerSubmissionDto);
|
||||||
|
|
||||||
const submissionAnswer = this.submissionAnswerRepository.create({
|
const submissionAnswer = this.submissionAnswerRepository.create({
|
||||||
submission,
|
submission,
|
||||||
@@ -162,8 +131,8 @@ export class AnswerSubmissionProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||||
answerSubmissionDto.choiceId
|
answerSubmissionDto.choiceId !== undefined
|
||||||
) {
|
) {
|
||||||
const choice = currentQuestion.choices.find(
|
const choice = currentQuestion.choices.find(
|
||||||
(el) => el.id === answerSubmissionDto.choiceId,
|
(el) => el.id === answerSubmissionDto.choiceId,
|
||||||
@@ -178,6 +147,15 @@ export class AnswerSubmissionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submissionAnswer.choice = choice;
|
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 (
|
} else if (
|
||||||
currentQuestion.type === QuestionType.NUMERIC &&
|
currentQuestion.type === QuestionType.NUMERIC &&
|
||||||
answerSubmissionDto.numericValue !== undefined
|
answerSubmissionDto.numericValue !== undefined
|
||||||
@@ -185,18 +163,35 @@ export class AnswerSubmissionProvider {
|
|||||||
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
|
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextTransition = getNextTransition(
|
||||||
|
currentQuestion,
|
||||||
|
answerSubmissionDto,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextQuestion = nextTransition.toQuestion ?? null;
|
||||||
|
|
||||||
let completedResponse: object | null = null;
|
let completedResponse: object | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
completedResponse = await this.dataSource.transaction(async (manager) => {
|
completedResponse = await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.save(submissionAnswer);
|
await manager.save(submissionAnswer);
|
||||||
|
|
||||||
if (!nextQuestion) {
|
if (
|
||||||
const result = await this.calculateResultProvider.calculate(
|
nextTransition.type === QuestionTransitionType.END ||
|
||||||
submission,
|
!nextQuestion
|
||||||
manager,
|
) {
|
||||||
|
await manager.update(
|
||||||
|
Submission,
|
||||||
|
{ id: submission.id },
|
||||||
|
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result =
|
||||||
|
await this.calculateResultProvider.calculateAuthenticated(
|
||||||
|
submission,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isCompleted: true,
|
isCompleted: true,
|
||||||
currentQuestion: null,
|
currentQuestion: null,
|
||||||
@@ -204,16 +199,25 @@ export class AnswerSubmissionProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await manager.increment(
|
await manager.update(
|
||||||
Submission,
|
Submission,
|
||||||
{ id: submission.id },
|
{ id: submission.id },
|
||||||
'currentQuestionOrder',
|
{ currentQuestion: nextQuestion },
|
||||||
1,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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);
|
throw new AppTimeoutException(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +229,11 @@ export class AnswerSubmissionProvider {
|
|||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
// isFinal: ,
|
// isFinal: ,
|
||||||
currentQuestion: {
|
currentQuestion: {
|
||||||
type: nextQuestion.type,
|
type: nextQuestion?.type,
|
||||||
content: nextQuestion.content,
|
content: nextQuestion?.content,
|
||||||
required: nextQuestion.required,
|
required: nextQuestion?.required,
|
||||||
metadata: nextQuestion.metadata,
|
metadata: nextQuestion?.metadata,
|
||||||
choices: nextQuestion.choices.map((el) => ({
|
choices: nextQuestion?.choices.map((el) => ({
|
||||||
id: el.id,
|
id: el.id,
|
||||||
text: el.text,
|
text: el.text,
|
||||||
order: el.order,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-44
@@ -5,17 +5,21 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Submission } from '../entities/submission.entity';
|
import { Submission } from '../../entities/submission.entity';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
import { AppResponse } from '@/common/responses';
|
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 { Test } from '@/modules/tests/entities/test.entity';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
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()
|
@Injectable()
|
||||||
export class StartSubmissionProvider {
|
export class StartSubmissionProvider {
|
||||||
@@ -35,39 +39,20 @@ export class StartSubmissionProvider {
|
|||||||
* Inject Redis Service
|
* Inject Redis Service
|
||||||
*/
|
*/
|
||||||
private readonly redisService: RedisService,
|
private readonly redisService: RedisService,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject QuestionTransition Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(QuestionTransition)
|
||||||
|
private readonly transitionsRepository: Repository<QuestionTransition>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async start(testId: number, user?: User) {
|
public async start(testId: number, user?: User) {
|
||||||
const test = await this.testsService.adminGetOne(testId, {
|
const test = await this.testsService.adminGetOne(testId);
|
||||||
relations: {
|
|
||||||
questions: {
|
if (!test.isActive || test.status !== TestStatus.PUBLISHED) {
|
||||||
choices: true,
|
throw new ForbiddenException('tests.errors.notActiveTest');
|
||||||
},
|
}
|
||||||
},
|
|
||||||
order: {
|
|
||||||
questions: {
|
|
||||||
order: 'ASC',
|
|
||||||
choices: {
|
|
||||||
order: 'ASC',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
questions: {
|
|
||||||
id: true,
|
|
||||||
order: true,
|
|
||||||
type: true,
|
|
||||||
content: true,
|
|
||||||
required: true,
|
|
||||||
metadata: true,
|
|
||||||
choices: {
|
|
||||||
id: true,
|
|
||||||
order: true,
|
|
||||||
text: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (test.accessType !== AccessType.FREE && !user) {
|
if (test.accessType !== AccessType.FREE && !user) {
|
||||||
throw new ForbiddenException('auth.errors.accessDenied');
|
throw new ForbiddenException('auth.errors.accessDenied');
|
||||||
@@ -80,6 +65,54 @@ export class StartSubmissionProvider {
|
|||||||
return await this.startGuest(test);
|
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: {
|
||||||
|
toQuestion: {
|
||||||
|
choices: true,
|
||||||
|
rangeRules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
toQuestion: {
|
||||||
|
choices: {
|
||||||
|
order: 'ASC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
toQuestion: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
content: true,
|
||||||
|
required: true,
|
||||||
|
metadata: true,
|
||||||
|
choices: {
|
||||||
|
id: true,
|
||||||
|
order: true,
|
||||||
|
text: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transition || !transition.toQuestion) {
|
||||||
|
throw new NotFoundException('submissions.errors.transitionNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transition.toQuestion;
|
||||||
|
}
|
||||||
|
|
||||||
private async startAuthenticated(test: Test, user: User) {
|
private async startAuthenticated(test: Test, user: User) {
|
||||||
let submission: Submission | null = null;
|
let submission: Submission | null = null;
|
||||||
|
|
||||||
@@ -102,10 +135,13 @@ export class StartSubmissionProvider {
|
|||||||
throw new BadRequestException('submissions.errors.alreadyTookTest');
|
throw new BadRequestException('submissions.errors.alreadyTookTest');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstQuestion = await this.getFirstQuestion(test.id);
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
submission = this.submissionsRepository.create({
|
submission = this.submissionsRepository.create({
|
||||||
test,
|
test,
|
||||||
user: user || undefined,
|
user: user || undefined,
|
||||||
|
currentQuestion: firstQuestion,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -117,26 +153,32 @@ export class StartSubmissionProvider {
|
|||||||
|
|
||||||
return new AppResponse({
|
return new AppResponse({
|
||||||
submissionId: submission.id,
|
submissionId: submission.id,
|
||||||
question: test.questions.length > 0 ? test.questions[0] : null,
|
question: firstQuestion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startGuest(test: Test) {
|
private async startGuest(test: Test) {
|
||||||
|
const firstQuestion = await this.getFirstQuestion(test.id);
|
||||||
|
|
||||||
const guestSubmissionId = randomUUID();
|
const guestSubmissionId = randomUUID();
|
||||||
|
|
||||||
await this.redisService.set<GuestSubmissionSession>(
|
try {
|
||||||
`guest_submission:${guestSubmissionId}`,
|
await this.redisService.set<GuestSubmissionSession>(
|
||||||
{
|
`guest_submission:${guestSubmissionId}`,
|
||||||
testId: test.id,
|
{
|
||||||
currentQuestion: 0,
|
testId: test.id,
|
||||||
answers: [],
|
currentQuestionId: firstQuestion.id,
|
||||||
},
|
answers: [],
|
||||||
3600,
|
},
|
||||||
);
|
3600,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppTimeoutException(err);
|
||||||
|
}
|
||||||
|
|
||||||
return new AppResponse({
|
return new AppResponse({
|
||||||
guestSubmissionId,
|
guestSubmissionId,
|
||||||
question: test.questions[0],
|
question: firstQuestion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { 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 { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
||||||
|
import { SubmissionHistoryProvider } from './history/submission-history.provider';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubmissionsService {
|
export class SubmissionsService {
|
||||||
@@ -16,6 +17,11 @@ export class SubmissionsService {
|
|||||||
* Inject Answer Submission Provider
|
* Inject Answer Submission Provider
|
||||||
*/
|
*/
|
||||||
private readonly answerSubmissionProvider: AnswerSubmissionProvider,
|
private readonly answerSubmissionProvider: AnswerSubmissionProvider,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Submission History Provider
|
||||||
|
*/
|
||||||
|
private readonly submissionHistoryProvider: SubmissionHistoryProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async startSubmission(testId: number, user?: User) {
|
public async startSubmission(testId: number, user?: User) {
|
||||||
@@ -33,4 +39,8 @@ export class SubmissionsService {
|
|||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSubmissionHistory(id: string, user?: User) {
|
||||||
|
return await this.submissionHistoryProvider.getHistory(id, user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
@@ -53,4 +54,11 @@ export class SubmissionsController {
|
|||||||
user,
|
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 { SubmissionAnswer } from './entities/submission-answer.entity';
|
||||||
import { SubmissionResult } from './entities/submission-result.entity';
|
import { SubmissionResult } from './entities/submission-result.entity';
|
||||||
import { SubmissionsController } from './submissions.controller';
|
import { 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 { SubmissionsService } from './providers/submissions.service';
|
||||||
import { TestsModule } from '../tests/tests.module';
|
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 { RedisModule } from '@/common/modules/redis/redis.module';
|
||||||
import { QuestionsModule } from '../questions/questions.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 { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,8 +29,13 @@ import { ScoringModule } from '../scoring/scoring.module';
|
|||||||
controllers: [SubmissionsController],
|
controllers: [SubmissionsController],
|
||||||
providers: [
|
providers: [
|
||||||
StartSubmissionProvider,
|
StartSubmissionProvider,
|
||||||
AnswerSubmissionProvider,
|
|
||||||
CalculateResultProvider,
|
CalculateResultProvider,
|
||||||
|
AnswerAuthenticatedProvider,
|
||||||
|
AnswerGuestProvider,
|
||||||
|
AnswerSubmissionProvider,
|
||||||
|
HistoryAuthenticatedProvider,
|
||||||
|
HistoryGuestProvider,
|
||||||
|
SubmissionHistoryProvider,
|
||||||
SubmissionsService,
|
SubmissionsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AccessType } from '../enums/access-types.enum';
|
|||||||
import { Question } from '@/modules/questions/entities/question.entity';
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||||
import { TestStatus } from '../enums/test-status.enum';
|
import { TestStatus } from '../enums/test-status.enum';
|
||||||
|
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Check(`
|
@Check(`
|
||||||
@@ -70,6 +71,9 @@ export class Test {
|
|||||||
@OneToMany(() => ResultRange, (range) => range.test)
|
@OneToMany(() => ResultRange, (range) => range.test)
|
||||||
resultRanges!: Array<ResultRange>;
|
resultRanges!: Array<ResultRange>;
|
||||||
|
|
||||||
|
@OneToMany(() => QuestionTransition, (transition) => transition.test)
|
||||||
|
transitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
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 { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
import { UpdateTestDTO } from '../dtos/update-test.dto';
|
import { UpdateTestDTO } from '../dtos/update-test.dto';
|
||||||
import { AccessType } from '../enums/access-types.enum';
|
import { AccessType } from '../enums/access-types.enum';
|
||||||
|
import { PublishTestValidatorProvider } from './publish-test-validator.provider';
|
||||||
|
import { TestStatus } from '../enums/test-status.enum';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TestsCommandProvider {
|
export class TestsCommandProvider {
|
||||||
@@ -23,6 +25,11 @@ export class TestsCommandProvider {
|
|||||||
* Inject I18n Service
|
* Inject I18n Service
|
||||||
*/
|
*/
|
||||||
private readonly i18nService: I18nService,
|
private readonly i18nService: I18nService,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Publish Test Validator Provider
|
||||||
|
*/
|
||||||
|
private readonly publishTestValidatorProvider: PublishTestValidatorProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async createOne(createTestDto: CreateTestDTO) {
|
public async createOne(createTestDto: CreateTestDTO) {
|
||||||
@@ -76,6 +83,10 @@ export class TestsCommandProvider {
|
|||||||
|
|
||||||
Object.assign(test, updateTestDto);
|
Object.assign(test, updateTestDto);
|
||||||
|
|
||||||
|
if (updateTestDto.status === TestStatus.PUBLISHED) {
|
||||||
|
await this.publishTestValidatorProvider.validate(test.id);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.testRepository.save(test);
|
return await this.testRepository.save(test);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { filterTests } from '../helpers/filter-tests.helper';
|
|||||||
import Utils from '@/common/utils';
|
import Utils from '@/common/utils';
|
||||||
import { Test } from '../entities/test.entity';
|
import { Test } from '../entities/test.entity';
|
||||||
import { AppException } from '@/common/exceptions/app.exception';
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
|
import { TestStatus } from '../enums/test-status.enum';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TestsQueryProvider {
|
export class TestsQueryProvider {
|
||||||
@@ -49,7 +50,7 @@ export class TestsQueryProvider {
|
|||||||
public async findAllForUser() {
|
public async findAllForUser() {
|
||||||
try {
|
try {
|
||||||
return await this.testRepository.find({
|
return await this.testRepository.find({
|
||||||
where: { isActive: true },
|
where: { isActive: true, status: TestStatus.PUBLISHED },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
accessType: true,
|
accessType: true,
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ import { TestsService } from './providers/tests.service';
|
|||||||
import { TestsController } from './tests.controller';
|
import { TestsController } from './tests.controller';
|
||||||
import { TestsQueryProvider } from './providers/tests-query.provider';
|
import { TestsQueryProvider } from './providers/tests-query.provider';
|
||||||
import { TestsCommandProvider } from './providers/tests-command.provider';
|
import { TestsCommandProvider } from './providers/tests-command.provider';
|
||||||
|
import { PublishTestValidatorProvider } from './providers/publish-test-validator.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Test])],
|
imports: [TypeOrmModule.forFeature([Test])],
|
||||||
controllers: [TestsController],
|
controllers: [TestsController],
|
||||||
providers: [TestsService, TestsQueryProvider, TestsCommandProvider],
|
providers: [
|
||||||
|
TestsService,
|
||||||
|
TestsQueryProvider,
|
||||||
|
PublishTestValidatorProvider,
|
||||||
|
TestsCommandProvider,
|
||||||
|
],
|
||||||
exports: [TestsService],
|
exports: [TestsService],
|
||||||
})
|
})
|
||||||
export class TestsModule {}
|
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