diff --git a/backend/package.json b/backend/package.json index 83db321f..3a89eb87 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "main": "dist/app.js", "scripts": { "build": "cross-env NODE_ENV=production tsc --build", - "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", + "dev": "cross-env NODE_ENV=development tsx tool/seed.ts; tsx watch --env-file=.env.development.local src/app.ts", "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", "format": "prettier --write src/", "format-check": "prettier --check src/", diff --git a/backend/src/config.ts b/backend/src/config.ts index 9b4702b5..9b209ada 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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; diff --git a/backend/src/controllers/answers.ts b/backend/src/controllers/answers.ts new file mode 100644 index 00000000..38cebe84 --- /dev/null +++ b/backend/src/controllers/answers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }); +} diff --git a/backend/src/controllers/classes.ts b/backend/src/controllers/classes.ts index a041bf22..03e88a52 100644 --- a/backend/src/controllers/classes.ts +++ b/backend/src/controllers/classes.ts @@ -28,7 +28,7 @@ export async function createClassHandler(req: Request, res: Response): Promise { @@ -40,7 +40,7 @@ export async function getClassHandler(req: Request, res: Response): Promise { diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index a2510631..83aa33f9 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -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.'); } diff --git a/backend/src/controllers/questions.ts b/backend/src/controllers/questions.ts index 4df165ee..282c90b8 100644 --- a/backend/src/controllers/questions.ts +++ b/backend/src/controllers/questions.ts @@ -5,51 +5,35 @@ import { getAllQuestions, getAnswersByQuestion, getQuestion, - getQuestionsAboutLearningObjectInAssignment, + getQuestionsAboutLearningObjectInAssignment, updateQuestion, } from '../services/questions.js'; import { FALLBACK_LANG, FALLBACK_SEQ_NUM } from '../config.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { Language } from '@dwengo-1/common/util/language'; -import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer'; - -interface QuestionPathParams { - hruid: string; - version: string; -} - -interface QuestionQueryParams { - lang: string; -} - -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; - } +import {requireFields} from "./error-helper"; +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, }; } -interface GetQuestionIdPathParams extends QuestionPathParams { - seq: string; +export function getQuestionId(learningObjectIdentifier: LearningObjectIdentifier, seq: string): QuestionId { + return { + learningObjectIdentifier, + sequenceNumber: seq ? Number(seq) : FALLBACK_SEQ_NUM, + }; } -function getQuestionId( - req: Request, - res: Response -): QuestionId | null { + +function getQuestionId(req: Request, res: Response): QuestionId | null { const seq = req.params.seq; - const learningObjectIdentifier = getObjectId(req, res); + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const learningObjectIdentifier = getLearningObjectId(hruid, version, language); if (!learningObjectIdentifier) { return null; @@ -61,117 +45,117 @@ function getQuestionId( }; } -interface GetAllQuestionsQueryParams extends QuestionQueryParams { - classId?: string; - assignmentId?: number; - forStudent?: string; - full?: boolean; -} +export async function getAllQuestionsHandler(req: Request, res: Response): Promise { + 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 }); -export async function getAllQuestionsHandler( - req: Request, - res: Response -): Promise { - const objectId = getObjectId(req, res); - const full = req.query.full; + const learningObjectId = getLearningObjectId(hruid, version, language); - if (!objectId) { - return; - } let questions: QuestionDTO[] | QuestionId[]; if (req.query.classId && req.query.assignmentId) { questions = await getQuestionsAboutLearningObjectInAssignment( - objectId, + learningObjectId, req.query.classId, req.query.assignmentId, full ?? false, req.query.forStudent ); } else { - questions = await getAllQuestions(objectId, full ?? false); + questions = await getAllQuestions(learningObjectId, full ?? false); } - 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 { - const questionId = getQuestionId(req, res); + export async function getQuestionHandler(req: Request, res: Response): Promise { + 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); + + res.json({ question }); } - const question = await getQuestion(questionId); + export async function getQuestionAnswersHandler( + req: Request, + res: Response + ): Promise { + const questionId = getQuestionId(req, res); + const full = req.query.full; - if (!question) { - res.status(404).json({ error: `Question not found.` }); - } else { - res.json(question); + if (!questionId) { + return; + } + + const answers = await getAnswersByQuestion(questionId, full); + + if (!answers) { + res.status(404).json({ error: `Questions not found` }); + } else { + res.json({ answers: answers }); + } } -} - -interface GetQuestionAnswersQueryParams extends QuestionQueryParams { - full: boolean; -} -export async function getQuestionAnswersHandler( - req: Request, - res: Response -): Promise { - const questionId = getQuestionId(req, res); - const full = req.query.full; - - if (!questionId) { - return; - } - - const answers = await getAnswersByQuestion(questionId, full); - - if (!answers) { - res.status(404).json({ error: `Questions not found` }); - } else { - res.json({ answers: answers }); - } -} export async function createQuestionHandler(req: Request, res: Response): Promise { - 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.inGroup || !questionDTO.content) { - res.status(400).json({ error: 'Missing required fields: identifier, author, inGroup, 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; + const inGroup = req.body.inGroup as string; + requireFields({ author, content, inGroup }); - 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 { - const questionId = getQuestionId(req, res); +export async function deleteQuestionHandler(req: Request, res: Response): Promise { + 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 { + 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 }); } diff --git a/backend/src/data/questions/answer-repository.ts b/backend/src/data/questions/answer-repository.ts index a50bfd28..54f67a01 100644 --- a/backend/src/data/questions/answer-repository.ts +++ b/backend/src/data/questions/answer-repository.ts @@ -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 { public async createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise { @@ -19,10 +20,21 @@ export class AnswerRepository extends DwengoEntityRepository { orderBy: { sequenceNumber: 'ASC' }, }); } + public async findAnswer(question: Question, sequenceNumber: number): Promise | null> { + return this.findOne({ + toQuestion: question, + sequenceNumber, + }); + } public async removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise { return this.deleteWhere({ toQuestion: question, sequenceNumber: sequenceNumber, }); } + public async updateContent(answer: Answer, newContent: string): Promise { + answer.content = newContent; + await this.save(answer); + return answer; + } } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 6b961e07..6736266b 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -5,6 +5,7 @@ import { Student } from '../../entities/users/student.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { Group } from '../../entities/assignments/group.entity'; import { Assignment } from '../../entities/assignments/assignment.entity'; +import { Loaded } from '@mikro-orm/core'; export class QuestionRepository extends DwengoEntityRepository { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { @@ -66,6 +67,21 @@ export class QuestionRepository extends DwengoEntityRepository { }); } + public async findByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise | null> { + return this.findOne({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + sequenceNumber, + }); + } + + public async updateContent(question: Question, newContent: string): Promise { + question.content = newContent; + await this.save(question); + return question; + } + /** * Looks up all questions for the given learning object which were asked as part of the given assignment. * When forStudentUsername is set, only the questions within the given user's group are shown. diff --git a/backend/src/interfaces/answer.ts b/backend/src/interfaces/answer.ts index 1f0d0625..513fc63e 100644 --- a/backend/src/interfaces/answer.ts +++ b/backend/src/interfaces/answer.ts @@ -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(), diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts index a9347047..b4e58db7 100644 --- a/backend/src/interfaces/question.ts +++ b/backend/src/interfaces/question.ts @@ -3,8 +3,9 @@ import { mapToStudentDTO } from './student.js'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content'; import { mapToGroupDTOId } from './group'; +import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content'; -function getLearningObjectIdentifier(question: Question): LearningObjectIdentifier { +function getLearningObjectIdentifier(question: Question): LearningObjectIdentifierDTO { return { hruid: question.learningObjectHruid, language: question.learningObjectLanguage, @@ -12,6 +13,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. */ diff --git a/backend/src/orm.ts b/backend/src/orm.ts index 3e6e26c8..76cd0ee9 100644 --- a/backend/src/orm.ts +++ b/backend/src/orm.ts @@ -1,10 +1,10 @@ -import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { EntityManager, IDatabaseDriver, MikroORM } from '@mikro-orm/core'; import config from './mikro-orm.config.js'; import { envVars, getEnvVar } from './util/envVars.js'; import { getLogger, Logger } from './logging/initalize.js'; let orm: MikroORM | undefined; -export async function initORM(testingMode = false): Promise { +export async function initORM(testingMode = false): Promise> { const logger: Logger = getLogger(); logger.info('Initializing ORM'); @@ -25,6 +25,8 @@ export async function initORM(testingMode = false): Promise { ); } } + + return orm; } export function forkEntityManager(): EntityManager { if (!orm) { diff --git a/backend/src/routes/answers.ts b/backend/src/routes/answers.ts new file mode 100644 index 00000000..b74f76a0 --- /dev/null +++ b/backend/src/routes/answers.ts @@ -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; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts index 31a71f3b..5135c197 100644 --- a/backend/src/routes/questions.ts +++ b/backend/src/routes/questions.ts @@ -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; diff --git a/backend/src/services/answers.ts b/backend/src/services/answers.ts new file mode 100644 index 00000000..ab603883 --- /dev/null +++ b/backend/src/services/answers.ts @@ -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 { + 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 { + 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 { + 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 { + const answer = await fetchAnswer(questionId, sequenceNumber); + return mapToAnswerDTO(answer); +} + +export async function deleteAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + 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 { + const answerRepository = getAnswerRepository(); + const answer = await fetchAnswer(questionId, sequenceNumber); + + const newAnswer = await answerRepository.updateContent(answer, answerData.content); + return mapToAnswerDTO(newAnswer); +} diff --git a/backend/src/services/learning-objects/attachment-service.ts b/backend/src/services/learning-objects/attachment-service.ts index 46fc5e03..2a6298c1 100644 --- a/backend/src/services/learning-objects/attachment-service.ts +++ b/backend/src/services/learning-objects/attachment-service.ts @@ -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 { + async getAttachment(learningObjectId: LearningObjectIdentifierDTO, attachmentName: string): Promise { const attachmentRepo = getAttachmentRepository(); if (learningObjectId.version) { diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 361153f5..fa278bba 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -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 { +async function findLearningObjectEntityById(id: LearningObjectIdentifierDTO): Promise { 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 { + async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise { 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 { + async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { const learningObjectRepo = getLearningObjectRepository(); const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language); diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index d67b69ae..e9898f62 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -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 { + async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise { const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; const metadata = await fetchWithLogging( 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 { + async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; const html = await fetchWithLogging(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { params: { ...id }, diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts index a0fcb552..14848bc0 100644 --- a/backend/src/services/learning-objects/learning-object-provider.ts +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -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; + getLearningObjectById(id: LearningObjectIdentifierDTO): Promise; /** * 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; + getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise; } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 5a06f0f2..7b4f47fc 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -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 { + async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise { 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 { + async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { return getProvider(id).getLearningObjectHTML(id); }, }; diff --git a/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts index 87ba13b7..e01d8ed6 100644 --- a/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts +++ b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts @@ -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, diff --git a/backend/src/services/learning-objects/processing/processing-service.ts b/backend/src/services/learning-objects/processing/processing-service.ts index e9147e31..61821d80 100644 --- a/backend/src/services/learning-objects/processing/processing-service.ts +++ b/backend/src/services/learning-objects/processing/processing-service.ts @@ -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 = //g; @@ -50,7 +50,7 @@ class ProcessingService { */ async render( learningObject: LearningObject, - fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise + fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifierDTO) => Promise ): Promise { const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); if (fetchEmbeddedLearningObjects) { diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts index aa3e1298..45bfa750 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -1,5 +1,10 @@ -import { getAnswerRepository, getAssignmentRepository, getClassRepository, getGroupRepository, getQuestionRepository } from '../data/repositories.js'; -import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; +import { + getAnswerRepository, getAssignmentRepository, + getClassRepository, + getGroupRepository, + 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'; @@ -8,8 +13,9 @@ import { LearningObjectIdentifier } from '../entities/content/learning-object-id 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 { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; -import { mapToAssignment } from '../interfaces/assignment'; +import {fetchStudent} from "./students"; +import {mapToAssignment} from "../interfaces/assignment"; +import { NotFoundException } from '../exceptions/not-found-exception.js'; export async function getQuestionsAboutLearningObjectInAssignment( loId: LearningObjectIdentifier, @@ -32,10 +38,6 @@ export async function getAllQuestions(id: LearningObjectIdentifier, full: boolea const questionRepository: QuestionRepository = getQuestionRepository(); const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); - if (!questions) { - return []; - } - if (full) { return questions.map(mapToQuestionDTO); } @@ -43,24 +45,22 @@ export async function getAllQuestions(id: LearningObjectIdentifier, full: boolea return questions.map(mapToQuestionDTOId); } -async function fetchQuestion(questionId: QuestionId): Promise { +export async function fetchQuestion(questionId: QuestionId): Promise { const questionRepository = getQuestionRepository(); - - return await questionRepository.findOne({ - learningObjectHruid: questionId.learningObjectIdentifier.hruid, - learningObjectLanguage: questionId.learningObjectIdentifier.language, - learningObjectVersion: questionId.learningObjectIdentifier.version, - sequenceNumber: questionId.sequenceNumber, - }); -} - -export async function getQuestion(questionId: QuestionId): Promise { - const question = await fetchQuestion(questionId); + const question = await questionRepository.findByLearningObjectAndSequenceNumber( + mapToLearningObjectID(questionId.learningObjectIdentifier), + questionId.sequenceNumber + ); if (!question) { - return null; + throw new NotFoundException('Question with loID and sequence number not found'); } + return question; +} + +export async function getQuestion(questionId: QuestionId): Promise { + const question = await fetchQuestion(questionId); return mapToQuestionDTO(question); } @@ -85,53 +85,44 @@ export async function getAnswersByQuestion(questionId: QuestionId, full: boolean return answers.map(mapToAnswerDTOId); } -export async function createQuestion(questionDTO: QuestionDTO): Promise { +export async function createQuestion(loId: LearningObjectIdentifier, questionData: QuestionData): Promise { const questionRepository = getQuestionRepository(); - - const author = mapToStudent(questionDTO.author); - - const loId: LearningObjectIdentifier = { - ...questionDTO.learningObjectIdentifier, - version: questionDTO.learningObjectIdentifier.version ?? 1, - }; + const author = await fetchStudent(questionData.author!); + const content = questionData.content; const clazz = await getClassRepository().findById((questionDTO.inGroup.assignment as AssignmentDTO).class); + let questionDTO; const assignment = mapToAssignment(questionDTO.inGroup.assignment as AssignmentDTO, clazz!); const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionDTO.inGroup.groupNumber); - try { - await questionRepository.createQuestion({ - loId, - author, - inGroup: inGroup!, - content: questionDTO.content, - }); - } catch (_) { - return null; - } - - return questionDTO; -} - -export async function deleteQuestion(questionId: QuestionId): Promise { - 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; - } + const question = await questionRepository.createQuestion({ + loId, + inGroup, + author, + content, + }); return mapToQuestionDTO(question); } + +export async function deleteQuestion(questionId: QuestionId): Promise { + const questionRepository = getQuestionRepository(); + const question = await fetchQuestion(questionId); // Throws error if not found + + const loId: LearningObjectIdentifier = { + hruid: questionId.learningObjectIdentifier.hruid, + language: questionId.learningObjectIdentifier.language, + version: questionId.learningObjectIdentifier.version || FALLBACK_VERSION_NUM, + }; + + await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(loId, questionId.sequenceNumber); + return mapToQuestionDTO(question); +} + +export async function updateQuestion(questionId: QuestionId, questionData: QuestionData): Promise { + const questionRepository = getQuestionRepository(); + const question = await fetchQuestion(questionId); + + const newQuestion = await questionRepository.updateContent(question, questionData.content); + return mapToQuestionDTO(newQuestion); +} diff --git a/backend/src/util/links.ts b/backend/src/util/links.ts index 9ede7d12..106613a4 100644 --- a/backend/src/util/links.ts +++ b/backend/src/util/links.ts @@ -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}`; diff --git a/backend/tests/controllers/answers.test.ts b/backend/tests/controllers/answers.test.ts new file mode 100644 index 00000000..2bc8ed16 --- /dev/null +++ b/backend/tests/controllers/answers.test.ts @@ -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; + let res: Partial; + + 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); + }); +}); diff --git a/backend/tests/controllers/questions.test.ts b/backend/tests/controllers/questions.test.ts new file mode 100644 index 00000000..f2612fc7 --- /dev/null +++ b/backend/tests/controllers/questions.test.ts @@ -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; + let res: Partial; + + 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); + }); +}); diff --git a/backend/tests/controllers/students.test.ts b/backend/tests/controllers/students.test.ts index 93f35c48..18331f2d 100644 --- a/backend/tests/controllers/students.test.ts +++ b/backend/tests/controllers/students.test.ts @@ -186,7 +186,7 @@ describe('Student controllers', () => { it('Get join request by student and class', async () => { req = { - params: { username: 'PinkFloyd', classId: 'id02' }, + params: { username: 'PinkFloyd', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await getStudentRequestHandler(req as Request, res as Response); @@ -201,7 +201,7 @@ describe('Student controllers', () => { it('Create join request', async () => { req = { params: { username: 'Noordkaap' }, - body: { classId: 'id02' }, + body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await createStudentRequestHandler(req as Request, res as Response); @@ -212,7 +212,7 @@ describe('Student controllers', () => { it('Create join request duplicate', async () => { req = { params: { username: 'Tool' }, - body: { classId: 'id02' }, + body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); @@ -220,7 +220,7 @@ describe('Student controllers', () => { it('Delete join request', async () => { req = { - params: { username: 'Noordkaap', classId: 'id02' }, + params: { username: 'Noordkaap', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await deleteClassJoinRequestHandler(req as Request, res as Response); diff --git a/backend/tests/controllers/teachers.test.ts b/backend/tests/controllers/teachers.test.ts index bee23987..cdcb6229 100644 --- a/backend/tests/controllers/teachers.test.ts +++ b/backend/tests/controllers/teachers.test.ts @@ -104,9 +104,9 @@ describe('Teacher controllers', () => { const result = jsonMock.mock.lastCall?.[0]; const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); - expect(teacherUsernames).toContain('FooFighters'); + expect(teacherUsernames).toContain('testleerkracht1'); - expect(result.teachers).toHaveLength(4); + expect(result.teachers).toHaveLength(5); }); it('Deleting non-existent student', async () => { @@ -117,7 +117,7 @@ describe('Teacher controllers', () => { it('Get teacher classes', async () => { req = { - params: { username: 'FooFighters' }, + params: { username: 'testleerkracht1' }, query: { full: 'true' }, }; @@ -132,7 +132,7 @@ describe('Teacher controllers', () => { it('Get teacher students', async () => { req = { - params: { username: 'FooFighters' }, + params: { username: 'testleerkracht1' }, query: { full: 'true' }, }; @@ -169,7 +169,7 @@ describe('Teacher controllers', () => { it('Get join requests by class', async () => { req = { query: { username: 'LimpBizkit' }, - params: { classId: 'id02' }, + params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await getStudentJoinRequestHandler(req as Request, res as Response); @@ -184,7 +184,7 @@ describe('Teacher controllers', () => { it('Update join request status', async () => { req = { query: { username: 'LimpBizkit', studentUsername: 'PinkFloyd' }, - params: { classId: 'id02' }, + params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { accepted: 'true' }, }; diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts index 2bad08f2..206ab4fd 100644 --- a/backend/tests/data/assignments/assignments.test.ts +++ b/backend/tests/data/assignments/assignments.test.ts @@ -15,7 +15,7 @@ describe('AssignmentRepository', () => { }); it('should return the requested assignment', async () => { - const class_ = await classRepository.findById('id02'); + const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const assignment = await assignmentRepository.findByClassAndId(class_!, 2); expect(assignment).toBeTruthy(); @@ -23,7 +23,7 @@ describe('AssignmentRepository', () => { }); it('should return all assignments for a class', async () => { - const class_ = await classRepository.findById('id02'); + const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); expect(assignments).toBeTruthy(); diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts index 96684d68..f7fb3046 100644 --- a/backend/tests/data/assignments/groups.test.ts +++ b/backend/tests/data/assignments/groups.test.ts @@ -18,7 +18,7 @@ describe('GroupRepository', () => { }); it('should return the requested group', async () => { - const class_ = await classRepository.findById('id01'); + const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const assignment = await assignmentRepository.findByClassAndId(class_!, 1); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); @@ -27,7 +27,7 @@ describe('GroupRepository', () => { }); it('should return all groups for assignment', async () => { - const class_ = await classRepository.findById('id01'); + const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const assignment = await assignmentRepository.findByClassAndId(class_!, 1); const groups = await groupRepository.findAllGroupsForAssignment(assignment!); @@ -37,7 +37,7 @@ describe('GroupRepository', () => { }); it('should not find removed group', async () => { - const class_ = await classRepository.findById('id02'); + const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const assignment = await assignmentRepository.findByClassAndId(class_!, 2); await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts index acc82384..47e1c414 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -53,7 +53,7 @@ describe('SubmissionRepository', () => { it('should find the most recent submission for a group', async () => { const id = new LearningObjectIdentifier('id03', Language.English, 1); - const class_ = await classRepository.findById('id01'); + const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const assignment = await assignmentRepository.findByClassAndId(class_!, 1); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); diff --git a/backend/tests/data/classes/class-join-request.test.ts b/backend/tests/data/classes/class-join-request.test.ts index cd53bf05..afb83766 100644 --- a/backend/tests/data/classes/class-join-request.test.ts +++ b/backend/tests/data/classes/class-join-request.test.ts @@ -26,7 +26,7 @@ describe('ClassJoinRequestRepository', () => { }); it('should list all requests to a single class', async () => { - const class_ = await cassRepository.findById('id02'); + const class_ = await cassRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); expect(requests).toBeTruthy(); @@ -35,7 +35,7 @@ describe('ClassJoinRequestRepository', () => { it('should not find a removed request', async () => { const student = await studentRepository.findByUsername('SmashingPumpkins'); - const class_ = await cassRepository.findById('id03'); + const class_ = await cassRepository.findById('80dcc3e0-1811-4091-9361-42c0eee91cfa'); await classJoinRequestRepository.deleteBy(student!, class_!); const request = await classJoinRequestRepository.findAllRequestsBy(student!); diff --git a/backend/tests/data/classes/classes.test.ts b/backend/tests/data/classes/classes.test.ts index 22306ba6..f87f83ed 100644 --- a/backend/tests/data/classes/classes.test.ts +++ b/backend/tests/data/classes/classes.test.ts @@ -18,16 +18,16 @@ describe('ClassRepository', () => { }); it('should return requested class', async () => { - const classVar = await classRepository.findById('id01'); + const classVar = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); expect(classVar).toBeTruthy(); expect(classVar?.displayName).toBe('class01'); }); it('class should be gone after deletion', async () => { - await classRepository.deleteById('id04'); + await classRepository.deleteById('33d03536-83b8-4880-9982-9bbf2f908ddf'); - const classVar = await classRepository.findById('id04'); + const classVar = await classRepository.findById('33d03536-83b8-4880-9982-9bbf2f908ddf'); expect(classVar).toBeNull(); }); diff --git a/backend/tests/data/classes/teacher-invitation.test.ts b/backend/tests/data/classes/teacher-invitation.test.ts index dd03634a..f8afa36d 100644 --- a/backend/tests/data/classes/teacher-invitation.test.ts +++ b/backend/tests/data/classes/teacher-invitation.test.ts @@ -34,7 +34,7 @@ describe('ClassRepository', () => { }); it('should return all invitations for a class', async () => { - const class_ = await classRepository.findById('id02'); + const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); expect(invitations).toBeTruthy(); @@ -42,7 +42,7 @@ describe('ClassRepository', () => { }); it('should not find a removed invitation', async () => { - const class_ = await classRepository.findById('id01'); + const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const sender = await teacherRepository.findByUsername('FooFighters'); const receiver = await teacherRepository.findByUsername('LimpBizkit'); await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); diff --git a/backend/tests/services/learning-objects/learning-object-service.test.ts b/backend/tests/services/learning-objects/learning-object-service.test.ts index a0fea849..3ea4143d 100644 --- a/backend/tests/services/learning-objects/learning-object-service.test.ts +++ b/backend/tests/services/learning-objects/learning-object-service.test.ts @@ -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, diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts index e4372015..0f223ec4 100644 --- a/backend/tests/test_assets/classes/classes.testdata.ts +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -4,11 +4,11 @@ import { Student } from '../../../src/entities/users/student.entity'; import { Teacher } from '../../../src/entities/users/teacher.entity'; export function makeTestClasses(em: EntityManager, students: Student[], teachers: Teacher[]): Class[] { - const studentsClass01 = students.slice(0, 7); - const teacherClass01: Teacher[] = teachers.slice(0, 1); + const studentsClass01 = students.slice(0, 8); + const teacherClass01: Teacher[] = teachers.slice(4, 5); const class01 = em.create(Class, { - classId: 'id01', + classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', displayName: 'class01', teachers: teacherClass01, students: studentsClass01, @@ -18,7 +18,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass02: Teacher[] = teachers.slice(1, 2); const class02 = em.create(Class, { - classId: 'id02', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', displayName: 'class02', teachers: teacherClass02, students: studentsClass02, @@ -28,7 +28,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass03: Teacher[] = teachers.slice(2, 3); const class03 = em.create(Class, { - classId: 'id03', + classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', displayName: 'class03', teachers: teacherClass03, students: studentsClass03, @@ -38,7 +38,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers const teacherClass04: Teacher[] = teachers.slice(2, 3); const class04 = em.create(Class, { - classId: 'id04', + classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', displayName: 'class04', teachers: teacherClass04, students: studentsClass04, diff --git a/backend/tests/test_assets/users/students.testdata.ts b/backend/tests/test_assets/users/students.testdata.ts index 5cd75787..7a1fe191 100644 --- a/backend/tests/test_assets/users/students.testdata.ts +++ b/backend/tests/test_assets/users/students.testdata.ts @@ -11,6 +11,8 @@ export const TEST_STUDENTS = [ { username: 'TheDoors', firstName: 'Jim', lastName: 'Morisson' }, // ⚠️ Deze mag niet gebruikt worden in elke test! { username: 'Nirvana', firstName: 'Kurt', lastName: 'Cobain' }, + // Makes sure when logged in as leerling1, there exists a corresponding user + { username: 'testleerling1', firstName: 'Gerald', lastName: 'Schmittinger' }, ]; // 🏗️ Functie die ORM entities maakt uit de data array diff --git a/backend/tests/test_assets/users/teachers.testdata.ts b/backend/tests/test_assets/users/teachers.testdata.ts index 8e791bb1..db726dcf 100644 --- a/backend/tests/test_assets/users/teachers.testdata.ts +++ b/backend/tests/test_assets/users/teachers.testdata.ts @@ -27,5 +27,12 @@ export function makeTestTeachers(em: EntityManager): Teacher[] { lastName: 'Cappelle', }); - return [teacher01, teacher02, teacher03, teacher04]; + // Makes sure when logged in as testleerkracht1, there exists a corresponding user + const teacher05 = em.create(Teacher, { + username: 'testleerkracht1', + firstName: 'Bob', + lastName: 'Dylan', + }); + + return [teacher01, teacher02, teacher03, teacher04, teacher05]; } diff --git a/backend/tool/seed.ts b/backend/tool/seed.ts new file mode 100644 index 00000000..33344234 --- /dev/null +++ b/backend/tool/seed.ts @@ -0,0 +1,72 @@ +import { forkEntityManager, initORM } from '../src/orm.js'; +import dotenv from 'dotenv'; +import { makeTestAssignemnts } from '../tests/test_assets/assignments/assignments.testdata.js'; +import { makeTestGroups } from '../tests/test_assets/assignments/groups.testdata.js'; +import { makeTestSubmissions } from '../tests/test_assets/assignments/submission.testdata.js'; +import { makeTestClassJoinRequests } from '../tests/test_assets/classes/class-join-requests.testdata.js'; +import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata.js'; +import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata.js'; +import { makeTestAttachments } from '../tests/test_assets/content/attachments.testdata.js'; +import { makeTestLearningObjects } from '../tests/test_assets/content/learning-objects.testdata.js'; +import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata.js'; +import { makeTestAnswers } from '../tests/test_assets/questions/answers.testdata.js'; +import { makeTestQuestions } from '../tests/test_assets/questions/questions.testdata.js'; +import { makeTestStudents } from '../tests/test_assets/users/students.testdata.js'; +import { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata.js'; +import { getLogger, Logger } from '../src/logging/initalize.js'; + +const logger: Logger = getLogger(); + +export async function seedDatabase(): Promise { + dotenv.config({ path: '.env.development.local' }); + const orm = await initORM(); + await orm.schema.clearDatabase(); + + const em = forkEntityManager(); + + logger.info('seeding database...'); + + const students = makeTestStudents(em); + const teachers = makeTestTeachers(em); + const learningObjects = makeTestLearningObjects(em); + const learningPaths = makeTestLearningPaths(em); + const classes = makeTestClasses(em, students, teachers); + const assignments = makeTestAssignemnts(em, classes); + const groups = makeTestGroups(em, students, assignments); + + assignments[0].groups = groups.slice(0, 3); + assignments[1].groups = groups.slice(3, 4); + + const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); + const classJoinRequests = makeTestClassJoinRequests(em, students, classes); + const attachments = makeTestAttachments(em, learningObjects); + + learningObjects[1].attachments = attachments; + + const questions = makeTestQuestions(em, students); + const answers = makeTestAnswers(em, teachers, questions); + const submissions = makeTestSubmissions(em, students, groups); + + // Persist all entities + await em.persistAndFlush([ + ...students, + ...teachers, + ...learningObjects, + ...learningPaths, + ...classes, + ...assignments, + ...groups, + ...teacherInvitations, + ...classJoinRequests, + ...attachments, + ...questions, + ...answers, + ...submissions, + ]); + + logger.info('Development database seeded successfully!'); + + await orm.close(); +} + +seedDatabase().catch(logger.error); diff --git a/common/src/interfaces/answer.ts b/common/src/interfaces/answer.ts index e9280f8a..8510ac7b 100644 --- a/common/src/interfaces/answer.ts +++ b/common/src/interfaces/answer.ts @@ -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; diff --git a/common/src/interfaces/learning-content.ts b/common/src/interfaces/learning-content.ts index 02e49648..435e7001 100644 --- a/common/src/interfaces/learning-content.ts +++ b/common/src/interfaces/learning-content.ts @@ -11,7 +11,7 @@ export interface Transition { }; } -export interface LearningObjectIdentifier { +export interface LearningObjectIdentifierDTO { hruid: string; language: Language; version?: number; diff --git a/common/src/interfaces/question.ts b/common/src/interfaces/question.ts index bd689c34..582e12dd 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -1,17 +1,22 @@ -import { LearningObjectIdentifier } from './learning-content'; +import { LearningObjectIdentifierDTO } from './learning-content'; import { StudentDTO } from './student'; import { GroupDTO } from './group'; export interface QuestionDTO { - learningObjectIdentifier: LearningObjectIdentifier; + learningObjectIdentifier: LearningObjectIdentifierDTO; sequenceNumber?: number; author: StudentDTO; inGroup: GroupDTO; - timestamp?: string; + timestamp: string; + content: string; +} + +export interface QuestionData { + author?: string; content: string; } export interface QuestionId { - learningObjectIdentifier: LearningObjectIdentifier; + learningObjectIdentifier: LearningObjectIdentifierDTO; sequenceNumber: number; } diff --git a/common/src/interfaces/submission.ts b/common/src/interfaces/submission.ts index f63660b1..7643f0e6 100644 --- a/common/src/interfaces/submission.ts +++ b/common/src/interfaces/submission.ts @@ -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; diff --git a/frontend/src/controllers/answers.ts b/frontend/src/controllers/answers.ts new file mode 100644 index 00000000..04235e77 --- /dev/null +++ b/frontend/src/controllers/answers.ts @@ -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 { + return this.get("/", { lang: this.loId.lang, full }); + } + + async getBy(seq: number): Promise { + return this.get(`/${seq}`, { lang: this.loId.lang }); + } + + async create(answerData: AnswerData): Promise { + return this.post("/", answerData, { lang: this.loId.lang }); + } + + async remove(seq: number): Promise { + return this.delete(`/${seq}`, { lang: this.loId.lang }); + } + + async update(seq: number, answerData: AnswerData): Promise { + return this.put(`/${seq}`, answerData, { lang: this.loId.lang }); + } +} diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 72d71819..3d22e099 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -10,7 +10,7 @@ export abstract class BaseController { } private static assertSuccessResponse(response: AxiosResponse): void { - if (response.status / 100 !== 2) { + if (response.status < 200 || response.status >= 300) { throw new HttpErrorResponseException(response); } } @@ -21,20 +21,20 @@ export abstract class BaseController { return response.data; } - protected async post(path: string, body: unknown): Promise { - const response = await apiClient.post(this.absolutePathFor(path), body); + protected async post(path: string, body: unknown, queryParams?: QueryParams): Promise { + const response = await apiClient.post(this.absolutePathFor(path), body, { params: queryParams }); BaseController.assertSuccessResponse(response); return response.data; } - protected async delete(path: string): Promise { - const response = await apiClient.delete(this.absolutePathFor(path)); + protected async delete(path: string, queryParams?: QueryParams): Promise { + const response = await apiClient.delete(this.absolutePathFor(path), { params: queryParams }); BaseController.assertSuccessResponse(response); return response.data; } - protected async put(path: string, body: unknown): Promise { - const response = await apiClient.put(this.absolutePathFor(path), body); + protected async put(path: string, body: unknown, queryParams?: QueryParams): Promise { + const response = await apiClient.put(this.absolutePathFor(path), body, { params: queryParams }); BaseController.assertSuccessResponse(response); return response.data; } diff --git a/frontend/src/controllers/controllers.ts b/frontend/src/controllers/controllers.ts index 39392a7d..d8dbc9e2 100644 --- a/frontend/src/controllers/controllers.ts +++ b/frontend/src/controllers/controllers.ts @@ -1,6 +1,7 @@ import { ThemeController } from "@/controllers/themes.ts"; import { LearningObjectController } from "@/controllers/learning-objects.ts"; import { LearningPathController } from "@/controllers/learning-paths.ts"; +import { ClassController } from "@/controllers/classes.ts"; export function controllerGetter(factory: new () => T): () => T { let instance: T | undefined; @@ -16,3 +17,4 @@ export function controllerGetter(factory: new () => T): () => T { export const getThemeController = controllerGetter(ThemeController); export const getLearningObjectController = controllerGetter(LearningObjectController); export const getLearningPathController = controllerGetter(LearningPathController); +export const getClassController = controllerGetter(ClassController); diff --git a/frontend/src/controllers/questions.ts b/frontend/src/controllers/questions.ts index 16abd38b..60a51d1a 100644 --- a/frontend/src/controllers/questions.ts +++ b/frontend/src/controllers/questions.ts @@ -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 { + return this.get("/", { lang: this.loId.lang, full }); + } + + async getBy(sequenceNumber: number): Promise { + return this.get(`/${sequenceNumber}`, { lang: this.loId.lang }); + } + + async create(questionData: QuestionData): Promise { + return this.post("/", questionData, { lang: this.loId.lang }); + } + + async remove(sequenceNumber: number): Promise { + return this.delete(`/${sequenceNumber}`, { lang: this.loId.lang }); + } + + async update(sequenceNumber: number, questionData: QuestionData): Promise { + return this.put(`/${sequenceNumber}`, questionData, { lang: this.loId.lang }); + } +} diff --git a/frontend/src/controllers/students.ts b/frontend/src/controllers/students.ts index f74f02da..e9da9c74 100644 --- a/frontend/src/controllers/students.ts +++ b/frontend/src/controllers/students.ts @@ -70,7 +70,7 @@ export class StudentController extends BaseController { } async createJoinRequest(username: string, classId: string): Promise { - return this.post(`/${username}/joinRequests}`, classId); + return this.post(`/${username}/joinRequests`, { classId }); } async deleteJoinRequest(username: string, classId: string): Promise { diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index d20154a4..d7bded04 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -17,6 +17,11 @@ "inclusive": "Inclusiv", "sociallyRelevant": "Gesellschaftlich relevant", "translate": "übersetzen", + "joinClass": "Klasse beitreten", + "JoinClassExplanation": "Geben Sie den Code ein, den Ihnen die Lehrkraft mitgeteilt hat, um der Klasse beizutreten.", + "invalidFormat": "Ungültiges Format", + "submitCode": "senden", + "members": "Mitglieder", "themes": "Themen", "choose-theme": "Wähle ein thema", "choose-age": "Alter auswählen", @@ -51,5 +56,24 @@ "noLearningPathsFoundDescription": "Es gibt keine Lernpfade, die zu Ihrem Suchbegriff passen.", "legendNotCompletedYet": "Noch nicht fertig", "legendCompleted": "Fertig", - "legendTeacherExclusive": "Information für Lehrkräfte" + "legendTeacherExclusive": "Information für Lehrkräfte", + "code": "code", + "class": "Klasse", + "invitations": "Einladungen", + "createClass": "Klasse erstellen", + "createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.", + "classname": "Klassenname", + "EnterNameOfClass": "einen Klassennamen eingeben.", + "create": "erstellen", + "sender": "Absender", + "nameIsMandatory": "Der Klassenname ist ein Pflichtfeld", + "onlyUse": "nur Buchstaben, Zahlen, Bindestriche (-) und Unterstriche (_) verwenden", + "close": "schließen", + "copied": "kopiert!", + "accept": "akzeptieren", + "deny": "ablehnen", + "sent": "sent", + "failed": "gescheitert", + "wrong": "etwas ist schief gelaufen", + "created": "erstellt" } diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index 8d7ed775..171eb1f8 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -29,6 +29,11 @@ "sociallyRelevant": "Socially relevant", "login": "log in", "translate": "translate", + "joinClass": "Join class", + "JoinClassExplanation": "Enter the code the teacher has given you to join the class.", + "invalidFormat": "Invalid format.", + "submitCode": "submit", + "members": "members", "themes": "Themes", "choose-theme": "Select a theme", "choose-age": "Select age", @@ -51,5 +56,24 @@ "high-school": "16-18 years old", "older": "18 and older" }, - "read-more": "Read more" + "read-more": "Read more", + "code": "code", + "class": "class", + "invitations": "invitations", + "createClass": "create class", + "classname": "classname", + "EnterNameOfClass": "Enter a classname.", + "create": "create", + "sender": "sender", + "nameIsMandatory": "classname is mandatory", + "onlyUse": "only use letters, numbers, dashes (-) and underscores (_)", + "close": "close", + "copied": "copied!", + "accept": "accept", + "deny": "deny", + "createClassInstructions": "Enter a name for your class and click on create. A window will appear with a code that you can copy. Give this code to your students and they will be able to join.", + "sent": "sent", + "failed": "failed", + "wrong": "something went wrong", + "created": "created" } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 2dfbb949..a3d3d89b 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -29,6 +29,11 @@ "inclusive": "Inclusif", "sociallyRelevant": "Socialement pertinent", "translate": "traduire", + "joinClass": "Rejoindre une classe", + "JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.", + "invalidFormat": "Format non valide.", + "submitCode": "envoyer", + "members": "membres", "themes": "Thèmes", "choose-theme": "Choisis un thème", "choose-age": "Choisis un âge", @@ -51,5 +56,24 @@ "high-school": "16-18 ans", "older": "18 et plus" }, - "read-more": "En savoir plus" + "read-more": "En savoir plus", + "code": "code", + "class": "classe", + "invitations": "invitations", + "createClass": "créer une classe", + "createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.", + "classname": "nom de classe", + "EnterNameOfClass": "saisir un nom de classe.", + "create": "créer", + "sender": "expéditeur", + "nameIsMandatory": "le nom de classe est obligatoire", + "onlyUse": "n'utiliser que des lettres, des chiffres, des tirets (-) et des traits de soulignement (_)", + "close": "fermer", + "copied": "copié!", + "accept": "accepter", + "deny": "refuser", + "sent": "envoyé", + "failed": "échoué", + "wrong": "quelque chose n'a pas fonctionné", + "created": "créé" } diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 80cdd8e1..1fbed08d 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -29,6 +29,11 @@ "sociallyRelevant": "Maatschappelijk relevant", "login": "log in", "translate": "vertalen", + "joinClass": "Word lid van een klas", + "JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.", + "invalidFormat": "Ongeldig formaat.", + "submitCode": "verzenden", + "members": "leden", "themes": "Lesthema's", "choose-theme": "Kies een thema", "choose-age": "Kies een leeftijd", @@ -51,5 +56,24 @@ "high-school": "3e graad secundair", "older": "Hoger onderwijs" }, - "read-more": "Lees meer" + "read-more": "Lees meer", + "code": "code", + "class": "klas", + "invitations": "uitnodigingen", + "createClass": "klas aanmaken", + "createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.", + "classname": "klasnaam", + "EnterNameOfClass": "Geef een klasnaam op.", + "create": "aanmaken", + "sender": "afzender", + "nameIsMandatory": "klasnaam is verplicht", + "onlyUse": "gebruik enkel letters, cijfers, dashes (-) en underscores (_)", + "close": "sluiten", + "copied": "gekopieerd!", + "accept": "accepteren", + "deny": "weigeren", + "sent": "verzonden", + "failed": "mislukt", + "wrong": "er ging iets verkeerd", + "created": "gecreëerd" } diff --git a/frontend/src/queries/answers.ts b/frontend/src/queries/answers.ts new file mode 100644 index 00000000..f2d0f9c4 --- /dev/null +++ b/frontend/src/queries/answers.ts @@ -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, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + return useQuery({ + queryFn: async () => new AnswerController(toValue(questionId)).getAll(toValue(full)), + enabled: () => Boolean(toValue(questionId)), + }); +} + +export function useAnswerQuery( + questionId: MaybeRefOrGetter, + sequenceNumber: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryFn: async () => new AnswerController(toValue(questionId)).getBy(toValue(sequenceNumber)), + enabled: () => Boolean(toValue(questionId)), + }); +} + +export function useCreateAnswerMutation( + questionId: MaybeRefOrGetter, +): UseMutationReturnType { + return useMutation({ + mutationFn: async (data) => new AnswerController(toValue(questionId)).create(data), + }); +} + +export function useDeleteAnswerMutation( + questionId: MaybeRefOrGetter, +): UseMutationReturnType { + return useMutation({ + mutationFn: async (seq) => new AnswerController(toValue(questionId)).remove(seq), + }); +} + +export function useUpdateAnswerMutation( + questionId: MaybeRefOrGetter, +): UseMutationReturnType { + return useMutation({ + mutationFn: async (data, seq) => new AnswerController(toValue(questionId)).update(seq, data), + }); +} diff --git a/frontend/src/queries/questions.ts b/frontend/src/queries/questions.ts new file mode 100644 index 00000000..b69164a4 --- /dev/null +++ b/frontend/src/queries/questions.ts @@ -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, + full: MaybeRefOrGetter = true, +): UseQueryReturnType { + 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, +): UseQueryReturnType { + 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, +): UseMutationReturnType { + 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, +): UseMutationReturnType { + 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, +): UseMutationReturnType { + 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)) }); + }, + }); +} diff --git a/frontend/src/queries/students.ts b/frontend/src/queries/students.ts index 822083d9..5b01e3c5 100644 --- a/frontend/src/queries/students.ts +++ b/frontend/src/queries/students.ts @@ -19,7 +19,7 @@ import type { AssignmentsResponse } from "@/controllers/assignments.ts"; import type { GroupsResponse } from "@/controllers/groups.ts"; import type { SubmissionsResponse } from "@/controllers/submissions.ts"; import type { QuestionsResponse } from "@/controllers/questions.ts"; -import type { StudentDTO } from "@dwengo-1/interfaces/student"; +import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; const studentController = new StudentController(); @@ -179,7 +179,7 @@ export function useCreateJoinRequestMutation(): UseMutationReturnType< mutationFn: async ({ username, classId }) => studentController.createJoinRequest(username, classId), onSuccess: async (newJoinRequest) => { await queryClient.invalidateQueries({ - queryKey: studentJoinRequestsQueryKey(newJoinRequest.request.requester), + queryKey: studentJoinRequestsQueryKey(newJoinRequest.request.requester.username), }); }, }); @@ -196,7 +196,7 @@ export function useDeleteJoinRequestMutation(): UseMutationReturnType< return useMutation({ mutationFn: async ({ username, classId }) => studentController.deleteJoinRequest(username, classId), onSuccess: async (deletedJoinRequest) => { - const username = deletedJoinRequest.request.requester; + const username = deletedJoinRequest.request.requester.username; const classId = deletedJoinRequest.request.class; await queryClient.invalidateQueries({ queryKey: studentJoinRequestsQueryKey(username) }); await queryClient.invalidateQueries({ queryKey: studentJoinRequestQueryKey(username, classId) }); diff --git a/frontend/src/queries/teachers.ts b/frontend/src/queries/teachers.ts index 778b2cba..50d61f6c 100644 --- a/frontend/src/queries/teachers.ts +++ b/frontend/src/queries/teachers.ts @@ -1,11 +1,17 @@ import { computed, toValue } from "vue"; import type { MaybeRefOrGetter } from "vue"; -import { useMutation, useQuery, useQueryClient, UseMutationReturnType, UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts"; import type { ClassesResponse } from "@/controllers/classes.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; import type { QuestionsResponse } from "@/controllers/questions.ts"; -import type { TeacherDTO } from "@dwengo-1/interfaces/teacher"; +import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; const teacherController = new TeacherController(); diff --git a/frontend/src/views/classes/SingleClass.vue b/frontend/src/views/classes/SingleClass.vue index 1a35a59f..6a6b2f12 100644 --- a/frontend/src/views/classes/SingleClass.vue +++ b/frontend/src/views/classes/SingleClass.vue @@ -1,7 +1,224 @@ - + +
+
+ +

Loading...

+
+
+

{{ currentClass!.displayName }}

+ + + + + + + {{ t("students") }} + + + + + + + {{ s.firstName + " " + s.lastName }} + + + {{ t("remove") }} + + + + + + + +
+ + + {{ t("areusure") }} - + + + + {{ t("cancel") }} + + {{ t("yes") }} + + + +
+ + diff --git a/frontend/src/views/classes/StudentClasses.vue b/frontend/src/views/classes/StudentClasses.vue index 1a35a59f..2bfdc1b8 100644 --- a/frontend/src/views/classes/StudentClasses.vue +++ b/frontend/src/views/classes/StudentClasses.vue @@ -1,7 +1,380 @@ - + +
+
+ +

Loading...

+
- +
+ mdi-alert-circle +

Error loading: {{ error.message }}

+
+
+

{{ t("classes") }}

+ + + + + + + {{ t("classes") }} + {{ t("teachers") }} + {{ t("members") }} + + + + + {{ c.displayName }} + + {{ c.teachers.length }} + + + {{ c.students.length }} + + + + + + + + + + + {{ selectedClass?.displayName }} + +
    +
  • + {{ student.firstName + " " + student.lastName }} +
  • +
+
    +
  • + {{ teacher.firstName + " " + teacher.lastName }} +
  • +
+
+ + Close + +
+
+
+
+

{{ t("joinClass") }}

+

{{ t("JoinClassExplanation") }}

+ + + + + {{ t("submitCode") }} + + +
+
+
+ + {{ snackbar.message }} + +
+ + diff --git a/frontend/src/views/classes/TeacherClasses.vue b/frontend/src/views/classes/TeacherClasses.vue index 1a35a59f..ae673d99 100644 --- a/frontend/src/views/classes/TeacherClasses.vue +++ b/frontend/src/views/classes/TeacherClasses.vue @@ -1,7 +1,404 @@ - + +
+
+ +

Loading...

+
- +
+ mdi-alert-circle +

Error loading: {{ error.message }}

+
+
+

{{ t("classes") }}

+ + + + + + + {{ t("classes") }} + + {{ t("code") }} + + {{ t("members") }} + + + + + + + {{ c.displayName }} + mdi-menu-right + + + {{ c.id }} + {{ c.students.length }} + + + + + +
+

{{ t("createClass") }}

+ + +

{{ t("createClassInstructions") }}

+ + + {{ t("create") }} + +
+ + + + code + + + +
+ {{ t("copied") }} +
+
+
+ + + + {{ t("close") }} + + +
+
+
+
+
+
+
+ +

+ {{ t("invitations") }} +

+ + + + {{ t("class") }} + {{ t("sender") }} + + + + + + + {{ (i.class as ClassDTO).displayName }} + + {{ (i.sender as TeacherDTO).firstName + " " + (i.sender as TeacherDTO).lastName }} + +
+ + {{ t("accept") }} + + + {{ t("deny") }} + +
+ + + +
+
+ + {{ snackbar.message }} + +
+ + diff --git a/frontend/src/views/classes/UserClasses.vue b/frontend/src/views/classes/UserClasses.vue index 1a35a59f..566bd02a 100644 --- a/frontend/src/views/classes/UserClasses.vue +++ b/frontend/src/views/classes/UserClasses.vue @@ -1,7 +1,17 @@ - +