Compare commits

..
10 Commits
61 changed files with 3275 additions and 1236 deletions
+1182 -894
View File
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();
} }
+2 -1
View File
@@ -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.",
+4
View File
@@ -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!",
+6 -2
View File
@@ -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."
} }
} }
+8 -1
View File
@@ -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."
+2 -1
View File
@@ -3,7 +3,8 @@
"internalServerError": "خطایی رخ داد", "internalServerError": "خطایی رخ داد",
"tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.", "tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.",
"requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.", "requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.",
"entityNotFound": "{entity} مورد نظر یافت نشد." "entityNotFound": "{entity} مورد نظر یافت نشد.",
"sameProcessInProgress": "فرایند مشابهی در حال اجراست."
}, },
"messages": { "messages": {
"createSuccess": "{entity} با موفقیت ایجاد شد.", "createSuccess": "{entity} با موفقیت ایجاد شد.",
+5 -1
View File
@@ -31,8 +31,12 @@
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.", "endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
"invalidTransition": "مسیر اشتباه است!", "invalidTransition": "مسیر اشتباه است!",
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.", "defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.", "fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.",
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.",
"invalidChoiceCondition": "شرط گزینه اشتباه است!", "invalidChoiceCondition": "شرط گزینه اشتباه است!",
"invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!",
"invalidMultiChoiceOperator": "عملگر شرط اشتباه است!",
"duplicateChoiceIds": "شناسه گزینه ها تکراری است!",
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.", "choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.", "fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
"invalidNumberRange": "بازه عددی اشتباه است!", "invalidNumberRange": "بازه عددی اشتباه است!",
+6 -2
View File
@@ -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": "این سوال قبلا پاسخ داده شده است."
} }
} }
+8 -1
View File
@@ -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}
`);
}
}
+1
View File
@@ -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,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 {
@@ -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,
) { ) {
+1 -1
View File
@@ -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;
}>;
}
@@ -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);
}
}
}
@@ -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);
}
} }
+14 -4
View File
@@ -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,
+7 -1
View File
@@ -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;
}
}