feat: complete publish test logic
This commit is contained in:
@@ -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."
|
||||||
|
|||||||
@@ -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,35 +146,33 @@ 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)) {
|
private graphHasCycle(
|
||||||
throw new AppException(
|
id: number,
|
||||||
'questions.errors.test.noPathToEnd',
|
visiting: Set<number>,
|
||||||
HttpStatus.BAD_REQUEST,
|
visitedCycle: Set<number>,
|
||||||
);
|
graph: Map<number, QuestionTransition[]>,
|
||||||
}
|
): boolean {
|
||||||
|
|
||||||
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 (visiting.has(id)) return true;
|
||||||
if (visitedCycle.has(id)) return false;
|
if (visitedCycle.has(id)) return false;
|
||||||
|
|
||||||
visiting.add(id);
|
visiting.add(id);
|
||||||
|
|
||||||
for (const transition of this.graph.get(id) ?? []) {
|
for (const transition of graph.get(id) ?? []) {
|
||||||
if (transition.toQuestion) {
|
if (transition.toQuestion) {
|
||||||
if (hasCycle(transition.toQuestion.id)) return true;
|
if (
|
||||||
|
this.graphHasCycle(
|
||||||
|
transition.toQuestion.id,
|
||||||
|
visiting,
|
||||||
|
visitedCycle,
|
||||||
|
graph,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,37 +180,51 @@ export class PublishTestValidatorProvider {
|
|||||||
visitedCycle.add(id);
|
visitedCycle.add(id);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
|
||||||
|
|
||||||
if (hasCycle(startQuestionId)) {
|
|
||||||
throw new AppException(
|
|
||||||
'questions.errors.test.hasCycle',
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private questionReachesEnd(questionId: number) {
|
private allPathsReachEnd(
|
||||||
if (this.memo.has(questionId)) return this.memo.get(questionId)!;
|
questionId: number,
|
||||||
|
memo: Map<number, boolean>,
|
||||||
|
graph: Map<number, QuestionTransition[]>,
|
||||||
|
): boolean {
|
||||||
|
if (memo.has(questionId)) {
|
||||||
|
return memo.get(questionId)!;
|
||||||
|
}
|
||||||
|
|
||||||
const outgoing = this.graph.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.memo.set(questionId, false);
|
|
||||||
return 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 { 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,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user