Merge pull request #171 from SELab-2/feat/question-routes
feat: Question en Answer routes
This commit is contained in:
commit
1405fd66d1
32 changed files with 815 additions and 218 deletions
|
@ -5,3 +5,4 @@ export const DWENGO_API_BASE = getEnvVar(envVars.LearningContentRepoApiBaseUrl);
|
|||
export const FALLBACK_LANG = getEnvVar(envVars.FallbackLanguage);
|
||||
|
||||
export const FALLBACK_SEQ_NUM = 1;
|
||||
export const FALLBACK_VERSION_NUM = 1;
|
||||
|
|
99
backend/src/controllers/answers.ts
Normal file
99
backend/src/controllers/answers.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { requireFields } from './error-helper.js';
|
||||
import { getLearningObjectId, getQuestionId } from './questions.js';
|
||||
import { createAnswer, deleteAnswer, getAnswer, getAnswersByQuestion, updateAnswer } from '../services/answers.js';
|
||||
import { FALLBACK_SEQ_NUM } from '../config.js';
|
||||
import { AnswerData } from '@dwengo-1/common/interfaces/answer';
|
||||
|
||||
export async function getAllAnswersHandler(req: Request, res: Response): Promise<void> {
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
const full = req.query.full === 'true';
|
||||
requireFields({ hruid });
|
||||
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const answers = await getAnswersByQuestion(questionId, full);
|
||||
|
||||
res.json({ answers });
|
||||
}
|
||||
|
||||
export async function getAnswerHandler(req: Request, res: Response): Promise<void> {
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
const seqAnswer = req.params.seqAnswer;
|
||||
requireFields({ hruid });
|
||||
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM;
|
||||
const answer = await getAnswer(questionId, sequenceNumber);
|
||||
|
||||
res.json({ answer });
|
||||
}
|
||||
|
||||
export async function createAnswerHandler(req: Request, res: Response): Promise<void> {
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
requireFields({ hruid });
|
||||
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const author = req.body.author as string;
|
||||
const content = req.body.content as string;
|
||||
requireFields({ author, content });
|
||||
|
||||
const answerData = req.body as AnswerData;
|
||||
|
||||
const answer = await createAnswer(questionId, answerData);
|
||||
|
||||
res.json({ answer });
|
||||
}
|
||||
|
||||
export async function deleteAnswerHandler(req: Request, res: Response): Promise<void> {
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
const seqAnswer = req.params.seqAnswer;
|
||||
requireFields({ hruid });
|
||||
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM;
|
||||
const answer = await deleteAnswer(questionId, sequenceNumber);
|
||||
|
||||
res.json({ answer });
|
||||
}
|
||||
|
||||
export async function updateAnswerHandler(req: Request, res: Response): Promise<void> {
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
const seqAnswer = req.params.seqAnswer;
|
||||
requireFields({ hruid });
|
||||
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const content = req.body.content as string;
|
||||
requireFields({ content });
|
||||
|
||||
const answerData = req.body as AnswerData;
|
||||
|
||||
const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM;
|
||||
const answer = await updateAnswer(questionId, sequenceNumber, answerData);
|
||||
|
||||
res.json({ answer });
|
||||
}
|
|
@ -6,9 +6,9 @@ import attachmentService from '../services/learning-objects/attachment-service.j
|
|||
import { BadRequestException } from '../exceptions/bad-request-exception.js';
|
||||
import { NotFoundException } from '../exceptions/not-found-exception.js';
|
||||
import { envVars, getEnvVar } from '../util/envVars.js';
|
||||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
|
||||
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier {
|
||||
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO {
|
||||
if (!req.params.hruid) {
|
||||
throw new BadRequestException('HRUID is required.');
|
||||
}
|
||||
|
|
|
@ -1,34 +1,20 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { createQuestion, deleteQuestion, getAllQuestions, getAnswersByQuestion, getQuestion } from '../services/questions.js';
|
||||
import { FALLBACK_LANG, FALLBACK_SEQ_NUM } from '../config.js';
|
||||
import { createQuestion, deleteQuestion, getAllQuestions, getQuestion, updateQuestion } from '../services/questions.js';
|
||||
import { FALLBACK_LANG, FALLBACK_SEQ_NUM, FALLBACK_VERSION_NUM } from '../config.js';
|
||||
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
|
||||
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
|
||||
import { QuestionData, QuestionId } from '@dwengo-1/common/interfaces/question';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { requireFields } from './error-helper.js';
|
||||
|
||||
function getObjectId(req: Request, res: Response): LearningObjectIdentifier | null {
|
||||
const { hruid, version } = req.params;
|
||||
const lang = req.query.lang;
|
||||
|
||||
if (!hruid || !version) {
|
||||
res.status(400).json({ error: 'Missing required parameters.' });
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLearningObjectId(hruid: string, version: string, lang: string): LearningObjectIdentifier {
|
||||
return {
|
||||
hruid,
|
||||
language: (lang as Language) || FALLBACK_LANG,
|
||||
version: Number(version),
|
||||
language: (lang || FALLBACK_LANG) as Language,
|
||||
version: Number(version) || FALLBACK_VERSION_NUM,
|
||||
};
|
||||
}
|
||||
|
||||
function getQuestionId(req: Request, res: Response): QuestionId | null {
|
||||
const seq = req.params.seq;
|
||||
const learningObjectIdentifier = getObjectId(req, res);
|
||||
|
||||
if (!learningObjectIdentifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getQuestionId(learningObjectIdentifier: LearningObjectIdentifier, seq: string): QuestionId {
|
||||
return {
|
||||
learningObjectIdentifier,
|
||||
sequenceNumber: seq ? Number(seq) : FALLBACK_SEQ_NUM,
|
||||
|
@ -36,84 +22,84 @@ function getQuestionId(req: Request, res: Response): QuestionId | null {
|
|||
}
|
||||
|
||||
export async function getAllQuestionsHandler(req: Request, res: Response): Promise<void> {
|
||||
const objectId = getObjectId(req, res);
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const full = req.query.full === 'true';
|
||||
requireFields({ hruid });
|
||||
|
||||
if (!objectId) {
|
||||
return;
|
||||
}
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
|
||||
const questions = await getAllQuestions(objectId, full);
|
||||
const questions = await getAllQuestions(learningObjectId, full);
|
||||
|
||||
if (!questions) {
|
||||
res.status(404).json({ error: `Questions not found.` });
|
||||
} else {
|
||||
res.json({ questions: questions });
|
||||
}
|
||||
res.json({ questions });
|
||||
}
|
||||
|
||||
export async function getQuestionHandler(req: Request, res: Response): Promise<void> {
|
||||
const questionId = getQuestionId(req, res);
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
requireFields({ hruid });
|
||||
|
||||
if (!questionId) {
|
||||
return;
|
||||
}
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const question = await getQuestion(questionId);
|
||||
|
||||
if (!question) {
|
||||
res.status(404).json({ error: `Question not found.` });
|
||||
} else {
|
||||
res.json(question);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuestionAnswersHandler(req: Request, res: Response): Promise<void> {
|
||||
const questionId = getQuestionId(req, res);
|
||||
const full = req.query.full === 'true';
|
||||
|
||||
if (!questionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answers = await getAnswersByQuestion(questionId, full);
|
||||
|
||||
if (!answers) {
|
||||
res.status(404).json({ error: `Questions not found` });
|
||||
} else {
|
||||
res.json({ answers: answers });
|
||||
}
|
||||
res.json({ question });
|
||||
}
|
||||
|
||||
export async function createQuestionHandler(req: Request, res: Response): Promise<void> {
|
||||
const questionDTO = req.body as QuestionDTO;
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
requireFields({ hruid });
|
||||
|
||||
if (!questionDTO.learningObjectIdentifier || !questionDTO.author || !questionDTO.content) {
|
||||
res.status(400).json({ error: 'Missing required fields: identifier and content' });
|
||||
return;
|
||||
}
|
||||
const loId = getLearningObjectId(hruid, version, language);
|
||||
|
||||
const question = await createQuestion(questionDTO);
|
||||
const author = req.body.author as string;
|
||||
const content = req.body.content as string;
|
||||
requireFields({ author, content });
|
||||
|
||||
if (!question) {
|
||||
res.status(400).json({ error: 'Could not create question' });
|
||||
} else {
|
||||
res.json(question);
|
||||
}
|
||||
const questionData = req.body as QuestionData;
|
||||
|
||||
const question = await createQuestion(loId, questionData);
|
||||
|
||||
res.json({ question });
|
||||
}
|
||||
|
||||
export async function deleteQuestionHandler(req: Request, res: Response): Promise<void> {
|
||||
const questionId = getQuestionId(req, res);
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
requireFields({ hruid });
|
||||
|
||||
if (!questionId) {
|
||||
return;
|
||||
}
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const question = await deleteQuestion(questionId);
|
||||
|
||||
if (!question) {
|
||||
res.status(400).json({ error: 'Could not find nor delete question' });
|
||||
} else {
|
||||
res.json(question);
|
||||
}
|
||||
res.json({ question });
|
||||
}
|
||||
|
||||
export async function updateQuestionHandler(req: Request, res: Response): Promise<void> {
|
||||
const hruid = req.params.hruid;
|
||||
const version = req.params.version;
|
||||
const language = req.query.lang as string;
|
||||
const seq = req.params.seq;
|
||||
requireFields({ hruid });
|
||||
|
||||
const learningObjectId = getLearningObjectId(hruid, version, language);
|
||||
const questionId = getQuestionId(learningObjectId, seq);
|
||||
|
||||
const content = req.body.content as string;
|
||||
requireFields({ content });
|
||||
|
||||
const questionData = req.body as QuestionData;
|
||||
|
||||
const question = await updateQuestion(questionId, questionData);
|
||||
|
||||
res.json({ question });
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
|
|||
import { Answer } from '../../entities/questions/answer.entity.js';
|
||||
import { Question } from '../../entities/questions/question.entity.js';
|
||||
import { Teacher } from '../../entities/users/teacher.entity.js';
|
||||
import { Loaded } from '@mikro-orm/core';
|
||||
|
||||
export class AnswerRepository extends DwengoEntityRepository<Answer> {
|
||||
public async createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> {
|
||||
|
@ -19,10 +20,21 @@ export class AnswerRepository extends DwengoEntityRepository<Answer> {
|
|||
orderBy: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
public async findAnswer(question: Question, sequenceNumber: number): Promise<Loaded<Answer> | null> {
|
||||
return this.findOne({
|
||||
toQuestion: question,
|
||||
sequenceNumber,
|
||||
});
|
||||
}
|
||||
public async removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise<void> {
|
||||
return this.deleteWhere({
|
||||
toQuestion: question,
|
||||
sequenceNumber: sequenceNumber,
|
||||
});
|
||||
}
|
||||
public async updateContent(answer: Answer, newContent: string): Promise<Answer> {
|
||||
answer.content = newContent;
|
||||
await this.save(answer);
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Question } from '../../entities/questions/question.entity.js';
|
|||
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
|
||||
import { Student } from '../../entities/users/student.entity.js';
|
||||
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||
import { Loaded } from '@mikro-orm/core';
|
||||
|
||||
export class QuestionRepository extends DwengoEntityRepository<Question> {
|
||||
public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> {
|
||||
|
@ -61,4 +62,19 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
|
|||
orderBy: { timestamp: 'DESC' }, // New to old
|
||||
});
|
||||
}
|
||||
|
||||
public async findByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise<Loaded<Question> | null> {
|
||||
return this.findOne({
|
||||
learningObjectHruid: loId.hruid,
|
||||
learningObjectLanguage: loId.language,
|
||||
learningObjectVersion: loId.version,
|
||||
sequenceNumber,
|
||||
});
|
||||
}
|
||||
|
||||
public async updateContent(question: Question, newContent: string): Promise<Question> {
|
||||
question.content = newContent;
|
||||
await this.save(question);
|
||||
return question;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { mapToUserDTO } from './user.js';
|
||||
import { mapToQuestionDTO, mapToQuestionDTOId } from './question.js';
|
||||
import { Answer } from '../entities/questions/answer.entity.js';
|
||||
import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer';
|
||||
import { mapToTeacherDTO } from './teacher.js';
|
||||
|
||||
/**
|
||||
* Convert a Question entity to a DTO format.
|
||||
*/
|
||||
export function mapToAnswerDTO(answer: Answer): AnswerDTO {
|
||||
return {
|
||||
author: mapToUserDTO(answer.author),
|
||||
author: mapToTeacherDTO(answer.author),
|
||||
toQuestion: mapToQuestionDTO(answer.toQuestion),
|
||||
sequenceNumber: answer.sequenceNumber!,
|
||||
timestamp: answer.timestamp.toISOString(),
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Question } from '../entities/questions/question.entity.js';
|
||||
import { mapToStudentDTO } from './student.js';
|
||||
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
|
||||
import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
|
||||
|
||||
function getLearningObjectIdentifier(question: Question): LearningObjectIdentifier {
|
||||
function getLearningObjectIdentifier(question: Question): LearningObjectIdentifierDTO {
|
||||
return {
|
||||
hruid: question.learningObjectHruid,
|
||||
language: question.learningObjectLanguage,
|
||||
|
@ -11,6 +12,14 @@ function getLearningObjectIdentifier(question: Question): LearningObjectIdentifi
|
|||
};
|
||||
}
|
||||
|
||||
export function mapToLearningObjectID(loID: LearningObjectIdentifierDTO): LearningObjectIdentifier {
|
||||
return {
|
||||
hruid: loID.hruid,
|
||||
language: loID.language,
|
||||
version: loID.version ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Question entity to a DTO format.
|
||||
*/
|
||||
|
|
16
backend/src/routes/answers.ts
Normal file
16
backend/src/routes/answers.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import express from 'express';
|
||||
import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', getAllAnswersHandler);
|
||||
|
||||
router.post('/', createAnswerHandler);
|
||||
|
||||
router.get('/:seqAnswer', getAnswerHandler);
|
||||
|
||||
router.delete('/:seqAnswer', deleteAnswerHandler);
|
||||
|
||||
router.put('/:seqAnswer', updateAnswerHandler);
|
||||
|
||||
export default router;
|
|
@ -1,11 +1,7 @@
|
|||
import express from 'express';
|
||||
import {
|
||||
createQuestionHandler,
|
||||
deleteQuestionHandler,
|
||||
getAllQuestionsHandler,
|
||||
getQuestionAnswersHandler,
|
||||
getQuestionHandler,
|
||||
} from '../controllers/questions.js';
|
||||
import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js';
|
||||
import answerRoutes from './answers.js';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
// Query language
|
||||
|
@ -20,6 +16,6 @@ router.delete('/:seq', deleteQuestionHandler);
|
|||
// Information about a question with id
|
||||
router.get('/:seq', getQuestionHandler);
|
||||
|
||||
router.get('/answers/:seq', getQuestionAnswersHandler);
|
||||
router.use('/:seq/answers', answerRoutes);
|
||||
|
||||
export default router;
|
||||
|
|
70
backend/src/services/answers.ts
Normal file
70
backend/src/services/answers.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { getAnswerRepository } from '../data/repositories.js';
|
||||
import { Answer } from '../entities/questions/answer.entity.js';
|
||||
import { mapToAnswerDTO, mapToAnswerDTOId } from '../interfaces/answer.js';
|
||||
import { fetchTeacher } from './teachers.js';
|
||||
import { fetchQuestion } from './questions.js';
|
||||
import { QuestionId } from '@dwengo-1/common/interfaces/question';
|
||||
import { AnswerData, AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer';
|
||||
import { NotFoundException } from '../exceptions/not-found-exception.js';
|
||||
|
||||
export async function getAnswersByQuestion(questionId: QuestionId, full: boolean): Promise<AnswerDTO[] | AnswerId[]> {
|
||||
const answerRepository = getAnswerRepository();
|
||||
const question = await fetchQuestion(questionId);
|
||||
|
||||
const answers: Answer[] = await answerRepository.findAllAnswersToQuestion(question);
|
||||
|
||||
if (full) {
|
||||
return answers.map(mapToAnswerDTO);
|
||||
}
|
||||
|
||||
return answers.map(mapToAnswerDTOId);
|
||||
}
|
||||
|
||||
export async function createAnswer(questionId: QuestionId, answerData: AnswerData): Promise<AnswerDTO> {
|
||||
const answerRepository = getAnswerRepository();
|
||||
const toQuestion = await fetchQuestion(questionId);
|
||||
const author = await fetchTeacher(answerData.author);
|
||||
const content = answerData.content;
|
||||
|
||||
const answer = await answerRepository.createAnswer({
|
||||
toQuestion,
|
||||
author,
|
||||
content,
|
||||
});
|
||||
return mapToAnswerDTO(answer);
|
||||
}
|
||||
|
||||
async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> {
|
||||
const answerRepository = getAnswerRepository();
|
||||
const question = await fetchQuestion(questionId);
|
||||
const answer = await answerRepository.findAnswer(question, sequenceNumber);
|
||||
|
||||
if (!answer) {
|
||||
throw new NotFoundException('Answer with questionID and sequence number not found');
|
||||
}
|
||||
|
||||
return answer;
|
||||
}
|
||||
|
||||
export async function getAnswer(questionId: QuestionId, sequenceNumber: number): Promise<AnswerDTO> {
|
||||
const answer = await fetchAnswer(questionId, sequenceNumber);
|
||||
return mapToAnswerDTO(answer);
|
||||
}
|
||||
|
||||
export async function deleteAnswer(questionId: QuestionId, sequenceNumber: number): Promise<AnswerDTO> {
|
||||
const answerRepository = getAnswerRepository();
|
||||
|
||||
const question = await fetchQuestion(questionId);
|
||||
const answer = await fetchAnswer(questionId, sequenceNumber);
|
||||
|
||||
await answerRepository.removeAnswerByQuestionAndSequenceNumber(question, sequenceNumber);
|
||||
return mapToAnswerDTO(answer);
|
||||
}
|
||||
|
||||
export async function updateAnswer(questionId: QuestionId, sequenceNumber: number, answerData: AnswerData): Promise<AnswerDTO> {
|
||||
const answerRepository = getAnswerRepository();
|
||||
const answer = await fetchAnswer(questionId, sequenceNumber);
|
||||
|
||||
const newAnswer = await answerRepository.updateContent(answer, answerData.content);
|
||||
return mapToAnswerDTO(newAnswer);
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import { getAttachmentRepository } from '../../data/repositories.js';
|
||||
import { Attachment } from '../../entities/content/attachment.entity.js';
|
||||
|
||||
import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content';
|
||||
|
||||
const attachmentService = {
|
||||
async getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise<Attachment | null> {
|
||||
async getAttachment(learningObjectId: LearningObjectIdentifierDTO, attachmentName: string): Promise<Attachment | null> {
|
||||
const attachmentRepo = getAttachmentRepository();
|
||||
|
||||
if (learningObjectId.version) {
|
||||
|
|
|
@ -6,7 +6,7 @@ import processingService from './processing/processing-service.js';
|
|||
import { NotFoundError } from '@mikro-orm/core';
|
||||
import learningObjectService from './learning-object-service.js';
|
||||
import { getLogger, Logger } from '../../logging/initalize.js';
|
||||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
|
@ -40,7 +40,7 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL
|
|||
};
|
||||
}
|
||||
|
||||
async function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise<LearningObject | null> {
|
||||
async function findLearningObjectEntityById(id: LearningObjectIdentifierDTO): Promise<LearningObject | null> {
|
||||
const learningObjectRepo = getLearningObjectRepository();
|
||||
|
||||
return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language);
|
||||
|
@ -53,7 +53,7 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
|
|||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
|
||||
async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null> {
|
||||
const learningObject = await findLearningObjectEntityById(id);
|
||||
return convertLearningObject(learningObject);
|
||||
},
|
||||
|
@ -61,7 +61,7 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
|
|||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
|
||||
const learningObjectRepo = getLearningObjectRepository();
|
||||
|
||||
const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language);
|
||||
|
|
|
@ -5,7 +5,7 @@ import { LearningObjectProvider } from './learning-object-provider.js';
|
|||
import { getLogger, Logger } from '../../logging/initalize.js';
|
||||
import {
|
||||
FilteredLearningObject,
|
||||
LearningObjectIdentifier,
|
||||
LearningObjectIdentifierDTO,
|
||||
LearningObjectMetadata,
|
||||
LearningObjectNode,
|
||||
LearningPathIdentifier,
|
||||
|
@ -67,7 +67,7 @@ async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full
|
|||
|
||||
const objects = await Promise.all(
|
||||
nodes.map(async (node) => {
|
||||
const learningObjectId: LearningObjectIdentifier = {
|
||||
const learningObjectId: LearningObjectIdentifierDTO = {
|
||||
hruid: node.learningobject_hruid,
|
||||
language: learningPathId.language,
|
||||
};
|
||||
|
@ -85,7 +85,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
|
|||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
|
||||
async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null> {
|
||||
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
|
||||
const metadata = await fetchWithLogging<LearningObjectMetadata>(
|
||||
metadataUrl,
|
||||
|
@ -121,7 +121,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
|
|||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects
|
||||
* from the Dwengo API, this means passing through the HTML rendering from there.
|
||||
*/
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
|
||||
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
|
||||
const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
|
||||
params: { ...id },
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
|
||||
export interface LearningObjectProvider {
|
||||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null>;
|
||||
getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null>;
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
|
@ -19,5 +19,5 @@ export interface LearningObjectProvider {
|
|||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null>;
|
||||
getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>;
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@ import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provid
|
|||
import { LearningObjectProvider } from './learning-object-provider.js';
|
||||
import { envVars, getEnvVar } from '../../util/envVars.js';
|
||||
import databaseLearningObjectProvider from './database-learning-object-provider.js';
|
||||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
|
||||
function getProvider(id: LearningObjectIdentifier): LearningObjectProvider {
|
||||
function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider {
|
||||
if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
|
||||
return databaseLearningObjectProvider;
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ const learningObjectService = {
|
|||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
|
||||
async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null> {
|
||||
return getProvider(id).getLearningObjectById(id);
|
||||
},
|
||||
|
||||
|
@ -39,7 +39,7 @@ const learningObjectService = {
|
|||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
|
||||
return getProvider(id).getLearningObjectHTML(id);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ import Image = marked.Tokens.Image;
|
|||
import Heading = marked.Tokens.Heading;
|
||||
import Link = marked.Tokens.Link;
|
||||
import RendererObject = marked.RendererObject;
|
||||
import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
|
||||
const prefixes = {
|
||||
|
@ -25,7 +25,7 @@ const prefixes = {
|
|||
blockly: '@blockly',
|
||||
};
|
||||
|
||||
function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier {
|
||||
function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifierDTO {
|
||||
const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/');
|
||||
return {
|
||||
hruid,
|
||||
|
|
|
@ -14,7 +14,7 @@ import { LearningObject } from '../../../entities/content/learning-object.entity
|
|||
import Processor from './processor.js';
|
||||
import { DwengoContentType } from './content-type.js';
|
||||
import { replaceAsync } from '../../../util/async.js';
|
||||
import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
|
||||
const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g;
|
||||
|
@ -50,7 +50,7 @@ class ProcessingService {
|
|||
*/
|
||||
async render(
|
||||
learningObject: LearningObject,
|
||||
fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise<LearningObject | null>
|
||||
fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifierDTO) => Promise<LearningObject | null>
|
||||
): Promise<string> {
|
||||
const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject);
|
||||
if (fetchEmbeddedLearningObjects) {
|
||||
|
|
|
@ -1,22 +1,17 @@
|
|||
import { getAnswerRepository, getQuestionRepository } from '../data/repositories.js';
|
||||
import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
|
||||
import { getQuestionRepository } from '../data/repositories.js';
|
||||
import { mapToLearningObjectID, mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
|
||||
import { Question } from '../entities/questions/question.entity.js';
|
||||
import { Answer } from '../entities/questions/answer.entity.js';
|
||||
import { mapToAnswerDTO, mapToAnswerDTOId } from '../interfaces/answer.js';
|
||||
import { QuestionRepository } from '../data/questions/question-repository.js';
|
||||
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
|
||||
import { mapToStudent } from '../interfaces/student.js';
|
||||
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
|
||||
import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer';
|
||||
import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
|
||||
import { NotFoundException } from '../exceptions/not-found-exception.js';
|
||||
import { FALLBACK_VERSION_NUM } from '../config.js';
|
||||
import { fetchStudent } from './students.js';
|
||||
|
||||
export async function getAllQuestions(id: LearningObjectIdentifier, full: boolean): Promise<QuestionDTO[] | QuestionId[]> {
|
||||
const questionRepository: QuestionRepository = getQuestionRepository();
|
||||
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
|
||||
|
||||
if (!questions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (full) {
|
||||
return questions.map(mapToQuestionDTO);
|
||||
}
|
||||
|
@ -24,90 +19,57 @@ export async function getAllQuestions(id: LearningObjectIdentifier, full: boolea
|
|||
return questions.map(mapToQuestionDTOId);
|
||||
}
|
||||
|
||||
async function fetchQuestion(questionId: QuestionId): Promise<Question | null> {
|
||||
export async function fetchQuestion(questionId: QuestionId): Promise<Question> {
|
||||
const questionRepository = getQuestionRepository();
|
||||
const question = await questionRepository.findByLearningObjectAndSequenceNumber(
|
||||
mapToLearningObjectID(questionId.learningObjectIdentifier),
|
||||
questionId.sequenceNumber
|
||||
);
|
||||
|
||||
return await questionRepository.findOne({
|
||||
learningObjectHruid: questionId.learningObjectIdentifier.hruid,
|
||||
learningObjectLanguage: questionId.learningObjectIdentifier.language,
|
||||
learningObjectVersion: questionId.learningObjectIdentifier.version,
|
||||
sequenceNumber: questionId.sequenceNumber,
|
||||
if (!question) {
|
||||
throw new NotFoundException('Question with loID and sequence number not found');
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
|
||||
export async function getQuestion(questionId: QuestionId): Promise<QuestionDTO> {
|
||||
const question = await fetchQuestion(questionId);
|
||||
return mapToQuestionDTO(question);
|
||||
}
|
||||
|
||||
export async function createQuestion(loId: LearningObjectIdentifier, questionData: QuestionData): Promise<QuestionDTO> {
|
||||
const questionRepository = getQuestionRepository();
|
||||
const author = await fetchStudent(questionData.author!);
|
||||
const content = questionData.content;
|
||||
|
||||
const question = await questionRepository.createQuestion({
|
||||
loId,
|
||||
author,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getQuestion(questionId: QuestionId): Promise<QuestionDTO | null> {
|
||||
const question = await fetchQuestion(questionId);
|
||||
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapToQuestionDTO(question);
|
||||
}
|
||||
|
||||
export async function getAnswersByQuestion(questionId: QuestionId, full: boolean): Promise<AnswerDTO[] | AnswerId[]> {
|
||||
const answerRepository = getAnswerRepository();
|
||||
const question = await fetchQuestion(questionId);
|
||||
|
||||
if (!question) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const answers: Answer[] = await answerRepository.findAllAnswersToQuestion(question);
|
||||
|
||||
if (!answers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (full) {
|
||||
return answers.map(mapToAnswerDTO);
|
||||
}
|
||||
|
||||
return answers.map(mapToAnswerDTOId);
|
||||
}
|
||||
|
||||
export async function createQuestion(questionDTO: QuestionDTO): Promise<QuestionDTO | null> {
|
||||
export async function deleteQuestion(questionId: QuestionId): Promise<QuestionDTO> {
|
||||
const questionRepository = getQuestionRepository();
|
||||
|
||||
const author = mapToStudent(questionDTO.author);
|
||||
const question = await fetchQuestion(questionId); // Throws error if not found
|
||||
|
||||
const loId: LearningObjectIdentifier = {
|
||||
...questionDTO.learningObjectIdentifier,
|
||||
version: questionDTO.learningObjectIdentifier.version ?? 1,
|
||||
hruid: questionId.learningObjectIdentifier.hruid,
|
||||
language: questionId.learningObjectIdentifier.language,
|
||||
version: questionId.learningObjectIdentifier.version || FALLBACK_VERSION_NUM,
|
||||
};
|
||||
|
||||
try {
|
||||
await questionRepository.createQuestion({
|
||||
loId,
|
||||
author,
|
||||
content: questionDTO.content,
|
||||
});
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return questionDTO;
|
||||
}
|
||||
|
||||
export async function deleteQuestion(questionId: QuestionId): Promise<QuestionDTO | null> {
|
||||
const questionRepository = getQuestionRepository();
|
||||
|
||||
const question = await fetchQuestion(questionId);
|
||||
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loId: LearningObjectIdentifier = {
|
||||
...questionId.learningObjectIdentifier,
|
||||
version: questionId.learningObjectIdentifier.version ?? 1,
|
||||
};
|
||||
|
||||
try {
|
||||
await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(loId, questionId.sequenceNumber);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(loId, questionId.sequenceNumber);
|
||||
return mapToQuestionDTO(question);
|
||||
}
|
||||
|
||||
export async function updateQuestion(questionId: QuestionId, questionData: QuestionData): Promise<QuestionDTO> {
|
||||
const questionRepository = getQuestionRepository();
|
||||
const question = await fetchQuestion(questionId);
|
||||
|
||||
const newQuestion = await questionRepository.updateContent(question, questionData.content);
|
||||
return mapToQuestionDTO(newQuestion);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content';
|
||||
|
||||
export function isValidHttpUrl(url: string): boolean {
|
||||
try {
|
||||
|
@ -9,7 +9,7 @@ export function isValidHttpUrl(url: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier): string {
|
||||
export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifierDTO): string {
|
||||
let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`;
|
||||
if (learningObjectId.version) {
|
||||
url += `&version=${learningObjectId.version}`;
|
||||
|
@ -17,7 +17,7 @@ export function getUrlStringForLearningObject(learningObjectId: LearningObjectId
|
|||
return url;
|
||||
}
|
||||
|
||||
export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string {
|
||||
export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifierDTO): string {
|
||||
let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`;
|
||||
if (learningObjectIdentifier.version) {
|
||||
url += `&version=${learningObjectIdentifier.version}`;
|
||||
|
|
87
backend/tests/controllers/answers.test.ts
Normal file
87
backend/tests/controllers/answers.test.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { setupTestApp } from '../setup-tests';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { getAllAnswersHandler, getAnswerHandler, updateAnswerHandler } from '../../src/controllers/answers';
|
||||
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
|
||||
import { NotFoundException } from '../../src/exceptions/not-found-exception';
|
||||
|
||||
describe('Questions controllers', () => {
|
||||
let req: Partial<Request>;
|
||||
let res: Partial<Response>;
|
||||
|
||||
let jsonMock: Mock;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jsonMock = vi.fn();
|
||||
res = {
|
||||
json: jsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
it('Get answers list', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1', seq: '2' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await getAllAnswersHandler(req as Request, res as Response);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ answers: expect.anything() }));
|
||||
|
||||
const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.answers);
|
||||
expect(result.answers).to.have.length.greaterThan(1);
|
||||
});
|
||||
|
||||
it('Get answer', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1', seq: '2', seqAnswer: '2' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await getAnswerHandler(req as Request, res as Response);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ answer: expect.anything() }));
|
||||
|
||||
// Const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.answer);
|
||||
});
|
||||
|
||||
it('Get answer hruid does not exist', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id_not_exist' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await expect(async () => getAnswerHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Get answer no hruid given', async () => {
|
||||
req = {
|
||||
params: {},
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await expect(async () => getAnswerHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('Update question', async () => {
|
||||
const newContent = 'updated question';
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1', seq: '2', seqAnswer: '2' },
|
||||
query: { lang: Language.English },
|
||||
body: { content: newContent },
|
||||
};
|
||||
|
||||
await updateAnswerHandler(req as Request, res as Response);
|
||||
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ answer: expect.anything() }));
|
||||
|
||||
const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.question);
|
||||
expect(result.answer.content).to.eq(newContent);
|
||||
});
|
||||
});
|
117
backend/tests/controllers/questions.test.ts
Normal file
117
backend/tests/controllers/questions.test.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { Request, Response } from 'express';
|
||||
import { setupTestApp } from '../setup-tests';
|
||||
import { getAllQuestionsHandler, getQuestionHandler, updateQuestionHandler } from '../../src/controllers/questions';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
import { NotFoundException } from '../../src/exceptions/not-found-exception';
|
||||
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
|
||||
|
||||
describe('Questions controllers', () => {
|
||||
let req: Partial<Request>;
|
||||
let res: Partial<Response>;
|
||||
|
||||
let jsonMock: Mock;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jsonMock = vi.fn();
|
||||
res = {
|
||||
json: jsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
it('Get question list', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await getAllQuestionsHandler(req as Request, res as Response);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ questions: expect.anything() }));
|
||||
|
||||
const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.questions);
|
||||
expect(result.questions).to.have.length.greaterThan(1);
|
||||
});
|
||||
|
||||
it('Get question', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1', seq: '1' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await getQuestionHandler(req as Request, res as Response);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ question: expect.anything() }));
|
||||
|
||||
// Const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.question);
|
||||
});
|
||||
|
||||
it('Get question with fallback sequence number and version', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id05' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await getQuestionHandler(req as Request, res as Response);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ question: expect.anything() }));
|
||||
|
||||
// Const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.question);
|
||||
});
|
||||
|
||||
it('Get question hruid does not exist', async () => {
|
||||
req = {
|
||||
params: { hruid: 'id_not_exist' },
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await expect(async () => getQuestionHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Get question no hruid given', async () => {
|
||||
req = {
|
||||
params: {},
|
||||
query: { lang: Language.English, full: 'true' },
|
||||
};
|
||||
|
||||
await expect(async () => getQuestionHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
/*
|
||||
It('Create and delete question', async() => {
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1', seq: '2'},
|
||||
query: { lang: Language.English },
|
||||
};
|
||||
|
||||
await deleteQuestionHandler(req as Request, res as Response);
|
||||
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ question: expect.anything() }));
|
||||
|
||||
const result = jsonMock.mock.lastCall?.[0];
|
||||
console.log(result.question);
|
||||
});
|
||||
|
||||
*/
|
||||
|
||||
it('Update question', async () => {
|
||||
const newContent = 'updated question';
|
||||
req = {
|
||||
params: { hruid: 'id05', version: '1', seq: '1' },
|
||||
query: { lang: Language.English },
|
||||
body: { content: newContent },
|
||||
};
|
||||
|
||||
await updateQuestionHandler(req as Request, res as Response);
|
||||
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ question: expect.anything() }));
|
||||
|
||||
const result = jsonMock.mock.lastCall?.[0];
|
||||
// Console.log(result.question);
|
||||
expect(result.question.content).to.eq(newContent);
|
||||
});
|
||||
});
|
|
@ -7,11 +7,11 @@ import learningObjectService from '../../../src/services/learning-objects/learni
|
|||
import { envVars, getEnvVar } from '../../../src/util/envVars';
|
||||
import { LearningPath } from '../../../src/entities/content/learning-path.entity';
|
||||
import learningPathExample from '../../test-assets/learning-paths/pn-werking-example';
|
||||
import { LearningObjectIdentifier, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
|
||||
import { Language } from '@dwengo-1/common/util/language';
|
||||
|
||||
const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks';
|
||||
const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifier = {
|
||||
const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifierDTO = {
|
||||
hruid: 'pn_werkingnotebooks',
|
||||
language: Language.Dutch,
|
||||
version: 3,
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
import { UserDTO } from './user';
|
||||
import { QuestionDTO, QuestionId } from './question';
|
||||
import { TeacherDTO } from './teacher';
|
||||
|
||||
export interface AnswerDTO {
|
||||
author: UserDTO;
|
||||
author: TeacherDTO;
|
||||
toQuestion: QuestionDTO;
|
||||
sequenceNumber: number;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AnswerData {
|
||||
author: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AnswerId {
|
||||
author: string;
|
||||
toQuestion: QuestionId;
|
||||
|
|
|
@ -11,7 +11,7 @@ export interface Transition {
|
|||
};
|
||||
}
|
||||
|
||||
export interface LearningObjectIdentifier {
|
||||
export interface LearningObjectIdentifierDTO {
|
||||
hruid: string;
|
||||
language: Language;
|
||||
version?: number;
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { LearningObjectIdentifier } from './learning-content';
|
||||
import { LearningObjectIdentifierDTO } from './learning-content';
|
||||
import { StudentDTO } from './student';
|
||||
|
||||
export interface QuestionDTO {
|
||||
learningObjectIdentifier: LearningObjectIdentifier;
|
||||
learningObjectIdentifier: LearningObjectIdentifierDTO;
|
||||
sequenceNumber?: number;
|
||||
author: StudentDTO;
|
||||
timestamp?: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface QuestionData {
|
||||
author?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface QuestionId {
|
||||
learningObjectIdentifier: LearningObjectIdentifier;
|
||||
learningObjectIdentifier: LearningObjectIdentifierDTO;
|
||||
sequenceNumber: number;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { GroupDTO } from './group';
|
||||
import { LearningObjectIdentifier } from './learning-content';
|
||||
import { LearningObjectIdentifierDTO } from './learning-content';
|
||||
import { StudentDTO } from './student';
|
||||
import { Language } from '../util/language';
|
||||
|
||||
export interface SubmissionDTO {
|
||||
learningObjectIdentifier: LearningObjectIdentifier;
|
||||
learningObjectIdentifier: LearningObjectIdentifierDTO;
|
||||
|
||||
submissionNumber?: number;
|
||||
submitter: StudentDTO;
|
||||
|
|
39
frontend/src/controllers/answers.ts
Normal file
39
frontend/src/controllers/answers.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import type { AnswerData, AnswerDTO, AnswerId } from "@dwengo-1/common/interfaces/answer";
|
||||
import { BaseController } from "@/controllers/base-controller.ts";
|
||||
import type { QuestionId } from "@dwengo-1/common/interfaces/question";
|
||||
|
||||
export interface AnswersResponse {
|
||||
answers: AnswerDTO[] | AnswerId[];
|
||||
}
|
||||
|
||||
export interface AnswerResponse {
|
||||
answer: AnswerDTO;
|
||||
}
|
||||
|
||||
export class AnswerController extends BaseController {
|
||||
constructor(questionId: QuestionId) {
|
||||
this.loId = questionId.learningObjectIdentifier;
|
||||
this.sequenceNumber = questionId.sequenceNumber;
|
||||
super(`learningObject/${loId.hruid}/:${loId.version}/questions/${this.sequenceNumber}/answers`);
|
||||
}
|
||||
|
||||
async getAll(full = true): Promise<AnswersResponse> {
|
||||
return this.get<AnswersResponse>("/", { lang: this.loId.lang, full });
|
||||
}
|
||||
|
||||
async getBy(seq: number): Promise<AnswerResponse> {
|
||||
return this.get<AnswerResponse>(`/${seq}`, { lang: this.loId.lang });
|
||||
}
|
||||
|
||||
async create(answerData: AnswerData): Promise<AnswerResponse> {
|
||||
return this.post<AnswerResponse>("/", answerData, { lang: this.loId.lang });
|
||||
}
|
||||
|
||||
async remove(seq: number): Promise<AnswerResponse> {
|
||||
return this.delete<AnswerResponse>(`/${seq}`, { lang: this.loId.lang });
|
||||
}
|
||||
|
||||
async update(seq: number, answerData: AnswerData): Promise<AnswerResponse> {
|
||||
return this.put<AnswerResponse>(`/${seq}`, answerData, { lang: this.loId.lang });
|
||||
}
|
||||
}
|
|
@ -21,20 +21,20 @@ export abstract class BaseController {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
protected async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await apiClient.post<T>(this.absolutePathFor(path), body);
|
||||
protected async post<T>(path: string, body: unknown, queryParams?: QueryParams): Promise<T> {
|
||||
const response = await apiClient.post<T>(this.absolutePathFor(path), body, { params: queryParams });
|
||||
BaseController.assertSuccessResponse(response);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async delete<T>(path: string): Promise<T> {
|
||||
const response = await apiClient.delete<T>(this.absolutePathFor(path));
|
||||
protected async delete<T>(path: string, queryParams?: QueryParams): Promise<T> {
|
||||
const response = await apiClient.delete<T>(this.absolutePathFor(path), { params: queryParams });
|
||||
BaseController.assertSuccessResponse(response);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async put<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await apiClient.put<T>(this.absolutePathFor(path), body);
|
||||
protected async put<T>(path: string, body: unknown, queryParams?: QueryParams): Promise<T> {
|
||||
const response = await apiClient.put<T>(this.absolutePathFor(path), body, { params: queryParams });
|
||||
BaseController.assertSuccessResponse(response);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,38 @@
|
|||
import type { QuestionDTO, QuestionId } from "@dwengo-1/common/interfaces/question";
|
||||
import type { QuestionData, QuestionDTO, QuestionId } from "@dwengo-1/common/interfaces/question";
|
||||
import { BaseController } from "@/controllers/base-controller.ts";
|
||||
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
|
||||
|
||||
export interface QuestionsResponse {
|
||||
questions: QuestionDTO[] | QuestionId[];
|
||||
}
|
||||
|
||||
export interface QuestionResponse {
|
||||
question: QuestionDTO;
|
||||
}
|
||||
|
||||
export class QuestionController extends BaseController {
|
||||
constructor(loId: LearningObjectIdentifierDTO) {
|
||||
this.loId = loId;
|
||||
super(`learningObject/${loId.hruid}/:${loId.version}/questions`);
|
||||
}
|
||||
|
||||
async getAll(full = true): Promise<QuestionsResponse> {
|
||||
return this.get<QuestionsResponse>("/", { lang: this.loId.lang, full });
|
||||
}
|
||||
|
||||
async getBy(sequenceNumber: number): Promise<QuestionResponse> {
|
||||
return this.get<QuestionResponse>(`/${sequenceNumber}`, { lang: this.loId.lang });
|
||||
}
|
||||
|
||||
async create(questionData: QuestionData): Promise<QuestionResponse> {
|
||||
return this.post<QuestionResponse>("/", questionData, { lang: this.loId.lang });
|
||||
}
|
||||
|
||||
async remove(sequenceNumber: number): Promise<QuestionResponse> {
|
||||
return this.delete<QuestionResponse>(`/${sequenceNumber}`, { lang: this.loId.lang });
|
||||
}
|
||||
|
||||
async update(sequenceNumber: number, questionData: QuestionData): Promise<QuestionResponse> {
|
||||
return this.put<QuestionResponse>(`/${sequenceNumber}`, questionData, { lang: this.loId.lang });
|
||||
}
|
||||
}
|
||||
|
|
51
frontend/src/queries/answers.ts
Normal file
51
frontend/src/queries/answers.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import type { QuestionId } from "@dwengo-1/common/dist/interfaces/question.ts";
|
||||
import { type MaybeRefOrGetter, toValue } from "vue";
|
||||
import { useMutation, type UseMutationReturnType, useQuery, type UseQueryReturnType } from "@tanstack/vue-query";
|
||||
import { AnswerController, type AnswerResponse, type AnswersResponse } from "@/controllers/answers.ts";
|
||||
import type { AnswerData } from "@dwengo-1/common/dist/interfaces/answer.ts";
|
||||
|
||||
// TODO caching
|
||||
|
||||
export function useAnswersQuery(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<AnswersResponse, Error> {
|
||||
return useQuery({
|
||||
queryFn: async () => new AnswerController(toValue(questionId)).getAll(toValue(full)),
|
||||
enabled: () => Boolean(toValue(questionId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnswerQuery(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
sequenceNumber: MaybeRefOrGetter<number>,
|
||||
): UseQueryReturnType<AnswerResponse, Error> {
|
||||
return useQuery({
|
||||
queryFn: async () => new AnswerController(toValue(questionId)).getBy(toValue(sequenceNumber)),
|
||||
enabled: () => Boolean(toValue(questionId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAnswerMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<AnswerResponse, Error, AnswerData, unknown> {
|
||||
return useMutation({
|
||||
mutationFn: async (data) => new AnswerController(toValue(questionId)).create(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAnswerMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<AnswerResponse, Error, number, unknown> {
|
||||
return useMutation({
|
||||
mutationFn: async (seq) => new AnswerController(toValue(questionId)).remove(seq),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAnswerMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<AnswerResponse, Error, { answerData: AnswerData; seq: number }, unknown> {
|
||||
return useMutation({
|
||||
mutationFn: async (data, seq) => new AnswerController(toValue(questionId)).update(seq, data),
|
||||
});
|
||||
}
|
93
frontend/src/queries/questions.ts
Normal file
93
frontend/src/queries/questions.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { QuestionController, type QuestionResponse, type QuestionsResponse } from "@/controllers/questions.ts";
|
||||
import type { QuestionData, QuestionId } from "@dwengo-1/common/interfaces/question";
|
||||
import type { LearningObjectIdentifierDTO } from "@dwengo-1/common/interfaces/learning-content";
|
||||
import { computed, type MaybeRefOrGetter, toValue } from "vue";
|
||||
import {
|
||||
useMutation,
|
||||
type UseMutationReturnType,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryReturnType,
|
||||
} from "@tanstack/vue-query";
|
||||
|
||||
export function questionsQueryKey(
|
||||
loId: LearningObjectIdentifierDTO,
|
||||
full: boolean,
|
||||
): [string, string, number, string, boolean] {
|
||||
return ["questions", loId.hruid, loId.version, loId.language, full];
|
||||
}
|
||||
|
||||
export function questionQueryKey(questionId: QuestionId): [string, string, number, string, number] {
|
||||
const loId = questionId.learningObjectIdentifier;
|
||||
return ["question", loId.hruid, loId.version, loId.language, questionId.sequenceNumber];
|
||||
}
|
||||
|
||||
export function useQuestionsQuery(
|
||||
loId: MaybeRefOrGetter<LearningObjectIdentifierDTO>,
|
||||
full: MaybeRefOrGetter<boolean> = true,
|
||||
): UseQueryReturnType<QuestionsResponse, Error> {
|
||||
return useQuery({
|
||||
queryKey: computed(() => questionsQueryKey(toValue(loId), toValue(full))),
|
||||
queryFn: async () => new QuestionController(toValue(loId)).getAll(toValue(full)),
|
||||
enabled: () => Boolean(toValue(loId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useQuestionQuery(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseQueryReturnType<QuestionResponse, Error> {
|
||||
const loId = toValue(questionId).learningObjectIdentifier;
|
||||
const sequenceNumber = toValue(questionId).sequenceNumber;
|
||||
return useQuery({
|
||||
queryKey: computed(() => questionQueryKey(loId, sequenceNumber)),
|
||||
queryFn: async () => new QuestionController(loId).getBy(sequenceNumber),
|
||||
enabled: () => Boolean(toValue(questionId)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateQuestionMutation(
|
||||
loId: MaybeRefOrGetter<LearningObjectIdentifierDTO>,
|
||||
): UseMutationReturnType<QuestionResponse, Error, QuestionData, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => new QuestionController(toValue(loId)).create(data),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateQuestionMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<QuestionResponse, Error, QuestionData, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
const loId = toValue(questionId).learningObjectIdentifier;
|
||||
const sequenceNumber = toValue(questionId).sequenceNumber;
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data) => new QuestionController(loId).update(sequenceNumber, data),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionQueryKey(toValue(questionId)) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteQuestionMutation(
|
||||
questionId: MaybeRefOrGetter<QuestionId>,
|
||||
): UseMutationReturnType<QuestionResponse, Error, void, unknown> {
|
||||
const queryClient = useQueryClient();
|
||||
const loId = toValue(questionId).learningObjectIdentifier;
|
||||
const sequenceNumber = toValue(questionId).sequenceNumber;
|
||||
return useMutation({
|
||||
mutationFn: async () => new QuestionController(loId).remove(sequenceNumber),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), true) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionsQueryKey(toValue(loId), false) });
|
||||
await queryClient.invalidateQueries({ queryKey: questionQueryKey(toValue(questionId)) });
|
||||
},
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue