feat: change answer submission logic
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"choiceNumericConstraint": "You must send either choiceId or numericValue.",
|
||||
"emptyNumericValue": "A numeric value should be provided as answer.",
|
||||
"numericValueRange": "Numeric value is out of specified range.",
|
||||
"emptyChoiceId": "Choice Id should be provided as answer."
|
||||
"emptyChoiceId": "Choice Id should be provided as answer.",
|
||||
"transitionNotFound": "No transition found for the test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
|
||||
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
|
||||
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
|
||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود."
|
||||
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
|
||||
"transitionNotFound": "مسیری برای تست پیدا نشد."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SubmissionCurrentQuestion1786282238898 implements MigrationInterface {
|
||||
name = 'SubmissionCurrentQuestion1786282238898';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."unq_test_start"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."unq_question_end"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."unq_question_default"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."unq_question_choice"`);
|
||||
await queryRunner.query(`DROP INDEX "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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class Question {
|
||||
outgoingTransitions!: Array<QuestionTransition>;
|
||||
|
||||
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
||||
incomingTransition!: Array<QuestionTransition>;
|
||||
incomingTransitions!: Array<QuestionTransition>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -60,6 +60,7 @@ import { QuestionTransitionService } from './providers/transition/question-trans
|
||||
ChoicesService,
|
||||
QuestionRangeRuleService,
|
||||
QuestionTransitionService,
|
||||
TypeOrmModule,
|
||||
],
|
||||
})
|
||||
export class QuestionsModule {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||
import { SubmissionAnswer } from './submission-answer.entity';
|
||||
import { SubmissionResult } from './submission-result.entity';
|
||||
import { Question } from '@/modules/questions/entities/question.entity';
|
||||
|
||||
@Entity()
|
||||
export class Submission {
|
||||
@@ -31,8 +32,8 @@ export class Submission {
|
||||
})
|
||||
status!: SubmissionStatus;
|
||||
|
||||
@Column({ type: 'int', nullable: false, default: 1 })
|
||||
currentQuestionOrder!: number;
|
||||
@ManyToOne(() => Question, { onDelete: 'SET NULL', nullable: true })
|
||||
currentQuestion!: Question | null;
|
||||
|
||||
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
|
||||
answers!: Array<SubmissionAnswer>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface GuestSubmissionSession {
|
||||
testId: number;
|
||||
currentQuestion: number;
|
||||
currentQuestionId: number;
|
||||
answers: Array<{
|
||||
questionId: number;
|
||||
choiceId: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Submission } from '../entities/submission.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
||||
@@ -17,6 +17,10 @@ import { SubmissionAnswer } from '../entities/submission-answer.entity';
|
||||
import { CalculateResultProvider } from './calculate-result.provider';
|
||||
import { QuestionsService } from '@/modules/questions/providers/questions.service';
|
||||
import { SubmissionResult } from '../entities/submission-result.entity';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
||||
import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
|
||||
import { SubmissionStatus } from '../enums/submission-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class AnswerSubmissionProvider {
|
||||
@@ -94,6 +98,10 @@ export class AnswerSubmissionProvider {
|
||||
where: { id: submissionId, user: { id: user.id } },
|
||||
relations: {
|
||||
test: true,
|
||||
currentQuestion: {
|
||||
choices: true,
|
||||
outgoingTransitions: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -108,23 +116,7 @@ export class AnswerSubmissionProvider {
|
||||
);
|
||||
}
|
||||
|
||||
const [currentQuestion, nextQuestion]: Array<Question | null> =
|
||||
await this.questionsService.findByOptionsForAdmin({
|
||||
where: {
|
||||
test: { id: submission.test.id },
|
||||
order: In([
|
||||
submission.currentQuestionOrder,
|
||||
submission.currentQuestionOrder + 1,
|
||||
]),
|
||||
},
|
||||
relations: {
|
||||
choices: true,
|
||||
rangeRules: true,
|
||||
},
|
||||
order: {
|
||||
order: 'ASC',
|
||||
},
|
||||
});
|
||||
const currentQuestion = submission.currentQuestion;
|
||||
|
||||
if (!currentQuestion) {
|
||||
let result: SubmissionResult | null;
|
||||
@@ -163,7 +155,7 @@ export class AnswerSubmissionProvider {
|
||||
|
||||
if (
|
||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
||||
answerSubmissionDto.choiceId
|
||||
answerSubmissionDto.choiceId !== undefined
|
||||
) {
|
||||
const choice = currentQuestion.choices.find(
|
||||
(el) => el.id === answerSubmissionDto.choiceId,
|
||||
@@ -185,13 +177,29 @@ export class AnswerSubmissionProvider {
|
||||
submissionAnswer.numericValue = answerSubmissionDto.numericValue;
|
||||
}
|
||||
|
||||
const nextTransition = this.getNextTransition(
|
||||
currentQuestion,
|
||||
answerSubmissionDto,
|
||||
);
|
||||
|
||||
const nextQuestion = nextTransition.toQuestion ?? null;
|
||||
|
||||
let completedResponse: object | null = null;
|
||||
|
||||
try {
|
||||
completedResponse = await this.dataSource.transaction(async (manager) => {
|
||||
await manager.save(submissionAnswer);
|
||||
|
||||
if (!nextQuestion) {
|
||||
if (
|
||||
nextTransition.type === QuestionTransitionType.END ||
|
||||
!nextQuestion
|
||||
) {
|
||||
await manager.update(
|
||||
Submission,
|
||||
{ id: submission.id },
|
||||
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
|
||||
);
|
||||
|
||||
const result = await this.calculateResultProvider.calculate(
|
||||
submission,
|
||||
manager,
|
||||
@@ -204,11 +212,10 @@ export class AnswerSubmissionProvider {
|
||||
};
|
||||
}
|
||||
|
||||
await manager.increment(
|
||||
await manager.update(
|
||||
Submission,
|
||||
{ id: submission.id },
|
||||
'currentQuestionOrder',
|
||||
1,
|
||||
{ currentQuestion: nextQuestion },
|
||||
);
|
||||
|
||||
return null;
|
||||
@@ -225,11 +232,11 @@ export class AnswerSubmissionProvider {
|
||||
isCompleted: false,
|
||||
// isFinal: ,
|
||||
currentQuestion: {
|
||||
type: nextQuestion.type,
|
||||
content: nextQuestion.content,
|
||||
required: nextQuestion.required,
|
||||
metadata: nextQuestion.metadata,
|
||||
choices: nextQuestion.choices.map((el) => ({
|
||||
type: nextQuestion?.type,
|
||||
content: nextQuestion?.content,
|
||||
required: nextQuestion?.required,
|
||||
metadata: nextQuestion?.metadata,
|
||||
choices: nextQuestion?.choices.map((el) => ({
|
||||
id: el.id,
|
||||
text: el.text,
|
||||
order: el.order,
|
||||
@@ -296,4 +303,59 @@ export class AnswerSubmissionProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getNextTransition(
|
||||
question: Question,
|
||||
answerSubmissionDto: AnswerSubmissionDTO,
|
||||
) {
|
||||
const transitions = question.outgoingTransitions ?? [];
|
||||
|
||||
const endTransition = transitions.find(
|
||||
(t) => t.type === QuestionTransitionType.END,
|
||||
);
|
||||
|
||||
if (
|
||||
question.type === QuestionType.MULTI_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.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Submission } from '../entities/submission.entity';
|
||||
@@ -17,7 +18,8 @@ import { randomUUID } from 'crypto';
|
||||
import { RedisService } from '@/common/modules/redis/providers/redis.service';
|
||||
import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces';
|
||||
import { TestStatus } from '@/modules/tests/enums/test-status.enum';
|
||||
import { QuestionTransitionService } from '@/modules/questions/providers/transition/question-transition.service';
|
||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class StartSubmissionProvider {
|
||||
@@ -39,42 +41,14 @@ export class StartSubmissionProvider {
|
||||
private readonly redisService: RedisService,
|
||||
|
||||
/**
|
||||
* Inject Transitions Service
|
||||
* Inject QuestionTransition Repository
|
||||
*/
|
||||
private readonly transitionsService: QuestionTransitionService,
|
||||
@InjectRepository(QuestionTransition)
|
||||
private readonly transitionsRepository: Repository<QuestionTransition>,
|
||||
) {}
|
||||
|
||||
public async start(testId: number, user?: User) {
|
||||
const test = await this.testsService.adminGetOne(testId, {
|
||||
relations: {
|
||||
questions: {
|
||||
choices: true,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const test = await this.testsService.adminGetOne(testId);
|
||||
|
||||
if (!test.isActive || test.status !== TestStatus.PUBLISHED) {
|
||||
throw new ForbiddenException('tests.errors.notActiveTest');
|
||||
@@ -91,6 +65,54 @@ export class StartSubmissionProvider {
|
||||
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) {
|
||||
let submission: Submission | null = null;
|
||||
|
||||
@@ -113,10 +135,13 @@ export class StartSubmissionProvider {
|
||||
throw new BadRequestException('submissions.errors.alreadyTookTest');
|
||||
}
|
||||
|
||||
const firstQuestion = await this.getFirstQuestion(test.id);
|
||||
|
||||
if (!submission) {
|
||||
submission = this.submissionsRepository.create({
|
||||
test,
|
||||
user: user || undefined,
|
||||
currentQuestion: firstQuestion,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -126,22 +151,22 @@ export class StartSubmissionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// const startTransition = await this.transitionsService.
|
||||
|
||||
return new AppResponse({
|
||||
submissionId: submission.id,
|
||||
question: test.questions.length > 0 ? test.questions[0] : null,
|
||||
question: firstQuestion,
|
||||
});
|
||||
}
|
||||
|
||||
private async startGuest(test: Test) {
|
||||
const firstQuestion = await this.getFirstQuestion(test.id);
|
||||
|
||||
const guestSubmissionId = randomUUID();
|
||||
|
||||
await this.redisService.set<GuestSubmissionSession>(
|
||||
`guest_submission:${guestSubmissionId}`,
|
||||
{
|
||||
testId: test.id,
|
||||
currentQuestion: 0,
|
||||
currentQuestionId: firstQuestion.id,
|
||||
answers: [],
|
||||
},
|
||||
3600,
|
||||
@@ -149,7 +174,7 @@ export class StartSubmissionProvider {
|
||||
|
||||
return new AppResponse({
|
||||
guestSubmissionId,
|
||||
question: test.questions[0],
|
||||
question: firstQuestion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user