From b4343bdb53e3162ad9687e0295ba94e6938aa015 Mon Sep 17 00:00:00 2001 From: radmehr Date: Sat, 8 Aug 2026 16:59:19 +0330 Subject: [PATCH] feat: complete publish test logic --- src/i18n/en/tests.json | 7 +- src/i18n/fa/tests.json | 7 +- .../providers/start-submission.provider.ts | 13 ++ .../publish-test-validator.provider.ts | 186 ++++++++++++------ .../tests/providers/tests-command.provider.ts | 11 ++ .../tests/providers/tests-query.provider.ts | 3 +- src/modules/tests/tests.module.ts | 8 +- 7 files changed, 170 insertions(+), 65 deletions(-) diff --git a/src/i18n/en/tests.json b/src/i18n/en/tests.json index 2ae7136..69ceef9 100644 --- a/src/i18n/en/tests.json +++ b/src/i18n/en/tests.json @@ -9,7 +9,12 @@ }, "errors": { "intPrice": "Price should be an integer.", - "priceAccessConstraint": "Test's price depends on access type of 'Paid'." + "priceAccessConstraint": "Test's price depends on access type of 'Paid'.", + "mustHaveSingleStart": "Test must have single start.", + "hasOrphanQuestions": "Test has some orphan questions.", + "hasCycle": "Test has cycle.", + "notEndingFlow": "Test include some flows which never end.", + "notActiveTest": "Test is not active." }, "messages": { "createSuccess": "Test Created Successfully." diff --git a/src/i18n/fa/tests.json b/src/i18n/fa/tests.json index 9d57bbf..5e10b47 100644 --- a/src/i18n/fa/tests.json +++ b/src/i18n/fa/tests.json @@ -9,7 +9,12 @@ }, "errors": { "intPrice": "قیمت باید عدد صحیح باشد.", - "priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است." + "priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است.", + "mustHaveSingleStart": "تست باید یک سوال شروع داشته باشد.", + "hasOrphanQuestions": "تست شامل تعدادی سوال یتیم است.", + "hasCycle": "تست شامل حلقه است.", + "notEndingFlow": "تست شامل جریان بی پایان است.", + "notActiveTest": "تست مورد نظر فعال نیست." }, "messages": { "createSuccess": "تست با موفقیت ایجاد شد." diff --git a/src/modules/submissions/providers/start-submission.provider.ts b/src/modules/submissions/providers/start-submission.provider.ts index a359a28..2c67799 100644 --- a/src/modules/submissions/providers/start-submission.provider.ts +++ b/src/modules/submissions/providers/start-submission.provider.ts @@ -16,6 +16,8 @@ import { Test } from '@/modules/tests/entities/test.entity'; import { randomUUID } from 'crypto'; import { RedisService } from '@/common/modules/redis/providers/redis.service'; import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces'; +import { TestStatus } from '@/modules/tests/enums/test-status.enum'; +import { QuestionTransitionService } from '@/modules/questions/providers/transition/question-transition.service'; @Injectable() export class StartSubmissionProvider { @@ -35,6 +37,11 @@ export class StartSubmissionProvider { * Inject Redis Service */ private readonly redisService: RedisService, + + /** + * Inject Transitions Service + */ + private readonly transitionsService: QuestionTransitionService, ) {} public async start(testId: number, user?: User) { @@ -69,6 +76,10 @@ export class StartSubmissionProvider { }, }); + if (!test.isActive || test.status !== TestStatus.PUBLISHED) { + throw new ForbiddenException('tests.errors.notActiveTest'); + } + if (test.accessType !== AccessType.FREE && !user) { throw new ForbiddenException('auth.errors.accessDenied'); } @@ -115,6 +126,8 @@ export class StartSubmissionProvider { } } + // const startTransition = await this.transitionsService. + return new AppResponse({ submissionId: submission.id, question: test.questions.length > 0 ? test.questions[0] : null, diff --git a/src/modules/tests/providers/publish-test-validator.provider.ts b/src/modules/tests/providers/publish-test-validator.provider.ts index 847adc1..fe88533 100644 --- a/src/modules/tests/providers/publish-test-validator.provider.ts +++ b/src/modules/tests/providers/publish-test-validator.provider.ts @@ -6,12 +6,10 @@ 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'; @Injectable() export class PublishTestValidatorProvider { - private readonly graph = new Map(); - private readonly memo = new Map(); - constructor( /** * Inject Tests Repository @@ -29,6 +27,7 @@ export class PublishTestValidatorProvider { id: testId, }, relations: { + questions: true, transitions: { fromQuestion: true, toQuestion: true, @@ -62,16 +61,69 @@ export class PublishTestValidatorProvider { const startQuestionId = startTransitions[0].toQuestion!.id; + const transitionsGraph = new Map(); + + this.createTransitionsGraph(transitions, transitionsGraph); + + if ( + this.hasOrphanQuestions(startQuestionId, transitionsGraph, test.questions) + ) { + throw new AppException( + 'tests.errors.hasOrphanQuestions', + HttpStatus.BAD_REQUEST, + ); + } + + const visiting = new Set(); + const visitedCycle = new Set(); + + if ( + this.graphHasCycle( + startQuestionId, + visiting, + visitedCycle, + transitionsGraph, + ) + ) { + throw new AppException('tests.errors.hasCycle', HttpStatus.BAD_REQUEST); + } + + const endingQuestionsMemo = new Map(); + + if ( + !this.allPathsReachEnd( + startQuestionId, + endingQuestionsMemo, + transitionsGraph, + ) + ) { + throw new AppException( + 'tests.errors.notEndingFlow', + HttpStatus.BAD_REQUEST, + ); + } + } + + private createTransitionsGraph( + transitions: QuestionTransition[], + graph: Map, + ) { for (const transition of transitions) { if (!transition.fromQuestion) continue; - const list = this.graph.get(transition.fromQuestion.id) ?? []; + const list = graph.get(transition.fromQuestion.id) ?? []; list.push(transition); - this.graph.set(transition.fromQuestion.id, list); + graph.set(transition.fromQuestion.id, list); } + } + private hasOrphanQuestions( + startQuestionId: number, + graph: Map, + allQuestions: Array, + ) { const visited = new Set(); const stack = [startQuestionId]; @@ -83,9 +135,9 @@ export class PublishTestValidatorProvider { visited.add(id); - const next = this.graph.get(id) ?? []; + const nextTransitions = graph.get(id) ?? []; - for (const transition of next) { + for (const transition of nextTransitions) { const toQuestion = transition.toQuestion; if (toQuestion) { @@ -94,73 +146,85 @@ export class PublishTestValidatorProvider { } } - const orphanQuestions = test.questions.filter((q) => !visited.has(q.id)); + const orphanQuestions = allQuestions.filter((q) => !visited.has(q.id)); - if (orphanQuestions.length > 0) { - throw new AppException( - 'questions.errors.test.hasOrphanQuestions', - HttpStatus.BAD_REQUEST, - ); - } - - if (!this.questionReachesEnd(startQuestionId)) { - throw new AppException( - 'questions.errors.test.noPathToEnd', - HttpStatus.BAD_REQUEST, - ); - } - - const visiting = new Set(); - const visitedCycle = new Set(); - - // TODO: Fix this - const hasCycle = (id: number): boolean => { - if (visiting.has(id)) return true; - if (visitedCycle.has(id)) return false; - - visiting.add(id); - - for (const transition of this.graph.get(id) ?? []) { - if (transition.toQuestion) { - if (hasCycle(transition.toQuestion.id)) return true; - } - } - - visiting.delete(id); - visitedCycle.add(id); - - return false; - }; - - if (hasCycle(startQuestionId)) { - throw new AppException( - 'questions.errors.test.hasCycle', - HttpStatus.BAD_REQUEST, - ); - } + return orphanQuestions.length > 0; } - private questionReachesEnd(questionId: number) { - if (this.memo.has(questionId)) return this.memo.get(questionId)!; + private graphHasCycle( + id: number, + visiting: Set, + visitedCycle: Set, + graph: Map, + ): boolean { + if (visiting.has(id)) return true; + if (visitedCycle.has(id)) return false; - const outgoing = this.graph.get(questionId) ?? []; + 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, + graph: Map, + ): 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) { - this.memo.set(questionId, true); - return true; + hasEndTransition = true; + continue; } if ( - transition.toQuestion && - this.questionReachesEnd(transition.toQuestion.id) + !transition.toQuestion || + !this.allPathsReachEnd(transition.toQuestion.id, memo, graph) ) { - this.memo.set(questionId, true); - return true; + memo.set(questionId, false); + return false; } } - this.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; } } diff --git a/src/modules/tests/providers/tests-command.provider.ts b/src/modules/tests/providers/tests-command.provider.ts index 9ad7bed..bc55653 100644 --- a/src/modules/tests/providers/tests-command.provider.ts +++ b/src/modules/tests/providers/tests-command.provider.ts @@ -9,6 +9,8 @@ import { AppResponse } from '@/common/responses'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { UpdateTestDTO } from '../dtos/update-test.dto'; import { AccessType } from '../enums/access-types.enum'; +import { PublishTestValidatorProvider } from './publish-test-validator.provider'; +import { TestStatus } from '../enums/test-status.enum'; @Injectable() export class TestsCommandProvider { @@ -23,6 +25,11 @@ export class TestsCommandProvider { * Inject I18n Service */ private readonly i18nService: I18nService, + + /** + * Inject Publish Test Validator Provider + */ + private readonly publishTestValidatorProvider: PublishTestValidatorProvider, ) {} public async createOne(createTestDto: CreateTestDTO) { @@ -76,6 +83,10 @@ export class TestsCommandProvider { Object.assign(test, updateTestDto); + if (updateTestDto.status === TestStatus.PUBLISHED) { + await this.publishTestValidatorProvider.validate(test.id); + } + try { return await this.testRepository.save(test); } catch (err) { diff --git a/src/modules/tests/providers/tests-query.provider.ts b/src/modules/tests/providers/tests-query.provider.ts index cc0db31..4ac725f 100644 --- a/src/modules/tests/providers/tests-query.provider.ts +++ b/src/modules/tests/providers/tests-query.provider.ts @@ -8,6 +8,7 @@ import { filterTests } from '../helpers/filter-tests.helper'; import Utils from '@/common/utils'; import { Test } from '../entities/test.entity'; import { AppException } from '@/common/exceptions/app.exception'; +import { TestStatus } from '../enums/test-status.enum'; @Injectable() export class TestsQueryProvider { @@ -49,7 +50,7 @@ export class TestsQueryProvider { public async findAllForUser() { try { return await this.testRepository.find({ - where: { isActive: true }, + where: { isActive: true, status: TestStatus.PUBLISHED }, select: { id: true, accessType: true, diff --git a/src/modules/tests/tests.module.ts b/src/modules/tests/tests.module.ts index b65a377..19a931b 100644 --- a/src/modules/tests/tests.module.ts +++ b/src/modules/tests/tests.module.ts @@ -5,11 +5,17 @@ import { TestsService } from './providers/tests.service'; import { TestsController } from './tests.controller'; import { TestsQueryProvider } from './providers/tests-query.provider'; import { TestsCommandProvider } from './providers/tests-command.provider'; +import { PublishTestValidatorProvider } from './providers/publish-test-validator.provider'; @Module({ imports: [TypeOrmModule.forFeature([Test])], controllers: [TestsController], - providers: [TestsService, TestsQueryProvider, TestsCommandProvider], + providers: [ + TestsService, + TestsQueryProvider, + PublishTestValidatorProvider, + TestsCommandProvider, + ], exports: [TestsService], }) export class TestsModule {}