Merge branch 'dev' into chore/docker
This commit is contained in:
		
						commit
						441bf990e7
					
				
					 149 changed files with 5420 additions and 780 deletions
				
			
		
							
								
								
									
										12
									
								
								.github/ISSUE_TEMPLATE/bug-report.md
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/ISSUE_TEMPLATE/bug-report.md
									
										
									
									
										vendored
									
									
								
							|  | @ -24,6 +24,14 @@ Een duidelijke, beknopte beschrijving van wat je verwacht dat er gebeurt. | ||||||
| Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt. | Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt. | ||||||
| 
 | 
 | ||||||
| **Extra context** | **Extra context** | ||||||
| Voeg extra context over het probleem toe. Was je ergens bijzonder mee bezig of naar op zoek? | Was je ergens bijzonder mee bezig of naar op zoek? Welke documentatie of links heb je (al) geraadpleegd? | ||||||
| 
 | 
 | ||||||
| - [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... | <!-- | ||||||
|  | ## Richtlijnen | ||||||
|  | 
 | ||||||
|  | Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt: | ||||||
|  | 
 | ||||||
|  | - Het is duidelijk waar de bug vandaan komt. | ||||||
|  | - Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... | ||||||
|  | - Ik heb de issue toegekend aan de juiste milestone. | ||||||
|  | --> | ||||||
|  |  | ||||||
							
								
								
									
										11
									
								
								.github/ISSUE_TEMPLATE/feature-request.md
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/ISSUE_TEMPLATE/feature-request.md
									
										
									
									
										vendored
									
									
								
							|  | @ -6,7 +6,7 @@ labels: enhancement | ||||||
| assignees: '' | assignees: '' | ||||||
| --- | --- | ||||||
| 
 | 
 | ||||||
| **Is your feature request related to a problem? Please describe.** | **Is jouw feature request gerelateerd tot een probleem? Beschrijf.** | ||||||
| Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter? | Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter? | ||||||
| 
 | 
 | ||||||
| **Beschrijf de oplossing die je zou willen** | **Beschrijf de oplossing die je zou willen** | ||||||
|  | @ -15,4 +15,11 @@ Een duidelijke, beknopte beschrijving van wat je zou willen dat er gebeurt. | ||||||
| **Extra context** | **Extra context** | ||||||
| Extra context of screenshots bij de feature. | Extra context of screenshots bij de feature. | ||||||
| 
 | 
 | ||||||
| - [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... | <!-- | ||||||
|  | ## Richtlijnen | ||||||
|  | 
 | ||||||
|  | Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt: | ||||||
|  | 
 | ||||||
|  | - Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... | ||||||
|  | - Ik heb de issue toegekend aan de juiste milestone. | ||||||
|  | --> | ||||||
|  |  | ||||||
							
								
								
									
										47
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										47
									
								
								README.md
									
										
									
									
									
								
							|  | @ -9,29 +9,26 @@ Figma</a></span> | ||||||
| Projectopgave</a></span> | Projectopgave</a></span> | ||||||
| </p> | </p> | ||||||
| 
 | 
 | ||||||
| <ul align="center" style="list-style-type: none"> |  | ||||||
| <li>Projectleider: Fransisco Gabriel Van Langenhove (<a href="https://github.com/Gabriellvl">@Gabriellvl</a>)</li> |  | ||||||
| <li>Technische lead: Tibo De Peuter (<a href="https://github.com/tdpeuter">@tdpeuter</a>)</li> |  | ||||||
| <li>Systeembeheerder: Timo De Meyst (<a href="https://github.com/kloep1">@kloep1</a>)</li> |  | ||||||
| <li>Customer relations officer: Adriaan Jacquet (<a href="https://github.com/WhisperinCheetah">@WhisperinCheetah</a>)</li> |  | ||||||
| </ul> |  | ||||||
| 
 |  | ||||||
| Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten | Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten | ||||||
| en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. | en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. | ||||||
| 
 | 
 | ||||||
| ## Installatie | ## Installatie | ||||||
| 
 | 
 | ||||||
| Om de applicatie in te stellen voor een productieomgeving, volg de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). | Om de applicatie in te stellen voor een productieomgeving, volg | ||||||
|  | de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). | ||||||
| 
 | 
 | ||||||
| Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien. | Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien. | ||||||
| 
 | 
 | ||||||
| ### Quick start | ### Quick start | ||||||
| 
 | 
 | ||||||
| 1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)). | 1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) | ||||||
|  |    en [Docker Compose](https://docs.docker.com/compose/)). | ||||||
| 2. Clone deze repository. | 2. Clone deze repository. | ||||||
| 3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar nodig. | 3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar | ||||||
|  |    nodig. | ||||||
| 4. Voer `docker compose up` uit in de root van de repository. | 4. Voer `docker compose up` uit in de root van de repository. | ||||||
| 5. Optioneel: Configureer de applicatie aan de hand van de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). | 5. Optioneel: Configureer de applicatie aan de hand van | ||||||
|  |    de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
| docker compose version | docker compose version | ||||||
|  | @ -47,7 +44,8 @@ docker compose up | ||||||
| 
 | 
 | ||||||
| ### Handmatige installatie | ### Handmatige installatie | ||||||
| 
 | 
 | ||||||
| Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) en [backend](./backend/README.md). | Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) | ||||||
|  | en [backend](./backend/README.md). | ||||||
| 
 | 
 | ||||||
| ## Architectuur | ## Architectuur | ||||||
| 
 | 
 | ||||||
|  | @ -59,8 +57,31 @@ De tech-stack bestaat uit: | ||||||
| - **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL | - **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL | ||||||
| - **Identity provider**: Keycloak | - **Identity provider**: Keycloak | ||||||
| 
 | 
 | ||||||
| Voor meer informatie over de keuze van deze tech-stack, zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes). | Voor meer informatie over de keuze van deze tech-stack, | ||||||
|  | zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes). | ||||||
|  | 
 | ||||||
|  | ## Testen | ||||||
|  | 
 | ||||||
|  | Voer volgende commando's uit om de <frontend/backend> te testen: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | npm run test:unit | ||||||
|  | ``` | ||||||
| 
 | 
 | ||||||
| ## Bijdragen aan Dwengo-1 | ## Bijdragen aan Dwengo-1 | ||||||
| 
 | 
 | ||||||
| Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1. | Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1. | ||||||
|  | 
 | ||||||
|  | Deze rocksterren hebben bijgedragen aan Dwengo-1: | ||||||
|  | 
 | ||||||
|  | | Naam                                                                                                                                                    | Functie                    | | ||||||
|  | | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | ||||||
|  | | [<img src="https://github.com/WhisperinCheetah.png" width="100px"/><br/><sub><b>Adriaan Jacquet</b></sub>](https://github.com/WhisperinCheetah)         | Backend Lead               | | ||||||
|  | | [<img src="https://github.com/Gabriellvl.png" width="100px"/><br/><sub><b>Francisco Gabriel Van Langenhove</b></sub>](https://github.com/Gabriellvl)    | Team Lead                  | | ||||||
|  | | [<img src="https://github.com/geraldschmittinger.png" width="100px"/><br/><sub><b>Gerald Schmittinger</b></sub>](https://github.com/geraldschmittinger) | Database Administrator     | | ||||||
|  | | [<img src="https://github.com/joyelle436.png" width="100px"/><br/><sub><b>Joyelle Ndagijimana</b></sub>](https://github.com/joyelle436)                 | Frontend Lead              | | ||||||
|  | | [<img src="https://github.com/laurejablonski.png" width="100px"/><br><sub><b>Laure Jablonski</b></sub>](https://github.com/laurejablonski)              | Documentatie- en Test Lead | | ||||||
|  | | [<img src="https://github.com/tdpeuter.png" width="100px"/><br/><sub><b>Tibo De Peuter</b></sub>](https://github.com/tdpeuter)                          | Technische Lead            | | ||||||
|  | | [<img src="https://github.com/kloep1.png" width="100px"/><br/><sub><b>Timo De Meyst</b></sub>](https://github.com/kloep1)                               | System Administrator       | | ||||||
|  | 
 | ||||||
|  | En in de toekomst misschien jij ook? | ||||||
|  |  | ||||||
|  | @ -21,6 +21,14 @@ npm run build | ||||||
| npm run start | npm run start | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | ### Tests | ||||||
|  | 
 | ||||||
|  | Voer volgend commando uit om de unit tests uit te voeren: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | npm run test:unit | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
| ## Keycloak configuratie | ## Keycloak configuratie | ||||||
| 
 | 
 | ||||||
| Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. | Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. | ||||||
|  |  | ||||||
|  | @ -8,4 +8,14 @@ export default [ | ||||||
|             globals: globals.node, |             globals: globals.node, | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     { | ||||||
|  |         files: ['tests/**/*.ts'], | ||||||
|  |         languageOptions: { | ||||||
|  |             globals: globals.node, | ||||||
|  |         }, | ||||||
|  |         rules: { | ||||||
|  |             'no-console': 'off', | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|  | @ -5,40 +5,46 @@ | ||||||
|     "private": true, |     "private": true, | ||||||
|     "type": "module", |     "type": "module", | ||||||
|     "scripts": { |     "scripts": { | ||||||
|         "build": "NODE_ENV=production tsc --project tsconfig.json", |         "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", | ||||||
|         "dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", |         "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", | ||||||
|         "start": "NODE_ENV=production node --env-file=.env dist/app.js", |         "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", | ||||||
|         "format": "prettier --write src/", |         "format": "prettier --write src/", | ||||||
|         "format-check": "prettier --check src/", |         "format-check": "prettier --check src/", | ||||||
|         "lint": "eslint . --fix", |         "lint": "eslint . --fix", | ||||||
|         "test:unit": "vitest" |         "test:unit": "vitest" | ||||||
|     }, |     }, | ||||||
|     "dependencies": { |     "dependencies": { | ||||||
|         "@mikro-orm/core": "6.4.6", |         "@mikro-orm/core": "6.4.9", | ||||||
|         "@mikro-orm/postgresql": "6.4.6", |         "@mikro-orm/knex": "6.4.9", | ||||||
|         "@mikro-orm/reflection": "6.4.6", |         "@mikro-orm/postgresql": "6.4.9", | ||||||
|         "@mikro-orm/sqlite": "6.4.6", |         "@mikro-orm/reflection": "6.4.9", | ||||||
|         "@types/js-yaml": "^4.0.9", |         "@mikro-orm/sqlite": "6.4.9", | ||||||
|         "axios": "^1.8.2", |         "axios": "^1.8.2", | ||||||
|  |         "cors": "^2.8.5", | ||||||
|  |         "cross": "^1.0.0", | ||||||
|  |         "cross-env": "^7.0.3", | ||||||
|         "dotenv": "^16.4.7", |         "dotenv": "^16.4.7", | ||||||
|         "express": "^5.0.1", |         "express": "^5.0.1", | ||||||
|         "express-jwt": "^8.5.1", |         "express-jwt": "^8.5.1", | ||||||
|         "jwks-rsa": "^3.1.0", |         "gift-pegjs": "^1.0.2", | ||||||
|         "uuid": "^11.1.0", |         "isomorphic-dompurify": "^2.22.0", | ||||||
|         "js-yaml": "^4.1.0", |         "js-yaml": "^4.1.0", | ||||||
|  |         "jsonpath-plus": "^10.3.0", | ||||||
|  |         "jwks-rsa": "^3.1.0", | ||||||
|         "loki-logger-ts": "^1.0.2", |         "loki-logger-ts": "^1.0.2", | ||||||
|  |         "marked": "^15.0.7", | ||||||
|         "response-time": "^2.3.3", |         "response-time": "^2.3.3", | ||||||
|  |         "uuid": "^11.1.0", | ||||||
|         "winston": "^3.17.0", |         "winston": "^3.17.0", | ||||||
|         "winston-loki": "^6.1.3", |         "winston-loki": "^6.1.3" | ||||||
|         "cors": "^2.8.5", |  | ||||||
|         "@types/cors": "^2.8.17" |  | ||||||
|     }, |     }, | ||||||
|     "devDependencies": { |     "devDependencies": { | ||||||
|         "@mikro-orm/cli": "6.4.6", |         "@mikro-orm/cli": "6.4.9", | ||||||
|  |         "@types/cors": "^2.8.17", | ||||||
|         "@types/express": "^5.0.0", |         "@types/express": "^5.0.0", | ||||||
|  |         "@types/js-yaml": "^4.0.9", | ||||||
|         "@types/node": "^22.13.4", |         "@types/node": "^22.13.4", | ||||||
|         "@types/response-time": "^2.3.8", |         "@types/response-time": "^2.3.8", | ||||||
|         "@types/js-yaml": "^4.0.9", |  | ||||||
|         "globals": "^15.15.0", |         "globals": "^15.15.0", | ||||||
|         "ts-node": "^10.9.2", |         "ts-node": "^10.9.2", | ||||||
|         "tsx": "^4.19.3", |         "tsx": "^4.19.3", | ||||||
|  |  | ||||||
|  | @ -2,8 +2,8 @@ import express, { Express, Response } from 'express'; | ||||||
| import { initORM } from './orm.js'; | import { initORM } from './orm.js'; | ||||||
| 
 | 
 | ||||||
| import themeRoutes from './routes/themes.js'; | import themeRoutes from './routes/themes.js'; | ||||||
| import learningPathRoutes from './routes/learningPaths.js'; | import learningPathRoutes from './routes/learning-paths.js'; | ||||||
| import learningObjectRoutes from './routes/learningObjects.js'; | import learningObjectRoutes from './routes/learning-objects.js'; | ||||||
| 
 | 
 | ||||||
| import studentRouter from './routes/student.js'; | import studentRouter from './routes/student.js'; | ||||||
| import groupRouter from './routes/group.js'; | import groupRouter from './routes/group.js'; | ||||||
|  |  | ||||||
|  | @ -1,10 +1,9 @@ | ||||||
| export const FALLBACK_LANG: string = 'nl'; | import { EnvVars, getEnvVar } from './util/envvars.js'; | ||||||
| 
 | 
 | ||||||
| // API
 | // API
 | ||||||
| 
 | export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); | ||||||
| export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api'; | export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); | ||||||
| 
 | 
 | ||||||
| // Logging
 | // Logging
 | ||||||
| 
 |  | ||||||
| export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; | export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; | ||||||
| export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102'; | export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102'; | ||||||
|  |  | ||||||
							
								
								
									
										69
									
								
								backend/src/controllers/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								backend/src/controllers/learning-objects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { FALLBACK_LANG } from '../config.js'; | ||||||
|  | import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js'; | ||||||
|  | import learningObjectService from '../services/learning-objects/learning-object-service.js'; | ||||||
|  | import { EnvVars, getEnvVar } from '../util/envvars.js'; | ||||||
|  | import { Language } from '../entities/content/language.js'; | ||||||
|  | import { BadRequestException } from '../exceptions.js'; | ||||||
|  | import attachmentService from '../services/learning-objects/attachment-service.js'; | ||||||
|  | import { NotFoundError } from '@mikro-orm/core'; | ||||||
|  | 
 | ||||||
|  | function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { | ||||||
|  |     if (!req.params.hruid) { | ||||||
|  |         throw new BadRequestException('HRUID is required.'); | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |         hruid: req.params.hruid as string, | ||||||
|  |         language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language, | ||||||
|  |         version: parseInt(req.query.version as string), | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { | ||||||
|  |     if (!req.query.hruid) { | ||||||
|  |         throw new BadRequestException('HRUID is required.'); | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |         hruid: req.params.hruid as string, | ||||||
|  |         language: (req.query.language as Language) || FALLBACK_LANG, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { | ||||||
|  |     const learningPathId = getLearningPathIdentifierFromRequest(req); | ||||||
|  |     const full = req.query.full; | ||||||
|  | 
 | ||||||
|  |     let learningObjects: FilteredLearningObject[] | string[]; | ||||||
|  |     if (full) { | ||||||
|  |         learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); | ||||||
|  |     } else { | ||||||
|  |         learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     res.json(learningObjects); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function getLearningObject(req: Request, res: Response): Promise<void> { | ||||||
|  |     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||||
|  | 
 | ||||||
|  |     const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); | ||||||
|  |     res.json(learningObject); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function getLearningObjectHTML(req: Request, res: Response): Promise<void> { | ||||||
|  |     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||||
|  | 
 | ||||||
|  |     const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId); | ||||||
|  |     res.send(learningObject); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function getAttachment(req: Request, res: Response): Promise<void> { | ||||||
|  |     const learningObjectId = getLearningObjectIdentifierFromRequest(req); | ||||||
|  |     const name = req.params.attachmentName; | ||||||
|  |     const attachment = await attachmentService.getAttachment(learningObjectId, name); | ||||||
|  | 
 | ||||||
|  |     if (!attachment) { | ||||||
|  |         throw new NotFoundError(`Attachment ${name} not found`); | ||||||
|  |     } | ||||||
|  |     res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); | ||||||
|  | } | ||||||
							
								
								
									
										64
									
								
								backend/src/controllers/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								backend/src/controllers/learning-paths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | ||||||
|  | import { Request, Response } from 'express'; | ||||||
|  | import { themes } from '../data/themes.js'; | ||||||
|  | import { FALLBACK_LANG } from '../config.js'; | ||||||
|  | import learningPathService from '../services/learning-paths/learning-path-service.js'; | ||||||
|  | import { BadRequestException, NotFoundException } from '../exceptions.js'; | ||||||
|  | import { Language } from '../entities/content/language.js'; | ||||||
|  | import { | ||||||
|  |     PersonalizationTarget, | ||||||
|  |     personalizedForGroup, | ||||||
|  |     personalizedForStudent, | ||||||
|  | } from '../services/learning-paths/learning-path-personalization-util.js'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Fetch learning paths based on query parameters. | ||||||
|  |  */ | ||||||
|  | export async function getLearningPaths(req: Request, res: Response): Promise<void> { | ||||||
|  |     const hruids = req.query.hruid; | ||||||
|  |     const themeKey = req.query.theme as string; | ||||||
|  |     const searchQuery = req.query.search as string; | ||||||
|  |     const language = (req.query.language as string) || FALLBACK_LANG; | ||||||
|  | 
 | ||||||
|  |     const forStudent = req.query.forStudent as string; | ||||||
|  |     const forGroupNo = req.query.forGroup as string; | ||||||
|  |     const assignmentNo = req.query.assignmentNo as string; | ||||||
|  |     const classId = req.query.classId as string; | ||||||
|  | 
 | ||||||
|  |     let personalizationTarget: PersonalizationTarget | undefined; | ||||||
|  | 
 | ||||||
|  |     if (forStudent) { | ||||||
|  |         personalizationTarget = await personalizedForStudent(forStudent); | ||||||
|  |     } else if (forGroupNo) { | ||||||
|  |         if (!assignmentNo || !classId) { | ||||||
|  |             throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); | ||||||
|  |         } | ||||||
|  |         personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo)); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     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, personalizationTarget); | ||||||
|  |         res.json(searchResults); | ||||||
|  |         return; | ||||||
|  |     } else { | ||||||
|  |         hruidList = themes.flatMap((theme) => theme.hruids); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const learningPaths = await learningPathService.fetchLearningPaths( | ||||||
|  |         hruidList, | ||||||
|  |         language as Language, | ||||||
|  |         `HRUIDs: ${hruidList.join(', ')}`, | ||||||
|  |         personalizationTarget | ||||||
|  |     ); | ||||||
|  |     res.json(learningPaths.data); | ||||||
|  | } | ||||||
|  | @ -1,48 +0,0 @@ | ||||||
| import { Request, Response } from 'express'; |  | ||||||
| import { getLearningObjectById, getLearningObjectIdsFromPath, getLearningObjectsFromPath } from '../services/learningObjects.js'; |  | ||||||
| import { FALLBACK_LANG } from '../config.js'; |  | ||||||
| import { FilteredLearningObject } from '../interfaces/learningPath.js'; |  | ||||||
| import { getLogger } from '../logging/initalize.js'; |  | ||||||
| 
 |  | ||||||
| export async function getAllLearningObjects(req: Request, res: Response): Promise<void> { |  | ||||||
|     try { |  | ||||||
|         const hruid = req.query.hruid as string; |  | ||||||
|         const full = req.query.full === 'true'; |  | ||||||
|         const language = (req.query.language as string) || FALLBACK_LANG; |  | ||||||
| 
 |  | ||||||
|         if (!hruid) { |  | ||||||
|             res.status(400).json({ error: 'HRUID query is required.' }); |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         let learningObjects: FilteredLearningObject[] | string[]; |  | ||||||
|         if (full) { |  | ||||||
|             learningObjects = await getLearningObjectsFromPath(hruid, language); |  | ||||||
|         } else { |  | ||||||
|             learningObjects = await getLearningObjectIdsFromPath(hruid, language); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         res.json(learningObjects); |  | ||||||
|     } catch (error) { |  | ||||||
|         getLogger().error('Error fetching learning objects:', error); |  | ||||||
|         res.status(500).json({ error: 'Internal server error' }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export async function getLearningObject(req: Request, res: Response): Promise<void> { |  | ||||||
|     try { |  | ||||||
|         const { hruid } = req.params; |  | ||||||
|         const language = (req.query.language as string) || FALLBACK_LANG; |  | ||||||
| 
 |  | ||||||
|         if (!hruid) { |  | ||||||
|             res.status(400).json({ error: 'HRUID parameter is required.' }); |  | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         const learningObject = await getLearningObjectById(hruid, language); |  | ||||||
|         res.json(learningObject); |  | ||||||
|     } catch (error) { |  | ||||||
|         getLogger().error('Error fetching learning object:', error); |  | ||||||
|         res.status(500).json({ error: 'Internal server error' }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -1,8 +1,9 @@ | ||||||
| import { Request, Response } from 'express'; | import { Request, Response } from 'express'; | ||||||
| import { themes } from '../data/themes.js'; | import { themes } from '../data/themes.js'; | ||||||
| import { FALLBACK_LANG } from '../config.js'; | import { FALLBACK_LANG } from '../config.js'; | ||||||
| import { fetchLearningPaths, searchLearningPaths } from '../services/learningPaths.js'; |  | ||||||
| import { getLogger } from '../logging/initalize.js'; | import { getLogger } from '../logging/initalize.js'; | ||||||
|  | import learningPathService from '../services/learning-paths/learning-path-service.js'; | ||||||
|  | import { Language } from '../entities/content/language.js'; | ||||||
| /** | /** | ||||||
|  * Fetch learning paths based on query parameters. |  * Fetch learning paths based on query parameters. | ||||||
|  */ |  */ | ||||||
|  | @ -11,7 +12,7 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi | ||||||
|         const hruids = req.query.hruid; |         const hruids = req.query.hruid; | ||||||
|         const themeKey = req.query.theme as string; |         const themeKey = req.query.theme as string; | ||||||
|         const searchQuery = req.query.search as string; |         const searchQuery = req.query.search as string; | ||||||
|         const language = (req.query.language as string) || FALLBACK_LANG; |         const language = (req.query.language as Language) || FALLBACK_LANG; | ||||||
| 
 | 
 | ||||||
|         let hruidList; |         let hruidList; | ||||||
| 
 | 
 | ||||||
|  | @ -28,14 +29,14 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|         } else if (searchQuery) { |         } else if (searchQuery) { | ||||||
|             const searchResults = await searchLearningPaths(searchQuery, language); |             const searchResults = await learningPathService.searchLearningPaths(searchQuery, language); | ||||||
|             res.json(searchResults); |             res.json(searchResults); | ||||||
|             return; |             return; | ||||||
|         } else { |         } else { | ||||||
|             hruidList = themes.flatMap((theme) => theme.hruids); |             hruidList = themes.flatMap((theme) => theme.hruids); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const learningPaths = await fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); |         const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); | ||||||
|         res.json(learningPaths.data); |         res.json(learningPaths.data); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|         getLogger().error('❌ Unexpected error fetching learning paths:', error); |         getLogger().error('❌ Unexpected error fetching learning paths:', error); | ||||||
|  |  | ||||||
|  | @ -1,13 +1,37 @@ | ||||||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||||
| import { Attachment } from '../../entities/content/attachment.entity.js'; | import { Attachment } from '../../entities/content/attachment.entity.js'; | ||||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | import { Language } from '../../entities/content/language'; | ||||||
|  | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier'; | ||||||
| 
 | 
 | ||||||
| export class AttachmentRepository extends DwengoEntityRepository<Attachment> { | export class AttachmentRepository extends DwengoEntityRepository<Attachment> { | ||||||
|     public findByLearningObjectAndNumber(learningObject: LearningObject, sequenceNumber: number) { |     public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> { | ||||||
|         return this.findOne({ |         return this.findOne({ | ||||||
|             learningObject: learningObject, |             learningObject: { | ||||||
|             sequenceNumber: sequenceNumber, |                 hruid: learningObjectId.hruid, | ||||||
|  |                 language: learningObjectId.language, | ||||||
|  |                 version: learningObjectId.version, | ||||||
|  |             }, | ||||||
|  |             name: name, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise<Attachment | null> { | ||||||
|  |         return this.findOne( | ||||||
|  |             { | ||||||
|  |                 learningObject: { | ||||||
|  |                     hruid: hruid, | ||||||
|  |                     language: language, | ||||||
|  |                 }, | ||||||
|  |                 name: attachmentName, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 orderBy: { | ||||||
|  |                     learningObject: { | ||||||
|  |                         version: 'DESC', | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 |     // This repository is read-only for now since creating own learning object is an extension feature.
 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,14 +1,34 @@ | ||||||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||||
|  | import { Language } from '../../entities/content/language'; | ||||||
| 
 | 
 | ||||||
| export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | ||||||
|     public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { |     public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||||
|         return this.findOne({ |         return this.findOne( | ||||||
|  |             { | ||||||
|                 hruid: identifier.hruid, |                 hruid: identifier.hruid, | ||||||
|                 language: identifier.language, |                 language: identifier.language, | ||||||
|                 version: identifier.version, |                 version: identifier.version, | ||||||
|         }); |             }, | ||||||
|  |             { | ||||||
|  |                 populate: ['keywords'], | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public findLatestByHruidAndLanguage(hruid: string, language: Language) { | ||||||
|  |         return this.findOne( | ||||||
|  |             { | ||||||
|  |                 hruid: hruid, | ||||||
|  |                 language: language, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 populate: ['keywords'], | ||||||
|  |                 orderBy: { | ||||||
|  |                     version: 'DESC', | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,7 +4,23 @@ import { Language } from '../../entities/content/language.js'; | ||||||
| 
 | 
 | ||||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||||
|     public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { |     public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||||
|         return this.findOne({ hruid: hruid, language: language }); |         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Returns all learning paths which have the given language and whose title OR description contains the | ||||||
|  |      * query string. | ||||||
|  |      * | ||||||
|  |      * @param query The query string we want to seach for in the title or description. | ||||||
|  |      * @param language The language of the learning paths we want to find. | ||||||
|  |      */ | ||||||
|  |     public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> { | ||||||
|  |         return this.findAll({ | ||||||
|  |             where: { | ||||||
|  |                 language: language, | ||||||
|  |                 $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], | ||||||
|  |             }, | ||||||
|  |             populate: ['nodes', 'nodes.transitions'], | ||||||
|  |         }); | ||||||
|     } |     } | ||||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -5,10 +5,12 @@ import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class AnswerRepository extends DwengoEntityRepository<Answer> { | export class AnswerRepository extends DwengoEntityRepository<Answer> { | ||||||
|     public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> { |     public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> { | ||||||
|         const answerEntity = new Answer(); |         const answerEntity = this.create({ | ||||||
|         answerEntity.toQuestion = answer.toQuestion; |             toQuestion: answer.toQuestion, | ||||||
|         answerEntity.author = answer.author; |             author: answer.author, | ||||||
|         answerEntity.content = answer.content; |             content: answer.content, | ||||||
|  |             timestamp: new Date(), | ||||||
|  |         }); | ||||||
|         return this.insert(answerEntity); |         return this.insert(answerEntity); | ||||||
|     } |     } | ||||||
|     public findAllAnswersToQuestion(question: Question): Promise<Answer[]> { |     public findAllAnswersToQuestion(question: Question): Promise<Answer[]> { | ||||||
|  |  | ||||||
|  | @ -5,7 +5,14 @@ import { Student } from '../../entities/users/student.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class QuestionRepository extends DwengoEntityRepository<Question> { | export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||||
|     public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> { |     public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> { | ||||||
|         const questionEntity = new Question(); |         const questionEntity = this.create({ | ||||||
|  |             learningObjectHruid: question.loId.hruid, | ||||||
|  |             learningObjectLanguage: question.loId.language, | ||||||
|  |             learningObjectVersion: question.loId.version, | ||||||
|  |             author: question.author, | ||||||
|  |             content: question.content, | ||||||
|  |             timestamp: new Date(), | ||||||
|  |         }); | ||||||
|         questionEntity.learningObjectHruid = question.loId.hruid; |         questionEntity.learningObjectHruid = question.loId.hruid; | ||||||
|         questionEntity.learningObjectLanguage = question.loId.language; |         questionEntity.learningObjectLanguage = question.loId.language; | ||||||
|         questionEntity.learningObjectVersion = question.loId.version; |         questionEntity.learningObjectVersion = question.loId.version; | ||||||
|  |  | ||||||
|  | @ -28,6 +28,8 @@ import { LearningPath } from '../entities/content/learning-path.entity.js'; | ||||||
| import { LearningPathRepository } from './content/learning-path-repository.js'; | import { LearningPathRepository } from './content/learning-path-repository.js'; | ||||||
| import { AttachmentRepository } from './content/attachment-repository.js'; | import { AttachmentRepository } from './content/attachment-repository.js'; | ||||||
| import { Attachment } from '../entities/content/attachment.entity.js'; | import { Attachment } from '../entities/content/attachment.entity.js'; | ||||||
|  | import { LearningPathNode } from '../entities/content/learning-path-node.entity.js'; | ||||||
|  | import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js'; | ||||||
| 
 | 
 | ||||||
| let entityManager: EntityManager | undefined; | let entityManager: EntityManager | undefined; | ||||||
| 
 | 
 | ||||||
|  | @ -59,7 +61,7 @@ export const getTeacherRepository = repositoryGetter<Teacher, TeacherRepository> | ||||||
| /* Classes */ | /* Classes */ | ||||||
| export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class); | export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class); | ||||||
| export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest); | export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest); | ||||||
| export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitationRepository); | export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitation); | ||||||
| 
 | 
 | ||||||
| /* Assignments */ | /* Assignments */ | ||||||
| export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment); | export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment); | ||||||
|  | @ -73,4 +75,6 @@ export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(An | ||||||
| /* Learning content */ | /* Learning content */ | ||||||
| export const getLearningObjectRepository = repositoryGetter<LearningObject, LearningObjectRepository>(LearningObject); | export const getLearningObjectRepository = repositoryGetter<LearningObject, LearningObjectRepository>(LearningObject); | ||||||
| export const getLearningPathRepository = repositoryGetter<LearningPath, LearningPathRepository>(LearningPath); | export const getLearningPathRepository = repositoryGetter<LearningPath, LearningPathRepository>(LearningPath); | ||||||
| export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Assignment); | export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode); | ||||||
|  | export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition); | ||||||
|  | export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Attachment); | ||||||
|  |  | ||||||
|  | @ -2,8 +2,9 @@ import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro | ||||||
| import { Class } from '../classes/class.entity.js'; | import { Class } from '../classes/class.entity.js'; | ||||||
| import { Group } from './group.entity.js'; | import { Group } from './group.entity.js'; | ||||||
| import { Language } from '../content/language.js'; | import { Language } from '../content/language.js'; | ||||||
|  | import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => AssignmentRepository }) | ||||||
| export class Assignment { | export class Assignment { | ||||||
|     @ManyToOne({ entity: () => Class, primary: true }) |     @ManyToOne({ entity: () => Class, primary: true }) | ||||||
|     within!: Class; |     within!: Class; | ||||||
|  |  | ||||||
|  | @ -1,8 +1,9 @@ | ||||||
| import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; | import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; | ||||||
| import { Assignment } from './assignment.entity.js'; | import { Assignment } from './assignment.entity.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
|  | import { GroupRepository } from '../../data/assignments/group-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => GroupRepository }) | ||||||
| export class Group { | export class Group { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => Assignment, |         entity: () => Assignment, | ||||||
|  |  | ||||||
|  | @ -2,8 +2,9 @@ import { Student } from '../users/student.entity.js'; | ||||||
| import { Group } from './group.entity.js'; | import { Group } from './group.entity.js'; | ||||||
| import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Language } from '../content/language.js'; | import { Language } from '../content/language.js'; | ||||||
|  | import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => SubmissionRepository }) | ||||||
| export class Submission { | export class Submission { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     learningObjectHruid!: string; |     learningObjectHruid!: string; | ||||||
|  | @ -14,8 +15,8 @@ export class Submission { | ||||||
|     }) |     }) | ||||||
|     learningObjectLanguage!: Language; |     learningObjectLanguage!: Language; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'numeric' }) | ||||||
|     learningObjectVersion: string = '1'; |     learningObjectVersion: number = 1; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer' }) |     @PrimaryKey({ type: 'integer' }) | ||||||
|     submissionNumber!: number; |     submissionNumber!: number; | ||||||
|  |  | ||||||
|  | @ -1,8 +1,9 @@ | ||||||
| import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; | import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
| import { Class } from './class.entity.js'; | import { Class } from './class.entity.js'; | ||||||
|  | import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => ClassJoinRequestRepository }) | ||||||
| export class ClassJoinRequest { | export class ClassJoinRequest { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => Student, |         entity: () => Student, | ||||||
|  |  | ||||||
|  | @ -2,8 +2,9 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm | ||||||
| import { v4 } from 'uuid'; | import { v4 } from 'uuid'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
|  | import { ClassRepository } from '../../data/classes/class-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => ClassRepository }) | ||||||
| export class Class { | export class Class { | ||||||
|     @PrimaryKey() |     @PrimaryKey() | ||||||
|     classId = v4(); |     classId = v4(); | ||||||
|  |  | ||||||
|  | @ -1,11 +1,15 @@ | ||||||
| import { Entity, ManyToOne } from '@mikro-orm/core'; | import { Entity, ManyToOne } from '@mikro-orm/core'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { Class } from './class.entity.js'; | import { Class } from './class.entity.js'; | ||||||
|  | import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Invitation of a teacher into a class (in order to teach it). |  * Invitation of a teacher into a class (in order to teach it). | ||||||
|  */ |  */ | ||||||
| @Entity() | @Entity({ repository: () => TeacherInvitationRepository }) | ||||||
|  | @Entity({ | ||||||
|  |     repository: () => TeacherInvitationRepository, | ||||||
|  | }) | ||||||
| export class TeacherInvitation { | export class TeacherInvitation { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => Teacher, |         entity: () => Teacher, | ||||||
|  |  | ||||||
|  | @ -1,7 +1,8 @@ | ||||||
| import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { LearningObject } from './learning-object.entity.js'; | import { LearningObject } from './learning-object.entity.js'; | ||||||
|  | import { AttachmentRepository } from '../../data/content/attachment-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => AttachmentRepository }) | ||||||
| export class Attachment { | export class Attachment { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => LearningObject, |         entity: () => LearningObject, | ||||||
|  | @ -9,8 +10,8 @@ export class Attachment { | ||||||
|     }) |     }) | ||||||
|     learningObject!: LearningObject; |     learningObject!: LearningObject; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     sequenceNumber!: number; |     name!: string; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     mimeType!: string; |     mimeType!: string; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,186 @@ | ||||||
| export enum Language { | export enum Language { | ||||||
|  |     Afar = 'aa', | ||||||
|  |     Abkhazian = 'ab', | ||||||
|  |     Afrikaans = 'af', | ||||||
|  |     Akan = 'ak', | ||||||
|  |     Albanian = 'sq', | ||||||
|  |     Amharic = 'am', | ||||||
|  |     Arabic = 'ar', | ||||||
|  |     Aragonese = 'an', | ||||||
|  |     Armenian = 'hy', | ||||||
|  |     Assamese = 'as', | ||||||
|  |     Avaric = 'av', | ||||||
|  |     Avestan = 'ae', | ||||||
|  |     Aymara = 'ay', | ||||||
|  |     Azerbaijani = 'az', | ||||||
|  |     Bashkir = 'ba', | ||||||
|  |     Bambara = 'bm', | ||||||
|  |     Basque = 'eu', | ||||||
|  |     Belarusian = 'be', | ||||||
|  |     Bengali = 'bn', | ||||||
|  |     Bihari = 'bh', | ||||||
|  |     Bislama = 'bi', | ||||||
|  |     Bosnian = 'bs', | ||||||
|  |     Breton = 'br', | ||||||
|  |     Bulgarian = 'bg', | ||||||
|  |     Burmese = 'my', | ||||||
|  |     Catalan = 'ca', | ||||||
|  |     Chamorro = 'ch', | ||||||
|  |     Chechen = 'ce', | ||||||
|  |     Chinese = 'zh', | ||||||
|  |     ChurchSlavic = 'cu', | ||||||
|  |     Chuvash = 'cv', | ||||||
|  |     Cornish = 'kw', | ||||||
|  |     Corsican = 'co', | ||||||
|  |     Cree = 'cr', | ||||||
|  |     Czech = 'cs', | ||||||
|  |     Danish = 'da', | ||||||
|  |     Divehi = 'dv', | ||||||
|     Dutch = 'nl', |     Dutch = 'nl', | ||||||
|     French = 'fr', |     Dzongkha = 'dz', | ||||||
|     English = 'en', |     English = 'en', | ||||||
|     Germany = 'de', |     Esperanto = 'eo', | ||||||
|  |     Estonian = 'et', | ||||||
|  |     Ewe = 'ee', | ||||||
|  |     Faroese = 'fo', | ||||||
|  |     Fijian = 'fj', | ||||||
|  |     Finnish = 'fi', | ||||||
|  |     French = 'fr', | ||||||
|  |     Frisian = 'fy', | ||||||
|  |     Fulah = 'ff', | ||||||
|  |     Georgian = 'ka', | ||||||
|  |     German = 'de', | ||||||
|  |     Gaelic = 'gd', | ||||||
|  |     Irish = 'ga', | ||||||
|  |     Galician = 'gl', | ||||||
|  |     Manx = 'gv', | ||||||
|  |     Greek = 'el', | ||||||
|  |     Guarani = 'gn', | ||||||
|  |     Gujarati = 'gu', | ||||||
|  |     Haitian = 'ht', | ||||||
|  |     Hausa = 'ha', | ||||||
|  |     Hebrew = 'he', | ||||||
|  |     Herero = 'hz', | ||||||
|  |     Hindi = 'hi', | ||||||
|  |     HiriMotu = 'ho', | ||||||
|  |     Croatian = 'hr', | ||||||
|  |     Hungarian = 'hu', | ||||||
|  |     Igbo = 'ig', | ||||||
|  |     Icelandic = 'is', | ||||||
|  |     Ido = 'io', | ||||||
|  |     SichuanYi = 'ii', | ||||||
|  |     Inuktitut = 'iu', | ||||||
|  |     Interlingue = 'ie', | ||||||
|  |     Interlingua = 'ia', | ||||||
|  |     Indonesian = 'id', | ||||||
|  |     Inupiaq = 'ik', | ||||||
|  |     Italian = 'it', | ||||||
|  |     Javanese = 'jv', | ||||||
|  |     Japanese = 'ja', | ||||||
|  |     Kalaallisut = 'kl', | ||||||
|  |     Kannada = 'kn', | ||||||
|  |     Kashmiri = 'ks', | ||||||
|  |     Kanuri = 'kr', | ||||||
|  |     Kazakh = 'kk', | ||||||
|  |     Khmer = 'km', | ||||||
|  |     Kikuyu = 'ki', | ||||||
|  |     Kinyarwanda = 'rw', | ||||||
|  |     Kirghiz = 'ky', | ||||||
|  |     Komi = 'kv', | ||||||
|  |     Kongo = 'kg', | ||||||
|  |     Korean = 'ko', | ||||||
|  |     Kuanyama = 'kj', | ||||||
|  |     Kurdish = 'ku', | ||||||
|  |     Lao = 'lo', | ||||||
|  |     Latin = 'la', | ||||||
|  |     Latvian = 'lv', | ||||||
|  |     Limburgan = 'li', | ||||||
|  |     Lingala = 'ln', | ||||||
|  |     Lithuanian = 'lt', | ||||||
|  |     Luxembourgish = 'lb', | ||||||
|  |     LubaKatanga = 'lu', | ||||||
|  |     Ganda = 'lg', | ||||||
|  |     Macedonian = 'mk', | ||||||
|  |     Marshallese = 'mh', | ||||||
|  |     Malayalam = 'ml', | ||||||
|  |     Maori = 'mi', | ||||||
|  |     Marathi = 'mr', | ||||||
|  |     Malay = 'ms', | ||||||
|  |     Malagasy = 'mg', | ||||||
|  |     Maltese = 'mt', | ||||||
|  |     Mongolian = 'mn', | ||||||
|  |     Nauru = 'na', | ||||||
|  |     Navajo = 'nv', | ||||||
|  |     SouthNdebele = 'nr', | ||||||
|  |     NorthNdebele = 'nd', | ||||||
|  |     Ndonga = 'ng', | ||||||
|  |     Nepali = 'ne', | ||||||
|  |     NorwegianNynorsk = 'nn', | ||||||
|  |     NorwegianBokmal = 'nb', | ||||||
|  |     Norwegian = 'no', | ||||||
|  |     Chichewa = 'ny', | ||||||
|  |     Occitan = 'oc', | ||||||
|  |     Ojibwa = 'oj', | ||||||
|  |     Oriya = 'or', | ||||||
|  |     Oromo = 'om', | ||||||
|  |     Ossetian = 'os', | ||||||
|  |     Punjabi = 'pa', | ||||||
|  |     Persian = 'fa', | ||||||
|  |     Pali = 'pi', | ||||||
|  |     Polish = 'pl', | ||||||
|  |     Portuguese = 'pt', | ||||||
|  |     Pashto = 'ps', | ||||||
|  |     Quechua = 'qu', | ||||||
|  |     Romansh = 'rm', | ||||||
|  |     Romanian = 'ro', | ||||||
|  |     Rundi = 'rn', | ||||||
|  |     Russian = 'ru', | ||||||
|  |     Sango = 'sg', | ||||||
|  |     Sanskrit = 'sa', | ||||||
|  |     Sinhala = 'si', | ||||||
|  |     Slovak = 'sk', | ||||||
|  |     Slovenian = 'sl', | ||||||
|  |     NorthernSami = 'se', | ||||||
|  |     Samoan = 'sm', | ||||||
|  |     Shona = 'sn', | ||||||
|  |     Sindhi = 'sd', | ||||||
|  |     Somali = 'so', | ||||||
|  |     Sotho = 'st', | ||||||
|  |     Spanish = 'es', | ||||||
|  |     Sardinian = 'sc', | ||||||
|  |     Serbian = 'sr', | ||||||
|  |     Swati = 'ss', | ||||||
|  |     Sundanese = 'su', | ||||||
|  |     Swahili = 'sw', | ||||||
|  |     Swedish = 'sv', | ||||||
|  |     Tahitian = 'ty', | ||||||
|  |     Tamil = 'ta', | ||||||
|  |     Tatar = 'tt', | ||||||
|  |     Telugu = 'te', | ||||||
|  |     Tajik = 'tg', | ||||||
|  |     Tagalog = 'tl', | ||||||
|  |     Thai = 'th', | ||||||
|  |     Tibetan = 'bo', | ||||||
|  |     Tigrinya = 'ti', | ||||||
|  |     Tonga = 'to', | ||||||
|  |     Tswana = 'tn', | ||||||
|  |     Tsonga = 'ts', | ||||||
|  |     Turkmen = 'tk', | ||||||
|  |     Turkish = 'tr', | ||||||
|  |     Twi = 'tw', | ||||||
|  |     Uighur = 'ug', | ||||||
|  |     Ukrainian = 'uk', | ||||||
|  |     Urdu = 'ur', | ||||||
|  |     Uzbek = 'uz', | ||||||
|  |     Venda = 've', | ||||||
|  |     Vietnamese = 'vi', | ||||||
|  |     Volapuk = 'vo', | ||||||
|  |     Welsh = 'cy', | ||||||
|  |     Walloon = 'wa', | ||||||
|  |     Wolof = 'wo', | ||||||
|  |     Xhosa = 'xh', | ||||||
|  |     Yiddish = 'yi', | ||||||
|  |     Yoruba = 'yo', | ||||||
|  |     Zhuang = 'za', | ||||||
|  |     Zulu = 'zu', | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,6 +4,6 @@ export class LearningObjectIdentifier { | ||||||
|     constructor( |     constructor( | ||||||
|         public hruid: string, |         public hruid: string, | ||||||
|         public language: Language, |         public language: Language, | ||||||
|         public version: string |         public version: number | ||||||
|     ) {} |     ) {} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,6 +2,9 @@ import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, | ||||||
| import { Language } from './language.js'; | import { Language } from './language.js'; | ||||||
| import { Attachment } from './attachment.entity.js'; | import { Attachment } from './attachment.entity.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
|  | import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | ||||||
|  | import { v4 } from 'uuid'; | ||||||
|  | import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Embeddable() | @Embeddable() | ||||||
| export class EducationalGoal { | export class EducationalGoal { | ||||||
|  | @ -21,7 +24,7 @@ export class ReturnValue { | ||||||
|     callbackSchema!: string; |     callbackSchema!: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => LearningObjectRepository }) | ||||||
| export class LearningObject { | export class LearningObject { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     hruid!: string; |     hruid!: string; | ||||||
|  | @ -32,8 +35,11 @@ export class LearningObject { | ||||||
|     }) |     }) | ||||||
|     language!: Language; |     language!: Language; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'number' }) | ||||||
|     version: string = '1'; |     version: number = 1; | ||||||
|  | 
 | ||||||
|  |     @Property({ type: 'uuid', unique: true }) | ||||||
|  |     uuid = v4(); | ||||||
| 
 | 
 | ||||||
|     @ManyToMany({ |     @ManyToMany({ | ||||||
|         entity: () => Teacher, |         entity: () => Teacher, | ||||||
|  | @ -47,19 +53,19 @@ export class LearningObject { | ||||||
|     description!: string; |     description!: string; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     contentType!: string; |     contentType!: DwengoContentType; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'array' }) |     @Property({ type: 'array' }) | ||||||
|     keywords: string[] = []; |     keywords: string[] = []; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'array', nullable: true }) |     @Property({ type: 'array', nullable: true }) | ||||||
|     targetAges?: number[]; |     targetAges?: number[] = []; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'bool' }) |     @Property({ type: 'bool' }) | ||||||
|     teacherExclusive: boolean = false; |     teacherExclusive: boolean = false; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'array' }) |     @Property({ type: 'array' }) | ||||||
|     skosConcepts!: string[]; |     skosConcepts: string[] = []; | ||||||
| 
 | 
 | ||||||
|     @Embedded({ |     @Embedded({ | ||||||
|         entity: () => EducationalGoal, |         entity: () => EducationalGoal, | ||||||
|  | @ -76,8 +82,8 @@ export class LearningObject { | ||||||
|     @Property({ type: 'smallint', nullable: true }) |     @Property({ type: 'smallint', nullable: true }) | ||||||
|     difficulty?: number; |     difficulty?: number; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'integer' }) |     @Property({ type: 'integer', nullable: true }) | ||||||
|     estimatedTime!: number; |     estimatedTime?: number; | ||||||
| 
 | 
 | ||||||
|     @Embedded({ |     @Embedded({ | ||||||
|         entity: () => ReturnValue, |         entity: () => ReturnValue, | ||||||
|  | @ -99,12 +105,3 @@ export class LearningObject { | ||||||
|     @Property({ type: 'blob' }) |     @Property({ type: 'blob' }) | ||||||
|     content!: Buffer; |     content!: Buffer; | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export enum ContentType { |  | ||||||
|     Markdown = 'text/markdown', |  | ||||||
|     Image = 'image/image', |  | ||||||
|     Mpeg = 'audio/mpeg', |  | ||||||
|     Pdf = 'application/pdf', |  | ||||||
|     Extern = 'extern', |  | ||||||
|     Blockly = 'Blockly', |  | ||||||
| } |  | ||||||
|  |  | ||||||
							
								
								
									
										37
									
								
								backend/src/entities/content/learning-path-node.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								backend/src/entities/content/learning-path-node.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | ||||||
|  | import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | ||||||
|  | import { Language } from './language.js'; | ||||||
|  | import { LearningPath } from './learning-path.entity.js'; | ||||||
|  | import { LearningPathTransition } from './learning-path-transition.entity.js'; | ||||||
|  | 
 | ||||||
|  | @Entity() | ||||||
|  | export class LearningPathNode { | ||||||
|  |     @ManyToOne({ entity: () => LearningPath, primary: true }) | ||||||
|  |     learningPath!: Rel<LearningPath>; | ||||||
|  | 
 | ||||||
|  |     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||||
|  |     nodeNumber!: number; | ||||||
|  | 
 | ||||||
|  |     @Property({ type: 'string' }) | ||||||
|  |     learningObjectHruid!: string; | ||||||
|  | 
 | ||||||
|  |     @Enum({ items: () => Language }) | ||||||
|  |     language!: Language; | ||||||
|  | 
 | ||||||
|  |     @Property({ type: 'number' }) | ||||||
|  |     version!: number; | ||||||
|  | 
 | ||||||
|  |     @Property({ type: 'text', nullable: true }) | ||||||
|  |     instruction?: string; | ||||||
|  | 
 | ||||||
|  |     @Property({ type: 'bool' }) | ||||||
|  |     startNode!: boolean; | ||||||
|  | 
 | ||||||
|  |     @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) | ||||||
|  |     transitions: LearningPathTransition[] = []; | ||||||
|  | 
 | ||||||
|  |     @Property({ length: 3 }) | ||||||
|  |     createdAt: Date = new Date(); | ||||||
|  | 
 | ||||||
|  |     @Property({ length: 3, onUpdate: () => new Date() }) | ||||||
|  |     updatedAt: Date = new Date(); | ||||||
|  | } | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | import { Entity, ManyToOne, PrimaryKey, Property, Rel } from '@mikro-orm/core'; | ||||||
|  | import { LearningPathNode } from './learning-path-node.entity.js'; | ||||||
|  | 
 | ||||||
|  | @Entity() | ||||||
|  | export class LearningPathTransition { | ||||||
|  |     @ManyToOne({ entity: () => LearningPathNode, primary: true }) | ||||||
|  |     node!: Rel<LearningPathNode>; | ||||||
|  | 
 | ||||||
|  |     @PrimaryKey({ type: 'numeric' }) | ||||||
|  |     transitionNumber!: number; | ||||||
|  | 
 | ||||||
|  |     @Property({ type: 'string' }) | ||||||
|  |     condition!: string; | ||||||
|  | 
 | ||||||
|  |     @ManyToOne({ entity: () => LearningPathNode }) | ||||||
|  |     next!: Rel<LearningPathNode>; | ||||||
|  | } | ||||||
|  | @ -1,21 +1,18 @@ | ||||||
| import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'; | import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Language } from './language.js'; | import { Language } from './language.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
|  | import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | ||||||
|  | import { LearningPathNode } from './learning-path-node.entity.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => LearningPathRepository }) | ||||||
| export class LearningPath { | export class LearningPath { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     hruid!: string; |     hruid!: string; | ||||||
| 
 | 
 | ||||||
|     @Enum({ |     @Enum({ items: () => Language, primary: true }) | ||||||
|         items: () => Language, |  | ||||||
|         primary: true, |  | ||||||
|     }) |  | ||||||
|     language!: Language; |     language!: Language; | ||||||
| 
 | 
 | ||||||
|     @ManyToMany({ |     @ManyToMany({ entity: () => Teacher }) | ||||||
|         entity: () => Teacher, |  | ||||||
|     }) |  | ||||||
|     admins!: Teacher[]; |     admins!: Teacher[]; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|  | @ -24,49 +21,9 @@ export class LearningPath { | ||||||
|     @Property({ type: 'text' }) |     @Property({ type: 'text' }) | ||||||
|     description!: string; |     description!: string; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'blob' }) |     @Property({ type: 'blob', nullable: true }) | ||||||
|     image!: string; |     image: Buffer | null = null; | ||||||
| 
 | 
 | ||||||
|     @Embedded({ |     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) | ||||||
|         entity: () => LearningPathNode, |  | ||||||
|         array: true, |  | ||||||
|     }) |  | ||||||
|     nodes: LearningPathNode[] = []; |     nodes: LearningPathNode[] = []; | ||||||
| } | } | ||||||
| 
 |  | ||||||
| @Embeddable() |  | ||||||
| export class LearningPathNode { |  | ||||||
|     @Property({ type: 'string' }) |  | ||||||
|     learningObjectHruid!: string; |  | ||||||
| 
 |  | ||||||
|     @Enum({ |  | ||||||
|         items: () => Language, |  | ||||||
|     }) |  | ||||||
|     language!: Language; |  | ||||||
| 
 |  | ||||||
|     @Property({ type: 'string' }) |  | ||||||
|     version!: string; |  | ||||||
| 
 |  | ||||||
|     @Property({ type: 'longtext' }) |  | ||||||
|     instruction!: string; |  | ||||||
| 
 |  | ||||||
|     @Property({ type: 'bool' }) |  | ||||||
|     startNode!: boolean; |  | ||||||
| 
 |  | ||||||
|     @Embedded({ |  | ||||||
|         entity: () => LearningPathTransition, |  | ||||||
|         array: true, |  | ||||||
|     }) |  | ||||||
|     transitions!: LearningPathTransition[]; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @Embeddable() |  | ||||||
| export class LearningPathTransition { |  | ||||||
|     @Property({ type: 'string' }) |  | ||||||
|     condition!: string; |  | ||||||
| 
 |  | ||||||
|     @OneToOne({ |  | ||||||
|         entity: () => LearningPathNode, |  | ||||||
|     }) |  | ||||||
|     next!: LearningPathNode; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,8 +1,9 @@ | ||||||
| import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Question } from './question.entity.js'; | import { Question } from './question.entity.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
|  | import { AnswerRepository } from '../../data/questions/answer-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => AnswerRepository }) | ||||||
| export class Answer { | export class Answer { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => Teacher, |         entity: () => Teacher, | ||||||
|  | @ -16,8 +17,8 @@ export class Answer { | ||||||
|     }) |     }) | ||||||
|     toQuestion!: Question; |     toQuestion!: Question; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer' }) |     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||||
|     sequenceNumber!: number; |     sequenceNumber?: number; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'datetime' }) |     @Property({ type: 'datetime' }) | ||||||
|     timestamp: Date = new Date(); |     timestamp: Date = new Date(); | ||||||
|  |  | ||||||
|  | @ -1,8 +1,9 @@ | ||||||
| import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Language } from '../content/language.js'; | import { Language } from '../content/language.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
|  | import { QuestionRepository } from '../../data/questions/question-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => QuestionRepository }) | ||||||
| export class Question { | export class Question { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     learningObjectHruid!: string; |     learningObjectHruid!: string; | ||||||
|  | @ -13,11 +14,11 @@ export class Question { | ||||||
|     }) |     }) | ||||||
|     learningObjectLanguage!: Language; |     learningObjectLanguage!: Language; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'number' }) | ||||||
|     learningObjectVersion: string = '1'; |     learningObjectVersion: number = 1; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer' }) |     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||||
|     sequenceNumber!: number; |     sequenceNumber?: number; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => Student, |         entity: () => Student, | ||||||
|  |  | ||||||
|  | @ -1,9 +1,18 @@ | ||||||
| import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; | import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; | ||||||
| import { User } from './user.entity.js'; | import { User } from './user.entity.js'; | ||||||
| import { Class } from '../classes/class.entity.js'; | import { Class } from '../classes/class.entity.js'; | ||||||
|  | import { TeacherRepository } from '../../data/users/teacher-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity({ repository: () => TeacherRepository }) | ||||||
| export class Teacher extends User { | export class Teacher extends User { | ||||||
|     @ManyToMany(() => Class) |     @ManyToMany(() => Class) | ||||||
|     classes!: Collection<Class>; |     classes!: Collection<Class>; | ||||||
|  | 
 | ||||||
|  |     constructor( | ||||||
|  |         public username: string, | ||||||
|  |         public firstName: string, | ||||||
|  |         public lastName: string | ||||||
|  |     ) { | ||||||
|  |         super(); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,3 +1,17 @@ | ||||||
|  | /** | ||||||
|  |  * Exception for HTTP 400 Bad Request | ||||||
|  |  */ | ||||||
|  | export class BadRequestException extends Error { | ||||||
|  |     public status = 400; | ||||||
|  | 
 | ||||||
|  |     constructor(error: string) { | ||||||
|  |         super(error); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Exception for HTTP 401 Unauthorized | ||||||
|  |  */ | ||||||
| export class UnauthorizedException extends Error { | export class UnauthorizedException extends Error { | ||||||
|     status = 401; |     status = 401; | ||||||
|     constructor(message: string = 'Unauthorized') { |     constructor(message: string = 'Unauthorized') { | ||||||
|  | @ -5,9 +19,24 @@ export class UnauthorizedException extends Error { | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Exception for HTTP 403 Forbidden | ||||||
|  |  */ | ||||||
| export class ForbiddenException extends Error { | export class ForbiddenException extends Error { | ||||||
|     status = 403; |     status = 403; | ||||||
|  | 
 | ||||||
|     constructor(message: string = 'Forbidden') { |     constructor(message: string = 'Forbidden') { | ||||||
|         super(message); |         super(message); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Exception for HTTP 404 Not Found | ||||||
|  |  */ | ||||||
|  | export class NotFoundException extends Error { | ||||||
|  |     public status = 404; | ||||||
|  | 
 | ||||||
|  |     constructor(error: string) { | ||||||
|  |         super(error); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,3 +1,5 @@ | ||||||
|  | import { Language } from '../entities/content/language'; | ||||||
|  | 
 | ||||||
| export interface Transition { | export interface Transition { | ||||||
|     default: boolean; |     default: boolean; | ||||||
|     _id: string; |     _id: string; | ||||||
|  | @ -9,15 +11,22 @@ export interface Transition { | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export interface LearningObjectIdentifier { | ||||||
|  |     hruid: string; | ||||||
|  |     language: Language; | ||||||
|  |     version?: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface LearningObjectNode { | export interface LearningObjectNode { | ||||||
|     _id: string; |     _id: string; | ||||||
|     learningobject_hruid: string; |     learningobject_hruid: string; | ||||||
|     version: number; |     version: number; | ||||||
|     language: string; |     language: Language; | ||||||
|     start_node?: boolean; |     start_node?: boolean; | ||||||
|     transitions: Transition[]; |     transitions: Transition[]; | ||||||
|     created_at: string; |     created_at: string; | ||||||
|     updatedAt: string; |     updatedAt: string; | ||||||
|  |     done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface LearningPath { | export interface LearningPath { | ||||||
|  | @ -37,6 +46,11 @@ export interface LearningPath { | ||||||
|     __order: number; |     __order: number; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export interface LearningPathIdentifier { | ||||||
|  |     hruid: string; | ||||||
|  |     language: Language; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface EducationalGoal { | export interface EducationalGoal { | ||||||
|     source: string; |     source: string; | ||||||
|     id: string; |     id: string; | ||||||
|  | @ -52,7 +66,7 @@ export interface LearningObjectMetadata { | ||||||
|     uuid: string; |     uuid: string; | ||||||
|     hruid: string; |     hruid: string; | ||||||
|     version: number; |     version: number; | ||||||
|     language: string; |     language: Language; | ||||||
|     title: string; |     title: string; | ||||||
|     description: string; |     description: string; | ||||||
|     difficulty: number; |     difficulty: number; | ||||||
|  | @ -75,9 +89,9 @@ export interface FilteredLearningObject { | ||||||
|     version: number; |     version: number; | ||||||
|     title: string; |     title: string; | ||||||
|     htmlUrl: string; |     htmlUrl: string; | ||||||
|     language: string; |     language: Language; | ||||||
|     difficulty: number; |     difficulty: number; | ||||||
|     estimatedTime: number; |     estimatedTime?: number; | ||||||
|     available: boolean; |     available: boolean; | ||||||
|     teacherExclusive: boolean; |     teacherExclusive: boolean; | ||||||
|     educationalGoals: EducationalGoal[]; |     educationalGoals: EducationalGoal[]; | ||||||
|  | @ -24,6 +24,7 @@ import { LearningPath } from './entities/content/learning-path.entity.js'; | ||||||
| 
 | 
 | ||||||
| import { Answer } from './entities/questions/answer.entity.js'; | import { Answer } from './entities/questions/answer.entity.js'; | ||||||
| import { Question } from './entities/questions/question.entity.js'; | import { Question } from './entities/questions/question.entity.js'; | ||||||
|  | import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js'; | ||||||
| 
 | 
 | ||||||
| const entities = [ | const entities = [ | ||||||
|     User, |     User, | ||||||
|  | @ -47,6 +48,7 @@ function config(testingMode: boolean = false): Options { | ||||||
|         return { |         return { | ||||||
|             driver: SqliteDriver, |             driver: SqliteDriver, | ||||||
|             dbName: getEnvVar(EnvVars.DbName), |             dbName: getEnvVar(EnvVars.DbName), | ||||||
|  |             subscribers: [new SqliteAutoincrementSubscriber()], | ||||||
|             entities: entities, |             entities: entities, | ||||||
|             // EntitiesTs: entitiesTs,
 |             // EntitiesTs: entitiesTs,
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getAllLearningObjects, getLearningObject } from '../controllers/learningObjects.js'; | import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
|  | @ -21,4 +21,16 @@ router.get('/', getAllLearningObjects); | ||||||
| // Example: http://localhost:3000/learningObject/un_ai7
 | // Example: http://localhost:3000/learningObject/un_ai7
 | ||||||
| router.get('/:hruid', getLearningObject); | router.get('/:hruid', getLearningObject); | ||||||
| 
 | 
 | ||||||
|  | // Parameter: hruid of learning object
 | ||||||
|  | // 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); | ||||||
|  | 
 | ||||||
|  | // 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); | ||||||
|  | 
 | ||||||
| export default router; | export default router; | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getLearningPaths } from '../controllers/learningPaths.js'; | import { getLearningPaths } from '../controllers/learning-paths.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
							
								
								
									
										23
									
								
								backend/src/services/learning-objects/attachment-service.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								backend/src/services/learning-objects/attachment-service.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | import { getAttachmentRepository } from '../../data/repositories.js'; | ||||||
|  | import { Attachment } from '../../entities/content/attachment.entity.js'; | ||||||
|  | import { LearningObjectIdentifier } from '../../interfaces/learning-content.js'; | ||||||
|  | 
 | ||||||
|  | const attachmentService = { | ||||||
|  |     getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise<Attachment | null> { | ||||||
|  |         const attachmentRepo = getAttachmentRepository(); | ||||||
|  | 
 | ||||||
|  |         if (learningObjectId.version) { | ||||||
|  |             return attachmentRepo.findByLearningObjectIdAndName( | ||||||
|  |                 { | ||||||
|  |                     hruid: learningObjectId.hruid, | ||||||
|  |                     language: learningObjectId.language, | ||||||
|  |                     version: learningObjectId.version, | ||||||
|  |                 }, | ||||||
|  |                 attachmentName | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName); | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default attachmentService; | ||||||
|  | @ -0,0 +1,115 @@ | ||||||
|  | import { LearningObjectProvider } from './learning-object-provider.js'; | ||||||
|  | import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; | ||||||
|  | import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js'; | ||||||
|  | import { Language } from '../../entities/content/language.js'; | ||||||
|  | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
|  | import { getUrlStringForLearningObject } from '../../util/links.js'; | ||||||
|  | import processingService from './processing/processing-service.js'; | ||||||
|  | import { NotFoundError } from '@mikro-orm/core'; | ||||||
|  | import learningObjectService from './learning-object-service.js'; | ||||||
|  | import { getLogger, Logger } from '../../logging/initalize.js'; | ||||||
|  | 
 | ||||||
|  | const logger: Logger = getLogger(); | ||||||
|  | 
 | ||||||
|  | function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null { | ||||||
|  |     if (!learningObject) { | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |         key: learningObject.hruid, | ||||||
|  |         _id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field.
 | ||||||
|  |         uuid: learningObject.uuid, | ||||||
|  |         language: learningObject.language, | ||||||
|  |         version: learningObject.version, | ||||||
|  |         title: learningObject.title, | ||||||
|  |         description: learningObject.description, | ||||||
|  |         htmlUrl: getUrlStringForLearningObject(learningObject), | ||||||
|  |         available: learningObject.available, | ||||||
|  |         contentType: learningObject.contentType, | ||||||
|  |         contentLocation: learningObject.contentLocation, | ||||||
|  |         difficulty: learningObject.difficulty || 1, | ||||||
|  |         estimatedTime: learningObject.estimatedTime, | ||||||
|  |         keywords: learningObject.keywords, | ||||||
|  |         educationalGoals: learningObject.educationalGoals, | ||||||
|  |         returnValue: { | ||||||
|  |             callback_url: learningObject.returnValue.callbackUrl, | ||||||
|  |             callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), | ||||||
|  |         }, | ||||||
|  |         skosConcepts: learningObject.skosConcepts, | ||||||
|  |         targetAges: learningObject.targetAges || [], | ||||||
|  |         teacherExclusive: learningObject.teacherExclusive, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||||
|  |     const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  | 
 | ||||||
|  |     return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Service providing access to data about learning objects from the database | ||||||
|  |  */ | ||||||
|  | const databaseLearningObjectProvider: LearningObjectProvider = { | ||||||
|  |     /** | ||||||
|  |      * Fetches a single learning object by its HRUID | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { | ||||||
|  |         const learningObject = await findLearningObjectEntityById(id); | ||||||
|  |         return convertLearningObject(learningObject); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> { | ||||||
|  |         const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  | 
 | ||||||
|  |         const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); | ||||||
|  |         if (!learningObject) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id)); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch the HRUIDs of all learning objects on this path. | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> { | ||||||
|  |         const learningPathRepo = getLearningPathRepository(); | ||||||
|  | 
 | ||||||
|  |         const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); | ||||||
|  |         if (!learningPath) { | ||||||
|  |             throw new NotFoundError('The learning path with the given ID could not be found.'); | ||||||
|  |         } | ||||||
|  |         return learningPath.nodes.map((it) => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user.
 | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch the full metadata of all learning objects on this path. | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> { | ||||||
|  |         const learningPathRepo = getLearningPathRepository(); | ||||||
|  | 
 | ||||||
|  |         const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); | ||||||
|  |         if (!learningPath) { | ||||||
|  |             throw new NotFoundError('The learning path with the given ID could not be found.'); | ||||||
|  |         } | ||||||
|  |         const learningObjects = await Promise.all( | ||||||
|  |             learningPath.nodes.map((it) => { | ||||||
|  |                 const learningObject = learningObjectService.getLearningObjectById({ | ||||||
|  |                     hruid: it.learningObjectHruid, | ||||||
|  |                     language: it.language, | ||||||
|  |                     version: it.version, | ||||||
|  |                 }); | ||||||
|  |                 if (learningObject === null) { | ||||||
|  |                     logger.warn(`WARN: Learning object corresponding with node ${it} not found!`); | ||||||
|  |                 } | ||||||
|  |                 return learningObject; | ||||||
|  |             }) | ||||||
|  |         ); | ||||||
|  |         return learningObjects.filter((it) => it !== null); | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default databaseLearningObjectProvider; | ||||||
|  | @ -0,0 +1,138 @@ | ||||||
|  | import { DWENGO_API_BASE } from '../../config.js'; | ||||||
|  | import { fetchWithLogging } from '../../util/apiHelper.js'; | ||||||
|  | import { | ||||||
|  |     FilteredLearningObject, | ||||||
|  |     LearningObjectIdentifier, | ||||||
|  |     LearningObjectMetadata, | ||||||
|  |     LearningObjectNode, | ||||||
|  |     LearningPathIdentifier, | ||||||
|  |     LearningPathResponse, | ||||||
|  | } from '../../interfaces/learning-content.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'; | ||||||
|  | 
 | ||||||
|  | const logger: Logger = getLogger(); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which | ||||||
|  |  * our API should return. | ||||||
|  |  * @param data | ||||||
|  |  */ | ||||||
|  | function filterData(data: LearningObjectMetadata): FilteredLearningObject { | ||||||
|  |     return { | ||||||
|  |         key: data.hruid, // Hruid learningObject (not path)
 | ||||||
|  |         _id: data._id, | ||||||
|  |         uuid: data.uuid, | ||||||
|  |         version: data.version, | ||||||
|  |         title: data.title, | ||||||
|  |         htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content
 | ||||||
|  |         language: data.language, | ||||||
|  |         difficulty: data.difficulty, | ||||||
|  |         estimatedTime: data.estimated_time, | ||||||
|  |         available: data.available, | ||||||
|  |         teacherExclusive: data.teacher_exclusive, | ||||||
|  |         educationalGoals: data.educational_goals, // List with learningObjects
 | ||||||
|  |         keywords: data.keywords, // For search
 | ||||||
|  |         description: data.description, // For search (not an actual description)
 | ||||||
|  |         targetAges: data.target_ages, | ||||||
|  |         contentType: data.content_type, // Markdown, image, audio, etc.
 | ||||||
|  |         contentLocation: data.content_location, // If content type extern
 | ||||||
|  |         skosConcepts: data.skos_concepts, | ||||||
|  |         returnValue: data.return_value, // Callback response information
 | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs) | ||||||
|  |  */ | ||||||
|  | async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise<FilteredLearningObject[] | string[]> { | ||||||
|  |     try { | ||||||
|  |         const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths( | ||||||
|  |             [learningPathId.hruid], | ||||||
|  |             learningPathId.language, | ||||||
|  |             `Learning path for HRUID "${learningPathId.hruid}"` | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         if (!learningPathResponse.success || !learningPathResponse.data?.length) { | ||||||
|  |             logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`); | ||||||
|  |             return []; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; | ||||||
|  | 
 | ||||||
|  |         if (!full) { | ||||||
|  |             return nodes.map((node) => node.learningobject_hruid); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const objects = await Promise.all( | ||||||
|  |             nodes.map(async (node) => | ||||||
|  |                 dwengoApiLearningObjectProvider.getLearningObjectById({ | ||||||
|  |                     hruid: node.learningobject_hruid, | ||||||
|  |                     language: learningPathId.language, | ||||||
|  |                 }) | ||||||
|  |             ) | ||||||
|  |         ); | ||||||
|  |         return objects.filter((obj): obj is FilteredLearningObject => obj !== null); | ||||||
|  |     } catch (error) { | ||||||
|  |         logger.error('❌ Error fetching learning objects:', error); | ||||||
|  |         return []; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const dwengoApiLearningObjectProvider: LearningObjectProvider = { | ||||||
|  |     /** | ||||||
|  |      * Fetches a single learning object by its HRUID | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { | ||||||
|  |         const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; | ||||||
|  |         const metadata = await fetchWithLogging<LearningObjectMetadata>( | ||||||
|  |             metadataUrl, | ||||||
|  |             `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, | ||||||
|  |             { | ||||||
|  |                 params: id, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         if (!metadata || typeof metadata !== 'object') { | ||||||
|  |             logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return filterData(metadata); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch full learning object data (metadata) | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> { | ||||||
|  |         return (await fetchLearningObjects(id, true)) as FilteredLearningObject[]; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch only learning object HRUIDs | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> { | ||||||
|  |         return (await fetchLearningObjects(id, false)) as string[]; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects | ||||||
|  |      * from the Dwengo API, this means passing through the HTML rendering from there. | ||||||
|  |      */ | ||||||
|  |     async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> { | ||||||
|  |         const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; | ||||||
|  |         const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { | ||||||
|  |             params: id, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         if (!html) { | ||||||
|  |             logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return html; | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default dwengoApiLearningObjectProvider; | ||||||
|  | @ -0,0 +1,23 @@ | ||||||
|  | import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; | ||||||
|  | 
 | ||||||
|  | export interface LearningObjectProvider { | ||||||
|  |     /** | ||||||
|  |      * Fetches a single learning object by its HRUID | ||||||
|  |      */ | ||||||
|  |     getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch full learning object data (metadata) | ||||||
|  |      */ | ||||||
|  |     getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch only learning object HRUIDs | ||||||
|  |      */ | ||||||
|  |     getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||||
|  |      */ | ||||||
|  |     getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null>; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,47 @@ | ||||||
|  | import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; | ||||||
|  | import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js'; | ||||||
|  | import { LearningObjectProvider } from './learning-object-provider.js'; | ||||||
|  | import { EnvVars, getEnvVar } from '../../util/envvars.js'; | ||||||
|  | import databaseLearningObjectProvider from './database-learning-object-provider.js'; | ||||||
|  | 
 | ||||||
|  | function getProvider(id: LearningObjectIdentifier): LearningObjectProvider { | ||||||
|  |     if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) { | ||||||
|  |         return databaseLearningObjectProvider; | ||||||
|  |     } | ||||||
|  |     return dwengoApiLearningObjectProvider; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api) | ||||||
|  |  */ | ||||||
|  | const learningObjectService = { | ||||||
|  |     /** | ||||||
|  |      * Fetches a single learning object by its HRUID | ||||||
|  |      */ | ||||||
|  |     getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { | ||||||
|  |         return getProvider(id).getLearningObjectById(id); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch full learning object data (metadata) | ||||||
|  |      */ | ||||||
|  |     getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> { | ||||||
|  |         return getProvider(id).getLearningObjectsFromPath(id); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Fetch only learning object HRUIDs | ||||||
|  |      */ | ||||||
|  |     getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> { | ||||||
|  |         return getProvider(id).getLearningObjectIdsFromPath(id); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||||
|  |      */ | ||||||
|  |     getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> { | ||||||
|  |         return getProvider(id).getLearningObjectHTML(id); | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default learningObjectService; | ||||||
|  | @ -0,0 +1,25 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js
 | ||||||
|  |  * | ||||||
|  |  * WARNING: The support for audio learning objects is currently still experimental. | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | import { type } from 'node:os'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | 
 | ||||||
|  | class AudioProcessor extends StringProcessor { | ||||||
|  |     constructor() { | ||||||
|  |         super(DwengoContentType.AUDIO_MPEG); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     protected renderFn(audioUrl: string): string { | ||||||
|  |         return DOMPurify.sanitize(`<audio controls>
 | ||||||
|  |             <source src="${audioUrl}" type=${type}> | ||||||
|  |             Your browser does not support the audio element. | ||||||
|  |             </audio>`);
 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default AudioProcessor; | ||||||
|  | @ -0,0 +1,18 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | enum DwengoContentType { | ||||||
|  |     TEXT_PLAIN = 'text/plain', | ||||||
|  |     TEXT_MARKDOWN = 'text/markdown', | ||||||
|  |     IMAGE_BLOCK = 'image/image-block', | ||||||
|  |     IMAGE_INLINE = 'image/image', | ||||||
|  |     AUDIO_MPEG = 'audio/mpeg', | ||||||
|  |     APPLICATION_PDF = 'application/pdf', | ||||||
|  |     EXTERN = 'extern', | ||||||
|  |     BLOCKLY = 'blockly', | ||||||
|  |     GIFT = 'text/gift', | ||||||
|  |     CT_SCHEMA = 'text/ct-schema', | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export { DwengoContentType }; | ||||||
							
								
								
									
										40
									
								
								backend/src/services/learning-objects/processing/extern/extern-processor.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								backend/src/services/learning-objects/processing/extern/extern-processor.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js
 | ||||||
|  |  * | ||||||
|  |  * WARNING: The support for external content is currently still experimental. | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | import { ProcessingError } from '../processing-error.js'; | ||||||
|  | import { isValidHttpUrl } from '../../../../util/links.js'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | 
 | ||||||
|  | class ExternProcessor extends StringProcessor { | ||||||
|  |     constructor() { | ||||||
|  |         super(DwengoContentType.EXTERN); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(externURL: string) { | ||||||
|  |         if (!isValidHttpUrl(externURL)) { | ||||||
|  |             throw new ProcessingError('The url is not valid: ' + externURL); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // If a seperate youtube-processor would be added, this code would need to move to that processor
 | ||||||
|  |         // Converts youtube urls to youtube-embed urls
 | ||||||
|  |         const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL); | ||||||
|  |         if (match) { | ||||||
|  |             externURL = match[1] + 'embed/' + match[2]; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return DOMPurify.sanitize( | ||||||
|  |             ` | ||||||
|  |             <div class="iframe-container"> | ||||||
|  |                 <iframe src="${externURL}" allowfullscreen></iframe> | ||||||
|  |             </div>`,
 | ||||||
|  |             { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default ExternProcessor; | ||||||
|  | @ -0,0 +1,61 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | import { GIFTQuestion, parse } from 'gift-pegjs'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js'; | ||||||
|  | import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js'; | ||||||
|  | import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js'; | ||||||
|  | import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js'; | ||||||
|  | import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js'; | ||||||
|  | import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js'; | ||||||
|  | import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js'; | ||||||
|  | import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js'; | ||||||
|  | import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | 
 | ||||||
|  | class GiftProcessor extends StringProcessor { | ||||||
|  |     private renderers: RendererMap = { | ||||||
|  |         Category: new CategoryQuestionRenderer(), | ||||||
|  |         Description: new DescriptionQuestionRenderer(), | ||||||
|  |         Essay: new EssayQuestionRenderer(), | ||||||
|  |         Matching: new MatchingQuestionRenderer(), | ||||||
|  |         Numerical: new NumericalQuestionRenderer(), | ||||||
|  |         Short: new ShortQuestionRenderer(), | ||||||
|  |         TF: new TrueFalseQuestionRenderer(), | ||||||
|  |         MC: new MultipleChoiceQuestionRenderer(), | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     constructor() { | ||||||
|  |         super(DwengoContentType.GIFT); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(giftString: string) { | ||||||
|  |         const quizQuestions: GIFTQuestion[] = parse(giftString); | ||||||
|  | 
 | ||||||
|  |         let html = "<div class='learning-object-gift'>\n"; | ||||||
|  |         let i = 1; | ||||||
|  |         for (const question of quizQuestions) { | ||||||
|  |             html += `    <div class='gift-question' id='gift-q${i}'>\n`; | ||||||
|  |             html += '        ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n        $1'); // Replace for indentation.
 | ||||||
|  |             html += `    </div>\n`; | ||||||
|  |             i++; | ||||||
|  |         } | ||||||
|  |         html += '</div>\n'; | ||||||
|  | 
 | ||||||
|  |         return DOMPurify.sanitize(html); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private renderQuestion<T extends GIFTQuestion>(question: T, questionNumber: number): string { | ||||||
|  |         const renderer = this.renderers[question.type] as GIFTQuestionRenderer<T>; | ||||||
|  |         return renderer.render(question, questionNumber); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type RendererMap = { | ||||||
|  |     [K in GIFTQuestion['type']]: GIFTQuestionRenderer<Extract<GIFTQuestion, { type: K }>>; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default GiftProcessor; | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { Category } from 'gift-pegjs'; | ||||||
|  | import { ProcessingError } from '../../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | export class CategoryQuestionRenderer extends GIFTQuestionRenderer<Category> { | ||||||
|  |     render(question: Category, questionNumber: number): string { | ||||||
|  |         throw new ProcessingError("The question type 'Category' is not supported yet!"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { Description } from 'gift-pegjs'; | ||||||
|  | import { ProcessingError } from '../../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | export class DescriptionQuestionRenderer extends GIFTQuestionRenderer<Description> { | ||||||
|  |     render(question: Description, questionNumber: number): string { | ||||||
|  |         throw new ProcessingError("The question type 'Description' is not supported yet!"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,16 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { Essay } from 'gift-pegjs'; | ||||||
|  | 
 | ||||||
|  | export class EssayQuestionRenderer extends GIFTQuestionRenderer<Essay> { | ||||||
|  |     render(question: Essay, questionNumber: number): string { | ||||||
|  |         let renderedHtml = ''; | ||||||
|  |         if (question.title) { | ||||||
|  |             renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`; | ||||||
|  |         } | ||||||
|  |         if (question.stem) { | ||||||
|  |             renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`; | ||||||
|  |         } | ||||||
|  |         renderedHtml += `<textarea class='gift-essay-answer' id='gift-q${questionNumber}-answer'></textarea>\n`; | ||||||
|  |         return renderedHtml; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,14 @@ | ||||||
|  | import { GIFTQuestion } from 'gift-pegjs'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML. | ||||||
|  |  */ | ||||||
|  | export abstract class GIFTQuestionRenderer<T extends GIFTQuestion> { | ||||||
|  |     /** | ||||||
|  |      * Render the given question to HTML. | ||||||
|  |      * @param question The question. | ||||||
|  |      * @param questionNumber The index number of the question. | ||||||
|  |      * @returns The question rendered as HTML. | ||||||
|  |      */ | ||||||
|  |     abstract render(question: T, questionNumber: number): string; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { Matching } from 'gift-pegjs'; | ||||||
|  | import { ProcessingError } from '../../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | export class MatchingQuestionRenderer extends GIFTQuestionRenderer<Matching> { | ||||||
|  |     render(question: Matching, questionNumber: number): string { | ||||||
|  |         throw new ProcessingError("The question type 'Matching' is not supported yet!"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,23 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { MultipleChoice } from 'gift-pegjs'; | ||||||
|  | 
 | ||||||
|  | export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<MultipleChoice> { | ||||||
|  |     render(question: MultipleChoice, questionNumber: number): string { | ||||||
|  |         let renderedHtml = ''; | ||||||
|  |         if (question.title) { | ||||||
|  |             renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`; | ||||||
|  |         } | ||||||
|  |         if (question.stem) { | ||||||
|  |             renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`; | ||||||
|  |         } | ||||||
|  |         let i = 0; | ||||||
|  |         for (const choice of question.choices) { | ||||||
|  |             renderedHtml += `<div class="gift-choice-div">\n`; | ||||||
|  |             renderedHtml += `    <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`; | ||||||
|  |             renderedHtml += `    <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`; | ||||||
|  |             renderedHtml += `</div>\n`; | ||||||
|  |             i++; | ||||||
|  |         } | ||||||
|  |         return renderedHtml; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { Numerical } from 'gift-pegjs'; | ||||||
|  | import { ProcessingError } from '../../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | export class NumericalQuestionRenderer extends GIFTQuestionRenderer<Numerical> { | ||||||
|  |     render(question: Numerical, questionNumber: number): string { | ||||||
|  |         throw new ProcessingError("The question type 'Numerical' is not supported yet!"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { ShortAnswer } from 'gift-pegjs'; | ||||||
|  | import { ProcessingError } from '../../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | export class ShortQuestionRenderer extends GIFTQuestionRenderer<ShortAnswer> { | ||||||
|  |     render(question: ShortAnswer, questionNumber: number): string { | ||||||
|  |         throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,9 @@ | ||||||
|  | import { GIFTQuestionRenderer } from './gift-question-renderer.js'; | ||||||
|  | import { TrueFalse } from 'gift-pegjs'; | ||||||
|  | import { ProcessingError } from '../../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer<TrueFalse> { | ||||||
|  |     render(question: TrueFalse, questionNumber: number): string { | ||||||
|  |         throw new ProcessingError("The question type 'TrueFalse' is not supported yet!"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,19 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import InlineImageProcessor from './inline-image-processor.js'; | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | 
 | ||||||
|  | class BlockImageProcessor extends InlineImageProcessor { | ||||||
|  |     constructor() { | ||||||
|  |         super(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(imageUrl: string) { | ||||||
|  |         const inlineHtml = super.render(imageUrl); | ||||||
|  |         return DOMPurify.sanitize(`<div>${inlineHtml}</div>`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default BlockImageProcessor; | ||||||
|  | @ -0,0 +1,24 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import { ProcessingError } from '../processing-error.js'; | ||||||
|  | import { isValidHttpUrl } from '../../../../util/links.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | 
 | ||||||
|  | class InlineImageProcessor extends StringProcessor { | ||||||
|  |     constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) { | ||||||
|  |         super(contentType); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(imageUrl: string) { | ||||||
|  |         if (!isValidHttpUrl(imageUrl)) { | ||||||
|  |             throw new ProcessingError(`Image URL is invalid: ${imageUrl}`); | ||||||
|  |         } | ||||||
|  |         return DOMPurify.sanitize(`<img src="${imageUrl}" alt="">`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default InlineImageProcessor; | ||||||
|  | @ -0,0 +1,109 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!]
 | ||||||
|  |  */ | ||||||
|  | import PdfProcessor from '../pdf/pdf-processor.js'; | ||||||
|  | import AudioProcessor from '../audio/audio-processor.js'; | ||||||
|  | import ExternProcessor from '../extern/extern-processor.js'; | ||||||
|  | import InlineImageProcessor from '../image/inline-image-processor.js'; | ||||||
|  | import * as marked from 'marked'; | ||||||
|  | import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js'; | ||||||
|  | import { ProcessingError } from '../processing-error.js'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js'; | ||||||
|  | import { Language } from '../../../../entities/content/language.js'; | ||||||
|  | 
 | ||||||
|  | import Image = marked.Tokens.Image; | ||||||
|  | import Heading = marked.Tokens.Heading; | ||||||
|  | import Link = marked.Tokens.Link; | ||||||
|  | import RendererObject = marked.RendererObject; | ||||||
|  | 
 | ||||||
|  | const prefixes = { | ||||||
|  |     learningObject: '@learning-object', | ||||||
|  |     pdf: '@pdf', | ||||||
|  |     audio: '@audio', | ||||||
|  |     extern: '@extern', | ||||||
|  |     video: '@youtube', | ||||||
|  |     notebook: '@notebook', | ||||||
|  |     blockly: '@blockly', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier { | ||||||
|  |     const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/'); | ||||||
|  |     return { | ||||||
|  |         hruid, | ||||||
|  |         language: language as Language, | ||||||
|  |         version: parseInt(version), | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * An extension for the renderer of the Marked Markdown renderer which adds support for | ||||||
|  |  * - a custom heading, | ||||||
|  |  * - links to other learning objects, | ||||||
|  |  * - embeddings of other learning objects. | ||||||
|  |  */ | ||||||
|  | const dwengoMarkedRenderer: RendererObject = { | ||||||
|  |     heading(heading: Heading): string { | ||||||
|  |         const text = heading.text; | ||||||
|  |         const level = heading.depth; | ||||||
|  |         const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-'); | ||||||
|  | 
 | ||||||
|  |         return ( | ||||||
|  |             `<h${level}>\n` + | ||||||
|  |             `    <a name="${escapedText}" class="anchor" href="#${escapedText}">\n` + | ||||||
|  |             `        <span class="header-link"></span>\n` + | ||||||
|  |             `    </a>\n` + | ||||||
|  |             `    ${text}\n` + | ||||||
|  |             `</h${level}>\n` | ||||||
|  |         ); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     // When the syntax for a link is used => [text](href "title")
 | ||||||
|  |     // Render a custom link when the prefix for a learning object is used.
 | ||||||
|  |     link(link: Link): string { | ||||||
|  |         const href = link.href; | ||||||
|  |         const title = link.title || ''; | ||||||
|  |         const text = marked.parseInline(link.text); // There could for example be an image in the link.
 | ||||||
|  | 
 | ||||||
|  |         if (href.startsWith(prefixes.learningObject)) { | ||||||
|  |             // Link to learning-object
 | ||||||
|  |             const learningObjectId = extractLearningObjectIdFromHref(href); | ||||||
|  |             return `<a href="${getUrlStringForLearningObjectHTML(learningObjectId)}" target="_blank" title="${title}">${text}</a>`; | ||||||
|  |         } | ||||||
|  |         // Any other link
 | ||||||
|  |         if (!isValidHttpUrl(href)) { | ||||||
|  |             throw new ProcessingError('Link is not a valid HTTP URL!'); | ||||||
|  |         } | ||||||
|  |         //<a href="https://kiks.ilabt.imec.be/hub/tmplogin?id=0101" title="Notebooks Werking"><img src="Knop.png" alt="" title="Knop"></a>
 | ||||||
|  |         return `<a href="${href}" target="_blank" title="${title}">${text}</a>`; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     // When the syntax for an image is used => 
 | ||||||
|  |     // Render a learning object, pdf, audio or video if a prefix is used.
 | ||||||
|  |     image(img: Image): string { | ||||||
|  |         const href = img.href; | ||||||
|  |         if (href.startsWith(prefixes.learningObject)) { | ||||||
|  |             // Embedded learning-object
 | ||||||
|  |             const learningObjectId = extractLearningObjectIdFromHref(href); | ||||||
|  |             return ` | ||||||
|  |                 <learning-object hruid="${learningObjectId.hruid}" language="${learningObjectId.language}" version="${learningObjectId.version}"/> | ||||||
|  |             `; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!)
 | ||||||
|  |         } else if (href.startsWith(prefixes.pdf)) { | ||||||
|  |             // Embedded pdf
 | ||||||
|  |             const proc = new PdfProcessor(); | ||||||
|  |             return proc.render(href.split(/\/(.+)/, 2)[1]); | ||||||
|  |         } else if (href.startsWith(prefixes.audio)) { | ||||||
|  |             // Embedded audio
 | ||||||
|  |             const proc = new AudioProcessor(); | ||||||
|  |             return proc.render(href.split(/\/(.+)/, 2)[1]); | ||||||
|  |         } else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) { | ||||||
|  |             // Embedded youtube video or notebook (or other extern content)
 | ||||||
|  |             const proc = new ExternProcessor(); | ||||||
|  |             return proc.render(href.split(/\/(.+)/, 2)[1]); | ||||||
|  |         } | ||||||
|  |         // Embedded image
 | ||||||
|  |         const proc = new InlineImageProcessor(); | ||||||
|  |         return proc.render(href); | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default dwengoMarkedRenderer; | ||||||
|  | @ -0,0 +1,39 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import { marked } from 'marked'; | ||||||
|  | import InlineImageProcessor from '../image/inline-image-processor.js'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import dwengoMarkedRenderer from './dwengo-marked-renderer.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | import { ProcessingError } from '../processing-error.js'; | ||||||
|  | 
 | ||||||
|  | class MarkdownProcessor extends StringProcessor { | ||||||
|  |     constructor() { | ||||||
|  |         super(DwengoContentType.TEXT_MARKDOWN); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(mdText: string) { | ||||||
|  |         let html = ''; | ||||||
|  |         try { | ||||||
|  |             marked.use({ renderer: dwengoMarkedRenderer }); | ||||||
|  |             html = marked(mdText, { async: false }); | ||||||
|  |             html = this.replaceLinks(html); // Replace html image links path
 | ||||||
|  |         } catch (e: any) { | ||||||
|  |             throw new ProcessingError(e.message); | ||||||
|  |         } | ||||||
|  |         return html; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     replaceLinks(html: string) { | ||||||
|  |         const proc = new InlineImageProcessor(); | ||||||
|  |         html = html.replace( | ||||||
|  |             /<img.*?src="(.*?)".*?(alt="(.*?)")?.*?(title="(.*?)")?.*?>/g, | ||||||
|  |             (match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src) | ||||||
|  |         ); | ||||||
|  |         return html; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export { MarkdownProcessor }; | ||||||
|  | @ -0,0 +1,32 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js
 | ||||||
|  |  * | ||||||
|  |  * WARNING: The support for PDF learning objects is currently still experimental. | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import { isValidHttpUrl } from '../../../../util/links.js'; | ||||||
|  | import { ProcessingError } from '../processing-error.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | 
 | ||||||
|  | class PdfProcessor extends StringProcessor { | ||||||
|  |     constructor() { | ||||||
|  |         super(DwengoContentType.APPLICATION_PDF); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(pdfUrl: string) { | ||||||
|  |         if (!isValidHttpUrl(pdfUrl)) { | ||||||
|  |             throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return DOMPurify.sanitize( | ||||||
|  |             ` | ||||||
|  |             <embed src="${pdfUrl}" type="application/pdf" width="100%" height="800px"/> | ||||||
|  |             `,
 | ||||||
|  |             { ADD_TAGS: ['embed'] } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default PdfProcessor; | ||||||
|  | @ -0,0 +1,5 @@ | ||||||
|  | export class ProcessingError extends Error { | ||||||
|  |     constructor(error: string) { | ||||||
|  |         super(error); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,83 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import BlockImageProcessor from './image/block-image-processor.js'; | ||||||
|  | import InlineImageProcessor from './image/inline-image-processor.js'; | ||||||
|  | import { MarkdownProcessor } from './markdown/markdown-processor.js'; | ||||||
|  | import TextProcessor from './text/text-processor.js'; | ||||||
|  | import AudioProcessor from './audio/audio-processor.js'; | ||||||
|  | import PdfProcessor from './pdf/pdf-processor.js'; | ||||||
|  | import ExternProcessor from './extern/extern-processor.js'; | ||||||
|  | import GiftProcessor from './gift/gift-processor.js'; | ||||||
|  | import { LearningObject } from '../../../entities/content/learning-object.entity.js'; | ||||||
|  | import Processor from './processor.js'; | ||||||
|  | import { DwengoContentType } from './content-type.js'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js'; | ||||||
|  | import { Language } from '../../../entities/content/language.js'; | ||||||
|  | import { replaceAsync } from '../../../util/async.js'; | ||||||
|  | 
 | ||||||
|  | const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g; | ||||||
|  | const LEARNING_OBJECT_DOES_NOT_EXIST = "<div class='non-existing-learning-object' />"; | ||||||
|  | 
 | ||||||
|  | class ProcessingService { | ||||||
|  |     private processors!: Map<DwengoContentType, Processor<any>>; | ||||||
|  | 
 | ||||||
|  |     constructor() { | ||||||
|  |         const processors = [ | ||||||
|  |             new InlineImageProcessor(), | ||||||
|  |             new BlockImageProcessor(), | ||||||
|  |             new MarkdownProcessor(), | ||||||
|  |             new TextProcessor(), | ||||||
|  |             new AudioProcessor(), | ||||||
|  |             new PdfProcessor(), | ||||||
|  |             new ExternProcessor(), | ||||||
|  |             new GiftProcessor(), | ||||||
|  |         ]; | ||||||
|  | 
 | ||||||
|  |         this.processors = new Map(processors.map((processor) => [processor.contentType, processor])); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Render the given learning object. | ||||||
|  |      * @param learningObject The learning object to render | ||||||
|  |      * @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and | ||||||
|  |      *                                     returns the corresponding learning object. This is used to fetch learning | ||||||
|  |      *                                     objects embedded into this one. | ||||||
|  |      *                                     If this argument is omitted, embedded learning objects will be represented | ||||||
|  |      *                                     by placeholders. | ||||||
|  |      * @returns Rendered HTML for this LearningObject as a string. | ||||||
|  |      */ | ||||||
|  |     async render( | ||||||
|  |         learningObject: LearningObject, | ||||||
|  |         fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise<LearningObject | null> | ||||||
|  |     ): Promise<string> { | ||||||
|  |         const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); | ||||||
|  |         if (fetchEmbeddedLearningObjects) { | ||||||
|  |             // Replace all embedded learning objects.
 | ||||||
|  |             return replaceAsync( | ||||||
|  |                 html, | ||||||
|  |                 EMBEDDED_LEARNING_OBJECT_PLACEHOLDER, | ||||||
|  |                 async (_, hruid: string, language: string, version: string): Promise<string> => { | ||||||
|  |                     // Fetch the embedded learning object...
 | ||||||
|  |                     const learningObject = await fetchEmbeddedLearningObjects({ | ||||||
|  |                         hruid, | ||||||
|  |                         language: language as Language, | ||||||
|  |                         version: parseInt(version), | ||||||
|  |                     }); | ||||||
|  | 
 | ||||||
|  |                     // If it does not exist, replace it by a placeholder.
 | ||||||
|  |                     if (!learningObject) { | ||||||
|  |                         return LEARNING_OBJECT_DOES_NOT_EXIST; | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     // ... and render it.
 | ||||||
|  |                     return this.render(learningObject); | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         return html; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default new ProcessingService(); | ||||||
|  | @ -0,0 +1,61 @@ | ||||||
|  | import { LearningObject } from '../../../entities/content/learning-object.entity.js'; | ||||||
|  | import { ProcessingError } from './processing-error.js'; | ||||||
|  | import { DwengoContentType } from './content-type.js'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Abstract base class for all processors. | ||||||
|  |  * Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML. | ||||||
|  |  * | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js
 | ||||||
|  |  */ | ||||||
|  | abstract class Processor<T> { | ||||||
|  |     protected constructor(public contentType: DwengoContentType) {} | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Render the given object. | ||||||
|  |      * | ||||||
|  |      * @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this | ||||||
|  |      *                 Processor is responsible. | ||||||
|  |      * @return Rendered HTML-string | ||||||
|  |      * @throws ProcessingError if the rendering fails. | ||||||
|  |      */ | ||||||
|  |     render(toRender: T): string { | ||||||
|  |         return this.renderFn(toRender); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Render a learning object with the content type for which this processor is responsible. | ||||||
|  |      * @param toRender | ||||||
|  |      */ | ||||||
|  |     renderLearningObject(toRender: LearningObject): string { | ||||||
|  |         if (toRender.contentType !== this.contentType) { | ||||||
|  |             throw new ProcessingError( | ||||||
|  |                 `Unsupported content type: ${toRender.contentType}.
 | ||||||
|  |                 This processor is only responsible for content of type ${this.contentType}.` | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         return this.renderLearningObjectFn(toRender); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Function which actually renders the content. | ||||||
|  |      * | ||||||
|  |      * @param toRender Content to be rendered | ||||||
|  |      * @return Rendered HTML as a string | ||||||
|  |      * @protected | ||||||
|  |      */ | ||||||
|  |     protected abstract renderFn(toRender: T): string; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Function which actually executes the rendering of a learning object. | ||||||
|  |      * | ||||||
|  |      * When implementing this function, we may assume that we are responsible for the content type of the learning | ||||||
|  |      * object. | ||||||
|  |      * | ||||||
|  |      * @param toRender Learning object to render | ||||||
|  |      * @protected | ||||||
|  |      */ | ||||||
|  |     protected abstract renderLearningObjectFn(toRender: LearningObject): string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default Processor; | ||||||
|  | @ -0,0 +1,19 @@ | ||||||
|  | import Processor from './processor.js'; | ||||||
|  | import { LearningObject } from '../../../entities/content/learning-object.entity.js'; | ||||||
|  | 
 | ||||||
|  | export abstract class StringProcessor extends Processor<string> { | ||||||
|  |     /** | ||||||
|  |      * Function which actually executes the rendering of a learning object. | ||||||
|  |      * By default, this just means rendering the content in the content property of the learning object (interpreted | ||||||
|  |      * as string) | ||||||
|  |      * | ||||||
|  |      * When implementing this function, we may assume that we are responsible for the content type of the learning | ||||||
|  |      * object. | ||||||
|  |      * | ||||||
|  |      * @param toRender Learning object to render | ||||||
|  |      * @protected | ||||||
|  |      */ | ||||||
|  |     protected renderLearningObjectFn(toRender: LearningObject): string { | ||||||
|  |         return this.render(toRender.content.toString('ascii')); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | /** | ||||||
|  |  * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js
 | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import DOMPurify from 'isomorphic-dompurify'; | ||||||
|  | import { DwengoContentType } from '../content-type.js'; | ||||||
|  | import { StringProcessor } from '../string-processor.js'; | ||||||
|  | 
 | ||||||
|  | class TextProcessor extends StringProcessor { | ||||||
|  |     constructor() { | ||||||
|  |         super(DwengoContentType.TEXT_PLAIN); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override renderFn(text: string) { | ||||||
|  |         // Sanitize plain text to prevent xss.
 | ||||||
|  |         return DOMPurify.sanitize(text); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default TextProcessor; | ||||||
|  | @ -0,0 +1,190 @@ | ||||||
|  | import { LearningPathProvider } from './learning-path-provider.js'; | ||||||
|  | import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js'; | ||||||
|  | import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; | ||||||
|  | import { getLearningPathRepository } from '../../data/repositories.js'; | ||||||
|  | import { Language } from '../../entities/content/language.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 { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its | ||||||
|  |  * corresponding learning object. | ||||||
|  |  * @param nodes The nodes to find the learning object for. | ||||||
|  |  */ | ||||||
|  | async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> { | ||||||
|  |     // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
 | ||||||
|  |     // Its corresponding learning object.
 | ||||||
|  |     const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>( | ||||||
|  |         await Promise.all( | ||||||
|  |             nodes.map((node) => | ||||||
|  |                 learningObjectService | ||||||
|  |                     .getLearningObjectById({ | ||||||
|  |                         hruid: node.learningObjectHruid, | ||||||
|  |                         version: node.version, | ||||||
|  |                         language: node.language, | ||||||
|  |                     }) | ||||||
|  |                     .then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject]) | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     ); | ||||||
|  |     if (nullableNodesToLearningObjects.values().some((it) => it === null)) { | ||||||
|  |         throw new Error('At least one of the learning objects on this path could not be found.'); | ||||||
|  |     } | ||||||
|  |     return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Convert the given learning path entity to an object which conforms to the learning path content. | ||||||
|  |  */ | ||||||
|  | async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> { | ||||||
|  |     const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes); | ||||||
|  | 
 | ||||||
|  |     const targetAges = nodesToLearningObjects | ||||||
|  |         .values() | ||||||
|  |         .flatMap((it) => it.targetAges || []) | ||||||
|  |         .toArray(); | ||||||
|  | 
 | ||||||
|  |     const keywords = nodesToLearningObjects | ||||||
|  |         .values() | ||||||
|  |         .flatMap((it) => it.keywords || []) | ||||||
|  |         .toArray(); | ||||||
|  | 
 | ||||||
|  |     const image = learningPath.image ? learningPath.image.toString('base64') : undefined; | ||||||
|  | 
 | ||||||
|  |     const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
 | ||||||
|  |         __order: order, | ||||||
|  |         hruid: learningPath.hruid, | ||||||
|  |         language: learningPath.language, | ||||||
|  |         description: learningPath.description, | ||||||
|  |         image: image, | ||||||
|  |         title: learningPath.title, | ||||||
|  |         nodes: convertedNodes, | ||||||
|  |         num_nodes: learningPath.nodes.length, | ||||||
|  |         num_nodes_left: convertedNodes.filter((it) => !it.done).length, | ||||||
|  |         keywords: keywords.join(' '), | ||||||
|  |         target_ages: targetAges, | ||||||
|  |         max_age: Math.max(...targetAges), | ||||||
|  |         min_age: Math.min(...targetAges), | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding | ||||||
|  |  * learning objects into a list of learning path nodes as they should be represented in the API. | ||||||
|  |  * @param nodesToLearningObjects | ||||||
|  |  * @param personalizedFor | ||||||
|  |  */ | ||||||
|  | async function convertNodes( | ||||||
|  |     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||||
|  |     personalizedFor?: PersonalizationTarget | ||||||
|  | ): Promise<LearningObjectNode[]> { | ||||||
|  |     const nodesPromise = nodesToLearningObjects | ||||||
|  |         .entries() | ||||||
|  |         .map(async (entry) => { | ||||||
|  |             const [node, learningObject] = entry; | ||||||
|  |             const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; | ||||||
|  |             return { | ||||||
|  |                 _id: learningObject.uuid, | ||||||
|  |                 language: learningObject.language, | ||||||
|  |                 start_node: node.startNode, | ||||||
|  |                 created_at: node.createdAt.toISOString(), | ||||||
|  |                 updatedAt: node.updatedAt.toISOString(), | ||||||
|  |                 learningobject_hruid: node.learningObjectHruid, | ||||||
|  |                 version: learningObject.version, | ||||||
|  |                 transitions: node.transitions | ||||||
|  |                     .filter( | ||||||
|  |                         (trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible.
 | ||||||
|  |                     ) | ||||||
|  |                     .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition
 | ||||||
|  |             }; | ||||||
|  |         }) | ||||||
|  |         .toArray(); | ||||||
|  |     return await Promise.all(nodesPromise); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper method to convert a json string to an object, or null if it is undefined. | ||||||
|  |  */ | ||||||
|  | function optionalJsonStringToObject(jsonString?: string): object | null { | ||||||
|  |     if (!jsonString) { | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |     return JSON.parse(jsonString); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Helper function which converts a transition in the database representation to a transition in the representation | ||||||
|  |  * the Dwengo API uses. | ||||||
|  |  * | ||||||
|  |  * @param transition | ||||||
|  |  * @param index | ||||||
|  |  * @param nodesToLearningObjects | ||||||
|  |  */ | ||||||
|  | function convertTransition( | ||||||
|  |     transition: LearningPathTransition, | ||||||
|  |     index: number, | ||||||
|  |     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> | ||||||
|  | ): Transition { | ||||||
|  |     const nextNode = nodesToLearningObjects.get(transition.next); | ||||||
|  |     if (!nextNode) { | ||||||
|  |         throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`); | ||||||
|  |     } else { | ||||||
|  |         return { | ||||||
|  |             _id: '' + 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.
 | ||||||
|  |             next: { | ||||||
|  |                 _id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
 | ||||||
|  |                 hruid: transition.next.learningObjectHruid, | ||||||
|  |                 language: nextNode.language, | ||||||
|  |                 version: nextNode.version, | ||||||
|  |             }, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Service providing access to data about learning paths from the database. | ||||||
|  |  */ | ||||||
|  | const databaseLearningPathProvider: LearningPathProvider = { | ||||||
|  |     /** | ||||||
|  |      * Fetch the learning paths with the given hruids from the database. | ||||||
|  |      */ | ||||||
|  |     async fetchLearningPaths( | ||||||
|  |         hruids: string[], | ||||||
|  |         language: Language, | ||||||
|  |         source: string, | ||||||
|  |         personalizedFor?: PersonalizationTarget | ||||||
|  |     ): Promise<LearningPathResponse> { | ||||||
|  |         const learningPathRepo = getLearningPathRepository(); | ||||||
|  | 
 | ||||||
|  |         const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( | ||||||
|  |             (learningPath) => learningPath !== null | ||||||
|  |         ); | ||||||
|  |         const filteredLearningPaths = await Promise.all( | ||||||
|  |             learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             success: filteredLearningPaths.length > 0, | ||||||
|  |             data: await Promise.all(filteredLearningPaths), | ||||||
|  |             source, | ||||||
|  |         }; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Search learning paths in the database using the given search string. | ||||||
|  |      */ | ||||||
|  |     async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> { | ||||||
|  |         const learningPathRepo = getLearningPathRepository(); | ||||||
|  | 
 | ||||||
|  |         const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); | ||||||
|  |         return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor))); | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default databaseLearningPathProvider; | ||||||
|  | @ -0,0 +1,50 @@ | ||||||
|  | import { fetchWithLogging } from '../../util/apiHelper.js'; | ||||||
|  | import { DWENGO_API_BASE } from '../../config.js'; | ||||||
|  | import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; | ||||||
|  | import { LearningPathProvider } from './learning-path-provider.js'; | ||||||
|  | import { getLogger, Logger } from '../../logging/initalize.js'; | ||||||
|  | 
 | ||||||
|  | const logger: Logger = getLogger(); | ||||||
|  | 
 | ||||||
|  | const dwengoApiLearningPathProvider: LearningPathProvider = { | ||||||
|  |     async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> { | ||||||
|  |         if (hruids.length === 0) { | ||||||
|  |             return { | ||||||
|  |                 success: false, | ||||||
|  |                 source, | ||||||
|  |                 data: null, | ||||||
|  |                 message: `No HRUIDs provided for ${source}.`, | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; | ||||||
|  |         const params = { pathIdList: JSON.stringify({ hruids }), language }; | ||||||
|  | 
 | ||||||
|  |         const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params }); | ||||||
|  | 
 | ||||||
|  |         if (!learningPaths || learningPaths.length === 0) { | ||||||
|  |             logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); | ||||||
|  |             return { | ||||||
|  |                 success: false, | ||||||
|  |                 source, | ||||||
|  |                 data: [], | ||||||
|  |                 message: `No learning paths found for ${source}.`, | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             success: true, | ||||||
|  |             source, | ||||||
|  |             data: learningPaths, | ||||||
|  |         }; | ||||||
|  |     }, | ||||||
|  |     async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { | ||||||
|  |         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; | ||||||
|  |         const params = { all: query, language }; | ||||||
|  | 
 | ||||||
|  |         const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); | ||||||
|  |         return searchResults ?? []; | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default dwengoApiLearningPathProvider; | ||||||
|  | @ -0,0 +1,90 @@ | ||||||
|  | import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||||
|  | import { Student } from '../../entities/users/student.entity.js'; | ||||||
|  | import { Group } from '../../entities/assignments/group.entity.js'; | ||||||
|  | import { Submission } from '../../entities/assignments/submission.entity.js'; | ||||||
|  | import { getClassRepository, getGroupRepository, getStudentRepository, 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'; | ||||||
|  | 
 | ||||||
|  | export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Shortcut function to easily create a PersonalizationTarget object for a student by his/her username. | ||||||
|  |  * @param username Username of the student we want to generate a personalized learning path for. | ||||||
|  |  *                 If there is no student with this username, return undefined. | ||||||
|  |  */ | ||||||
|  | export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> { | ||||||
|  |     const student = await getStudentRepository().findByUsername(username); | ||||||
|  |     if (student) { | ||||||
|  |         return { | ||||||
|  |             type: 'student', | ||||||
|  |             student: student, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     return undefined; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and | ||||||
|  |  * group number. | ||||||
|  |  * @param classId Id of the class in which this group was created | ||||||
|  |  * @param assignmentNumber Number of the assignment for which this group was created | ||||||
|  |  * @param groupNumber Number of the group for which we want to personalize the learning path. | ||||||
|  |  */ | ||||||
|  | export async function personalizedForGroup( | ||||||
|  |     classId: string, | ||||||
|  |     assignmentNumber: number, | ||||||
|  |     groupNumber: number | ||||||
|  | ): Promise<PersonalizationTarget | undefined> { | ||||||
|  |     const clazz = await getClassRepository().findById(classId); | ||||||
|  |     if (!clazz) { | ||||||
|  |         return undefined; | ||||||
|  |     } | ||||||
|  |     const group = await getGroupRepository().findOne({ | ||||||
|  |         assignment: { | ||||||
|  |             within: clazz, | ||||||
|  |             id: assignmentNumber, | ||||||
|  |         }, | ||||||
|  |         groupNumber: groupNumber, | ||||||
|  |     }); | ||||||
|  |     if (group) { | ||||||
|  |         return { | ||||||
|  |             type: 'group', | ||||||
|  |             group: group, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     return undefined; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Returns the last submission for the learning object associated with the given node and for the student or group | ||||||
|  |  */ | ||||||
|  | export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> { | ||||||
|  |     const submissionRepo = getSubmissionRepository(); | ||||||
|  |     const learningObjectId: LearningObjectIdentifier = { | ||||||
|  |         hruid: node.learningObjectHruid, | ||||||
|  |         language: node.language, | ||||||
|  |         version: node.version, | ||||||
|  |     }; | ||||||
|  |     if (pathFor.type === 'group') { | ||||||
|  |         return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group); | ||||||
|  |     } | ||||||
|  |     return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Checks whether the condition of the given transaction is fulfilled by the given submission. | ||||||
|  |  * @param transition | ||||||
|  |  * @param submitted | ||||||
|  |  */ | ||||||
|  | export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean { | ||||||
|  |     if (transition.condition === 'true' || !transition.condition) { | ||||||
|  |         return true; // If the transition is unconditional, we can go on.
 | ||||||
|  |     } | ||||||
|  |     if (submitted === null) { | ||||||
|  |         return false; // If the transition is not unconditional and there was no submission, the transition is not possible.
 | ||||||
|  |     } | ||||||
|  |     const match = JSONPath({ path: transition.condition, json: { submission: submitted } }); | ||||||
|  |     return match.length === 1; | ||||||
|  | } | ||||||
|  | @ -0,0 +1,18 @@ | ||||||
|  | import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; | ||||||
|  | import { Language } from '../../entities/content/language.js'; | ||||||
|  | import { PersonalizationTarget } from './learning-path-personalization-util.js'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Generic interface for a service which provides access to learning paths from a data source. | ||||||
|  |  */ | ||||||
|  | export interface LearningPathProvider { | ||||||
|  |     /** | ||||||
|  |      * Fetch the learning paths with the given hruids from the data source. | ||||||
|  |      */ | ||||||
|  |     fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Search learning paths in the data source using the given search string. | ||||||
|  |      */ | ||||||
|  |     searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>; | ||||||
|  | } | ||||||
							
								
								
									
										57
									
								
								backend/src/services/learning-paths/learning-path-service.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								backend/src/services/learning-paths/learning-path-service.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | ||||||
|  | import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; | ||||||
|  | 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 { Language } from '../../entities/content/language.js'; | ||||||
|  | import { PersonalizationTarget } from './learning-path-personalization-util.js'; | ||||||
|  | 
 | ||||||
|  | const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix); | ||||||
|  | const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) | ||||||
|  |  */ | ||||||
|  | const learningPathService = { | ||||||
|  |     /** | ||||||
|  |      * Fetch the learning paths with the given hruids from the data source. | ||||||
|  |      * @param hruids For each of the hruids, the learning path will be fetched. | ||||||
|  |      * @param language This is the language each of the learning paths will use. | ||||||
|  |      * @param source | ||||||
|  |      * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. | ||||||
|  |      */ | ||||||
|  |     async fetchLearningPaths( | ||||||
|  |         hruids: string[], | ||||||
|  |         language: Language, | ||||||
|  |         source: string, | ||||||
|  |         personalizedFor?: PersonalizationTarget | ||||||
|  |     ): Promise<LearningPathResponse> { | ||||||
|  |         const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); | ||||||
|  |         const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); | ||||||
|  | 
 | ||||||
|  |         const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor); | ||||||
|  |         const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths( | ||||||
|  |             nonUserContentHruids, | ||||||
|  |             language, | ||||||
|  |             source, | ||||||
|  |             personalizedFor | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             data: result, | ||||||
|  |             source: source, | ||||||
|  |             success: userContentLearningPaths.success || nonUserContentLearningPaths.success, | ||||||
|  |         }; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Search learning paths in the data source using the given search string. | ||||||
|  |      */ | ||||||
|  |     async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> { | ||||||
|  |         const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor))); | ||||||
|  |         return providerResponses.flat(); | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default learningPathService; | ||||||
|  | @ -1,91 +0,0 @@ | ||||||
| import { DWENGO_API_BASE } from '../config.js'; |  | ||||||
| import { fetchWithLogging } from '../util/apiHelper.js'; |  | ||||||
| import { FilteredLearningObject, LearningObjectMetadata, LearningObjectNode, LearningPathResponse } from '../interfaces/learningPath.js'; |  | ||||||
| import { fetchLearningPaths } from './learningPaths.js'; |  | ||||||
| import { getLogger, Logger } from '../logging/initalize.js'; |  | ||||||
| 
 |  | ||||||
| const logger: Logger = getLogger(); |  | ||||||
| 
 |  | ||||||
| function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { |  | ||||||
|     return { |  | ||||||
|         key: data.hruid, // Hruid learningObject (not path)
 |  | ||||||
|         _id: data._id, |  | ||||||
|         uuid: data.uuid, |  | ||||||
|         version: data.version, |  | ||||||
|         title: data.title, |  | ||||||
|         htmlUrl, // Url to fetch html content
 |  | ||||||
|         language: data.language, |  | ||||||
|         difficulty: data.difficulty, |  | ||||||
|         estimatedTime: data.estimated_time, |  | ||||||
|         available: data.available, |  | ||||||
|         teacherExclusive: data.teacher_exclusive, |  | ||||||
|         educationalGoals: data.educational_goals, // List with learningObjects
 |  | ||||||
|         keywords: data.keywords, // For search
 |  | ||||||
|         description: data.description, // For search (not an actual description)
 |  | ||||||
|         targetAges: data.target_ages, |  | ||||||
|         contentType: data.content_type, // Markdown, image, audio, etc.
 |  | ||||||
|         contentLocation: data.content_location, // If content type extern
 |  | ||||||
|         skosConcepts: data.skos_concepts, |  | ||||||
|         returnValue: data.return_value, // Callback response information
 |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Fetches a single learning object by its HRUID |  | ||||||
|  */ |  | ||||||
| export async function getLearningObjectById(hruid: string, language: string): Promise<FilteredLearningObject | null> { |  | ||||||
|     const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; |  | ||||||
|     const metadata = await fetchWithLogging<LearningObjectMetadata>( |  | ||||||
|         metadataUrl, |  | ||||||
|         `Metadata for Learning Object HRUID "${hruid}" (language ${language})` |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
|     if (!metadata) { |  | ||||||
|         logger.warn(`⚠️ WARNING: Learning object "${hruid}" not found.`); |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`; |  | ||||||
|     return filterData(metadata, htmlUrl); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Generic function to fetch learning objects (full data or just HRUIDs) |  | ||||||
|  */ |  | ||||||
| async function fetchLearningObjects(hruid: string, full: boolean, language: string): Promise<FilteredLearningObject[] | string[]> { |  | ||||||
|     try { |  | ||||||
|         const learningPathResponse: LearningPathResponse = await fetchLearningPaths([hruid], language, `Learning path for HRUID "${hruid}"`); |  | ||||||
| 
 |  | ||||||
|         if (!learningPathResponse.success || !learningPathResponse.data?.length) { |  | ||||||
|             logger.warn(`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`); |  | ||||||
|             return []; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; |  | ||||||
| 
 |  | ||||||
|         if (!full) { |  | ||||||
|             return nodes.map((node) => node.learningobject_hruid); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return await Promise.all(nodes.map(async (node) => getLearningObjectById(node.learningobject_hruid, language))).then((objects) => |  | ||||||
|             objects.filter((obj): obj is FilteredLearningObject => obj !== null) |  | ||||||
|         ); |  | ||||||
|     } catch (error) { |  | ||||||
|         logger.error('❌ Error fetching learning objects:', error); |  | ||||||
|         return []; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Fetch full learning object data (metadata) |  | ||||||
|  */ |  | ||||||
| export async function getLearningObjectsFromPath(hruid: string, language: string): Promise<FilteredLearningObject[]> { |  | ||||||
|     return (await fetchLearningObjects(hruid, true, language)) as FilteredLearningObject[]; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Fetch only learning object HRUIDs |  | ||||||
|  */ |  | ||||||
| export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise<string[]> { |  | ||||||
|     return (await fetchLearningObjects(hruid, false, language)) as string[]; |  | ||||||
| } |  | ||||||
|  | @ -1,46 +0,0 @@ | ||||||
| import { fetchWithLogging } from '../util/apiHelper.js'; |  | ||||||
| import { DWENGO_API_BASE } from '../config.js'; |  | ||||||
| import { LearningPath, LearningPathResponse } from '../interfaces/learningPath.js'; |  | ||||||
| import { getLogger, Logger } from '../logging/initalize.js'; |  | ||||||
| 
 |  | ||||||
| const logger: Logger = getLogger(); |  | ||||||
| 
 |  | ||||||
| export async function fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> { |  | ||||||
|     if (hruids.length === 0) { |  | ||||||
|         return { |  | ||||||
|             success: false, |  | ||||||
|             source, |  | ||||||
|             data: null, |  | ||||||
|             message: `No HRUIDs provided for ${source}.`, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; |  | ||||||
|     const params = { pathIdList: JSON.stringify({ hruids }), language }; |  | ||||||
| 
 |  | ||||||
|     const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, params); |  | ||||||
| 
 |  | ||||||
|     if (!learningPaths || learningPaths.length === 0) { |  | ||||||
|         logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); |  | ||||||
|         return { |  | ||||||
|             success: false, |  | ||||||
|             source, |  | ||||||
|             data: [], |  | ||||||
|             message: `No learning paths found for ${source}.`, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return { |  | ||||||
|         success: true, |  | ||||||
|         source, |  | ||||||
|         data: learningPaths, |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export async function searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { |  | ||||||
|     const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; |  | ||||||
|     const params = { all: query, language }; |  | ||||||
| 
 |  | ||||||
|     const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, params); |  | ||||||
|     return searchResults ?? []; |  | ||||||
| } |  | ||||||
							
								
								
									
										41
									
								
								backend/src/sqlite-autoincrement-workaround.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								backend/src/sqlite-autoincrement-workaround.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | ||||||
|  | import { EntityProperty, EventArgs, EventSubscriber } from '@mikro-orm/core'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * The tests are ran on an in-memory SQLite database. However, SQLite does not allow fields which are part of composite | ||||||
|  |  * primary keys to be autoincremented (while PostgreSQL, which we use in production, does). This Subscriber works around | ||||||
|  |  * the issue by remembering the highest values for every autoincremented part of a primary key and assigning them when | ||||||
|  |  * creating a new entity. | ||||||
|  |  * | ||||||
|  |  * However, it is important to note the following limitations: | ||||||
|  |  * - this class can only be used for in-memory SQLite databases since the information on what the highest sequence | ||||||
|  |  *   number for each of the properties is, is only saved transiently. | ||||||
|  |  * - automatically setting the generated "autoincremented" value for properties only works when the entity is created | ||||||
|  |  *   via an entityManager.create(...) or repo.create(...) method. Otherwise, onInit will not be called and therefore, | ||||||
|  |  *   the sequence number will not be filled in. | ||||||
|  |  */ | ||||||
|  | export class SqliteAutoincrementSubscriber implements EventSubscriber { | ||||||
|  |     private sequenceNumbersForEntityType: Map<string, number> = new Map(); | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * When an entity with an autoincremented property which is part of the composite private key is created, | ||||||
|  |      * automatically fill this property so we won't face not-null-constraint exceptions when persisting it. | ||||||
|  |      */ | ||||||
|  |     onInit<T extends object>(args: EventArgs<T>): void { | ||||||
|  |         if (!args.meta.compositePK) { | ||||||
|  |             return; // If there is not a composite primary key, autoincrement works fine with SQLite anyway.
 | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         for (const prop of Object.values(args.meta.properties)) { | ||||||
|  |             const property = prop as EntityProperty<T>; | ||||||
|  |             if (property.primary && property.autoincrement && !(args.entity as Record<string, any>)[property.name]) { | ||||||
|  |                 // Obtain and increment sequence number of this entity.
 | ||||||
|  |                 const propertyKey = args.meta.class.name + '.' + property.name; | ||||||
|  |                 const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; | ||||||
|  |                 this.sequenceNumbersForEntityType.set(propertyKey, nextSeqNumber + 1); | ||||||
|  | 
 | ||||||
|  |                 // Set the property accordingly.
 | ||||||
|  |                 (args.entity as Record<string, any>)[property.name] = nextSeqNumber + 1; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -9,13 +9,21 @@ const logger: Logger = getLogger(); | ||||||
|  * |  * | ||||||
|  * @param url The API endpoint to fetch from. |  * @param url The API endpoint to fetch from. | ||||||
|  * @param description A short description of what is being fetched (for logging). |  * @param description A short description of what is being fetched (for logging). | ||||||
|  * @param params |  * @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") | ||||||
|  * @returns The response data if successful, or null if an error occurs. |  * @returns The response data if successful, or null if an error occurs. | ||||||
|  */ |  */ | ||||||
| export async function fetchWithLogging<T>(url: string, description: string, params?: Record<string, any>): Promise<T | null> { | export async function fetchWithLogging<T>( | ||||||
|  |     url: string, | ||||||
|  |     description: string, | ||||||
|  |     options?: { | ||||||
|  |         params?: Record<string, any>; | ||||||
|  |         query?: Record<string, any>; | ||||||
|  |         responseType?: 'json' | 'text'; | ||||||
|  |     } | ||||||
|  | ): Promise<T | null> { | ||||||
|     try { |     try { | ||||||
|         const config: AxiosRequestConfig = params ? { params } : {}; |         const config: AxiosRequestConfig = options || {}; | ||||||
| 
 |  | ||||||
|         const response = await axios.get<T>(url, config); |         const response = await axios.get<T>(url, config); | ||||||
|         return response.data; |         return response.data; | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|  |  | ||||||
							
								
								
									
										23
									
								
								backend/src/util/async.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								backend/src/util/async.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | /** | ||||||
|  |  * Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of | ||||||
|  |  * the parts matched by a group in the regex as arguments. | ||||||
|  |  * | ||||||
|  |  * @param str The string where to replace the occurrences | ||||||
|  |  * @param regex | ||||||
|  |  * @param replacementFn | ||||||
|  |  */ | ||||||
|  | export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise<string>) { | ||||||
|  |     const promises: Promise<string>[] = []; | ||||||
|  | 
 | ||||||
|  |     // First run through matches: add all Promises resulting from the replacement function
 | ||||||
|  |     str.replace(regex, (full, ...args) => { | ||||||
|  |         promises.push(replacementFn(full, ...args)); | ||||||
|  |         return full; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     // Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner.
 | ||||||
|  |     const replacements: string[] = await Promise.all(promises); | ||||||
|  | 
 | ||||||
|  |     // Second run through matches: Replace them by their previously computed replacements.
 | ||||||
|  |     return str.replace(regex, () => replacements.pop()!); | ||||||
|  | } | ||||||
|  | @ -15,6 +15,9 @@ export const EnvVars: { [key: string]: EnvVar } = { | ||||||
|     DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, |     DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, | ||||||
|     DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, |     DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, | ||||||
|     DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, |     DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, | ||||||
|  |     LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' }, | ||||||
|  |     FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' }, | ||||||
|  |     UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' }, | ||||||
|     IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, |     IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, | ||||||
|     IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, |     IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, | ||||||
|     IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, |     IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, | ||||||
|  |  | ||||||
							
								
								
									
										26
									
								
								backend/src/util/links.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/util/links.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | ||||||
|  | import { LearningObjectIdentifier } from '../interfaces/learning-content'; | ||||||
|  | 
 | ||||||
|  | export function isValidHttpUrl(url: string): boolean { | ||||||
|  |     try { | ||||||
|  |         const parsedUrl = new URL(url, 'http://test.be'); | ||||||
|  |         return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; | ||||||
|  |     } catch (e) { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier) { | ||||||
|  |     let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`; | ||||||
|  |     if (learningObjectId.version) { | ||||||
|  |         url += `&version=${learningObjectId.version}`; | ||||||
|  |     } | ||||||
|  |     return url; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string { | ||||||
|  |     let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; | ||||||
|  |     if (learningObjectIdentifier.version) { | ||||||
|  |         url += `&version=${learningObjectIdentifier.version}`; | ||||||
|  |     } | ||||||
|  |     return url; | ||||||
|  | } | ||||||
							
								
								
									
										42
									
								
								backend/tests/data/assignments/assignments.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								backend/tests/data/assignments/assignments.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | ||||||
|  | import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; | ||||||
|  | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | 
 | ||||||
|  | describe('AssignmentRepository', () => { | ||||||
|  |     let assignmentRepository: AssignmentRepository; | ||||||
|  |     let classRepository: ClassRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         assignmentRepository = getAssignmentRepository(); | ||||||
|  |         classRepository = getClassRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the requested assignment', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id02'); | ||||||
|  |         const assignment = await assignmentRepository.findByClassAndId(class_!, 2); | ||||||
|  | 
 | ||||||
|  |         expect(assignment).toBeTruthy(); | ||||||
|  |         expect(assignment!.title).toBe('tool'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return all assignments for a class', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id02'); | ||||||
|  |         const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); | ||||||
|  | 
 | ||||||
|  |         expect(assignments).toBeTruthy(); | ||||||
|  |         expect(assignments).toHaveLength(1); | ||||||
|  |         expect(assignments[0].title).toBe('tool'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find removed assignment', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id01'); | ||||||
|  |         await assignmentRepository.deleteByClassAndId(class_!, 3); | ||||||
|  | 
 | ||||||
|  |         const assignment = await assignmentRepository.findByClassAndId(class_!, 3); | ||||||
|  | 
 | ||||||
|  |         expect(assignment).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										49
									
								
								backend/tests/data/assignments/groups.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								backend/tests/data/assignments/groups.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | ||||||
|  | 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 { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | ||||||
|  | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | 
 | ||||||
|  | describe('GroupRepository', () => { | ||||||
|  |     let groupRepository: GroupRepository; | ||||||
|  |     let assignmentRepository: AssignmentRepository; | ||||||
|  |     let classRepository: ClassRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         groupRepository = getGroupRepository(); | ||||||
|  |         assignmentRepository = getAssignmentRepository(); | ||||||
|  |         classRepository = getClassRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the requested group', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id01'); | ||||||
|  |         const assignment = await assignmentRepository.findByClassAndId(class_!, 1); | ||||||
|  | 
 | ||||||
|  |         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); | ||||||
|  | 
 | ||||||
|  |         expect(group).toBeTruthy(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return all groups for assignment', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id01'); | ||||||
|  |         const assignment = await assignmentRepository.findByClassAndId(class_!, 1); | ||||||
|  | 
 | ||||||
|  |         const groups = await groupRepository.findAllGroupsForAssignment(assignment!); | ||||||
|  | 
 | ||||||
|  |         expect(groups).toBeTruthy(); | ||||||
|  |         expect(groups).toHaveLength(3); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find removed group', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id02'); | ||||||
|  |         const assignment = await assignmentRepository.findByClassAndId(class_!, 2); | ||||||
|  | 
 | ||||||
|  |         await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1); | ||||||
|  | 
 | ||||||
|  |         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); | ||||||
|  | 
 | ||||||
|  |         expect(group).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										70
									
								
								backend/tests/data/assignments/submissions.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								backend/tests/data/assignments/submissions.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | ||||||
|  | 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 { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; | ||||||
|  | import { Language } from '../../../src/entities/content/language'; | ||||||
|  | import { StudentRepository } from '../../../src/data/users/student-repository'; | ||||||
|  | import { GroupRepository } from '../../../src/data/assignments/group-repository'; | ||||||
|  | import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; | ||||||
|  | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | 
 | ||||||
|  | describe('SubmissionRepository', () => { | ||||||
|  |     let submissionRepository: SubmissionRepository; | ||||||
|  |     let studentRepository: StudentRepository; | ||||||
|  |     let groupRepository: GroupRepository; | ||||||
|  |     let assignmentRepository: AssignmentRepository; | ||||||
|  |     let classRepository: ClassRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         submissionRepository = getSubmissionRepository(); | ||||||
|  |         studentRepository = getStudentRepository(); | ||||||
|  |         groupRepository = getGroupRepository(); | ||||||
|  |         assignmentRepository = getAssignmentRepository(); | ||||||
|  |         classRepository = getClassRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should find the requested submission', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id03', Language.English, '1'); | ||||||
|  |         const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); | ||||||
|  | 
 | ||||||
|  |         expect(submission).toBeTruthy(); | ||||||
|  |         expect(submission?.content).toBe('sub1'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should find the most recent submission for a student', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id02', Language.English, '1'); | ||||||
|  |         const student = await studentRepository.findByUsername('Noordkaap'); | ||||||
|  |         const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, student!); | ||||||
|  | 
 | ||||||
|  |         expect(submission).toBeTruthy(); | ||||||
|  |         expect(submission?.submissionTime.getDate()).toBe(25); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should find the most recent submission for a group', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id03', Language.English, '1'); | ||||||
|  |         const class_ = await classRepository.findById('id01'); | ||||||
|  |         const assignment = await assignmentRepository.findByClassAndId(class_!, 1); | ||||||
|  |         const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); | ||||||
|  |         const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); | ||||||
|  | 
 | ||||||
|  |         expect(submission).toBeTruthy(); | ||||||
|  |         expect(submission?.submissionTime.getDate()).toBe(25); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find a deleted submission', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id01', Language.English, '1'); | ||||||
|  |         await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); | ||||||
|  | 
 | ||||||
|  |         const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); | ||||||
|  | 
 | ||||||
|  |         expect(submission).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										47
									
								
								backend/tests/data/classes/class-join-request.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								backend/tests/data/classes/class-join-request.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | ||||||
|  | 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 { StudentRepository } from '../../../src/data/users/student-repository'; | ||||||
|  | import { Class } from '../../../src/entities/classes/class.entity'; | ||||||
|  | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | import { Student } from '../../../src/entities/users/student.entity'; | ||||||
|  | 
 | ||||||
|  | describe('ClassJoinRequestRepository', () => { | ||||||
|  |     let classJoinRequestRepository: ClassJoinRequestRepository; | ||||||
|  |     let studentRepository: StudentRepository; | ||||||
|  |     let cassRepository: ClassRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         classJoinRequestRepository = getClassJoinRequestRepository(); | ||||||
|  |         studentRepository = getStudentRepository(); | ||||||
|  |         cassRepository = getClassRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should list all requests from student to join classes', async () => { | ||||||
|  |         const student = await studentRepository.findByUsername('PinkFloyd'); | ||||||
|  |         const requests = await classJoinRequestRepository.findAllRequestsBy(student!); | ||||||
|  | 
 | ||||||
|  |         expect(requests).toBeTruthy(); | ||||||
|  |         expect(requests).toHaveLength(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should list all requests to a single class', async () => { | ||||||
|  |         const class_ = await cassRepository.findById('id02'); | ||||||
|  |         const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); | ||||||
|  | 
 | ||||||
|  |         expect(requests).toBeTruthy(); | ||||||
|  |         expect(requests).toHaveLength(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find a removed request', async () => { | ||||||
|  |         const student = await studentRepository.findByUsername('SmashingPumpkins'); | ||||||
|  |         const class_ = await cassRepository.findById('id03'); | ||||||
|  |         await classJoinRequestRepository.deleteBy(student!, class_!); | ||||||
|  | 
 | ||||||
|  |         const request = await classJoinRequestRepository.findAllRequestsBy(student!); | ||||||
|  | 
 | ||||||
|  |         expect(request).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										34
									
								
								backend/tests/data/classes/classes.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								backend/tests/data/classes/classes.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { getClassRepository } from '../../../src/data/repositories'; | ||||||
|  | 
 | ||||||
|  | describe('ClassRepository', () => { | ||||||
|  |     let classRepository: ClassRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         classRepository = getClassRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return nothing because id does not exist', async () => { | ||||||
|  |         const classVar = await classRepository.findById('test_id'); | ||||||
|  | 
 | ||||||
|  |         expect(classVar).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return requested class', async () => { | ||||||
|  |         const classVar = await classRepository.findById('id01'); | ||||||
|  | 
 | ||||||
|  |         expect(classVar).toBeTruthy(); | ||||||
|  |         expect(classVar?.displayName).toBe('class01'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('class should be gone after deletion', async () => { | ||||||
|  |         await classRepository.deleteById('id04'); | ||||||
|  | 
 | ||||||
|  |         const classVar = await classRepository.findById('id04'); | ||||||
|  | 
 | ||||||
|  |         expect(classVar).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										54
									
								
								backend/tests/data/classes/teacher-invitation.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								backend/tests/data/classes/teacher-invitation.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { getClassRepository, getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories'; | ||||||
|  | import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; | ||||||
|  | import { TeacherRepository } from '../../../src/data/users/teacher-repository'; | ||||||
|  | import { ClassRepository } from '../../../src/data/classes/class-repository'; | ||||||
|  | 
 | ||||||
|  | describe('ClassRepository', () => { | ||||||
|  |     let teacherInvitationRepository: TeacherInvitationRepository; | ||||||
|  |     let teacherRepository: TeacherRepository; | ||||||
|  |     let classRepository: ClassRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         teacherInvitationRepository = getTeacherInvitationRepository(); | ||||||
|  |         teacherRepository = getTeacherRepository(); | ||||||
|  |         classRepository = getClassRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return all invitations from a teacher', async () => { | ||||||
|  |         const teacher = await teacherRepository.findByUsername('LimpBizkit'); | ||||||
|  |         const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!); | ||||||
|  | 
 | ||||||
|  |         expect(invitations).toBeTruthy(); | ||||||
|  |         expect(invitations).toHaveLength(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return all invitations for a teacher', async () => { | ||||||
|  |         const teacher = await teacherRepository.findByUsername('FooFighters'); | ||||||
|  |         const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!); | ||||||
|  | 
 | ||||||
|  |         expect(invitations).toBeTruthy(); | ||||||
|  |         expect(invitations).toHaveLength(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return all invitations for a class', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id02'); | ||||||
|  |         const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); | ||||||
|  | 
 | ||||||
|  |         expect(invitations).toBeTruthy(); | ||||||
|  |         expect(invitations).toHaveLength(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find a removed invitation', async () => { | ||||||
|  |         const class_ = await classRepository.findById('id01'); | ||||||
|  |         const sender = await teacherRepository.findByUsername('FooFighters'); | ||||||
|  |         const receiver = await teacherRepository.findByUsername('LimpBizkit'); | ||||||
|  |         await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); | ||||||
|  | 
 | ||||||
|  |         const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!); | ||||||
|  | 
 | ||||||
|  |         expect(invitation).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										79
									
								
								backend/tests/data/content/attachment-repository.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								backend/tests/data/content/attachment-repository.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,79 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests.js'; | ||||||
|  | import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; | ||||||
|  | import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; | ||||||
|  | import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; | ||||||
|  | import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; | ||||||
|  | import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; | ||||||
|  | import { Attachment } from '../../../src/entities/content/attachment.entity.js'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; | ||||||
|  | 
 | ||||||
|  | const NEWER_TEST_SUFFIX = 'nEweR'; | ||||||
|  | 
 | ||||||
|  | function createTestLearningObjects(learningObjectRepo: LearningObjectRepository): { older: LearningObject; newer: LearningObject } { | ||||||
|  |     const olderExample = example.createLearningObject(); | ||||||
|  |     learningObjectRepo.save(olderExample); | ||||||
|  | 
 | ||||||
|  |     const newerExample = example.createLearningObject(); | ||||||
|  |     newerExample.title = 'Newer example'; | ||||||
|  |     newerExample.version = 100; | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |         older: olderExample, | ||||||
|  |         newer: newerExample, | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('AttachmentRepository', () => { | ||||||
|  |     let attachmentRepo: AttachmentRepository; | ||||||
|  |     let exampleLearningObjects: { older: LearningObject; newer: LearningObject }; | ||||||
|  |     let attachmentsOlderLearningObject: Attachment[]; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         attachmentRepo = getAttachmentRepository(); | ||||||
|  |         exampleLearningObjects = createTestLearningObjects(getLearningObjectRepository()); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('can add attachments to learning objects without throwing an error', () => { | ||||||
|  |         attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older)); | ||||||
|  | 
 | ||||||
|  |         for (const attachment of attachmentsOlderLearningObject) { | ||||||
|  |             attachmentRepo.save(attachment); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     let attachmentOnlyNewer: Attachment; | ||||||
|  |     it('allows us to add attachments with the same name to a different learning object without throwing an error', () => { | ||||||
|  |         attachmentOnlyNewer = Object.values(example.createAttachment)[0](exampleLearningObjects.newer); | ||||||
|  |         attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX); | ||||||
|  | 
 | ||||||
|  |         attachmentRepo.save(attachmentOnlyNewer); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     let olderLearningObjectId: LearningObjectIdentifier; | ||||||
|  |     it('returns the correct attachment when queried by learningObjectId and attachment name', async () => { | ||||||
|  |         olderLearningObjectId = { | ||||||
|  |             hruid: exampleLearningObjects.older.hruid, | ||||||
|  |             language: exampleLearningObjects.older.language, | ||||||
|  |             version: exampleLearningObjects.older.version, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name); | ||||||
|  |         expect(result).toBe(attachmentsOlderLearningObject[0]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('returns null when queried by learningObjectId and non-existing attachment name', async () => { | ||||||
|  |         const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, 'non-existing name'); | ||||||
|  |         expect(result).toBe(null); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('returns the newer version of the attachment when only queried by hruid, language and attachment name (but not version)', async () => { | ||||||
|  |         const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName( | ||||||
|  |             exampleLearningObjects.older.hruid, | ||||||
|  |             exampleLearningObjects.older.language, | ||||||
|  |             attachmentOnlyNewer.name | ||||||
|  |         ); | ||||||
|  |         expect(result).toBe(attachmentOnlyNewer); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										31
									
								
								backend/tests/data/content/attachments.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								backend/tests/data/content/attachments.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests.js'; | ||||||
|  | import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; | ||||||
|  | import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; | ||||||
|  | import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; | ||||||
|  | import { Language } from '../../../src/entities/content/language.js'; | ||||||
|  | 
 | ||||||
|  | describe('AttachmentRepository', () => { | ||||||
|  |     let attachmentRepository: AttachmentRepository; | ||||||
|  |     let learningObjectRepository: LearningObjectRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         attachmentRepository = getAttachmentRepository(); | ||||||
|  |         learningObjectRepository = getLearningObjectRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the requested attachment', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id02', Language.English, '1'); | ||||||
|  |         const learningObject = await learningObjectRepository.findByIdentifier(id); | ||||||
|  | 
 | ||||||
|  |         const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName( | ||||||
|  |             learningObject!, | ||||||
|  |             Language.English, | ||||||
|  |             'attachment01' | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         expect(attachment).toBeTruthy(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,72 @@ | ||||||
|  | import { beforeAll, describe, it, expect } from 'vitest'; | ||||||
|  | import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; | ||||||
|  | import { setupTestApp } from '../../setup-tests.js'; | ||||||
|  | import { getLearningObjectRepository } from '../../../src/data/repositories.js'; | ||||||
|  | import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; | ||||||
|  | import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; | ||||||
|  | import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; | ||||||
|  | 
 | ||||||
|  | describe('LearningObjectRepository', () => { | ||||||
|  |     let learningObjectRepository: LearningObjectRepository; | ||||||
|  | 
 | ||||||
|  |     let exampleLearningObject: LearningObject; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         learningObjectRepository = getLearningObjectRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should be able to add a learning object to it without an error', async () => { | ||||||
|  |         exampleLearningObject = example.createLearningObject(); | ||||||
|  |         await learningObjectRepository.insert(exampleLearningObject); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the learning object when queried by id', async () => { | ||||||
|  |         const result = await learningObjectRepository.findByIdentifier({ | ||||||
|  |             hruid: exampleLearningObject.hruid, | ||||||
|  |             language: exampleLearningObject.language, | ||||||
|  |             version: exampleLearningObject.version, | ||||||
|  |         }); | ||||||
|  |         expect(result).toBeInstanceOf(LearningObject); | ||||||
|  |         expectToBeCorrectEntity( | ||||||
|  |             { | ||||||
|  |                 name: 'actual', | ||||||
|  |                 entity: result!, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 name: 'expected', | ||||||
|  |                 entity: exampleLearningObject, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return null when non-existing version is queried', async () => { | ||||||
|  |         const result = await learningObjectRepository.findByIdentifier({ | ||||||
|  |             hruid: exampleLearningObject.hruid, | ||||||
|  |             language: exampleLearningObject.language, | ||||||
|  |             version: 100, | ||||||
|  |         }); | ||||||
|  |         expect(result).toBe(null); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     let newerExample: LearningObject; | ||||||
|  | 
 | ||||||
|  |     it('should allow a learning object with the same id except a different version to be added', async () => { | ||||||
|  |         newerExample = example.createLearningObject(); | ||||||
|  |         newerExample.version = 10; | ||||||
|  |         newerExample.title += ' (nieuw)'; | ||||||
|  |         await learningObjectRepository.save(newerExample); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     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)'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return null when queried by non-existing hruid or language', async () => { | ||||||
|  |         const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', exampleLearningObject.language); | ||||||
|  |         expect(result).toBe(null); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										32
									
								
								backend/tests/data/content/learning-objects.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								backend/tests/data/content/learning-objects.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; | ||||||
|  | import { getLearningObjectRepository } from '../../../src/data/repositories'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; | ||||||
|  | import { Language } from '../../../src/entities/content/language'; | ||||||
|  | 
 | ||||||
|  | describe('LearningObjectRepository', () => { | ||||||
|  |     let learningObjectRepository: LearningObjectRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         learningObjectRepository = getLearningObjectRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const id01 = new LearningObjectIdentifier('id01', Language.English, '1'); | ||||||
|  |     const id02 = new LearningObjectIdentifier('test_id', Language.English, '1'); | ||||||
|  | 
 | ||||||
|  |     it('should return the learning object that matches identifier 1', async () => { | ||||||
|  |         const learningObject = await learningObjectRepository.findByIdentifier(id01); | ||||||
|  | 
 | ||||||
|  |         expect(learningObject).toBeTruthy(); | ||||||
|  |         expect(learningObject?.title).toBe('Undertow'); | ||||||
|  |         expect(learningObject?.description).toBe('debute'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return nothing because the identifier does not exist in the database', async () => { | ||||||
|  |         const learningObject = await learningObjectRepository.findByIdentifier(id02); | ||||||
|  | 
 | ||||||
|  |         expect(learningObject).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										66
									
								
								backend/tests/data/content/learning-path-repository.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								backend/tests/data/content/learning-path-repository.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests.js'; | ||||||
|  | import { getLearningPathRepository } from '../../../src/data/repositories.js'; | ||||||
|  | import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.js'; | ||||||
|  | import example from '../../test-assets/learning-paths/pn-werking-example.js'; | ||||||
|  | import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; | ||||||
|  | import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; | ||||||
|  | import { Language } from '../../../src/entities/content/language.js'; | ||||||
|  | 
 | ||||||
|  | function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void { | ||||||
|  |     expect(result).toHaveProperty('length'); | ||||||
|  |     expect(result.length).toBe(1); | ||||||
|  |     expectToBeCorrectEntity({ entity: result[0]! }, { entity: expected }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function expectToHaveFoundNothing(result: LearningPath[]): void { | ||||||
|  |     expect(result).toHaveProperty('length'); | ||||||
|  |     expect(result.length).toBe(0); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('LearningPathRepository', () => { | ||||||
|  |     let learningPathRepo: LearningPathRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         learningPathRepo = getLearningPathRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     let examplePath: LearningPath; | ||||||
|  | 
 | ||||||
|  |     it('should be able to add a learning path without throwing an error', async () => { | ||||||
|  |         examplePath = example.createLearningPath(); | ||||||
|  |         await learningPathRepo.insert(examplePath); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the added path when it is queried by hruid and language', async () => { | ||||||
|  |         const result = await learningPathRepo.findByHruidAndLanguage(examplePath.hruid, examplePath.language); | ||||||
|  |         expect(result).toBeInstanceOf(LearningPath); | ||||||
|  |         expectToBeCorrectEntity({ entity: result! }, { entity: examplePath }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return null to a query on a non-existing hruid or language', async () => { | ||||||
|  |         const result = await learningPathRepo.findByHruidAndLanguage('not_existing_hruid', examplePath.language); | ||||||
|  |         expect(result).toBe(null); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the learning path when we search for a search term occurring in its title', async () => { | ||||||
|  |         const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(4, 9), examplePath.language); | ||||||
|  |         expectToHaveFoundPrecisely(examplePath, result); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the learning path when we search for a search term occurring in its description', async () => { | ||||||
|  |         const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(8, 15), examplePath.language); | ||||||
|  |         expectToHaveFoundPrecisely(examplePath, result); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return null when we search for something not occurring in its title or description', async () => { | ||||||
|  |         const result = await learningPathRepo.findByQueryStringAndLanguage('something not occurring in the path', examplePath.language); | ||||||
|  |         expectToHaveFoundNothing(result); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return null when we search for something occurring in its title, but another language', async () => { | ||||||
|  |         const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(1, 3), Language.Kalaallisut); | ||||||
|  |         expectToHaveFoundNothing(result); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										28
									
								
								backend/tests/data/content/learning-paths.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								backend/tests/data/content/learning-paths.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { getLearningPathRepository } from '../../../src/data/repositories'; | ||||||
|  | import { LearningPathRepository } from '../../../src/data/content/learning-path-repository'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { Language } from '../../../src/entities/content/language'; | ||||||
|  | 
 | ||||||
|  | describe('LearningPathRepository', () => { | ||||||
|  |     let learningPathRepository: LearningPathRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         learningPathRepository = getLearningPathRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return nothing because no match for hruid and language', async () => { | ||||||
|  |         const learningPath = await learningPathRepository.findByHruidAndLanguage('test_id', Language.Dutch); | ||||||
|  | 
 | ||||||
|  |         expect(learningPath).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return requested learning path', async () => { | ||||||
|  |         const learningPath = await learningPathRepository.findByHruidAndLanguage('id01', Language.English); | ||||||
|  | 
 | ||||||
|  |         expect(learningPath).toBeTruthy(); | ||||||
|  |         expect(learningPath?.title).toBe('repertoire Tool'); | ||||||
|  |         expect(learningPath?.description).toBe('all about Tool'); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										66
									
								
								backend/tests/data/questions/answers.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								backend/tests/data/questions/answers.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | ||||||
|  | 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 '../../../src/entities/content/language'; | ||||||
|  | import { TeacherRepository } from '../../../src/data/users/teacher-repository'; | ||||||
|  | 
 | ||||||
|  | describe('AnswerRepository', () => { | ||||||
|  |     let answerRepository: AnswerRepository; | ||||||
|  |     let questionRepository: QuestionRepository; | ||||||
|  |     let teacherRepository: TeacherRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         answerRepository = getAnswerRepository(); | ||||||
|  |         questionRepository = getQuestionRepository(); | ||||||
|  |         teacherRepository = getTeacherRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should find all answers to a question', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id05', Language.English, '1'); | ||||||
|  |         const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); | ||||||
|  | 
 | ||||||
|  |         const question = questions.filter((it) => it.sequenceNumber == 2)[0]; | ||||||
|  | 
 | ||||||
|  |         const answers = await answerRepository.findAllAnswersToQuestion(question); | ||||||
|  | 
 | ||||||
|  |         expect(answers).toBeTruthy(); | ||||||
|  |         expect(answers).toHaveLength(2); | ||||||
|  |         expect(answers[0].content).toBeOneOf(['answer', 'answer2']); | ||||||
|  |         expect(answers[1].content).toBeOneOf(['answer', 'answer2']); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should create an answer to a question', async () => { | ||||||
|  |         const teacher = await teacherRepository.findByUsername('FooFighters'); | ||||||
|  |         const id = new LearningObjectIdentifier('id05', Language.English, '1'); | ||||||
|  |         const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); | ||||||
|  | 
 | ||||||
|  |         const question = questions[0]; | ||||||
|  | 
 | ||||||
|  |         await answerRepository.createAnswer({ | ||||||
|  |             toQuestion: question, | ||||||
|  |             author: teacher!, | ||||||
|  |             content: 'created answer', | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         const answers = await answerRepository.findAllAnswersToQuestion(question); | ||||||
|  | 
 | ||||||
|  |         expect(answers).toBeTruthy(); | ||||||
|  |         expect(answers).toHaveLength(1); | ||||||
|  |         expect(answers[0].content).toBe('created answer'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find a removed answer', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id04', Language.English, '1'); | ||||||
|  |         const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); | ||||||
|  | 
 | ||||||
|  |         await answerRepository.removeAnswerByQuestionAndSequenceNumber(questions[0], 1); | ||||||
|  | 
 | ||||||
|  |         const emptyList = await answerRepository.findAllAnswersToQuestion(questions[0]); | ||||||
|  | 
 | ||||||
|  |         expect(emptyList).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										52
									
								
								backend/tests/data/questions/questions.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								backend/tests/data/questions/questions.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { QuestionRepository } from '../../../src/data/questions/question-repository'; | ||||||
|  | import { getLearningObjectRepository, getQuestionRepository, getStudentRepository } from '../../../src/data/repositories'; | ||||||
|  | import { StudentRepository } from '../../../src/data/users/student-repository'; | ||||||
|  | import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; | ||||||
|  | import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; | ||||||
|  | import { Language } from '../../../src/entities/content/language'; | ||||||
|  | 
 | ||||||
|  | describe('QuestionRepository', () => { | ||||||
|  |     let questionRepository: QuestionRepository; | ||||||
|  |     let studentRepository: StudentRepository; | ||||||
|  |     let learningObjectRepository: LearningObjectRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         questionRepository = getQuestionRepository(); | ||||||
|  |         studentRepository = getStudentRepository(); | ||||||
|  |         learningObjectRepository = getLearningObjectRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return all questions part of the given learning object', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id05', Language.English, '1'); | ||||||
|  |         const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); | ||||||
|  | 
 | ||||||
|  |         expect(questions).toBeTruthy(); | ||||||
|  |         expect(questions).toHaveLength(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should create new question', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id03', Language.English, '1'); | ||||||
|  |         const student = await studentRepository.findByUsername('Noordkaap'); | ||||||
|  |         await questionRepository.createQuestion({ | ||||||
|  |             loId: id, | ||||||
|  |             author: student!, | ||||||
|  |             content: 'question?', | ||||||
|  |         }); | ||||||
|  |         const question = await questionRepository.findAllQuestionsAboutLearningObject(id); | ||||||
|  | 
 | ||||||
|  |         expect(question).toBeTruthy(); | ||||||
|  |         expect(question).toHaveLength(1); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not find removed question', async () => { | ||||||
|  |         const id = new LearningObjectIdentifier('id04', Language.English, '1'); | ||||||
|  |         await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1); | ||||||
|  | 
 | ||||||
|  |         const question = await questionRepository.findAllQuestionsAboutLearningObject(id); | ||||||
|  | 
 | ||||||
|  |         expect(question).toHaveLength(0); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| import { setupTestApp } from '../setup-tests.js'; | import { setupTestApp } from '../../setup-tests.js'; | ||||||
| import { Student } from '../../src/entities/users/student.entity.js'; | import { Student } from '../../../src/entities/users/student.entity.js'; | ||||||
| import { describe, it, expect, beforeAll } from 'vitest'; | import { describe, it, expect, beforeAll } from 'vitest'; | ||||||
| import { StudentRepository } from '../../src/data/users/student-repository.js'; | import { StudentRepository } from '../../../src/data/users/student-repository.js'; | ||||||
| import { getStudentRepository } from '../../src/data/repositories.js'; | import { getStudentRepository } from '../../../src/data/repositories.js'; | ||||||
| 
 | 
 | ||||||
| const username = 'teststudent'; | const username = 'teststudent'; | ||||||
| const firstName = 'John'; | const firstName = 'John'; | ||||||
|  | @ -15,6 +15,20 @@ describe('StudentRepository', () => { | ||||||
|         studentRepository = getStudentRepository(); |         studentRepository = getStudentRepository(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('should not return a student because username does not exist', async () => { | ||||||
|  |         const student = await studentRepository.findByUsername('test'); | ||||||
|  | 
 | ||||||
|  |         expect(student).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return student from the datbase', async () => { | ||||||
|  |         const student = await studentRepository.findByUsername('Noordkaap'); | ||||||
|  | 
 | ||||||
|  |         expect(student).toBeTruthy(); | ||||||
|  |         expect(student?.firstName).toBe('Stijn'); | ||||||
|  |         expect(student?.lastName).toBe('Meuris'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('should return the queried student after he was added', async () => { |     it('should return the queried student after he was added', async () => { | ||||||
|         await studentRepository.insert(new Student(username, firstName, lastName)); |         await studentRepository.insert(new Student(username, firstName, lastName)); | ||||||
| 
 | 
 | ||||||
							
								
								
									
										47
									
								
								backend/tests/data/users/teachers.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								backend/tests/data/users/teachers.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | ||||||
|  | import { describe, it, expect, beforeAll } from 'vitest'; | ||||||
|  | import { TeacherRepository } from '../../../src/data/users/teacher-repository'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { getTeacherRepository } from '../../../src/data/repositories'; | ||||||
|  | import { Teacher } from '../../../src/entities/users/teacher.entity'; | ||||||
|  | 
 | ||||||
|  | const username = 'testteacher'; | ||||||
|  | const firstName = 'John'; | ||||||
|  | const lastName = 'Doe'; | ||||||
|  | describe('TeacherRepository', () => { | ||||||
|  |     let teacherRepository: TeacherRepository; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         teacherRepository = getTeacherRepository(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should not return a teacher because username does not exist', async () => { | ||||||
|  |         const teacher = await teacherRepository.findByUsername('test'); | ||||||
|  | 
 | ||||||
|  |         expect(teacher).toBeNull(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return teacher from the datbase', async () => { | ||||||
|  |         const teacher = await teacherRepository.findByUsername('FooFighters'); | ||||||
|  | 
 | ||||||
|  |         expect(teacher).toBeTruthy(); | ||||||
|  |         expect(teacher?.firstName).toBe('Dave'); | ||||||
|  |         expect(teacher?.lastName).toBe('Grohl'); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return the queried teacher after he was added', async () => { | ||||||
|  |         await teacherRepository.insert(new Teacher(username, firstName, lastName)); | ||||||
|  | 
 | ||||||
|  |         const retrievedTeacher = await teacherRepository.findByUsername(username); | ||||||
|  |         expect(retrievedTeacher).toBeTruthy(); | ||||||
|  |         expect(retrievedTeacher?.firstName).toBe(firstName); | ||||||
|  |         expect(retrievedTeacher?.lastName).toBe(lastName); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should no longer return the queried teacher after he was removed again', async () => { | ||||||
|  |         await teacherRepository.deleteByUsername('ZesdeMetaal'); | ||||||
|  | 
 | ||||||
|  |         const retrievedTeacher = await teacherRepository.findByUsername('ZesdeMetaal'); | ||||||
|  |         expect(retrievedTeacher).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										117
									
								
								backend/tests/service/learning-objects.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								backend/tests/service/learning-objects.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,117 @@ | ||||||
|  | import { describe, it, expect, vi } from 'vitest'; | ||||||
|  | import { LearningObjectMetadata, LearningPath } from '../../src/interfaces/learningPath'; | ||||||
|  | import { fetchWithLogging } from '../../src/util/apiHelper'; | ||||||
|  | import { getLearningObjectById, getLearningObjectsFromPath } from '../../src/services/learningObjects'; | ||||||
|  | import { fetchLearningPaths } from '../../src/services/learningPaths'; | ||||||
|  | 
 | ||||||
|  | // Mock API functions
 | ||||||
|  | vi.mock('../../src/util/apiHelper', () => ({ | ||||||
|  |     fetchWithLogging: vi.fn(), | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | vi.mock('../../src/services/learningPaths', () => ({ | ||||||
|  |     fetchLearningPaths: vi.fn(), | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | describe('getLearningObjectById', () => { | ||||||
|  |     const hruid = 'test-object'; | ||||||
|  |     const language = 'en'; | ||||||
|  |     const mockMetadata: LearningObjectMetadata = { | ||||||
|  |         hruid, | ||||||
|  |         _id: '123', | ||||||
|  |         uuid: 'uuid-123', | ||||||
|  |         version: 1, | ||||||
|  |         title: 'Test Object', | ||||||
|  |         language, | ||||||
|  |         difficulty: 5, | ||||||
|  |         estimated_time: 120, | ||||||
|  |         available: true, | ||||||
|  |         teacher_exclusive: false, | ||||||
|  |         educational_goals: [{ source: 'source', id: 'id' }], | ||||||
|  |         keywords: ['robotics'], | ||||||
|  |         description: 'A test object', | ||||||
|  |         target_ages: [10, 12], | ||||||
|  |         content_type: 'markdown', | ||||||
|  |         content_location: '', | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     it('✅ Should return a filtered learning object when API provides data', async () => { | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValueOnce(mockMetadata); | ||||||
|  | 
 | ||||||
|  |         const result = await getLearningObjectById(hruid, language); | ||||||
|  | 
 | ||||||
|  |         expect(result).toEqual({ | ||||||
|  |             key: hruid, | ||||||
|  |             _id: '123', | ||||||
|  |             uuid: 'uuid-123', | ||||||
|  |             version: 1, | ||||||
|  |             title: 'Test Object', | ||||||
|  |             htmlUrl: expect.stringContaining('/learningObject/getRaw?hruid=test-object&language=en'), | ||||||
|  |             language, | ||||||
|  |             difficulty: 5, | ||||||
|  |             estimatedTime: 120, | ||||||
|  |             available: true, | ||||||
|  |             teacherExclusive: false, | ||||||
|  |             educationalGoals: [{ source: 'source', id: 'id' }], | ||||||
|  |             keywords: ['robotics'], | ||||||
|  |             description: 'A test object', | ||||||
|  |             targetAges: [10, 12], | ||||||
|  |             contentType: 'markdown', | ||||||
|  |             contentLocation: '', | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('⚠️ Should return null if API returns no metadata', async () => { | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValueOnce(null); | ||||||
|  |         const result = await getLearningObjectById(hruid, language); | ||||||
|  |         expect(result).toBeNull(); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | describe('getLearningObjectsFromPath', () => { | ||||||
|  |     const hruid = 'test-path'; | ||||||
|  |     const language = 'en'; | ||||||
|  | 
 | ||||||
|  |     it('✅ Should not give error or warning', async () => { | ||||||
|  |         const mockPathResponse: LearningPath[] = [ | ||||||
|  |             { | ||||||
|  |                 _id: 'path-1', | ||||||
|  |                 hruid, | ||||||
|  |                 language, | ||||||
|  |                 title: 'Test Path', | ||||||
|  |                 description: '', | ||||||
|  |                 num_nodes: 1, | ||||||
|  |                 num_nodes_left: 0, | ||||||
|  |                 nodes: [], | ||||||
|  |                 keywords: '', | ||||||
|  |                 target_ages: [], | ||||||
|  |                 min_age: 10, | ||||||
|  |                 max_age: 12, | ||||||
|  |                 __order: 1, | ||||||
|  |             }, | ||||||
|  |         ]; | ||||||
|  | 
 | ||||||
|  |         vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ | ||||||
|  |             success: true, | ||||||
|  |             source: 'Test Source', | ||||||
|  |             data: mockPathResponse, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         const result = await getLearningObjectsFromPath(hruid, language); | ||||||
|  |         expect(result).toEqual([]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('⚠️ Should give a warning', async () => { | ||||||
|  |         vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ success: false, source: 'Test Source', data: [] }); | ||||||
|  | 
 | ||||||
|  |         const result = await getLearningObjectsFromPath(hruid, language); | ||||||
|  |         expect(result).toEqual([]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('❌ Should give an error', async () => { | ||||||
|  |         vi.mocked(fetchLearningPaths).mockRejectedValueOnce(new Error('API Error')); | ||||||
|  | 
 | ||||||
|  |         const result = await getLearningObjectsFromPath(hruid, language); | ||||||
|  |         expect(result).toEqual([]); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										90
									
								
								backend/tests/service/learning-paths.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								backend/tests/service/learning-paths.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,90 @@ | ||||||
|  | import { describe, it, expect, vi } from 'vitest'; | ||||||
|  | import { fetchLearningPaths, searchLearningPaths } from '../../src/services/learningPaths'; | ||||||
|  | import { fetchWithLogging } from '../../src/util/apiHelper'; | ||||||
|  | import { LearningPathResponse } from '../../src/interfaces/learningPath'; | ||||||
|  | 
 | ||||||
|  | // Mock the fetchWithLogging module using vi
 | ||||||
|  | vi.mock('../../src/util/apiHelper', () => ({ | ||||||
|  |     fetchWithLogging: vi.fn(), | ||||||
|  | })); | ||||||
|  | 
 | ||||||
|  | describe('fetchLearningPaths', () => { | ||||||
|  |     // Mock data and response
 | ||||||
|  |     const mockHruids = ['pn_werking', 'art1']; | ||||||
|  |     const language = 'en'; | ||||||
|  |     const source = 'Test Source'; | ||||||
|  |     const mockResponse = [{ title: 'Test Path', hruids: mockHruids }]; | ||||||
|  | 
 | ||||||
|  |     it('✅ Should return a successful response when HRUIDs are provided', async () => { | ||||||
|  |         // Mock the function to return mockResponse
 | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); | ||||||
|  | 
 | ||||||
|  |         const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); | ||||||
|  | 
 | ||||||
|  |         expect(result.success).toBe(true); | ||||||
|  |         expect(result.data).toEqual(mockResponse); | ||||||
|  |         expect(result.source).toBe(source); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('⚠️ Should return an error when no HRUIDs are provided', async () => { | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); | ||||||
|  | 
 | ||||||
|  |         const result: LearningPathResponse = await fetchLearningPaths([], language, source); | ||||||
|  | 
 | ||||||
|  |         expect(result.success).toBe(false); | ||||||
|  |         expect(result.data).toBeNull(); | ||||||
|  |         expect(result.message).toBe(`No HRUIDs provided for ${source}.`); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('⚠️ Should return a failure response when no learning paths are found', async () => { | ||||||
|  |         // Mock fetchWithLogging to return an empty array
 | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValue([]); | ||||||
|  | 
 | ||||||
|  |         const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); | ||||||
|  | 
 | ||||||
|  |         expect(result.success).toBe(false); | ||||||
|  |         expect(result.data).toEqual([]); | ||||||
|  |         expect(result.message).toBe(`No learning paths found for ${source}.`); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | describe('searchLearningPaths', () => { | ||||||
|  |     const query = | ||||||
|  |         'https://dwengo.org/backend/api/learningPath/getPathsFromIdList?pathIdList=%7B%22hruids%22:%5B%22pn_werking%22,%22un_artificiele_intelligentie%22%5D%7D&language=nl'; | ||||||
|  |     const language = 'nl'; | ||||||
|  | 
 | ||||||
|  |     it('✅ Should return search results when API responds with data', async () => { | ||||||
|  |         const mockResults = [ | ||||||
|  |             { | ||||||
|  |                 _id: '67b4488c9dadb305c4104618', | ||||||
|  |                 language: 'nl', | ||||||
|  |                 hruid: 'pn_werking', | ||||||
|  |                 title: 'Werken met notebooks', | ||||||
|  |                 description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?', | ||||||
|  |                 num_nodes: 0, | ||||||
|  |                 num_nodes_left: 0, | ||||||
|  |                 nodes: [], | ||||||
|  |                 keywords: 'Python KIKS Wiskunde STEM AI', | ||||||
|  |                 target_ages: [14, 15, 16, 17, 18], | ||||||
|  |                 min_age: 14, | ||||||
|  |                 max_age: 18, | ||||||
|  |                 __order: 0, | ||||||
|  |             }, | ||||||
|  |         ]; | ||||||
|  | 
 | ||||||
|  |         // Mock fetchWithLogging to return search results
 | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValue(mockResults); | ||||||
|  | 
 | ||||||
|  |         const result = await searchLearningPaths(query, language); | ||||||
|  | 
 | ||||||
|  |         expect(result).toEqual(mockResults); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('⚠️ Should return an empty array when API returns no results', async () => { | ||||||
|  |         vi.mocked(fetchWithLogging).mockResolvedValue([]); | ||||||
|  | 
 | ||||||
|  |         const result = await searchLearningPaths(query, language); | ||||||
|  | 
 | ||||||
|  |         expect(result).toEqual([]); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,108 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; | ||||||
|  | import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; | ||||||
|  | import { LearningObject } from '../../../src/entities/content/learning-object.entity'; | ||||||
|  | import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider'; | ||||||
|  | import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations'; | ||||||
|  | import { FilteredLearningObject } from '../../../src/interfaces/learning-content'; | ||||||
|  | import { Language } from '../../../src/entities/content/language'; | ||||||
|  | import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; | ||||||
|  | import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; | ||||||
|  | import { LearningPath } from '../../../src/entities/content/learning-path.entity'; | ||||||
|  | 
 | ||||||
|  | async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { | ||||||
|  |     const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  |     const learningPathRepo = getLearningPathRepository(); | ||||||
|  |     const learningObject = learningObjectExample.createLearningObject(); | ||||||
|  |     const learningPath = learningPathExample.createLearningPath(); | ||||||
|  |     await learningObjectRepo.save(learningObject); | ||||||
|  |     await learningPathRepo.save(learningPath); | ||||||
|  |     return { learningObject, learningPath }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan'; | ||||||
|  | 
 | ||||||
|  | describe('DatabaseLearningObjectProvider', () => { | ||||||
|  |     let exampleLearningObject: LearningObject; | ||||||
|  |     let exampleLearningPath: LearningPath; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         const exampleData = await initExampleData(); | ||||||
|  |         exampleLearningObject = exampleData.learningObject; | ||||||
|  |         exampleLearningPath = exampleData.learningPath; | ||||||
|  |     }); | ||||||
|  |     describe('getLearningObjectById', () => { | ||||||
|  |         it('should return the learning object when it is queried by its id', async () => { | ||||||
|  |             const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById(exampleLearningObject); | ||||||
|  |             expect(result).toBeTruthy(); | ||||||
|  |             expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         it('should return the learning object when it is queried by only hruid and language (but not version)', async () => { | ||||||
|  |             const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ | ||||||
|  |                 hruid: exampleLearningObject.hruid, | ||||||
|  |                 language: exampleLearningObject.language, | ||||||
|  |             }); | ||||||
|  |             expect(result).toBeTruthy(); | ||||||
|  |             expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         it('should return null when queried with an id that does not exist', async () => { | ||||||
|  |             const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ | ||||||
|  |                 hruid: 'non_existing_hruid', | ||||||
|  |                 language: Language.Dutch, | ||||||
|  |             }); | ||||||
|  |             expect(result).toBeNull(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |     describe('getLearningObjectHTML', () => { | ||||||
|  |         it('should return the correct rendering of the learning object', async () => { | ||||||
|  |             const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject); | ||||||
|  |             expect(result).toEqual(example.getHTMLRendering()); | ||||||
|  |         }); | ||||||
|  |         it('should return null for a non-existing learning object', async () => { | ||||||
|  |             const result = await databaseLearningObjectProvider.getLearningObjectHTML({ | ||||||
|  |                 hruid: 'non_existing_hruid', | ||||||
|  |                 language: Language.Dutch, | ||||||
|  |             }); | ||||||
|  |             expect(result).toBeNull(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |     describe('getLearningObjectIdsFromPath', () => { | ||||||
|  |         it('should return all learning object IDs from a path', async () => { | ||||||
|  |             const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath); | ||||||
|  |             expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); | ||||||
|  |         }); | ||||||
|  |         it('should throw an error if queried with a path identifier for which there is no learning path', async () => { | ||||||
|  |             await expect( | ||||||
|  |                 (async () => { | ||||||
|  |                     await databaseLearningObjectProvider.getLearningObjectIdsFromPath({ | ||||||
|  |                         hruid: 'non_existing_hruid', | ||||||
|  |                         language: Language.Dutch, | ||||||
|  |                     }); | ||||||
|  |                 })() | ||||||
|  |             ).rejects.toThrowError(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |     describe('getLearningObjectsFromPath', () => { | ||||||
|  |         it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => { | ||||||
|  |             const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPath); | ||||||
|  |             expect(result.length).toBe(exampleLearningPath.nodes.length); | ||||||
|  |             expect(new Set(result.map((it) => it.key))).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); | ||||||
|  | 
 | ||||||
|  |             expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT); | ||||||
|  |         }); | ||||||
|  |         it('should throw an error if queried with a path identifier for which there is no learning path', async () => { | ||||||
|  |             await expect( | ||||||
|  |                 (async () => { | ||||||
|  |                     await databaseLearningObjectProvider.getLearningObjectsFromPath({ | ||||||
|  |                         hruid: 'non_existing_hruid', | ||||||
|  |                         language: Language.Dutch, | ||||||
|  |                     }); | ||||||
|  |                 })() | ||||||
|  |             ).rejects.toThrowError(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,126 @@ | ||||||
|  | import { beforeAll, describe, expect, it } from 'vitest'; | ||||||
|  | import { setupTestApp } from '../../setup-tests'; | ||||||
|  | import { LearningObject } from '../../../src/entities/content/learning-object.entity'; | ||||||
|  | import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; | ||||||
|  | import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; | ||||||
|  | import learningObjectService from '../../../src/services/learning-objects/learning-object-service'; | ||||||
|  | import { LearningObjectIdentifier, LearningPathIdentifier } from '../../../src/interfaces/learning-content'; | ||||||
|  | import { Language } from '../../../src/entities/content/language'; | ||||||
|  | import { EnvVars, getEnvVar } from '../../../src/util/envvars'; | ||||||
|  | import { LearningPath } from '../../../src/entities/content/learning-path.entity'; | ||||||
|  | import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; | ||||||
|  | 
 | ||||||
|  | const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks'; | ||||||
|  | const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifier = { | ||||||
|  |     hruid: 'pn_werkingnotebooks', | ||||||
|  |     language: Language.Dutch, | ||||||
|  |     version: 3, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const DWENGO_TEST_LEARNING_PATH_ID: LearningPathIdentifier = { | ||||||
|  |     hruid: 'pn_werking', | ||||||
|  |     language: Language.Dutch, | ||||||
|  | }; | ||||||
|  | const DWENGO_TEST_LEARNING_PATH_HRUIDS = new Set(['pn_werkingnotebooks', 'pn_werkingnotebooks2', 'pn_werkingnotebooks3']); | ||||||
|  | 
 | ||||||
|  | async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { | ||||||
|  |     const learningObjectRepo = getLearningObjectRepository(); | ||||||
|  |     const learningPathRepo = getLearningPathRepository(); | ||||||
|  |     const learningObject = learningObjectExample.createLearningObject(); | ||||||
|  |     const learningPath = learningPathExample.createLearningPath(); | ||||||
|  |     await learningObjectRepo.save(learningObject); | ||||||
|  |     await learningPathRepo.save(learningPath); | ||||||
|  |     return { learningObject, learningPath }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('LearningObjectService', () => { | ||||||
|  |     let exampleLearningObject: LearningObject; | ||||||
|  |     let exampleLearningPath: LearningPath; | ||||||
|  | 
 | ||||||
|  |     beforeAll(async () => { | ||||||
|  |         await setupTestApp(); | ||||||
|  |         const exampleData = await initExampleData(); | ||||||
|  |         exampleLearningObject = exampleData.learningObject; | ||||||
|  |         exampleLearningPath = exampleData.learningPath; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('getLearningObjectById', () => { | ||||||
|  |         it('returns the learning object from the Dwengo API if it does not have the user content prefix', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectById(DWENGO_TEST_LEARNING_OBJECT_ID); | ||||||
|  |             expect(result).not.toBeNull(); | ||||||
|  |             expect(result?.title).toBe(EXPECTED_DWENGO_LEARNING_OBJECT_TITLE); | ||||||
|  |         }); | ||||||
|  |         it('returns the learning object from the database if it does have the user content prefix', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectById(exampleLearningObject); | ||||||
|  |             expect(result).not.toBeNull(); | ||||||
|  |             expect(result?.title).toBe(exampleLearningObject.title); | ||||||
|  |         }); | ||||||
|  |         it('returns null if the hruid does not have the user content prefix and does not exist in the Dwengo repo', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectById({ | ||||||
|  |                 hruid: 'non-existing', | ||||||
|  |                 language: Language.Dutch, | ||||||
|  |             }); | ||||||
|  |             expect(result).toBeNull(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('getLearningObjectHTML', () => { | ||||||
|  |         it('returns the expected HTML when queried with the identifier of a learning object saved in the database', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject); | ||||||
|  |             expect(result).not.toBeNull(); | ||||||
|  |             expect(result).toEqual(learningObjectExample.getHTMLRendering()); | ||||||
|  |         }); | ||||||
|  |         it( | ||||||
|  |             'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' + | ||||||
|  |                 'not start with the user content prefix', | ||||||
|  |             async () => { | ||||||
|  |                 const result = await learningObjectService.getLearningObjectHTML(DWENGO_TEST_LEARNING_OBJECT_ID); | ||||||
|  |                 expect(result).not.toBeNull(); | ||||||
|  | 
 | ||||||
|  |                 const responseFromDwengoApi = await fetch( | ||||||
|  |                     getEnvVar(EnvVars.LearningContentRepoApiBaseUrl) + | ||||||
|  |                         `/learningObject/getRaw?hruid=${DWENGO_TEST_LEARNING_OBJECT_ID.hruid}&language=${DWENGO_TEST_LEARNING_OBJECT_ID.language}&version=${DWENGO_TEST_LEARNING_OBJECT_ID.version}` | ||||||
|  |                 ); | ||||||
|  |                 const responseHtml = await responseFromDwengoApi.text(); | ||||||
|  |                 expect(result).toEqual(responseHtml); | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |         it('returns null when queried with a non-existing identifier', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectHTML({ | ||||||
|  |                 hruid: 'non_existing_hruid', | ||||||
|  |                 language: Language.Dutch, | ||||||
|  |             }); | ||||||
|  |             expect(result).toBeNull(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('getLearningObjectsFromPath', () => { | ||||||
|  |         it('returns all learning objects when a learning path in the database is queried', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath); | ||||||
|  |             expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); | ||||||
|  |         }); | ||||||
|  |         it('also returns all learning objects when a learning path from the Dwengo API is queried', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID); | ||||||
|  |             expect(new Set(result.map((it) => it.key))).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); | ||||||
|  |         }); | ||||||
|  |         it('returns an empty list when queried with a non-existing learning path id', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectsFromPath({ hruid: 'non_existing', language: Language.Dutch }); | ||||||
|  |             expect(result).toEqual([]); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('getLearningObjectIdsFromPath', () => { | ||||||
|  |         it('returns all learning objects when a learning path in the database is queried', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath); | ||||||
|  |             expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); | ||||||
|  |         }); | ||||||
|  |         it('also returns all learning object hruids when a learning path from the Dwengo API is queried', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID); | ||||||
|  |             expect(new Set(result)).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); | ||||||
|  |         }); | ||||||
|  |         it('returns an empty list when queried with a non-existing learning path id', async () => { | ||||||
|  |             const result = await learningObjectService.getLearningObjectIdsFromPath({ hruid: 'non_existing', language: Language.Dutch }); | ||||||
|  |             expect(result).toEqual([]); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -0,0 +1,25 @@ | ||||||
|  | import { describe, expect, it } from 'vitest'; | ||||||
|  | import mdExample from '../../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; | ||||||
|  | import multipleChoiceExample from '../../../test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example'; | ||||||
|  | import essayExample from '../../../test-assets/learning-objects/test-essay/test-essay-example'; | ||||||
|  | import processingService from '../../../../src/services/learning-objects/processing/processing-service'; | ||||||
|  | 
 | ||||||
|  | describe('ProcessingService', () => { | ||||||
|  |     it('renders a markdown learning object correctly', async () => { | ||||||
|  |         const markdownLearningObject = mdExample.createLearningObject(); | ||||||
|  |         const result = await processingService.render(markdownLearningObject); | ||||||
|  |         expect(result).toEqual(mdExample.getHTMLRendering()); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders a multiple choice question correctly', async () => { | ||||||
|  |         const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject(); | ||||||
|  |         const result = await processingService.render(multipleChoiceLearningObject); | ||||||
|  |         expect(result).toEqual(multipleChoiceExample.getHTMLRendering()); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('renders an essay question correctly', async () => { | ||||||
|  |         const essayLearningObject = essayExample.createLearningObject(); | ||||||
|  |         const result = await processingService.render(essayLearningObject); | ||||||
|  |         expect(result).toEqual(essayExample.getHTMLRendering()); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
Some files were not shown because too many files have changed in this diff Show more
		Reference in a new issue