feat: change answer submission logic

This commit is contained in:
2026-08-09 17:12:55 +03:30
parent b4343bdb53
commit 0c82d3426b
9 changed files with 224 additions and 72 deletions
+2 -1
View File
@@ -11,6 +11,7 @@
"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.",
"transitionNotFound": "No transition found for the test"
} }
} }
+2 -1
View File
@@ -11,6 +11,7 @@
"choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.", "choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.",
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.", "emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.", "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>; 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;
@@ -60,6 +60,7 @@ import { QuestionTransitionService } from './providers/transition/question-trans
ChoicesService, ChoicesService,
QuestionRangeRuleService, QuestionRangeRuleService,
QuestionTransitionService, QuestionTransitionService,
TypeOrmModule,
], ],
}) })
export class QuestionsModule {} export class QuestionsModule {}
@@ -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>;
@@ -1,6 +1,6 @@
export interface GuestSubmissionSession { export interface GuestSubmissionSession {
testId: number; testId: number;
currentQuestion: number; currentQuestionId: number;
answers: Array<{ answers: Array<{
questionId: number; questionId: number;
choiceId: number; choiceId: number;
@@ -1,5 +1,5 @@
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; 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 { Submission } from '../entities/submission.entity';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; 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 { CalculateResultProvider } from './calculate-result.provider';
import { QuestionsService } from '@/modules/questions/providers/questions.service'; import { QuestionsService } from '@/modules/questions/providers/questions.service';
import { SubmissionResult } from '../entities/submission-result.entity'; 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() @Injectable()
export class AnswerSubmissionProvider { export class AnswerSubmissionProvider {
@@ -94,6 +98,10 @@ 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: true,
},
}, },
}); });
} catch (err) { } catch (err) {
@@ -108,23 +116,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;
@@ -163,7 +155,7 @@ export class AnswerSubmissionProvider {
if ( if (
currentQuestion.type === QuestionType.MULTI_CHOICE && currentQuestion.type === QuestionType.MULTI_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,
@@ -185,13 +177,29 @@ export class AnswerSubmissionProvider {
submissionAnswer.numericValue = answerSubmissionDto.numericValue; submissionAnswer.numericValue = answerSubmissionDto.numericValue;
} }
const nextTransition = this.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 (
nextTransition.type === QuestionTransitionType.END ||
!nextQuestion
) {
await manager.update(
Submission,
{ id: submission.id },
{ currentQuestion: null, status: SubmissionStatus.COMPLETED },
);
const result = await this.calculateResultProvider.calculate( const result = await this.calculateResultProvider.calculate(
submission, submission,
manager, manager,
@@ -204,11 +212,10 @@ 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;
@@ -225,11 +232,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,
@@ -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, 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';
@@ -17,7 +18,8 @@ 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.interfaces';
import { TestStatus } from '@/modules/tests/enums/test-status.enum'; 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() @Injectable()
export class StartSubmissionProvider { export class StartSubmissionProvider {
@@ -39,42 +41,14 @@ export class StartSubmissionProvider {
private readonly redisService: RedisService, 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) { public async start(testId: number, user?: User) {
const test = await this.testsService.adminGetOne(testId, { 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,
},
},
},
});
if (!test.isActive || test.status !== TestStatus.PUBLISHED) { if (!test.isActive || test.status !== TestStatus.PUBLISHED) {
throw new ForbiddenException('tests.errors.notActiveTest'); throw new ForbiddenException('tests.errors.notActiveTest');
@@ -91,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;
@@ -113,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 {
@@ -126,22 +151,22 @@ export class StartSubmissionProvider {
} }
} }
// const startTransition = await this.transitionsService.
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>( await this.redisService.set<GuestSubmissionSession>(
`guest_submission:${guestSubmissionId}`, `guest_submission:${guestSubmissionId}`,
{ {
testId: test.id, testId: test.id,
currentQuestion: 0, currentQuestionId: firstQuestion.id,
answers: [], answers: [],
}, },
3600, 3600,
@@ -149,7 +174,7 @@ export class StartSubmissionProvider {
return new AppResponse({ return new AppResponse({
guestSubmissionId, guestSubmissionId,
question: test.questions[0], question: firstQuestion,
}); });
} }
} }