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": { "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."
}, },
"messages": { "messages": {
"createSuccess": "Test Created Successfully." "createSuccess": "Test Created Successfully."
+6 -1
View File
@@ -9,7 +9,12 @@
}, },
"errors": { "errors": {
"intPrice": "قیمت باید عدد صحیح باشد.", "intPrice": "قیمت باید عدد صحیح باشد.",
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است." "priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است.",
"mustHaveSingleStart": "تست باید یک سوال شروع داشته باشد.",
"hasOrphanQuestions": "تست شامل تعدادی سوال یتیم است.",
"hasCycle": "تست شامل حلقه است.",
"notEndingFlow": "تست شامل جریان بی پایان است.",
"notActiveTest": "تست مورد نظر فعال نیست."
}, },
"messages": { "messages": {
"createSuccess": "تست با موفقیت ایجاد شد." "createSuccess": "تست با موفقیت ایجاد شد."
@@ -16,6 +16,8 @@ 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.interfaces';
import { TestStatus } from '@/modules/tests/enums/test-status.enum';
import { QuestionTransitionService } from '@/modules/questions/providers/transition/question-transition.service';
@Injectable() @Injectable()
export class StartSubmissionProvider { export class StartSubmissionProvider {
@@ -35,6 +37,11 @@ export class StartSubmissionProvider {
* Inject Redis Service * Inject Redis Service
*/ */
private readonly redisService: RedisService, private readonly redisService: RedisService,
/**
* Inject Transitions Service
*/
private readonly transitionsService: QuestionTransitionService,
) {} ) {}
public async start(testId: number, user?: User) { 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) { if (test.accessType !== AccessType.FREE && !user) {
throw new ForbiddenException('auth.errors.accessDenied'); throw new ForbiddenException('auth.errors.accessDenied');
} }
@@ -115,6 +126,8 @@ 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: 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 { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity'; import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { Question } from '@/modules/questions/entities/question.entity';
@Injectable() @Injectable()
export class PublishTestValidatorProvider { export class PublishTestValidatorProvider {
private readonly graph = new Map<number, QuestionTransition[]>();
private readonly memo = new Map<number, boolean>();
constructor( constructor(
/** /**
* Inject Tests Repository * Inject Tests Repository
@@ -29,6 +27,7 @@ export class PublishTestValidatorProvider {
id: testId, id: testId,
}, },
relations: { relations: {
questions: true,
transitions: { transitions: {
fromQuestion: true, fromQuestion: true,
toQuestion: true, toQuestion: true,
@@ -62,16 +61,69 @@ export class PublishTestValidatorProvider {
const startQuestionId = startTransitions[0].toQuestion!.id; 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) { for (const transition of transitions) {
if (!transition.fromQuestion) continue; if (!transition.fromQuestion) continue;
const list = this.graph.get(transition.fromQuestion.id) ?? []; const list = graph.get(transition.fromQuestion.id) ?? [];
list.push(transition); 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 visited = new Set<number>();
const stack = [startQuestionId]; const stack = [startQuestionId];
@@ -83,9 +135,9 @@ export class PublishTestValidatorProvider {
visited.add(id); 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; const toQuestion = transition.toQuestion;
if (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) { return 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,
);
}
} }
private questionReachesEnd(questionId: number) { private graphHasCycle(
if (this.memo.has(questionId)) return this.memo.get(questionId)!; 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) { for (const transition of outgoing) {
if (transition.type === QuestionTransitionType.END) { if (transition.type === QuestionTransitionType.END) {
this.memo.set(questionId, true); hasEndTransition = true;
return true; continue;
} }
if ( if (
transition.toQuestion && !transition.toQuestion ||
this.questionReachesEnd(transition.toQuestion.id) !this.allPathsReachEnd(transition.toQuestion.id, memo, graph)
) { ) {
this.memo.set(questionId, true); memo.set(questionId, false);
return true; return false;
} }
} }
this.memo.set(questionId, false); const result =
return false; 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 {}