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 bb3464c3..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,7 +44,6 @@ COPY common/package.json ./common/ RUN npm install --silent --only=production -COPY ./docs ./docs COPY ./backend/i18n ./backend/i18n EXPOSE 3000 diff --git a/backend/package.json b/backend/package.json index b4ef9df2..aa1b169e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,12 +7,12 @@ "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/", "lint": "eslint . --fix", - "pretest:unit": "npm run build", + "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", "test:unit": "vitest --run" }, "dependencies": { 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/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts new file mode 100644 index 00000000..5f003f7f --- /dev/null +++ b/backend/src/controllers/teacher-invitations.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { requireFields } from './error-helper.js'; +import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; + +export async function getAllInvitationsHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const by = req.query.sent === 'true'; + requireFields({ username }); + + const invitations = await getAllInvitations(username, by); + + res.json({ invitations }); +} + +export async function getInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const invitation = await getInvitation(sender, receiver, classId); + + res.json({ invitation }); +} + +export async function createInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await createInvitation(data); + + res.json({ invitation }); +} + +export async function updateInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + req.body.accepted = req.body.accepted !== 'false'; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await updateInvitation(data); + + res.json({ invitation }); +} + +export async function deleteInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const data: TeacherInvitationData = { + sender, + receiver, + class: classId, + }; + const invitation = await deleteInvitation(data); + + res.json({ invitation }); +} 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/classes/class-join-request-repository.ts b/backend/src/data/classes/class-join-request-repository.ts index 0d9ab6e1..8bd0f81e 100644 --- a/backend/src/data/classes/class-join-request-repository.ts +++ b/backend/src/data/classes/class-join-request-repository.ts @@ -2,14 +2,14 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js'; import { Student } from '../../entities/users/student.entity.js'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export class ClassJoinRequestRepository extends DwengoEntityRepository { public async findAllRequestsBy(requester: Student): Promise { return this.findAll({ where: { requester: requester } }); } public async findAllOpenRequestsTo(clazz: Class): Promise { - return this.findAll({ where: { class: clazz, status: ClassJoinRequestStatus.Open } }); // TODO check if works like this + return this.findAll({ where: { class: clazz, status: ClassStatus.Open } }); // TODO check if works like this } public async findByStudentAndClass(requester: Student, clazz: Class): Promise { return this.findOne({ requester, class: clazz }); diff --git a/backend/src/data/classes/teacher-invitation-repository.ts b/backend/src/data/classes/teacher-invitation-repository.ts index ce059ca8..c9442e29 100644 --- a/backend/src/data/classes/teacher-invitation-repository.ts +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -2,6 +2,7 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export class TeacherInvitationRepository extends DwengoEntityRepository { public async findAllInvitationsForClass(clazz: Class): Promise { @@ -11,7 +12,7 @@ export class TeacherInvitationRepository extends DwengoEntityRepository { - return this.findAll({ where: { receiver: receiver } }); + return this.findAll({ where: { receiver: receiver, status: ClassStatus.Open } }); } public async deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { return this.deleteWhere({ @@ -20,4 +21,11 @@ export class TeacherInvitationRepository extends DwengoEntityRepository { + return this.findOne({ + sender: sender, + receiver: receiver, + class: clazz, + }); + } } 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/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index 907c0199..548968a6 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -2,7 +2,7 @@ import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; @Entity({ repository: () => ClassJoinRequestRepository, @@ -20,6 +20,6 @@ export class ClassJoinRequest { }) class!: Class; - @Enum(() => ClassJoinRequestStatus) - status!: ClassJoinRequestStatus; + @Enum(() => ClassStatus) + status!: ClassStatus; } diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index 668a0a1c..6059f155 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -1,7 +1,8 @@ -import { Entity, ManyToOne } from '@mikro-orm/core'; +import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { Class } from './class.entity.js'; import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; /** * Invitation of a teacher into a class (in order to teach it). @@ -25,4 +26,7 @@ export class TeacherInvitation { primary: true, }) class!: Class; + + @Enum(() => ClassStatus) + status!: ClassStatus; } 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..792086d4 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.js'; + +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/student-request.ts b/backend/src/interfaces/student-request.ts index d97f5eb5..a4d3b31b 100644 --- a/backend/src/interfaces/student-request.ts +++ b/backend/src/interfaces/student-request.ts @@ -4,7 +4,7 @@ import { getClassJoinRequestRepository } from '../data/repositories.js'; import { Student } from '../entities/users/student.entity.js'; import { Class } from '../entities/classes/class.entity.js'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToStudentRequestDTO(request: ClassJoinRequest): ClassJoinRequestDTO { return { @@ -18,6 +18,6 @@ export function mapToStudentRequest(student: Student, cls: Class): ClassJoinRequ return getClassJoinRequestRepository().create({ requester: student, class: cls, - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); } diff --git a/backend/src/interfaces/submission.ts b/backend/src/interfaces/submission.ts index b4ed4a2b..e179d458 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.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Group } from '../entities/assignments/group.entity.js'; 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/interfaces/teacher-invitation.ts b/backend/src/interfaces/teacher-invitation.ts index d9cb9915..88b66f7a 100644 --- a/backend/src/interfaces/teacher-invitation.ts +++ b/backend/src/interfaces/teacher-invitation.ts @@ -1,13 +1,17 @@ import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; -import { mapToClassDTO } from './class.js'; import { mapToUserDTO } from './user.js'; import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getTeacherInvitationRepository } from '../data/repositories.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO { return { sender: mapToUserDTO(invitation.sender), receiver: mapToUserDTO(invitation.receiver), - class: mapToClassDTO(invitation.class), + classId: invitation.class.classId!, + status: invitation.status, }; } @@ -15,6 +19,16 @@ export function mapToTeacherInvitationDTOIds(invitation: TeacherInvitation): Tea return { sender: invitation.sender.username, receiver: invitation.receiver.username, - class: invitation.class.classId!, + classId: invitation.class.classId!, + status: invitation.status, }; } + +export function mapToInvitation(sender: Teacher, receiver: Teacher, cls: Class): TeacherInvitation { + return getTeacherInvitationRepository().create({ + sender, + receiver, + class: cls, + status: ClassStatus.Open, + }); +} 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 1486edce..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.put('/:groupid', putGroupHandler); + +router.delete('/:groupid', deleteGroupHandler); + router.get('/:groupid/submissions', getGroupSubmissionsHandler); -// The list of questions a group has made -router.get('/:id/questions', (_req, res) => { - res.json({ - questions: ['0'], - }); -}); - 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/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts new file mode 100644 index 00000000..23b943d0 --- /dev/null +++ b/backend/src/routes/teacher-invitations.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../controllers/teacher-invitations.js'; + +const router = express.Router({ mergeParams: true }); + +router.get('/:username', getAllInvitationsHandler); + +router.get('/:sender/:receiver/:classId', getInvitationHandler); + +router.post('/', createInvitationHandler); + +router.put('/', updateInvitationHandler); + +router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); + +export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index a6106a80..801eaee8 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -10,6 +10,8 @@ import { getTeacherStudentHandler, updateStudentJoinRequestHandler, } from '../controllers/teachers.js'; +import invitationRouter from './teacher-invitations.js'; + const router = express.Router(); // Root endpoint used to search objects @@ -32,10 +34,6 @@ router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); // Invitations to other classes a teacher received -router.get('/:id/invitations', (_req, res) => { - res.json({ - invitations: ['0'], - }); -}); +router.get('/invitations', invitationRouter); export default router; diff --git a/backend/src/services/answers.ts b/backend/src/services/answers.ts new file mode 100644 index 00000000..ab603883 --- /dev/null +++ b/backend/src/services/answers.ts @@ -0,0 +1,70 @@ +import { getAnswerRepository } from '../data/repositories.js'; +import { Answer } from '../entities/questions/answer.entity.js'; +import { mapToAnswerDTO, mapToAnswerDTOId } from '../interfaces/answer.js'; +import { fetchTeacher } from './teachers.js'; +import { fetchQuestion } from './questions.js'; +import { QuestionId } from '@dwengo-1/common/interfaces/question'; +import { AnswerData, AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; + +export async function getAnswersByQuestion(questionId: QuestionId, full: boolean): Promise { + const answerRepository = getAnswerRepository(); + const question = await fetchQuestion(questionId); + + const answers: Answer[] = await answerRepository.findAllAnswersToQuestion(question); + + if (full) { + return answers.map(mapToAnswerDTO); + } + + return answers.map(mapToAnswerDTOId); +} + +export async function createAnswer(questionId: QuestionId, answerData: AnswerData): Promise { + const answerRepository = getAnswerRepository(); + const toQuestion = await fetchQuestion(questionId); + const author = await fetchTeacher(answerData.author); + const content = answerData.content; + + const answer = await answerRepository.createAnswer({ + toQuestion, + author, + content, + }); + return mapToAnswerDTO(answer); +} + +async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + const answerRepository = getAnswerRepository(); + const question = await fetchQuestion(questionId); + const answer = await answerRepository.findAnswer(question, sequenceNumber); + + if (!answer) { + throw new NotFoundException('Answer with questionID and sequence number not found'); + } + + return answer; +} + +export async function getAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + const answer = await fetchAnswer(questionId, sequenceNumber); + return mapToAnswerDTO(answer); +} + +export async function deleteAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + const answerRepository = getAnswerRepository(); + + const question = await fetchQuestion(questionId); + const answer = await fetchAnswer(questionId, sequenceNumber); + + await answerRepository.removeAnswerByQuestionAndSequenceNumber(question, sequenceNumber); + return mapToAnswerDTO(answer); +} + +export async function updateAnswer(questionId: QuestionId, sequenceNumber: number, answerData: AnswerData): Promise { + const answerRepository = getAnswerRepository(); + const answer = await fetchAnswer(questionId, sequenceNumber); + + const newAnswer = await answerRepository.updateContent(answer, answerData.content); + return mapToAnswerDTO(newAnswer); +} diff --git a/backend/src/services/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..a16c277b 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.js'; +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/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts new file mode 100644 index 00000000..aead8715 --- /dev/null +++ b/backend/src/services/teacher-invitations.ts @@ -0,0 +1,87 @@ +import { fetchTeacher } from './teachers.js'; +import { getTeacherInvitationRepository } from '../data/repositories.js'; +import { mapToInvitation, mapToTeacherInvitationDTO } from '../interfaces/teacher-invitation.js'; +import { addClassTeacher, fetchClass } from './classes.js'; +import { TeacherInvitationData, TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { ConflictException } from '../exceptions/conflict-exception.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +export async function getAllInvitations(username: string, sent: boolean): Promise { + const teacher = await fetchTeacher(username); + const teacherInvitationRepository = getTeacherInvitationRepository(); + + let invitations; + if (sent) { + invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher); + } else { + invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher); + } + return invitations.map(mapToTeacherInvitationDTO); +} + +export async function createInvitation(data: TeacherInvitationData): Promise { + const teacherInvitationRepository = getTeacherInvitationRepository(); + const sender = await fetchTeacher(data.sender); + const receiver = await fetchTeacher(data.receiver); + + const cls = await fetchClass(data.class); + + if (!cls.teachers.contains(sender)) { + throw new ConflictException('The teacher sending the invite is not part of the class'); + } + + const newInvitation = mapToInvitation(sender, receiver, cls); + await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); + + return mapToTeacherInvitationDTO(newInvitation); +} + +async function fetchInvitation(usernameSender: string, usernameReceiver: string, classId: string): Promise { + const sender = await fetchTeacher(usernameSender); + const receiver = await fetchTeacher(usernameReceiver); + const cls = await fetchClass(classId); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + const invite = await teacherInvitationRepository.findBy(cls, sender, receiver); + + if (!invite) { + throw new NotFoundException('Teacher invite not found'); + } + + return invite; +} + +export async function getInvitation(sender: string, receiver: string, classId: string): Promise { + const invitation = await fetchInvitation(sender, receiver, classId); + return mapToTeacherInvitationDTO(invitation); +} + +export async function updateInvitation(data: TeacherInvitationData): Promise { + const invitation = await fetchInvitation(data.sender, data.receiver, data.class); + invitation.status = ClassStatus.Declined; + + if (data.accepted) { + invitation.status = ClassStatus.Accepted; + await addClassTeacher(data.class, data.receiver); + } + + const teacherInvitationRepository = getTeacherInvitationRepository(); + await teacherInvitationRepository.save(invitation); + + return mapToTeacherInvitationDTO(invitation); +} + +export async function deleteInvitation(data: TeacherInvitationData): Promise { + const invitation = await fetchInvitation(data.sender, data.receiver, data.class); + + const sender = await fetchTeacher(data.sender); + const receiver = await fetchTeacher(data.receiver); + const cls = await fetchClass(data.class); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + await teacherInvitationRepository.deleteBy(cls, sender, receiver); + + return mapToTeacherInvitationDTO(invitation); +} diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts index 1b7643fb..982b657b 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 { ClassStatus } 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 = ClassStatus.Declined; + + if (accepted) { + request.status = ClassStatus.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/teacher-invitations.test.ts b/backend/tests/controllers/teacher-invitations.test.ts new file mode 100644 index 00000000..ed2f5ebf --- /dev/null +++ b/backend/tests/controllers/teacher-invitations.test.ts @@ -0,0 +1,123 @@ +import { beforeAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { Request, Response } from 'express'; +import { setupTestApp } from '../setup-tests.js'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../../src/controllers/teacher-invitations'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getClassHandler } from '../../src/controllers/classes'; +import { BadRequestException } from '../../src/exceptions/bad-request-exception'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +describe('Teacher controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(() => { + jsonMock = vi.fn(); + res = { + json: jsonMock, + }; + }); + + it('Get teacher invitations by', async () => { + req = { params: { username: 'LimpBizkit' }, query: { sent: 'true' } }; + + await getAllInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + + const result = jsonMock.mock.lastCall?.[0]; + // Console.log(result.invitations); + expect(result.invitations).to.have.length.greaterThan(0); + }); + + it('Get teacher invitations for', async () => { + req = { params: { username: 'FooFighters' }, query: { by: 'false' } }; + + await getAllInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + + const result = jsonMock.mock.lastCall?.[0]; + expect(result.invitations).to.have.length.greaterThan(0); + }); + + it('Create and delete invitation', async () => { + const body = { + sender: 'LimpBizkit', + receiver: 'testleerkracht1', + class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + } as TeacherInvitationData; + req = { body }; + + await createInvitationHandler(req as Request, res as Response); + + req = { + params: { + sender: 'LimpBizkit', + receiver: 'testleerkracht1', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + body: { accepted: 'false' }, + }; + + await deleteInvitationHandler(req as Request, res as Response); + }); + + it('Get invitation', async () => { + req = { + params: { + sender: 'LimpBizkit', + receiver: 'FooFighters', + classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + }; + await getInvitationHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitation: expect.anything() })); + }); + + it('Get invitation error', async () => { + req = { + params: { no: 'no params' }, + }; + + await expect(async () => getInvitationHandler(req as Request, res as Response)).rejects.toThrowError(BadRequestException); + }); + + it('Accept invitation', async () => { + const body = { + sender: 'LimpBizkit', + receiver: 'FooFighters', + class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + } as TeacherInvitationData; + req = { body }; + + await updateInvitationHandler(req as Request, res as Response); + + const result1 = jsonMock.mock.lastCall?.[0]; + expect(result1.invitation.status).toEqual(ClassStatus.Accepted); + + req = { + params: { + id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + }, + }; + + await getClassHandler(req as Request, res as Response); + + const result = jsonMock.mock.lastCall?.[0]; + expect(result.class.teachers).toContain('FooFighters'); + }); +}); 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..699e081b 100644 --- a/backend/tests/setup-tests.ts +++ b/backend/tests/setup-tests.ts @@ -13,6 +13,7 @@ import { makeTestAttachments } from './test_assets/content/attachments.testdata. import { makeTestQuestions } from './test_assets/questions/questions.testdata.js'; import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; +import { Collection } from '@mikro-orm/core'; export async function setupTestApp(): Promise { dotenv.config({ path: '.env.test' }); @@ -28,8 +29,8 @@ export async function setupTestApp(): Promise { const assignments = makeTestAssignemnts(em, classes); const groups = makeTestGroups(em, students, assignments); - assignments[0].groups = groups.slice(0, 3); - assignments[1].groups = groups.slice(3, 4); + 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); @@ -37,7 +38,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/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts index 32337b19..63319dc4 100644 --- a/backend/tests/test_assets/classes/class-join-requests.testdata.ts +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -2,31 +2,31 @@ import { EntityManager } from '@mikro-orm/core'; import { ClassJoinRequest } from '../../../src/entities/classes/class-join-request.entity'; import { Student } from '../../../src/entities/users/student.entity'; import { Class } from '../../../src/entities/classes/class.entity'; -import { ClassJoinRequestStatus } from '@dwengo-1/common/util/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function makeTestClassJoinRequests(em: EntityManager, students: Student[], classes: Class[]): ClassJoinRequest[] { const classJoinRequest01 = em.create(ClassJoinRequest, { requester: students[4], class: classes[1], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest02 = em.create(ClassJoinRequest, { requester: students[2], class: classes[1], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest03 = em.create(ClassJoinRequest, { requester: students[4], class: classes[2], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); const classJoinRequest04 = em.create(ClassJoinRequest, { requester: students[3], class: classes[2], - status: ClassJoinRequestStatus.Open, + status: ClassStatus.Open, }); return [classJoinRequest01, classJoinRequest02, classJoinRequest03, classJoinRequest04]; 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/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts index 68204a57..6337a513 100644 --- a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -2,30 +2,35 @@ import { EntityManager } from '@mikro-orm/core'; import { TeacherInvitation } from '../../../src/entities/classes/teacher-invitation.entity'; import { Teacher } from '../../../src/entities/users/teacher.entity'; import { Class } from '../../../src/entities/classes/class.entity'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function makeTestTeacherInvitations(em: EntityManager, teachers: Teacher[], classes: Class[]): TeacherInvitation[] { const teacherInvitation01 = em.create(TeacherInvitation, { sender: teachers[1], receiver: teachers[0], class: classes[1], + status: ClassStatus.Open, }); const teacherInvitation02 = em.create(TeacherInvitation, { sender: teachers[1], receiver: teachers[2], class: classes[1], + status: ClassStatus.Open, }); const teacherInvitation03 = em.create(TeacherInvitation, { sender: teachers[2], receiver: teachers[0], class: classes[2], + status: ClassStatus.Open, }); const teacherInvitation04 = em.create(TeacherInvitation, { sender: teachers[0], receiver: teachers[1], class: classes[0], + status: ClassStatus.Open, }); return [teacherInvitation01, teacherInvitation02, teacherInvitation03, teacherInvitation04]; 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 e756ed3b..9ff4fdcb 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-join-request.ts b/common/src/interfaces/class-join-request.ts index 6787998b..5e8b2683 100644 --- a/common/src/interfaces/class-join-request.ts +++ b/common/src/interfaces/class-join-request.ts @@ -1,8 +1,8 @@ import { StudentDTO } from './student'; -import { ClassJoinRequestStatus } from '../util/class-join-request'; +import { ClassStatus } from '../util/class-join-request'; export interface ClassJoinRequestDTO { requester: StudentDTO; class: string; - status: ClassJoinRequestStatus; + status: ClassStatus; } 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/common/src/interfaces/teacher-invitation.ts b/common/src/interfaces/teacher-invitation.ts index 13709322..f878b741 100644 --- a/common/src/interfaces/teacher-invitation.ts +++ b/common/src/interfaces/teacher-invitation.ts @@ -1,8 +1,16 @@ import { UserDTO } from './user'; -import { ClassDTO } from './class'; +import { ClassStatus } from '../util/class-join-request'; export interface TeacherInvitationDTO { sender: string | UserDTO; receiver: string | UserDTO; - class: string | ClassDTO; + classId: string; + status: ClassStatus; +} + +export interface TeacherInvitationData { + sender: string; + receiver: string; + class: string; + accepted?: boolean; // Use for put requests, else skip } diff --git a/common/src/util/class-join-request.ts b/common/src/util/class-join-request.ts index 5f9410f0..2049a16d 100644 --- a/common/src/util/class-join-request.ts +++ b/common/src/util/class-join-request.ts @@ -1,4 +1,4 @@ -export enum ClassJoinRequestStatus { +export enum ClassStatus { Open = 'open', Accepted = 'accepted', Declined = 'declined', 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/eslint.config.ts b/frontend/eslint.config.ts index 9ca1a08b..b7f15371 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -21,7 +21,14 @@ const vueConfig = defineConfigWithVueTs( { name: "app/files-to-ignore", - ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**", "prettier.config.js"], + ignores: [ + "**/dist/**", + "**/dist-ssr/**", + "**/coverage/**", + "prettier.config.js", + "**/test-results/**", + "**/playwright-report/**", + ], }, pluginVue.configs["flat/essential"], diff --git a/frontend/package.json b/frontend/package.json index cc45c7cf..4be35d40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@vueuse/core": "^13.1.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/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 15e9ba82..11541436 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -80,13 +80,14 @@ > {{ t("classes") }} - - {{ t("discussions") }} - + + + + + + + + + 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..a3fba26e 100644 --- a/frontend/src/views/classes/TeacherClasses.vue +++ b/frontend/src/views/classes/TeacherClasses.vue @@ -1,7 +1,405 @@ - + +
+
+ +

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.classId }} + + + {{ (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 00bfb8e1..ae8059ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,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", @@ -86,6 +213,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" @@ -106,6 +275,7 @@ "@vueuse/core": "^13.1.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", @@ -1818,133 +1988,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", @@ -7895,15 +7938,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", @@ -8695,14 +8729,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" }, @@ -8808,7 +8843,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 64cfd665..3d9be4d0 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",