diff --git a/backend/.env.example b/backend/.env.example index 8873515c..e77eab20 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,7 +13,7 @@ #DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api # The default fallback language. #DWENGO_FALLBACK_LANGUAGE=nl -# Whether running in production mode or not. Possible values are "prod" or "dev". +# Whether running in production mode or not. Possible values are "prod", "staging", "test" or "dev". #DWENGO_RUN_MODE=dev # ! Change this! The hostname or IP address of the database @@ -66,3 +66,12 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://hostname/realms/teacher/protocol/openid # The address of the Loki instance, a log aggregation system. # If running your stack in docker, this should use the docker service name. #DWENGO_LOGGING_LOKI_HOST=http://localhost:3102 + +# The hostname or IP address of the caching server, e.g. Memcached. +# If running your stack in docker, this should use the docker service name. +#DWENGO_CACHE_HOST=localhost +#DWENGO_CACHE_PORT=11211 +# The time-to-live (TTL) for cached items in seconds. +#DWENGO_CACHE_TTL=3600 +# If your cache server benefits from a prefix, you can set it here. +#DWENGO_CACHE_KEY_PREFIX=dwengo diff --git a/backend/.env.production.example b/backend/.env.production.example index 4f36cf53..aa380ee0 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -35,3 +35,7 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/op DWENGO_LOGGING_LEVEL=info DWENGO_LOGGING_LOKI_HOST=http://logging:3102 + +DWENGO_CACHE_HOST=caching +#DWENGO_CACHE_PORT=11211 +DWENGO_CACHE_TTL=604800 diff --git a/backend/.env.staging b/backend/.env.staging index bedfb0b7..52e98b48 100644 --- a/backend/.env.staging +++ b/backend/.env.staging @@ -1,4 +1,6 @@ PORT=3000 +DWENGO_RUN_MODE=staging + DWENGO_DB_HOST=db DWENGO_DB_PORT=5432 DWENGO_DB_USERNAME=postgres @@ -18,4 +20,8 @@ DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080 # Logging and monitoring -LOKI_HOST=http://logging:3102 +DWENGO_LOGGING_LEVEL=debug +DWENGO_LOGGING_LOKI_HOST=http://logging:3102 + +DWENGO_CACHE_HOST=caching +DWENGO_CACHE_TTL=86400 diff --git a/backend/.env.test b/backend/.env.test index e76cbe2b..b4cdca4f 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -10,6 +10,7 @@ ### Dwengo ### DWENGO_PORT=3000 +DWENGO_RUN_MODE=test DWENGO_DB_NAME=":memory:" DWENGO_DB_UPDATE=true diff --git a/backend/Dockerfile b/backend/Dockerfile index 1d82a484..a7aaa6b3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -27,6 +27,13 @@ FROM node:22 AS production-stage WORKDIR /app/dwengo +COPY package*.json ./ +COPY backend/package.json ./backend/ +# Backend depends on common +COPY common/package.json ./common/ + +RUN npm install --silent --only=production + # Copy static files COPY ./backend/i18n ./i18n @@ -37,15 +44,6 @@ COPY --from=build-stage /app/dwengo/common/dist ./common/dist COPY --from=build-stage /app/dwengo/backend/dist ./backend/dist COPY --from=build-stage /app/dwengo/docs/api/swagger.json ./docs/api/swagger.json -COPY package*.json ./ -COPY backend/package.json ./backend/ -# Backend depends on common -COPY common/package.json ./common/ - -RUN npm install --silent --only=production - -COPY ./backend/i18n ./backend/i18n - EXPOSE 3000 CMD ["node", "--env-file=/app/dwengo/backend/.env", "/app/dwengo/backend/dist/app.js"] diff --git a/backend/package.json b/backend/package.json index 7943d61d..b0264ea8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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,8 +38,12 @@ "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", + "memjs": "^1.3.2", + "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" @@ -47,10 +52,14 @@ "@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/memjs": "^1.3.3", + "@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", diff --git a/backend/src/caching.ts b/backend/src/caching.ts new file mode 100644 index 00000000..b0591796 --- /dev/null +++ b/backend/src/caching.ts @@ -0,0 +1,44 @@ +import { getLogger } from './logging/initalize.js'; +import { envVars, getEnvVar } from './util/envVars.js'; +import { Client } from 'memjs'; + +export type CacheClient = Client; + +let cacheClient: CacheClient; + +async function initializeClient(): Promise { + if (cacheClient !== undefined) { + return cacheClient; + } + + const cachingHost = getEnvVar(envVars.CacheHost); + const cachingPort = getEnvVar(envVars.CachePort); + const cachingUrl = `${cachingHost}:${cachingPort}`; + + if (cachingHost === '') { + return cacheClient; + } + + cacheClient = Client.create(cachingUrl); + + getLogger().info(`Memcached client initialized at ${cachingUrl}`); + + return cacheClient; +} + +export async function getCacheClient(): Promise { + cacheClient ||= await initializeClient(); + return cacheClient; +} + +export async function checkCachingHealth(): Promise { + try { + const client = await getCacheClient(); + await client.set('health', Buffer.from('ok'), { expires: 60 }); + const reply = await cacheClient.get('health'); + return reply?.value?.toString() === 'ok'; + } catch (error) { + getLogger().error('Caching Health Check Failed:', error); + return false; + } +} diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 49e2159b..0a249c5b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -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 { 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 { diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 83aa33f9..e4318efa 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -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 { - 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 { @@ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise } res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } + +export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise { + 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 { + 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'); + } +} diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 86d2af76..69408df7 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -7,66 +7,116 @@ 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'; -import { LearningPath } from '@dwengo-1/common/interfaces/learning-content'; +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: AuthenticatedRequest, 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 apiLearningPathResponse = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup); - const apiLearningPaths: LearningPath[] = apiLearningPathResponse.data || []; - let allLearningPaths: LearningPath[] = apiLearningPaths; + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; - if (req.auth) { - const adminUsername = req.auth.username; - const userLearningPaths = await learningPathService.searchLearningPathsByAdmin([adminUsername], language as Language, forGroup) || []; - allLearningPaths = apiLearningPaths.concat(userLearningPaths); + 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; + } } - res.json(allLearningPaths); - return; - } + let hruidList; - const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); - res.json(learningPaths.data); + 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 apiLearningPathResponse = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup); + const apiLearningPaths: LearningPath[] = apiLearningPathResponse.data || []; + let allLearningPaths: LearningPath[] = apiLearningPaths; + + if (req.auth) { + const adminUsername = req.auth.username; + const userLearningPaths = await learningPathService.searchLearningPathsByAdmin([adminUsername], language as Language, forGroup) || []; + allLearningPaths = apiLearningPaths.concat(userLearningPaths); + } + + res.json(allLearningPaths); + return; + } + + 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 = 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 { + 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.'); + } } diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts index 229cff7e..e4c49683 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -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 }); } diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts index a117d7bf..012636ea 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -62,6 +62,11 @@ export async function getAllSubmissionsHandler(req: Request, res: Response): Pro // TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden export async function createSubmissionHandler(req: Request, res: Response): Promise { + const submitter = req.body.submitter; + const usernameSubmitter = req.body.submitter.username; + const group = req.body.group; + requireFields({ group, submitter, usernameSubmitter }); + const submissionDTO = req.body as SubmissionDTO; const submission = await createSubmission(submissionDTO); diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts index 932bb1af..9e8eee6e 100644 --- a/backend/src/controllers/teacher-invitations.ts +++ b/backend/src/controllers/teacher-invitations.ts @@ -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 { 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); diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts index c8063f80..6d8ab0bc 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -7,7 +7,6 @@ import { getJoinRequestsByClass, getStudentsByTeacher, getTeacher, - getTeacherQuestions, updateClassJoinRequestStatus, } from '../services/teachers.js'; import { requireFields } from './error-helper.js'; @@ -70,16 +69,6 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro res.json({ students }); } -export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { - const username = req.params.username; - const full = req.query.full === 'true'; - requireFields({ username }); - - const questions = await getTeacherQuestions(username, full); - - res.json({ questions }); -} - export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise { const classId = req.params.classId; requireFields({ classId }); diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index 889a1594..e793b991 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Language } from '@dwengo-1/common/util/language'; -import { Teacher } from '../../entities/users/teacher.entity.js'; export class LearningObjectRepository extends DwengoEntityRepository { public async findByIdentifier(identifier: LearningObjectIdentifier): Promise { @@ -13,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository { + public async findAllByAdmin(adminUsername: string): Promise { return this.find( - { admins: teacher }, + { + admins: { + username: adminUsername, + }, + }, { populate: ['admins'] } // Make sure to load admin relations ); } + + public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise { + const learningObject = await this.findByIdentifier(identifier); + if (learningObject) { + await this.em.removeAndFlush(learningObject); + } + return learningObject; + } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 546fe404..ea42cab9 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -5,12 +5,14 @@ import { MatchMode } from '@dwengo-1/common/util/match-mode'; 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'; import { Teacher } from '../../entities/users/teacher.entity'; export class LearningPathRepository extends DwengoEntityRepository { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { - return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); + return this.findOne({ + hruid: hruid, + language: language, + }, { populate: ['nodes', 'nodes.transitions', 'admins'] }); } public async findByAdmins(admins: Teacher[], language: Language, _matchMode?: MatchMode): Promise { @@ -38,7 +40,21 @@ export class LearningPathRepository extends DwengoEntityRepository 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 { + return this.findAll({ + where: { + admins: { + username: adminUsername, + }, + }, + populate: ['nodes', 'nodes.transitions', 'admins'], }); } @@ -50,18 +66,15 @@ export class LearningPathRepository extends DwengoEntityRepository return this.em.create(LearningPathTransition, transitionData); } - public async saveLearningPathNodesAndTransitions( - path: LearningPath, - nodes: LearningPathNode[], - transitions: LearningPathTransition[], - options?: { preventOverwrite?: boolean } - ): Promise { - 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 { + 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; } } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 4900ba82..6a3c0ead 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -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 { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index a12ffbac..88c3160f 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -26,6 +26,9 @@ export class Assignment { @Property({ type: 'string' }) learningPathHruid!: string; + @Property({ type: 'datetime', nullable: true }) + deadline?: Date; + @Enum({ items: () => Language, }) diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index b2c59ade..5bedf560 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -1,15 +1,17 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; -import { v4 } from 'uuid'; import { Teacher } from '../users/teacher.entity.js'; import { Student } from '../users/student.entity.js'; import { ClassRepository } from '../../data/classes/class-repository.js'; +import { customAlphabet } from 'nanoid'; + +const generateClassId = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6); @Entity({ repository: () => ClassRepository, }) export class Class { @PrimaryKey() - classId? = v4(); + classId? = generateClassId(); @Property({ type: 'string' }) displayName!: string; diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts index 80104f28..99901495 100644 --- a/backend/src/entities/content/attachment.entity.ts +++ b/backend/src/entities/content/attachment.entity.ts @@ -9,6 +9,7 @@ export class Attachment { @ManyToOne({ entity: () => LearningObject, primary: true, + deleteRule: 'cascade', }) learningObject!: LearningObject; diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index e0ae09d6..1d129b9c 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -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 = new Collection(this); @Property({ type: 'string' }) title!: string; @@ -84,7 +84,7 @@ export class LearningObject { entity: () => Attachment, mappedBy: 'learningObject', }) - attachments: Attachment[] = []; + attachments: Collection = new Collection(this); @Property({ type: 'blob' }) content!: Buffer; diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts index fd870dcd..08543d22 100644 --- a/backend/src/entities/content/learning-path-node.entity.ts +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -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; @Property({ length: 3 }) diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 1b96d8ea..36e13766 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -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 = new Collection(this); } diff --git a/backend/src/interfaces/assignment.ts b/backend/src/interfaces/assignment.ts index 7c5a0909..2dc158d2 100644 --- a/backend/src/interfaces/assignment.ts +++ b/backend/src/interfaces/assignment.ts @@ -20,6 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { description: assignment.description, learningPath: assignment.learningPathHruid, language: assignment.learningPathLanguage, + deadline: assignment.deadline ?? new Date(), groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), }; } @@ -31,6 +32,7 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi description: assignmentData.description, learningPathHruid: assignmentData.learningPath, learningPathLanguage: languageMap[assignmentData.language], + deadline: assignmentData.deadline, groups: [], }); } diff --git a/backend/src/interfaces/user.ts b/backend/src/interfaces/user.ts index f4413b5e..3084c494 100644 --- a/backend/src/interfaces/user.ts +++ b/backend/src/interfaces/user.ts @@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO { }; } +export function mapToUsername(user: { username: string }): string { + return user.username; +} + export function mapToUser(userData: UserDTO, userInstance: T): T { userInstance.username = userData.username; userInstance.firstName = userData.firstName; diff --git a/backend/src/logging/initalize.ts b/backend/src/logging/initalize.ts index f89518c4..463a4627 100644 --- a/backend/src/logging/initalize.ts +++ b/backend/src/logging/initalize.ts @@ -29,7 +29,8 @@ function initializeLogger(): Logger { format: format.combine(format.cli(), format.simple()), }); - if (getEnvVar(envVars.RunMode) === 'dev') { + const runMode = getEnvVar(envVars.RunMode); + if (runMode === 'dev' || runMode.includes('test')) { logger = createLogger({ transports: [consoleTransport], }); diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index 73a65b9a..24be4825 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -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'); diff --git a/backend/src/middleware/auth/authenticated-request.d.ts b/backend/src/middleware/auth/authenticated-request.d.ts index 9737fa7e..af7630af 100644 --- a/backend/src/middleware/auth/authenticated-request.d.ts +++ b/backend/src/middleware/auth/authenticated-request.d.ts @@ -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 = Record, +> extends Request { // Properties are optional since the user is not necessarily authenticated. jwtPayload?: JwtPayload; auth?: AuthenticationInfo; diff --git a/backend/src/middleware/auth/checks/assignment-auth-checks.ts b/backend/src/middleware/auth/checks/assignment-auth-checks.ts new file mode 100644 index 00000000..bd9f51d7 --- /dev/null +++ b/backend/src/middleware/auth/checks/assignment-auth-checks.ts @@ -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)); +}); diff --git a/backend/src/middleware/auth/checks/auth-checks.ts b/backend/src/middleware/auth/checks/auth-checks.ts new file mode 100644 index 00000000..bf4891a3 --- /dev/null +++ b/backend/src/middleware/auth/checks/auth-checks.ts @@ -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>( + accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest) => boolean | Promise +): RequestHandler { + // Bypass authentication during testing + if (getEnvVar(envVars.RunMode) === 'test') { + return async ( + _req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + next(); + }; + } + + return async ( + req: AuthenticatedRequest, + _res: express.Response, + next: express.NextFunction + ): Promise => { + 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); diff --git a/backend/src/middleware/auth/checks/class-auth-checks.ts b/backend/src/middleware/auth/checks/class-auth-checks.ts new file mode 100644 index 00000000..ea75d21d --- /dev/null +++ b/backend/src/middleware/auth/checks/class-auth-checks.ts @@ -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 { + 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); +}); diff --git a/backend/src/middleware/auth/checks/group-auth-checker.ts b/backend/src/middleware/auth/checks/group-auth-checker.ts new file mode 100644 index 00000000..563edf57 --- /dev/null +++ b/backend/src/middleware/auth/checks/group-auth-checker.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/learning-content-auth-checks.ts b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts new file mode 100644 index 00000000..6942b425 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-content-auth-checks.ts @@ -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; +}); diff --git a/backend/src/middleware/auth/checks/learning-object-auth-checks.ts b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts new file mode 100644 index 00000000..9bf94799 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-object-auth-checks.ts @@ -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); +}); 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..3668ab07 --- /dev/null +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/question-checks.ts b/backend/src/middleware/auth/checks/question-checks.ts new file mode 100644 index 00000000..76ede049 --- /dev/null +++ b/backend/src/middleware/auth/checks/question-checks.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/submission-checks.ts b/backend/src/middleware/auth/checks/submission-checks.ts new file mode 100644 index 00000000..893371c2 --- /dev/null +++ b/backend/src/middleware/auth/checks/submission-checks.ts @@ -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); +}); diff --git a/backend/src/middleware/auth/checks/teacher-invitation-checks.ts b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts new file mode 100644 index 00000000..0c6a790f --- /dev/null +++ b/backend/src/middleware/auth/checks/teacher-invitation-checks.ts @@ -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 +); diff --git a/backend/src/middleware/auth/checks/user-auth-checks.ts b/backend/src/middleware/auth/checks/user-auth-checks.ts new file mode 100644 index 00000000..27228369 --- /dev/null +++ b/backend/src/middleware/auth/checks/user-auth-checks.ts @@ -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); diff --git a/backend/src/orm.ts b/backend/src/orm.ts index 1cb6babf..424ec15d 100644 --- a/backend/src/orm.ts +++ b/backend/src/orm.ts @@ -17,7 +17,7 @@ export async function initORM(testingMode = false): Promise { - 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; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index cef6fd72..8a35eb2a 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -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); diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index 3043c23b..e8cb4c2d 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -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; diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index 7532765b..cae56b88 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -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; diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index efe17312..8f2f1249 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 { 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; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts index 5135c197..6cad3c01 100644 --- a/backend/src/routes/questions.ts +++ b/backend/src/routes/questions.ts @@ -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); diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index 99d4312c..ae141913 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -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; diff --git a/backend/src/routes/student-join-requests.ts b/backend/src/routes/student-join-requests.ts index daf79f09..a49984c7 100644 --- a/backend/src/routes/student-join-requests.ts +++ b/backend/src/routes/student-join-requests.ts @@ -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; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 0f5d5349..9ecf4688 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -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); diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index fc0aa7c6..88309ce8 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -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; diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts index 23b943d0..90117088 100644 --- a/backend/src/routes/teacher-invitations.ts +++ b/backend/src/routes/teacher-invitations.ts @@ -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; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index 44d3064b..cb2405aa 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -6,32 +6,31 @@ import { getStudentJoinRequestHandler, getTeacherClassHandler, getTeacherHandler, - getTeacherQuestionHandler, getTeacherStudentHandler, 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/questions', getTeacherQuestionHandler); +router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); -router.get('/:username/joinRequests/:classId', 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); diff --git a/backend/src/routes/themes.ts b/backend/src/routes/themes.ts index b135d44f..6310c2ab 100644 --- a/backend/src/routes/themes.ts +++ b/backend/src/routes/themes.ts @@ -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; diff --git a/backend/src/services/answers.ts b/backend/src/services/answers.ts index ab603883..7ec5773a 100644 --- a/backend/src/services/answers.ts +++ b/backend/src/services/answers.ts @@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat return mapToAnswerDTO(answer); } -async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { +export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { const answerRepository = getAnswerRepository(); const question = await fetchQuestion(questionId); const answer = await answerRepository.findAnswer(question, sequenceNumber); diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index b75fe82f..e5026020 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou return group; } +export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise { + 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 { const group = await fetchGroup(classId, assignmentNumber, groupNumber); return mapToGroupDTO(group, group.assignment.within); diff --git a/backend/src/services/learning-objects.ts b/backend/src/services/learning-objects.ts index 089fd25a..cd7c8785 100644 --- a/backend/src/services/learning-objects.ts +++ b/backend/src/services/learning-objects.ts @@ -1,5 +1,5 @@ import { DWENGO_API_BASE } from '../config.js'; -import { fetchWithLogging } from '../util/api-helper.js'; +import { fetchRemote } from '../util/api-helper.js'; import { FilteredLearningObject, @@ -39,10 +39,7 @@ function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLear */ export async function getLearningObjectById(hruid: string, language: string): Promise { const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; - const metadata = await fetchWithLogging( - metadataUrl, - `Metadata for Learning Object HRUID "${hruid}" (language ${language})` - ); + const metadata = await fetchRemote(metadataUrl, `Metadata for Learning Object HRUID "${hruid}" (language ${language})`); if (!metadata) { getLogger().error(`⚠️ WARNING: Learning object "${hruid}" not found.`); diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 0b805a56..fb0f70fe 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -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 { + const learningObjectRepo = getLearningObjectRepository(); + const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername); + return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null); + }, }; export default databaseLearningObjectProvider; diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index 4a4bdc54..500dad61 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -1,5 +1,5 @@ import { DWENGO_API_BASE } from '../../config.js'; -import { fetchWithLogging } from '../../util/api-helper.js'; +import { fetchRemote } from '../../util/api-helper.js'; import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; import { LearningObjectProvider } from './learning-object-provider.js'; import { getLogger, Logger } from '../../logging/initalize.js'; @@ -88,7 +88,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { */ async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise { const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; - const metadata = await fetchWithLogging( + const metadata = await fetchRemote( metadataUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { @@ -124,7 +124,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { */ async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; - const html = await fetchWithLogging(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { + const html = await fetchRemote(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { params: { ...id }, }); @@ -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 { + return []; // The dwengo database does not contain any learning objects administrated by users. + }, }; export default dwengoApiLearningObjectProvider; diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts index 14848bc0..69ad268d 100644 --- a/backend/src/services/learning-objects/learning-object-provider.ts +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -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; + + /** + * Obtain all learning object who have the user with the given username as an admin. + */ + getLearningObjectsAdministratedBy(username: string): Promise; } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 7b4f47fc..6be10775 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -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 { return getProvider(id).getLearningObjectHTML(id); }, + + /** + * Obtain all learning objects administrated by the user with the given username. + */ + async getLearningObjectsAdministratedBy(adminUsername: string): Promise { + 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 { + 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 { + 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 { + 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; diff --git a/backend/src/services/learning-objects/learning-object-zip-processing-service.ts b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts new file mode 100644 index 00000000..9b9c4d9f --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-zip-processing-service.ts @@ -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 { + 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 { + const buf = await file.buffer(); + const content = buf.toString(); + return JSON.parse(content); +} + +async function processFile(file: unzipper.File): Promise { + return await file.buffer(); +} 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 21c096e1..a3bd5bf3 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js'; import learningObjectService from '../learning-objects/learning-object-service.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; -import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; +import { getLastSubmissionForGroup, idFromLearningPathNode, isTransitionPossible } from './learning-path-personalization-util.js'; import { FilteredLearningObject, LearningObjectNode, @@ -17,8 +17,11 @@ import { MatchMode } from '@dwengo-1/common/util/match-mode'; import { Group } from '../../entities/assignments/group.entity'; import { Collection } from '@mikro-orm/core'; import { v4 } from 'uuid'; +import { getLogger } from '../../logging/initalize.js'; import { Teacher } from '../../entities/users/teacher.entity'; +const logger = getLogger(); + /** * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its * corresponding learning object. @@ -40,8 +43,13 @@ async function getLearningObjectsForNodes(nodes: Collection): ), ), ); - 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; } @@ -97,14 +105,22 @@ async function convertNode( personalizedFor: Group | undefined, nodesToLearningObjects: Map, ): Promise { - const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; + const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningPathNode(node), personalizedFor) : null; const transitions = node.transitions .filter( (trans) => !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, @@ -166,6 +182,7 @@ function convertTransition( return { _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path. default: false, // We don't work with default transitions but retain this for backwards compatibility. + condition: transition.condition, next: { _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility. hruid: transition.next.learningObjectHruid, @@ -200,6 +217,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 551feda4..4c57f055 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 @@ -1,15 +1,37 @@ -import { fetchWithLogging } from '../../util/api-helper.js'; +import { fetchRemote } from '../../util/api-helper.js'; import { DWENGO_API_BASE } from '../../config.js'; import { LearningPathProvider } from './learning-path-provider.js'; import { getLogger, Logger } from '../../logging/initalize.js'; import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { Teacher } from '../../entities/users/teacher.entity'; import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { getLastSubmissionForGroup, idFromLearningObjectNode } from './learning-path-personalization-util.js'; const logger: Logger = getLogger(); +/** + * Adds progress information to the learning path. Modifies the learning path in-place. + * @param learningPath The learning path to add progress to. + * @param personalizedFor The group whose progress should be shown. + * @returns the modified learning path. + */ +async function addProgressToLearningPath(learningPath: LearningPath, personalizedFor: Group): Promise { + await Promise.all( + learningPath.nodes.map(async (node) => { + const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningObjectNode(node), personalizedFor) : null; + node.done = Boolean(lastSubmission); + }) + ); + + learningPath.num_nodes = learningPath.nodes.length; + learningPath.num_nodes_left = learningPath.nodes.filter((it) => !it.done).length; + + return learningPath; +} + const dwengoApiLearningPathProvider: LearningPathProvider = { - async fetchLearningPaths(hruids: string[], language: string, source: string): Promise { + async fetchLearningPaths(hruids: string[], language: string, source: string, personalizedFor: Group): Promise { if (hruids.length === 0) { return { success: false, @@ -22,7 +44,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; const params = { pathIdList: JSON.stringify({ hruids }), language }; - const learningPaths = await fetchWithLogging(apiUrl, `Learning paths for ${source}`, { params }); + const learningPaths = await fetchRemote(apiUrl, `Learning paths for ${source}`, { params }); if (!learningPaths || learningPaths.length === 0) { logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); @@ -34,6 +56,8 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { }; } + await Promise.all(learningPaths?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); + return { success: true, source, @@ -41,14 +65,22 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { }; }, - async searchLearningPaths(query: string, language: string): Promise { + async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise { const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; const params = { all: query, language }; - const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); + const searchResults = await fetchRemote(apiUrl, `Search learning paths with query "${query}"`, { params }); + + if (searchResults) { + await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); + } return searchResults ?? []; }, + async getLearningPathsAdministratedBy(_adminUsername: string) { + return []; // Learning paths fetched from the Dwengo API cannot be administrated by a user. + }, + async searchLearningPathsByAdmin(admins: Teacher[], language: string): Promise { if (!admins || admins.length === 0) { return this.searchLearningPaths('', language as Language); diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts index a10d5ead..7651baa3 100644 --- a/backend/src/services/learning-paths/learning-path-personalization-util.ts +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -5,18 +5,36 @@ import { getSubmissionRepository } from '../../data/repositories.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { JSONPath } from 'jsonpath-plus'; +import { LearningObjectNode } from '@dwengo-1/common/interfaces/learning-content'; /** * Returns the last submission for the learning object associated with the given node and for the group */ -export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise { +export async function getLastSubmissionForGroup(learningObjectId: LearningObjectIdentifier, pathFor: Group): Promise { const submissionRepo = getSubmissionRepository(); - const learningObjectId: LearningObjectIdentifier = { + return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); +} + +/** + * Creates a LearningObjectIdentifier describing the specified node. + */ +export function idFromLearningObjectNode(node: LearningObjectNode): LearningObjectIdentifier { + return { + hruid: node.learningobject_hruid, + language: node.language, + version: node.version, + }; +} + +/** + * Creates a LearningObjectIdentifier describing the specified node. + */ +export function idFromLearningPathNode(node: LearningPathNode): LearningObjectIdentifier { + return { hruid: node.learningObjectHruid, language: node.language, version: node.version, }; - return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor); } /** diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 72182db3..c781f134 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -18,6 +18,11 @@ export interface LearningPathProvider { */ 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; + /** * Fetch the learning paths for the given admins from the data source. */ diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index 68051bb2..35c46704 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -1,7 +1,12 @@ 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 +17,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'; import { Teacher } from '../../entities/users/teacher.entity'; const userContentPrefix = getEnvVar(envVars.UserContentPrefix); @@ -44,27 +52,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(fromNode, transitions); }); @@ -106,6 +111,14 @@ 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. */ @@ -136,11 +149,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 { + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { 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 { + 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); }, }; diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts index 09643cd2..c6d978d8 100644 --- a/backend/src/services/questions.ts +++ b/backend/src/services/questions.ts @@ -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, }); diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts index 77ec6648..3ccd2dba 100644 --- a/backend/src/services/students.ts +++ b/backend/src/services/students.ts @@ -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 { const studentRepository = getStudentRepository(); @@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchStudent(username: string): Promise { @@ -42,7 +43,7 @@ export async function fetchStudent(username: string): Promise { const user = await studentRepository.findByUsername(username); if (!user) { - throw new NotFoundException('Student with username not found'); + throw new NotFoundException(`Student with username ${username} not found`); } return user; @@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise { const newStudent = mapToStudent(userData); await studentRepository.save(newStudent, { preventOverwrite: true }); - return userData; + return mapToStudentDTO(newStudent); } export async function createOrUpdateStudent(userData: StudentDTO): Promise { diff --git a/backend/src/services/teacher-invitations.ts b/backend/src/services/teacher-invitations.ts index aead8715..0457496f 100644 --- a/backend/src/services/teacher-invitations.ts +++ b/backend/src/services/teacher-invitations.ts @@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); @@ -38,7 +27,7 @@ export async function getAllTeachers(full: boolean): Promise user.username); + return users.map(mapToUsername); } export async function fetchTeacher(username: string): Promise { @@ -57,7 +46,8 @@ export async function getTeacher(username: string): Promise { return mapToTeacherDTO(user); } -export async function createTeacher(userData: TeacherDTO): Promise { +// TODO update parameter +export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise { const teacherRepository: TeacherRepository = getTeacherRepository(); const newTeacher = mapToTeacher(userData); @@ -110,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; @@ -119,28 +111,6 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro return students.map((student) => student.username); } -export async function getTeacherQuestions(username: string, full: boolean): Promise { - const teacher: Teacher = await fetchTeacher(username); - - // Find all learning objects that this teacher manages - const learningObjectRepository: LearningObjectRepository = getLearningObjectRepository(); - const learningObjects: LearningObject[] = await learningObjectRepository.findAllByTeacher(teacher); - - if (!learningObjects || learningObjects.length === 0) { - return []; - } - - // Fetch all questions related to these learning objects - const questionRepository: QuestionRepository = getQuestionRepository(); - const questions: Question[] = await questionRepository.findAllByLearningObjects(learningObjects); - - if (full) { - return questions.map(mapToQuestionDTO); - } - - return questions.map(mapToQuestionDTOId); -} - export async function getJoinRequestsByClass(classId: string): Promise { const classRepository: ClassRepository = getClassRepository(); const cls: Class | null = await classRepository.findById(classId); diff --git a/backend/src/util/api-helper.ts b/backend/src/util/api-helper.ts index af36532d..e0c42e3a 100644 --- a/backend/src/util/api-helper.ts +++ b/backend/src/util/api-helper.ts @@ -1,28 +1,66 @@ import axios, { AxiosRequestConfig } from 'axios'; import { getLogger, Logger } from '../logging/initalize.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { getCacheClient } from '../caching.js'; +import { envVars, getEnvVar, getNumericEnvVar } from './envVars.js'; +import { createHash } from 'crypto'; +const cacheClient = await getCacheClient(); const logger: Logger = getLogger(); +const runMode: string = getEnvVar(envVars.RunMode); +const prefix: string = getEnvVar(envVars.CacheKeyPrefix); + +interface Options { + params?: Record | LearningObjectIdentifier; + query?: Record; + responseType?: 'json' | 'text'; +} /** - * Utility function to fetch data from an API endpoint with error handling. + * Utility function to fetch data from an API endpoint with error handling and caching. * Logs errors but does NOT throw exceptions to keep the system running. * * @param url The API endpoint to fetch from. * @param description A short description of what is being fetched (for logging). * @param options Contains further options such as params (the query params) and responseType (whether the response * should be parsed as JSON ("json") or whether it should be returned as plain text ("text") + * @param cacheTTL Time-to-live for the cache in seconds (default: 60 seconds). * @returns The response data if successful, or null if an error occurs. */ -export async function fetchWithLogging( - url: string, - description: string, - options?: { - params?: Record | LearningObjectIdentifier; - query?: Record; - responseType?: 'json' | 'text'; +export async function fetchRemote(url: string, description: string, options?: Options, cacheTTL?: number): Promise { + if (runMode !== 'dev' && !runMode.includes('test') && cacheClient !== undefined) { + return fetchWithCache(url, description, options, cacheTTL); } -): Promise { + + getLogger().debug(`🔄 INFO: Bypassing cache for ${description} at "${url}".`); + return fetchWithLogging(url, description, options); +} + +async function fetchWithCache(url: string, description: string, options?: Options, cacheTTL?: number): Promise { + // Combine the URL and parameters to create a unique cache key. + // NOTE Using a hash function to keep the key short, since Memcached has a limit on key size + const urlWithParams = `${url}${options?.params ? JSON.stringify(options.params) : ''}`; + const hashedUrl = createHash('sha256').update(urlWithParams).digest('hex'); + const key = `${prefix}:${hashedUrl}`; + + const cachedData = await cacheClient.get(key); + + if (cachedData?.value) { + logger.debug(`✅ INFO: Cache hit for ${description} at "${url}" (key: "${key}")`); + return JSON.parse(cachedData.value.toString()) as T; + } + + logger.debug(`🔄 INFO: Cache miss for ${description} at "${url}". Fetching data...`); + const response = await fetchWithLogging(url, description, options); + + const ttl = cacheTTL || getNumericEnvVar(envVars.CacheTTL); + await cacheClient.set(key, JSON.stringify(response), { expires: ttl }); + logger.debug(`✅ INFO: Cached response for ${description} at "${url}" for ${ttl} seconds. (key: "${key}")`); + + return response; +} + +async function fetchWithLogging(url: string, description: string, options?: Options): Promise { try { const config: AxiosRequestConfig = options || {}; const response = await axios.get(url, config); diff --git a/backend/src/util/envVars.ts b/backend/src/util/envVars.ts index d5bf9fcf..80e7bed4 100644 --- a/backend/src/util/envVars.ts +++ b/backend/src/util/envVars.ts @@ -5,6 +5,7 @@ const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_'; const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_'; const CORS_PREFIX = PREFIX + 'CORS_'; const LOGGING_PREFIX = PREFIX + 'LOGGING_'; +const CACHE_PREFIX = PREFIX + 'CACHE_'; interface EnvVar { key: string; @@ -39,6 +40,11 @@ export const envVars: Record = { LogLevel: { key: LOGGING_PREFIX + 'LEVEL', defaultValue: 'info' }, LokiHost: { key: LOGGING_PREFIX + 'LOKI_HOST', defaultValue: 'http://localhost:3102' }, + + CacheHost: { key: CACHE_PREFIX + 'HOST' }, + CachePort: { key: CACHE_PREFIX + 'PORT', defaultValue: 11211 }, + CacheTTL: { key: CACHE_PREFIX + 'TTL', defaultValue: 60 * 60 * 24 }, // 24 hours + CacheKeyPrefix: { key: CACHE_PREFIX + 'KEY_PREFIX', defaultValue: 'dwengo' }, } as const; /** @@ -56,7 +62,7 @@ export function getEnvVar(envVar: EnvVar): string { } else if (envVar.required) { throw new Error(`Missing environment variable: ${envVar.key}`); } else { - return String(envVar.defaultValue) || ''; + return envVar.defaultValue !== undefined ? String(envVar.defaultValue) || '' : ''; } } diff --git a/backend/tests/controllers/answers.test.ts b/backend/tests/controllers/answers.test.ts index 9eac6c14..bf7b1456 100644 --- a/backend/tests/controllers/answers.test.ts +++ b/backend/tests/controllers/answers.test.ts @@ -5,7 +5,6 @@ import { Language } from '@dwengo-1/common/util/language'; import { getAllAnswersHandler, getAnswerHandler, updateAnswerHandler } from '../../src/controllers/answers'; import { BadRequestException } from '../../src/exceptions/bad-request-exception'; import { NotFoundException } from '../../src/exceptions/not-found-exception'; -import { getQuestion02 } from '../test_assets/questions/questions.testdata'; import { getAnswer02 } from '../test_assets/questions/answers.testdata'; describe('Questions controllers', () => { diff --git a/backend/tests/controllers/assignments.test.ts b/backend/tests/controllers/assignments.test.ts new file mode 100644 index 00000000..88cac366 --- /dev/null +++ b/backend/tests/controllers/assignments.test.ts @@ -0,0 +1,76 @@ +import { setupTestApp } from '../setup-tests.js'; +import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; +import { Request, Response } from 'express'; +import { getAssignmentHandler, getAllAssignmentsHandler, getAssignmentsSubmissionsHandler } from '../../src/controllers/assignments.js'; +import { NotFoundException } from '../../src/exceptions/not-found-exception'; +import { getClass01 } from '../test_assets/classes/classes.testdata'; +import { getAssignment01 } from '../test_assets/assignments/assignments.testdata'; + +function createRequestObject( + classid: string, + assignmentid: string +): { + query: { full: string }; + params: { classid: string; id: string }; +} { + return { + params: { + classid: classid, + id: assignmentid, + }, + query: { + full: 'true', + }, + }; +} + +describe('Assignment controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + let statusMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(async () => { + jsonMock = vi.fn(); + statusMock = vi.fn().mockReturnThis(); + + res = { + json: jsonMock, + status: statusMock, + }; + }); + + it('return error non-existing assignment', async () => { + req = createRequestObject('doesnotexist', '43000'); // Should not exist + + await expect(async () => getAssignmentHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return an assignment', async () => { + const assignment = getAssignment01(); + req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString()); + + await getAssignmentHandler(req as Request, res as Response); + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignment: expect.anything() })); + }); + + it('should return a list of assignments', async () => { + req = createRequestObject(getClass01().classId as string, 'irrelevant'); + + await getAllAssignmentsHandler(req as Request, res as Response); + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() })); + }); + + it('should return a list of submissions for an assignment', async () => { + const assignment = getAssignment01(); + req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString()); + + await getAssignmentsSubmissionsHandler(req as Request, res as Response); + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() })); + }); +}); diff --git a/backend/tests/controllers/classes.test.ts b/backend/tests/controllers/classes.test.ts new file mode 100644 index 00000000..d9614a3b --- /dev/null +++ b/backend/tests/controllers/classes.test.ts @@ -0,0 +1,123 @@ +import { setupTestApp } from '../setup-tests.js'; +import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; +import { + createClassHandler, + deleteClassHandler, + getAllClassesHandler, + getClassHandler, + getClassStudentsHandler, + getTeacherInvitationsHandler, +} from '../../src/controllers/classes.js'; +import { Request, Response } from 'express'; +import { NotFoundException } from '../../src/exceptions/not-found-exception'; +import { BadRequestException } from '../../src/exceptions/bad-request-exception'; +import { getClass01 } from '../test_assets/classes/classes.testdata'; +describe('Class controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + let statusMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(async () => { + jsonMock = vi.fn(); + statusMock = vi.fn().mockReturnThis(); + + res = { + json: jsonMock, + status: statusMock, + }; + }); + + it('create and delete class', async () => { + req = { + body: { displayName: 'coole_nieuwe_klas' }, + }; + + await createClassHandler(req as Request, res as Response); + + const result = jsonMock.mock.lastCall?.[0]; + // Console.log('class', result.class); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() })); + + req = { + params: { id: result.class.id }, + }; + + await deleteClassHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() })); + }); + + it('Error class not found', async () => { + req = { + params: { id: 'doesnotexist' }, + }; + + await expect(async () => getClassHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('Error create a class without name', async () => { + req = { + body: {}, + }; + + await expect(async () => createClassHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException); + }); + + it('return list of students', async () => { + req = { + params: { id: getClass01().classId as string }, + query: {}, + }; + + await getClassStudentsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ students: expect.anything() })); + }); + + it('Error students on a non-existent class', async () => { + req = { + params: { id: 'doesnotexist' }, + query: {}, + }; + + await expect(async () => getClassStudentsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return 200 and a list of teacher-invitations', async () => { + const classId = getClass01().classId as string; + req = { + params: { id: classId }, + query: {}, + }; + + await getTeacherInvitationsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() })); + }); + + it('Error teacher-invitations on a non-existent class', async () => { + req = { + params: { id: 'doesnotexist' }, + query: {}, + }; + + await expect(async () => getTeacherInvitationsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return a list of classes', async () => { + req = { + query: {}, + }; + + await getAllClassesHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ classes: expect.anything() })); + }); +}); diff --git a/backend/tests/controllers/groups.test.ts b/backend/tests/controllers/groups.test.ts new file mode 100644 index 00000000..f9e35cea --- /dev/null +++ b/backend/tests/controllers/groups.test.ts @@ -0,0 +1,140 @@ +import { setupTestApp } from '../setup-tests.js'; +import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; +import { Request, Response } from 'express'; +import { + createGroupHandler, + deleteGroupHandler, + getAllGroupsHandler, + getGroupHandler, + getGroupSubmissionsHandler, +} from '../../src/controllers/groups.js'; +import { NotFoundException } from '../../src/exceptions/not-found-exception'; +import { getClass01 } from '../test_assets/classes/classes.testdata'; +import { getAssignment01, getAssignment02 } from '../test_assets/assignments/assignments.testdata'; +import { getTestGroup01 } from '../test_assets/assignments/groups.testdata'; + +function createRequestObject( + classid: string, + assignmentid: string, + groupNumber: string +): { + query: { full: string }; + params: { classid: string; groupid: string; assignmentid: string }; +} { + return { + params: { + classid: classid, + assignmentid: assignmentid, + groupid: groupNumber, + }, + query: { + full: 'true', + }, + }; +} + +describe('Group controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + let statusMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(async () => { + jsonMock = vi.fn(); + statusMock = vi.fn().mockReturnThis(); + + res = { + json: jsonMock, + status: statusMock, + }; + }); + + it('Error not found on a non-existing group', async () => { + req = { + params: { + classid: 'id01', + assignmentid: '1', + groupid: '154981', // Should not exist + }, + query: {}, + }; + + await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return 404 not found on a non-existing assignment', async () => { + req = { + params: { + classid: 'id01', + assignmentid: '1000', // Should not exist + groupid: '42000', // Should not exist + }, + query: {}, + }; + + await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return 404 not found ont a non-existing class', async () => { + req = { + params: { + classid: 'doesnotexist', // Should not exist + assignmentid: '1000', // Should not exist + groupid: '42000', // Should not exist + }, + query: {}, + }; + + await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return an existing group', async () => { + const group = getTestGroup01(); + const classId = getClass01().classId as string; + req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString()); + + await getGroupHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() })); + }); + + it('Create and delete', async () => { + const assignment = getAssignment02(); + const classId = assignment.within.classId as string; + req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1'); + req.body = { + members: ['Noordkaap', 'DireStraits'], + }; + + await createGroupHandler(req as Request, res as Response); + + await deleteGroupHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() })); + }); + + it('should return the submissions for a group', async () => { + const group = getTestGroup01(); + const classId = getClass01().classId as string; + req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString()); + + await getGroupSubmissionsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() })); + }); + + it('should return a list of groups for an assignment', async () => { + const assignment = getAssignment01(); + const classId = assignment.within.classId as string; + req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1'); + + await getAllGroupsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ groups: expect.anything() })); + }); +}); diff --git a/backend/tests/controllers/students.test.ts b/backend/tests/controllers/students.test.ts index 957602c9..b5ac1e0d 100644 --- a/backend/tests/controllers/students.test.ts +++ b/backend/tests/controllers/students.test.ts @@ -15,7 +15,7 @@ import { deleteClassJoinRequestHandler, getStudentRequestHandler, } from '../../src/controllers/students.js'; -import { getDireStraits, getNoordkaap, getPinkFloyd, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js'; +import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js'; import { NotFoundException } from '../../src/exceptions/not-found-exception.js'; import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { ConflictException } from '../../src/exceptions/conflict-exception.js'; @@ -25,7 +25,6 @@ import { getClass02 } from '../test_assets/classes/classes.testdata.js'; import { getClassJoinRequest02 } from '../test_assets/classes/class-join-requests.testdata.js'; import { getTestGroup01 } from '../test_assets/assignments/groups.testdata.js'; import { getSubmission01 } from '../test_assets/assignments/submission.testdata.js'; -import { getQuestion } from '../../src/services/questions.js'; import { getQuestion01 } from '../test_assets/questions/questions.testdata.js'; describe('Student controllers', () => { diff --git a/backend/tests/controllers/submissions.test.ts b/backend/tests/controllers/submissions.test.ts new file mode 100644 index 00000000..942b51f8 --- /dev/null +++ b/backend/tests/controllers/submissions.test.ts @@ -0,0 +1,61 @@ +import { setupTestApp } from '../setup-tests.js'; +import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest'; +import { getSubmissionHandler, getAllSubmissionsHandler } from '../../src/controllers/submissions.js'; +import { Request, Response } from 'express'; +import { NotFoundException } from '../../src/exceptions/not-found-exception'; +import { getClass02 } from '../test_assets/classes/classes.testdata'; + +function createRequestObject( + hruid: string, + submissionNumber: string +): { + query: { language: string; version: string }; + params: { hruid: string; id: string }; +} { + return { + params: { + hruid: hruid, + id: submissionNumber, + }, + query: { + language: 'en', + version: '1', + }, + }; +} + +describe('Submission controllers', () => { + let req: Partial; + let res: Partial; + + let jsonMock: Mock; + let statusMock: Mock; + + beforeAll(async () => { + await setupTestApp(); + }); + + beforeEach(async () => { + jsonMock = vi.fn(); + statusMock = vi.fn().mockReturnThis(); + + res = { + json: jsonMock, + status: statusMock, + }; + }); + + it('error submission is not found', async () => { + req = createRequestObject('id01', '1000000'); + + await expect(async () => getSubmissionHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException); + }); + + it('should return a list of submissions for a learning object', async () => { + req = createRequestObject(getClass02().classId as string, 'irrelevant'); + + await getAllSubmissionsHandler(req as Request, res as Response); + + expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() })); + }); +}); diff --git a/backend/tests/controllers/teacher-invitations.test.ts b/backend/tests/controllers/teacher-invitations.test.ts index 94c5899e..289d384a 100644 --- a/backend/tests/controllers/teacher-invitations.test.ts +++ b/backend/tests/controllers/teacher-invitations.test.ts @@ -14,7 +14,6 @@ import { BadRequestException } from '../../src/exceptions/bad-request-exception' import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { getTeacherInvitation01 } from '../test_assets/classes/teacher-invitations.testdata.js'; import { getLimpBizkit, getTestleerkracht1 } from '../test_assets/users/teachers.testdata.js'; -import { getTestGroup01 } from '../test_assets/assignments/groups.testdata.js'; import { getClass02 } from '../test_assets/classes/classes.testdata.js'; describe('Teacher controllers', () => { diff --git a/backend/tests/controllers/teachers.test.ts b/backend/tests/controllers/teachers.test.ts index c455d184..fe36e99b 100644 --- a/backend/tests/controllers/teachers.test.ts +++ b/backend/tests/controllers/teachers.test.ts @@ -15,12 +15,10 @@ 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 { getFooFighters, getTestleerkracht1 } from '../test_assets/users/teachers.testdata.js'; import { getClass02 } from '../test_assets/classes/classes.testdata.js'; -import { getPinkFloyd, TEST_STUDENTS } from '../test_assets/users/students.testdata.js'; -import { getClassJoinRequest01, getClassJoinRequest02 } from '../test_assets/classes/class-join-requests.testdata.js'; +import { getClassJoinRequest01 } from '../test_assets/classes/class-join-requests.testdata.js'; describe('Teacher controllers', () => { let req: Partial; @@ -102,7 +100,7 @@ describe('Teacher controllers', () => { }); it('Teacher list', async () => { - req = { query: { full: 'true' } }; + req = { query: { full: 'false' } }; await getAllTeachersHandler(req as Request, res as Response); @@ -110,12 +108,10 @@ describe('Teacher controllers', () => { const result = jsonMock.mock.lastCall?.[0]; - const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); - const teacher = getTestleerkracht1(); - expect(teacherUsernames).toContain(teacher.username); + expect(result.teachers).toContain(teacher.username); - expect(result.teachers).toHaveLength(5); + expect(result.teachers.length).toBeGreaterThan(0); }); it('Deleting non-existent teacher', async () => { @@ -156,27 +152,6 @@ describe('Teacher controllers', () => { expect(result.students.length).toBeGreaterThan(0); }); - /* - - It('Get teacher questions', async () => { - req = { - params: { username: 'FooFighters' }, - query: { full: 'true' }, - }; - - await getTeacherQuestionHandler(req as Request, res as Response); - - expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ questions: expect.anything() })); - - const result = jsonMock.mock.lastCall?.[0]; - // console.log('[TEACHER QUESTIONS]', result.questions); - expect(result.questions.length).toBeGreaterThan(0); - - // TODO fix - }); - - */ - it('Get join requests by class', async () => { const jr = getClassJoinRequest01(); req = { diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts index 2ab477fb..b0839d10 100644 --- a/backend/tests/data/assignments/assignments.test.ts +++ b/backend/tests/data/assignments/assignments.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { getAssignmentRepository } from '../../../src/data/repositories'; -import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata'; +import { getClass02 } from '../../test_assets/classes/classes.testdata'; import { getAssignment02, getAssignment03 } from '../../test_assets/assignments/assignments.testdata'; import { getTestleerkracht1 } from '../../test_assets/users/teachers.testdata'; @@ -17,7 +17,7 @@ describe('AssignmentRepository', () => { it('should return the requested assignment', async () => { const class_ = getClass02(); const usedAssignment = getAssignment02(); - const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); + const assignment = await assignmentRepository.findByClassAndId(class_, 21001); expect(assignment).toBeTruthy(); expect(assignment!.description).toBe(usedAssignment.description); @@ -30,7 +30,7 @@ describe('AssignmentRepository', () => { it('should return all assignments for a class', async () => { const class_ = getClass02(); const usedAssignment = getAssignment02(); - const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); + const assignments = await assignmentRepository.findAllAssignmentsInClass(class_); expect(assignments).toBeTruthy(); expect(assignments).toHaveLength(1); diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts index 4f4c9ee1..5a70077a 100644 --- a/backend/tests/data/assignments/groups.test.ts +++ b/backend/tests/data/assignments/groups.test.ts @@ -1,8 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { GroupRepository } from '../../../src/data/assignments/group-repository'; -import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; -import { getClass01 } from '../../test_assets/classes/classes.testdata'; +import { getGroupRepository } from '../../../src/data/repositories'; import { getAssignment01, getAssignment02 } from '../../test_assets/assignments/assignments.testdata'; import { getTestGroup01, getTestGroup02, getTestGroup03 } from '../../test_assets/assignments/groups.testdata'; import { getDireStraits, getNoordkaap } from '../../test_assets/users/students.testdata'; @@ -21,7 +20,7 @@ describe('GroupRepository', () => { const member1 = getNoordkaap(); const member2 = getDireStraits(); - const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, usedGroup.groupNumber!); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, usedGroup.groupNumber!); expect(group).toBeTruthy(); expect(group?.groupNumber).toBe(usedGroup.groupNumber); @@ -36,7 +35,7 @@ describe('GroupRepository', () => { const gr2 = getTestGroup02(); const gr3 = getTestGroup03(); - const groups = await groupRepository.findAllGroupsForAssignment(assignment!); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); expect(groups).toBeTruthy(); expect(groups).toHaveLength(3); @@ -49,9 +48,9 @@ describe('GroupRepository', () => { const assignment = getAssignment02(); const deleted = getTestGroup01(); - await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, deleted.groupNumber!); + await groupRepository.deleteByAssignmentAndGroupNumber(assignment, deleted.groupNumber!); - const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, deleted.groupNumber!); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, deleted.groupNumber!); expect(group).toBeNull(); }); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts index b98f4900..16f0e5a2 100644 --- a/backend/tests/data/assignments/submissions.test.ts +++ b/backend/tests/data/assignments/submissions.test.ts @@ -1,21 +1,11 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { SubmissionRepository } from '../../../src/data/assignments/submission-repository'; -import { - getAssignmentRepository, - getClassRepository, - getGroupRepository, - getStudentRepository, - getSubmissionRepository, -} from '../../../src/data/repositories'; +import { getSubmissionRepository } from '../../../src/data/repositories'; import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; -import { Language } from '@dwengo-1/common/util/language'; import { Submission } from '../../../src/entities/assignments/submission.entity'; -import { Class } from '../../../src/entities/classes/class.entity'; -import { Assignment } from '../../../src/entities/assignments/assignment.entity'; -import { testLearningObject01, testLearningObject03 } from '../../test_assets/content/learning-objects.testdata'; +import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; import { getSubmission01, getSubmission02, getSubmission07, getSubmission08 } from '../../test_assets/assignments/submission.testdata'; -import { use } from 'marked'; import { getAssignment01 } from '../../test_assets/assignments/assignments.testdata'; import { getTestGroup02 } from '../../test_assets/assignments/groups.testdata'; @@ -29,7 +19,11 @@ describe('SubmissionRepository', () => { it('should find the requested submission', async () => { const usedSubmission = getSubmission01(); - const id = new LearningObjectIdentifier(usedSubmission.learningObjectHruid, usedSubmission.learningObjectLanguage, usedSubmission.learningObjectVersion); + const id = new LearningObjectIdentifier( + usedSubmission.learningObjectHruid, + usedSubmission.learningObjectLanguage, + usedSubmission.learningObjectVersion + ); const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, usedSubmission.submissionNumber!); expect(submission).toBeTruthy(); @@ -40,9 +34,13 @@ describe('SubmissionRepository', () => { it('should find the most recent submission for a student', async () => { const usedSubmission = getSubmission02(); - const id = new LearningObjectIdentifier(usedSubmission.learningObjectHruid, usedSubmission.learningObjectLanguage, usedSubmission.learningObjectVersion); - - const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, usedSubmission.submitter!); + const id = new LearningObjectIdentifier( + usedSubmission.learningObjectHruid, + usedSubmission.learningObjectLanguage, + usedSubmission.learningObjectVersion + ); + + const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, usedSubmission.submitter); expect(submission).toBeTruthy(); expect(submission?.submissionTime).toStrictEqual(usedSubmission.submissionTime); @@ -50,8 +48,12 @@ describe('SubmissionRepository', () => { it('should find the most recent submission for a group', async () => { const usedSubmission = getSubmission02(); - const id = new LearningObjectIdentifier(usedSubmission.learningObjectHruid, usedSubmission.learningObjectLanguage, usedSubmission.learningObjectVersion); - + const id = new LearningObjectIdentifier( + usedSubmission.learningObjectHruid, + usedSubmission.learningObjectLanguage, + usedSubmission.learningObjectVersion + ); + const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, usedSubmission.onBehalfOf); expect(submission).toBeTruthy(); @@ -61,13 +63,13 @@ describe('SubmissionRepository', () => { it('should find all submissions for a certain learning object and assignment', async () => { const usedSubmission = getSubmission08(); const assignment = getAssignment01(); - + const loId = { hruid: usedSubmission.learningObjectHruid, language: usedSubmission.learningObjectLanguage, version: usedSubmission.learningObjectVersion, }; - const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!); + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment); sortSubmissions(result); expect(result).toHaveLength(3); @@ -94,7 +96,7 @@ describe('SubmissionRepository', () => { version: usedSubmission.learningObjectVersion, }; - const result = await submissionRepository.findAllSubmissionsForLearningObjectAndGroup(loId, group!); + const result = await submissionRepository.findAllSubmissionsForLearningObjectAndGroup(loId, group); expect(result).toHaveLength(1); diff --git a/backend/tests/data/classes/class-join-request.test.ts b/backend/tests/data/classes/class-join-request.test.ts index 7e3aa8e5..d34525a1 100644 --- a/backend/tests/data/classes/class-join-request.test.ts +++ b/backend/tests/data/classes/class-join-request.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join-request-repository'; -import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; +import { getClassJoinRequestRepository } from '../../../src/data/repositories'; import { getPinkFloyd, getSmashingPumpkins } from '../../test_assets/users/students.testdata'; import { getClass02, getClass03 } from '../../test_assets/classes/classes.testdata'; import { getClassJoinRequest01, getClassJoinRequest02, getClassJoinRequest03 } from '../../test_assets/classes/class-join-requests.testdata'; @@ -30,7 +30,7 @@ describe('ClassJoinRequestRepository', () => { const class_ = getClass02(); const jr1 = getClassJoinRequest01(); const jr2 = getClassJoinRequest02(); - const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); + const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_); expect(requests).toBeTruthy(); expect(requests).toHaveLength(2); @@ -41,9 +41,9 @@ describe('ClassJoinRequestRepository', () => { it('should not find a removed request', async () => { const studentUsed = getSmashingPumpkins(); const class_ = getClass03(); - await classJoinRequestRepository.deleteBy(studentUsed!, class_!); + await classJoinRequestRepository.deleteBy(studentUsed, class_); - const request = await classJoinRequestRepository.findAllRequestsBy(studentUsed!); + const request = await classJoinRequestRepository.findAllRequestsBy(studentUsed); expect(request).toHaveLength(0); }); diff --git a/backend/tests/data/classes/classes.test.ts b/backend/tests/data/classes/classes.test.ts index 99ef1b7f..2f3683d8 100644 --- a/backend/tests/data/classes/classes.test.ts +++ b/backend/tests/data/classes/classes.test.ts @@ -3,6 +3,7 @@ import { ClassRepository } from '../../../src/data/classes/class-repository'; import { setupTestApp } from '../../setup-tests'; import { getClassRepository } from '../../../src/data/repositories'; import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata'; +import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata'; describe('ClassRepository', () => { let classRepository: ClassRepository; diff --git a/backend/tests/data/classes/teacher-invitation.test.ts b/backend/tests/data/classes/teacher-invitation.test.ts index f3d125cc..7b1a0540 100644 --- a/backend/tests/data/classes/teacher-invitation.test.ts +++ b/backend/tests/data/classes/teacher-invitation.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; -import { getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { getTeacherInvitationRepository } from '../../../src/data/repositories'; import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; import { getFooFighters, getLimpBizkit } from '../../test_assets/users/teachers.testdata'; import { getTeacherInvitation01, getTeacherInvitation02, getTeacherInvitation03 } from '../../test_assets/classes/teacher-invitations.testdata'; @@ -18,7 +18,7 @@ describe('ClassRepository', () => { const teacher = getLimpBizkit(); const ti1 = getTeacherInvitation01(); const ti2 = getTeacherInvitation02(); - const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!); + const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher); expect(invitations).toBeTruthy(); expect(invitations).toHaveLength(2); @@ -30,7 +30,7 @@ describe('ClassRepository', () => { const teacher = getFooFighters(); const ti1 = getTeacherInvitation01(); const ti2 = getTeacherInvitation03(); - const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!); + const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher); expect(invitations).toBeTruthy(); expect(invitations).toHaveLength(2); @@ -42,22 +42,21 @@ describe('ClassRepository', () => { const class_ = getClass02(); const ti1 = getTeacherInvitation01(); const ti2 = getTeacherInvitation02(); - const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_); expect(invitations).toBeTruthy(); expect(invitations).toHaveLength(2); expect(invitations[0].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]); expect(invitations[1].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]); - }); it('should not find a removed invitation', async () => { const class_ = getClass01(); const sender = getFooFighters(); const receiver = getLimpBizkit(); - await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); + await teacherInvitationRepository.deleteBy(class_, sender, receiver); - const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!); + const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender); expect(invitation).toHaveLength(0); }); diff --git a/backend/tests/data/content/attachments.test.ts b/backend/tests/data/content/attachments.test.ts index f0bf017e..c271c60b 100644 --- a/backend/tests/data/content/attachments.test.ts +++ b/backend/tests/data/content/attachments.test.ts @@ -2,7 +2,6 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests.js'; import { getAttachmentRepository } from '../../../src/data/repositories.js'; import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; -import { testLearningObject02 } from '../../test_assets/content/learning-objects.testdata'; import { getAttachment01 } from '../../test_assets/content/attachments.testdata.js'; describe('AttachmentRepository', () => { diff --git a/backend/tests/data/content/learning-object-repository.test.ts b/backend/tests/data/content/learning-object-repository.test.ts index c534343a..88287bf5 100644 --- a/backend/tests/data/content/learning-object-repository.test.ts +++ b/backend/tests/data/content/learning-object-repository.test.ts @@ -6,7 +6,6 @@ import { LearningObject } from '../../../src/entities/content/learning-object.en import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; import { testLearningObject01, testLearningObject02, testLearningObject03 } from '../../test_assets/content/learning-objects.testdata'; import { v4 } from 'uuid'; -import { wrap } from '@mikro-orm/core'; describe('LearningObjectRepository', () => { let learningObjectRepository: LearningObjectRepository; @@ -38,7 +37,7 @@ describe('LearningObjectRepository', () => { let newerExample: LearningObject; it('should allow a learning object with the same id except a different version to be added', async () => { - // structeredClone failed on teacher, this copies all fields to a json object + // StructeredClone failed on teacher, this copies all fields to a json object const testLearningObject01Newer = { ...testLearningObject01 }; testLearningObject01Newer.version = 10; testLearningObject01Newer.title += ' (nieuw)'; @@ -49,12 +48,10 @@ describe('LearningObjectRepository', () => { }); it('should return the newest version of the learning object when queried by only hruid and language', async () => { - - const result = await learningObjectRepository.findLatestByHruidAndLanguage(newerExample.hruid, newerExample.language); - // expect(result).toBeInstanceOf(LearningObject); - // expect(result?.version).toBe(10); - // expect(result?.title).toContain('(nieuw)'); + expect(result).toBeInstanceOf(LearningObject); + expect(result?.version).toBe(10); + expect(result?.title).toContain('(nieuw)'); }); it('should return null when queried by non-existing hruid or language', async () => { diff --git a/backend/tests/data/questions/answers.test.ts b/backend/tests/data/questions/answers.test.ts index b69765be..5447900e 100644 --- a/backend/tests/data/questions/answers.test.ts +++ b/backend/tests/data/questions/answers.test.ts @@ -1,15 +1,10 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { setupTestApp } from '../../setup-tests'; import { AnswerRepository } from '../../../src/data/questions/answer-repository'; -import { getAnswerRepository, getQuestionRepository, getTeacherRepository } from '../../../src/data/repositories'; -import { QuestionRepository } from '../../../src/data/questions/question-repository'; -import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; -import { Language } from '@dwengo-1/common/util/language'; -import { TeacherRepository } from '../../../src/data/users/teacher-repository'; -import { getQuestion01, getQuestion02, getQuestion04, getQuestion05, getQuestion06 } from '../../test_assets/questions/questions.testdata'; +import { getAnswerRepository } from '../../../src/data/repositories'; +import { getQuestion01, getQuestion02 } from '../../test_assets/questions/questions.testdata'; import { getAnswer01, getAnswer02, getAnswer03 } from '../../test_assets/questions/answers.testdata'; import { getFooFighters } from '../../test_assets/users/teachers.testdata'; -import { testLearningObject05 } from '../../test_assets/content/learning-objects.testdata'; describe('AnswerRepository', () => { let answerRepository: AnswerRepository; @@ -24,7 +19,7 @@ describe('AnswerRepository', () => { const a1 = getAnswer01(); const a2 = getAnswer02(); - const answers = await answerRepository.findAllAnswersToQuestion(question!); + const answers = await answerRepository.findAllAnswersToQuestion(question); expect(answers).toBeTruthy(); expect(answers).toHaveLength(2); @@ -38,7 +33,7 @@ describe('AnswerRepository', () => { await answerRepository.createAnswer({ toQuestion: question, - author: teacher!, + author: teacher, content: 'created answer', }); diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts index b3392860..1e52b743 100644 --- a/backend/tests/data/questions/questions.test.ts +++ b/backend/tests/data/questions/questions.test.ts @@ -48,8 +48,8 @@ describe('QuestionRepository', () => { const group = getTestGroup01(); await questionRepository.createQuestion({ loId: id, - inGroup: group!, - author: student!, + inGroup: group, + author: student, content: 'question?', }); const question = await questionRepository.findAllQuestionsAboutLearningObject(id); @@ -66,7 +66,7 @@ describe('QuestionRepository', () => { language: testLearningObject05.language, version: testLearningObject05.version, }; - const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!); + const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment); sortQuestions(result); expect(result).toHaveLength(3); @@ -94,7 +94,7 @@ describe('QuestionRepository', () => { }; const assignment = getAssignment01(); - const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!, getTool().username); + const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment, getTool().username); // (student Tool is in group #2) expect(result).toHaveLength(1); diff --git a/backend/tests/data/users/students.test.ts b/backend/tests/data/users/students.test.ts index ee607f74..f5d4e2bc 100644 --- a/backend/tests/data/users/students.test.ts +++ b/backend/tests/data/users/students.test.ts @@ -2,7 +2,6 @@ import { setupTestApp } from '../../setup-tests.js'; import { describe, it, expect, beforeAll } from 'vitest'; import { StudentRepository } from '../../../src/data/users/student-repository.js'; import { getStudentRepository } from '../../../src/data/repositories.js'; -import { getNameOfJSDocTypedef } from 'typescript'; import { getNoordkaap } from '../../test_assets/users/students.testdata.js'; const username = 'teststudent'; diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts index 0abebf2f..021483bc 100644 --- a/backend/tests/test_assets/assignments/assignments.testdata.ts +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -5,6 +5,12 @@ import { testLearningPath01, testLearningPath02, testLearningPathWithConditions import { getClass01, getClass02, getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; export function makeTestAssignemnts(em: EntityManager): Assignment[] { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 7); + const today = new Date(); + today.setHours(23, 59); assignment01 = em.create(Assignment, { id: 21000, within: getClass01(), @@ -12,6 +18,7 @@ export function makeTestAssignemnts(em: EntityManager): Assignment[] { description: 'reading', learningPathHruid: testLearningPath02.hruid, learningPathLanguage: testLearningPath02.language as Language, + deadline: today, groups: [], }); @@ -22,6 +29,7 @@ export function makeTestAssignemnts(em: EntityManager): Assignment[] { description: 'reading', learningPathHruid: testLearningPath01.hruid, learningPathLanguage: testLearningPath01.language as Language, + deadline: futureDate, groups: [], }); @@ -32,6 +40,7 @@ export function makeTestAssignemnts(em: EntityManager): Assignment[] { description: 'will be deleted', learningPathHruid: testLearningPath02.hruid, learningPathLanguage: testLearningPath02.language as Language, + deadline: pastDate, groups: [], }); @@ -42,6 +51,7 @@ export function makeTestAssignemnts(em: EntityManager): Assignment[] { description: 'with a description', learningPathHruid: testLearningPath01.hruid, learningPathLanguage: testLearningPath01.language as Language, + deadline: pastDate, groups: [], }); @@ -52,6 +62,7 @@ export function makeTestAssignemnts(em: EntityManager): Assignment[] { description: 'You have to do the testing learning path with a condition.', learningPathHruid: testLearningPathWithConditions.hruid, learningPathLanguage: testLearningPathWithConditions.language as Language, + deadline: futureDate, groups: [], }); diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts index c177ee78..f2508108 100644 --- a/backend/tests/test_assets/assignments/groups.testdata.ts +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -8,7 +8,7 @@ export function makeTestGroups(em: EntityManager): Group[] { * Group #1 for Assignment #1 in class 'id01' * => Assigned to do learning path 'id02' */ - // gets deleted + // Gets deleted group01 = em.create(Group, { assignment: getAssignment01(), groupNumber: 21001, diff --git a/backend/tests/test_assets/assignments/submission.testdata.ts b/backend/tests/test_assets/assignments/submission.testdata.ts index 96d02c55..01ef5d98 100644 --- a/backend/tests/test_assets/assignments/submission.testdata.ts +++ b/backend/tests/test_assets/assignments/submission.testdata.ts @@ -71,7 +71,7 @@ export function makeTestSubmissions(em: EntityManager): Submission[] { content: '', }); - // gets deleted + // Gets deleted submission07 = em.create(Submission, { learningObjectHruid: testLearningObject01.hruid, learningObjectLanguage: testLearningObject01.language, @@ -106,34 +106,34 @@ let submission06: Submission; let submission07: Submission; let submission08: Submission; -export function getSubmission01(): Submission{ +export function getSubmission01(): Submission { return submission01; } -export function getSubmission02(): Submission{ +export function getSubmission02(): Submission { return submission02; } -export function getSubmission03(): Submission{ +export function getSubmission03(): Submission { return submission03; } -export function getSubmission04(): Submission{ +export function getSubmission04(): Submission { return submission04; } -export function getSubmission05(): Submission{ +export function getSubmission05(): Submission { return submission05; } -export function getSubmission06(): Submission{ +export function getSubmission06(): Submission { return submission06; } -export function getSubmission07(): Submission{ +export function getSubmission07(): Submission { return submission07; } -export function getSubmission08(): Submission{ +export function getSubmission08(): Submission { return submission08; -} \ No newline at end of file +} diff --git a/backend/tests/test_assets/classes/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts index ee2f6383..f6fc63f9 100644 --- a/backend/tests/test_assets/classes/class-join-requests.testdata.ts +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -37,18 +37,18 @@ let classJoinRequest02: ClassJoinRequest; let classJoinRequest03: ClassJoinRequest; let classJoinRequest04: ClassJoinRequest; -export function getClassJoinRequest01(): ClassJoinRequest{ +export function getClassJoinRequest01(): ClassJoinRequest { return classJoinRequest01; } -export function getClassJoinRequest02(): ClassJoinRequest{ +export function getClassJoinRequest02(): ClassJoinRequest { return classJoinRequest02; } -export function getClassJoinRequest03(): ClassJoinRequest{ +export function getClassJoinRequest03(): ClassJoinRequest { return classJoinRequest03; } -export function getClassJoinRequest04(): ClassJoinRequest{ +export function getClassJoinRequest04(): ClassJoinRequest { return classJoinRequest04; -} \ No newline at end of file +} diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts index 883d2a03..29a3ae80 100644 --- a/backend/tests/test_assets/classes/classes.testdata.ts +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -10,7 +10,7 @@ export function makeTestClasses(em: EntityManager): Class[] { const teacherClass01: Teacher[] = [getTestleerkracht1()]; class01 = em.create(Class, { - classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', + classId: 'X2J9QT', // 8764b861-90a6-42e5-9732-c0d9eb2f55f9 displayName: 'class01', teachers: teacherClass01, students: studentsClass01, @@ -20,7 +20,7 @@ export function makeTestClasses(em: EntityManager): Class[] { const teacherClass02: Teacher[] = [getLimpBizkit()]; class02 = em.create(Class, { - classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', + classId: '7KLPMA', // 34d484a1-295f-4e9f-bfdc-3e7a23d86a89 displayName: 'class02', teachers: teacherClass02, students: studentsClass02, @@ -30,7 +30,7 @@ export function makeTestClasses(em: EntityManager): Class[] { const teacherClass03: Teacher[] = [getStaind()]; class03 = em.create(Class, { - classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', + classId: 'R0D3UZ', // 80dcc3e0-1811-4091-9361-42c0eee91cfa displayName: 'class03', teachers: teacherClass03, students: studentsClass03, @@ -39,16 +39,16 @@ export function makeTestClasses(em: EntityManager): Class[] { const studentsClass04: Student[] = [getNoordkaap(), getDireStraits()]; const teacherClass04: Teacher[] = [getStaind()]; - // gets deleted in test + // Gets deleted in test class04 = em.create(Class, { - classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', + classId: 'Q8N5YC', // 33d03536-83b8-4880-9982-9bbf2f908ddf displayName: 'class04', teachers: teacherClass04, students: studentsClass04, }); classWithTestleerlingAndTestleerkracht = em.create(Class, { - classId: 'a75298b5-18aa-471d-8eeb-5d77eb989393', + classId: 'ZAV71B', // Was a75298b5-18aa-471d-8eeb-5d77eb989393 displayName: 'Testklasse', teachers: [getTestleerkracht1()], students: [getTestleerling1()], diff --git a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts index c3d78eb4..48ece518 100644 --- a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -26,7 +26,7 @@ export function makeTestTeacherInvitations(em: EntityManager): TeacherInvitation status: ClassStatus.Open, }); - // gets deleted in test + // Gets deleted in test teacherInvitation04 = em.create(TeacherInvitation, { sender: getFooFighters(), receiver: getLimpBizkit(), @@ -56,4 +56,4 @@ export function getTeacherInvitation03(): TeacherInvitation { export function getTeacherInvitation04(): TeacherInvitation { return teacherInvitation04; -} \ No newline at end of file +} diff --git a/backend/tests/test_assets/content/attachments.testdata.ts b/backend/tests/test_assets/content/attachments.testdata.ts index 1f1bc042..11d12d78 100644 --- a/backend/tests/test_assets/content/attachments.testdata.ts +++ b/backend/tests/test_assets/content/attachments.testdata.ts @@ -4,7 +4,7 @@ import { testLearningObject01 } from './learning-objects.testdata'; import { LearningObject } from '../../../src/entities/content/learning-object.entity'; export function makeTestAttachments(em: EntityManager): Attachment[] { - // prevent duplicate insertion + // Prevent duplicate insertion const lo = em.merge(LearningObject, testLearningObject01); attachment01 = em.create(Attachment, { @@ -19,6 +19,6 @@ export function makeTestAttachments(em: EntityManager): Attachment[] { let attachment01: Attachment; -export function getAttachment01(): Attachment{ +export function getAttachment01(): Attachment { return attachment01; } diff --git a/backend/tests/test_assets/questions/answers.testdata.ts b/backend/tests/test_assets/questions/answers.testdata.ts index 904ba338..a329cdcb 100644 --- a/backend/tests/test_assets/questions/answers.testdata.ts +++ b/backend/tests/test_assets/questions/answers.testdata.ts @@ -20,7 +20,7 @@ export function makeTestAnswers(em: EntityManager): Answer[] { content: 'answer2', }); - // gets deleted + // Gets deleted answer03 = em.create(Answer, { author: getLimpBizkit(), toQuestion: getQuestion04(), @@ -72,4 +72,4 @@ export function getAnswer04(): Answer { export function getAnswer05(): Answer { return answer05; -} \ No newline at end of file +} diff --git a/backend/tests/test_assets/questions/questions.testdata.ts b/backend/tests/test_assets/questions/questions.testdata.ts index b9339274..b76f0119 100644 --- a/backend/tests/test_assets/questions/questions.testdata.ts +++ b/backend/tests/test_assets/questions/questions.testdata.ts @@ -1,7 +1,12 @@ import { EntityManager } from '@mikro-orm/core'; import { Question } from '../../../src/entities/questions/question.entity'; import { getDireStraits, getNoordkaap, getTestleerling1, getTool } from '../users/students.testdata'; -import { testLearningObject01, testLearningObject04, testLearningObject05, testLearningObjectMultipleChoice } from '../content/learning-objects.testdata'; +import { + testLearningObject01, + testLearningObject04, + testLearningObject05, + testLearningObjectMultipleChoice, +} from '../content/learning-objects.testdata'; import { getGroup1ConditionalLearningPath, getTestGroup01, getTestGroup02 } from '../assignments/groups.testdata'; export function makeTestQuestions(em: EntityManager): Question[] { @@ -27,7 +32,7 @@ export function makeTestQuestions(em: EntityManager): Question[] { content: 'question', }); - //gets deleted + //Gets deleted question03 = em.create(Question, { learningObjectLanguage: testLearningObject04.language, learningObjectVersion: testLearningObject04.version, @@ -130,7 +135,6 @@ export function getQuestion06(): Question { return question06; } - export function getQuestion07(): Question { return question07; } diff --git a/backend/tool/seed.ts b/backend/tool/seed.ts index 4e2c5d38..5fef0848 100644 --- a/backend/tool/seed.ts +++ b/backend/tool/seed.ts @@ -5,7 +5,10 @@ import { seedORM } from './seedORM.js'; const logger: Logger = getLogger(); -export async function seedDatabase(envFile = '.env.development.local', testMode = process.env.NODE_ENV !== undefined && process.env.NODE_ENV === 'test'): Promise { +export async function seedDatabase( + envFile = '.env.development.local', + testMode = process.env.NODE_ENV !== undefined && process.env.NODE_ENV === 'test' +): Promise { dotenv.config({ path: envFile }); try { diff --git a/backend/tool/seedORM.ts b/backend/tool/seedORM.ts index b9d869d9..eec73a77 100644 --- a/backend/tool/seedORM.ts +++ b/backend/tool/seedORM.ts @@ -6,7 +6,7 @@ import { makeTestLearningObjects } from '../tests/test_assets/content/learning-o import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata'; import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata'; import { makeTestAssignemnts } from '../tests/test_assets/assignments/assignments.testdata'; -import { makeTestGroups } from '../tests/test_assets/assignments/groups.testdata'; +import { getTestGroup01, getTestGroup02, getTestGroup03, getTestGroup04, makeTestGroups } from '../tests/test_assets/assignments/groups.testdata'; import { Group } from '../src/entities/assignments/group.entity'; import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata'; import { makeTestClassJoinRequests } from '../tests/test_assets/classes/class-join-requests.testdata'; @@ -31,23 +31,23 @@ export async function seedORM(orm: MikroORM): Promise { const teachers = makeTestTeachers(em); const learningObjects = makeTestLearningObjects(em); const learningPaths = makeTestLearningPaths(em); - const classes = makeTestClasses(em, students, teachers); - const assignments = makeTestAssignemnts(em, classes); + const classes = makeTestClasses(em); + const assignments = makeTestAssignemnts(em); - const groups = makeTestGroups(em, students, assignments); + const groups = makeTestGroups(em); - assignments[0].groups = new Collection(groups.slice(0, 3)); - assignments[1].groups = new Collection(groups.slice(3, 4)); + assignments[0].groups = new Collection([getTestGroup01(), getTestGroup02(), getTestGroup03()]); + assignments[1].groups = new Collection([getTestGroup04()]); - const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); - const classJoinRequests = makeTestClassJoinRequests(em, students, classes); - const attachments = makeTestAttachments(em, learningObjects); + const teacherInvitations = makeTestTeacherInvitations(em); + const classJoinRequests = makeTestClassJoinRequests(em); + const attachments = makeTestAttachments(em); learningObjects[1].attachments = attachments; - const questions = makeTestQuestions(em, students, groups); - const answers = makeTestAnswers(em, teachers, questions); - const submissions = makeTestSubmissions(em, students, groups); + const questions = makeTestQuestions(em); + const answers = makeTestAnswers(em); + const submissions = makeTestSubmissions(em); // Persist all entities await em.persistAndFlush([ diff --git a/common/src/interfaces/assignment.ts b/common/src/interfaces/assignment.ts index fb7dfbf0..677221f1 100644 --- a/common/src/interfaces/assignment.ts +++ b/common/src/interfaces/assignment.ts @@ -7,6 +7,7 @@ export interface AssignmentDTO { description: string; learningPath: string; language: string; + deadline: Date; groups: GroupDTO[] | string[][]; } diff --git a/common/src/interfaces/learning-content.ts b/common/src/interfaces/learning-content.ts index 582e0086..453d1bac 100644 --- a/common/src/interfaces/learning-content.ts +++ b/common/src/interfaces/learning-content.ts @@ -25,8 +25,8 @@ export interface LearningObjectNode { language: Language; start_node?: boolean; transitions: Transition[]; - created_at: string; - updatedAt: string; + created_at?: string; + updatedAt?: string; done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. } @@ -79,6 +79,8 @@ export interface LearningObjectMetadata { target_ages: number[]; content_type: string; // Markdown, image, etc. content_location?: string; + copyright?: string; + license?: string; skos_concepts?: string[]; return_value?: ReturnValue; } diff --git a/common/src/interfaces/question.ts b/common/src/interfaces/question.ts index 172d14b7..2d681fc0 100644 --- a/common/src/interfaces/question.ts +++ b/common/src/interfaces/question.ts @@ -13,8 +13,8 @@ export interface QuestionDTO { export interface QuestionData { author?: string; - content: string; inGroup: GroupDTO; + content: string; } export interface QuestionId { diff --git a/common/src/util/account-types.ts b/common/src/util/account-types.ts new file mode 100644 index 00000000..f0957019 --- /dev/null +++ b/common/src/util/account-types.ts @@ -0,0 +1,4 @@ +export enum AccountType { + Student = 'student', + Teacher = 'teacher', +} diff --git a/compose.production.yml b/compose.production.yml index 65dc199b..544e527f 100644 --- a/compose.production.yml +++ b/compose.production.yml @@ -67,8 +67,6 @@ services: - 'traefik.enable=true' - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' - 'traefik.http.services.idp.loadbalancer.server.port=7080' - - 'traefik.http.routers.block-admin.rule=PathPrefix(`/idp/admin`)' - - 'traefik.http.routers.block-admin.service=web' depends_on: - keycloak-db volumes: @@ -95,6 +93,9 @@ services: - '80:80/tcp' - '443:443/tcp' command: + # Enable web UI + - '--api=true' + # Add Docker provider - '--providers.docker=true' - '--providers.docker.exposedbydefault=false' @@ -115,6 +116,17 @@ services: - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' + labels: + # BasicAuth middleware + # To create a user:password pair, the following command can be used: + # echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g + - 'traefik.http.middlewares.protected-sub-path.basicauth.users=dwengo.org:$$apr1$$FdALqAjI$$7ZhPq0I/qEQ6k3OYqxJKZ1' + # Proxying + - 'traefik.enable=true' + - 'traefik.http.routers.proxy.middlewares=protected-sub-path' + - 'traefik.http.routers.proxy.service=api@internal' + - 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)' + - 'traefik.http.services.proxy.loadbalancer.server.port=8080' restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock:ro @@ -137,12 +149,25 @@ services: dashboards: image: grafana/grafana:latest - ports: - - '9002:3000' + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' + - 'traefik.http.services.graphs.loadbalancer.server.port=3000' restart: unless-stopped volumes: - dwengo_grafana_data:/var/lib/grafana + caching: + image: memcached + restart: always + ports: + - '11211:11211' + command: + - --conn-limit=1024 + - --memory-limit=2048 + - -I 128m + - --threads=4 + volumes: dwengo_grafana_data: dwengo_letsencrypt: diff --git a/compose.staging.yml b/compose.staging.yml index 253ab7d5..a404f67c 100644 --- a/compose.staging.yml +++ b/compose.staging.yml @@ -60,6 +60,13 @@ services: # Add web entrypoint - '--entrypoints.web.address=:80/tcp' + + # Proxying the web UI on a sub-path + - '--api.basePath=/proxy' + labels: + - 'traefik.http.routers.proxy.service=api@internal' + - 'traefik.http.routers.proxy.rule=PathPrefix(`/proxy`)' + - 'traefik.http.services.proxy.loadbalancer.server.port=8080' ports: - '9000:8080' - '80:80/tcp' @@ -82,10 +89,25 @@ services: image: grafana/grafana:latest ports: - '9002:3000' + labels: + - 'traefik.http.routers.graphs.rule=PathPrefix(`/graphs`)' + - 'traefik.http.services.graphs.loadbalancer.server.port=3000' volumes: - dwengo_grafana_data:/var/lib/grafana + - ./config/grafana/grafana.ini:/etc/grafana/grafana.ini restart: unless-stopped + caching: + image: memcached + restart: always + ports: + - '11211:11211' + command: + - --conn-limit=1024 + - --memory-limit=2048 + - -I 128m + - --threads=4 + volumes: dwengo_grafana_data: dwengo_loki_data: diff --git a/config/grafana/grafana.ini b/config/grafana/grafana.ini new file mode 100644 index 00000000..7421cb3f --- /dev/null +++ b/config/grafana/grafana.ini @@ -0,0 +1,8 @@ +[server] + +root_url = http://localhost:3000/graphs +serve_from_sub_path = true + +[security] + +admin_user = dwengo.org diff --git a/docs/api/generate.ts b/docs/api/generate.ts index 796369d1..07523a32 100644 --- a/docs/api/generate.ts +++ b/docs/api/generate.ts @@ -26,7 +26,59 @@ const doc = { ], components: { securitySchemes: { - student: { + studentDev: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacherDev: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + studentStaging: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost/idp/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacherStaging: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost/idp/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + studentProduction: { type: 'oauth2', flows: { implicit: { @@ -39,7 +91,7 @@ const doc = { }, }, }, - teacher: { + teacherProduction: { type: 'oauth2', flows: { implicit: { diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9cbb61ea..1ddb8dc0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,22 +3,28 @@ FROM node:22 AS build-stage # install simple http server for serving static content RUN npm install -g http-server -WORKDIR /app +WORKDIR /app/dwengo # Install dependencies COPY package*.json ./ COPY ./frontend/package.json ./frontend/ +# Frontend depends on common +COPY common/package.json ./common/ RUN npm install --silent # Build the frontend # Root tsconfig.json -COPY tsconfig.json ./ -COPY assets ./assets/ +COPY tsconfig.json tsconfig.build.json ./ -WORKDIR /app/frontend +COPY assets ./assets +COPY common ./common + +RUN npm run build --workspace=common + +WORKDIR /app/dwengo/frontend COPY frontend ./ @@ -28,8 +34,8 @@ FROM nginx:stable AS production-stage COPY config/nginx/nginx.conf /etc/nginx/nginx.conf -COPY --from=build-stage /app/assets /usr/share/nginx/html/assets -COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html +COPY --from=build-stage /app/dwengo/assets /usr/share/nginx/html/assets +COPY --from=build-stage /app/dwengo/frontend/dist /usr/share/nginx/html EXPOSE 8080 diff --git a/frontend/e2e/assignments.spec.ts b/frontend/e2e/assignments.spec.ts new file mode 100644 index 00000000..1279ffde --- /dev/null +++ b/frontend/e2e/assignments.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test"; + +test("Teacher can create new assignment", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to assignments + await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); + await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible(); + await expect(page.getByRole("button", { name: "New Assignment" })).toBeVisible(); + + // Create new assignment + await page.getByRole("button", { name: "New Assignment" }).click(); + await expect(page.getByRole("button", { name: "submit" })).toBeVisible(); + await expect(page.getByRole("link", { name: "cancel" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Title Title" }).fill("Assignment test 1"); + await page.getByRole("textbox", { name: "Select a learning path Select" }).click(); + await page.getByText("Using notebooks").click(); + await page.getByRole("textbox", { name: "Pick a class Pick a class" }).click(); + await page.getByText("class01").click(); + await page.getByRole("textbox", { name: "Select Deadline Select" }).fill("2099-01-01T12:34"); + await page.getByRole("textbox", { name: "Description Description" }).fill("Assignment description"); + + await page.getByRole("button", { name: "submit" }).click(); + + await expect(page.getByText("Assignment test")).toBeVisible(); + await expect(page.getByRole("main").getByRole("button").first()).toBeVisible(); + await expect(page.getByRole("main")).toContainText("Assignment test 1"); + await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible(); + await expect(page.getByRole("main")).toContainText("Assignment description"); +}); + +test("Student can see list of assignments", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "student" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to assignments + await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); + await expect(page.getByRole("heading", { name: "Assignments" })).toBeVisible(); + await expect(page.getByText("dire straits")).toBeVisible(); + await expect(page.locator(".button-row > .v-btn").first()).toBeVisible(); + await expect(page.getByText("Class: class01").first()).toBeVisible(); +}); + +test("Student can see assignment details", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "student" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to assignments + await expect(page.getByRole("banner").getByRole("link", { name: "Assignments" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Assignments" }).click(); + await expect(page.getByText("Assignment: Conditional")).toBeVisible(); + await expect(page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn")).toBeVisible(); + + // View assignment details + await page.locator("div:nth-child(2) > .v-card > .button-row > .v-btn").click(); + await expect(page.getByText("Assignment: Conditional")).toBeVisible(); + await expect(page.getByRole("link", { name: "Learning path" })).toBeVisible(); + await expect(page.getByRole("progressbar").locator("div").first()).toBeVisible(); +}); diff --git a/frontend/e2e/basic-learning.spec.ts b/frontend/e2e/basic-learning.spec.ts index f7438454..03be6e10 100644 --- a/frontend/e2e/basic-learning.spec.ts +++ b/frontend/e2e/basic-learning.spec.ts @@ -1,8 +1,16 @@ -import { test, expect } from "./fixtures.js"; +import { test, expect } from "@playwright/test"; test("Users can filter", async ({ page }) => { - await page.goto("/user"); + await page.goto("/"); + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Filter await page.getByRole("combobox").filter({ hasText: "Select a themeAll" }).locator("i").click(); await page.getByText("Nature and climate").click(); await page.getByRole("combobox").filter({ hasText: "Select ageAll agesSelect age" }).locator("i").click(); diff --git a/frontend/e2e/basic-learning.ts b/frontend/e2e/basic-learning.ts deleted file mode 100644 index 157debb0..00000000 --- a/frontend/e2e/basic-learning.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { test, expect } from "./fixtures.js"; - -test("myTest", async ({ page }) => { - await expect(page).toHaveURL("/"); -}); diff --git a/frontend/e2e/class.spec.ts b/frontend/e2e/class.spec.ts new file mode 100644 index 00000000..b3ef29b9 --- /dev/null +++ b/frontend/e2e/class.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from "@playwright/test"; + +test("Teacher can create a class", async ({ page }) => { + const className = "DeTijdLoze"; + + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to class + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + + // Check if the class page is visible + await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "classname classname" })).toBeVisible(); + await expect(page.getByRole("button", { name: "create" })).toBeVisible(); + + // Create a class + await page.getByRole("textbox", { name: "classname classname" }).click(); + await page.getByRole("textbox", { name: "classname classname" }).fill(className); + await page.getByRole("button", { name: "create" }).click(); + + // Check if the class is created + await expect(page.getByRole("dialog").getByText("code")).toBeVisible(); + await expect(page.getByRole("button", { name: "close" })).toBeVisible(); +}); + +test("Teacher can share a class by code", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to classes + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + + await expect(page.getByRole("row", { name: "class01" }).locator("i").nth(1)).toBeVisible(); + await page.getByRole("row", { name: "class01" }).locator("i").nth(1).click(); + await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(2)).toBeVisible(); + await expect(page.getByRole("button").filter({ hasText: /^$/ }).nth(3)).toBeVisible(); + await page.getByRole("button").filter({ hasText: /^$/ }).nth(3).click(); + await expect(page.getByText("copied!")).toBeVisible(); + await page.getByRole("button", { name: "close" }).click(); +}); + +test("Student can join class by code", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "student" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerling1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Go to class + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + + // Check if the class page is visible + await expect(page.getByRole("heading", { name: "Classes" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Join class" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "CODE CODE" })).toBeVisible(); + await expect(page.getByRole("button", { name: "submit" })).toBeVisible(); + + // Join a class + await page.getByRole("textbox", { name: "CODE CODE" }).click(); + await page.getByRole("textbox", { name: "CODE CODE" }).fill("X2J9QT"); + await page.getByRole("button", { name: "submit" }).click(); +}); + +test("Teacher can remove student from class", async ({ page }) => { + await page.goto("/"); + + // Login + await page.getByRole("link", { name: "log in" }).click(); + await page.getByRole("button", { name: "teacher" }).click(); + await page.getByRole("textbox", { name: "Username or email" }).fill("testleerkracht1"); + await page.getByRole("textbox", { name: "Password" }).fill("password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page.getByRole("banner").getByRole("link", { name: "Classes" })).toBeVisible(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + await expect(page.getByRole("link", { name: "class01" })).toBeVisible(); + await expect(page.locator("#app")).toContainText("8"); + await page.getByRole("link", { name: "class01" }).click(); + await expect(page.getByRole("cell", { name: "Kurt Cobain" })).toBeVisible(); + await expect(page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button")).toBeVisible(); + await page.getByRole("row", { name: "Kurt Cobain remove" }).getByRole("button").click(); + await expect(page.getByText("Are you sure?")).toBeVisible(); + await expect(page.getByRole("button", { name: "cancel" })).toBeVisible(); + await expect(page.getByRole("button", { name: "yes" })).toBeVisible(); + await page.getByRole("button", { name: "yes" }).click(); + await page.getByRole("banner").getByRole("link", { name: "Classes" }).click(); + await expect(page.locator("#app")).toContainText("7"); +}); diff --git a/frontend/package.json b/frontend/package.json index 0826edae..c9bea614 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,12 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dwengo-1/common": "^0.2.0", "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", "@vueuse/core": "^13.1.0", "axios": "^1.8.2", + "json-editor-vue": "^0.18.1", "oidc-client-ts": "^3.1.0", "rollup": "^4.40.0", "uuid": "^11.1.0", diff --git a/frontend/src/assets/common.css b/frontend/src/assets/common.css new file mode 100644 index 00000000..bcc5d39f --- /dev/null +++ b/frontend/src/assets/common.css @@ -0,0 +1,54 @@ +.h1 { + color: #0e6942; + text-transform: uppercase; + font-weight: bolder; + font-size: 50px; + padding-left: 1%; +} + +.empty-message { + text-align: center; + font-size: 18px; +} + +.header { + font-weight: bold !important; + background-color: #0e6942; + color: white; + padding: 10px; +} + +.table thead th:first-child { + border-top-left-radius: 10px; +} + +.table thead th:last-child { + border-top-right-radius: 10px; +} + +.table tbody tr:nth-child(odd) { + background-color: white; +} + +.table tbody tr:nth-child(even) { + background-color: #f6faf2; +} + +.table td, +.table th { + border-bottom: 1px solid #0e6942; + border-top: 1px solid #0e6942; +} + +.table { + width: 90%; + padding-top: 10px; + border-collapse: collapse; +} + +@media screen and (max-width: 850px) { + .h1 { + text-align: center; + padding-left: 0; + } +} diff --git a/frontend/src/components/BrowseThemes.vue b/frontend/src/components/BrowseThemes.vue index b65c4e26..738ea7fb 100644 --- a/frontend/src/components/BrowseThemes.vue +++ b/frontend/src/components/BrowseThemes.vue @@ -5,6 +5,7 @@ import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; import { useThemeQuery } from "@/queries/themes.ts"; import type { Theme } from "@/data-objects/theme.ts"; + import authService from "@/services/auth/auth-service"; const props = defineProps({ selectedTheme: { type: String, required: true }, @@ -33,6 +34,8 @@ cards.value = themes; } }); + + const isTeacher = computed(() => authService.authState.activeRole === "teacher"); - + diff --git a/frontend/src/components/ButtonWithConfirmation.vue b/frontend/src/components/ButtonWithConfirmation.vue new file mode 100644 index 00000000..93dec470 --- /dev/null +++ b/frontend/src/components/ButtonWithConfirmation.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/src/components/LearningPathSearchField.vue b/frontend/src/components/LearningPathSearchField.vue index b8b71960..9afd62f6 100644 --- a/frontend/src/components/LearningPathSearchField.vue +++ b/frontend/src/components/LearningPathSearchField.vue @@ -31,4 +31,9 @@ > - + diff --git a/frontend/src/components/LearningPathsGrid.vue b/frontend/src/components/LearningPathsGrid.vue index 865c7166..8df08a00 100644 --- a/frontend/src/components/LearningPathsGrid.vue +++ b/frontend/src/components/LearningPathsGrid.vue @@ -53,9 +53,9 @@ white-space: normal; } .results-grid { - margin: 20px; + margin: 20px auto; display: flex; - align-items: stretch; + justify-content: center; gap: 20px; flex-wrap: wrap; } diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 6c4dbda0..229a7266 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -7,13 +7,17 @@ // Import assets import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; + import { useLocale } from "vuetify"; const { t, locale } = useI18n(); + const { current: vuetifyLocale } = useLocale(); const role = auth.authState.activeRole; const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable const name: string = auth.authState.user!.profile.name!; + const username = auth.authState.user!.profile.preferred_username!; + const email = auth.authState.user!.profile.email; const initials: string = name .split(" ") .map((n) => n[0]) @@ -30,6 +34,7 @@ // Logic to change the language of the website to the selected language function changeLanguage(langCode: string): void { locale.value = langCode; + vuetifyLocale.value = langCode; localStorage.setItem("user-lang", langCode); } @@ -89,31 +94,34 @@ > {{ t("discussions") }} - - - - - {{ language.name }} - - - + + + + + {{ language.name }} + + + - {{ initials }} + + + + +
+ + {{ initials }} + +

{{ name }}

+

{{ username }}

+

{{ email }}

+ + {{ t("logout") }} + +
+
+
+
diff --git a/frontend/src/components/SingleQuestion.vue b/frontend/src/components/SingleQuestion.vue index 7dd73eb9..d68016da 100644 --- a/frontend/src/components/SingleQuestion.vue +++ b/frontend/src/components/SingleQuestion.vue @@ -7,8 +7,8 @@ import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer"; import authService from "@/services/auth/auth-service"; import { useI18n } from "vue-i18n"; - - + + const { t } = useI18n(); const props = defineProps<{ @@ -22,7 +22,7 @@ expanded.value = !expanded.value; // Scroll to the answers container if expanded - if (expanded.value && answersContainer.value) { + if (expanded.value && answersContainer.value) { setTimeout(() => { if (answersContainer.value) { answersContainer.value.scrollIntoView({ @@ -97,7 +97,7 @@ {{ question.content }}
diff --git a/frontend/src/components/assignments/DeadlineSelector.vue b/frontend/src/components/assignments/DeadlineSelector.vue index 9295eec0..304c544c 100644 --- a/frontend/src/components/assignments/DeadlineSelector.vue +++ b/frontend/src/components/assignments/DeadlineSelector.vue @@ -1,49 +1,30 @@ - - diff --git a/frontend/src/controllers/answers.ts b/frontend/src/controllers/answers.ts index 57407b19..bdfb5e4f 100644 --- a/frontend/src/controllers/answers.ts +++ b/frontend/src/controllers/answers.ts @@ -17,7 +17,7 @@ export class AnswerController extends BaseController { constructor(questionId: QuestionId) { super( - `learningObject/${questionId.learningObjectIdentifier.hruid}/:${questionId.learningObjectIdentifier.version}/questions/${questionId.sequenceNumber}/answers`, + `learningObject/${questionId.learningObjectIdentifier.hruid}/${questionId.learningObjectIdentifier.version}/questions/${questionId.sequenceNumber}/answers`, ); this.loId = questionId.learningObjectIdentifier; this.sequenceNumber = questionId.sequenceNumber; diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 64f2363d..b2b0da01 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -37,6 +37,33 @@ export abstract class BaseController { return response.data; } + /** + * Sends a POST-request with a form-data body with the given file. + * + * @param path Relative path in the api to send the request to. + * @param formFieldName The name of the form field in which the file should be. + * @param file The file to upload. + * @param queryParams The query parameters. + * @returns The response the POST request generated. + */ + protected async postFile( + path: string, + formFieldName: string, + file: File, + queryParams?: QueryParams, + ): Promise { + const formData = new FormData(); + formData.append(formFieldName, file); + const response = await apiClient.post(this.absolutePathFor(path), formData, { + params: queryParams, + headers: { + "Content-Type": "multipart/form-data", + }, + }); + BaseController.assertSuccessResponse(response); + return response.data; + } + protected async delete(path: string, queryParams?: QueryParams): Promise { const response = await apiClient.delete(this.absolutePathFor(path), { params: queryParams }); BaseController.assertSuccessResponse(response); diff --git a/frontend/src/controllers/learning-objects.ts b/frontend/src/controllers/learning-objects.ts index d62ba1f4..a9ecf22f 100644 --- a/frontend/src/controllers/learning-objects.ts +++ b/frontend/src/controllers/learning-objects.ts @@ -14,4 +14,16 @@ export class LearningObjectController extends BaseController { async getHTML(hruid: string, language: Language, version: number): Promise { return this.get(`/${hruid}/html`, { language, version }, "document"); } + + async getAllAdministratedBy(admin: string): Promise { + return this.get("/", { admin }); + } + + async upload(learningObjectZip: File): Promise { + return this.postFile("/", "learningObject", learningObjectZip); + } + + async deleteLearningObject(hruid: string, language: Language, version: number): Promise { + return this.delete(`/${hruid}`, { language, version }); + } } diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index bad54286..1a1b0cc5 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -1,8 +1,8 @@ import { BaseController } from "@/controllers/base-controller.ts"; -import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { Language } from "@/data-objects/language.ts"; -import { single } from "@/utils/response-assertions.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import { LearningPath } from "@/data-objects/learning-paths/learning-path"; +import { NotFoundException } from "@/exception/not-found-exception"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathController extends BaseController { constructor() { @@ -24,10 +24,13 @@ export class LearningPathController extends BaseController { assignmentNo: forGroup?.assignmentNo, classId: forGroup?.classId, }); - return LearningPath.fromDTO(single(dtos)); + if (dtos.length === 0) { + throw new NotFoundException("learningPathNotFound"); + } + return LearningPath.fromDTO(dtos[0]); } - async getAllByTheme(theme: string): Promise { - const dtos = await this.get("/", { theme }); + async getAllByThemeAndLanguage(theme: string, language: Language): Promise { + const dtos = await this.get("/", { theme, language }); return dtos.map((dto) => LearningPath.fromDTO(dto)); } @@ -36,4 +39,20 @@ export class LearningPathController extends BaseController { const dtos = await this.get("/", query); return dtos.map((dto) => LearningPath.fromDTO(dto)); } + + async getAllByAdminRaw(admin: string): Promise { + return await this.get("/", { admin }); + } + + async postLearningPath(learningPath: Partial): Promise { + return await this.post("/", learningPath); + } + + async putLearningPath(learningPath: Partial): Promise { + return await this.put(`/${learningPath.hruid}/${learningPath.language}`, learningPath); + } + + async deleteLearningPath(hruid: string, language: string): Promise { + return await this.delete(`/${hruid}/${language}`); + } } diff --git a/frontend/src/controllers/teachers.ts b/frontend/src/controllers/teachers.ts index a97cf11f..a7adce18 100644 --- a/frontend/src/controllers/teachers.ts +++ b/frontend/src/controllers/teachers.ts @@ -1,6 +1,5 @@ import { BaseController } from "@/controllers/base-controller.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; -import type { QuestionsResponse } from "@/controllers/questions.ts"; import type { ClassesResponse } from "@/controllers/classes.ts"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; @@ -40,10 +39,6 @@ export class TeacherController extends BaseController { return this.get(`/${username}/students`, { full }); } - async getQuestions(username: string, full = false): Promise { - return this.get(`/${username}/questions`, { full }); - } - async getStudentJoinRequests(username: string, classId: string): Promise { return this.get(`/${username}/joinRequests/${classId}`); } diff --git a/frontend/src/data-objects/learning-paths/learning-path-node.ts b/frontend/src/data-objects/learning-paths/learning-path-node.ts index 99bac8db..72737f97 100644 --- a/frontend/src/data-objects/learning-paths/learning-path-node.ts +++ b/frontend/src/data-objects/learning-paths/learning-path-node.ts @@ -1,5 +1,5 @@ import type { Language } from "@/data-objects/language.ts"; -import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; +import type { LearningObjectNode as LearningPathNodeDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathNode { public readonly learningobjectHruid: string; @@ -14,7 +14,7 @@ export class LearningPathNode { learningobjectHruid: string; version: number; language: Language; - transitions: { next: LearningPathNode; default: boolean }[]; + transitions: { next: LearningPathNode; default?: boolean }[]; createdAt: Date; updatedAt: Date; done?: boolean; @@ -22,7 +22,7 @@ export class LearningPathNode { this.learningobjectHruid = options.learningobjectHruid; this.version = options.version; this.language = options.language; - this.transitions = options.transitions; + this.transitions = options.transitions.map((it) => ({ next: it.next, default: it.default ?? false })); this.createdAt = options.createdAt; this.updatedAt = options.updatedAt; this.done = options.done || false; @@ -50,8 +50,8 @@ export class LearningPathNode { return undefined; }) .filter((it) => it !== undefined), - createdAt: new Date(dto.created_at), - updatedAt: new Date(dto.updatedAt), + createdAt: dto.created_at ? new Date(dto.created_at) : new Date(), + updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : new Date(), done: dto.done, }); } diff --git a/frontend/src/data-objects/learning-paths/learning-path.ts b/frontend/src/data-objects/learning-paths/learning-path.ts index d764d123..880aad85 100644 --- a/frontend/src/data-objects/learning-paths/learning-path.ts +++ b/frontend/src/data-objects/learning-paths/learning-path.ts @@ -1,6 +1,6 @@ import type { Language } from "@/data-objects/language.ts"; import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import type { LearningObjectNode, LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export interface LearningPathNodeDTO { _id: string; @@ -77,20 +77,26 @@ export class LearningPath { hruid: dto.hruid, title: dto.title, description: dto.description, - amountOfNodes: dto.num_nodes, - amountOfNodesLeft: dto.num_nodes_left, + amountOfNodes: dto.num_nodes ?? dto.nodes.length, + amountOfNodesLeft: dto.num_nodes_left ?? dto.nodes.length, keywords: dto.keywords.split(" "), - targetAges: { min: dto.min_age, max: dto.max_age }, + targetAges: { + min: dto.min_age ?? NaN, + max: dto.max_age ?? NaN, + }, startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), image: dto.image, }); } - static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { + static getStartNode(dto: LearningPathDTO): LearningObjectNode { const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); if (startNodeDtos.length < 1) { // The learning path has no starting node -> use the first node. - return dto.nodes[0]; + if (dto.nodes.length > 0) { + return dto.nodes[0]; + } + throw new Error("emptyLearningPath"); } // The learning path has 1 or more starting nodes -> use the first start node. return startNodeDtos[0]; } diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json index e81e520c..831c8527 100644 --- a/frontend/src/i18n/locale/de.json +++ b/frontend/src/i18n/locale/de.json @@ -21,6 +21,7 @@ "JoinClassExplanation": "Geben Sie den Code ein, den Ihnen die Lehrkraft mitgeteilt hat, um der Klasse beizutreten.", "invalidFormat": "Ungültiges Format", "submitCode": "senden", + "submit": "senden", "members": "Mitglieder", "themes": "Themen", "choose-theme": "Wählen Sie ein Thema", @@ -68,10 +69,10 @@ "pick-class": "Wählen Sie eine klasse", "choose-students": "Studenten auswählen", "create-group": "Gruppe erstellen", - "class": "klasse", + "class": "Klasse", "delete": "löschen", "view-assignment": "Auftrag anzeigen", - "code": "code", + "code": "Code", "invitations": "Einladungen", "createClass": "Klasse erstellen", "createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.", @@ -83,7 +84,7 @@ "onlyUse": "nur Buchstaben, Zahlen, Bindestriche (-) und Unterstriche (_) verwenden", "close": "schließen", "copied": "kopiert!", - "accept": "akzeptieren", + "accept": "Akzeptieren", "deny": "ablehnen", "sent": "sent", "failed": "fehlgeschlagen", @@ -110,7 +111,7 @@ "remove": "entfernen", "students": "Studenten", "classJoinRequests": "Beitrittsanfragen", - "reject": "ablehnen", + "reject": "Ablehnen", "areusure": "Sind Sie sicher?", "yes": "ja", "teachers": "Lehrer", @@ -122,6 +123,50 @@ "assignmentIndicator": "AUFGABE", "searchAllLearningPathsTitle": "Alle Lernpfade durchsuchen", "searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen.", + "no-students-found": "Diese Klasse hat keine Schüler.", + "no-invitations-found": "Sie haben keine ausstehenden Einladungen.", + "no-join-requests-found": "Es gibt keine ausstehenden Beitrittsanfragen für diese Klasse.", + "no-classes-found": "Sie sind noch keinem Kurs beigetreten.", + "classCreated": "Klasse erstellt!", + "success": "Erfolg", + "submitted": "eingereicht", + "see-submission": "Einsendung anzeigen", + "view-submissions": "Einsendungen anzeigen", + "valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein", + "creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut", + "no-assignments": "Derzeit gibt es keine Zuweisungen.", + "deadline": "deadline", + "learningObjects": "Lernobjekte", + "learningPaths": "Lernpfade", + "hruid": "HRUID", + "language": "Sprache", + "version": "Version", + "previewFor": "Vorschau für ", + "upload": "Hochladen", + "learningObjectUploadTitle": "Lernobjekt hochladen", + "uploadFailed": "Hochladen fehlgeschlagen", + "invalidZip": "Dies ist keine gültige ZIP-Datei.", + "emptyZip": "Diese ZIP-Datei ist leer.", + "missingMetadata": "Dieses Lernobjekt enthält keine metadata.json-Datei.", + "missingContent": "Dieses Lernobjekt enthält keine content.*-Datei.", + "open": "öffnen", + "editLearningPath": "Lernpfad bearbeiten", + "newLearningPath": "Neuen Lernpfad erstellen", + "saveChanges": "Änderungen speichern", + "newLearningObject": "Lernobjekt hochladen", + "confirmDialogTitle": "Bitte bestätigen", + "learningPathDeleteQuery": "Möchten Sie diesen Lernpfad wirklich löschen?", + "learningObjectDeleteQuery": "Möchten Sie dieses Lernobjekt wirklich löschen?", + "learningPathCantModifyId": "Der HRUID oder die Sprache eines Lernpfads kann nicht geändert werden.", + "error": "Fehler", + "ownLearningContentTitle": "Eigene Lerninhalte", + "ownLearningContentDescription": "Erstellen und verwalten Sie eigene Lernobjekte und Lernpfade. Nur für fortgeschrittene Nutzer.", + "learningPathNotFound": "Dieser Lernpfad konnte nicht gefunden werden.", + "emptyLearningPath": "Dieser Lernpfad enthält keine Lernobjekte.", + "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", + "targetAgesMandatory": "Zielalter müssen angegeben werden.", + "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", + "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt" "questions": "Fragen", "view-questions": "Fragen anzeigen auf ", "question-input-placeholder": "Frage...", diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index 80a8d2c9..c16d8528 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -33,6 +33,7 @@ "JoinClassExplanation": "Enter the code the teacher has given you to join the class.", "invalidFormat": "Invalid format.", "submitCode": "submit", + "submit": "submit", "members": "Members", "themes": "Themes", "choose-theme": "Select a theme", @@ -68,21 +69,21 @@ "pick-class": "Pick a class", "choose-students": "Select students", "create-group": "Create group", - "class": "class", + "class": "Class", "delete": "delete", "view-assignment": "View assignment", - "code": "code", - "invitations": "invitations", - "createClass": "create class", + "code": "Code", + "invitations": "Invitations", + "createClass": "Create class", "classname": "classname", "EnterNameOfClass": "Enter a classname.", "create": "create", - "sender": "sender", + "sender": "Sender", "nameIsMandatory": "classname is mandatory", "onlyUse": "only use letters, numbers, dashes (-) and underscores (_)", "close": "close", "copied": "copied!", - "accept": "accept", + "accept": "Accept", "deny": "deny", "createClassInstructions": "Enter a name for your class and click on create. A window will appear with a code that you can copy. Give this code to your students and they will be able to join.", "sent": "sent", @@ -108,12 +109,12 @@ "progress": "Progress", "created": "created", "remove": "remove", - "students": "students", - "classJoinRequests": "join requests", - "reject": "reject", + "students": "Students", + "classJoinRequests": "Join requests", + "reject": "Reject", "areusure": "Are you sure?", "yes": "yes", - "teachers": "teachers", + "teachers": "Teachers", "accepted": "accepted", "rejected": "rejected", "enterUsername": "enter the username of the teacher you would like to invite", @@ -122,6 +123,50 @@ "assignmentIndicator": "ASSIGNMENT", "searchAllLearningPathsTitle": "Search all learning paths", "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.", + "no-students-found": "This class has no students.", + "no-invitations-found": "You have no pending invitations.", + "no-join-requests-found": "There are no pending join requests for this class.", + "no-classes-found": "You are not yet part of a class.", + "classCreated": "class created!", + "success": "success", + "submitted": "submitted", + "see-submission": "view submission", + "view-submissions": "view submissions", + "valid-username": "please enter a valid username", + "creationFailed": "creation failed, please try again", + "no-assignments": "There are currently no assignments.", + "deadline": "deadline", + "learningObjects": "Learning objects", + "learningPaths": "Learning paths", + "hruid": "HRUID", + "language": "Language", + "version": "Version", + "previewFor": "Preview for ", + "upload": "Upload", + "learningObjectUploadTitle": "Upload a learning object", + "uploadFailed": "Upload failed", + "invalidZip": "This is not a valid zip file.", + "emptyZip": "This zip file is empty", + "missingMetadata": "This learning object is missing a metadata.json file.", + "missingContent": "This learning object is missing a content.* file.", + "open": "open", + "editLearningPath": "Edit learning path", + "newLearningPath": "Create a new learning path", + "saveChanges": "Save changes", + "newLearningObject": "Upload learning object", + "confirmDialogTitle": "Please confirm", + "learningPathDeleteQuery": "Are you sure you want to delete this learning path?", + "learningObjectDeleteQuery": "Are you sure you want to delete this learning object?", + "learningPathCantModifyId": "The HRUID or language of a learning path cannot be modified.", + "error": "Error", + "ownLearningContentTitle": "Own learning content", + "ownLearningContentDescription": "Create and administrate your own learning objects and learning paths. For advanced users only.", + "learningPathNotFound": "This learning path could not be found.", + "emptyLearningPath": "This learning path does not contain any learning objects.", + "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", + "targetAgesMandatory": "Target ages must be specified.", + "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", + "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces" "questions": "questions", "view-questions": "View questions in ", "question-input-placeholder": "question...", @@ -130,5 +175,4 @@ "answers-toggle-show": "Show answers", "no-questions": "No questions asked yet", "no-discussion-tip": "Choose a learning object to view its questions" - } diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json index 156ffd36..154abcd1 100644 --- a/frontend/src/i18n/locale/fr.json +++ b/frontend/src/i18n/locale/fr.json @@ -33,6 +33,7 @@ "JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.", "invalidFormat": "Format non valide.", "submitCode": "envoyer", + "submit": "envoyer", "members": "Membres", "themes": "Thèmes", "choose-theme": "Choisis un thème", @@ -68,22 +69,22 @@ "pick-class": "Choisissez une classe", "choose-students": "Sélectionnez des élèves", "create-group": "Créer un groupe", - "class": "classe", + "class": "Classe", "delete": "supprimer", "view-assignment": "Voir le travail", - "code": "code", - "invitations": "invitations", - "createClass": "créer une classe", + "code": "Code", + "invitations": "Invitations", + "createClass": "Créer une classe", "createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.", "classname": "nom de classe", "EnterNameOfClass": "saisir un nom de classe.", "create": "créer", - "sender": "expéditeur", + "sender": "Expéditeur", "nameIsMandatory": "le nom de classe est obligatoire", "onlyUse": "n'utiliser que des lettres, des chiffres, des tirets (-) et des traits de soulignement (_)", "close": "fermer", "copied": "copié!", - "accept": "accepter", + "accept": "Accepter", "deny": "refuser", "sent": "envoyé", "failed": "échoué", @@ -108,12 +109,13 @@ "submission": "Soumission", "progress": "Progrès", "remove": "supprimer", - "students": "étudiants", - "classJoinRequests": "demandes d'adhésion", - "reject": "rejeter", + "students": "Étudiants", + + "classJoinRequests": "Demandes d'adhésion", + "reject": "Rejeter", "areusure": "Êtes-vous sûr?", "yes": "oui", - "teachers": "enseignants", + "teachers": "Enseignants", "accepted": "acceptée", "rejected": "rejetée", "enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter", @@ -122,6 +124,50 @@ "assignmentIndicator": "DEVOIR", "searchAllLearningPathsTitle": "Rechercher tous les parcours d'apprentissage", "searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles.", + "no-students-found": "Cette classe n'a pas d'élèves.", + "no-invitations-found": "Vous n'avez aucune invitation en attente.", + "no-join-requests-found": "Il n'y a aucune demande d'adhésion en attente pour cette classe.", + "no-classes-found": "Vous ne faites pas encore partie d'une classe.", + "classCreated": "Classe créée !", + "success": "succès", + "submitted": "soumis", + "see-submission": "voir la soumission", + "view-submissions": "voir les soumissions", + "valid-username": "veuillez entrer un nom d'utilisateur valide", + "creationFailed": "échec de la création, veuillez réessayer", + "no-assignments": "Il n'y a actuellement aucun travail.", + "deadline": "délai", + "learningObjects": "Objets d’apprentissage", + "learningPaths": "Parcours d’apprentissage", + "hruid": "HRUID", + "language": "Langue", + "version": "Version", + "previewFor": "Aperçu de ", + "upload": "Téléverser", + "learningObjectUploadTitle": "Téléverser un objet d’apprentissage", + "uploadFailed": "Échec du téléversement", + "invalidZip": "Ce n’est pas un fichier ZIP valide.", + "emptyZip": "Ce fichier ZIP est vide.", + "missingMetadata": "Il manque un fichier metadata.json à cet objet d’apprentissage.", + "missingContent": "Il manque un fichier content.* à cet objet d’apprentissage.", + "open": "ouvrir", + "editLearningPath": "Modifier le parcours", + "newLearningPath": "Créer un nouveau parcours", + "saveChanges": "Enregistrer les modifications", + "newLearningObject": "Téléverser un objet d’apprentissage", + "confirmDialogTitle": "Veuillez confirmer", + "learningPathDeleteQuery": "Voulez-vous vraiment supprimer ce parcours d’apprentissage ?", + "learningObjectDeleteQuery": "Voulez-vous vraiment supprimer cet objet d’apprentissage ?", + "learningPathCantModifyId": "Le HRUID ou la langue d’un parcours ne peuvent pas être modifiés.", + "error": "Erreur", + "ownLearningContentTitle": "Contenu d’apprentissage personnel", + "ownLearningContentDescription": "Créez et gérez vos propres objets et parcours d’apprentissage. Réservé aux utilisateurs avancés.", + "learningPathNotFound": "Ce parcours d'apprentissage est introuvable.", + "emptyLearningPath": "Ce parcours d'apprentissage ne contient aucun objet d'apprentissage.", + "pathContainsNonExistingLearningObjects": "Au moins un des objets d’apprentissage référencés dans ce chemin n’existe pas.", + "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", + "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", + "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces" "questions": "Questions", "view-questions": "Voir les questions dans ", "question-input-placeholder": "question...", diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json index 731b4e71..44c91307 100644 --- a/frontend/src/i18n/locale/nl.json +++ b/frontend/src/i18n/locale/nl.json @@ -33,6 +33,7 @@ "JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.", "invalidFormat": "Ongeldig formaat.", "submitCode": "verzenden", + "submit": "verzenden", "members": "Leden", "themes": "Lesthema's", "choose-theme": "Kies een thema", @@ -68,22 +69,22 @@ "pick-class": "Kies een klas", "choose-students": "Studenten selecteren", "create-group": "Groep aanmaken", - "class": "klas", + "class": "Klas", "delete": "verwijderen", "view-assignment": "Opdracht bekijken", - "code": "code", - "invitations": "uitnodigingen", - "createClass": "klas aanmaken", + "code": "Code", + "invitations": "Uitnodigingen", + "createClass": "Klas aanmaken", "createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.", "classname": "klasnaam", "EnterNameOfClass": "Geef een klasnaam op.", "create": "aanmaken", - "sender": "afzender", + "sender": "Afzender", "nameIsMandatory": "klasnaam is verplicht", "onlyUse": "gebruik enkel letters, cijfers, dashes (-) en underscores (_)", "close": "sluiten", "copied": "gekopieerd!", - "accept": "accepteren", + "accept": "Accepteren", "deny": "weigeren", "sent": "verzonden", "failed": "mislukt", @@ -108,12 +109,12 @@ "submission": "Indiening", "progress": "Vooruitgang", "remove": "verwijder", - "students": "studenten", - "classJoinRequests": "deelname verzoeken", - "reject": "weiger", + "students": "Studenten", + "classJoinRequests": "Deelname verzoeken", + "reject": "Weiger", "areusure": "Bent u zeker?", "yes": "ja", - "teachers": "leerkrachten", + "teachers": "Leerkrachten", "accepted": "geaccepteerd", "rejected": "geweigerd", "enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in", @@ -122,6 +123,50 @@ "assignmentIndicator": "OPDRACHT", "searchAllLearningPathsTitle": "Alle leerpaden doorzoeken", "searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken.", + "no-students-found": "Deze klas heeft geen leerlingen.", + "no-invitations-found": "U heeft geen openstaande uitnodigingen.", + "no-join-requests-found": "Er zijn geen openstaande verzoeken om lid te worden van deze klas.", + "no-classes-found": "U maakt nog geen deel uit van een klas.", + "classCreated": "Klas aangemaakt!", + "success": "succes", + "submitted": "ingediend", + "see-submission": "inzending bekijken", + "view-submissions": "inzendingen bekijken", + "valid-username": "voer een geldige gebruikersnaam in", + "creationFailed": "aanmaak mislukt, probeer het opnieuw", + "no-assignments": "Er zijn momenteel geen opdrachten.", + "deadline": "deadline", + "learningObjects": "Leerobjecten", + "learningPaths": "Leerpaden", + "hruid": "HRUID", + "language": "Taal", + "version": "Versie", + "previewFor": "Voorbeeld van ", + "upload": "Uploaden", + "learningObjectUploadTitle": "Leerobject uploaden", + "uploadFailed": "Upload mislukt", + "invalidZip": "Dit is geen geldig zipbestand.", + "emptyZip": "Dit zipbestand is leeg.", + "missingMetadata": "Dit leerobject mist een metadata.json-bestand.", + "missingContent": "Dit leerobject mist een content.*-bestand.", + "open": "openen", + "editLearningPath": "Leerpad bewerken", + "newLearningPath": "Nieuw leerpad aanmaken", + "saveChanges": "Wijzigingen opslaan", + "newLearningObject": "Leerobject uploaden", + "confirmDialogTitle": "Bevestig alstublieft", + "learningPathDeleteQuery": "Weet u zeker dat u dit leerpad wilt verwijderen?", + "learningObjectDeleteQuery": "Weet u zeker dat u dit leerobject wilt verwijderen?", + "learningPathCantModifyId": "De HRUID of taal van een leerpad kan niet worden gewijzigd.", + "error": "Fout", + "ownLearningContentTitle": "Eigen leerinhoud", + "ownLearningContentDescription": "Maak en beheer je eigen leerobjecten en leerpads. Alleen voor gevorderde gebruikers.", + "learningPathNotFound": "Dit leerpad kon niet gevonden worden.", + "emptyLearningPath": "Dit leerpad bevat geen leerobjecten.", + "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", + "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", + "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", + "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties" "questions": "vragen", "view-questions": "Bekijk vragen in ", "question-input-placeholder": "vraag...", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b5315634..3e825557 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,15 +7,20 @@ import * as components from "vuetify/components"; import * as directives from "vuetify/directives"; import i18n from "./i18n/i18n.ts"; +// JSON-editor +import JsonEditorVue from "json-editor-vue"; + // Components import App from "./App.vue"; import router from "./router"; import { aliases, mdi } from "vuetify/iconsets/mdi"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; +import { de, en, fr, nl } from "vuetify/locale"; const app = createApp(App); app.use(router); +app.use(JsonEditorVue, {}); const link = document.createElement("link"); link.rel = "stylesheet"; @@ -32,6 +37,11 @@ const vuetify = createVuetify({ mdi, }, }, + locale: { + locale: i18n.global.locale, + fallback: "en", + messages: { nl, en, de, fr }, + }, }); const queryClient = new QueryClient({ diff --git a/frontend/src/queries/classes.ts b/frontend/src/queries/classes.ts index dca92230..6c452f10 100644 --- a/frontend/src/queries/classes.ts +++ b/frontend/src/queries/classes.ts @@ -15,6 +15,7 @@ import { invalidateAllGroupKeys } from "./groups"; import { invalidateAllSubmissionKeys } from "./submissions"; import type { TeachersResponse } from "@/controllers/teachers"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; +import { studentClassesQueryKey } from "@/queries/students.ts"; const classController = new ClassController(); @@ -171,6 +172,8 @@ export function useClassDeleteStudentMutation(): UseMutationReturnType< await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); + await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, false) }); + await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, true) }); }, }); } diff --git a/frontend/src/queries/learning-objects.ts b/frontend/src/queries/learning-objects.ts index 35ed7ae4..20eb210c 100644 --- a/frontend/src/queries/learning-objects.ts +++ b/frontend/src/queries/learning-objects.ts @@ -1,9 +1,16 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { getLearningObjectController } from "@/controllers/controllers.ts"; import type { LearningObject } from "@/data-objects/learning-objects/learning-object.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; export const LEARNING_OBJECT_KEY = "learningObject"; const learningObjectController = getLearningObjectController(); @@ -24,15 +31,15 @@ export function useLearningObjectMetadataQuery( } export function useLearningObjectHTMLQuery( - hruid: MaybeRefOrGetter, - language: MaybeRefOrGetter, - version: MaybeRefOrGetter, + hruid: MaybeRefOrGetter, + language: MaybeRefOrGetter, + version: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ queryKey: [LEARNING_OBJECT_KEY, "html", hruid, language, version], queryFn: async () => { const [hruidVal, languageVal, versionVal] = [toValue(hruid), toValue(language), toValue(version)]; - return learningObjectController.getHTML(hruidVal, languageVal, versionVal); + return learningObjectController.getHTML(hruidVal!, languageVal!, versionVal!); }, enabled: () => Boolean(toValue(hruid)) && Boolean(toValue(language)) && Boolean(toValue(version)), }); @@ -55,3 +62,49 @@ export function useLearningObjectListForPathQuery( enabled: () => Boolean(toValue(learningPath)), }); } + +export function useLearningObjectListForAdminQuery( + admin: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_OBJECT_KEY, "forAdmin", admin], + queryFn: async () => { + const adminVal = toValue(admin); + return await learningObjectController.getAllAdministratedBy(adminVal!); + }, + enabled: () => toValue(admin) !== undefined, + }); +} + +export function useUploadLearningObjectMutation(): UseMutationReturnType< + LearningObject, + AxiosError, + { learningObjectZip: File }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningObjectZip }) => await learningObjectController.upload(learningObjectZip), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); + }, + }); +} + +export function useDeleteLearningObjectMutation(): UseMutationReturnType< + LearningObject, + AxiosError, + { hruid: string; language: Language; version: number }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language, version }) => + await learningObjectController.deleteLearningObject(hruid, language, version), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [LEARNING_OBJECT_KEY, "forAdmin"] }); + }, + }); +} diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts index 1f088c9d..80d59afa 100644 --- a/frontend/src/queries/learning-paths.ts +++ b/frontend/src/queries/learning-paths.ts @@ -1,8 +1,16 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; -import { useQuery, type UseQueryReturnType } from "@tanstack/vue-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseMutationReturnType, + type UseQueryReturnType, +} from "@tanstack/vue-query"; import { getLearningPathController } from "@/controllers/controllers"; -import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; +import type { AxiosError } from "axios"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; +import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; export const LEARNING_PATH_KEY = "learningPath"; const learningPathController = getLearningPathController(); @@ -22,16 +30,69 @@ export function useGetLearningPathQuery( }); } -export function useGetAllLearningPathsByThemeQuery( +export function useGetAllLearningPathsByThemeAndLanguageQuery( theme: MaybeRefOrGetter, + language: MaybeRefOrGetter, ): UseQueryReturnType { return useQuery({ - queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme], - queryFn: async () => learningPathController.getAllByTheme(toValue(theme)), + queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme, language], + queryFn: async () => learningPathController.getAllByThemeAndLanguage(toValue(theme), toValue(language)), enabled: () => Boolean(toValue(theme)), }); } +export function useGetAllLearningPathsByAdminQuery( + admin: MaybeRefOrGetter, +): UseQueryReturnType { + return useQuery({ + queryKey: [LEARNING_PATH_KEY, "getAllByAdmin", admin], + queryFn: async () => learningPathController.getAllByAdminRaw(toValue(admin)!), + enabled: () => Boolean(toValue(admin)), + }); +} + +export function usePostLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { learningPath: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.postLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + +export function usePutLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { learningPath: Partial }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ learningPath }) => learningPathController.putLearningPath(learningPath), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + +export function useDeleteLearningPathMutation(): UseMutationReturnType< + LearningPathDTO, + AxiosError, + { hruid: string; language: Language }, + unknown +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ hruid, language }) => learningPathController.deleteLearningPath(hruid, language), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: [LEARNING_PATH_KEY] }), + }); +} + export function useSearchLearningPathQuery( query: MaybeRefOrGetter, language: MaybeRefOrGetter, diff --git a/frontend/src/queries/students.ts b/frontend/src/queries/students.ts index da87d28b..1d6794f1 100644 --- a/frontend/src/queries/students.ts +++ b/frontend/src/queries/students.ts @@ -33,7 +33,7 @@ function studentsQueryKey(full: boolean): [string, boolean] { function studentQueryKey(username: string): [string, string] { return ["student", username]; } -function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { +export function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { return ["student-classes", username, full]; } function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { diff --git a/frontend/src/queries/teachers.ts b/frontend/src/queries/teachers.ts index 59da84f4..ed13f630 100644 --- a/frontend/src/queries/teachers.ts +++ b/frontend/src/queries/teachers.ts @@ -10,7 +10,6 @@ import { import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts"; import type { ClassesResponse } from "@/controllers/classes.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; -import type { QuestionsResponse } from "@/controllers/questions.ts"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; @@ -33,10 +32,6 @@ function teacherStudentsQueryKey(username: string, full: boolean): [string, stri return ["teacher-students", username, full]; } -function teacherQuestionsQueryKey(username: string, full: boolean): [string, string, boolean] { - return ["teacher-questions", username, full]; -} - export function teacherClassJoinRequests(classId: string): [string, string] { return ["teacher-class-join-requests", classId]; } @@ -80,17 +75,6 @@ export function useTeacherStudentsQuery( }); } -export function useTeacherQuestionsQuery( - username: MaybeRefOrGetter, - full: MaybeRefOrGetter = false, -): UseQueryReturnType { - return useQuery({ - queryKey: computed(() => teacherQuestionsQueryKey(toValue(username)!, toValue(full))), - queryFn: async () => teacherController.getQuestions(toValue(username)!, toValue(full)), - enabled: () => Boolean(toValue(username)), - }); -} - export function useTeacherJoinRequestsQuery( username: MaybeRefOrGetter, classId: MaybeRefOrGetter, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 890ccf39..27af9b72 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,21 +1,23 @@ -import { createRouter, createWebHistory } from "vue-router"; -import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; -import SingleClass from "@/views/classes/SingleClass.vue"; -import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; -import NotFound from "@/components/errors/NotFound.vue"; -import CreateAssignment from "@/views/assignments/CreateAssignment.vue"; -import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue"; -import CallbackPage from "@/views/CallbackPage.vue"; -import UserClasses from "@/views/classes/UserClasses.vue"; -import UserAssignments from "@/views/assignments/UserAssignments.vue"; -import LearningPathPage from "@/views/learning-paths/LearningPathPage.vue"; -import LearningPathSearchPage from "@/views/learning-paths/LearningPathSearchPage.vue"; -import UserHomePage from "@/views/homepage/UserHomePage.vue"; -import SingleTheme from "@/views/SingleTheme.vue"; -import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; -import authService from "@/services/auth/auth-service"; -import DiscussionForward from "@/views/discussions/DiscussionForward.vue"; -import NoDiscussion from "@/views/discussions/NoDiscussion.vue"; +import { createRouter, createWebHistory } from 'vue-router'; +import SingleAssignment from '@/views/assignments/SingleAssignment.vue'; +import SingleClass from '@/views/classes/SingleClass.vue'; +import SingleDiscussion from '@/views/discussions/SingleDiscussion.vue'; +import NotFound from '@/components/errors/NotFound.vue'; +import CreateAssignment from '@/views/assignments/CreateAssignment.vue'; +import CreateDiscussion from '@/views/discussions/CreateDiscussion.vue'; +import CallbackPage from '@/views/CallbackPage.vue'; +import UserClasses from '@/views/classes/UserClasses.vue'; +import UserAssignments from '@/views/assignments/UserAssignments.vue'; +import LearningPathPage from '@/views/learning-paths/LearningPathPage.vue'; +import LearningPathSearchPage from '@/views/learning-paths/LearningPathSearchPage.vue'; +import UserHomePage from '@/views/homepage/UserHomePage.vue'; +import SingleTheme from '@/views/SingleTheme.vue'; +import LearningObjectView from '@/views/learning-paths/learning-object/LearningObjectView.vue'; +import authService from '@/services/auth/auth-service'; +import DiscussionForward from '@/views/discussions/DiscussionForward.vue'; +import NoDiscussion from '@/views/discussions/NoDiscussion.vue'; +import OwnLearningContentPage from '@/views/own-learning-content/OwnLearningContentPage.vue'; +import { allowRedirect, Redirect } from '@/utils/redirect.ts'; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -115,6 +117,12 @@ const router = createRouter({ props: true, meta: { requiresAuth: true }, }, + { + path: "/my-content", + name: "OwnLearningContentPage", + component: OwnLearningContentPage, + meta: { requiresAuth: true }, + }, { path: "/learningPath", children: [ @@ -153,7 +161,11 @@ router.beforeEach(async (to, _from, next) => { // Verify if user is logged in before accessing certain routes if (to.meta.requiresAuth) { if (!authService.isLoggedIn.value && !(await authService.loadUser())) { - next("/login"); + const path = to.fullPath; + if (allowRedirect(path)) { + localStorage.setItem(Redirect.AFTER_LOGIN_KEY, path); + } + next(Redirect.LOGIN); } else { next(); } diff --git a/frontend/src/utils/redirect.ts b/frontend/src/utils/redirect.ts new file mode 100644 index 00000000..f3ec0e75 --- /dev/null +++ b/frontend/src/utils/redirect.ts @@ -0,0 +1,12 @@ +export enum Redirect { + AFTER_LOGIN_KEY = "redirectAfterLogin", + HOME = "/user", + LOGIN = "/login", + ROOT = "/", +} + +const NOT_ALLOWED_REDIRECTS = new Set([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]); + +export function allowRedirect(path: string): boolean { + return !NOT_ALLOWED_REDIRECTS.has(path as Redirect); +} diff --git a/frontend/src/views/CallbackPage.vue b/frontend/src/views/CallbackPage.vue index cd004eae..d4d300e4 100644 --- a/frontend/src/views/CallbackPage.vue +++ b/frontend/src/views/CallbackPage.vue @@ -3,6 +3,7 @@ import { useI18n } from "vue-i18n"; import { onMounted, ref, type Ref } from "vue"; import auth from "../services/auth/auth-service.ts"; + import { Redirect } from "@/utils/redirect.ts"; const { t } = useI18n(); @@ -10,10 +11,20 @@ const errorMessage: Ref = ref(null); + async function redirectPage(): Promise { + const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY); + if (redirectUrl) { + localStorage.removeItem(Redirect.AFTER_LOGIN_KEY); + await router.replace(redirectUrl); + } else { + await router.replace(Redirect.HOME); + } + } + onMounted(async () => { try { await auth.handleLoginCallback(); - await router.replace("/user"); // Redirect to theme page + await redirectPage(); } catch (error) { errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; } diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue index 08ce44c1..c0dcffaf 100644 --- a/frontend/src/views/HomePage.vue +++ b/frontend/src/views/HomePage.vue @@ -28,7 +28,7 @@ alt="Dwengo logo" style="align-self: center" /> -

{{ t("homeTitle") }}

+

{{ t("homeTitle") }}

{{ t("homeIntroduction1") }}

@@ -84,7 +84,10 @@
- + diff --git a/frontend/src/views/SingleTheme.vue b/frontend/src/views/SingleTheme.vue index 6924cc1c..1cd9afab 100644 --- a/frontend/src/views/SingleTheme.vue +++ b/frontend/src/views/SingleTheme.vue @@ -2,10 +2,11 @@ import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue"; - import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts"; + import { useGetAllLearningPathsByThemeAndLanguageQuery } from "@/queries/learning-paths.ts"; import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; import { useThemeQuery } from "@/queries/themes.ts"; + import type { Language } from "@/data-objects/language"; const props = defineProps<{ theme: string }>(); @@ -16,7 +17,10 @@ const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme)); - const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme); + const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeAndLanguageQuery( + () => props.theme, + () => locale.value as Language, + ); const { t } = useI18n(); const searchFilter = ref(""); @@ -31,13 +35,14 @@