diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 1bd3f2b1..9a03e681 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -7,51 +7,89 @@ 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'; /** * Fetch learning paths based on query parameters. */ export async function getLearningPaths(req: Request, res: Response): Promise { - 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 { + return async (req, res) => { + const path: LearningPath = req.body; + if (isPut) { + if (req.params.hruid !== path.hruid || req.params.language !== path.language) { + throw new BadRequestException("id_not_matching_query_params"); + } + } + const teacher = await getTeacher(req.auth!.username); + res.json(await learningPathService.createNewLearningPath(path, [teacher], isPut)); + } +} + +export const postLearningPath = postOrPutLearningPath(false); +export const putLearningPath = postOrPutLearningPath(true); + +export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise { + const id: LearningPathIdentifier = { + hruid: req.params.hruid, + language: req.params.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."); + } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 67f08a03..beb0abec 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -28,6 +28,21 @@ export class LearningPathRepository extends DwengoEntityRepository }); } + /** + * Returns all learning paths which have the user with the given username as an administrator. + */ + public async findAllByAdminUsername(adminUsername: string): Promise { + return this.findAll({ + where: { + admins: { + $contains: { + username: adminUsername + } + } + } + }); + } + public createNode(nodeData: RequiredEntityData): LearningPathNode { return this.em.create(LearningPathNode, nodeData); } @@ -50,4 +65,16 @@ export class LearningPathRepository extends DwengoEntityRepository await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); } + + /** + * 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 { + const path = await this.findByHruidAndLanguage(hruid, language); + if (path) { + await this.em.removeAndFlush(path); + } + return path; + } } diff --git a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts new file mode 100644 index 00000000..6c73e22e --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -0,0 +1,12 @@ +import learningPathService from "../../../services/learning-paths/learning-path-service"; +import { authorize } from "../auth"; +import { AuthenticatedRequest } from "../authenticated-request"; +import { AuthenticationInfo } from "../authentication-info"; + +export const onlyAdminsForLearningPath = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => { + const adminsForLearningPath = learningPathService.getAdmins({ + hruid: req.body.hruid, + language: req.body.language + }); + return adminsForLearningPath && auth.username in adminsForLearningPath; +}); diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index efe17312..b2e67d57 100644 --- a/backend/src/routes/learning-paths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,5 +1,7 @@ import express from 'express'; -import { getLearningPaths } from '../controllers/learning-paths.js'; +import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; +import { teachersOnly } from '../middleware/auth/auth.js'; +import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; const router = express.Router(); @@ -23,5 +25,9 @@ const router = express.Router(); // Example: http://localhost:3000/learningPath?theme=kiks router.get('/', getLearningPaths); +router.post('/', teachersOnly, postLearningPath) + +router.put('/:hruid/:language', onlyAdminsForLearningObject, putLearningPath); +router.delete('/:hruid/:language', onlyAdminsForLearningObject, deleteLearningPath); export default router; diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index fe05dda1..ac525831 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -198,6 +198,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 { + 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. */ diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts index 110cd570..f379c049 100644 --- a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -45,6 +45,10 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); return searchResults ?? []; }, + + async getLearningPathsAdministratedBy(_adminUsername: string) { + return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user. + }, }; export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 086777bd..0cf507ca 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -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; + + /** + * Get all learning paths which have the teacher with the given user as an administrator. + */ + getLearningPathsAdministratedBy(adminUsername: string): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index b20d8f97..53c084fd 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -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,7 @@ 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'; const userContentPrefix = getEnvVar(envVars.UserContentPrefix); const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; @@ -105,6 +106,16 @@ const learningPathService = { }; }, + /** + * Fetch the learning paths administrated by the teacher with the given username. + */ + async getLearningPathsAdministratedBy(adminUsername: string): Promise { + 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,12 +130,42 @@ 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. + * @param allowReplace If this is set to true and there is already a learning path with the same identifier, it is replaced. + * @returns the created learning path. */ - async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[], allowReplace = false): Promise { const repo = getLearningPathRepository(); const path = mapToLearningPath(dto, admins); - await repo.save(path, { preventOverwrite: true }); + await repo.save(path, { preventOverwrite: allowReplace }); + 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 { + 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 { + 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); + } }; export default learningPathService;