diff --git a/backend/.env-old b/backend/.env-old new file mode 100644 index 00000000..bedfb0b7 --- /dev/null +++ b/backend/.env-old @@ -0,0 +1,21 @@ +PORT=3000 +DWENGO_DB_HOST=db +DWENGO_DB_PORT=5432 +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres +DWENGO_DB_UPDATE=false + +DWENGO_AUTH_STUDENT_URL=http://localhost/idp/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost/idp/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs + +# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! +#DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/,127.0.0.1:80,http://127.0.0.1,http://localhost:80,http://127.0.0.1:80,localhost +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080 + +# Logging and monitoring + +LOKI_HOST=http://logging:3102 diff --git a/backend/Dockerfile b/backend/Dockerfile index f09a89eb..1d82a484 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,8 +6,9 @@ WORKDIR /app/dwengo COPY package*.json ./ COPY backend/package.json ./backend/ -# Backend depends on common +# Backend depends on common and docs COPY common/package.json ./common/ +COPY docs/package.json ./docs/ RUN npm install --silent @@ -34,6 +35,7 @@ COPY ./backend/i18n ./i18n COPY --from=build-stage /app/dwengo/common/dist ./common/dist COPY --from=build-stage /app/dwengo/backend/dist ./backend/dist +COPY --from=build-stage /app/dwengo/docs/api/swagger.json ./docs/api/swagger.json COPY package*.json ./ COPY backend/package.json ./backend/ @@ -42,9 +44,7 @@ COPY common/package.json ./common/ RUN npm install --silent --only=production -COPY ./docs ./docs COPY ./backend/i18n ./backend/i18n -COPY ./backend/.env ./backend/.env EXPOSE 3000 diff --git a/backend/package.json b/backend/package.json index a3060881..eb704fc5 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/assignments.ts b/backend/src/controllers/assignments.ts index 1520fc10..2ecb35cb 100644 --- a/backend/src/controllers/assignments.ts +++ b/backend/src/controllers/assignments.ts @@ -1,77 +1,94 @@ import { Request, Response } from 'express'; -import { createAssignment, getAllAssignments, getAssignment, getAssignmentsSubmissions } from '../services/assignments.js'; +import { + createAssignment, + deleteAssignment, + getAllAssignments, + getAssignment, + getAssignmentsSubmissions, + putAssignment, +} from '../services/assignments.js'; import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { requireFields } from './error-helper.js'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { EntityDTO } from '@mikro-orm/core'; -// Typescript is annoying with parameter forwarding from class.ts -interface AssignmentParams { - classid: string; - id: string; -} - -export async function getAllAssignmentsHandler(req: Request, res: Response): Promise { - const classid = req.params.classid; +export async function getAllAssignmentsHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; const full = req.query.full === 'true'; - const assignments = await getAllAssignments(classid, full); + const assignments = await getAllAssignments(classId, full); - res.json({ - assignments: assignments, - }); + res.json({ assignments }); } -export async function createAssignmentHandler(req: Request, res: Response): Promise { +export async function createAssignmentHandler(req: Request, res: Response): Promise { const classid = req.params.classid; + const description = req.body.description; + const language = req.body.language; + const learningPath = req.body.learningPath; + const title = req.body.title; + + requireFields({ description, language, learningPath, title }); + const assignmentData = req.body as AssignmentDTO; - - if (!assignmentData.description || !assignmentData.language || !assignmentData.learningPath || !assignmentData.title) { - res.status(400).json({ - error: 'Missing one or more required fields: title, description, learningPath, language', - }); - return; - } - const assignment = await createAssignment(classid, assignmentData); - if (!assignment) { - res.status(500).json({ error: 'Could not create assignment ' }); - return; - } - - res.status(201).json(assignment); + res.json({ assignment }); } -export async function getAssignmentHandler(req: Request, res: Response): Promise { +export async function getAssignmentHandler(req: Request, res: Response): Promise { const id = Number(req.params.id); const classid = req.params.classid; + requireFields({ id, classid }); if (isNaN(id)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id should be a number'); } const assignment = await getAssignment(classid, id); - if (!assignment) { - res.status(404).json({ error: 'Assignment not found' }); - return; - } - - res.json(assignment); + res.json({ assignment }); } -export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise { +export async function putAssignmentHandler(req: Request, res: Response): Promise { + const id = Number(req.params.id); + const classid = req.params.classid; + requireFields({ id, classid }); + + if (isNaN(id)) { + throw new BadRequestException('Assignment id should be a number'); + } + + const assignmentData = req.body as Partial>; + const assignment = await putAssignment(classid, id, assignmentData); + + res.json({ assignment }); +} + +export async function deleteAssignmentHandler(req: Request, _res: Response): Promise { + const id = Number(req.params.id); + const classid = req.params.classid; + requireFields({ id, classid }); + + if (isNaN(id)) { + throw new BadRequestException('Assignment id should be a number'); + } + + await deleteAssignment(classid, id); +} + +export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise { const classid = req.params.classid; const assignmentNumber = Number(req.params.id); const full = req.query.full === 'true'; + requireFields({ assignmentNumber, classid }); if (isNaN(assignmentNumber)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id should be a number'); } const submissions = await getAssignmentsSubmissions(classid, assignmentNumber, full); - res.json({ - submissions: submissions, - }); + res.json({ submissions }); } diff --git a/backend/src/controllers/classes.ts b/backend/src/controllers/classes.ts index a041bf22..6f253547 100644 --- a/backend/src/controllers/classes.ts +++ b/backend/src/controllers/classes.ts @@ -1,66 +1,132 @@ import { Request, Response } from 'express'; -import { createClass, getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/classes.js'; +import { + addClassStudent, + addClassTeacher, + createClass, + deleteClass, + deleteClassStudent, + deleteClassTeacher, + getAllClasses, + getClass, + getClassStudents, + getClassTeacherInvitations, + getClassTeachers, + putClass, +} from '../services/classes.js'; import { ClassDTO } from '@dwengo-1/common/interfaces/class'; +import { requireFields } from './error-helper.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { Class } from '../entities/classes/class.entity.js'; export async function getAllClassesHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; const classes = await getAllClasses(full); - res.json({ - classes: classes, - }); + res.json({ classes }); } export async function createClassHandler(req: Request, res: Response): Promise { + const displayName = req.body.displayName; + requireFields({ displayName }); + const classData = req.body as ClassDTO; - - if (!classData.displayName) { - res.status(400).json({ - error: 'Missing one or more required fields: displayName', - }); - return; - } - const cls = await createClass(classData); - if (!cls) { - res.status(500).json({ error: 'Something went wrong while creating class' }); - return; - } - - res.status(201).json(cls); + res.json({ class: cls }); } export async function getClassHandler(req: Request, res: Response): Promise { const classId = req.params.id; + requireFields({ classId }); + const cls = await getClass(classId); - if (!cls) { - res.status(404).json({ error: 'Class not found' }); - return; - } + res.json({ class: cls }); +} - res.json(cls); +export async function putClassHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + requireFields({ classId }); + + const newData = req.body as Partial>; + const cls = await putClass(classId, newData); + + res.json({ class: cls }); +} + +export async function deleteClassHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const cls = await deleteClass(classId); + + res.json({ class: cls }); } export async function getClassStudentsHandler(req: Request, res: Response): Promise { const classId = req.params.id; const full = req.query.full === 'true'; + requireFields({ classId }); - const students = full ? await getClassStudents(classId) : await getClassStudentsIds(classId); + const students = await getClassStudents(classId, full); - res.json({ - students: students, - }); + res.json({ students }); +} + +export async function getClassTeachersHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const full = req.query.full === 'true'; + requireFields({ classId }); + + const teachers = await getClassTeachers(classId, full); + + res.json({ teachers }); } export async function getTeacherInvitationsHandler(req: Request, res: Response): Promise { const classId = req.params.id; const full = req.query.full === 'true'; + requireFields({ classId }); const invitations = await getClassTeacherInvitations(classId, full); - res.json({ - invitations: invitations, - }); + res.json({ invitations }); +} + +export async function deleteClassStudentHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.params.username; + requireFields({ classId, username }); + + const cls = await deleteClassStudent(classId, username); + + res.json({ class: cls }); +} + +export async function deleteClassTeacherHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.params.username; + requireFields({ classId, username }); + + const cls = await deleteClassTeacher(classId, username); + + res.json({ class: cls }); +} + +export async function addClassStudentHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.body.username; + requireFields({ classId, username }); + + const cls = await addClassStudent(classId, username); + + res.json({ class: cls }); +} + +export async function addClassTeacherHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.body.username; + requireFields({ classId, username }); + + const cls = await addClassTeacher(classId, username); + + res.json({ class: cls }); } diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts index 989066a6..ec177dcc 100644 --- a/backend/src/controllers/groups.ts +++ b/backend/src/controllers/groups.ts @@ -1,100 +1,104 @@ import { Request, Response } from 'express'; -import { createGroup, getAllGroups, getGroup, getGroupSubmissions } from '../services/groups.js'; +import { createGroup, deleteGroup, getAllGroups, getGroup, getGroupSubmissions, putGroup } from '../services/groups.js'; import { GroupDTO } from '@dwengo-1/common/interfaces/group'; +import { requireFields } from './error-helper.js'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { Group } from '../entities/assignments/group.entity.js'; -// Typescript is annoywith with parameter forwarding from class.ts -interface GroupParams { - classid: string; - assignmentid: string; - groupid?: string; -} - -export async function getGroupHandler(req: Request, res: Response): Promise { - const classId = req.params.classid; - const full = req.query.full === 'true'; - const assignmentId = Number(req.params.assignmentid); +function checkGroupFields(classId: string, assignmentId: number, groupId: number): void { + requireFields({ classId, assignmentId, groupId }); if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } - const groupId = Number(req.params.groupid!); // Can't be undefined - if (isNaN(groupId)) { - res.status(400).json({ error: 'Group id must be a number' }); - return; + throw new BadRequestException('Group id must be a number'); } +} - const group = await getGroup(classId, assignmentId, groupId, full); +export async function getGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); - if (!group) { - res.status(404).json({ error: 'Group not found' }); - return; - } + const group = await getGroup(classId, assignmentId, groupId); - res.json(group); + res.json({ group }); +} + +export async function putGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); + + const group = await putGroup(classId, assignmentId, groupId, req.body as Partial>); + + res.json({ group }); +} + +export async function deleteGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); + + const group = await deleteGroup(classId, assignmentId, groupId); + + res.json({ group }); } export async function getAllGroupsHandler(req: Request, res: Response): Promise { const classId = req.params.classid; - const full = req.query.full === 'true'; - const assignmentId = Number(req.params.assignmentid); + const full = req.query.full === 'true'; + requireFields({ classId, assignmentId }); if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } const groups = await getAllGroups(classId, assignmentId, full); - res.json({ - groups: groups, - }); + res.json({ groups }); } export async function createGroupHandler(req: Request, res: Response): Promise { const classid = req.params.classid; const assignmentId = Number(req.params.assignmentid); + requireFields({ classid, assignmentId }); + if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } const groupData = req.body as GroupDTO; const group = await createGroup(groupData, classid, assignmentId); - if (!group) { - res.status(500).json({ error: 'Something went wrong while creating group' }); - return; - } - - res.status(201).json(group); + res.status(201).json({ group }); } export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise { const classId = req.params.classid; + const assignmentId = Number(req.params.assignmentid); + const groupId = Number(req.params.groupid); const full = req.query.full === 'true'; - const assignmentId = Number(req.params.assignmentid); + requireFields({ classId, assignmentId, groupId }); if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } - const groupId = Number(req.params.groupid); // Can't be undefined - if (isNaN(groupId)) { - res.status(400).json({ error: 'Group id must be a number' }); - return; + throw new BadRequestException('Group id must be a number'); } const submissions = await getGroupSubmissions(classId, assignmentId, groupId, full); - res.json({ - submissions: submissions, - }); + res.json({ submissions }); } 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 b5b764ac..3240aa71 100644 --- a/backend/src/controllers/questions.ts +++ b/backend/src/controllers/questions.ts @@ -1,34 +1,27 @@ import { Request, Response } from 'express'; -import { createQuestion, deleteQuestion, getAllQuestions, getAnswersByQuestion, getQuestion } from '../services/questions.js'; -import { FALLBACK_LANG, FALLBACK_SEQ_NUM } from '../config.js'; +import { + createQuestion, + deleteQuestion, + getAllQuestions, + getQuestion, + getQuestionsAboutLearningObjectInAssignment, + updateQuestion, +} from '../services/questions.js'; +import { FALLBACK_LANG, FALLBACK_SEQ_NUM, FALLBACK_VERSION_NUM } from '../config.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { Language } from '@dwengo-1/common/util/language'; +import { requireFields } from './error-helper.js'; -function getObjectId(req: Request, res: Response): LearningObjectIdentifier | null { - const { hruid, version } = req.params; - const lang = req.query.lang; - - if (!hruid || !version) { - res.status(400).json({ error: 'Missing required parameters.' }); - return null; - } - +export function getLearningObjectId(hruid: string, version: string, lang: string): LearningObjectIdentifier { return { hruid, - language: (lang as Language) || FALLBACK_LANG, - version: Number(version), + language: (lang || FALLBACK_LANG) as Language, + version: Number(version) || FALLBACK_VERSION_NUM, }; } -function getQuestionId(req: Request, res: Response): QuestionId | null { - const seq = req.params.seq; - const learningObjectIdentifier = getObjectId(req, res); - - if (!learningObjectIdentifier) { - return null; - } - +export function getQuestionId(learningObjectIdentifier: LearningObjectIdentifier, seq: string): QuestionId { return { learningObjectIdentifier, sequenceNumber: seq ? Number(seq) : FALLBACK_SEQ_NUM, @@ -36,84 +29,96 @@ function getQuestionId(req: Request, res: Response): QuestionId | null { } export async function getAllQuestionsHandler(req: Request, res: Response): Promise { - const objectId = getObjectId(req, res); + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; const full = req.query.full === 'true'; + requireFields({ hruid }); - if (!objectId) { - return; - } + const learningObjectId = getLearningObjectId(hruid, version, language); - const questions = await getAllQuestions(objectId, full); - - if (!questions) { - res.status(404).json({ error: `Questions not found.` }); + let questions: QuestionDTO[] | QuestionId[]; + if (req.query.classId && req.query.assignmentId) { + questions = await getQuestionsAboutLearningObjectInAssignment( + learningObjectId, + req.query.classId as string, + parseInt(req.query.assignmentId as string), + full ?? false, + req.query.forStudent as string | undefined + ); } else { - res.json({ questions: questions }); + questions = await getAllQuestions(learningObjectId, full ?? false); } + + res.json({ questions }); } export async function getQuestionHandler(req: Request, res: Response): Promise { - const questionId = getQuestionId(req, res); + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); - if (!questionId) { - return; - } + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); const question = await getQuestion(questionId); - if (!question) { - res.status(404).json({ error: `Question not found.` }); - } else { - res.json(question); - } -} - -export async function getQuestionAnswersHandler(req: Request, res: Response): Promise { - const questionId = getQuestionId(req, res); - const full = req.query.full === 'true'; - - if (!questionId) { - return; - } - - const answers = await getAnswersByQuestion(questionId, full); - - if (!answers) { - res.status(404).json({ error: `Questions not found` }); - } else { - res.json({ answers: answers }); - } + res.json({ question }); } export async function createQuestionHandler(req: Request, res: Response): Promise { - const questionDTO = req.body as QuestionDTO; + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + requireFields({ hruid }); - if (!questionDTO.learningObjectIdentifier || !questionDTO.author || !questionDTO.content) { - res.status(400).json({ error: 'Missing required fields: identifier and content' }); - return; - } + const loId = getLearningObjectId(hruid, version, language); - const question = await createQuestion(questionDTO); + const author = req.body.author as string; + const content = req.body.content as string; + const inGroup = req.body.inGroup; + 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); + 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/controllers/submissions.ts b/backend/src/controllers/submissions.ts index 239eb6d7..92cf84c1 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -1,61 +1,83 @@ import { Request, Response } from 'express'; -import { createSubmission, deleteSubmission, getSubmission } from '../services/submissions.js'; +import { + createSubmission, + deleteSubmission, + getAllSubmissions, + getSubmission, + getSubmissionsForLearningObjectAndAssignment, +} from '../services/submissions.js'; import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; import { Language, languageMap } from '@dwengo-1/common/util/language'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { requireFields } from './error-helper.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -interface SubmissionParams { - hruid: string; - id: number; +export async function getSubmissionsHandler(req: Request, res: Response): Promise { + const loHruid = req.params.hruid; + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = parseInt(req.query.version as string) ?? 1; + + const submissions = await getSubmissionsForLearningObjectAndAssignment( + loHruid, + lang, + version, + req.query.classId as string, + parseInt(req.query.assignmentId as string) + ); + + res.json(submissions); } -export async function getSubmissionHandler(req: Request, res: Response): Promise { +export async function getSubmissionHandler(req: Request, res: Response): Promise { const lohruid = req.params.hruid; - const submissionNumber = Number(req.params.id); - - if (isNaN(submissionNumber)) { - res.status(400).json({ error: 'Submission number is not a number' }); - return; - } - const lang = languageMap[req.query.language as string] || Language.Dutch; const version = (req.query.version || 1) as number; + const submissionNumber = Number(req.params.id); + requireFields({ lohruid, submissionNumber }); - const submission = await getSubmission(lohruid, lang, version, submissionNumber); - - if (!submission) { - res.status(404).json({ error: 'Submission not found' }); - return; + if (isNaN(submissionNumber)) { + throw new BadRequestException('Submission number must be a number'); } - res.json(submission); + const loId = new LearningObjectIdentifier(lohruid, lang, version); + const submission = await getSubmission(loId, submissionNumber); + + res.json({ submission }); } +export async function getAllSubmissionsHandler(req: Request, res: Response): Promise { + const lohruid = req.params.hruid; + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + requireFields({ lohruid }); + + const loId = new LearningObjectIdentifier(lohruid, lang, version); + const submissions = await getAllSubmissions(loId); + + res.json({ submissions }); +} + +// TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden export async function createSubmissionHandler(req: Request, res: Response): Promise { const submissionDTO = req.body as SubmissionDTO; - const submission = await createSubmission(submissionDTO); - if (!submission) { - res.status(400).json({ error: 'Failed to create submission' }); - return; - } - - res.json(submission); + res.json({ submission }); } export async function deleteSubmissionHandler(req: Request, res: Response): Promise { const hruid = req.params.hruid; - const submissionNumber = Number(req.params.id); - const lang = languageMap[req.query.language as string] || Language.Dutch; const version = (req.query.version || 1) as number; + const submissionNumber = Number(req.params.id); + requireFields({ hruid, submissionNumber }); - const submission = await deleteSubmission(hruid, lang, version, submissionNumber); - - if (!submission) { - res.status(404).json({ error: 'Submission not found' }); - return; + if (isNaN(submissionNumber)) { + throw new BadRequestException('Submission number must be a number'); } - res.json(submission); + const loId = new LearningObjectIdentifier(hruid, lang, version); + const submission = await deleteSubmission(loId, submissionNumber); + + res.json({ submission }); } diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts index 9275ca92..c8063f80 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -81,16 +81,15 @@ export async function getTeacherQuestionHandler(req: Request, res: Response): Pr } export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise { - const username = req.query.username as string; const classId = req.params.classId; - requireFields({ username, classId }); + requireFields({ classId }); const joinRequests = await getJoinRequestsByClass(classId); res.json({ joinRequests }); } export async function updateStudentJoinRequestHandler(req: Request, res: Response): Promise { - const studentUsername = req.query.studentUsername as string; + const studentUsername = req.params.studentUsername; const classId = req.params.classId; const accepted = req.body.accepted !== 'false'; // Default = true requireFields({ studentUsername, classId }); diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts index 3de5031d..db12a74f 100644 --- a/backend/src/data/assignments/assignment-repository.ts +++ b/backend/src/data/assignments/assignment-repository.ts @@ -6,6 +6,22 @@ export class AssignmentRepository extends DwengoEntityRepository { public async findByClassAndId(within: Class, id: number): Promise { return this.findOne({ within: within, id: id }); } + public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise { + return this.findOne({ within: { classId: withinClass }, id: id }); + } + public async findAllByResponsibleTeacher(teacherUsername: string): Promise { + return this.findAll({ + where: { + within: { + teachers: { + $some: { + username: teacherUsername, + }, + }, + }, + }, + }); + } public async findAllAssignmentsInClass(within: Class): Promise { return this.findAll({ where: { within: within } }); } diff --git a/backend/src/data/assignments/submission-repository.ts b/backend/src/data/assignments/submission-repository.ts index f5090adc..93089e3b 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -3,6 +3,7 @@ import { Group } from '../../entities/assignments/group.entity.js'; import { Submission } from '../../entities/assignments/submission.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; +import { Assignment } from '../../entities/assignments/assignment.entity'; export class SubmissionRepository extends DwengoEntityRepository { public async findSubmissionByLearningObjectAndSubmissionNumber( @@ -17,6 +18,14 @@ export class SubmissionRepository extends DwengoEntityRepository { }); } + public async findByLearningObject(loId: LearningObjectIdentifier): Promise { + return this.find({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + }); + } + public async findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { return this.findOne( { @@ -42,11 +51,58 @@ export class SubmissionRepository extends DwengoEntityRepository { } public async findAllSubmissionsForGroup(group: Group): Promise { - return this.find({ onBehalfOf: group }); + return this.find( + { onBehalfOf: group }, + { + populate: ['onBehalfOf.members'], + } + ); + } + + /** + * Looks up all submissions for the given learning object which were submitted as part of the given assignment. + * When forStudentUsername is set, only the submissions of the given user's group are shown. + */ + public async findAllSubmissionsForLearningObjectAndAssignment( + loId: LearningObjectIdentifier, + assignment: Assignment, + forStudentUsername?: string + ): Promise { + const onBehalfOf = forStudentUsername + ? { + assignment, + members: { + $some: { + username: forStudentUsername, + }, + }, + } + : { + assignment, + }; + + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + onBehalfOf, + }, + }); } public async findAllSubmissionsForStudent(student: Student): Promise { - return this.find({ submitter: student }); + const result = await this.find( + { submitter: student }, + { + populate: ['onBehalfOf.members'], + } + ); + + // Workaround: For some reason, without this MikroORM generates an UPDATE query with a syntax error in some tests + this.em.clear(); + + return result; } public async deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { 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 2d165abc..362ec4c9 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -3,14 +3,18 @@ import { Question } from '../../entities/questions/question.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { Assignment } from '../../entities/assignments/assignment.entity.js'; +import { Loaded } from '@mikro-orm/core'; +import { Group } from '../../entities/assignments/group.entity'; export class QuestionRepository extends DwengoEntityRepository { - public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { + public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { const questionEntity = this.create({ learningObjectHruid: question.loId.hruid, learningObjectLanguage: question.loId.language, learningObjectVersion: question.loId.version, author: question.author, + inGroup: question.inGroup, content: question.content, timestamp: new Date(), }); @@ -18,6 +22,7 @@ export class QuestionRepository extends DwengoEntityRepository { questionEntity.learningObjectLanguage = question.loId.language; questionEntity.learningObjectVersion = question.loId.version; questionEntity.author = question.author; + questionEntity.inGroup = question.inGroup; questionEntity.content = question.content; return this.insert(questionEntity); } @@ -55,10 +60,67 @@ export class QuestionRepository extends DwengoEntityRepository { }); } + public async findAllByAssignment(assignment: Assignment): Promise { + return this.find({ + inGroup: { + $contained: assignment.groups, + }, + learningObjectHruid: assignment.learningPathHruid, + learningObjectLanguage: assignment.learningPathLanguage, + }); + } + public async findAllByAuthor(author: Student): Promise { return this.findAll({ where: { author }, orderBy: { timestamp: 'DESC' }, // New to old }); } + + /** + * 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. + */ + public async findAllQuestionsAboutLearningObjectInAssignment( + loId: LearningObjectIdentifier, + assignment: Assignment, + forStudentUsername?: string + ): Promise { + const inGroup = forStudentUsername + ? { + assignment, + members: { + $some: { + username: forStudentUsername, + }, + }, + } + : { + assignment, + }; + + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + inGroup, + }, + }); + } + + 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; + } } diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index 36b24344..e3f75489 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Class } from '../classes/class.entity.js'; import { Group } from './group.entity.js'; import { Language } from '@dwengo-1/common/util/language'; @@ -35,5 +35,5 @@ export class Assignment { entity: () => Group, mappedBy: 'assignment', }) - groups!: Group[]; + groups!: Collection; } diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts index cfe21f7f..55770b7f 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -1,4 +1,4 @@ -import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; +import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; import { Assignment } from './assignment.entity.js'; import { Student } from '../users/student.entity.js'; import { GroupRepository } from '../../data/assignments/group-repository.js'; @@ -19,5 +19,5 @@ export class Group { @ManyToMany({ entity: () => Student, }) - members!: Student[]; + members!: Collection; } diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index 80b9a8fb..82d49a40 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -21,6 +21,11 @@ export class Submission { @PrimaryKey({ type: 'integer', autoincrement: true }) submissionNumber?: number; + @ManyToOne({ + entity: () => Group, + }) + onBehalfOf!: Group; + @ManyToOne({ entity: () => Student, }) @@ -29,12 +34,6 @@ export class Submission { @Property({ type: 'datetime' }) submissionTime!: Date; - @ManyToOne({ - entity: () => Group, - nullable: true, - }) - onBehalfOf?: Group; - @Property({ type: 'json' }) content!: string; } diff --git a/backend/src/entities/questions/question.entity.ts b/backend/src/entities/questions/question.entity.ts index 5e691f70..44ccfbd3 100644 --- a/backend/src/entities/questions/question.entity.ts +++ b/backend/src/entities/questions/question.entity.ts @@ -2,6 +2,7 @@ import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { QuestionRepository } from '../../data/questions/question-repository.js'; import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../assignments/group.entity.js'; @Entity({ repository: () => QuestionRepository }) export class Question { @@ -20,6 +21,9 @@ export class Question { @PrimaryKey({ type: 'integer', autoincrement: true }) sequenceNumber?: number; + @ManyToOne({ entity: () => Group }) + inGroup!: Group; + @ManyToOne({ entity: () => Student, }) 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/assignment.ts b/backend/src/interfaces/assignment.ts index d48a9083..7abb3d3c 100644 --- a/backend/src/interfaces/assignment.ts +++ b/backend/src/interfaces/assignment.ts @@ -8,19 +8,18 @@ import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTO { return { id: assignment.id!, - class: assignment.within.classId!, + within: assignment.within.classId!, title: assignment.title, description: assignment.description, learningPath: assignment.learningPathHruid, language: assignment.learningPathLanguage, - // Groups: assignment.groups.map(group => group.groupNumber), }; } export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { return { id: assignment.id!, - class: assignment.within.classId!, + within: assignment.within.classId!, title: assignment.title, description: assignment.description, learningPath: assignment.learningPathHruid, diff --git a/backend/src/interfaces/class.ts b/backend/src/interfaces/class.ts index 7b07fcf2..76fa5fd5 100644 --- a/backend/src/interfaces/class.ts +++ b/backend/src/interfaces/class.ts @@ -10,7 +10,6 @@ export function mapToClassDTO(cls: Class): ClassDTO { displayName: cls.displayName, teachers: cls.teachers.map((teacher) => teacher.username), students: cls.students.map((student) => student.username), - joinRequests: [], // TODO }; } diff --git a/backend/src/interfaces/group.ts b/backend/src/interfaces/group.ts index 1a169b2b..2a8287a3 100644 --- a/backend/src/interfaces/group.ts +++ b/backend/src/interfaces/group.ts @@ -1,11 +1,29 @@ import { Group } from '../entities/assignments/group.entity.js'; +import { mapToAssignment } from './assignment.js'; +import { mapToStudent } from './student.js'; import { mapToAssignmentDTO } from './assignment.js'; import { mapToStudentDTO } from './student.js'; import { GroupDTO } from '@dwengo-1/common/interfaces/group'; +import { getGroupRepository } from '../data/repositories.js'; +import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { Class } from '../entities/classes/class.entity.js'; +import { StudentDTO } from '@dwengo-1/common/interfaces/student'; +import { mapToClassDTO } from './class'; + +export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { + const assignmentDto = groupDto.assignment as AssignmentDTO; + + return getGroupRepository().create({ + groupNumber: groupDto.groupNumber, + assignment: mapToAssignment(assignmentDto, clazz), + members: groupDto.members!.map((studentDto) => mapToStudent(studentDto as StudentDTO)), + }); +} export function mapToGroupDTO(group: Group): GroupDTO { return { - assignment: mapToAssignmentDTO(group.assignment), // ERROR: , group.assignment.within), + class: mapToClassDTO(group.assignment.within), + assignment: mapToAssignmentDTO(group.assignment), groupNumber: group.groupNumber!, members: group.members.map(mapToStudentDTO), }; @@ -13,6 +31,18 @@ export function mapToGroupDTO(group: Group): GroupDTO { export function mapToGroupDTOId(group: Group): GroupDTO { return { + class: group.assignment.within.classId!, + assignment: group.assignment.id!, + groupNumber: group.groupNumber!, + }; +} + +/** + * Map to group DTO where other objects are only referenced by their id. + */ +export function mapToShallowGroupDTO(group: Group): GroupDTO { + return { + class: group.assignment.within.classId!, assignment: group.assignment.id!, groupNumber: group.groupNumber!, members: group.members.map((member) => member.username), diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts index 48d64f11..50a61301 100644 --- a/backend/src/interfaces/question.ts +++ b/backend/src/interfaces/question.ts @@ -1,9 +1,11 @@ import { Question } from '../entities/questions/question.entity.js'; import { mapToStudentDTO } from './student.js'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; -import { LearningObjectIdentifier } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { mapToGroupDTOId } from './group.js'; -function getLearningObjectIdentifier(question: Question): LearningObjectIdentifier { +function getLearningObjectIdentifier(question: Question): LearningObjectIdentifierDTO { return { hruid: question.learningObjectHruid, language: question.learningObjectLanguage, @@ -11,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. */ @@ -21,6 +31,7 @@ export function mapToQuestionDTO(question: Question): QuestionDTO { learningObjectIdentifier, sequenceNumber: question.sequenceNumber!, author: mapToStudentDTO(question.author), + inGroup: mapToGroupDTOId(question.inGroup), timestamp: question.timestamp.toISOString(), content: question.content, }; diff --git a/backend/src/interfaces/submission.ts b/backend/src/interfaces/submission.ts index b4ed4a2b..aa88f4a1 100644 --- a/backend/src/interfaces/submission.ts +++ b/backend/src/interfaces/submission.ts @@ -1,7 +1,10 @@ import { Submission } from '../entities/assignments/submission.entity.js'; import { mapToGroupDTO } from './group.js'; -import { mapToStudent, mapToStudentDTO } from './student.js'; +import { mapToStudentDTO } from './student.js'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; +import { getSubmissionRepository } from '../data/repositories'; +import { Student } from '../entities/users/student.entity'; +import { Group } from '../entities/assignments/group.entity'; export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { return { @@ -14,7 +17,7 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { submissionNumber: submission.submissionNumber, submitter: mapToStudentDTO(submission.submitter), time: submission.submissionTime, - group: submission.onBehalfOf ? mapToGroupDTO(submission.onBehalfOf) : undefined, + group: mapToGroupDTO(submission.onBehalfOf), content: submission.content, }; } @@ -29,17 +32,14 @@ export function mapToSubmissionDTOId(submission: Submission): SubmissionDTOId { }; } -export function mapToSubmission(submissionDTO: SubmissionDTO): Submission { - const submission = new Submission(); - submission.learningObjectHruid = submissionDTO.learningObjectIdentifier.hruid; - submission.learningObjectLanguage = submissionDTO.learningObjectIdentifier.language; - submission.learningObjectVersion = submissionDTO.learningObjectIdentifier.version!; - // Submission.submissionNumber = submissionDTO.submissionNumber; - submission.submitter = mapToStudent(submissionDTO.submitter); - // Submission.submissionTime = submissionDTO.time; - // Submission.onBehalfOf = submissionDTO.group!; - // TODO fix group - submission.content = submissionDTO.content; - - return submission; +export function mapToSubmission(submissionDTO: SubmissionDTO, submitter: Student, onBehalfOf: Group): Submission { + return getSubmissionRepository().create({ + learningObjectHruid: submissionDTO.learningObjectIdentifier.hruid, + learningObjectLanguage: submissionDTO.learningObjectIdentifier.language, + learningObjectVersion: submissionDTO.learningObjectIdentifier.version || 1, + submitter: submitter, + submissionTime: new Date(), + content: submissionDTO.content, + onBehalfOf: onBehalfOf, + }); } 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/assignments.ts b/backend/src/routes/assignments.ts index 3652dcc6..083ee586 100644 --- a/backend/src/routes/assignments.ts +++ b/backend/src/routes/assignments.ts @@ -1,22 +1,26 @@ import express from 'express'; import { createAssignmentHandler, + deleteAssignmentHandler, getAllAssignmentsHandler, getAssignmentHandler, getAssignmentsSubmissionsHandler, + putAssignmentHandler, } from '../controllers/assignments.js'; import groupRouter from './groups.js'; const router = express.Router({ mergeParams: true }); -// Root endpoint used to search objects router.get('/', getAllAssignmentsHandler); router.post('/', createAssignmentHandler); -// Information about an assignment with id 'id' router.get('/:id', getAssignmentHandler); +router.put('/:id', putAssignmentHandler); + +router.delete('/:id', deleteAssignmentHandler); + router.get('/:id/submissions', getAssignmentsSubmissionsHandler); router.get('/:id/questions', (_req, res) => { diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index e0972988..cef6fd72 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -1,10 +1,17 @@ import express from 'express'; import { + addClassStudentHandler, + addClassTeacherHandler, createClassHandler, + deleteClassHandler, + deleteClassStudentHandler, + deleteClassTeacherHandler, getAllClassesHandler, getClassHandler, getClassStudentsHandler, + getClassTeachersHandler, getTeacherInvitationsHandler, + putClassHandler, } from '../controllers/classes.js'; import assignmentRouter from './assignments.js'; @@ -15,13 +22,26 @@ router.get('/', getAllClassesHandler); router.post('/', createClassHandler); -// Information about an class with id 'id' router.get('/:id', getClassHandler); +router.put('/:id', putClassHandler); + +router.delete('/:id', deleteClassHandler); + router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); router.get('/:id/students', getClassStudentsHandler); +router.post('/:id/students', addClassStudentHandler); + +router.delete('/:id/students/:username', deleteClassStudentHandler); + +router.get('/:id/teachers', getClassTeachersHandler); + +router.post('/:id/teachers', addClassTeacherHandler); + +router.delete('/:id/teachers/:username', deleteClassTeacherHandler); + router.use('/:classid/assignments', assignmentRouter); export default router; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index dc8917bd..7f973972 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -1,5 +1,12 @@ import express from 'express'; -import { createGroupHandler, getAllGroupsHandler, getGroupHandler, getGroupSubmissionsHandler } from '../controllers/groups.js'; +import { + createGroupHandler, + deleteGroupHandler, + getAllGroupsHandler, + getGroupHandler, + getGroupSubmissionsHandler, + putGroupHandler, +} from '../controllers/groups.js'; const router = express.Router({ mergeParams: true }); @@ -8,16 +15,12 @@ router.get('/', getAllGroupsHandler); router.post('/', createGroupHandler); -// Information about a group (members, ... [TODO DOC]) router.get('/:groupid', getGroupHandler); -router.get('/:groupid', getGroupSubmissionsHandler); +router.put('/:groupid', putGroupHandler); -// The list of questions a group has made -router.get('/:id/questions', (_req, res) => { - res.json({ - questions: ['0'], - }); -}); +router.delete('/:groupid', deleteGroupHandler); + +router.get('/:groupid/submissions', getGroupSubmissionsHandler); 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/routes/submissions.ts b/backend/src/routes/submissions.ts index 8e9831b9..492b6439 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -1,13 +1,9 @@ import express from 'express'; -import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler } from '../controllers/submissions.js'; +import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; const router = express.Router({ mergeParams: true }); // Root endpoint used to search objects -router.get('/', (_req, res) => { - res.json({ - submissions: ['0', '1'], - }); -}); +router.get('/', getSubmissionsHandler); router.post('/:id', createSubmissionHandler); 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/assignments.ts b/backend/src/services/assignments.ts index e86b69b2..5fd8f67f 100644 --- a/backend/src/services/assignments.ts +++ b/backend/src/services/assignments.ts @@ -1,18 +1,43 @@ -import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; -import { mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; -import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getQuestionRepository, + getSubmissionRepository, +} from '../data/repositories.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; +import { mapToQuestionDTO } from '../interfaces/question.js'; +import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; +import { fetchClass } from './classes.js'; +import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; -import { getLogger } from '../logging/initalize.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { putObject } from './service-helper.js'; -export async function getAllAssignments(classid: string, full: boolean): Promise { +export async function fetchAssignment(classid: string, assignmentNumber: number): Promise { const classRepository = getClassRepository(); const cls = await classRepository.findById(classid); if (!cls) { - return []; + throw new NotFoundException("Could not find assignment's class"); } + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + throw new NotFoundException('Could not find assignment'); + } + + return assignment; +} + +export async function getAllAssignments(classid: string, full: boolean): Promise { + const cls = await fetchClass(classid); + const assignmentRepository = getAssignmentRepository(); const assignments = await assignmentRepository.findAllAssignmentsInClass(cls); @@ -23,42 +48,37 @@ export async function getAllAssignments(classid: string, full: boolean): Promise return assignments.map(mapToAssignmentDTOId); } -export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); - - if (!cls) { - return null; - } +export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { + const cls = await fetchClass(classid); const assignment = mapToAssignment(assignmentData, cls); + const assignmentRepository = getAssignmentRepository(); + const newAssignment = assignmentRepository.create(assignment); + await assignmentRepository.save(newAssignment, { preventOverwrite: true }); - try { - const newAssignment = assignmentRepository.create(assignment); - await assignmentRepository.save(newAssignment); - - return mapToAssignmentDTO(newAssignment); - } catch (e) { - getLogger().error(e); - return null; - } + return mapToAssignmentDTO(newAssignment); } -export async function getAssignment(classid: string, id: number): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); +export async function getAssignment(classid: string, id: number): Promise { + const assignment = await fetchAssignment(classid, id); + return mapToAssignmentDTO(assignment); +} - if (!cls) { - return null; - } +export async function putAssignment(classid: string, id: number, assignmentData: Partial>): Promise { + const assignment = await fetchAssignment(classid, id); + + await putObject(assignment, assignmentData, getAssignmentRepository()); + + return mapToAssignmentDTO(assignment); +} + +export async function deleteAssignment(classid: string, id: number): Promise { + const assignment = await fetchAssignment(classid, id); + const cls = await fetchClass(classid); const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, id); - - if (!assignment) { - return null; - } + await assignmentRepository.deleteByClassAndId(cls, id); return mapToAssignmentDTO(assignment); } @@ -68,19 +88,7 @@ export async function getAssignmentsSubmissions( assignmentNumber: number, full: boolean ): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); - - if (!cls) { - return []; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return []; - } + const assignment = await fetchAssignment(classid, assignmentNumber); const groupRepository = getGroupRepository(); const groups = await groupRepository.findAllGroupsForAssignment(assignment); @@ -94,3 +102,16 @@ export async function getAssignmentsSubmissions( return submissions.map(mapToSubmissionDTOId); } + +export async function getAssignmentsQuestions(classid: string, assignmentNumber: number, full: boolean): Promise { + const assignment = await fetchAssignment(classid, assignmentNumber); + + const questionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllByAssignment(assignment); + + if (full) { + return questions.map(mapToQuestionDTO); + } + + return questions.map(mapToQuestionDTO); +} diff --git a/backend/src/services/classes.ts b/backend/src/services/classes.ts index 754277cf..1f197b2a 100644 --- a/backend/src/services/classes.ts +++ b/backend/src/services/classes.ts @@ -1,22 +1,25 @@ -import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js'; +import { getClassRepository, getTeacherInvitationRepository } from '../data/repositories.js'; import { mapToClassDTO } from '../interfaces/class.js'; import { mapToStudentDTO } from '../interfaces/student.js'; import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds } from '../interfaces/teacher-invitation.js'; -import { getLogger } from '../logging/initalize.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; import { Class } from '../entities/classes/class.entity.js'; import { ClassDTO } from '@dwengo-1/common/interfaces/class'; import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; +import { fetchTeacher } from './teachers.js'; +import { fetchStudent } from './students.js'; +import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; +import { mapToTeacherDTO } from '../interfaces/teacher.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { putObject } from './service-helper.js'; -const logger = getLogger(); - -export async function fetchClass(classId: string): Promise { +export async function fetchClass(classid: string): Promise { const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); + const cls = await classRepository.findById(classid); if (!cls) { - throw new NotFoundException('Class with id not found'); + throw new NotFoundException('Class not found'); } return cls; @@ -24,11 +27,7 @@ export async function fetchClass(classId: string): Promise { export async function getAllClasses(full: boolean): Promise { const classRepository = getClassRepository(); - const classes = await classRepository.find({}, { populate: ['students', 'teachers'] }); - - if (!classes) { - return []; - } + const classes = await classRepository.findAll({ populate: ['students', 'teachers'] }); if (full) { return classes.map(mapToClassDTO); @@ -36,74 +35,71 @@ export async function getAllClasses(full: boolean): Promise cls.classId!); } -export async function createClass(classData: ClassDTO): Promise { - const teacherRepository = getTeacherRepository(); - const teacherUsernames = classData.teachers || []; - const teachers = (await Promise.all(teacherUsernames.map(async (id) => teacherRepository.findByUsername(id)))).filter( - (teacher) => teacher !== null - ); - - const studentRepository = getStudentRepository(); - const studentUsernames = classData.students || []; - const students = (await Promise.all(studentUsernames.map(async (id) => studentRepository.findByUsername(id)))).filter( - (student) => student !== null - ); - - const classRepository = getClassRepository(); - - try { - const newClass = classRepository.create({ - displayName: classData.displayName, - teachers: teachers, - students: students, - }); - await classRepository.save(newClass); - - return mapToClassDTO(newClass); - } catch (e) { - logger.error(e); - return null; - } +export async function getClass(classId: string): Promise { + const cls = await fetchClass(classId); + return mapToClassDTO(cls); } -export async function getClass(classId: string): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); +export async function createClass(classData: ClassDTO): Promise { + const teacherUsernames = classData.teachers || []; + const teachers = await Promise.all(teacherUsernames.map(async (id) => fetchTeacher(id))); - if (!cls) { - return null; - } + const studentUsernames = classData.students || []; + const students = await Promise.all(studentUsernames.map(async (id) => fetchStudent(id))); + + const classRepository = getClassRepository(); + const newClass = classRepository.create({ + displayName: classData.displayName, + teachers: teachers, + students: students, + }); + await classRepository.save(newClass, { preventOverwrite: true }); + + return mapToClassDTO(newClass); +} + +export async function putClass(classId: string, classData: Partial>): Promise { + const cls = await fetchClass(classId); + + await putObject(cls, classData, getClassRepository()); return mapToClassDTO(cls); } -async function fetchClassStudents(classId: string): Promise { +export async function deleteClass(classId: string): Promise { + const cls = await fetchClass(classId); + const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); + await classRepository.deleteById(classId); - if (!cls) { - return []; + return mapToClassDTO(cls); +} + +export async function getClassStudents(classId: string, full: boolean): Promise { + const cls = await fetchClass(classId); + + if (full) { + return cls.students.map(mapToStudentDTO); } + return cls.students.map((student) => student.username); +} +export async function getClassStudentsDTO(classId: string): Promise { + const cls = await fetchClass(classId); return cls.students.map(mapToStudentDTO); } -export async function getClassStudents(classId: string): Promise { - return await fetchClassStudents(classId); -} +export async function getClassTeachers(classId: string, full: boolean): Promise { + const cls = await fetchClass(classId); -export async function getClassStudentsIds(classId: string): Promise { - const students: StudentDTO[] = await fetchClassStudents(classId); - return students.map((student) => student.username); + if (full) { + return cls.teachers.map(mapToTeacherDTO); + } + return cls.teachers.map((student) => student.username); } export async function getClassTeacherInvitations(classId: string, full: boolean): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); - - if (!cls) { - return []; - } + const cls = await fetchClass(classId); const teacherInvitationRepository = getTeacherInvitationRepository(); const invitations = await teacherInvitationRepository.findAllInvitationsForClass(cls); @@ -114,3 +110,41 @@ export async function getClassTeacherInvitations(classId: string, full: boolean) return invitations.map(mapToTeacherInvitationDTOIds); } + +export async function deleteClassStudent(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + + const newStudents = { students: cls.students.filter((student) => student.username !== username) }; + await putObject(cls, newStudents, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function deleteClassTeacher(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + + const newTeachers = { teachers: cls.teachers.filter((teacher) => teacher.username !== username) }; + await putObject(cls, newTeachers, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function addClassStudent(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + const newStudent = await fetchStudent(username); + + const newStudents = { students: [...cls.students, newStudent] }; + await putObject(cls, newStudents, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function addClassTeacher(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + const newTeacher = await fetchTeacher(username); + + const newTeachers = { teachers: [...cls.teachers, newTeacher] }; + await putObject(cls, newTeachers, getClassRepository()); + + return mapToClassDTO(cls); +} diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index 346c1ee1..3c6f2919 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -1,109 +1,94 @@ -import { - getAssignmentRepository, - getClassRepository, - getGroupRepository, - getStudentRepository, - getSubmissionRepository, -} from '../data/repositories.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js'; import { Group } from '../entities/assignments/group.entity.js'; -import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToGroupDTO, mapToShallowGroupDTO } from '../interfaces/group.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; import { GroupDTO } from '@dwengo-1/common/interfaces/group'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; -import { getLogger } from '../logging/initalize.js'; +import { fetchAssignment } from './assignments.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { putObject } from './service-helper.js'; -export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number, full: boolean): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); - - if (!cls) { - return null; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return null; - } +export async function fetchGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const assignment = await fetchAssignment(classId, assignmentNumber); const groupRepository = getGroupRepository(); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); if (!group) { - return null; + throw new NotFoundException('Could not find group'); } - if (full) { - return mapToGroupDTO(group); - } - - return mapToGroupDTOId(group); + return group; } -export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { +export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + return mapToGroupDTO(group); +} + +export async function putGroup( + classId: string, + assignmentNumber: number, + groupNumber: number, + groupData: Partial> +): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + + await putObject(group, groupData, getGroupRepository()); + + return mapToGroupDTO(group); +} + +export async function deleteGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + const assignment = await fetchAssignment(classId, assignmentNumber); + + const groupRepository = getGroupRepository(); + await groupRepository.deleteByAssignmentAndGroupNumber(assignment, groupNumber); + + return mapToGroupDTO(group); +} + +export async function getExistingGroupFromGroupDTO(groupData: GroupDTO): Promise { + const classId = typeof groupData.class === 'string' ? groupData.class : groupData.class.id; + const assignmentNumber = typeof groupData.assignment === 'number' ? groupData.assignment : groupData.assignment.id; + const groupNumber = groupData.groupNumber; + + return await fetchGroup(classId, assignmentNumber, groupNumber); +} + +export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { const studentRepository = getStudentRepository(); - const memberUsernames = (groupData.members as string[]) || []; // TODO check if groupdata.members is a list + const memberUsernames = (groupData.members as string[]) || []; const members = (await Promise.all([...memberUsernames].map(async (id) => studentRepository.findByUsername(id)))).filter( (student) => student !== null ); - getLogger().debug(members); - - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); - - if (!cls) { - return null; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return null; - } + const assignment = await fetchAssignment(classid, assignmentNumber); const groupRepository = getGroupRepository(); - try { - const newGroup = groupRepository.create({ - assignment: assignment, - members: members, - }); - await groupRepository.save(newGroup); + const newGroup = groupRepository.create({ + assignment: assignment, + members: members, + }); + await groupRepository.save(newGroup); - return newGroup; - } catch (e) { - getLogger().error(e); - return null; - } + return mapToGroupDTO(newGroup); } export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); - - if (!cls) { - return []; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return []; - } + const assignment = await fetchAssignment(classId, assignmentNumber); const groupRepository = getGroupRepository(); const groups = await groupRepository.findAllGroupsForAssignment(assignment); if (full) { - getLogger().debug({ full: full, groups: groups }); return groups.map(mapToGroupDTO); } - return groups.map(mapToGroupDTOId); + return groups.map(mapToShallowGroupDTO); } export async function getGroupSubmissions( @@ -112,26 +97,7 @@ export async function getGroupSubmissions( groupNumber: number, full: boolean ): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); - - if (!cls) { - return []; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return []; - } - - const groupRepository = getGroupRepository(); - const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); - - if (!group) { - return []; - } + const group = await fetchGroup(classId, assignmentNumber, groupNumber); const submissionRepository = getSubmissionRepository(); const submissions = await submissionRepository.findAllSubmissionsForGroup(group); 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 319061c5..49bf9e92 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -1,22 +1,39 @@ -import { getAnswerRepository, 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'; import { QuestionRepository } from '../data/questions/question-repository.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -import { mapToStudent } from '../interfaces/student.js'; -import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer'; +import { mapToAssignment } from '../interfaces/assignment.js'; +import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { fetchStudent } from './students.js'; +import { NotFoundException } from '../exceptions/not-found-exception'; +import { FALLBACK_VERSION_NUM } from '../config.js'; + +export async function getQuestionsAboutLearningObjectInAssignment( + loId: LearningObjectIdentifier, + classId: string, + assignmentId: number, + full: boolean, + studentUsername?: string +): Promise { + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); + + const questions = await getQuestionRepository().findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!, studentUsername); + + if (full) { + return questions.map((q) => mapToQuestionDTO(q)); + } + return questions.map((q) => mapToQuestionDTOId(q)); +} export async function getAllQuestions(id: LearningObjectIdentifier, full: boolean): Promise { const questionRepository: QuestionRepository = getQuestionRepository(); const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); - if (!questions) { - return []; - } - if (full) { return questions.map(mapToQuestionDTO); } @@ -24,24 +41,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); } @@ -66,48 +81,43 @@ 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 = await fetchStudent(questionData.author!); + const content = questionData.content; - const author = mapToStudent(questionDTO.author); + const clazz = await getClassRepository().findById((questionData.inGroup.assignment as AssignmentDTO).within); + const assignment = mapToAssignment(questionData.inGroup.assignment as AssignmentDTO, clazz!); + const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); - const loId: LearningObjectIdentifier = { - ...questionDTO.learningObjectIdentifier, - version: questionDTO.learningObjectIdentifier.version ?? 1, - }; - - try { - await questionRepository.createQuestion({ - loId, - author, - 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, + author, + inGroup: inGroup!, + 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/services/service-helper.ts b/backend/src/services/service-helper.ts new file mode 100644 index 00000000..641fada4 --- /dev/null +++ b/backend/src/services/service-helper.ts @@ -0,0 +1,20 @@ +import { EntityDTO, FromEntityType } from '@mikro-orm/core'; +import { DwengoEntityRepository } from '../data/dwengo-entity-repository'; + +/** + * Utility function to perform an PUT on an object. + * + * @param object The object that needs to be changed + * @param data The datafields and their values that will be updated + * @param repo The repository on which this action needs to be performed + * + * @returns Nothing. + */ +export async function putObject( + object: T, + data: Partial>>, + repo: DwengoEntityRepository +): Promise { + repo.assign(object, data); + await repo.getEntityManager().flush(); +} diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index dc40e468..b1467886 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -7,7 +7,7 @@ import { getSubmissionRepository, } from '../data/repositories.js'; import { mapToClassDTO } from '../interfaces/class.js'; -import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToGroupDTO, mapToShallowGroupDTO } from '../interfaces/group.js'; import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; import { getAllAssignments } from './assignments.js'; @@ -23,6 +23,8 @@ import { GroupDTO } from '@dwengo-1/common/interfaces/group'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; +import { ConflictException } from '../exceptions/conflict-exception.js'; +import { Submission } from '../entities/assignments/submission.entity'; export async function getAllStudents(full: boolean): Promise { const studentRepository = getStudentRepository(); @@ -100,14 +102,15 @@ export async function getStudentGroups(username: string, full: boolean): Promise return groups.map(mapToGroupDTO); } - return groups.map(mapToGroupDTOId); + return groups.map(mapToShallowGroupDTO); } export async function getStudentSubmissions(username: string, full: boolean): Promise { const student = await fetchStudent(username); const submissionRepository = getSubmissionRepository(); - const submissions = await submissionRepository.findAllSubmissionsForStudent(student); + + const submissions: Submission[] = await submissionRepository.findAllSubmissionsForStudent(student); if (full) { return submissions.map(mapToSubmissionDTO); @@ -135,6 +138,10 @@ export async function createClassJoinRequest(username: string, classId: string): const student = await fetchStudent(username); // Throws error if student not found const cls = await fetchClass(classId); + if (cls.students.contains(student)) { + throw new ConflictException('Student already in this class'); + } + const request = mapToStudentRequest(student, cls); await requestRepo.save(request, { preventOverwrite: true }); return mapToStudentRequestDTO(request); diff --git a/backend/src/services/submissions.ts b/backend/src/services/submissions.ts index 1d8a7874..64028a5f 100644 --- a/backend/src/services/submissions.ts +++ b/backend/src/services/submissions.ts @@ -1,57 +1,71 @@ -import { getSubmissionRepository } from '../data/repositories.js'; +import { getAssignmentRepository, getSubmissionRepository } from '../data/repositories.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js'; import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; +import { fetchStudent } from './students.js'; +import { getExistingGroupFromGroupDTO } from './groups.js'; +import { Submission } from '../entities/assignments/submission.entity.js'; import { Language } from '@dwengo-1/common/util/language'; -export async function getSubmission( - learningObjectHruid: string, - language: Language, - version: number, - submissionNumber: number -): Promise { - const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); - +export async function fetchSubmission(loId: LearningObjectIdentifier, submissionNumber: number): Promise { const submissionRepository = getSubmissionRepository(); const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(loId, submissionNumber); if (!submission) { - return null; + throw new NotFoundException('Could not find submission'); } - return mapToSubmissionDTO(submission); -} - -export async function createSubmission(submissionDTO: SubmissionDTO): Promise { - const submissionRepository = getSubmissionRepository(); - const submission = mapToSubmission(submissionDTO); - - try { - const newSubmission = submissionRepository.create(submission); - await submissionRepository.save(newSubmission); - } catch (_) { - return null; - } - - return mapToSubmissionDTO(submission); -} - -export async function deleteSubmission( - learningObjectHruid: string, - language: Language, - version: number, - submissionNumber: number -): Promise { - const submissionRepository = getSubmissionRepository(); - - const submission = getSubmission(learningObjectHruid, language, version, submissionNumber); - - if (!submission) { - return null; - } - - const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); - await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(loId, submissionNumber); - return submission; } + +export async function getSubmission(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + const submission = await fetchSubmission(loId, submissionNumber); + return mapToSubmissionDTO(submission); +} + +export async function getAllSubmissions(loId: LearningObjectIdentifier): Promise { + const submissionRepository = getSubmissionRepository(); + const submissions = await submissionRepository.findByLearningObject(loId); + + return submissions.map(mapToSubmissionDTO); +} + +export async function createSubmission(submissionDTO: SubmissionDTO): Promise { + const submitter = await fetchStudent(submissionDTO.submitter.username); + const group = await getExistingGroupFromGroupDTO(submissionDTO.group); + + const submissionRepository = getSubmissionRepository(); + const submission = mapToSubmission(submissionDTO, submitter, group); + await submissionRepository.save(submission); + + return mapToSubmissionDTO(submission); +} + +export async function deleteSubmission(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + const submission = await fetchSubmission(loId, submissionNumber); + + const submissionRepository = getSubmissionRepository(); + await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(loId, submissionNumber); + + return mapToSubmissionDTO(submission); +} + +/** + * Returns all the submissions made by on behalf of any group the given student is in. + */ +export async function getSubmissionsForLearningObjectAndAssignment( + learningObjectHruid: string, + language: Language, + version: number, + classId: string, + assignmentId: number, + studentUsername?: string +): Promise { + const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); + const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId); + + const submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, studentUsername); + + return submissions.map((s) => mapToSubmissionDTO(s)); +} diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index 1b7643fb..e6596f9e 100644 --- a/backend/src/services/teachers.ts +++ b/backend/src/services/teachers.ts @@ -22,13 +22,14 @@ import { Question } from '../entities/questions/question.entity.js'; import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js'; import { Student } from '../entities/users/student.entity.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; -import { getClassStudents } from './classes.js'; +import { addClassStudent, fetchClass, getClassStudentsDTO } from './classes.js'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { ClassDTO } from '@dwengo-1/common/interfaces/class'; import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ConflictException } from '../exceptions/conflict-exception.js'; export async function getAllTeachers(full: boolean): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); @@ -99,10 +100,12 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro const classIds: string[] = classes.map((cls) => cls.id); - const students: StudentDTO[] = (await Promise.all(classIds.map(async (id) => getClassStudents(id)))).flat(); + const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat(); + if (full) { return students; } + return students.map((student) => student.username); } @@ -143,13 +146,12 @@ export async function getJoinRequestsByClass(classId: string): Promise { const requestRepo: ClassJoinRequestRepository = getClassJoinRequestRepository(); - const classRepo: ClassRepository = getClassRepository(); const student: Student = await fetchStudent(studentUsername); - const cls: Class | null = await classRepo.findById(classId); + const cls = await fetchClass(classId); - if (!cls) { - throw new NotFoundException('Class not found'); + if (cls.students.contains(student)) { + throw new ConflictException('Student already in this class'); } const request: ClassJoinRequest | null = await requestRepo.findByStudentAndClass(student, cls); @@ -158,8 +160,14 @@ export async function updateClassJoinRequestStatus(studentUsername: string, clas throw new NotFoundException('Join request not found'); } - request.status = accepted ? ClassJoinRequestStatus.Accepted : ClassJoinRequestStatus.Declined; + request.status = ClassJoinRequestStatus.Declined; + + if (accepted) { + request.status = ClassJoinRequestStatus.Accepted; + await addClassStudent(classId, studentUsername); + } await requestRepo.save(request); + return mapToStudentRequestDTO(request); } 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..aca29de1 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); @@ -198,29 +198,18 @@ describe('Student controllers', () => { ); }); - it('Create join request', async () => { + it('Create and delete join request', async () => { req = { - params: { username: 'Noordkaap' }, - body: { classId: 'id02' }, + params: { username: 'TheDoors' }, + body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await createStudentRequestHandler(req as Request, res as Response); expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() })); - }); - it('Create join request duplicate', async () => { req = { - params: { username: 'Tool' }, - body: { classId: 'id02' }, - }; - - await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); - }); - - it('Delete join request', async () => { - req = { - params: { username: 'Noordkaap', classId: 'id02' }, + params: { username: 'TheDoors', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, }; await deleteClassJoinRequestHandler(req as Request, res as Response); @@ -229,4 +218,22 @@ describe('Student controllers', () => { await expect(async () => deleteClassJoinRequestHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); }); + + it('Create join request student already in class error', async () => { + req = { + params: { username: 'Noordkaap' }, + body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + }; + + await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); + }); + + it('Create join request duplicate', async () => { + req = { + params: { username: 'Tool' }, + body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + }; + + await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); + }); }); diff --git a/backend/tests/controllers/teachers.test.ts b/backend/tests/controllers/teachers.test.ts index bee23987..a73a79a5 100644 --- a/backend/tests/controllers/teachers.test.ts +++ b/backend/tests/controllers/teachers.test.ts @@ -16,6 +16,7 @@ import { BadRequestException } from '../../src/exceptions/bad-request-exception. import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; +import { getClassHandler } from '../../src/controllers/classes'; describe('Teacher controllers', () => { let req: Partial; @@ -104,9 +105,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 +118,7 @@ describe('Teacher controllers', () => { it('Get teacher classes', async () => { req = { - params: { username: 'FooFighters' }, + params: { username: 'testleerkracht1' }, query: { full: 'true' }, }; @@ -132,7 +133,7 @@ describe('Teacher controllers', () => { it('Get teacher students', async () => { req = { - params: { username: 'FooFighters' }, + params: { username: 'testleerkracht1' }, query: { full: 'true' }, }; @@ -168,8 +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); @@ -183,8 +183,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', studentUsername: 'PinkFloyd' }, body: { accepted: 'true' }, }; @@ -200,5 +199,13 @@ describe('Teacher controllers', () => { const status: boolean = jsonMock.mock.lastCall?.[0].requests[0].status; expect(status).toBeTruthy(); + + req = { + params: { id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, + }; + + await getClassHandler(req as Request, res as Response); + const students: string[] = jsonMock.mock.lastCall?.[0].class.students; + expect(students).contains('PinkFloyd'); }); }); diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts index c26fb5ba..1fe52523 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(); @@ -31,6 +31,13 @@ describe('AssignmentRepository', () => { expect(assignments[0].title).toBe('tool'); }); + it('should find all by username of the responsible teacher', async () => { + const result = await assignmentRepository.findAllByResponsibleTeacher('testleerkracht1'); + const resultIds = result.map((it) => it.id).sort((a, b) => (a ?? 0) - (b ?? 0)); + + expect(resultIds).toEqual([1, 3, 4]); + }); + it('should not find removed assignment', async () => { const class_ = await classRepository.findById('id01'); await assignmentRepository.deleteByClassAndId(class_!, 3); 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 85e1bc11..31aafc1d 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -14,6 +14,9 @@ import { StudentRepository } from '../../../src/data/users/student-repository'; import { GroupRepository } from '../../../src/data/assignments/group-repository'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { Submission } from '../../../src/entities/assignments/submission.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; describe('SubmissionRepository', () => { let submissionRepository: SubmissionRepository; @@ -50,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!); @@ -59,6 +62,49 @@ describe('SubmissionRepository', () => { expect(submission?.submissionTime.getDate()).toBe(25); }); + let clazz: Class | null; + let assignment: Assignment | null; + let loId: LearningObjectIdentifier; + it('should find all submissions for a certain learning object and assignment', async () => { + clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); + assignment = await assignmentRepository.findByClassAndId(clazz!, 1); + loId = { + hruid: 'id02', + language: Language.English, + version: 1, + }; + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!); + sortSubmissions(result); + + expect(result).toHaveLength(3); + + // Submission3 should be found (for learning object 'id02' by group #1 for Assignment #1 in class 'id01') + expect(result[0].learningObjectHruid).toBe(loId.hruid); + expect(result[0].submissionNumber).toBe(1); + + // Submission4 should be found (for learning object 'id02' by group #1 for Assignment #1 in class 'id01') + expect(result[1].learningObjectHruid).toBe(loId.hruid); + expect(result[1].submissionNumber).toBe(2); + + // Submission8 should be found (for learning object 'id02' by group #2 for Assignment #1 in class 'id01') + expect(result[2].learningObjectHruid).toBe(loId.hruid); + expect(result[2].submissionNumber).toBe(3); + }); + + it("should find only the submissions for a certain learning object and assignment made for the user's group", async () => { + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!, 'Tool'); + // (student Tool is in group #2) + + expect(result).toHaveLength(1); + + // Submission8 should be found (for learning object 'id02' by group #2 for Assignment #1 in class 'id01') + expect(result[0].learningObjectHruid).toBe(loId.hruid); + expect(result[0].submissionNumber).toBe(3); + + // The other submissions found in the previous test case should not be found anymore as they were made on + // Behalf of group #1 which Tool is no member of. + }); + it('should not find a deleted submission', async () => { const id = new LearningObjectIdentifier('id01', Language.English, 1); await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); @@ -68,3 +114,15 @@ describe('SubmissionRepository', () => { expect(submission).toBeNull(); }); }); + +function sortSubmissions(submissions: Submission[]): void { + submissions.sort((a, b) => { + if (a.learningObjectHruid < b.learningObjectHruid) { + return -1; + } + if (a.learningObjectHruid > b.learningObjectHruid) { + return 1; + } + return a.submissionNumber! - b.submissionNumber!; + }); +} 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/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts index 055a9d79..9565e71d 100644 --- a/backend/tests/data/questions/questions.test.ts +++ b/backend/tests/data/questions/questions.test.ts @@ -1,10 +1,19 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { QuestionRepository } from '../../../src/data/questions/question-repository'; -import { getQuestionRepository, getStudentRepository } from '../../../src/data/repositories'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getQuestionRepository, + getStudentRepository, +} from '../../../src/data/repositories'; import { StudentRepository } from '../../../src/data/users/student-repository'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; import { Language } from '@dwengo-1/common/util/language'; +import { Question } from '../../../src/entities/questions/question.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; describe('QuestionRepository', () => { let questionRepository: QuestionRepository; @@ -21,14 +30,19 @@ describe('QuestionRepository', () => { const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); expect(questions).toBeTruthy(); - expect(questions).toHaveLength(2); + expect(questions).toHaveLength(4); }); it('should create new question', async () => { const id = new LearningObjectIdentifier('id03', Language.English, 1); const student = await studentRepository.findByUsername('Noordkaap'); + + const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); + const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 1); + const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 1); await questionRepository.createQuestion({ loId: id, + inGroup: group!, author: student!, content: 'question?', }); @@ -38,6 +52,52 @@ describe('QuestionRepository', () => { expect(question).toHaveLength(1); }); + let clazz: Class | null; + let assignment: Assignment | null; + let loId: LearningObjectIdentifier; + it('should find all questions for a certain learning object and assignment', async () => { + clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); + assignment = await getAssignmentRepository().findByClassAndId(clazz!, 1); + loId = { + hruid: 'id05', + language: Language.English, + version: 1, + }; + const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!); + sortQuestions(result); + + expect(result).toHaveLength(3); + + // Question01: About learning object 'id05', in group #1 for Assignment #1 in class 'id01' + expect(result[0].learningObjectHruid).toEqual(loId.hruid); + expect(result[0].sequenceNumber).toEqual(1); + + // Question02: About learning object 'id05', in group #1 for Assignment #1 in class 'id01' + expect(result[1].learningObjectHruid).toEqual(loId.hruid); + expect(result[1].sequenceNumber).toEqual(2); + + // Question05: About learning object 'id05', in group #2 for Assignment #1 in class 'id01' + expect(result[2].learningObjectHruid).toEqual(loId.hruid); + expect(result[2].sequenceNumber).toEqual(3); + + // Question06: About learning object 'id05', but for Assignment #2 in class 'id01' => not expected. + }); + + it("should find only the questions for a certain learning object and assignment asked by the user's group", async () => { + const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!, 'Tool'); + // (student Tool is in group #2) + + expect(result).toHaveLength(1); + + // Question01 and question02 are in group #1 => not displayed. + + // Question05: About learning object 'id05', in group #2 for Assignment #1 in class 'id01' + expect(result[0].learningObjectHruid).toEqual(loId.hruid); + expect(result[0].sequenceNumber).toEqual(3); + + // Question06: About learning object 'id05', but for Assignment #2 in class 'id01' => not expected. + }); + it('should not find removed question', async () => { const id = new LearningObjectIdentifier('id04', Language.English, 1); await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1); @@ -47,3 +107,14 @@ describe('QuestionRepository', () => { expect(question).toHaveLength(0); }); }); + +function sortQuestions(questions: Question[]): void { + questions.sort((a, b) => { + if (a.learningObjectHruid < b.learningObjectHruid) { + return -1; + } else if (a.learningObjectHruid > b.learningObjectHruid) { + return 1; + } + return a.sequenceNumber! - b.sequenceNumber!; + }); +} 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/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts index b8a733e7..0a0370a3 100644 --- a/backend/tests/services/learning-path/database-learning-path-provider.test.ts +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -3,6 +3,9 @@ import { LearningObject } from '../../../src/entities/content/learning-object.en import { setupTestApp } from '../../setup-tests.js'; import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, getLearningObjectRepository, getLearningPathRepository, getStudentRepository, @@ -22,6 +25,10 @@ import { Student } from '../../../src/entities/users/student.entity.js'; import { LearningObjectNode, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; +const STUDENT_A_USERNAME = 'student_a'; +const STUDENT_B_USERNAME = 'student_b'; +const CLASS_NAME = 'test_class'; + async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { const learningObjectRepo = getLearningObjectRepository(); const learningPathRepo = getLearningPathRepository(); @@ -38,6 +45,9 @@ async function initPersonalizationTestData(): Promise<{ studentB: Student; }> { const studentRepo = getStudentRepository(); + const classRepo = getClassRepository(); + const assignmentRepo = getAssignmentRepository(); + const groupRepo = getGroupRepository(); const submissionRepo = getSubmissionRepository(); const learningPathRepo = getLearningPathRepository(); const learningObjectRepo = getLearningObjectRepository(); @@ -47,32 +57,69 @@ async function initPersonalizationTestData(): Promise<{ await learningObjectRepo.save(learningContent.extraExerciseObject); await learningPathRepo.save(learningContent.learningPath); + // Create students const studentA = studentRepo.create({ - username: 'student_a', + username: STUDENT_A_USERNAME, firstName: 'Aron', lastName: 'Student', }); await studentRepo.save(studentA); + + const studentB = studentRepo.create({ + username: STUDENT_B_USERNAME, + firstName: 'Bill', + lastName: 'Student', + }); + await studentRepo.save(studentB); + + // Create class for students + const testClass = classRepo.create({ + classId: CLASS_NAME, + displayName: 'Test class', + }); + await classRepo.save(testClass); + + // Create assignment for students and assign them to different groups + const assignment = assignmentRepo.create({ + id: 0, + title: 'Test assignment', + description: 'Test description', + learningPathHruid: learningContent.learningPath.hruid, + learningPathLanguage: learningContent.learningPath.language, + within: testClass, + }); + + const groupA = groupRepo.create({ + groupNumber: 0, + members: [studentA], + assignment, + }); + await groupRepo.save(groupA); + + const groupB = groupRepo.create({ + groupNumber: 1, + members: [studentB], + assignment, + }); + await groupRepo.save(groupB); + + // Let each of the students make a submission in his own group. const submissionA = submissionRepo.create({ learningObjectHruid: learningContent.branchingObject.hruid, learningObjectLanguage: learningContent.branchingObject.language, learningObjectVersion: learningContent.branchingObject.version, + onBehalfOf: groupA, submitter: studentA, submissionTime: new Date(), content: '[0]', }); await submissionRepo.save(submissionA); - const studentB = studentRepo.create({ - username: 'student_b', - firstName: 'Bill', - lastName: 'Student', - }); - await studentRepo.save(studentB); const submissionB = submissionRepo.create({ learningObjectHruid: learningContent.branchingObject.hruid, learningObjectLanguage: learningContent.branchingObject.language, learningObjectVersion: learningContent.branchingObject.version, + onBehalfOf: groupA, submitter: studentB, submissionTime: new Date(), content: '[1]', diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts index 016099f3..5bd2fbd6 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -37,7 +37,7 @@ export async function setupTestApp(): Promise { learningObjects[1].attachments = attachments; - const questions = makeTestQuestions(em, students); + const questions = makeTestQuestions(em, students, groups); const answers = makeTestAnswers(em, teachers, questions); const submissions = makeTestSubmissions(em, students, groups); diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts index b0da638f..14253c0a 100644 --- a/backend/tests/test_assets/assignments/assignments.testdata.ts +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -34,5 +34,15 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign groups: [], }); - return [assignment01, assignment02, assignment03]; + const assignment04 = em.create(Assignment, { + within: classes[0], + id: 4, + title: 'another assignment', + description: 'with a description', + learningPathHruid: 'id01', + learningPathLanguage: Language.English, + groups: [], + }); + + return [assignment01, assignment02, assignment03, assignment04]; } diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts index a8ff8380..c82887bb 100644 --- a/backend/tests/test_assets/assignments/groups.testdata.ts +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -4,29 +4,55 @@ import { Assignment } from '../../../src/entities/assignments/assignment.entity' import { Student } from '../../../src/entities/users/student.entity'; export function makeTestGroups(em: EntityManager, students: Student[], assignments: Assignment[]): Group[] { + /* + * Group #1 for Assignment #1 in class 'id01' + * => Assigned to do learning path 'id02' + */ const group01 = em.create(Group, { assignment: assignments[0], groupNumber: 1, members: students.slice(0, 2), }); + /* + * Group #2 for Assignment #1 in class 'id01' + * => Assigned to do learning path 'id02' + */ const group02 = em.create(Group, { assignment: assignments[0], groupNumber: 2, members: students.slice(2, 4), }); + /* + * Group #3 for Assignment #1 in class 'id01' + * => Assigned to do learning path 'id02' + */ const group03 = em.create(Group, { assignment: assignments[0], groupNumber: 3, members: students.slice(4, 6), }); + /* + * Group #4 for Assignment #2 in class 'id02' + * => Assigned to do learning path 'id01' + */ const group04 = em.create(Group, { assignment: assignments[1], groupNumber: 4, members: students.slice(3, 4), }); - return [group01, group02, group03, group04]; + /* + * Group #5 for Assignment #4 in class 'id01' + * => Assigned to do learning path 'id01' + */ + const group05 = em.create(Group, { + assignment: assignments[3], + groupNumber: 1, + members: students.slice(0, 2), + }); + + return [group01, group02, group03, group04, group05]; } diff --git a/backend/tests/test_assets/assignments/submission.testdata.ts b/backend/tests/test_assets/assignments/submission.testdata.ts index f454b133..81db2229 100644 --- a/backend/tests/test_assets/assignments/submission.testdata.ts +++ b/backend/tests/test_assets/assignments/submission.testdata.ts @@ -12,7 +12,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 1, submitter: students[0], submissionTime: new Date(2025, 2, 20), - onBehalfOf: groups[0], + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: 'sub1', }); @@ -23,7 +23,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 2, submitter: students[0], submissionTime: new Date(2025, 2, 25), - onBehalfOf: groups[0], + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: '', }); @@ -34,6 +34,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 1, submitter: students[0], submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: '', }); @@ -44,6 +45,7 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 2, submitter: students[0], submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[0], // Group #1 for Assignment #1 in class 'id01' content: '', }); @@ -54,8 +56,42 @@ export function makeTestSubmissions(em: EntityManager, students: Student[], grou submissionNumber: 1, submitter: students[1], submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[1], // Group #2 for Assignment #1 in class 'id01' content: '', }); - return [submission01, submission02, submission03, submission04, submission05]; + const submission06 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 2, + submitter: students[1], + submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[4], // Group #5 for Assignment #4 in class 'id01' + content: '', + }); + + const submission07 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 3, + submitter: students[3], + submissionTime: new Date(2025, 3, 25), + onBehalfOf: groups[3], // Group #4 for Assignment #2 in class 'id02' + content: '', + }); + + const submission08 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 3, + submitter: students[1], + submissionTime: new Date(2025, 4, 7), + onBehalfOf: groups[1], // Group #2 for Assignment #1 in class 'id01' + content: '', + }); + + return [submission01, submission02, submission03, submission04, submission05, submission06, submission07, submission08]; } 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/questions/questions.testdata.ts b/backend/tests/test_assets/questions/questions.testdata.ts index dff742bb..10a04571 100644 --- a/backend/tests/test_assets/questions/questions.testdata.ts +++ b/backend/tests/test_assets/questions/questions.testdata.ts @@ -2,12 +2,14 @@ import { EntityManager } from '@mikro-orm/core'; import { Question } from '../../../src/entities/questions/question.entity'; import { Language } from '@dwengo-1/common/util/language'; import { Student } from '../../../src/entities/users/student.entity'; +import { Group } from '../../../src/entities/assignments/group.entity'; -export function makeTestQuestions(em: EntityManager, students: Student[]): Question[] { +export function makeTestQuestions(em: EntityManager, students: Student[], groups: Group[]): Question[] { const question01 = em.create(Question, { learningObjectLanguage: Language.English, learningObjectVersion: 1, learningObjectHruid: 'id05', + inGroup: groups[0], // Group #1 for Assignment #1 in class 'id01' sequenceNumber: 1, author: students[0], timestamp: new Date(), @@ -18,6 +20,7 @@ export function makeTestQuestions(em: EntityManager, students: Student[]): Quest learningObjectLanguage: Language.English, learningObjectVersion: 1, learningObjectHruid: 'id05', + inGroup: groups[0], // Group #1 for Assignment #1 in class 'id01' sequenceNumber: 2, author: students[2], timestamp: new Date(), @@ -30,6 +33,7 @@ export function makeTestQuestions(em: EntityManager, students: Student[]): Quest learningObjectHruid: 'id04', sequenceNumber: 1, author: students[0], + inGroup: groups[0], // Group #1 for Assignment #1 in class 'id01' timestamp: new Date(), content: 'question', }); @@ -40,9 +44,32 @@ export function makeTestQuestions(em: EntityManager, students: Student[]): Quest learningObjectHruid: 'id01', sequenceNumber: 1, author: students[1], + inGroup: groups[1], // Group #2 for Assignment #1 in class 'id01' timestamp: new Date(), content: 'question', }); - return [question01, question02, question03, question04]; + const question05 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id05', + sequenceNumber: 3, + author: students[1], + inGroup: groups[1], // Group #2 for Assignment #1 in class 'id01' + timestamp: new Date(), + content: 'question', + }); + + const question06 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id05', + sequenceNumber: 4, + author: students[2], + inGroup: groups[3], // Group #4 for Assignment #2 in class 'id02' + timestamp: new Date(), + content: 'question', + }); + + return [question01, question02, question03, question04, question05, question06]; } 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..3ded9379 --- /dev/null +++ b/backend/tool/seed.ts @@ -0,0 +1,74 @@ +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'; +import { Collection } from '@mikro-orm/core'; +import { Group } from '../dist/entities/assignments/group.entity.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 = new Collection(groups.slice(0, 3)); + assignments[1].groups = new Collection(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, groups); + 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/assignment.ts b/common/src/interfaces/assignment.ts index 8ad1649b..5cb8feff 100644 --- a/common/src/interfaces/assignment.ts +++ b/common/src/interfaces/assignment.ts @@ -2,7 +2,7 @@ import { GroupDTO } from './group'; export interface AssignmentDTO { id: number; - class: string; // Id of class 'within' + within: string; title: string; description: string; learningPath: string; diff --git a/common/src/interfaces/class.ts b/common/src/interfaces/class.ts index c35c2dfc..d71e15e6 100644 --- a/common/src/interfaces/class.ts +++ b/common/src/interfaces/class.ts @@ -3,5 +3,4 @@ export interface ClassDTO { displayName: string; teachers: string[]; students: string[]; - joinRequests: string[]; } diff --git a/common/src/interfaces/group.ts b/common/src/interfaces/group.ts index ca95770a..6baa79a5 100644 --- a/common/src/interfaces/group.ts +++ b/common/src/interfaces/group.ts @@ -1,8 +1,10 @@ import { AssignmentDTO } from './assignment'; +import { ClassDTO } from './class'; import { StudentDTO } from './student'; export interface GroupDTO { + class: string | ClassDTO; assignment: number | AssignmentDTO; groupNumber: number; - members: string[] | StudentDTO[]; + members?: string[] | StudentDTO[]; } 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 b80ff0af..172d14b7 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -1,15 +1,23 @@ -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; - timestamp?: string; + inGroup: GroupDTO; + timestamp: string; content: string; } +export interface QuestionData { + author?: string; + content: string; + inGroup: GroupDTO; +} + export interface QuestionId { - learningObjectIdentifier: LearningObjectIdentifier; + learningObjectIdentifier: LearningObjectIdentifierDTO; sequenceNumber: number; } diff --git a/common/src/interfaces/submission.ts b/common/src/interfaces/submission.ts index 6b250616..7643f0e6 100644 --- a/common/src/interfaces/submission.ts +++ b/common/src/interfaces/submission.ts @@ -1,15 +1,15 @@ 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; time?: Date; - group?: GroupDTO; + group: GroupDTO; content: string; } diff --git a/compose.production.yml b/compose.production.yml index e6f140d4..7c8ad946 100644 --- a/compose.production.yml +++ b/compose.production.yml @@ -24,7 +24,7 @@ services: restart: unless-stopped volumes: # TODO Replace with environment keys - - ./backend/.env:/app/.env + - ./backend/.env:/app/dwengo/backend/.env depends_on: - db - logging diff --git a/compose.staging.yml b/compose.staging.yml index 16814b77..253ab7d5 100644 --- a/compose.staging.yml +++ b/compose.staging.yml @@ -24,7 +24,7 @@ services: - '3000:3000/tcp' restart: unless-stopped volumes: - - ./backend/.env.staging:/app/.env + - ./backend/.env.staging:/app/dwengo/backend/.env depends_on: - db - logging diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..ca73ebc9 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +api/swagger.json diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 24d3a6cb..796369d1 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -15,6 +15,10 @@ const doc = { url: 'http://localhost:3000/', description: 'Development server', }, + { + url: 'http://localhost/', + description: 'Staging server', + }, { url: 'https://sel2-1.ugent.be/', description: 'Production server', @@ -55,4 +59,4 @@ const doc = { const outputFile = './swagger.json'; const routes = ['../../backend/src/app.ts']; -await swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); +void swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); diff --git a/docs/api/swagger.json b/docs/api/swagger.json deleted file mode 100644 index 911839d0..00000000 --- a/docs/api/swagger.json +++ /dev/null @@ -1,1964 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "version": "0.1.0", - "title": "Dwengo-1 Backend API", - "description": "Dwengo-1 Backend API using Express, based on VZW Dwengo", - "license": { - "name": "MIT", - "url": "https://github.com/SELab-2/Dwengo-1/blob/336496ab6352ee3f8bf47490c90b5cf81526cef6/LICENSE" - } - }, - "servers": [ - { - "url": "http://localhost:3000/", - "description": "Development server" - }, - { - "url": "https://sel2-1.ugent.be/", - "description": "Production server" - } - ], - "paths": { - "/api/": { - "get": { - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Student"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "example": "any" - }, - "firstName": { - "example": "any" - }, - "lastName": { - "example": "any" - } - } - } - } - } - } - }, - "delete": { - "tags": ["Student"], - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/student/{username}": { - "delete": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "username", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "username", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/student/{id}/classes": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/student/{id}/submissions": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/assignments": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/groups": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/student/{id}/questions": { - "get": { - "tags": ["Student"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/group/": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Group"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/group/{groupid}": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/group/{id}/questions": { - "get": { - "tags": ["Group"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/assignment/": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Assignment"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "example": "any" - }, - "language": { - "example": "any" - }, - "learningPath": { - "example": "any" - }, - "title": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/assignment/{id}": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/assignment/{id}/submissions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/assignment/{id}/questions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/{groupid}": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/assignment/{assignmentid}/groups/{id}/questions": { - "get": { - "tags": ["Assignment"], - "description": "", - "parameters": [ - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/submission/": { - "get": { - "tags": ["Submission"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/submission/{id}": { - "post": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "delete": { - "tags": ["Submission"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/class/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "displayName": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/class/{id}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/class/{id}/teacher-invitations": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{id}/students": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{classid}/assignments/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "description": { - "example": "any" - }, - "language": { - "example": "any" - }, - "learningPath": { - "example": "any" - }, - "title": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/class/{classid}/assignments/{id}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/class/{classid}/assignments/{id}/submissions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/class/{classid}/assignments/{id}/questions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "post": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Created" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/{groupid}": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "groupid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - } - }, - "/api/class/{classid}/assignments/{assignmentid}/groups/{id}/questions": { - "get": { - "tags": ["Class"], - "description": "", - "parameters": [ - { - "name": "classid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "assignmentid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/question/": { - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Question"], - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "learningObjectIdentifier": { - "example": "any" - }, - "author": { - "example": "any" - }, - "content": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/question/{seq}": { - "delete": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/question/answers/{seq}": { - "get": { - "tags": ["Question"], - "description": "", - "parameters": [ - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/auth/config": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/auth/testAuthenticatedOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "student": [] - }, - { - "teacher": [] - } - ] - } - }, - "/api/auth/testStudentsOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "student": [] - } - ] - } - }, - "/api/auth/testTeachersOnly": { - "get": { - "tags": ["Auth"], - "description": "", - "responses": { - "200": { - "description": "OK" - } - }, - "security": [ - { - "teacher": [] - } - ] - } - }, - "/api/theme/": { - "get": { - "tags": ["Theme"], - "description": "", - "parameters": [ - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/theme/{theme}": { - "get": { - "tags": ["Theme"], - "description": "", - "parameters": [ - { - "name": "theme", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningPath/": { - "get": { - "tags": ["Learning Path"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "search", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "forStudent", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "forGroup", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "assignmentNo", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "classId", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/html": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/html/{attachmentName}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "attachmentName", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "default": { - "description": "" - } - } - } - }, - "/api/learningObject/{hruid}/submissions/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - } - } - } - }, - "/api/learningObject/{hruid}/submissions/{id}": { - "post": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not Found" - } - } - }, - "delete": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "language", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - }, - "post": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "learningObjectIdentifier": { - "example": "any" - }, - "author": { - "example": "any" - }, - "content": { - "example": "any" - } - } - } - } - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/{seq}": { - "delete": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - } - } - }, - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - }, - "/api/learningObject/{hruid}/{version}/questions/answers/{seq}": { - "get": { - "tags": ["Learning Object"], - "description": "", - "parameters": [ - { - "name": "hruid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "seq", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "full", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "Not Found" - } - } - } - } - }, - "components": { - "securitySchemes": { - "student": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://sel2-1.ugent.be/idp/realms/student/protocol/openid-connect/auth", - "scopes": { - "openid": "openid", - "profile": "profile", - "email": "email" - } - } - } - }, - "teacher": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://sel2-1.ugent.be/idp/realms/teacher/protocol/openid-connect/auth", - "scopes": { - "openid": "openid", - "profile": "profile", - "email": "email" - } - } - } - } - } - } -} diff --git a/eslint.config.ts b/eslint.config.ts index fb19e5c4..30e8fe2f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -44,16 +44,16 @@ export default [ // All @typescript-eslint configuration options are listed. // If the rules are commented, they are configured by the inherited configurations. - '@typescript-eslint/adjacent-overload-signatures': 'warn', - '@typescript-eslint/array-type': 'warn', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/array-type': 'error', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/ban-ts-comment': ['error', { minimumDescriptionLength: 10 }], '@typescript-eslint/ban-tslint-comment': 'error', camelcase: 'off', - '@typescript-eslint/class-literal-property-style': 'warn', + '@typescript-eslint/class-literal-property-style': 'error', 'class-methods-use-this': 'off', '@typescript-eslint/class-methods-use-this': ['error', { ignoreOverrideMethods: true }], - '@typescript-eslint/consistent-generic-constructors': 'warn', + '@typescript-eslint/consistent-generic-constructors': 'error', '@typescript-eslint/consistent-indexed-object-style': 'error', 'consistent-return': 'off', '@typescript-eslint/consistent-return': 'off', @@ -64,18 +64,18 @@ export default [ 'default-param-last': 'off', '@typescript-eslint/default-param-last': 'error', 'dot-notation': 'off', - '@typescript-eslint/dot-notation': 'warn', - '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/dot-notation': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', '@typescript-eslint/explicit-member-accessibility': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'error', 'init-declarations': 'off', '@typescript-eslint/init-declarations': 'off', 'max-params': 'off', '@typescript-eslint/max-params': ['error', { max: 6 }], - '@typescript-eslint/member-ordering': 'warn', + '@typescript-eslint/member-ordering': 'error', '@typescript-eslint/method-signature-style': 'off', // Don't care about TypeScript strict mode. '@typescript-eslint/naming-convention': [ - 'warn', + 'error', { // Enforce that all variables, functions and properties are camelCase selector: 'variableLike', @@ -113,7 +113,7 @@ export default [ '@typescript-eslint/no-empty-function': 'error', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-empty-object-type': 'error', - '@typescript-eslint/no-explicit-any': 'warn', // Once in production, this should be an error. + '@typescript-eslint/no-explicit-any': 'error', // Once in production, this should be an error. '@typescript-eslint/no-extra-non-null-assertion': 'error', '@typescript-eslint/no-extraneous-class': 'error', '@typescript-eslint/no-floating-promises': 'error', @@ -121,7 +121,7 @@ export default [ 'no-implied-eval': 'off', '@typescript-eslint/no-implied-eval': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', - '@typescript-eslint/no-inferrable-types': 'warn', + '@typescript-eslint/no-inferrable-types': 'error', 'no-invalid-this': 'off', '@typescript-eslint/no-invalid-this': 'off', '@typescript-eslint/no-invalid-void-type': 'error', @@ -146,10 +146,10 @@ export default [ '@typescript-eslint/no-unsafe-function-type': 'error', 'no-unused-expressions': 'off', - '@typescript-eslint/no-unused-expressions': 'warn', + '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ - 'warn', + 'error', { args: 'all', argsIgnorePattern: '^_', @@ -164,53 +164,53 @@ export default [ '@typescript-eslint/parameter-properties': 'off', - '@typescript-eslint/prefer-find': 'warn', + '@typescript-eslint/prefer-find': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-readonly-parameter-types': 'off', '@typescript-eslint/prefer-reduce-type-parameter': 'error', - '@typescript-eslint/promise-function-async': 'warn', + '@typescript-eslint/promise-function-async': 'error', - '@typescript-eslint/require-array-sort-compare': 'warn', + '@typescript-eslint/require-array-sort-compare': 'error', - 'no-await-in-loop': 'warn', + 'no-await-in-loop': 'error', 'no-constructor-return': 'error', 'no-inner-declarations': 'error', 'no-self-compare': 'error', 'no-template-curly-in-string': 'error', - 'no-unmodified-loop-condition': 'warn', - 'no-unreachable-loop': 'warn', + 'no-unmodified-loop-condition': 'error', + 'no-unreachable-loop': 'error', 'no-useless-assignment': 'error', - 'arrow-body-style': ['warn', 'as-needed'], - 'block-scoped-var': 'warn', - 'capitalized-comments': 'warn', + 'arrow-body-style': ['error', 'as-needed'], + 'block-scoped-var': 'error', + 'capitalized-comments': 'error', 'consistent-this': 'error', curly: 'error', 'default-case': 'error', 'default-case-last': 'error', eqeqeq: 'error', - 'func-names': 'warn', - 'func-style': ['warn', 'declaration'], - 'grouped-accessor-pairs': ['warn', 'getBeforeSet'], - 'guard-for-in': 'warn', - 'logical-assignment-operators': 'warn', - 'max-classes-per-file': 'warn', + 'func-names': 'error', + 'func-style': ['error', 'declaration'], + 'grouped-accessor-pairs': ['error', 'getBeforeSet'], + 'guard-for-in': 'error', + 'logical-assignment-operators': 'error', + 'max-classes-per-file': 'error', 'no-alert': 'error', - 'no-bitwise': 'warn', - 'no-console': 'warn', - 'no-continue': 'warn', - 'no-else-return': 'warn', + 'no-bitwise': 'error', + 'no-console': 'error', + 'no-continue': 'error', + 'no-else-return': 'error', 'no-eq-null': 'error', 'no-eval': 'error', 'no-extend-native': 'error', 'no-extra-label': 'error', - 'no-implicit-coercion': 'warn', + 'no-implicit-coercion': 'error', 'no-iterator': 'error', - 'no-label-var': 'warn', - 'no-labels': 'warn', + 'no-label-var': 'error', + 'no-labels': 'error', 'no-multi-assign': 'error', 'no-nested-ternary': 'error', 'no-object-constructor': 'error', diff --git a/frontend/package.json b/frontend/package.json index 105331f0..4f0e04d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@tanstack/vue-query": "^5.69.0", "axios": "^1.8.2", "oidc-client-ts": "^3.1.0", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", 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/assignments.ts b/frontend/src/controllers/assignments.ts index 5fd5090a..a66f8e84 100644 --- a/frontend/src/controllers/assignments.ts +++ b/frontend/src/controllers/assignments.ts @@ -1,5 +1,51 @@ -import type { AssignmentDTO } from "@dwengo-1/interfaces/assignment"; +import { BaseController } from "./base-controller"; +import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; +import type { SubmissionsResponse } from "./submissions"; +import type { QuestionsResponse } from "./questions"; +import type { GroupsResponse } from "./groups"; export interface AssignmentsResponse { - assignments: AssignmentDTO[]; -} // TODO ID + assignments: AssignmentDTO[] | string[]; +} + +export interface AssignmentResponse { + assignment: AssignmentDTO; +} + +export class AssignmentController extends BaseController { + constructor(classid: string) { + super(`class/${classid}/assignments`); + } + + async getAll(full = true): Promise { + return this.get(`/`, { full }); + } + + async getByNumber(num: number): Promise { + return this.get(`/${num}`); + } + + async createAssignment(data: AssignmentDTO): Promise { + return this.post(`/`, data); + } + + async deleteAssignment(num: number): Promise { + return this.delete(`/${num}`); + } + + async updateAssignment(num: number, data: Partial): Promise { + return this.put(`/${num}`, data); + } + + async getSubmissions(assignmentNumber: number, full = true): Promise { + return this.get(`/${assignmentNumber}/submissions`, { full }); + } + + async getQuestions(assignmentNumber: number, full = true): Promise { + return this.get(`/${assignmentNumber}/questions`, { full }); + } + + async getGroups(assignmentNumber: number, full = true): Promise { + return this.get(`/${assignmentNumber}/groups`, { full }); + } +} 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/classes.ts b/frontend/src/controllers/classes.ts index d2d95ed5..03e3f560 100644 --- a/frontend/src/controllers/classes.ts +++ b/frontend/src/controllers/classes.ts @@ -1,5 +1,80 @@ -import type { ClassDTO } from "@dwengo-1/interfaces/class"; +import { BaseController } from "./base-controller"; +import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; +import type { StudentsResponse } from "./students"; +import type { AssignmentsResponse } from "./assignments"; +import type { TeacherInvitationDTO } from "@dwengo-1/common/interfaces/teacher-invitation"; +import type { TeachersResponse } from "@/controllers/teachers.ts"; export interface ClassesResponse { classes: ClassDTO[] | string[]; } + +export interface ClassResponse { + class: ClassDTO; +} + +export interface TeacherInvitationsResponse { + invites: TeacherInvitationDTO[]; +} + +export interface TeacherInvitationResponse { + invite: TeacherInvitationDTO; +} + +export class ClassController extends BaseController { + constructor() { + super("class"); + } + + async getAll(full = true): Promise { + return this.get(`/`, { full }); + } + + async getById(id: string): Promise { + return this.get(`/${id}`); + } + + async createClass(data: ClassDTO): Promise { + return this.post(`/`, data); + } + + async deleteClass(id: string): Promise { + return this.delete(`/${id}`); + } + + async updateClass(id: string, data: Partial): Promise { + return this.put(`/${id}`, data); + } + + async getStudents(id: string, full = true): Promise { + return this.get(`/${id}/students`, { full }); + } + + async addStudent(id: string, username: string): Promise { + return this.post(`/${id}/students`, { username }); + } + + async deleteStudent(id: string, username: string): Promise { + return this.delete(`/${id}/students/${username}`); + } + + async getTeachers(id: string, full = true): Promise { + return this.get(`/${id}/teachers`, { full }); + } + + async addTeacher(id: string, username: string): Promise { + return this.post(`/${id}/teachers`, { username }); + } + + async deleteTeacher(id: string, username: string): Promise { + return this.delete(`/${id}/teachers/${username}`); + } + + async getTeacherInvitations(id: string, full = true): Promise { + return this.get(`/${id}/teacher-invitations`, { full }); + } + + async getAssignments(id: string, full = true): Promise { + return this.get(`/${id}/assignments`, { full }); + } +} 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/groups.ts b/frontend/src/controllers/groups.ts index d6738e04..de6592b5 100644 --- a/frontend/src/controllers/groups.ts +++ b/frontend/src/controllers/groups.ts @@ -1,5 +1,46 @@ -import type { GroupDTO } from "@dwengo-1/interfaces/group"; +import { BaseController } from "./base-controller"; +import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; +import type { SubmissionsResponse } from "./submissions"; +import type { QuestionsResponse } from "./questions"; export interface GroupsResponse { groups: GroupDTO[]; -} // | TODO id +} + +export interface GroupResponse { + group: GroupDTO; +} + +export class GroupController extends BaseController { + constructor(classid: string, assignmentNumber: number) { + super(`class/${classid}/assignments/${assignmentNumber}/groups`); + } + + async getAll(full = true): Promise { + return this.get(`/`, { full }); + } + + async getByNumber(num: number): Promise { + return this.get(`/${num}`); + } + + async createGroup(data: GroupDTO): Promise { + return this.post(`/`, data); + } + + async deleteGroup(num: number): Promise { + return this.delete(`/${num}`); + } + + async updateGroup(num: number, data: Partial): Promise { + return this.put(`/${num}`, data); + } + + async getSubmissions(groupNumber: number, full = true): Promise { + return this.get(`/${groupNumber}/submissions`, { full }); + } + + async getQuestions(groupNumber: number, full = true): Promise { + return this.get(`/${groupNumber}/questions`, { full }); + } +} diff --git a/frontend/src/controllers/questions.ts b/frontend/src/controllers/questions.ts index 9b0182de..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/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 b36f1d5a..e9da9c74 100644 --- a/frontend/src/controllers/students.ts +++ b/frontend/src/controllers/students.ts @@ -4,8 +4,8 @@ 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 { ClassJoinRequestDTO } from "@dwengo-1/interfaces/class-join-request"; +import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; +import type { ClassJoinRequestDTO } from "@dwengo-1/common/interfaces/class-join-request"; export interface StudentsResponse { students: StudentDTO[] | string[]; @@ -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/controllers/submissions.ts b/frontend/src/controllers/submissions.ts index 99b6ba8d..837d356c 100644 --- a/frontend/src/controllers/submissions.ts +++ b/frontend/src/controllers/submissions.ts @@ -1,5 +1,32 @@ -import { type SubmissionDTO, SubmissionDTOId } from "@dwengo-1/interfaces/submission"; +import { BaseController } from "./base-controller"; +import type { SubmissionDTO, SubmissionDTOId } from "@dwengo-1/common/interfaces/submission"; export interface SubmissionsResponse { submissions: SubmissionDTO[] | SubmissionDTOId[]; } + +export interface SubmissionResponse { + submission: SubmissionDTO; +} + +export class SubmissionController extends BaseController { + constructor(classid: string, assignmentNumber: number, groupNumber: number) { + super(`class/${classid}/assignments/${assignmentNumber}/groups/${groupNumber}`); + } + + async getAll(full = true): Promise { + return this.get(`/`, { full }); + } + + async getByNumber(submissionNumber: number): Promise { + return this.get(`/${submissionNumber}`); + } + + async createSubmission(data: unknown): Promise { + return this.post(`/`, data); + } + + async deleteSubmission(submissionNumber: number): Promise { + return this.delete(`/${submissionNumber}`); + } +} diff --git a/frontend/src/controllers/teachers.ts b/frontend/src/controllers/teachers.ts index e0d38a6c..973e2b02 100644 --- a/frontend/src/controllers/teachers.ts +++ b/frontend/src/controllers/teachers.ts @@ -2,7 +2,7 @@ import { BaseController } from "@/controllers/base-controller.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; import type { QuestionsResponse } from "@/controllers/questions.ts"; import type { ClassesResponse } from "@/controllers/classes.ts"; -import type { TeacherDTO } from "@dwengo-1/interfaces/teacher"; +import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; export interface TeachersResponse { teachers: TeacherDTO[] | string[]; diff --git a/frontend/src/controllers/themes.ts b/frontend/src/controllers/themes.ts index bb76249d..ca173373 100644 --- a/frontend/src/controllers/themes.ts +++ b/frontend/src/controllers/themes.ts @@ -1,5 +1,5 @@ import { BaseController } from "@/controllers/base-controller.ts"; -import type { Theme } from "@dwengo-1/interfaces/theme"; +import type { Theme } from "@dwengo-1/common/interfaces/theme"; export class ThemeController extends BaseController { constructor() { 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 @@ - + diff --git a/package-lock.json b/package-lock.json index 27d261cb..58560862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,133 @@ "vitest": "^3.0.6" } }, + "backend/node_modules/@mikro-orm/cli": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-6.4.9.tgz", + "integrity": "sha512-LQzVsmar/0DoJkPGyz3OpB8pa9BCQtvYreEC71h0O+RcizppJjgBQNTkj5tJd2Iqvh4hSaMv6qTv0l5UK6F2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jercle/yargonaut": "1.1.5", + "@mikro-orm/core": "6.4.9", + "@mikro-orm/knex": "6.4.9", + "fs-extra": "11.3.0", + "tsconfig-paths": "4.2.0", + "yargs": "17.7.2" + }, + "bin": { + "mikro-orm": "cli", + "mikro-orm-esm": "esm" + }, + "engines": { + "node": ">= 18.12.0" + } + }, + "backend/node_modules/@mikro-orm/core": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-6.4.9.tgz", + "integrity": "sha512-osB2TbvSH4ZL1s62LCBQFAnxPqLycX5fakPHOoztudixqfbVD5QQydeGizJXMMh2zKP6vRCwIJy3MeSuFxPjHg==", + "license": "MIT", + "dependencies": { + "dataloader": "2.2.3", + "dotenv": "16.4.7", + "esprima": "4.0.1", + "fs-extra": "11.3.0", + "globby": "11.1.0", + "mikro-orm": "6.4.9", + "reflect-metadata": "0.2.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/b4nan" + } + }, + "backend/node_modules/@mikro-orm/knex": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/knex/-/knex-6.4.9.tgz", + "integrity": "sha512-iGXJfe/TziVOQsWuxMIqkOpurysWzQA6kj3+FDtOkHJAijZhqhjSBnfUVHHY/JzU9o0M0rgLrDVJFry/uEaJEA==", + "license": "MIT", + "dependencies": { + "fs-extra": "11.3.0", + "knex": "3.1.0", + "sqlstring": "2.3.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0", + "better-sqlite3": "*", + "libsql": "*", + "mariadb": "*" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "libsql": { + "optional": true + }, + "mariadb": { + "optional": true + } + } + }, + "backend/node_modules/@mikro-orm/postgresql": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/postgresql/-/postgresql-6.4.9.tgz", + "integrity": "sha512-ZdVVFAL/TSbzpEmChGdH0oUpy2KiHLjNIeItZHRQgInn1X9p0qx28VVDR78p8qgRGkQ3LquxGTkvmWI0w7qi3A==", + "license": "MIT", + "dependencies": { + "@mikro-orm/knex": "6.4.9", + "pg": "8.13.3", + "postgres-array": "3.0.4", + "postgres-date": "2.1.0", + "postgres-interval": "4.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, + "backend/node_modules/@mikro-orm/reflection": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/reflection/-/reflection-6.4.9.tgz", + "integrity": "sha512-fgY7yLrcZm3J/8dv9reUC4PQo7C2muImU31jmzz1SxmNKPJFDJl7OzcDZlM5NOisXzsWUBrcNdCyuQiWViVc3A==", + "license": "MIT", + "dependencies": { + "globby": "11.1.0", + "ts-morph": "25.0.1" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, + "backend/node_modules/@mikro-orm/sqlite": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/sqlite/-/sqlite-6.4.9.tgz", + "integrity": "sha512-O7Jy/5DrTWpJI/3qkhRJHl+OcECx1N625LHDODAAauOK3+MJB/bj80TrvQhe6d/CHZMmvxZ7m2GzaL1NulKxRw==", + "license": "MIT", + "dependencies": { + "@mikro-orm/knex": "6.4.9", + "fs-extra": "11.3.0", + "sqlite3": "5.1.7", + "sqlstring-sqlite": "0.1.1" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, "backend/node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -85,6 +212,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "backend/node_modules/mikro-orm": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-6.4.9.tgz", + "integrity": "sha512-XwVrWNT4NNwS6kHIKFNDfvy8L1eWcBBEHeTVzFFYcnb2ummATaLxqeVkNEmKA68jmdtfQdUmWBqGdbcIPwtL2Q==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + } + }, + "backend/node_modules/pg": { + "version": "8.13.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", + "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.1", + "pg-protocol": "^1.7.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "backend/node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "license": "MIT" + }, "common": { "name": "@dwengo-1/common", "version": "0.1.1" @@ -104,6 +273,7 @@ "@tanstack/vue-query": "^5.69.0", "axios": "^1.8.2", "oidc-client-ts": "^3.1.0", + "uuid": "^11.1.0", "vue": "^3.5.13", "vue-i18n": "^11.1.2", "vue-router": "^4.5.0", @@ -1816,133 +1986,6 @@ "jsep": "^0.4.0||^1.0.0" } }, - "node_modules/@mikro-orm/cli": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-6.4.9.tgz", - "integrity": "sha512-LQzVsmar/0DoJkPGyz3OpB8pa9BCQtvYreEC71h0O+RcizppJjgBQNTkj5tJd2Iqvh4hSaMv6qTv0l5UK6F2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jercle/yargonaut": "1.1.5", - "@mikro-orm/core": "6.4.9", - "@mikro-orm/knex": "6.4.9", - "fs-extra": "11.3.0", - "tsconfig-paths": "4.2.0", - "yargs": "17.7.2" - }, - "bin": { - "mikro-orm": "cli", - "mikro-orm-esm": "esm" - }, - "engines": { - "node": ">= 18.12.0" - } - }, - "node_modules/@mikro-orm/core": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-6.4.9.tgz", - "integrity": "sha512-osB2TbvSH4ZL1s62LCBQFAnxPqLycX5fakPHOoztudixqfbVD5QQydeGizJXMMh2zKP6vRCwIJy3MeSuFxPjHg==", - "license": "MIT", - "dependencies": { - "dataloader": "2.2.3", - "dotenv": "16.4.7", - "esprima": "4.0.1", - "fs-extra": "11.3.0", - "globby": "11.1.0", - "mikro-orm": "6.4.9", - "reflect-metadata": "0.2.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/b4nan" - } - }, - "node_modules/@mikro-orm/knex": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mikro-orm/knex/-/knex-6.4.9.tgz", - "integrity": "sha512-iGXJfe/TziVOQsWuxMIqkOpurysWzQA6kj3+FDtOkHJAijZhqhjSBnfUVHHY/JzU9o0M0rgLrDVJFry/uEaJEA==", - "license": "MIT", - "dependencies": { - "fs-extra": "11.3.0", - "knex": "3.1.0", - "sqlstring": "2.3.3" - }, - "engines": { - "node": ">= 18.12.0" - }, - "peerDependencies": { - "@mikro-orm/core": "^6.0.0", - "better-sqlite3": "*", - "libsql": "*", - "mariadb": "*" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "libsql": { - "optional": true - }, - "mariadb": { - "optional": true - } - } - }, - "node_modules/@mikro-orm/postgresql": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mikro-orm/postgresql/-/postgresql-6.4.9.tgz", - "integrity": "sha512-ZdVVFAL/TSbzpEmChGdH0oUpy2KiHLjNIeItZHRQgInn1X9p0qx28VVDR78p8qgRGkQ3LquxGTkvmWI0w7qi3A==", - "license": "MIT", - "dependencies": { - "@mikro-orm/knex": "6.4.9", - "pg": "8.13.3", - "postgres-array": "3.0.4", - "postgres-date": "2.1.0", - "postgres-interval": "4.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "peerDependencies": { - "@mikro-orm/core": "^6.0.0" - } - }, - "node_modules/@mikro-orm/reflection": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mikro-orm/reflection/-/reflection-6.4.9.tgz", - "integrity": "sha512-fgY7yLrcZm3J/8dv9reUC4PQo7C2muImU31jmzz1SxmNKPJFDJl7OzcDZlM5NOisXzsWUBrcNdCyuQiWViVc3A==", - "license": "MIT", - "dependencies": { - "globby": "11.1.0", - "ts-morph": "25.0.1" - }, - "engines": { - "node": ">= 18.12.0" - }, - "peerDependencies": { - "@mikro-orm/core": "^6.0.0" - } - }, - "node_modules/@mikro-orm/sqlite": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mikro-orm/sqlite/-/sqlite-6.4.9.tgz", - "integrity": "sha512-O7Jy/5DrTWpJI/3qkhRJHl+OcECx1N625LHDODAAauOK3+MJB/bj80TrvQhe6d/CHZMmvxZ7m2GzaL1NulKxRw==", - "license": "MIT", - "dependencies": { - "@mikro-orm/knex": "6.4.9", - "fs-extra": "11.3.0", - "sqlite3": "5.1.7", - "sqlstring-sqlite": "0.1.1" - }, - "engines": { - "node": ">= 18.12.0" - }, - "peerDependencies": { - "@mikro-orm/core": "^6.0.0" - } - }, "node_modules/@napi-rs/snappy-android-arm-eabi": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm-eabi/-/snappy-android-arm-eabi-7.2.2.tgz", @@ -7849,15 +7892,6 @@ "node": ">=8.6" } }, - "node_modules/mikro-orm": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-6.4.9.tgz", - "integrity": "sha512-XwVrWNT4NNwS6kHIKFNDfvy8L1eWcBBEHeTVzFFYcnb2ummATaLxqeVkNEmKA68jmdtfQdUmWBqGdbcIPwtL2Q==", - "license": "MIT", - "engines": { - "node": ">= 18.12.0" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -8649,14 +8683,15 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.13.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", - "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", + "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.7.0", - "pg-pool": "^3.7.1", - "pg-protocol": "^1.7.1", + "pg-pool": "^3.8.0", + "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -8762,7 +8797,8 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pgpass": { "version": "1.0.5", diff --git a/package.json b/package.json index abd18fcf..aba6a975 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "prebuild": "npm run clean", + "prebuild": "npm run clean && npm run swagger --workspace=docs", "build": "tsc --build tsconfig.build.json", "clean": "tsc --build tsconfig.build.json --clean", "watch": "tsc --build tsconfig.build.json --watch",