feat: add submission history

This commit is contained in:
2026-08-21 14:28:10 +03:30
parent 2e146e52b5
commit 55ddc7f7fe
6 changed files with 387 additions and 0 deletions
@@ -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<Submission>,
/**
* Inject Submission Answer Repository
*/
@InjectRepository(SubmissionAnswer)
private readonly submissionAnswerRepository: Repository<SubmissionAnswer>,
) {}
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,
});
}
}
@@ -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<Question>,
) {}
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<GuestSubmissionSession>(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<number>();
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<number, Question>();
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,
});
}
}
@@ -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);
}
}
}
@@ -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);
}
}