feat: complete publish test logic

This commit is contained in:
2026-08-08 16:59:19 +03:30
parent 9742616f28
commit b4343bdb53
7 changed files with 170 additions and 65 deletions
+6 -1
View File
@@ -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."
+6 -1
View File
@@ -9,7 +9,12 @@
},
"errors": {
"intPrice": "قیمت باید عدد صحیح باشد.",
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است."
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است.",
"mustHaveSingleStart": "تست باید یک سوال شروع داشته باشد.",
"hasOrphanQuestions": "تست شامل تعدادی سوال یتیم است.",
"hasCycle": "تست شامل حلقه است.",
"notEndingFlow": "تست شامل جریان بی پایان است.",
"notActiveTest": "تست مورد نظر فعال نیست."
},
"messages": {
"createSuccess": "تست با موفقیت ایجاد شد."
@@ -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,
@@ -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<number, QuestionTransition[]>();
private readonly memo = new Map<number, boolean>();
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<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 = 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<number, QuestionTransition[]>,
allQuestions: Array<Question>,
) {
const visited = new Set<number>();
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<number>();
const visitedCycle = new Set<number>();
// 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<number>,
visitedCycle: Set<number>,
graph: Map<number, QuestionTransition[]>,
): 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<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) {
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;
}
}
@@ -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) {
@@ -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,
+7 -1
View File
@@ -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 {}