Merge dev into feat/progress-bar
This commit is contained in:
		
						commit
						43317c5dee
					
				
					 120 changed files with 4409 additions and 1520 deletions
				
			
		|  | @ -8,6 +8,7 @@ | |||
| ### Dwengo ### | ||||
| 
 | ||||
| DWENGO_PORT=3000 | ||||
| DWENGO_RUN_MODE=test | ||||
| 
 | ||||
| DWENGO_DB_NAME=":memory:" | ||||
| DWENGO_DB_UPDATE=true | ||||
|  |  | |||
|  | @ -29,6 +29,7 @@ | |||
|         "cross-env": "^7.0.3", | ||||
|         "dotenv": "^16.4.7", | ||||
|         "express": "^5.0.1", | ||||
|         "express-fileupload": "^1.5.1", | ||||
|         "express-jwt": "^8.5.1", | ||||
|         "gift-pegjs": "^1.0.2", | ||||
|         "isomorphic-dompurify": "^2.22.0", | ||||
|  | @ -37,9 +38,11 @@ | |||
|         "jwks-rsa": "^3.1.0", | ||||
|         "loki-logger-ts": "^1.0.2", | ||||
|         "marked": "^15.0.7", | ||||
|         "mime-types": "^3.0.1", | ||||
|         "nanoid": "^5.1.5", | ||||
|         "response-time": "^2.3.3", | ||||
|         "swagger-ui-express": "^5.0.1", | ||||
|         "unzipper": "^0.12.3", | ||||
|         "uuid": "^11.1.0", | ||||
|         "winston": "^3.17.0", | ||||
|         "winston-loki": "^6.1.3" | ||||
|  | @ -48,10 +51,13 @@ | |||
|         "@mikro-orm/cli": "6.4.12", | ||||
|         "@types/cors": "^2.8.17", | ||||
|         "@types/express": "^5.0.0", | ||||
|         "@types/express-fileupload": "^1.5.1", | ||||
|         "@types/js-yaml": "^4.0.9", | ||||
|         "@types/mime-types": "^2.1.4", | ||||
|         "@types/node": "^22.13.4", | ||||
|         "@types/response-time": "^2.3.8", | ||||
|         "@types/swagger-ui-express": "^4.1.8", | ||||
|         "@types/unzipper": "^0.10.11", | ||||
|         "globals": "^15.15.0", | ||||
|         "ts-node": "^10.9.2", | ||||
|         "tsx": "^4.19.3", | ||||
|  |  | |||
|  | @ -1,10 +1,11 @@ | |||
| import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; | ||||
| import { getLogger } from '../logging/initalize.js'; | ||||
| import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; | ||||
| import { createOrUpdateStudent } from '../services/students.js'; | ||||
| import { createOrUpdateTeacher } from '../services/teachers.js'; | ||||
| import { envVars, getEnvVar } from '../util/envVars.js'; | ||||
| import { Response } from 'express'; | ||||
| import { createOrUpdateStudent } from '../services/students.js'; | ||||
| import { Request, Response } from 'express'; | ||||
| import { createOrUpdateTeacher } from '../services/teachers.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| interface FrontendIdpConfig { | ||||
|     authority: string; | ||||
|  | @ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig { | |||
|     }; | ||||
| } | ||||
| 
 | ||||
| export function handleGetFrontendAuthConfig(_req: Request, res: Response): void { | ||||
|     res.json(getFrontendAuthConfig()); | ||||
| } | ||||
| 
 | ||||
| export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||
|     const auth = req.auth; | ||||
|     if (!auth) { | ||||
|  | @ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response) | |||
|         firstName: auth.firstName ?? '', | ||||
|         lastName: auth.lastName ?? '', | ||||
|     }; | ||||
|     if (auth.accountType === 'student') { | ||||
|     if (auth.accountType === AccountType.Student) { | ||||
|         await createOrUpdateStudent(userData); | ||||
|         logger.debug(`Synchronized student ${userData.username} with IDP`); | ||||
|     } else { | ||||
|  |  | |||
|  | @ -7,6 +7,9 @@ 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, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { UploadedFile } from 'express-fileupload'; | ||||
| import { AuthenticatedRequest } from '../middleware/auth/authenticated-request'; | ||||
| import { requireFields } from './error-helper.js'; | ||||
| 
 | ||||
| function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { | ||||
|     if (!req.params.hruid) { | ||||
|  | @ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde | |||
| } | ||||
| 
 | ||||
| function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { | ||||
|     if (!req.query.hruid) { | ||||
|         throw new BadRequestException('HRUID is required.'); | ||||
|     } | ||||
|     const { hruid, language } = req.params; | ||||
|     requireFields({ hruid }); | ||||
| 
 | ||||
|     return { | ||||
|         hruid: req.params.hruid, | ||||
|         language: (req.query.language as Language) || FALLBACK_LANG, | ||||
|         hruid, | ||||
|         language: (language as Language) || FALLBACK_LANG, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { | ||||
|     const learningPathId = getLearningPathIdentifierFromRequest(req); | ||||
|     const full = req.query.full; | ||||
|     if (req.query.admin) { | ||||
|         // If the admin query parameter is present, the user wants to have all learning objects with this admin.
 | ||||
|         const learningObjects = await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string); | ||||
| 
 | ||||
|     let learningObjects: FilteredLearningObject[] | string[]; | ||||
|     if (full) { | ||||
|         learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); | ||||
|         res.json(learningObjects); | ||||
|     } else { | ||||
|         learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); | ||||
|     } | ||||
|         // Else he/she wants all learning objects on the path specified by the request parameters.
 | ||||
|         const learningPathId = getLearningPathIdentifierFromRequest(req); | ||||
|         const full = req.query.full; | ||||
| 
 | ||||
|     res.json({ learningObjects: learningObjects }); | ||||
|         let learningObjects: FilteredLearningObject[] | string[]; | ||||
|         if (full) { | ||||
|             learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); | ||||
|         } else { | ||||
|             learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); | ||||
|         } | ||||
| 
 | ||||
|         res.json({ learningObjects: learningObjects }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export async function getLearningObject(req: Request, res: Response): Promise<void> { | ||||
|  | @ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise<void> | |||
|     } | ||||
|     res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); | ||||
| } | ||||
| 
 | ||||
| export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||
|     if (!req.files || !req.files.learningObject) { | ||||
|         throw new BadRequestException('No file uploaded'); | ||||
|     } | ||||
|     const learningObject = await learningObjectService.storeLearningObject((req.files.learningObject as UploadedFile).tempFilePath, [ | ||||
|         req.auth!.username, | ||||
|     ]); | ||||
|     res.json(learningObject); | ||||
| } | ||||
| 
 | ||||
| export async function handleDeleteLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||
|     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||
| 
 | ||||
|     if (!learningObjectId.version) { | ||||
|         throw new BadRequestException('When deleting a learning object, a version must be specified.'); | ||||
|     } | ||||
| 
 | ||||
|     const deletedLearningObject = await learningObjectService.deleteLearningObject({ | ||||
|         hruid: learningObjectId.hruid, | ||||
|         version: learningObjectId.version, | ||||
|         language: learningObjectId.language, | ||||
|     }); | ||||
|     if (deletedLearningObject) { | ||||
|         res.json(deletedLearningObject); | ||||
|     } else { | ||||
|         throw new NotFoundException('Learning object not found'); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -7,51 +7,103 @@ import { BadRequestException } from '../exceptions/bad-request-exception.js'; | |||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||
| import { Group } from '../entities/assignments/group.entity.js'; | ||||
| import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; | ||||
| import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; | ||||
| import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { getTeacher } from '../services/teachers.js'; | ||||
| import { requireFields } from './error-helper.js'; | ||||
| 
 | ||||
| /** | ||||
|  * Fetch learning paths based on query parameters. | ||||
|  */ | ||||
| export async function getLearningPaths(req: Request, res: Response): Promise<void> { | ||||
|     const hruids = req.query.hruid; | ||||
|     const themeKey = req.query.theme as string; | ||||
|     const searchQuery = req.query.search as string; | ||||
|     const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|     const forGroupNo = req.query.forGroup as string; | ||||
|     const assignmentNo = req.query.assignmentNo as string; | ||||
|     const classId = req.query.classId as string; | ||||
| 
 | ||||
|     let forGroup: Group | undefined; | ||||
| 
 | ||||
|     if (forGroupNo) { | ||||
|         if (!assignmentNo || !classId) { | ||||
|             throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); | ||||
|         } | ||||
|         const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); | ||||
|         if (assignment) { | ||||
|             forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let hruidList; | ||||
| 
 | ||||
|     if (hruids) { | ||||
|         hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; | ||||
|     } else if (themeKey) { | ||||
|         const theme = themes.find((t) => t.title === themeKey); | ||||
|         if (theme) { | ||||
|             hruidList = theme.hruids; | ||||
|         } else { | ||||
|             throw new NotFoundException(`Theme "${themeKey}" not found.`); | ||||
|         } | ||||
|     } else if (searchQuery) { | ||||
|         const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); | ||||
|         res.json(searchResults); | ||||
|         return; | ||||
|     const admin = req.query.admin; | ||||
|     if (admin) { | ||||
|         const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string); | ||||
|         res.json(paths); | ||||
|     } else { | ||||
|         hruidList = themes.flatMap((theme) => theme.hruids); | ||||
|     } | ||||
|         const hruids = req.query.hruid; | ||||
|         const themeKey = req.query.theme as string; | ||||
|         const searchQuery = req.query.search as string; | ||||
|         const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|     const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); | ||||
|     res.json(learningPaths.data); | ||||
|         const forGroupNo = req.query.forGroup as string; | ||||
|         const assignmentNo = req.query.assignmentNo as string; | ||||
|         const classId = req.query.classId as string; | ||||
| 
 | ||||
|         let forGroup: Group | undefined; | ||||
| 
 | ||||
|         if (forGroupNo) { | ||||
|             if (!assignmentNo || !classId) { | ||||
|                 throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); | ||||
|             } | ||||
|             const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo)); | ||||
|             if (assignment) { | ||||
|                 forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         let hruidList; | ||||
| 
 | ||||
|         if (hruids) { | ||||
|             hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; | ||||
|         } else if (themeKey) { | ||||
|             const theme = themes.find((t) => t.title === themeKey); | ||||
|             if (theme) { | ||||
|                 hruidList = theme.hruids; | ||||
|             } else { | ||||
|                 throw new NotFoundException(`Theme "${themeKey}" not found.`); | ||||
|             } | ||||
|         } else if (searchQuery) { | ||||
|             const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup); | ||||
|             res.json(searchResults); | ||||
|             return; | ||||
|         } else { | ||||
|             hruidList = themes.flatMap((theme) => theme.hruids); | ||||
|         } | ||||
| 
 | ||||
|         const learningPaths = await learningPathService.fetchLearningPaths( | ||||
|             hruidList, | ||||
|             language as Language, | ||||
|             `HRUIDs: ${hruidList.join(', ')}`, | ||||
|             forGroup | ||||
|         ); | ||||
|         res.json(learningPaths.data); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise<void> { | ||||
|     return async (req, res) => { | ||||
|         const path = req.body as LearningPath; | ||||
|         const { hruid: hruidParam, language: languageParam } = req.params; | ||||
| 
 | ||||
|         if (isPut) { | ||||
|             requireFields({ hruidParam, languageParam, path }); | ||||
|         } | ||||
| 
 | ||||
|         const teacher = await getTeacher(req.auth!.username); | ||||
|         if (isPut) { | ||||
|             if (req.params.hruid !== path.hruid || req.params.language !== path.language) { | ||||
|                 throw new BadRequestException('id_not_matching_query_params'); | ||||
|             } | ||||
|             await learningPathService.deleteLearningPath({ hruid: path.hruid, language: path.language as Language }); | ||||
|         } | ||||
|         res.json(await learningPathService.createNewLearningPath(path, [teacher])); | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export const postLearningPath = postOrPutLearningPath(false); | ||||
| export const putLearningPath = postOrPutLearningPath(true); | ||||
| 
 | ||||
| export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise<void> { | ||||
|     const { hruid, language } = req.params; | ||||
| 
 | ||||
|     requireFields({ hruid, language }); | ||||
| 
 | ||||
|     const id: LearningPathIdentifier = { hruid, language: language as Language }; | ||||
|     const deletedPath = await learningPathService.deleteLearningPath(id); | ||||
|     if (deletedPath) { | ||||
|         res.json(deletedPath); | ||||
|     } else { | ||||
|         throw new NotFoundException('The learning path could not be found.'); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response): | |||
|     const classId = req.body.classId; | ||||
|     requireFields({ username, classId }); | ||||
| 
 | ||||
|     const request = await createClassJoinRequest(username, classId); | ||||
|     const request = await createClassJoinRequest(username, classId.toUpperCase()); | ||||
|     res.json({ request }); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ 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'; | ||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||
| 
 | ||||
| export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> { | ||||
|     const username = req.params.username; | ||||
|  | @ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom | |||
|     const classId = req.body.class; | ||||
|     requireFields({ sender, receiver, classId }); | ||||
| 
 | ||||
|     if (sender === receiver) { | ||||
|         throw new ConflictException('Cannot send an invitation to yourself'); | ||||
|     } | ||||
| 
 | ||||
|     const data = req.body as TeacherInvitationData; | ||||
|     const invitation = await createInvitation(data); | ||||
| 
 | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj | |||
|                 version: identifier.version, | ||||
|             }, | ||||
|             { | ||||
|                 populate: ['keywords'], | ||||
|                 populate: ['keywords', 'admins'], | ||||
|             } | ||||
|         ); | ||||
|     } | ||||
|  | @ -31,4 +31,23 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj | |||
|             } | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public async findAllByAdmin(adminUsername: string): Promise<LearningObject[]> { | ||||
|         return this.find( | ||||
|             { | ||||
|                 admins: { | ||||
|                     username: adminUsername, | ||||
|                 }, | ||||
|             }, | ||||
|             { populate: ['admins'] } // Make sure to load admin relations
 | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||
|         const learningObject = await this.findByIdentifier(identifier); | ||||
|         if (learningObject) { | ||||
|             await this.em.removeAndFlush(learningObject); | ||||
|         } | ||||
|         return learningObject; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -4,11 +4,10 @@ import { Language } from '@dwengo-1/common/util/language'; | |||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||
| import { RequiredEntityData } from '@mikro-orm/core'; | ||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||
| import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; | ||||
| 
 | ||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||
|     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); | ||||
|         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions', 'admins'] }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -24,7 +23,21 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath> | |||
|                 language: language, | ||||
|                 $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], | ||||
|             }, | ||||
|             populate: ['nodes', 'nodes.transitions'], | ||||
|             populate: ['nodes', 'nodes.transitions', 'admins'], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns all learning paths which have the user with the given username as an administrator. | ||||
|      */ | ||||
|     public async findAllByAdminUsername(adminUsername: string): Promise<LearningPath[]> { | ||||
|         return this.findAll({ | ||||
|             where: { | ||||
|                 admins: { | ||||
|                     username: adminUsername, | ||||
|                 }, | ||||
|             }, | ||||
|             populate: ['nodes', 'nodes.transitions', 'admins'], | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|  | @ -36,18 +49,15 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath> | |||
|         return this.em.create(LearningPathTransition, transitionData); | ||||
|     } | ||||
| 
 | ||||
|     public async saveLearningPathNodesAndTransitions( | ||||
|         path: LearningPath, | ||||
|         nodes: LearningPathNode[], | ||||
|         transitions: LearningPathTransition[], | ||||
|         options?: { preventOverwrite?: boolean } | ||||
|     ): Promise<void> { | ||||
|         if (options?.preventOverwrite && (await this.findOne(path))) { | ||||
|             throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); | ||||
|     /** | ||||
|      * Deletes the learning path with the given hruid and language. | ||||
|      * @returns the deleted learning path or null if it was not found. | ||||
|      */ | ||||
|     public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|         const path = await this.findByHruidAndLanguage(hruid, language); | ||||
|         if (path) { | ||||
|             await this.em.removeAndFlush(path); | ||||
|         } | ||||
|         const em = this.getEntityManager(); | ||||
|         await em.persistAndFlush(path); | ||||
|         await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); | ||||
|         await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); | ||||
|         return path; | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -3,9 +3,9 @@ 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 { Group } from '../../entities/assignments/group.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<Question> { | ||||
|     public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> { | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ export class Attachment { | |||
|     @ManyToOne({ | ||||
|         entity: () => LearningObject, | ||||
|         primary: true, | ||||
|         deleteRule: 'cascade', | ||||
|     }) | ||||
|     learningObject!: LearningObject; | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { ArrayType, Collection, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Attachment } from './attachment.entity.js'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | ||||
|  | @ -28,7 +28,7 @@ export class LearningObject { | |||
|     @ManyToMany({ | ||||
|         entity: () => Teacher, | ||||
|     }) | ||||
|     admins!: Teacher[]; | ||||
|     admins: Collection<Teacher> = new Collection<Teacher>(this); | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
|  | @ -84,7 +84,7 @@ export class LearningObject { | |||
|         entity: () => Attachment, | ||||
|         mappedBy: 'learningObject', | ||||
|     }) | ||||
|     attachments: Attachment[] = []; | ||||
|     attachments: Collection<Attachment> = new Collection<Attachment>(this); | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     content!: Buffer; | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | ||||
| import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | ||||
| import { LearningPath } from './learning-path.entity.js'; | ||||
| import { LearningPathTransition } from './learning-path-transition.entity.js'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
|  | @ -26,7 +26,7 @@ export class LearningPathNode { | |||
|     @Property({ type: 'bool' }) | ||||
|     startNode!: boolean; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) | ||||
|     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] }) | ||||
|     transitions!: Collection<LearningPathTransition>; | ||||
| 
 | ||||
|     @Property({ length: 3 }) | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Cascade, Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | ||||
| import { LearningPathNode } from './learning-path-node.entity.js'; | ||||
|  | @ -24,6 +24,6 @@ export class LearningPath { | |||
|     @Property({ type: 'blob', nullable: true }) | ||||
|     image: Buffer | null = null; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) | ||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] }) | ||||
|     nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this); | ||||
| } | ||||
|  |  | |||
|  | @ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO { | |||
|     }; | ||||
| } | ||||
| 
 | ||||
| export function mapToUsername(user: { username: string }): string { | ||||
|     return user.username; | ||||
| } | ||||
| 
 | ||||
| export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T { | ||||
|     userInstance.username = userData.username; | ||||
|     userInstance.firstName = userData.firstName; | ||||
|  |  | |||
|  | @ -7,7 +7,6 @@ import * as express from 'express'; | |||
| import { AuthenticatedRequest } from './authenticated-request.js'; | ||||
| import { AuthenticationInfo } from './authentication-info.js'; | ||||
| import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; | ||||
| import { ForbiddenException } from '../../exceptions/forbidden-exception.js'; | ||||
| 
 | ||||
| const JWKS_CACHE = true; | ||||
| const JWKS_RATE_LIMIT = true; | ||||
|  | @ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response | |||
| } | ||||
| 
 | ||||
| export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill | ||||
|  * the given access condition. | ||||
|  * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates | ||||
|  *                        to true. | ||||
|  */ | ||||
| export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) { | ||||
|     return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => { | ||||
|         if (!req.auth) { | ||||
|             throw new UnauthorizedException(); | ||||
|         } else if (!accessCondition(req.auth)) { | ||||
|             throw new ForbiddenException(); | ||||
|         } else { | ||||
|             next(); | ||||
|         } | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects all unauthenticated users, but accepts all authenticated users. | ||||
|  */ | ||||
| export const authenticatedOnly = authorize((_) => true); | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't students. | ||||
|  */ | ||||
| export const studentsOnly = authorize((auth) => auth.accountType === 'student'); | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't teachers. | ||||
|  */ | ||||
| export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); | ||||
|  |  | |||
|  | @ -1,8 +1,15 @@ | |||
| import { Request } from 'express'; | ||||
| import { JwtPayload } from 'jsonwebtoken'; | ||||
| import { AuthenticationInfo } from './authentication-info.js'; | ||||
| import * as core from 'express-serve-static-core'; | ||||
| 
 | ||||
| export interface AuthenticatedRequest extends Request { | ||||
| export interface AuthenticatedRequest< | ||||
|     P = core.ParamsDictionary, | ||||
|     ResBody = unknown, | ||||
|     ReqBody = unknown, | ||||
|     ReqQuery = core.Query, | ||||
|     Locals extends Record<string, unknown> = Record<string, unknown>, | ||||
| > extends Request<P, ResBody, ReqBody, ReqQuery, Locals> { | ||||
|     // Properties are optional since the user is not necessarily authenticated.
 | ||||
|     jwtPayload?: JwtPayload; | ||||
|     auth?: AuthenticationInfo; | ||||
|  |  | |||
							
								
								
									
										21
									
								
								backend/src/middleware/auth/checks/assignment-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								backend/src/middleware/auth/checks/assignment-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| import { authorize } from './auth-checks.js'; | ||||
| import { fetchClass } from '../../../services/classes.js'; | ||||
| import { fetchAllGroups } from '../../../services/groups.js'; | ||||
| import { mapToUsername } from '../../../interfaces/user.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| /** | ||||
|  * Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment). | ||||
|  * Only allows requests from users who are | ||||
|  * - either teachers of the class the assignment was posted in, | ||||
|  * - or students in a group of the assignment. | ||||
|  */ | ||||
| export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => { | ||||
|     const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number }; | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         const clazz = await fetchClass(classId); | ||||
|         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } | ||||
|     const groups = await fetchAllGroups(classId, assignmentId); | ||||
|     return groups.some((group) => group.members.map((member) => member.username).includes(auth.username)); | ||||
| }); | ||||
							
								
								
									
										61
									
								
								backend/src/middleware/auth/checks/auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/middleware/auth/checks/auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | |||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| import * as express from 'express'; | ||||
| import { RequestHandler } from 'express'; | ||||
| import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js'; | ||||
| import { ForbiddenException } from '../../../exceptions/forbidden-exception.js'; | ||||
| import { envVars, getEnvVar } from '../../../util/envVars.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill | ||||
|  * the given access condition. | ||||
|  * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates | ||||
|  *                        to true. | ||||
|  */ | ||||
| export function authorize<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>( | ||||
|     accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean> | ||||
| ): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> { | ||||
|     // Bypass authentication during testing
 | ||||
|     if (getEnvVar(envVars.RunMode) === 'test') { | ||||
|         return async ( | ||||
|             _req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>, | ||||
|             _res: express.Response, | ||||
|             next: express.NextFunction | ||||
|         ): Promise<void> => { | ||||
|             next(); | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     return async ( | ||||
|         req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>, | ||||
|         _res: express.Response, | ||||
|         next: express.NextFunction | ||||
|     ): Promise<void> => { | ||||
|         if (!req.auth) { | ||||
|             throw new UnauthorizedException(); | ||||
|         } else if (!(await accessCondition(req.auth, req))) { | ||||
|             throw new ForbiddenException(); | ||||
|         } else { | ||||
|             next(); | ||||
|         } | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects all unauthenticated users, but accepts all authenticated users. | ||||
|  */ | ||||
| export const authenticatedOnly = authorize((_) => true); | ||||
| /** | ||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't students. | ||||
|  */ | ||||
| export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student); | ||||
| /** | ||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't teachers. | ||||
|  */ | ||||
| export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher); | ||||
| /** | ||||
|  * Middleware which is to be used on requests no normal user should be able to execute. | ||||
|  * Since there is no concept of administrator accounts yet, currently, those requests will always be blocked. | ||||
|  */ | ||||
| export const adminOnly = authorize(() => false); | ||||
							
								
								
									
										70
									
								
								backend/src/middleware/auth/checks/class-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								backend/src/middleware/auth/checks/class-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | |||
| import { authorize } from './auth-checks.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| import { fetchClass } from '../../../services/classes.js'; | ||||
| import { mapToUsername } from '../../../interfaces/user.js'; | ||||
| import { getAllInvitations } from '../../../services/teacher-invitations.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| async function teaches(teacherUsername: string, classId: string): Promise<boolean> { | ||||
|     const clazz = await fetchClass(classId); | ||||
|     return clazz.teachers.map(mapToUsername).includes(teacherUsername); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * To be used on a request with path parameters username and classId. | ||||
|  * Only allows requests whose username parameter is equal to the username of the user who is logged in and requests | ||||
|  * whose classId parameter references a class the logged-in user is a teacher of. | ||||
|  */ | ||||
| export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     if (req.params.username === auth.username) { | ||||
|         return true; | ||||
|     } else if (auth.accountType === AccountType.Teacher) { | ||||
|         return teaches(auth.username, req.params.classId); | ||||
|     } | ||||
|     return false; | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  * Only let the request pass through if its path parameter "username" is the username of the currently logged-in | ||||
|  * teacher and the path parameter "classId" refers to a class the teacher teaches. | ||||
|  */ | ||||
| export const onlyAllowTeacherOfClass = authorize( | ||||
|     async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId) | ||||
| ); | ||||
| 
 | ||||
| /** | ||||
|  * Only let the request pass through if the class id in it refers to a class the current user is in (as a student | ||||
|  * or teacher) | ||||
|  */ | ||||
| export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const classId = req.params.classId ?? req.params.classid ?? req.params.id; | ||||
|     const clazz = await fetchClass(classId); | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } | ||||
|     return clazz.students.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
| 
 | ||||
| export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const classId = req.params.classId ?? req.params.classid ?? req.params.id; | ||||
|     const clazz = await fetchClass(classId); | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         const invitations = await getAllInvitations(auth.username, false); | ||||
|         return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId); | ||||
|     } | ||||
|     return clazz.students.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  * Only allows the request to pass if the 'class' property in its body is a class the current user is a member of. | ||||
|  */ | ||||
| export const onlyAllowOwnClassInBody = authorize(async (auth, req) => { | ||||
|     const classId = (req.body as { class: string })?.class; | ||||
|     const clazz = await fetchClass(classId); | ||||
| 
 | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } | ||||
|     return clazz.students.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
							
								
								
									
										26
									
								
								backend/src/middleware/auth/checks/group-auth-checker.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/middleware/auth/checks/group-auth-checker.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import { authorize } from './auth-checks.js'; | ||||
| import { fetchClass } from '../../../services/classes.js'; | ||||
| import { fetchGroup } from '../../../services/groups.js'; | ||||
| import { mapToUsername } from '../../../interfaces/user.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| /** | ||||
|  * Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'. | ||||
|  * Only allows requests from users who are | ||||
|  * - either teachers of the class the assignment for the group was posted in, | ||||
|  * - or students in the group | ||||
|  */ | ||||
| export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => { | ||||
|     const { | ||||
|         classid: classId, | ||||
|         assignmentid: assignmentId, | ||||
|         groupid: groupId, | ||||
|     } = req.params as { classid: string; assignmentid: number; groupid: number }; | ||||
| 
 | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         const clazz = await fetchClass(classId); | ||||
|         return clazz.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } // User is student
 | ||||
|     const group = await fetchGroup(classId, assignmentId, groupId); | ||||
|     return group.members.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
|  | @ -0,0 +1,21 @@ | |||
| import { authorize } from './auth-checks'; | ||||
| import { AuthenticationInfo } from '../authentication-info'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| /** | ||||
|  * Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId') | ||||
|  * are | ||||
|  * - either not set | ||||
|  * - or set to a group the user is in, | ||||
|  * - or set to anything if the user is a teacher. | ||||
|  */ | ||||
| export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const { forGroup, assignmentNo, classId } = req.params; | ||||
|     if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) { | ||||
|         // TODO: groupNumber?
 | ||||
|         // Const group = await fetchGroup(Number(classId), Number(assignmentNo), )
 | ||||
|         return false; | ||||
|     } | ||||
|     return true; | ||||
| }); | ||||
|  | @ -0,0 +1,16 @@ | |||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import learningObjectService from '../../../services/learning-objects/learning-object-service.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { authorize } from './auth-checks.js'; | ||||
| 
 | ||||
| export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const { hruid } = req.params; | ||||
|     const { version, language } = req.query; | ||||
|     const admins = await learningObjectService.getAdmins({ | ||||
|         hruid, | ||||
|         language: language as Language, | ||||
|         version: parseInt(version as string), | ||||
|     }); | ||||
|     return admins.includes(auth.username); | ||||
| }); | ||||
|  | @ -0,0 +1,13 @@ | |||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import learningPathService from '../../../services/learning-paths/learning-path-service.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { authorize } from './auth-checks.js'; | ||||
| 
 | ||||
| export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const adminsForLearningPath = await learningPathService.getAdmins({ | ||||
|         hruid: req.params.hruid, | ||||
|         language: req.params.language as Language, | ||||
|     }); | ||||
|     return adminsForLearningPath && adminsForLearningPath.includes(auth.username); | ||||
| }); | ||||
							
								
								
									
										66
									
								
								backend/src/middleware/auth/checks/question-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								backend/src/middleware/auth/checks/question-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | |||
| import { authorize } from './auth-checks.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| import { requireFields } from '../../../controllers/error-helper.js'; | ||||
| import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js'; | ||||
| import { fetchQuestion } from '../../../services/questions.js'; | ||||
| import { FALLBACK_SEQ_NUM } from '../../../config.js'; | ||||
| import { fetchAnswer } from '../../../services/answers.js'; | ||||
| import { mapToUsername } from '../../../interfaces/user.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| export const onlyAllowAuthor = authorize( | ||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username | ||||
| ); | ||||
| 
 | ||||
| export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     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 question = await fetchQuestion(questionId); | ||||
| 
 | ||||
|     return question.author.username === auth.username; | ||||
| }); | ||||
| 
 | ||||
| export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     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 fetchAnswer(questionId, sequenceNumber); | ||||
| 
 | ||||
|     return answer.author.username === auth.username; | ||||
| }); | ||||
| 
 | ||||
| export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     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 question = await fetchQuestion(questionId); | ||||
|     const group = question.inGroup; | ||||
| 
 | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         const cls = group.assignment.within; // TODO check if contains full objects
 | ||||
|         return cls.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } // User is student
 | ||||
|     return group.members.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
							
								
								
									
										28
									
								
								backend/src/middleware/auth/checks/submission-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								backend/src/middleware/auth/checks/submission-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import { languageMap } from '@dwengo-1/common/util/language'; | ||||
| import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js'; | ||||
| import { fetchSubmission } from '../../../services/submissions.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { authorize } from './auth-checks.js'; | ||||
| import { FALLBACK_LANG } from '../../../config.js'; | ||||
| import { mapToUsername } from '../../../interfaces/user.js'; | ||||
| import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||
| 
 | ||||
| export const onlyAllowSubmitter = authorize( | ||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username | ||||
| ); | ||||
| 
 | ||||
| export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||
|     const { hruid: lohruid, id: submissionNumber } = req.params; | ||||
|     const { language: lang, version: version } = req.query; | ||||
| 
 | ||||
|     const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version)); | ||||
|     const submission = await fetchSubmission(loId, Number(submissionNumber)); | ||||
| 
 | ||||
|     if (auth.accountType === AccountType.Teacher) { | ||||
|         // Dit kan niet werken om dat al deze objecten niet gepopulate zijn.
 | ||||
|         return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username); | ||||
|     } | ||||
| 
 | ||||
|     return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username); | ||||
| }); | ||||
|  | @ -0,0 +1,17 @@ | |||
| import { authorize } from './auth-checks.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| 
 | ||||
| export const onlyAllowSenderOrReceiver = authorize( | ||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username | ||||
| ); | ||||
| 
 | ||||
| export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username); | ||||
| 
 | ||||
| export const onlyAllowSenderBody = authorize( | ||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username | ||||
| ); | ||||
| 
 | ||||
| export const onlyAllowReceiverBody = authorize( | ||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username | ||||
| ); | ||||
							
								
								
									
										8
									
								
								backend/src/middleware/auth/checks/user-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								backend/src/middleware/auth/checks/user-auth-checks.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| import { authorize } from './auth-checks.js'; | ||||
| import { AuthenticationInfo } from '../authentication-info.js'; | ||||
| import { AuthenticatedRequest } from '../authenticated-request.js'; | ||||
| 
 | ||||
| /** | ||||
|  * Only allow the user whose username is in the path parameter "username" to access the endpoint. | ||||
|  */ | ||||
| export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username); | ||||
|  | @ -1,16 +1,18 @@ | |||
| import express from 'express'; | ||||
| import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; | ||||
| import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; | ||||
| 
 | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| router.get('/', getAllAnswersHandler); | ||||
| router.get('/', authenticatedOnly, getAllAnswersHandler); | ||||
| 
 | ||||
| router.post('/', createAnswerHandler); | ||||
| router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler); | ||||
| 
 | ||||
| router.get('/:seqAnswer', getAnswerHandler); | ||||
| router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler); | ||||
| 
 | ||||
| router.delete('/:seqAnswer', deleteAnswerHandler); | ||||
| router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler); | ||||
| 
 | ||||
| router.put('/:seqAnswer', updateAnswerHandler); | ||||
| router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -9,22 +9,25 @@ import { | |||
|     putAssignmentHandler, | ||||
| } from '../controllers/assignments.js'; | ||||
| import groupRouter from './groups.js'; | ||||
| import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js'; | ||||
| import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| router.get('/', getAllAssignmentsHandler); | ||||
| router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler); | ||||
| 
 | ||||
| router.post('/', createAssignmentHandler); | ||||
| router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler); | ||||
| 
 | ||||
| router.get('/:id', getAssignmentHandler); | ||||
| router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler); | ||||
| 
 | ||||
| router.put('/:id', putAssignmentHandler); | ||||
| router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler); | ||||
| 
 | ||||
| router.delete('/:id', deleteAssignmentHandler); | ||||
| router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler); | ||||
| 
 | ||||
| router.get('/:id/submissions', getAssignmentsSubmissionsHandler); | ||||
| router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler); | ||||
| 
 | ||||
| router.get('/:id/questions', getAssignmentQuestionsHandler); | ||||
| router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler); | ||||
| 
 | ||||
| router.use('/:assignmentid/groups', groupRouter); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,28 +1,35 @@ | |||
| import express from 'express'; | ||||
| import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; | ||||
| import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; | ||||
| import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; | ||||
| import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Returns auth configuration for frontend
 | ||||
| router.get('/config', (_req, res) => { | ||||
|     res.json(getFrontendAuthConfig()); | ||||
| }); | ||||
| router.get('/config', handleGetFrontendAuthConfig); | ||||
| 
 | ||||
| router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { | ||||
|     /* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ | ||||
|     /* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
|     res.json({ message: 'If you see this, you should be authenticated!' }); | ||||
| }); | ||||
| 
 | ||||
| router.get('/testStudentsOnly', studentsOnly, (_req, res) => { | ||||
|     /* #swagger.security = [{ "student": [ ] }] */ | ||||
|     /* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */ | ||||
|     res.json({ message: 'If you see this, you should be a student!' }); | ||||
| }); | ||||
| 
 | ||||
| router.get('/testTeachersOnly', teachersOnly, (_req, res) => { | ||||
|     /* #swagger.security = [{ "teacher": [ ] }] */ | ||||
|     /* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */ | ||||
|     res.json({ message: 'If you see this, you should be a teacher!' }); | ||||
| }); | ||||
| 
 | ||||
| router.post('/hello', authenticatedOnly, postHelloHandler); | ||||
| // This endpoint is called by the client when the user has just logged in.
 | ||||
| // It creates or updates the user entity based on the authentication data the endpoint was called with.
 | ||||
| router.post( | ||||
|     '/hello', | ||||
|     authenticatedOnly, | ||||
|     /* | ||||
|     #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] | ||||
| */ postHelloHandler | ||||
| ); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -14,33 +14,35 @@ import { | |||
|     putClassHandler, | ||||
| } from '../controllers/classes.js'; | ||||
| import assignmentRouter from './assignments.js'; | ||||
| import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getAllClassesHandler); | ||||
| router.get('/', adminOnly, getAllClassesHandler); | ||||
| 
 | ||||
| router.post('/', createClassHandler); | ||||
| router.post('/', teachersOnly, createClassHandler); | ||||
| 
 | ||||
| router.get('/:id', getClassHandler); | ||||
| router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler); | ||||
| 
 | ||||
| router.put('/:id', putClassHandler); | ||||
| router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler); | ||||
| 
 | ||||
| router.delete('/:id', deleteClassHandler); | ||||
| router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler); | ||||
| 
 | ||||
| router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); | ||||
| router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler); | ||||
| 
 | ||||
| router.get('/:id/students', getClassStudentsHandler); | ||||
| router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler); | ||||
| 
 | ||||
| router.post('/:id/students', addClassStudentHandler); | ||||
| router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler); | ||||
| 
 | ||||
| router.delete('/:id/students/:username', deleteClassStudentHandler); | ||||
| router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler); | ||||
| 
 | ||||
| router.get('/:id/teachers', getClassTeachersHandler); | ||||
| router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler); | ||||
| 
 | ||||
| router.post('/:id/teachers', addClassTeacherHandler); | ||||
| // De combinatie van deze POST en DELETE endpoints kan lethal zijn
 | ||||
| router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler); | ||||
| 
 | ||||
| router.delete('/:id/teachers/:username', deleteClassTeacherHandler); | ||||
| router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler); | ||||
| 
 | ||||
| router.use('/:classid/assignments', assignmentRouter); | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,22 +8,24 @@ import { | |||
|     getGroupSubmissionsHandler, | ||||
|     putGroupHandler, | ||||
| } from '../controllers/groups.js'; | ||||
| import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js'; | ||||
| import { teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getAllGroupsHandler); | ||||
| router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler); | ||||
| 
 | ||||
| router.post('/', createGroupHandler); | ||||
| router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler); | ||||
| 
 | ||||
| router.get('/:groupid', getGroupHandler); | ||||
| router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler); | ||||
| 
 | ||||
| router.put('/:groupid', putGroupHandler); | ||||
| router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler); | ||||
| 
 | ||||
| router.delete('/:groupid', deleteGroupHandler); | ||||
| router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler); | ||||
| 
 | ||||
| router.get('/:groupid/submissions', getGroupSubmissionsHandler); | ||||
| router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler); | ||||
| 
 | ||||
| router.get('/:groupid/questions', getGroupQuestionsHandler); | ||||
| router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -1,8 +1,17 @@ | |||
| import express from 'express'; | ||||
| import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; | ||||
| 
 | ||||
| import { | ||||
|     getAllLearningObjects, | ||||
|     getAttachment, | ||||
|     getLearningObject, | ||||
|     getLearningObjectHTML, | ||||
|     handleDeleteLearningObject, | ||||
|     handlePostLearningObject, | ||||
| } from '../controllers/learning-objects.js'; | ||||
| import submissionRoutes from './submissions.js'; | ||||
| import questionRoutes from './questions.js'; | ||||
| import fileUpload from 'express-fileupload'; | ||||
| import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; | ||||
| import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
|  | @ -16,13 +25,21 @@ const router = express.Router(); | |||
| 
 | ||||
| // Route 2: list of object data
 | ||||
| // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
 | ||||
| router.get('/', getAllLearningObjects); | ||||
| router.get('/', authenticatedOnly, getAllLearningObjects); | ||||
| 
 | ||||
| router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject); | ||||
| 
 | ||||
| // Parameter: hruid of learning object
 | ||||
| // Query: language
 | ||||
| // Route to fetch data of one learning object based on its hruid
 | ||||
| // Example: http://localhost:3000/learningObject/un_ai7
 | ||||
| router.get('/:hruid', getLearningObject); | ||||
| router.get('/:hruid', authenticatedOnly, getLearningObject); | ||||
| 
 | ||||
| // Parameter: hruid of learning object
 | ||||
| // Query: language
 | ||||
| // Route to delete a learning object based on its hruid.
 | ||||
| // Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1
 | ||||
| router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject); | ||||
| 
 | ||||
| router.use('/:hruid/submissions', submissionRoutes); | ||||
| 
 | ||||
|  | @ -32,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes); | |||
| // Query: language, version (optional)
 | ||||
| // Route to fetch the HTML rendering of one learning object based on its hruid.
 | ||||
| // Example: http://localhost:3000/learningObject/un_ai7/html
 | ||||
| router.get('/:hruid/html', getLearningObjectHTML); | ||||
| router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML); | ||||
| 
 | ||||
| // Parameter: hruid of learning object, name of attachment.
 | ||||
| // Query: language, version (optional).
 | ||||
| // Route to get the raw data of the attachment for one learning object based on its hruid.
 | ||||
| // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
 | ||||
| router.get('/:hruid/html/:attachmentName', getAttachment); | ||||
| router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| import express from 'express'; | ||||
| import { getLearningPaths } from '../controllers/learning-paths.js'; | ||||
| import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; | ||||
| import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
|  | @ -22,6 +24,10 @@ const router = express.Router(); | |||
| // Route to fetch learning paths based on a theme
 | ||||
| // Example: http://localhost:3000/learningPath?theme=kiks
 | ||||
| 
 | ||||
| router.get('/', getLearningPaths); | ||||
| router.get('/', authenticatedOnly, getLearningPaths); | ||||
| router.post('/', teachersOnly, postLearningPath); | ||||
| 
 | ||||
| router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath); | ||||
| router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -1,20 +1,25 @@ | |||
| import express from 'express'; | ||||
| import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; | ||||
| import answerRoutes from './answers.js'; | ||||
| import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { updateAnswerHandler } from '../controllers/answers.js'; | ||||
| import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js'; | ||||
| 
 | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| // Query language
 | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getAllQuestionsHandler); | ||||
| router.get('/', authenticatedOnly, getAllQuestionsHandler); | ||||
| 
 | ||||
| router.post('/', createQuestionHandler); | ||||
| 
 | ||||
| router.delete('/:seq', deleteQuestionHandler); | ||||
| router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler); | ||||
| 
 | ||||
| // Information about a question with id
 | ||||
| router.get('/:seq', getQuestionHandler); | ||||
| router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler); | ||||
| 
 | ||||
| router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler); | ||||
| 
 | ||||
| router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler); | ||||
| 
 | ||||
| router.use('/:seq/answers', answerRoutes); | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,12 +18,30 @@ router.get('/', (_, res: Response) => { | |||
|     }); | ||||
| }); | ||||
| 
 | ||||
| router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); | ||||
| router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); | ||||
| router.use('/class', classRouter /* #swagger.tags = ['Class'] */); | ||||
| router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); | ||||
| router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); | ||||
| router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); | ||||
| router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); | ||||
| router.use( | ||||
|     '/class', | ||||
|     classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
| ); | ||||
| router.use( | ||||
|     '/learningObject', | ||||
|     learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
| ); | ||||
| router.use( | ||||
|     '/learningPath', | ||||
|     learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
| ); | ||||
| router.use( | ||||
|     '/student', | ||||
|     studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
| ); | ||||
| router.use( | ||||
|     '/teacher', | ||||
|     teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
| ); | ||||
| router.use( | ||||
|     '/theme', | ||||
|     themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */ | ||||
| ); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -5,15 +5,19 @@ import { | |||
|     getStudentRequestHandler, | ||||
|     getStudentRequestsHandler, | ||||
| } from '../controllers/students.js'; | ||||
| import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||
| import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js'; | ||||
| 
 | ||||
| // Under /:username/joinRequests/
 | ||||
| 
 | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| router.get('/', getStudentRequestsHandler); | ||||
| router.get('/', preventImpersonation, getStudentRequestsHandler); | ||||
| 
 | ||||
| router.post('/', createStudentRequestHandler); | ||||
| router.post('/', preventImpersonation, createStudentRequestHandler); | ||||
| 
 | ||||
| router.get('/:classId', getStudentRequestHandler); | ||||
| router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler); | ||||
| 
 | ||||
| router.delete('/:classId', deleteClassJoinRequestHandler); | ||||
| router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -11,33 +11,37 @@ import { | |||
|     getStudentSubmissionsHandler, | ||||
| } from '../controllers/students.js'; | ||||
| import joinRequestRouter from './student-join-requests.js'; | ||||
| import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||
| import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getAllStudentsHandler); | ||||
| router.get('/', adminOnly, getAllStudentsHandler); | ||||
| 
 | ||||
| router.post('/', createStudentHandler); | ||||
| // Users will be created automatically when some resource is created for them. Therefore, this endpoint
 | ||||
| // Can only be used by an administrator.
 | ||||
| router.post('/', adminOnly, createStudentHandler); | ||||
| 
 | ||||
| router.delete('/:username', deleteStudentHandler); | ||||
| router.delete('/:username', preventImpersonation, deleteStudentHandler); | ||||
| 
 | ||||
| // Information about a student's profile
 | ||||
| router.get('/:username', getStudentHandler); | ||||
| router.get('/:username', preventImpersonation, getStudentHandler); | ||||
| 
 | ||||
| // The list of classes a student is in
 | ||||
| router.get('/:username/classes', getStudentClassesHandler); | ||||
| router.get('/:username/classes', preventImpersonation, getStudentClassesHandler); | ||||
| 
 | ||||
| // The list of submissions a student has made
 | ||||
| router.get('/:username/submissions', getStudentSubmissionsHandler); | ||||
| router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler); | ||||
| 
 | ||||
| // The list of assignments a student has
 | ||||
| router.get('/:username/assignments', getStudentAssignmentsHandler); | ||||
| router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler); | ||||
| 
 | ||||
| // The list of groups a student is in
 | ||||
| router.get('/:username/groups', getStudentGroupsHandler); | ||||
| router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler); | ||||
| 
 | ||||
| // A list of questions a user has created
 | ||||
| router.get('/:username/questions', getStudentQuestionsHandler); | ||||
| router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler); | ||||
| 
 | ||||
| router.use('/:username/joinRequests', joinRequestRouter); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,15 +1,15 @@ | |||
| import express from 'express'; | ||||
| import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; | ||||
| import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js'; | ||||
| import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getSubmissionsHandler); | ||||
| router.get('/', adminOnly, getSubmissionsHandler); | ||||
| 
 | ||||
| router.post('/', createSubmissionHandler); | ||||
| router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler); | ||||
| 
 | ||||
| // Information about an submission with id 'id'
 | ||||
| router.get('/:id', getSubmissionHandler); | ||||
| router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler); | ||||
| 
 | ||||
| router.delete('/:id', deleteSubmissionHandler); | ||||
| router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -6,17 +6,24 @@ import { | |||
|     getInvitationHandler, | ||||
|     updateInvitationHandler, | ||||
| } from '../controllers/teacher-invitations.js'; | ||||
| import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||
| import { | ||||
|     onlyAllowReceiverBody, | ||||
|     onlyAllowSender, | ||||
|     onlyAllowSenderBody, | ||||
|     onlyAllowSenderOrReceiver, | ||||
| } from '../middleware/auth/checks/teacher-invitation-checks.js'; | ||||
| 
 | ||||
| const router = express.Router({ mergeParams: true }); | ||||
| 
 | ||||
| router.get('/:username', getAllInvitationsHandler); | ||||
| router.get('/:username', preventImpersonation, getAllInvitationsHandler); | ||||
| 
 | ||||
| router.get('/:sender/:receiver/:classId', getInvitationHandler); | ||||
| router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler); | ||||
| 
 | ||||
| router.post('/', createInvitationHandler); | ||||
| router.post('/', onlyAllowSenderBody, createInvitationHandler); | ||||
| 
 | ||||
| router.put('/', updateInvitationHandler); | ||||
| router.put('/', onlyAllowReceiverBody, updateInvitationHandler); | ||||
| 
 | ||||
| router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); | ||||
| router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -10,25 +10,27 @@ import { | |||
|     updateStudentJoinRequestHandler, | ||||
| } from '../controllers/teachers.js'; | ||||
| import invitationRouter from './teacher-invitations.js'; | ||||
| 
 | ||||
| import { adminOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js'; | ||||
| import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js'; | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', getAllTeachersHandler); | ||||
| router.get('/', adminOnly, getAllTeachersHandler); | ||||
| 
 | ||||
| router.post('/', createTeacherHandler); | ||||
| router.post('/', adminOnly, createTeacherHandler); | ||||
| 
 | ||||
| router.get('/:username', getTeacherHandler); | ||||
| router.get('/:username', preventImpersonation, getTeacherHandler); | ||||
| 
 | ||||
| router.delete('/:username', deleteTeacherHandler); | ||||
| router.delete('/:username', preventImpersonation, deleteTeacherHandler); | ||||
| 
 | ||||
| router.get('/:username/classes', getTeacherClassHandler); | ||||
| router.get('/:username/classes', preventImpersonation, getTeacherClassHandler); | ||||
| 
 | ||||
| router.get('/:username/students', getTeacherStudentHandler); | ||||
| router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); | ||||
| 
 | ||||
| router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); | ||||
| router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); | ||||
| 
 | ||||
| router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); | ||||
| router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); | ||||
| 
 | ||||
| // Invitations to other classes a teacher received
 | ||||
| router.use('/invitations', invitationRouter); | ||||
|  |  | |||
|  | @ -1,14 +1,15 @@ | |||
| import express from 'express'; | ||||
| import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; | ||||
| import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Query: language
 | ||||
| //  Route to fetch list of {key, title, description, image} themes in their respective language
 | ||||
| router.get('/', getThemesHandler); | ||||
| router.get('/', authenticatedOnly, getThemesHandler); | ||||
| 
 | ||||
| // Arg: theme (key)
 | ||||
| //  Route to fetch list of hruids based on theme
 | ||||
| router.get('/:theme', getHruidsByThemeHandler); | ||||
| router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler); | ||||
| 
 | ||||
| export default router; | ||||
|  |  | |||
|  | @ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat | |||
|     return mapToAnswerDTO(answer); | ||||
| } | ||||
| 
 | ||||
| async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { | ||||
| export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { | ||||
|     const answerRepository = getAnswerRepository(); | ||||
|     const question = await fetchQuestion(questionId); | ||||
|     const answer = await answerRepository.findAnswer(question, sequenceNumber); | ||||
|  |  | |||
|  | @ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou | |||
|     return group; | ||||
| } | ||||
| 
 | ||||
| export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise<Group[]> { | ||||
|     const assignment = await fetchAssignment(classId, assignmentNumber); | ||||
| 
 | ||||
|     const groupRepository = getGroupRepository(); | ||||
|     const groups = await groupRepository.findAllGroupsForAssignment(assignment); | ||||
| 
 | ||||
|     return groups; | ||||
| } | ||||
| 
 | ||||
| export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { | ||||
|     const group = await fetchGroup(classId, assignmentNumber, groupNumber); | ||||
|     return mapToGroupDTO(group, group.assignment.within); | ||||
|  |  | |||
|  | @ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = { | |||
|         ); | ||||
|         return learningObjects.filter((it) => it !== null); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Returns all learning objects containing the given username as an admin. | ||||
|      */ | ||||
|     async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> { | ||||
|         const learningObjectRepo = getLearningObjectRepository(); | ||||
|         const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername); | ||||
|         return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default databaseLearningObjectProvider; | ||||
|  |  | |||
|  | @ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { | |||
| 
 | ||||
|         return html; | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Obtain all learning objects who have the user with the given username as an admin. | ||||
|      */ | ||||
|     async getLearningObjectsAdministratedBy(_adminUsername: string): Promise<FilteredLearningObject[]> { | ||||
|         return []; // The dwengo database does not contain any learning objects administrated by users.
 | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default dwengoApiLearningObjectProvider; | ||||
|  |  | |||
|  | @ -20,4 +20,9 @@ export interface LearningObjectProvider { | |||
|      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||
|      */ | ||||
|     getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>; | ||||
| 
 | ||||
|     /** | ||||
|      * Obtain all learning object who have the user with the given username as an admin. | ||||
|      */ | ||||
|     getLearningObjectsAdministratedBy(username: string): Promise<FilteredLearningObject[]>; | ||||
| } | ||||
|  |  | |||
|  | @ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js'; | |||
| import { envVars, getEnvVar } from '../../util/envVars.js'; | ||||
| import databaseLearningObjectProvider from './database-learning-object-provider.js'; | ||||
| import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { getLearningObjectRepository, getTeacherRepository } from '../../data/repositories.js'; | ||||
| import { processLearningObjectZip } from './learning-object-zip-processing-service.js'; | ||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||
| import { NotFoundException } from '../../exceptions/not-found-exception.js'; | ||||
| 
 | ||||
| function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { | ||||
|     if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { | ||||
|  | @ -42,6 +47,66 @@ const learningObjectService = { | |||
|     async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> { | ||||
|         return getProvider(id).getLearningObjectHTML(id); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Obtain all learning objects administrated by the user with the given username. | ||||
|      */ | ||||
|     async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> { | ||||
|         return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Store the learning object in the given zip file in the database. | ||||
|      * @param learningObjectPath The path where the uploaded learning object resides. | ||||
|      * @param admins The usernames of the users which should be administrators of the learning object. | ||||
|      */ | ||||
|     async storeLearningObject(learningObjectPath: string, admins: string[]): Promise<LearningObject> { | ||||
|         const learningObjectRepository = getLearningObjectRepository(); | ||||
|         const learningObject = await processLearningObjectZip(learningObjectPath); | ||||
| 
 | ||||
|         if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { | ||||
|             learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid; | ||||
|         } | ||||
| 
 | ||||
|         // Lookup the admin teachers based on their usernames and add them to the admins of the learning object.
 | ||||
|         const teacherRepo = getTeacherRepository(); | ||||
|         const adminTeachers = await Promise.all(admins.map(async (it) => teacherRepo.findByUsername(it))); | ||||
|         adminTeachers.forEach((it) => { | ||||
|             if (it !== null) { | ||||
|                 learningObject.admins.add(it); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         try { | ||||
|             await learningObjectRepository.save(learningObject, { preventOverwrite: true }); | ||||
|         } catch (e: unknown) { | ||||
|             learningObjectRepository.getEntityManager().clear(); | ||||
|             throw e; | ||||
|         } | ||||
| 
 | ||||
|         return learningObject; | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Deletes the learning object with the given identifier. | ||||
|      */ | ||||
|     async deleteLearningObject(id: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||
|         const learningObjectRepository = getLearningObjectRepository(); | ||||
|         return await learningObjectRepository.removeByIdentifier(id); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Returns a list of the usernames of the administrators of the learning object with the given identifier. | ||||
|      * @throws NotFoundException if the specified learning object was not found in the database. | ||||
|      */ | ||||
|     async getAdmins(id: LearningObjectIdentifier): Promise<string[]> { | ||||
|         const learningObjectRepo = getLearningObjectRepository(); | ||||
|         const learningObject = await learningObjectRepo.findByIdentifier(id); | ||||
|         if (!learningObject) { | ||||
|             throw new NotFoundException('learningObjectNotFound'); | ||||
|         } | ||||
|         return learningObject.admins.map((admin) => admin.username); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default learningObjectService; | ||||
|  |  | |||
|  | @ -0,0 +1,119 @@ | |||
| import unzipper from 'unzipper'; | ||||
| import mime from 'mime-types'; | ||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||
| import { getAttachmentRepository, getLearningObjectRepository } from '../../data/repositories.js'; | ||||
| import { BadRequestException } from '../../exceptions/bad-request-exception.js'; | ||||
| import { LearningObjectMetadata } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { DwengoContentType } from './processing/content-type.js'; | ||||
| import { v4 } from 'uuid'; | ||||
| 
 | ||||
| const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/; | ||||
| const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/; | ||||
| 
 | ||||
| /** | ||||
|  * Process an uploaded zip file and construct a LearningObject from its contents. | ||||
|  * @param filePath Path of the zip file to process. | ||||
|  */ | ||||
| export async function processLearningObjectZip(filePath: string): Promise<LearningObject> { | ||||
|     let zip: unzipper.CentralDirectory; | ||||
|     try { | ||||
|         zip = await unzipper.Open.file(filePath); | ||||
|     } catch (_: unknown) { | ||||
|         throw new BadRequestException('invalidZip'); | ||||
|     } | ||||
| 
 | ||||
|     let metadata: LearningObjectMetadata | undefined = undefined; | ||||
|     const attachments: { name: string; content: Buffer }[] = []; | ||||
|     let content: Buffer | undefined = undefined; | ||||
| 
 | ||||
|     if (zip.files.length === 0) { | ||||
|         throw new BadRequestException('emptyZip'); | ||||
|     } | ||||
| 
 | ||||
|     await Promise.all( | ||||
|         zip.files.map(async (file) => { | ||||
|             if (file.type !== 'Directory') { | ||||
|                 if (METADATA_PATH_REGEX.test(file.path)) { | ||||
|                     metadata = await processMetadataJson(file); | ||||
|                 } else if (CONTENT_PATH_REGEX.test(file.path)) { | ||||
|                     content = await processFile(file); | ||||
|                 } else { | ||||
|                     attachments.push({ | ||||
|                         name: file.path, | ||||
|                         content: await processFile(file), | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     ); | ||||
| 
 | ||||
|     if (!metadata) { | ||||
|         throw new BadRequestException('missingMetadata'); | ||||
|     } | ||||
|     if (!content) { | ||||
|         throw new BadRequestException('missingIndex'); | ||||
|     } | ||||
| 
 | ||||
|     const learningObject = createLearningObject(metadata, content, attachments); | ||||
| 
 | ||||
|     return learningObject; | ||||
| } | ||||
| 
 | ||||
| function createLearningObject(metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer }[]): LearningObject { | ||||
|     const learningObjectRepo = getLearningObjectRepository(); | ||||
|     const attachmentRepo = getAttachmentRepository(); | ||||
| 
 | ||||
|     const returnValue = { | ||||
|         callbackUrl: metadata.return_value?.callback_url ?? '', | ||||
|         callbackSchema: metadata.return_value?.callback_schema ? JSON.stringify(metadata.return_value.callback_schema) : '', | ||||
|     }; | ||||
| 
 | ||||
|     if (!metadata.target_ages || metadata.target_ages.length === 0) { | ||||
|         throw new BadRequestException('targetAgesMandatory'); | ||||
|     } | ||||
| 
 | ||||
|     const learningObject = learningObjectRepo.create({ | ||||
|         admins: [], | ||||
|         available: metadata.available ?? true, | ||||
|         content: content, | ||||
|         contentType: metadata.content_type as DwengoContentType, | ||||
|         copyright: metadata.copyright ?? '', | ||||
|         description: metadata.description ?? '', | ||||
|         educationalGoals: metadata.educational_goals ?? [], | ||||
|         hruid: metadata.hruid, | ||||
|         keywords: metadata.keywords, | ||||
|         language: metadata.language, | ||||
|         license: metadata.license ?? '', | ||||
|         returnValue, | ||||
|         skosConcepts: metadata.skos_concepts ?? [], | ||||
|         teacherExclusive: metadata.teacher_exclusive, | ||||
|         title: metadata.title, | ||||
|         version: metadata.version, | ||||
|         estimatedTime: metadata.estimated_time ?? 1, | ||||
|         targetAges: metadata.target_ages ?? [], | ||||
|         difficulty: metadata.difficulty ?? 1, | ||||
|         uuid: v4(), | ||||
|     }); | ||||
|     const attachmentEntities = attachments.map((it) => | ||||
|         attachmentRepo.create({ | ||||
|             name: it.name, | ||||
|             content: it.content, | ||||
|             mimeType: mime.lookup(it.name) || 'text/plain', | ||||
|             learningObject, | ||||
|         }) | ||||
|     ); | ||||
|     attachmentEntities.forEach((it) => { | ||||
|         learningObject.attachments.add(it); | ||||
|     }); | ||||
|     return learningObject; | ||||
| } | ||||
| 
 | ||||
| async function processMetadataJson(file: unzipper.File): Promise<LearningObjectMetadata> { | ||||
|     const buf = await file.buffer(); | ||||
|     const content = buf.toString(); | ||||
|     return JSON.parse(content); | ||||
| } | ||||
| 
 | ||||
| async function processFile(file: unzipper.File): Promise<Buffer> { | ||||
|     return await file.buffer(); | ||||
| } | ||||
|  | @ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language'; | |||
| import { Group } from '../../entities/assignments/group.entity'; | ||||
| import { Collection } from '@mikro-orm/core'; | ||||
| import { v4 } from 'uuid'; | ||||
| import { getLogger } from '../../logging/initalize.js'; | ||||
| 
 | ||||
| const logger = getLogger(); | ||||
| 
 | ||||
| /** | ||||
|  * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its | ||||
|  | @ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>): | |||
|             ) | ||||
|         ) | ||||
|     ); | ||||
|     if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) { | ||||
|         throw new Error('At least one of the learning objects on this path could not be found.'); | ||||
| 
 | ||||
|     // Ignore all learning objects that cannot be found such that the rest of the learning path keeps working.
 | ||||
|     for (const [key, value] of nullableNodesToLearningObjects) { | ||||
|         if (value === null) { | ||||
|             logger.warn(`Learning object ${key.learningObjectHruid}/${key.language}/${key.version} not found!`); | ||||
|             nullableNodesToLearningObjects.delete(key); | ||||
|         } | ||||
|     } | ||||
|     return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>; | ||||
| } | ||||
|  | @ -104,8 +112,15 @@ async function convertNode( | |||
|                 !personalizedFor || // If we do not want a personalized learning path, keep all transitions
 | ||||
|                 isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
 | ||||
|         ) | ||||
|         .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)); | ||||
| 
 | ||||
|         .map((trans, i) => { | ||||
|             try { | ||||
|                 return convertTransition(trans, i, nodesToLearningObjects); | ||||
|             } catch (_: unknown) { | ||||
|                 logger.error(`Transition could not be resolved: ${JSON.stringify(trans)}`); | ||||
|                 return undefined; // Do not crash on invalid transitions, just ignore them so the rest of the learning path keeps working.
 | ||||
|             } | ||||
|         }) | ||||
|         .filter((it) => it !== undefined); | ||||
|     return { | ||||
|         _id: learningObject.uuid, | ||||
|         language: learningObject.language, | ||||
|  | @ -224,6 +239,15 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|         }; | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Returns all the learning paths which have the user with the given username as an administrator. | ||||
|      */ | ||||
|     async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> { | ||||
|         const repo = getLearningPathRepository(); | ||||
|         const paths = await repo.findAllByAdminUsername(adminUsername); | ||||
|         return await Promise.all(paths.map(async (result, index) => convertLearningPath(result, index))); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Search learning paths in the database using the given search string. | ||||
|      */ | ||||
|  |  | |||
|  | @ -74,6 +74,10 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
| 
 | ||||
|         return searchResults ?? []; | ||||
|     }, | ||||
| 
 | ||||
|     async getLearningPathsAdministratedBy(_adminUsername: string) { | ||||
|         return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user.
 | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default dwengoApiLearningPathProvider; | ||||
|  |  | |||
|  | @ -15,4 +15,9 @@ export interface LearningPathProvider { | |||
|      * Search learning paths in the data source using the given search string. | ||||
|      */ | ||||
|     searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; | ||||
| 
 | ||||
|     /** | ||||
|      * Get all learning paths which have the teacher with the given user as an administrator. | ||||
|      */ | ||||
|     getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]>; | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; | ||||
| import databaseLearningPathProvider from './database-learning-path-provider.js'; | ||||
| import { envVars, getEnvVar } from '../../util/envVars.js'; | ||||
| import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { Group } from '../../entities/assignments/group.entity.js'; | ||||
| import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; | ||||
|  | @ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; | |||
| import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; | ||||
| import { mapToTeacher } from '../../interfaces/teacher.js'; | ||||
| import { Collection } from '@mikro-orm/core'; | ||||
| import { NotFoundException } from '../../exceptions/not-found-exception.js'; | ||||
| import { BadRequestException } from '../../exceptions/bad-request-exception.js'; | ||||
| import learningObjectService from '../learning-objects/learning-object-service.js'; | ||||
| 
 | ||||
| const userContentPrefix = getEnvVar(envVars.UserContentPrefix); | ||||
| const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | ||||
|  | @ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | |||
|         const fromNode = nodes.find( | ||||
|             (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version | ||||
|         )!; | ||||
|         const transitions = nodeDto.transitions | ||||
|             .map((transDto, i) => { | ||||
|                 const toNode = nodes.find( | ||||
|                     (it) => | ||||
|                         it.learningObjectHruid === transDto.next.hruid && | ||||
|                         it.language === transDto.next.language && | ||||
|                         it.version === transDto.next.version | ||||
|                 ); | ||||
|         const transitions = nodeDto.transitions.map((transDto, i) => { | ||||
|             const toNode = nodes.find( | ||||
|                 (it) => | ||||
|                     it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version | ||||
|             ); | ||||
| 
 | ||||
|                 if (toNode) { | ||||
|                     return repo.createTransition({ | ||||
|                         transitionNumber: i, | ||||
|                         node: fromNode, | ||||
|                         next: toNode, | ||||
|                         condition: transDto.condition ?? 'true', | ||||
|                     }); | ||||
|                 } | ||||
|                 return undefined; | ||||
|             }) | ||||
|             .filter((it) => it) | ||||
|             .map((it) => it!); | ||||
|             if (toNode) { | ||||
|                 return repo.createTransition({ | ||||
|                     transitionNumber: i, | ||||
|                     node: fromNode, | ||||
|                     next: toNode, | ||||
|                     condition: transDto.condition ?? 'true', | ||||
|                 }); | ||||
|             } | ||||
|             throw new BadRequestException( | ||||
|                 `Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.` | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions); | ||||
|     }); | ||||
|  | @ -105,6 +105,14 @@ const learningPathService = { | |||
|         }; | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the learning paths administrated by the teacher with the given username. | ||||
|      */ | ||||
|     async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> { | ||||
|         const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername))); | ||||
|         return providerResponses.flat(); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Search learning paths in the data source using the given search string. | ||||
|      */ | ||||
|  | @ -119,11 +127,67 @@ const learningPathService = { | |||
|      * Add a new learning path to the database. | ||||
|      * @param dto Learning path DTO from which the learning path will be created. | ||||
|      * @param admins Teachers who should become an admin of the learning path. | ||||
|      * @returns the created learning path. | ||||
|      */ | ||||
|     async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> { | ||||
|     async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<LearningPathEntity> { | ||||
|         const repo = getLearningPathRepository(); | ||||
| 
 | ||||
|         const userContentPrefix = getEnvVar(envVars.UserContentPrefix); | ||||
|         if (!dto.hruid.startsWith(userContentPrefix)) { | ||||
|             dto.hruid = userContentPrefix + dto.hruid; | ||||
|         } | ||||
| 
 | ||||
|         const path = mapToLearningPath(dto, admins); | ||||
|         await repo.save(path, { preventOverwrite: true }); | ||||
| 
 | ||||
|         // Verify that all specified learning objects actually exist.
 | ||||
|         const learningObjectsOnPath = await Promise.all( | ||||
|             path.nodes.map(async (node) => | ||||
|                 learningObjectService.getLearningObjectById({ | ||||
|                     hruid: node.learningObjectHruid, | ||||
|                     language: node.language, | ||||
|                     version: node.version, | ||||
|                 }) | ||||
|             ) | ||||
|         ); | ||||
|         if (learningObjectsOnPath.some((it) => !it)) { | ||||
|             throw new BadRequestException('pathContainsNonExistingLearningObjects'); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             await repo.save(path, { preventOverwrite: true }); | ||||
|         } catch (e: unknown) { | ||||
|             repo.getEntityManager().clear(); | ||||
|             throw e; | ||||
|         } | ||||
|         return path; | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Deletes the learning path with the given identifier from the database. | ||||
|      * @param id Identifier of the learning path to delete. | ||||
|      * @returns the deleted learning path. | ||||
|      */ | ||||
|     async deleteLearningPath(id: LearningPathIdentifier): Promise<LearningPathEntity> { | ||||
|         const repo = getLearningPathRepository(); | ||||
| 
 | ||||
|         const deletedPath = await repo.deleteByHruidAndLanguage(id.hruid, id.language); | ||||
|         if (deletedPath) { | ||||
|             return deletedPath; | ||||
|         } | ||||
|         throw new NotFoundException('No learning path with the given identifier found.'); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Returns a list of the usernames of the administrators of the learning path with the given identifier. | ||||
|      * @param id The identifier of the learning path whose admins should be fetched. | ||||
|      */ | ||||
|     async getAdmins(id: LearningPathIdentifier): Promise<string[]> { | ||||
|         const repo = getLearningPathRepository(); | ||||
|         const path = await repo.findByHruidAndLanguage(id.hruid, id.language); | ||||
|         if (!path) { | ||||
|             throw new NotFoundException('No learning path with the given identifier found.'); | ||||
|         } | ||||
|         return path.admins.map((admin) => admin.username); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import { fetchStudent } from './students.js'; | |||
| import { NotFoundException } from '../exceptions/not-found-exception.js'; | ||||
| import { FALLBACK_VERSION_NUM } from '../config.js'; | ||||
| import { fetchAssignment } from './assignments.js'; | ||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||
| 
 | ||||
| export async function getQuestionsAboutLearningObjectInAssignment( | ||||
|     loId: LearningObjectIdentifier, | ||||
|  | @ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat | |||
| 
 | ||||
|     const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); | ||||
| 
 | ||||
|     if (!inGroup) { | ||||
|         throw new NotFoundException('Group with id and assignment not found'); | ||||
|     } | ||||
| 
 | ||||
|     if (!inGroup.members.contains(author)) { | ||||
|         throw new ConflictException('Author is not part of this group'); | ||||
|     } | ||||
| 
 | ||||
|     const question = await questionRepository.createQuestion({ | ||||
|         loId, | ||||
|         author, | ||||
|         inGroup: inGroup!, | ||||
|         inGroup: inGroup, | ||||
|         content, | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm | |||
| 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'; | ||||
| import { Submission } from '../entities/assignments/submission.entity.js'; | ||||
| import { mapToUsername } from '../interfaces/user.js'; | ||||
| 
 | ||||
| export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { | ||||
|     const studentRepository = getStudentRepository(); | ||||
|  | @ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise<StudentDTO[] | stri | |||
|         return users.map(mapToStudentDTO); | ||||
|     } | ||||
| 
 | ||||
|     return users.map((user) => user.username); | ||||
|     return users.map(mapToUsername); | ||||
| } | ||||
| 
 | ||||
| export async function fetchStudent(username: string): Promise<Student> { | ||||
|  | @ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> { | |||
|     const newStudent = mapToStudent(userData); | ||||
|     await studentRepository.save(newStudent, { preventOverwrite: true }); | ||||
| 
 | ||||
|     return userData; | ||||
|     return mapToStudentDTO(newStudent); | ||||
| } | ||||
| 
 | ||||
| export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { | ||||
|  |  | |||
|  | @ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise<Tea | |||
|         throw new ConflictException('The teacher sending the invite is not part of the class'); | ||||
|     } | ||||
| 
 | ||||
|     if (cls.teachers.contains(receiver)) { | ||||
|         throw new ConflictException('The teacher receiving the invite is already part of the class'); | ||||
|     } | ||||
| 
 | ||||
|     const newInvitation = mapToInvitation(sender, receiver, cls); | ||||
|     await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | |||
| import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | ||||
| import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | ||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||
| import { mapToUsername } from '../interfaces/user.js'; | ||||
| 
 | ||||
| export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { | ||||
|     const teacherRepository: TeacherRepository = getTeacherRepository(); | ||||
|  | @ -26,7 +27,7 @@ export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | stri | |||
|     if (full) { | ||||
|         return users.map(mapToTeacherDTO); | ||||
|     } | ||||
|     return users.map((user) => user.username); | ||||
|     return users.map(mapToUsername); | ||||
| } | ||||
| 
 | ||||
| export async function fetchTeacher(username: string): Promise<Teacher> { | ||||
|  | @ -45,7 +46,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> { | |||
|     return mapToTeacherDTO(user); | ||||
| } | ||||
| 
 | ||||
| export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { | ||||
| // TODO update parameter
 | ||||
| export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise<TeacherDTO> { | ||||
|     const teacherRepository: TeacherRepository = getTeacherRepository(); | ||||
| 
 | ||||
|     const newTeacher = mapToTeacher(userData); | ||||
|  | @ -98,7 +100,9 @@ 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 (username) => await getClassStudentsDTO(username)))).flat(); | ||||
|     const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId)))) | ||||
|         .flat() | ||||
|         .filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index); | ||||
| 
 | ||||
|     if (full) { | ||||
|         return students; | ||||
|  |  | |||
|  | @ -15,7 +15,6 @@ import { | |||
| import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; | ||||
| 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'; | ||||
| import { getClass02 } from '../test_assets/classes/classes.testdata'; | ||||
| 
 | ||||
|  | @ -97,7 +96,7 @@ describe('Teacher controllers', () => { | |||
|     }); | ||||
| 
 | ||||
|     it('Teacher list', async () => { | ||||
|         req = { query: { full: 'true' } }; | ||||
|         req = { query: { full: 'false' } }; | ||||
| 
 | ||||
|         await getAllTeachersHandler(req as Request, res as Response); | ||||
| 
 | ||||
|  | @ -105,8 +104,7 @@ describe('Teacher controllers', () => { | |||
| 
 | ||||
|         const result = jsonMock.mock.lastCall?.[0]; | ||||
| 
 | ||||
|         const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); | ||||
|         expect(teacherUsernames).toContain('testleerkracht1'); | ||||
|         expect(result.teachers).toContain('testleerkracht1'); | ||||
| 
 | ||||
|         expect(result.teachers).toHaveLength(5); | ||||
|     }); | ||||
|  |  | |||
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana