Merge branch 'dev' into docs/swagger-autogen
This commit is contained in:
		
						commit
						5986ca57bf
					
				
					 189 changed files with 6160 additions and 1581 deletions
				
			
		
							
								
								
									
										7
									
								
								.dockerignore
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.dockerignore
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| **/node_modules/ | ||||
| **/dist | ||||
| .git | ||||
| npm-debug.log | ||||
| .coverage | ||||
| .coverage.* | ||||
| .env | ||||
							
								
								
									
										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. | ||||
| 
 | ||||
| **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: '' | ||||
| --- | ||||
| 
 | ||||
| **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? | ||||
| 
 | ||||
| **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 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. | ||||
| --> | ||||
|  |  | |||
							
								
								
									
										57
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										57
									
								
								README.md
									
										
									
									
									
								
							|  | @ -9,34 +9,43 @@ Figma</a></span> | |||
| Projectopgave</a></span> | ||||
| </p> | ||||
| 
 | ||||
| <ul align="center" style="list-style-type: none"> | ||||
| <li>Projectleider: Fransisco 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 | ||||
| en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. | ||||
| 
 | ||||
| ## Installatie | ||||
| 
 | ||||
| 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. | ||||
| 
 | ||||
| ### 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. | ||||
| 3. Voer `docker compose up` uit in de root van de repository. | ||||
| 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. | ||||
| 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 | ||||
| docker compose version | ||||
| git clone https://github.com/SELab-2/Dwengo-1.git | ||||
| cd Dwengo-1 | ||||
| cd Dwengo-1/backend | ||||
| cp .env.example .env | ||||
| # Pas .env aan | ||||
| nano .env | ||||
| cd .. | ||||
| docker compose up | ||||
| # Configureer de applicatie | ||||
| ``` | ||||
| 
 | ||||
| ### 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 | ||||
| 
 | ||||
|  | @ -46,9 +55,33 @@ De tech-stack bestaat uit: | |||
| 
 | ||||
| - **Frontend**: TypeScript + Vue.js + Vuetify | ||||
| - **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL | ||||
| - **Identity provider**: Keycloak | ||||
| 
 | ||||
| Voor meer informatie over de keuze van deze tech-stack, zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/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 | ||||
| 
 | ||||
| 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? | ||||
|  |  | |||
							
								
								
									
										
											BIN
										
									
								
								assets/img/keycloak.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/img/keycloak.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 7.8 KiB | 
|  | @ -1,10 +1,16 @@ | |||
| DWENGO_PORT=3000 | ||||
| # | ||||
| # Basic configuration | ||||
| # | ||||
| 
 | ||||
| DWENGO_PORT=3000 # The port the backend will listen on | ||||
| DWENGO_DB_HOST=localhost | ||||
| DWENGO_DB_PORT=5431 | ||||
| DWENGO_DB_USERNAME=postgres | ||||
| DWENGO_DB_PASSWORD=postgres | ||||
| DWENGO_DB_UPDATE=true | ||||
| 
 | ||||
| # Auth | ||||
| 
 | ||||
| DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student | ||||
| DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs | ||||
|  | @ -14,3 +20,9 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/ | |||
| 
 | ||||
| # Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! | ||||
| DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 | ||||
| 
 | ||||
| # | ||||
| # Advanced configuration | ||||
| # | ||||
| 
 | ||||
| # LOKI_HOST=http://localhost:9001      # The address of the Loki instance, used for logging | ||||
|  |  | |||
|  | @ -2,10 +2,9 @@ | |||
| # Basic configuration | ||||
| # | ||||
| 
 | ||||
| # The port the backend will listen on | ||||
| DWENGO_PORT=3000 | ||||
| DWENGO_PORT=3000 # The port the backend will listen on | ||||
| DWENGO_DB_HOST=domain-or-ip-of-database | ||||
| DWENGO_DB_PORT=5432 | ||||
| DWENGO_DB_PORT=5431 | ||||
| 
 | ||||
| # Change this to the actual credentials of the user Dwengo should use in the backend | ||||
| DWENGO_DB_USERNAME=postgres | ||||
|  | @ -24,9 +23,5 @@ DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher | |||
| DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs | ||||
| 
 | ||||
| # | ||||
| # Advanced configuration | ||||
| # | ||||
| 
 | ||||
| # The address of the Lokiinstance, used for logging | ||||
| # LOKI_HOST=http://localhost:3102 | ||||
|  |  | |||
							
								
								
									
										28
									
								
								backend/.env.production.example
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								backend/.env.production.example
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| DWENGO_PORT=3000 # The port the backend will listen on | ||||
| DWENGO_DB_HOST=db # Name of the database container | ||||
| DWENGO_DB_PORT=5431 | ||||
| 
 | ||||
| # Change this to the actual credentials of the user Dwengo should use in the backend | ||||
| DWENGO_DB_NAME=postgres | ||||
| DWENGO_DB_USERNAME=postgres | ||||
| DWENGO_DB_PASSWORD=postgres | ||||
| 
 | ||||
| # Set this to true when the database scheme needs to be updated. In that case, take a backup first. | ||||
| DWENGO_DB_UPDATE=false | ||||
| 
 | ||||
| # Data for the identity provider via which the students authenticate. | ||||
| DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student | ||||
| DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container | ||||
| # Data for the identity provider via which the teachers authenticate. | ||||
| DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher | ||||
| DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo | ||||
| DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container | ||||
| 
 | ||||
| # | ||||
| # Advanced configuration | ||||
| # | ||||
| 
 | ||||
| # Logging and monitoring | ||||
| 
 | ||||
| # LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging | ||||
							
								
								
									
										35
									
								
								backend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| FROM node:22 AS build-stage | ||||
| 
 | ||||
| WORKDIR /app | ||||
| 
 | ||||
| # Install dependencies | ||||
| 
 | ||||
| COPY package*.json ./ | ||||
| COPY backend/package.json ./backend/ | ||||
| 
 | ||||
| RUN npm install --silent | ||||
| 
 | ||||
| # Build the backend | ||||
| 
 | ||||
| # Root tsconfig.json | ||||
| COPY tsconfig.json ./ | ||||
| 
 | ||||
| WORKDIR /app/backend | ||||
| 
 | ||||
| COPY backend ./ | ||||
| 
 | ||||
| RUN npm run build | ||||
| 
 | ||||
| FROM node:22 AS production-stage | ||||
| 
 | ||||
| WORKDIR /app | ||||
| 
 | ||||
| COPY package-lock.json backend/package.json ./ | ||||
| 
 | ||||
| RUN npm install --silent --only=production | ||||
| 
 | ||||
| COPY --from=build-stage /app/backend/dist ./dist/ | ||||
| 
 | ||||
| EXPOSE 3000 | ||||
| 
 | ||||
| CMD ["node", "--env-file=.env", "dist/app.js"] | ||||
|  | @ -20,3 +20,18 @@ npm run dev | |||
| npm run build | ||||
| npm run start | ||||
| ``` | ||||
| 
 | ||||
| ### Tests | ||||
| 
 | ||||
| Voer volgend commando uit om de unit tests uit te voeren: | ||||
| 
 | ||||
| ``` | ||||
| npm run test:unit | ||||
| ``` | ||||
| 
 | ||||
| ## Keycloak configuratie | ||||
| 
 | ||||
| Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. | ||||
| 
 | ||||
| Voor productie is het ten sterkste aangeraden om keycloak manueel te configureren. | ||||
| Voor meer informatie, zie de [administrator-handleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#installatie-en-server-configuratie). | ||||
|  |  | |||
|  | @ -1,10 +0,0 @@ | |||
| // Can be placed in dotenv but found it redundant
 | ||||
| 
 | ||||
| // Import dotenv from "dotenv";
 | ||||
| 
 | ||||
| // Load .env file
 | ||||
| // Dotenv.config();
 | ||||
| 
 | ||||
| export const DWENGO_API_BASE = 'https://dwengo.org/backend/api'; | ||||
| 
 | ||||
| export const FALLBACK_LANG = 'nl'; | ||||
|  | @ -8,4 +8,14 @@ export default [ | |||
|             globals: globals.node, | ||||
|         }, | ||||
|     }, | ||||
| 
 | ||||
|     { | ||||
|         files: ['tests/**/*.ts'], | ||||
|         languageOptions: { | ||||
|             globals: globals.node, | ||||
|         }, | ||||
|         rules: { | ||||
|             'no-console': 'off', | ||||
|         }, | ||||
|     }, | ||||
| ]; | ||||
|  |  | |||
|  | @ -5,27 +5,34 @@ | |||
|     "private": true, | ||||
|     "type": "module", | ||||
|     "scripts": { | ||||
|         "build": "NODE_ENV=production tsc --project tsconfig.json", | ||||
|         "dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", | ||||
|         "start": "NODE_ENV=production node --env-file=.env dist/app.js", | ||||
|         "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", | ||||
|         "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", | ||||
|         "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", | ||||
|         "format": "prettier --write src/", | ||||
|         "format-check": "prettier --check src/", | ||||
|         "lint": "eslint . --fix", | ||||
|         "test:unit": "vitest" | ||||
|     }, | ||||
|     "dependencies": { | ||||
|         "@mikro-orm/core": "6.4.6", | ||||
|         "@mikro-orm/postgresql": "6.4.6", | ||||
|         "@mikro-orm/reflection": "6.4.6", | ||||
|         "@mikro-orm/sqlite": "6.4.6", | ||||
|         "axios": "^1.8.1", | ||||
|         "@mikro-orm/core": "6.4.9", | ||||
|         "@mikro-orm/knex": "6.4.9", | ||||
|         "@mikro-orm/postgresql": "6.4.9", | ||||
|         "@mikro-orm/reflection": "6.4.9", | ||||
|         "@mikro-orm/sqlite": "6.4.9", | ||||
|         "axios": "^1.8.2", | ||||
|         "cors": "^2.8.5", | ||||
|         "cross": "^1.0.0", | ||||
|         "cross-env": "^7.0.3", | ||||
|         "dotenv": "^16.4.7", | ||||
|         "express": "^5.0.1", | ||||
|         "express-jwt": "^8.5.1", | ||||
|         "jwks-rsa": "^3.1.0", | ||||
|         "gift-pegjs": "^1.0.2", | ||||
|         "isomorphic-dompurify": "^2.22.0", | ||||
|         "js-yaml": "^4.1.0", | ||||
|         "cors": "^2.8.5", | ||||
|         "jsonpath-plus": "^10.3.0", | ||||
|         "jwks-rsa": "^3.1.0", | ||||
|         "loki-logger-ts": "^1.0.2", | ||||
|         "marked": "^15.0.7", | ||||
|         "response-time": "^2.3.3", | ||||
|         "swagger-ui-express": "^5.0.1", | ||||
|         "uuid": "^11.1.0", | ||||
|  | @ -33,13 +40,13 @@ | |||
|         "winston-loki": "^6.1.3" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "@types/js-yaml": "^4.0.9", | ||||
|         "@mikro-orm/cli": "^6.4.6", | ||||
|         "@mikro-orm/cli": "6.4.9", | ||||
|         "@types/cors": "^2.8.17", | ||||
|         "@types/express": "^5.0.0", | ||||
|         "@types/js-yaml": "^4.0.9", | ||||
|         "@types/node": "^22.13.4", | ||||
|         "@types/response-time": "^2.3.8", | ||||
|         "@types/swagger-ui-express": "^4.1.8", | ||||
|         "@types/cors": "^2.8.17", | ||||
|         "globals": "^15.15.0", | ||||
|         "ts-node": "^10.9.2", | ||||
|         "tsx": "^4.19.3", | ||||
|  |  | |||
|  | @ -1,25 +1,12 @@ | |||
| import express, { Express, Response } from 'express'; | ||||
| import express, { Express } from 'express'; | ||||
| import { initORM } from './orm.js'; | ||||
| 
 | ||||
| import themeRoutes from './routes/themes.js'; | ||||
| import learningPathRoutes from './routes/learningPaths.js'; | ||||
| import learningObjectRoutes from './routes/learningObjects.js'; | ||||
| 
 | ||||
| import studentRouter from './routes/student.js'; | ||||
| import groupRouter from './routes/group.js'; | ||||
| import assignmentRouter from './routes/assignment.js'; | ||||
| import submissionRouter from './routes/submission.js'; | ||||
| import classRouter from './routes/class.js'; | ||||
| import questionRouter from './routes/question.js'; | ||||
| import authRouter from './routes/auth.js'; | ||||
| import { authenticateUser } from './middleware/auth/auth.js'; | ||||
| import cors from './middleware/cors.js'; | ||||
| import { getLogger, Logger } from './logging/initalize.js'; | ||||
| import { responseTimeLogger } from './logging/responseTimeLogger.js'; | ||||
| import responseTime from 'response-time'; | ||||
| import { EnvVars, getNumericEnvVar } from './util/envvars.js'; | ||||
| import swaggerMiddleware from './swagger'; | ||||
| import swaggerUi from 'swagger-ui-express'; | ||||
| import apiRouter from './routes/router.js'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
|  | @ -32,41 +19,13 @@ app.use(authenticateUser); | |||
| // Add response time logging
 | ||||
| app.use(responseTime(responseTimeLogger)); | ||||
| 
 | ||||
| // TODO Replace with Express routes
 | ||||
| app.get('/', (_, res: Response) => { | ||||
|     logger.debug('GET /'); | ||||
|     res.json({ | ||||
|         message: 'Hello Dwengo!🚀', | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Routes
 | ||||
| app.use('/student', studentRouter /* #swagger.tags = ['Student'] */); | ||||
| app.use('/group', groupRouter /* #swagger.tags = ['Group'] */); | ||||
| app.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); | ||||
| app.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); | ||||
| app.use('/class', classRouter /* #swagger.tags = ['Class'] */); | ||||
| app.use('/question', questionRouter /* #swagger.tags = ['Question'] */); | ||||
| app.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); | ||||
| app.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); | ||||
| 
 | ||||
| app.use( | ||||
|     '/learningPath', | ||||
|     learningPathRoutes /* #swagger.tags = ['Learning Path'] */ | ||||
| ); | ||||
| app.use( | ||||
|     '/learningObject', | ||||
|     learningObjectRoutes /* #swagger.tags = ['Learning Object'] */ | ||||
| ); | ||||
| 
 | ||||
| // Swagger UI for API documentation
 | ||||
| app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); | ||||
| app.get('/api', apiRouter); | ||||
| 
 | ||||
| async function startServer() { | ||||
|     await initORM(); | ||||
| 
 | ||||
|     app.listen(port, () => { | ||||
|         logger.info(`Server is running at http://localhost:${port}`); | ||||
|         logger.info(`Server is running at http://localhost:${port}/api`); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,12 +1,9 @@ | |||
| export const FALLBACK_LANG: string = 'nl'; | ||||
| import { EnvVars, getEnvVar } from './util/envvars.js'; | ||||
| 
 | ||||
| // API
 | ||||
| 
 | ||||
| export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api'; | ||||
| export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); | ||||
| export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); | ||||
| 
 | ||||
| // Logging
 | ||||
| 
 | ||||
| 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 LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; | ||||
| 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,61 +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,34 +1,25 @@ | |||
| import { Request, Response } from 'express'; | ||||
| import { themes } from '../data/themes.js'; | ||||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| import { | ||||
|     fetchLearningPaths, | ||||
|     searchLearningPaths, | ||||
| } from '../services/learningPaths.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. | ||||
|  */ | ||||
| export async function getLearningPaths( | ||||
|     req: Request, | ||||
|     res: Response | ||||
| ): Promise<void> { | ||||
| export async function getLearningPaths(req: Request, res: Response): Promise<void> { | ||||
|     try { | ||||
|         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 language = (req.query.language as Language) || FALLBACK_LANG; | ||||
| 
 | ||||
|         let hruidList; | ||||
| 
 | ||||
|         if (hruids) { | ||||
|             hruidList = Array.isArray(hruids) | ||||
|                 ? hruids.map(String) | ||||
|                 : [String(hruids)]; | ||||
|             hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; | ||||
|         } else if (themeKey) { | ||||
|             const theme = themes.find((t) => { | ||||
|                 return t.title === themeKey; | ||||
|             }); | ||||
|             const theme = themes.find((t) => t.title === themeKey); | ||||
|             if (theme) { | ||||
|                 hruidList = theme.hruids; | ||||
|             } else { | ||||
|  | @ -38,29 +29,17 @@ export async function getLearningPaths( | |||
|                 return; | ||||
|             } | ||||
|         } else if (searchQuery) { | ||||
|             const searchResults = await searchLearningPaths( | ||||
|                 searchQuery, | ||||
|                 language | ||||
|             ); | ||||
|             const searchResults = await learningPathService.searchLearningPaths(searchQuery, language); | ||||
|             res.json(searchResults); | ||||
|             return; | ||||
|         } else { | ||||
|             hruidList = themes.flatMap((theme) => { | ||||
|                 return 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); | ||||
|     } catch (error) { | ||||
|         getLogger().error( | ||||
|             '❌ Unexpected error fetching learning paths:', | ||||
|             error | ||||
|         ); | ||||
|         getLogger().error('❌ Unexpected error fetching learning paths:', error); | ||||
|         res.status(500).json({ error: 'Internal server error' }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -11,24 +11,19 @@ interface Translations { | |||
| export function getThemes(req: Request, res: Response) { | ||||
|     const language = (req.query.language as string)?.toLowerCase() || 'nl'; | ||||
|     const translations = loadTranslations<Translations>(language); | ||||
|     const themeList = themes.map((theme) => { | ||||
|         return { | ||||
|             key: theme.title, | ||||
|             title: | ||||
|                 translations.curricula_page[theme.title]?.title || theme.title, | ||||
|             description: translations.curricula_page[theme.title]?.description, | ||||
|             image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, | ||||
|         }; | ||||
|     }); | ||||
|     const themeList = themes.map((theme) => ({ | ||||
|         key: theme.title, | ||||
|         title: translations.curricula_page[theme.title]?.title || theme.title, | ||||
|         description: translations.curricula_page[theme.title]?.description, | ||||
|         image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, | ||||
|     })); | ||||
| 
 | ||||
|     res.json(themeList); | ||||
| } | ||||
| 
 | ||||
| export function getThemeByTitle(req: Request, res: Response) { | ||||
|     const themeKey = req.params.theme; | ||||
|     const theme = themes.find((t) => { | ||||
|         return t.title === themeKey; | ||||
|     }); | ||||
|     const theme = themes.find((t) => t.title === themeKey); | ||||
| 
 | ||||
|     if (theme) { | ||||
|         res.json(theme.hruids); | ||||
|  |  | |||
|  | @ -3,10 +3,7 @@ import { Assignment } from '../../entities/assignments/assignment.entity.js'; | |||
| import { Class } from '../../entities/classes/class.entity.js'; | ||||
| 
 | ||||
| export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | ||||
|     public findByClassAndId( | ||||
|         within: Class, | ||||
|         id: number | ||||
|     ): Promise<Assignment | null> { | ||||
|     public findByClassAndId(within: Class, id: number): Promise<Assignment | null> { | ||||
|         return this.findOne({ within: within, id: id }); | ||||
|     } | ||||
|     public findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { | ||||
|  |  | |||
|  | @ -3,24 +3,16 @@ import { Group } from '../../entities/assignments/group.entity.js'; | |||
| import { Assignment } from '../../entities/assignments/assignment.entity.js'; | ||||
| 
 | ||||
| export class GroupRepository extends DwengoEntityRepository<Group> { | ||||
|     public findByAssignmentAndGroupNumber( | ||||
|         assignment: Assignment, | ||||
|         groupNumber: number | ||||
|     ): Promise<Group | null> { | ||||
|     public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise<Group | null> { | ||||
|         return this.findOne({ | ||||
|             assignment: assignment, | ||||
|             groupNumber: groupNumber, | ||||
|         }); | ||||
|     } | ||||
|     public findAllGroupsForAssignment( | ||||
|         assignment: Assignment | ||||
|     ): Promise<Group[]> { | ||||
|     public findAllGroupsForAssignment(assignment: Assignment): Promise<Group[]> { | ||||
|         return this.findAll({ where: { assignment: assignment } }); | ||||
|     } | ||||
|     public deleteByAssignmentAndGroupNumber( | ||||
|         assignment: Assignment, | ||||
|         groupNumber: number | ||||
|     ) { | ||||
|     public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) { | ||||
|         return this.deleteWhere({ | ||||
|             assignment: assignment, | ||||
|             groupNumber: groupNumber, | ||||
|  |  | |||
|  | @ -5,10 +5,7 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object | |||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| 
 | ||||
| export class SubmissionRepository extends DwengoEntityRepository<Submission> { | ||||
|     public findSubmissionByLearningObjectAndSubmissionNumber( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         submissionNumber: number | ||||
|     ): Promise<Submission | null> { | ||||
|     public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<Submission | null> { | ||||
|         return this.findOne({ | ||||
|             learningObjectHruid: loId.hruid, | ||||
|             learningObjectLanguage: loId.language, | ||||
|  | @ -17,10 +14,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     public findMostRecentSubmissionForStudent( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         submitter: Student | ||||
|     ): Promise<Submission | null> { | ||||
|     public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise<Submission | null> { | ||||
|         return this.findOne( | ||||
|             { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|  | @ -32,10 +26,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public findMostRecentSubmissionForGroup( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         group: Group | ||||
|     ): Promise<Submission | null> { | ||||
|     public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission | null> { | ||||
|         return this.findOne( | ||||
|             { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|  | @ -47,10 +38,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public deleteSubmissionByLearningObjectAndSubmissionNumber( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         submissionNumber: number | ||||
|     ): Promise<void> { | ||||
|     public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             learningObjectHruid: loId.hruid, | ||||
|             learningObjectLanguage: loId.language, | ||||
|  |  | |||
|  | @ -4,24 +4,16 @@ import { TeacherInvitation } from '../../entities/classes/teacher-invitation.ent | |||
| import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||
| 
 | ||||
| export class TeacherInvitationRepository extends DwengoEntityRepository<TeacherInvitation> { | ||||
|     public findAllInvitationsForClass( | ||||
|         clazz: Class | ||||
|     ): Promise<TeacherInvitation[]> { | ||||
|     public findAllInvitationsForClass(clazz: Class): Promise<TeacherInvitation[]> { | ||||
|         return this.findAll({ where: { class: clazz } }); | ||||
|     } | ||||
|     public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> { | ||||
|         return this.findAll({ where: { sender: sender } }); | ||||
|     } | ||||
|     public findAllInvitationsFor( | ||||
|         receiver: Teacher | ||||
|     ): Promise<TeacherInvitation[]> { | ||||
|     public findAllInvitationsFor(receiver: Teacher): Promise<TeacherInvitation[]> { | ||||
|         return this.findAll({ where: { receiver: receiver } }); | ||||
|     } | ||||
|     public deleteBy( | ||||
|         clazz: Class, | ||||
|         sender: Teacher, | ||||
|         receiver: Teacher | ||||
|     ): Promise<void> { | ||||
|     public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             sender: sender, | ||||
|             receiver: receiver, | ||||
|  |  | |||
|  | @ -1,16 +1,37 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.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> { | ||||
|     public findByLearningObjectAndNumber( | ||||
|         learningObject: LearningObject, | ||||
|         sequenceNumber: number | ||||
|     ) { | ||||
|     public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> { | ||||
|         return this.findOne({ | ||||
|             learningObject: learningObject, | ||||
|             sequenceNumber: sequenceNumber, | ||||
|             learningObject: { | ||||
|                 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.
 | ||||
| } | ||||
|  |  | |||
|  | @ -1,16 +1,34 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||
| import { Language } from '../../entities/content/language'; | ||||
| 
 | ||||
| export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | ||||
|     public findByIdentifier( | ||||
|         identifier: LearningObjectIdentifier | ||||
|     ): Promise<LearningObject | null> { | ||||
|         return this.findOne({ | ||||
|             hruid: identifier.hruid, | ||||
|             language: identifier.language, | ||||
|             version: identifier.version, | ||||
|         }); | ||||
|     public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||
|         return this.findOne( | ||||
|             { | ||||
|                 hruid: identifier.hruid, | ||||
|                 language: identifier.language, | ||||
|                 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.
 | ||||
| } | ||||
|  |  | |||
|  | @ -3,11 +3,24 @@ import { LearningPath } from '../../entities/content/learning-path.entity.js'; | |||
| import { Language } from '../../entities/content/language.js'; | ||||
| 
 | ||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||
|     public findByHruidAndLanguage( | ||||
|         hruid: string, | ||||
|         language: Language | ||||
|     ): Promise<LearningPath | null> { | ||||
|         return this.findOne({ hruid: hruid, language: language }); | ||||
|     public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|         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.
 | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,6 @@ | |||
| import { EntityRepository, FilterQuery } from '@mikro-orm/core'; | ||||
| 
 | ||||
| export abstract class DwengoEntityRepository< | ||||
|     T extends object, | ||||
| > extends EntityRepository<T> { | ||||
| export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> { | ||||
|     public async save(entity: T) { | ||||
|         const em = this.getEntityManager(); | ||||
|         em.persist(entity); | ||||
|  |  | |||
|  | @ -4,15 +4,13 @@ import { Question } from '../../entities/questions/question.entity.js'; | |||
| import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||
| 
 | ||||
| export class AnswerRepository extends DwengoEntityRepository<Answer> { | ||||
|     public createAnswer(answer: { | ||||
|         toQuestion: Question; | ||||
|         author: Teacher; | ||||
|         content: string; | ||||
|     }): Promise<Answer> { | ||||
|         const answerEntity = new Answer(); | ||||
|         answerEntity.toQuestion = answer.toQuestion; | ||||
|         answerEntity.author = answer.author; | ||||
|         answerEntity.content = answer.content; | ||||
|     public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> { | ||||
|         const answerEntity = this.create({ | ||||
|             toQuestion: answer.toQuestion, | ||||
|             author: answer.author, | ||||
|             content: answer.content, | ||||
|             timestamp: new Date(), | ||||
|         }); | ||||
|         return this.insert(answerEntity); | ||||
|     } | ||||
|     public findAllAnswersToQuestion(question: Question): Promise<Answer[]> { | ||||
|  | @ -21,10 +19,7 @@ export class AnswerRepository extends DwengoEntityRepository<Answer> { | |||
|             orderBy: { sequenceNumber: 'ASC' }, | ||||
|         }); | ||||
|     } | ||||
|     public removeAnswerByQuestionAndSequenceNumber( | ||||
|         question: Question, | ||||
|         sequenceNumber: number | ||||
|     ): Promise<void> { | ||||
|     public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             toQuestion: question, | ||||
|             sequenceNumber: sequenceNumber, | ||||
|  |  | |||
|  | @ -4,12 +4,15 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object | |||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| 
 | ||||
| export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||
|     public createQuestion(question: { | ||||
|         loId: LearningObjectIdentifier; | ||||
|         author: Student; | ||||
|         content: string; | ||||
|     }): Promise<Question> { | ||||
|         const questionEntity = new Question(); | ||||
|     public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<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.learningObjectLanguage = question.loId.language; | ||||
|         questionEntity.learningObjectVersion = question.loId.version; | ||||
|  | @ -17,9 +20,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> { | |||
|         questionEntity.content = question.content; | ||||
|         return this.insert(questionEntity); | ||||
|     } | ||||
|     public findAllQuestionsAboutLearningObject( | ||||
|         loId: LearningObjectIdentifier | ||||
|     ): Promise<Question[]> { | ||||
|     public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> { | ||||
|         return this.findAll({ | ||||
|             where: { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|  | @ -31,10 +32,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> { | |||
|             }, | ||||
|         }); | ||||
|     } | ||||
|     public removeQuestionByLearningObjectAndSequenceNumber( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         sequenceNumber: number | ||||
|     ): Promise<void> { | ||||
|     public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             learningObjectHruid: loId.hruid, | ||||
|             learningObjectLanguage: loId.language, | ||||
|  |  | |||
|  | @ -1,9 +1,4 @@ | |||
| import { | ||||
|     AnyEntity, | ||||
|     EntityManager, | ||||
|     EntityName, | ||||
|     EntityRepository, | ||||
| } from '@mikro-orm/core'; | ||||
| import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-orm/core'; | ||||
| import { forkEntityManager } from '../orm.js'; | ||||
| import { StudentRepository } from './users/student-repository.js'; | ||||
| import { Student } from '../entities/users/student.entity.js'; | ||||
|  | @ -33,6 +28,8 @@ import { LearningPath } from '../entities/content/learning-path.entity.js'; | |||
| import { LearningPathRepository } from './content/learning-path-repository.js'; | ||||
| import { AttachmentRepository } from './content/attachment-repository.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; | ||||
| 
 | ||||
|  | @ -43,9 +40,7 @@ export function transactional<T>(f: () => Promise<T>) { | |||
|     entityManager?.transactional(f); | ||||
| } | ||||
| 
 | ||||
| function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>( | ||||
|     entity: EntityName<T> | ||||
| ): () => R { | ||||
| function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(entity: EntityName<T>): () => R { | ||||
|     let cachedRepo: R | undefined; | ||||
|     return (): R => { | ||||
|         if (!cachedRepo) { | ||||
|  | @ -60,60 +55,26 @@ function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>( | |||
| 
 | ||||
| /* Users */ | ||||
| export const getUserRepository = repositoryGetter<User, UserRepository>(User); | ||||
| export const getStudentRepository = repositoryGetter< | ||||
|     Student, | ||||
|     StudentRepository | ||||
| >(Student); | ||||
| export const getTeacherRepository = repositoryGetter< | ||||
|     Teacher, | ||||
|     TeacherRepository | ||||
| >(Teacher); | ||||
| export const getStudentRepository = repositoryGetter<Student, StudentRepository>(Student); | ||||
| export const getTeacherRepository = repositoryGetter<Teacher, TeacherRepository>(Teacher); | ||||
| 
 | ||||
| /* Classes */ | ||||
| export const getClassRepository = repositoryGetter<Class, ClassRepository>( | ||||
|     Class | ||||
| ); | ||||
| export const getClassJoinRequestRepository = repositoryGetter< | ||||
|     ClassJoinRequest, | ||||
|     ClassJoinRequestRepository | ||||
| >(ClassJoinRequest); | ||||
| export const getTeacherInvitationRepository = repositoryGetter< | ||||
|     TeacherInvitation, | ||||
|     TeacherInvitationRepository | ||||
| >(TeacherInvitationRepository); | ||||
| export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class); | ||||
| export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest); | ||||
| export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitation); | ||||
| 
 | ||||
| /* Assignments */ | ||||
| export const getAssignmentRepository = repositoryGetter< | ||||
|     Assignment, | ||||
|     AssignmentRepository | ||||
| >(Assignment); | ||||
| export const getGroupRepository = repositoryGetter<Group, GroupRepository>( | ||||
|     Group | ||||
| ); | ||||
| export const getSubmissionRepository = repositoryGetter< | ||||
|     Submission, | ||||
|     SubmissionRepository | ||||
| >(Submission); | ||||
| export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment); | ||||
| export const getGroupRepository = repositoryGetter<Group, GroupRepository>(Group); | ||||
| export const getSubmissionRepository = repositoryGetter<Submission, SubmissionRepository>(Submission); | ||||
| 
 | ||||
| /* Questions and answers */ | ||||
| export const getQuestionRepository = repositoryGetter< | ||||
|     Question, | ||||
|     QuestionRepository | ||||
| >(Question); | ||||
| export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>( | ||||
|     Answer | ||||
| ); | ||||
| export const getQuestionRepository = repositoryGetter<Question, QuestionRepository>(Question); | ||||
| export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(Answer); | ||||
| 
 | ||||
| /* Learning content */ | ||||
| export const getLearningObjectRepository = repositoryGetter< | ||||
|     LearningObject, | ||||
|     LearningObjectRepository | ||||
| >(LearningObject); | ||||
| export const getLearningPathRepository = repositoryGetter< | ||||
|     LearningPath, | ||||
|     LearningPathRepository | ||||
| >(LearningPath); | ||||
| export const getAttachmentRepository = repositoryGetter< | ||||
|     Attachment, | ||||
|     AttachmentRepository | ||||
| >(Assignment); | ||||
| export const getLearningObjectRepository = repositoryGetter<LearningObject, LearningObjectRepository>(LearningObject); | ||||
| export const getLearningPathRepository = repositoryGetter<LearningPath, LearningPathRepository>(LearningPath); | ||||
| export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode); | ||||
| export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition); | ||||
| export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Attachment); | ||||
|  |  | |||
|  | @ -23,13 +23,7 @@ export const themes: Theme[] = [ | |||
|     }, | ||||
|     { | ||||
|         title: 'art', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'art1', | ||||
|             'art2', | ||||
|             'art3', | ||||
|         ], | ||||
|         hruids: ['pn_werking', 'un_artificiele_intelligentie', 'art1', 'art2', 'art3'], | ||||
|     }, | ||||
|     { | ||||
|         title: 'socialrobot', | ||||
|  | @ -37,12 +31,7 @@ export const themes: Theme[] = [ | |||
|     }, | ||||
|     { | ||||
|         title: 'agriculture', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'agri_landbouw', | ||||
|             'agri_lopendeband', | ||||
|         ], | ||||
|         hruids: ['pn_werking', 'un_artificiele_intelligentie', 'agri_landbouw', 'agri_lopendeband'], | ||||
|     }, | ||||
|     { | ||||
|         title: 'wegostem', | ||||
|  | @ -83,16 +72,7 @@ export const themes: Theme[] = [ | |||
|     }, | ||||
|     { | ||||
|         title: 'python_programming', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'pn_datatypes', | ||||
|             'pn_operatoren', | ||||
|             'pn_structuren', | ||||
|             'pn_functies', | ||||
|             'art2', | ||||
|             'stem_insectbooks', | ||||
|             'un_algoenprog', | ||||
|         ], | ||||
|         hruids: ['pn_werking', 'pn_datatypes', 'pn_operatoren', 'pn_structuren', 'pn_functies', 'art2', 'stem_insectbooks', 'un_algoenprog'], | ||||
|     }, | ||||
|     { | ||||
|         title: 'stem', | ||||
|  | @ -110,15 +90,7 @@ export const themes: Theme[] = [ | |||
|     }, | ||||
|     { | ||||
|         title: 'care', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'aiz1_zorg', | ||||
|             'aiz2_grafen', | ||||
|             'aiz3_unplugged', | ||||
|             'aiz4_eindtermen', | ||||
|             'aiz5_triage', | ||||
|         ], | ||||
|         hruids: ['pn_werking', 'un_artificiele_intelligentie', 'aiz1_zorg', 'aiz2_grafen', 'aiz3_unplugged', 'aiz4_eindtermen', 'aiz5_triage'], | ||||
|     }, | ||||
|     { | ||||
|         title: 'chatbot', | ||||
|  |  | |||
|  | @ -1,23 +1,12 @@ | |||
| import { | ||||
|     Entity, | ||||
|     Enum, | ||||
|     ManyToOne, | ||||
|     OneToMany, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Class } from '../classes/class.entity.js'; | ||||
| import { Group } from './group.entity.js'; | ||||
| import { Language } from '../content/language.js'; | ||||
| import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => AssignmentRepository }) | ||||
| export class Assignment { | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Class; | ||||
|         }, | ||||
|         primary: true, | ||||
|     }) | ||||
|     @ManyToOne({ entity: () => Class, primary: true }) | ||||
|     within!: Class; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'number' }) | ||||
|  | @ -32,18 +21,9 @@ export class Assignment { | |||
|     @Property({ type: 'string' }) | ||||
|     learningPathHruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => { | ||||
|             return Language; | ||||
|         }, | ||||
|     }) | ||||
|     @Enum({ items: () => Language }) | ||||
|     learningPathLanguage!: Language; | ||||
| 
 | ||||
|     @OneToMany({ | ||||
|         entity: () => { | ||||
|             return Group; | ||||
|         }, | ||||
|         mappedBy: 'assignment', | ||||
|     }) | ||||
|     @OneToMany({ entity: () => Group, mappedBy: 'assignment' }) | ||||
|     groups!: Group[]; | ||||
| } | ||||
|  |  | |||
|  | @ -1,13 +1,12 @@ | |||
| import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; | ||||
| import { Assignment } from './assignment.entity.js'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| import { GroupRepository } from '../../data/assignments/group-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => GroupRepository }) | ||||
| export class Group { | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Assignment; | ||||
|         }, | ||||
|         entity: () => Assignment, | ||||
|         primary: true, | ||||
|     }) | ||||
|     assignment!: Assignment; | ||||
|  | @ -16,9 +15,7 @@ export class Group { | |||
|     groupNumber!: number; | ||||
| 
 | ||||
|     @ManyToMany({ | ||||
|         entity: () => { | ||||
|             return Student; | ||||
|         }, | ||||
|         entity: () => Student, | ||||
|     }) | ||||
|     members!: Student[]; | ||||
| } | ||||
|  |  | |||
|  | @ -2,30 +2,27 @@ import { Student } from '../users/student.entity.js'; | |||
| import { Group } from './group.entity.js'; | ||||
| import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Language } from '../content/language.js'; | ||||
| import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => SubmissionRepository }) | ||||
| export class Submission { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => { | ||||
|             return Language; | ||||
|         }, | ||||
|         items: () => Language, | ||||
|         primary: true, | ||||
|     }) | ||||
|     learningObjectLanguage!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectVersion: string = '1'; | ||||
|     @PrimaryKey({ type: 'numeric' }) | ||||
|     learningObjectVersion: number = 1; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     submissionNumber!: number; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Student; | ||||
|         }, | ||||
|         entity: () => Student, | ||||
|     }) | ||||
|     submitter!: Student; | ||||
| 
 | ||||
|  | @ -33,9 +30,7 @@ export class Submission { | |||
|     submissionTime!: Date; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Group; | ||||
|         }, | ||||
|         entity: () => Group, | ||||
|         nullable: true, | ||||
|     }) | ||||
|     onBehalfOf?: Group; | ||||
|  |  | |||
|  | @ -1,28 +1,23 @@ | |||
| import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| import { Class } from './class.entity.js'; | ||||
| import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => ClassJoinRequestRepository }) | ||||
| export class ClassJoinRequest { | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Student; | ||||
|         }, | ||||
|         entity: () => Student, | ||||
|         primary: true, | ||||
|     }) | ||||
|     requester!: Student; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Class; | ||||
|         }, | ||||
|         entity: () => Class, | ||||
|         primary: true, | ||||
|     }) | ||||
|     class!: Class; | ||||
| 
 | ||||
|     @Enum(() => { | ||||
|         return ClassJoinRequestStatus; | ||||
|     }) | ||||
|     @Enum(() => ClassJoinRequestStatus) | ||||
|     status!: ClassJoinRequestStatus; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,15 +1,10 @@ | |||
| import { | ||||
|     Collection, | ||||
|     Entity, | ||||
|     ManyToMany, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { v4 } from 'uuid'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| import { ClassRepository } from '../../data/classes/class-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => ClassRepository }) | ||||
| export class Class { | ||||
|     @PrimaryKey() | ||||
|     classId = v4(); | ||||
|  | @ -17,13 +12,9 @@ export class Class { | |||
|     @Property({ type: 'string' }) | ||||
|     displayName!: string; | ||||
| 
 | ||||
|     @ManyToMany(() => { | ||||
|         return Teacher; | ||||
|     }) | ||||
|     @ManyToMany(() => Teacher) | ||||
|     teachers!: Collection<Teacher>; | ||||
| 
 | ||||
|     @ManyToMany(() => { | ||||
|         return Student; | ||||
|     }) | ||||
|     @ManyToMany(() => Student) | ||||
|     students!: Collection<Student>; | ||||
| } | ||||
|  |  | |||
|  | @ -1,32 +1,30 @@ | |||
| import { Entity, ManyToOne } from '@mikro-orm/core'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { Class } from './class.entity.js'; | ||||
| import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; | ||||
| 
 | ||||
| /** | ||||
|  * Invitation of a teacher into a class (in order to teach it). | ||||
|  */ | ||||
| @Entity() | ||||
| @Entity({ repository: () => TeacherInvitationRepository }) | ||||
| @Entity({ | ||||
|     repository: () => TeacherInvitationRepository, | ||||
| }) | ||||
| export class TeacherInvitation { | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Teacher; | ||||
|         }, | ||||
|         entity: () => Teacher, | ||||
|         primary: true, | ||||
|     }) | ||||
|     sender!: Teacher; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Teacher; | ||||
|         }, | ||||
|         entity: () => Teacher, | ||||
|         primary: true, | ||||
|     }) | ||||
|     receiver!: Teacher; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Class; | ||||
|         }, | ||||
|         entity: () => Class, | ||||
|         primary: true, | ||||
|     }) | ||||
|     class!: Class; | ||||
|  |  | |||
|  | @ -1,18 +1,17 @@ | |||
| import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { LearningObject } from './learning-object.entity.js'; | ||||
| import { AttachmentRepository } from '../../data/content/attachment-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => AttachmentRepository }) | ||||
| export class Attachment { | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return LearningObject; | ||||
|         }, | ||||
|         entity: () => LearningObject, | ||||
|         primary: true, | ||||
|     }) | ||||
|     learningObject!: LearningObject; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     sequenceNumber!: number; | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     name!: string; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     mimeType!: string; | ||||
|  |  | |||
|  | @ -1,6 +1,186 @@ | |||
| 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', | ||||
|     French = 'fr', | ||||
|     Dzongkha = 'dz', | ||||
|     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( | ||||
|         public hruid: string, | ||||
|         public language: Language, | ||||
|         public version: string | ||||
|         public version: number | ||||
|     ) {} | ||||
| } | ||||
|  |  | |||
|  | @ -1,105 +1,10 @@ | |||
| import { | ||||
|     Embeddable, | ||||
|     Embedded, | ||||
|     Entity, | ||||
|     Enum, | ||||
|     ManyToMany, | ||||
|     OneToMany, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Language } from './language.js'; | ||||
| import { Attachment } from './attachment.entity.js'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class LearningObject { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     hruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => { | ||||
|             return Language; | ||||
|         }, | ||||
|         primary: true, | ||||
|     }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     version: string = '1'; | ||||
| 
 | ||||
|     @ManyToMany({ | ||||
|         entity: () => { | ||||
|             return Teacher; | ||||
|         }, | ||||
|     }) | ||||
|     admins!: Teacher[]; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     description!: string; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     contentType!: string; | ||||
| 
 | ||||
|     @Property({ type: 'array' }) | ||||
|     keywords: string[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'array', nullable: true }) | ||||
|     targetAges?: number[]; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     teacherExclusive: boolean = false; | ||||
| 
 | ||||
|     @Property({ type: 'array' }) | ||||
|     skosConcepts!: string[]; | ||||
| 
 | ||||
|     @Embedded({ | ||||
|         entity: () => { | ||||
|             return EducationalGoal; | ||||
|         }, | ||||
|         array: true, | ||||
|     }) | ||||
|     educationalGoals: EducationalGoal[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     copyright: string = ''; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     license: string = ''; | ||||
| 
 | ||||
|     @Property({ type: 'smallint', nullable: true }) | ||||
|     difficulty?: number; | ||||
| 
 | ||||
|     @Property({ type: 'integer' }) | ||||
|     estimatedTime!: number; | ||||
| 
 | ||||
|     @Embedded({ | ||||
|         entity: () => { | ||||
|             return ReturnValue; | ||||
|         }, | ||||
|     }) | ||||
|     returnValue!: ReturnValue; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     available: boolean = true; | ||||
| 
 | ||||
|     @Property({ type: 'string', nullable: true }) | ||||
|     contentLocation?: string; | ||||
| 
 | ||||
|     @OneToMany({ | ||||
|         entity: () => { | ||||
|             return Attachment; | ||||
|         }, | ||||
|         mappedBy: 'learningObject', | ||||
|     }) | ||||
|     attachments: Attachment[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     content!: Buffer; | ||||
| } | ||||
| import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | ||||
| import { v4 } from 'uuid'; | ||||
| import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class EducationalGoal { | ||||
|  | @ -119,11 +24,84 @@ export class ReturnValue { | |||
|     callbackSchema!: string; | ||||
| } | ||||
| 
 | ||||
| export enum ContentType { | ||||
|     Markdown = 'text/markdown', | ||||
|     Image = 'image/image', | ||||
|     Mpeg = 'audio/mpeg', | ||||
|     Pdf = 'application/pdf', | ||||
|     Extern = 'extern', | ||||
|     Blockly = 'Blockly', | ||||
| @Entity({ repository: () => LearningObjectRepository }) | ||||
| export class LearningObject { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     hruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => Language, | ||||
|         primary: true, | ||||
|     }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'number' }) | ||||
|     version: number = 1; | ||||
| 
 | ||||
|     @Property({ type: 'uuid', unique: true }) | ||||
|     uuid = v4(); | ||||
| 
 | ||||
|     @ManyToMany({ | ||||
|         entity: () => Teacher, | ||||
|     }) | ||||
|     admins!: Teacher[]; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     description!: string; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     contentType!: DwengoContentType; | ||||
| 
 | ||||
|     @Property({ type: 'array' }) | ||||
|     keywords: string[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'array', nullable: true }) | ||||
|     targetAges?: number[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     teacherExclusive: boolean = false; | ||||
| 
 | ||||
|     @Property({ type: 'array' }) | ||||
|     skosConcepts: string[] = []; | ||||
| 
 | ||||
|     @Embedded({ | ||||
|         entity: () => EducationalGoal, | ||||
|         array: true, | ||||
|     }) | ||||
|     educationalGoals: EducationalGoal[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     copyright: string = ''; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     license: string = ''; | ||||
| 
 | ||||
|     @Property({ type: 'smallint', nullable: true }) | ||||
|     difficulty?: number; | ||||
| 
 | ||||
|     @Property({ type: 'integer', nullable: true }) | ||||
|     estimatedTime?: number; | ||||
| 
 | ||||
|     @Embedded({ | ||||
|         entity: () => ReturnValue, | ||||
|     }) | ||||
|     returnValue!: ReturnValue; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     available: boolean = true; | ||||
| 
 | ||||
|     @Property({ type: 'string', nullable: true }) | ||||
|     contentLocation?: string; | ||||
| 
 | ||||
|     @OneToMany({ | ||||
|         entity: () => Attachment, | ||||
|         mappedBy: 'learningObject', | ||||
|     }) | ||||
|     attachments: Attachment[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     content!: Buffer; | ||||
| } | ||||
|  |  | |||
							
								
								
									
										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,34 +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 { 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 { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     hruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => { | ||||
|             return Language; | ||||
|         }, | ||||
|         primary: true, | ||||
|     }) | ||||
|     @Enum({ items: () => Language, primary: true }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @ManyToMany({ | ||||
|         entity: () => { | ||||
|             return Teacher; | ||||
|         }, | ||||
|     }) | ||||
|     @ManyToMany({ entity: () => Teacher }) | ||||
|     admins!: Teacher[]; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|  | @ -37,57 +21,9 @@ export class LearningPath { | |||
|     @Property({ type: 'text' }) | ||||
|     description!: string; | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     image!: string; | ||||
|     @Property({ type: 'blob', nullable: true }) | ||||
|     image: Buffer | null = null; | ||||
| 
 | ||||
|     @Embedded({ | ||||
|         entity: () => { | ||||
|             return LearningPathNode; | ||||
|         }, | ||||
|         array: true, | ||||
|     }) | ||||
|     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) | ||||
|     nodes: LearningPathNode[] = []; | ||||
| } | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class LearningPathNode { | ||||
|     @Property({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => { | ||||
|             return Language; | ||||
|         }, | ||||
|     }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     version!: string; | ||||
| 
 | ||||
|     @Property({ type: 'longtext' }) | ||||
|     instruction!: string; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     startNode!: boolean; | ||||
| 
 | ||||
|     @Embedded({ | ||||
|         entity: () => { | ||||
|             return LearningPathTransition; | ||||
|         }, | ||||
|         array: true, | ||||
|     }) | ||||
|     transitions!: LearningPathTransition[]; | ||||
| } | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class LearningPathTransition { | ||||
|     @Property({ type: 'string' }) | ||||
|     condition!: string; | ||||
| 
 | ||||
|     @OneToOne({ | ||||
|         entity: () => { | ||||
|             return LearningPathNode; | ||||
|         }, | ||||
|     }) | ||||
|     next!: LearningPathNode; | ||||
| } | ||||
|  |  | |||
|  | @ -1,27 +1,24 @@ | |||
| import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Question } from './question.entity.js'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { AnswerRepository } from '../../data/questions/answer-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => AnswerRepository }) | ||||
| export class Answer { | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Teacher; | ||||
|         }, | ||||
|         entity: () => Teacher, | ||||
|         primary: true, | ||||
|     }) | ||||
|     author!: Teacher; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Question; | ||||
|         }, | ||||
|         entity: () => Question, | ||||
|         primary: true, | ||||
|     }) | ||||
|     toQuestion!: Question; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     sequenceNumber!: number; | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     sequenceNumber?: number; | ||||
| 
 | ||||
|     @Property({ type: 'datetime' }) | ||||
|     timestamp: Date = new Date(); | ||||
|  |  | |||
|  | @ -1,30 +1,27 @@ | |||
| import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Language } from '../content/language.js'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| import { QuestionRepository } from '../../data/questions/question-repository.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| @Entity({ repository: () => QuestionRepository }) | ||||
| export class Question { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|     @Enum({ | ||||
|         items: () => { | ||||
|             return Language; | ||||
|         }, | ||||
|         items: () => Language, | ||||
|         primary: true, | ||||
|     }) | ||||
|     learningObjectLanguage!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectVersion: string = '1'; | ||||
|     @PrimaryKey({ type: 'number' }) | ||||
|     learningObjectVersion: number = 1; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     sequenceNumber!: number; | ||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||
|     sequenceNumber?: number; | ||||
| 
 | ||||
|     @ManyToOne({ | ||||
|         entity: () => { | ||||
|             return Student; | ||||
|         }, | ||||
|         entity: () => Student, | ||||
|     }) | ||||
|     author!: Student; | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,19 +5,13 @@ import { Group } from '../assignments/group.entity.js'; | |||
| import { StudentRepository } from '../../data/users/student-repository.js'; | ||||
| 
 | ||||
| @Entity({ | ||||
|     repository: () => { | ||||
|         return StudentRepository; | ||||
|     }, | ||||
|     repository: () => StudentRepository, | ||||
| }) | ||||
| export class Student extends User { | ||||
|     @ManyToMany(() => { | ||||
|         return Class; | ||||
|     }) | ||||
|     @ManyToMany(() => Class) | ||||
|     classes!: Collection<Class>; | ||||
| 
 | ||||
|     @ManyToMany(() => { | ||||
|         return Group; | ||||
|     }) | ||||
|     @ManyToMany(() => Group) | ||||
|     groups!: Collection<Group>; | ||||
| 
 | ||||
|     constructor( | ||||
|  |  | |||
|  | @ -1,11 +1,18 @@ | |||
| import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; | ||||
| import { User } from './user.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 { | ||||
|     @ManyToMany(() => { | ||||
|         return Class; | ||||
|     }) | ||||
|     @ManyToMany(() => 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 { | ||||
|     status = 401; | ||||
|     constructor(message: string = 'Unauthorized') { | ||||
|  | @ -5,9 +19,24 @@ export class UnauthorizedException extends Error { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Exception for HTTP 403 Forbidden | ||||
|  */ | ||||
| export class ForbiddenException extends Error { | ||||
|     status = 403; | ||||
| 
 | ||||
|     constructor(message: string = 'Forbidden') { | ||||
|         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 { | ||||
|     default: boolean; | ||||
|     _id: string; | ||||
|  | @ -9,15 +11,22 @@ export interface Transition { | |||
|     }; | ||||
| } | ||||
| 
 | ||||
| export interface LearningObjectIdentifier { | ||||
|     hruid: string; | ||||
|     language: Language; | ||||
|     version?: number; | ||||
| } | ||||
| 
 | ||||
| export interface LearningObjectNode { | ||||
|     _id: string; | ||||
|     learningobject_hruid: string; | ||||
|     version: number; | ||||
|     language: string; | ||||
|     language: Language; | ||||
|     start_node?: boolean; | ||||
|     transitions: Transition[]; | ||||
|     created_at: string; | ||||
|     updatedAt: string; | ||||
|     done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
 | ||||
| } | ||||
| 
 | ||||
| export interface LearningPath { | ||||
|  | @ -37,6 +46,11 @@ export interface LearningPath { | |||
|     __order: number; | ||||
| } | ||||
| 
 | ||||
| export interface LearningPathIdentifier { | ||||
|     hruid: string; | ||||
|     language: Language; | ||||
| } | ||||
| 
 | ||||
| export interface EducationalGoal { | ||||
|     source: string; | ||||
|     id: string; | ||||
|  | @ -52,7 +66,7 @@ export interface LearningObjectMetadata { | |||
|     uuid: string; | ||||
|     hruid: string; | ||||
|     version: number; | ||||
|     language: string; | ||||
|     language: Language; | ||||
|     title: string; | ||||
|     description: string; | ||||
|     difficulty: number; | ||||
|  | @ -75,9 +89,9 @@ export interface FilteredLearningObject { | |||
|     version: number; | ||||
|     title: string; | ||||
|     htmlUrl: string; | ||||
|     language: string; | ||||
|     language: Language; | ||||
|     difficulty: number; | ||||
|     estimatedTime: number; | ||||
|     estimatedTime?: number; | ||||
|     available: boolean; | ||||
|     teacherExclusive: boolean; | ||||
|     educationalGoals: EducationalGoal[]; | ||||
|  | @ -1,9 +1,4 @@ | |||
| import { | ||||
|     createLogger, | ||||
|     format, | ||||
|     Logger as WinstonLogger, | ||||
|     transports, | ||||
| } from 'winston'; | ||||
| import { createLogger, format, Logger as WinstonLogger, transports } from 'winston'; | ||||
| import LokiTransport from 'winston-loki'; | ||||
| import { LokiLabels } from 'loki-logger-ts'; | ||||
| import { LOG_LEVEL, LOKI_HOST } from '../config.js'; | ||||
|  | @ -48,9 +43,7 @@ function initializeLogger(): Logger { | |||
|         transports: [lokiTransport, consoleTransport], | ||||
|     }); | ||||
| 
 | ||||
|     logger.debug( | ||||
|         `Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}` | ||||
|     ); | ||||
|     logger.debug(`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`); | ||||
|     return logger; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -12,42 +12,28 @@ export class MikroOrmLogger extends DefaultLogger { | |||
| 
 | ||||
|         switch (namespace) { | ||||
|             case 'query': | ||||
|                 this.logger.debug( | ||||
|                     this.createMessage(namespace, message, context) | ||||
|                 ); | ||||
|                 this.logger.debug(this.createMessage(namespace, message, context)); | ||||
|                 break; | ||||
|             case 'query-params': | ||||
|                 // TODO Which log level should this be?
 | ||||
|                 this.logger.info( | ||||
|                     this.createMessage(namespace, message, context) | ||||
|                 ); | ||||
|                 this.logger.info(this.createMessage(namespace, message, context)); | ||||
|                 break; | ||||
|             case 'schema': | ||||
|                 this.logger.info( | ||||
|                     this.createMessage(namespace, message, context) | ||||
|                 ); | ||||
|                 this.logger.info(this.createMessage(namespace, message, context)); | ||||
|                 break; | ||||
|             case 'discovery': | ||||
|                 this.logger.debug( | ||||
|                     this.createMessage(namespace, message, context) | ||||
|                 ); | ||||
|                 this.logger.debug(this.createMessage(namespace, message, context)); | ||||
|                 break; | ||||
|             case 'info': | ||||
|                 this.logger.info( | ||||
|                     this.createMessage(namespace, message, context) | ||||
|                 ); | ||||
|                 this.logger.info(this.createMessage(namespace, message, context)); | ||||
|                 break; | ||||
|             case 'deprecated': | ||||
|                 this.logger.warn( | ||||
|                     this.createMessage(namespace, message, context) | ||||
|                 ); | ||||
|                 this.logger.warn(this.createMessage(namespace, message, context)); | ||||
|                 break; | ||||
|             default: | ||||
|                 switch (context?.level) { | ||||
|                     case 'info': | ||||
|                         this.logger.info( | ||||
|                             this.createMessage(namespace, message, context) | ||||
|                         ); | ||||
|                         this.logger.info(this.createMessage(namespace, message, context)); | ||||
|                         break; | ||||
|                     case 'warning': | ||||
|                         this.logger.warn(message); | ||||
|  | @ -62,11 +48,7 @@ export class MikroOrmLogger extends DefaultLogger { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private createMessage( | ||||
|         namespace: LoggerNamespace, | ||||
|         messageArg: string, | ||||
|         context?: LogContext | ||||
|     ) { | ||||
|     private createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext) { | ||||
|         const labels: LokiLabels = { | ||||
|             service: 'ORM', | ||||
|         }; | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ import * as express from 'express'; | |||
| import * as jwt from 'jsonwebtoken'; | ||||
| import { AuthenticatedRequest } from './authenticated-request.js'; | ||||
| import { AuthenticationInfo } from './authentication-info.js'; | ||||
| import { ForbiddenException, UnauthorizedException } from '../../exceptions'; | ||||
| import { ForbiddenException, UnauthorizedException } from '../../exceptions.js'; | ||||
| 
 | ||||
| const JWKS_CACHE = true; | ||||
| const JWKS_RATE_LIMIT = true; | ||||
|  | @ -52,16 +52,12 @@ const verifyJwtToken = expressjwt({ | |||
| 
 | ||||
|         const issuer = (token.payload as JwtPayload).iss; | ||||
| 
 | ||||
|         const idpConfig = Object.values(idpConfigs).find((config) => { | ||||
|             return config.issuer === issuer; | ||||
|         }); | ||||
|         const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); | ||||
|         if (!idpConfig) { | ||||
|             throw new Error('Issuer not accepted.'); | ||||
|         } | ||||
| 
 | ||||
|         const signingKey = await idpConfig.jwksClient.getSigningKey( | ||||
|             token.header.kid | ||||
|         ); | ||||
|         const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); | ||||
|         if (!signingKey) { | ||||
|             throw new Error('Signing key not found.'); | ||||
|         } | ||||
|  | @ -76,9 +72,7 @@ const verifyJwtToken = expressjwt({ | |||
| /** | ||||
|  * Get an object with information about the authenticated user from a given authenticated request. | ||||
|  */ | ||||
| function getAuthenticationInfo( | ||||
|     req: AuthenticatedRequest | ||||
| ): AuthenticationInfo | undefined { | ||||
| function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined { | ||||
|     if (!req.jwtPayload) { | ||||
|         return; | ||||
|     } | ||||
|  | @ -106,11 +100,7 @@ function getAuthenticationInfo( | |||
|  * Add the AuthenticationInfo object with the information about the current authentication to the request in order | ||||
|  * to avoid that the routers have to deal with the JWT token. | ||||
|  */ | ||||
| const addAuthenticationInfo = ( | ||||
|     req: AuthenticatedRequest, | ||||
|     res: express.Response, | ||||
|     next: express.NextFunction | ||||
| ) => { | ||||
| const addAuthenticationInfo = (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => { | ||||
|     req.auth = getAuthenticationInfo(req); | ||||
|     next(); | ||||
| }; | ||||
|  | @ -123,14 +113,9 @@ export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; | |||
|  * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates | ||||
|  *                        to true. | ||||
|  */ | ||||
| export const authorize = ( | ||||
|     accessCondition: (auth: AuthenticationInfo) => boolean | ||||
| ) => { | ||||
|     return ( | ||||
|         req: AuthenticatedRequest, | ||||
|         res: express.Response, | ||||
|         next: express.NextFunction | ||||
|     ): void => { | ||||
| export const authorize = | ||||
|     (accessCondition: (auth: AuthenticationInfo) => boolean) => | ||||
|     (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction): void => { | ||||
|         if (!req.auth) { | ||||
|             throw new UnauthorizedException(); | ||||
|         } else if (!accessCondition(req.auth)) { | ||||
|  | @ -139,25 +124,18 @@ export const authorize = ( | |||
|             next(); | ||||
|         } | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects all unauthenticated users, but accepts all authenticated users. | ||||
|  */ | ||||
| export const authenticatedOnly = authorize((_) => { | ||||
|     return true; | ||||
| }); | ||||
| export const authenticatedOnly = authorize((_) => true); | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't students. | ||||
|  */ | ||||
| export const studentsOnly = authorize((auth) => { | ||||
|     return auth.accountType === 'student'; | ||||
| }); | ||||
| export const studentsOnly = authorize((auth) => auth.accountType === 'student'); | ||||
| 
 | ||||
| /** | ||||
|  * Middleware which rejects requests from unauthenticated users or users that aren't teachers. | ||||
|  */ | ||||
| export const teachersOnly = authorize((auth) => { | ||||
|     return auth.accountType === 'teacher'; | ||||
| }); | ||||
| export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ import { LearningPath } from './entities/content/learning-path.entity.js'; | |||
| 
 | ||||
| import { Answer } from './entities/questions/answer.entity.js'; | ||||
| import { Question } from './entities/questions/question.entity.js'; | ||||
| import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js'; | ||||
| 
 | ||||
| const entities = [ | ||||
|     User, | ||||
|  | @ -47,14 +48,13 @@ function config(testingMode: boolean = false): Options { | |||
|         return { | ||||
|             driver: SqliteDriver, | ||||
|             dbName: getEnvVar(EnvVars.DbName), | ||||
|             subscribers: [new SqliteAutoincrementSubscriber()], | ||||
|             entities: entities, | ||||
|             // EntitiesTs: entitiesTs,
 | ||||
| 
 | ||||
|             // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
 | ||||
|             // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
 | ||||
|             dynamicImportProvider: (id) => { | ||||
|                 return import(id); | ||||
|             }, | ||||
|             dynamicImportProvider: (id) => import(id), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|  | @ -70,9 +70,7 @@ function config(testingMode: boolean = false): Options { | |||
| 
 | ||||
|         // Logging
 | ||||
|         debug: LOG_LEVEL === 'debug', | ||||
|         loggerFactory: (options: LoggerOptions) => { | ||||
|             return new MikroOrmLogger(options); | ||||
|         }, | ||||
|         loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options), | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -28,9 +28,7 @@ export async function initORM(testingMode: boolean = false) { | |||
| } | ||||
| export function forkEntityManager(): EntityManager { | ||||
|     if (!orm) { | ||||
|         throw Error( | ||||
|             'Accessing the Entity Manager before the ORM is fully initialized.' | ||||
|         ); | ||||
|         throw Error('Accessing the Entity Manager before the ORM is fully initialized.'); | ||||
|     } | ||||
|     return orm.em.fork(); | ||||
| } | ||||
|  |  | |||
|  | @ -1,8 +1,5 @@ | |||
| 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(); | ||||
| 
 | ||||
|  | @ -24,4 +21,16 @@ router.get('/', getAllLearningObjects); | |||
| // Example: http://localhost:3000/learningObject/un_ai7
 | ||||
| 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; | ||||
|  | @ -1,5 +1,5 @@ | |||
| import express from 'express'; | ||||
| import { getLearningPaths } from '../controllers/learningPaths.js'; | ||||
| import { getLearningPaths } from '../controllers/learning-paths.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
|  | @ -15,8 +15,7 @@ router.get('/:id', (req, res) => { | |||
|         student: '0', | ||||
|         group: '0', | ||||
|         time: new Date(2025, 1, 1), | ||||
|         content: | ||||
|             'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', | ||||
|         content: 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', | ||||
|         learningObject: '0', | ||||
|         links: { | ||||
|             self: `${req.baseUrl}/${req.params.id}`, | ||||
|  |  | |||
							
								
								
									
										35
									
								
								backend/src/routes/router.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/src/routes/router.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import { Response, Router } from 'express'; | ||||
| import studentRouter from './student'; | ||||
| import groupRouter from './group'; | ||||
| import assignmentRouter from './assignment'; | ||||
| import submissionRouter from './submission'; | ||||
| import classRouter from './class'; | ||||
| import questionRouter from './question'; | ||||
| import authRouter from './auth'; | ||||
| import themeRoutes from './themes'; | ||||
| import learningPathRoutes from './learning-paths'; | ||||
| import learningObjectRoutes from './learning-objects'; | ||||
| import { getLogger, Logger } from '../logging/initalize'; | ||||
| 
 | ||||
| const router = Router(); | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
| router.get('/', (_, res: Response) => { | ||||
|     logger.debug('GET /'); | ||||
|     res.json({ | ||||
|         message: 'Hello Dwengo!🚀', | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); | ||||
| router.use('/group', groupRouter /* #swagger.tags = ['Group'] */); | ||||
| router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); | ||||
| router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); | ||||
| router.use('/class', classRouter /* #swagger.tags = ['Class'] */); | ||||
| router.use('/question', questionRouter /* #swagger.tags = ['Question'] */); | ||||
| router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); | ||||
| router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); | ||||
| router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); | ||||
| router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); | ||||
| 
 | ||||
| export default 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,137 +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) => { | ||||
|                 return node.learningobject_hruid; | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         return await Promise.all( | ||||
|             nodes.map(async (node) => { | ||||
|                 return getLearningObjectById( | ||||
|                     node.learningobject_hruid, | ||||
|                     language | ||||
|                 ); | ||||
|             }) | ||||
|         ).then((objects) => { | ||||
|             return objects.filter((obj): obj is FilteredLearningObject => { | ||||
|                 return 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,64 +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,35 +9,34 @@ const logger: Logger = getLogger(); | |||
|  * | ||||
|  * @param url The API endpoint to fetch from. | ||||
|  * @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. | ||||
|  */ | ||||
| export async function fetchWithLogging<T>( | ||||
|     url: string, | ||||
|     description: string, | ||||
|     params?: Record<string, any> | ||||
|     options?: { | ||||
|         params?: Record<string, any>; | ||||
|         query?: Record<string, any>; | ||||
|         responseType?: 'json' | 'text'; | ||||
|     } | ||||
| ): Promise<T | null> { | ||||
|     try { | ||||
|         const config: AxiosRequestConfig = params ? { params } : {}; | ||||
| 
 | ||||
|         const config: AxiosRequestConfig = options || {}; | ||||
|         const response = await axios.get<T>(url, config); | ||||
|         return response.data; | ||||
|     } catch (error: any) { | ||||
|         if (error.response) { | ||||
|             if (error.response.status === 404) { | ||||
|                 logger.debug( | ||||
|                     `❌ ERROR: ${description} not found (404) at "${url}".` | ||||
|                 ); | ||||
|                 logger.debug(`❌ ERROR: ${description} not found (404) at "${url}".`); | ||||
|             } else { | ||||
|                 logger.debug( | ||||
|                     `❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")` | ||||
|                 ); | ||||
|             } | ||||
|         } else { | ||||
|             logger.debug( | ||||
|                 `❌ ERROR: Network or unexpected error when fetching ${description}:`, | ||||
|                 error.message | ||||
|             ); | ||||
|             logger.debug(`❌ ERROR: Network or unexpected error when fetching ${description}:`, error.message); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  |  | |||
							
								
								
									
										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,33 +15,18 @@ export const EnvVars: { [key: string]: EnvVar } = { | |||
|     DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, | ||||
|     DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, | ||||
|     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 }, | ||||
|     IdpStudentClientId: { | ||||
|         key: STUDENT_IDP_PREFIX + 'CLIENT_ID', | ||||
|         required: true, | ||||
|     }, | ||||
|     IdpStudentJwksEndpoint: { | ||||
|         key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', | ||||
|         required: true, | ||||
|     }, | ||||
|     IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, | ||||
|     IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, | ||||
|     IdpTeacherUrl: { key: TEACHER_IDP_PREFIX + 'URL', required: true }, | ||||
|     IdpTeacherClientId: { | ||||
|         key: TEACHER_IDP_PREFIX + 'CLIENT_ID', | ||||
|         required: true, | ||||
|     }, | ||||
|     IdpTeacherJwksEndpoint: { | ||||
|         key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', | ||||
|         required: true, | ||||
|     }, | ||||
|     IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true }, | ||||
|     IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, | ||||
|     IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' }, | ||||
|     CorsAllowedOrigins: { | ||||
|         key: CORS_PREFIX + 'ALLOWED_ORIGINS', | ||||
|         defaultValue: '', | ||||
|     }, | ||||
|     CorsAllowedHeaders: { | ||||
|         key: CORS_PREFIX + 'ALLOWED_HEADERS', | ||||
|         defaultValue: 'Authorization,Content-Type', | ||||
|     }, | ||||
|     CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' }, | ||||
|     CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' }, | ||||
| } as const; | ||||
| 
 | ||||
| /** | ||||
|  | @ -67,9 +52,7 @@ export function getNumericEnvVar(envVar: EnvVar): number { | |||
|     const valueString = getEnvVar(envVar); | ||||
|     const value = parseInt(valueString); | ||||
|     if (isNaN(value)) { | ||||
|         throw new Error( | ||||
|             `Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.` | ||||
|         ); | ||||
|         throw new Error(`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`); | ||||
|     } else { | ||||
|         return value; | ||||
|     } | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show more
		Reference in a new issue