feat: add submission history
This commit is contained in:
@@ -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 { User } from '@/modules/users/entities/user.entity';
|
||||||
import { AnswerSubmissionProvider } from './answer/answer-submission.provider';
|
import { AnswerSubmissionProvider } from './answer/answer-submission.provider';
|
||||||
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto';
|
||||||
|
import { SubmissionHistoryProvider } from './history/submission-history.provider';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubmissionsService {
|
export class SubmissionsService {
|
||||||
@@ -16,6 +17,11 @@ export class SubmissionsService {
|
|||||||
* Inject Answer Submission Provider
|
* Inject Answer Submission Provider
|
||||||
*/
|
*/
|
||||||
private readonly answerSubmissionProvider: AnswerSubmissionProvider,
|
private readonly answerSubmissionProvider: AnswerSubmissionProvider,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Submission History Provider
|
||||||
|
*/
|
||||||
|
private readonly submissionHistoryProvider: SubmissionHistoryProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async startSubmission(testId: number, user?: User) {
|
public async startSubmission(testId: number, user?: User) {
|
||||||
@@ -33,4 +39,8 @@ export class SubmissionsService {
|
|||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSubmissionHistory(id: string, user?: User) {
|
||||||
|
return await this.submissionHistoryProvider.getHistory(id, user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
@@ -53,4 +54,11 @@ export class SubmissionsController {
|
|||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get(':id/history')
|
||||||
|
@UseGuards(OptionalJwtGuard)
|
||||||
|
public async getHistory(@Param('id') id: string, @ActiveUser() user?: User) {
|
||||||
|
return await this.submissionsService.getSubmissionHistory(id, user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { CalculateResultProvider } from './providers/answer/calculate-result.pro
|
|||||||
import { ScoringModule } from '../scoring/scoring.module';
|
import { ScoringModule } from '../scoring/scoring.module';
|
||||||
import { AnswerGuestProvider } from './providers/answer/answer-guest.provider';
|
import { AnswerGuestProvider } from './providers/answer/answer-guest.provider';
|
||||||
import { AnswerAuthenticatedProvider } from './providers/answer/answer-authenticated.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +33,9 @@ import { AnswerAuthenticatedProvider } from './providers/answer/answer-authentic
|
|||||||
AnswerAuthenticatedProvider,
|
AnswerAuthenticatedProvider,
|
||||||
AnswerGuestProvider,
|
AnswerGuestProvider,
|
||||||
AnswerSubmissionProvider,
|
AnswerSubmissionProvider,
|
||||||
|
HistoryAuthenticatedProvider,
|
||||||
|
HistoryGuestProvider,
|
||||||
|
SubmissionHistoryProvider,
|
||||||
SubmissionsService,
|
SubmissionsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user