From 55ddc7f7fe311469ce957f8cb23a0d18f2710028 Mon Sep 17 00:00:00 2001 From: radmehr Date: Fri, 21 Aug 2026 14:28:10 +0330 Subject: [PATCH] feat: add submission history --- .../history/history-authenticated.provider.ts | 144 +++++++++++++ .../history/history-guest.provider.ts | 192 ++++++++++++++++++ .../history/submission-history.provider.ts | 27 +++ .../providers/submissions.service.ts | 10 + .../submissions/submissions.controller.ts | 8 + src/modules/submissions/submissions.module.ts | 6 + 6 files changed, 387 insertions(+) create mode 100644 src/modules/submissions/providers/history/history-authenticated.provider.ts create mode 100644 src/modules/submissions/providers/history/history-guest.provider.ts create mode 100644 src/modules/submissions/providers/history/submission-history.provider.ts diff --git a/src/modules/submissions/providers/history/history-authenticated.provider.ts b/src/modules/submissions/providers/history/history-authenticated.provider.ts new file mode 100644 index 0000000..bbfcec6 --- /dev/null +++ b/src/modules/submissions/providers/history/history-authenticated.provider.ts @@ -0,0 +1,144 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Submission } from '../../entities/submission.entity'; +import { SubmissionAnswer } from '../../entities/submission-answer.entity'; +import { AppException } from '@/common/exceptions/app.exception'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { User } from '@/modules/users/entities/user.entity'; +import { AppResponse } from '@/common/responses'; + +@Injectable() +export class HistoryAuthenticatedProvider { + constructor( + /** + * Inject Submission Repository + */ + @InjectRepository(Submission) + private readonly submissionRepository: Repository, + + /** + * Inject Submission Answer Repository + */ + @InjectRepository(SubmissionAnswer) + private readonly submissionAnswerRepository: Repository, + ) {} + + public async getHistory(id: string, user: User) { + let submission: Submission | null = null; + + const submissionId = Number(id); + + if (Number.isNaN(submissionId)) { + throw new AppException( + 'validation.wrongFieldFormat', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.submissionId)' }, + ); + } + + try { + submission = await this.submissionRepository.findOne({ + where: { + id: submissionId, + user: { + id: user.id, + }, + }, + relations: { + test: true, + currentQuestion: { + choices: true, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!submission) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(submissions.fields.submission)', + }, + ); + } + + let answers: SubmissionAnswer[] = []; + + try { + answers = await this.submissionAnswerRepository.find({ + where: { + submission: { + id: submission.id, + }, + }, + relations: { + question: { + choices: true, + }, + choice: true, + choices: true, + }, + order: { + id: 'ASC', + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + const messages = answers.map((answer) => ({ + question: { + id: answer.question.id, + type: answer.question.type, + content: answer.question.content, + required: answer.question.required, + metadata: answer.question.metadata, + }, + answer: { + choiceId: answer.choice?.id, + choice: answer.choice + ? { + id: answer.choice.id, + text: answer.choice.text, + order: answer.choice.order, + } + : undefined, + + choiceIds: answer.choices?.map((choice) => choice.id), + + choices: answer.choices?.map((choice) => ({ + id: choice.id, + text: choice.text, + order: choice.order, + })), + + numericValue: answer.numericValue, + }, + })); + + return new AppResponse({ + messages, + + currentQuestion: submission.currentQuestion + ? { + id: submission.currentQuestion.id, + type: submission.currentQuestion.type, + content: submission.currentQuestion.content, + required: submission.currentQuestion.required, + metadata: submission.currentQuestion.metadata, + choices: submission.currentQuestion.choices?.map((choice) => ({ + id: choice.id, + text: choice.text, + order: choice.order, + })), + } + : null, + + isCompleted: submission.currentQuestion === null, + }); + } +} diff --git a/src/modules/submissions/providers/history/history-guest.provider.ts b/src/modules/submissions/providers/history/history-guest.provider.ts new file mode 100644 index 0000000..b471e1e --- /dev/null +++ b/src/modules/submissions/providers/history/history-guest.provider.ts @@ -0,0 +1,192 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { RedisService } from '@/common/modules/redis/providers/redis.service'; +import { AppException } from '@/common/exceptions/app.exception'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppResponse } from '@/common/responses'; + +import { Question } from '@/modules/questions/entities/question.entity'; +import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface'; +import { isUUID } from 'class-validator'; + +@Injectable() +export class HistoryGuestProvider { + constructor( + /** + * Inject Redis Service + */ + private readonly redisService: RedisService, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + ) {} + + public async getHistory(id: string) { + if (!isUUID(id)) { + throw new AppException( + 'validation.wrongFieldFormat', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.submissionId)' }, + ); + } + + const key = `guest_submission:${id}`; + + let submission: GuestSubmissionSession | null = null; + + try { + submission = await this.redisService.get(key); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!submission) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(submissions.fields.submission)', + }, + ); + } + + /** + * Get all question IDs that we need. + */ + const questionIds = new Set(); + + for (const answer of submission.answers) { + questionIds.add(answer.questionId); + } + + if (submission.currentQuestionId !== null) { + questionIds.add(submission.currentQuestionId); + } + + /** + * Load questions from DB. + */ + let questions: Question[] = []; + + if (questionIds.size > 0) { + try { + questions = await this.questionsRepository.find({ + where: { + id: In([...questionIds]), + }, + relations: { + choices: true, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + const questionsMap = new Map(); + + for (const question of questions) { + questionsMap.set(question.id, question); + } + + /** + * Build messages. + */ + const messages = submission.answers + .map((answer) => { + const question = questionsMap.get(answer.questionId); + + if (!question) { + return null; + } + + const selectedChoices = answer.choiceIds + ? question.choices.filter((choice) => + answer.choiceIds!.includes(choice.id), + ) + : []; + + const selectedChoice = + answer.choiceId !== undefined + ? question.choices.find((choice) => choice.id === answer.choiceId) + : undefined; + + return { + question: { + id: question.id, + type: question.type, + content: question.content, + required: question.required, + metadata: question.metadata, + }, + + answer: { + choiceId: answer.choiceId, + + choice: selectedChoice + ? { + id: selectedChoice.id, + text: selectedChoice.text, + order: selectedChoice.order, + } + : undefined, + + choiceIds: answer.choiceIds, + + choices: selectedChoices.map((choice) => ({ + id: choice.id, + text: choice.text, + order: choice.order, + })), + + numericValue: answer.numericValue, + }, + }; + }) + .filter((message) => message !== null); + + /** + * Current question. + */ + const currentQuestion = + submission.currentQuestionId !== null + ? questionsMap.get(submission.currentQuestionId) + : null; + + return new AppResponse({ + messages, + + currentQuestion: currentQuestion + ? { + id: currentQuestion.id, + type: currentQuestion.type, + content: currentQuestion.content, + required: currentQuestion.required, + metadata: currentQuestion.metadata, + + choices: currentQuestion.choices?.map((choice) => ({ + id: choice.id, + text: choice.text, + order: choice.order, + })), + } + : null, + + isCompleted: submission.currentQuestionId === null, + + result: submission.result + ? { + score: submission.result.score, + probability: submission.result.probability, + title: submission.result.title, + description: submission.result.description, + } + : null, + }); + } +} diff --git a/src/modules/submissions/providers/history/submission-history.provider.ts b/src/modules/submissions/providers/history/submission-history.provider.ts new file mode 100644 index 0000000..cbe071e --- /dev/null +++ b/src/modules/submissions/providers/history/submission-history.provider.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { HistoryGuestProvider } from './history-guest.provider'; +import { HistoryAuthenticatedProvider } from './history-authenticated.provider'; +import { User } from '@/modules/users/entities/user.entity'; + +@Injectable() +export class SubmissionHistoryProvider { + constructor( + /** + * Inject History Guest Provider + */ + private readonly historyGuestProvider: HistoryGuestProvider, + + /** + * Inject History Authenticated Provider + */ + private readonly historyAuthenticatedProvider: HistoryAuthenticatedProvider, + ) {} + + public async getHistory(id: string, user?: User) { + if (user) { + return await this.historyAuthenticatedProvider.getHistory(id, user); + } else { + return await this.historyGuestProvider.getHistory(id); + } + } +} diff --git a/src/modules/submissions/providers/submissions.service.ts b/src/modules/submissions/providers/submissions.service.ts index 12b6d72..99c8977 100644 --- a/src/modules/submissions/providers/submissions.service.ts +++ b/src/modules/submissions/providers/submissions.service.ts @@ -3,6 +3,7 @@ import { StartSubmissionProvider } from './start/start-submission.provider'; import { User } from '@/modules/users/entities/user.entity'; import { AnswerSubmissionProvider } from './answer/answer-submission.provider'; import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; +import { SubmissionHistoryProvider } from './history/submission-history.provider'; @Injectable() export class SubmissionsService { @@ -16,6 +17,11 @@ export class SubmissionsService { * Inject Answer Submission Provider */ private readonly answerSubmissionProvider: AnswerSubmissionProvider, + + /** + * Inject Submission History Provider + */ + private readonly submissionHistoryProvider: SubmissionHistoryProvider, ) {} public async startSubmission(testId: number, user?: User) { @@ -33,4 +39,8 @@ export class SubmissionsService { user, ); } + + public async getSubmissionHistory(id: string, user?: User) { + return await this.submissionHistoryProvider.getHistory(id, user); + } } diff --git a/src/modules/submissions/submissions.controller.ts b/src/modules/submissions/submissions.controller.ts index 65d2add..29d6a3f 100644 --- a/src/modules/submissions/submissions.controller.ts +++ b/src/modules/submissions/submissions.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Param, @@ -53,4 +54,11 @@ export class SubmissionsController { user, ); } + + @Public() + @Get(':id/history') + @UseGuards(OptionalJwtGuard) + public async getHistory(@Param('id') id: string, @ActiveUser() user?: User) { + return await this.submissionsService.getSubmissionHistory(id, user); + } } diff --git a/src/modules/submissions/submissions.module.ts b/src/modules/submissions/submissions.module.ts index f20dd3f..2758714 100644 --- a/src/modules/submissions/submissions.module.ts +++ b/src/modules/submissions/submissions.module.ts @@ -14,6 +14,9 @@ import { CalculateResultProvider } from './providers/answer/calculate-result.pro import { ScoringModule } from '../scoring/scoring.module'; import { AnswerGuestProvider } from './providers/answer/answer-guest.provider'; import { AnswerAuthenticatedProvider } from './providers/answer/answer-authenticated.provider'; +import { HistoryAuthenticatedProvider } from './providers/history/history-authenticated.provider'; +import { HistoryGuestProvider } from './providers/history/history-guest.provider'; +import { SubmissionHistoryProvider } from './providers/history/submission-history.provider'; @Module({ imports: [ @@ -30,6 +33,9 @@ import { AnswerAuthenticatedProvider } from './providers/answer/answer-authentic AnswerAuthenticatedProvider, AnswerGuestProvider, AnswerSubmissionProvider, + HistoryAuthenticatedProvider, + HistoryGuestProvider, + SubmissionHistoryProvider, SubmissionsService, ], })