Merge branch 'dev' into test/testen-voor-datalaag-#87
This commit is contained in:
		
						commit
						79393d6552
					
				
					 161 changed files with 9836 additions and 3751 deletions
				
			
		
							
								
								
									
										20
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										20
									
								
								README.md
									
										
									
									
									
								
							|  | @ -10,7 +10,7 @@ Projectopgave</a></span> | ||||||
| </p> | </p> | ||||||
| 
 | 
 | ||||||
| <ul align="center" style="list-style-type: none"> | <ul align="center" style="list-style-type: none"> | ||||||
| <li>Projectleider: Fransisco Van Langenhove (<a href="https://github.com/Gabriellvl">@Gabriellvl</a>)</li> | <li>Projectleider: Fransisco Gabriel Van Langenhove (<a href="https://github.com/Gabriellvl">@Gabriellvl</a>)</li> | ||||||
| <li>Technische lead: Tibo De Peuter (<a href="https://github.com/tdpeuter">@tdpeuter</a>)</li> | <li>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>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> | <li>Customer relations officer: Adriaan Jacquet (<a href="https://github.com/WhisperinCheetah">@WhisperinCheetah</a>)</li> | ||||||
|  | @ -21,17 +21,28 @@ en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. | ||||||
| 
 | 
 | ||||||
| ## Installatie | ## Installatie | ||||||
| 
 | 
 | ||||||
|  | Om de applicatie in te stellen voor een productieomgeving, volg de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). | ||||||
|  | 
 | ||||||
|  | Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien. | ||||||
|  | 
 | ||||||
| ### Quick start | ### Quick start | ||||||
| 
 | 
 | ||||||
| 1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)). | 1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)). | ||||||
| 2. Clone deze repository. | 2. Clone deze repository. | ||||||
| 3. 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 | ```bash | ||||||
| docker compose version | docker compose version | ||||||
| git clone https://github.com/SELab-2/Dwengo-1.git | 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 | docker compose up | ||||||
|  | # Configureer de applicatie | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| ### Handmatige installatie | ### Handmatige installatie | ||||||
|  | @ -46,8 +57,9 @@ De tech-stack bestaat uit: | ||||||
| 
 | 
 | ||||||
| - **Frontend**: TypeScript + Vue.js + Vuetify | - **Frontend**: TypeScript + Vue.js + Vuetify | ||||||
| - **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL | - **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 | ## Testen | ||||||
| Voer volgende commando's uit om de <frontend/backend> te testen: | Voer volgende commando's uit om de <frontend/backend> te testen: | ||||||
|  |  | ||||||
							
								
								
									
										
											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 | 
|  | @ -4,3 +4,13 @@ DWENGO_DB_PORT=5431 | ||||||
| DWENGO_DB_USERNAME=postgres | DWENGO_DB_USERNAME=postgres | ||||||
| DWENGO_DB_PASSWORD=postgres | DWENGO_DB_PASSWORD=postgres | ||||||
| DWENGO_DB_UPDATE=true | DWENGO_DB_UPDATE=true | ||||||
|  | 
 | ||||||
|  | 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 | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  | # 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 | ||||||
|  |  | ||||||
|  | @ -1,11 +1,22 @@ | ||||||
| # | DWENGO_PORT=3000    # The port the backend will listen on | ||||||
| # Basic configuration | DWENGO_DB_HOST=domain-or-ip-of-database | ||||||
| # | DWENGO_DB_PORT=5432 | ||||||
| 
 | 
 | ||||||
| PORT=3000                              # The port the backend will listen on | # Change this to the actual credentials of the user Dwengo should use in the backend | ||||||
|  | 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. | ||||||
| # Advanced configuration | DWENGO_DB_UPDATE=false | ||||||
| # | 
 | ||||||
|  | # Data for the identity provider via which the students authenticate. | ||||||
|  | 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 | ||||||
|  | 
 | ||||||
|  | # Data for the identity provider via which the teachers authenticate. | ||||||
|  | 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 | ||||||
| 
 | 
 | ||||||
| # LOKI_HOST=http://localhost:3102      # The address of the Loki instance, used for logging | # LOKI_HOST=http://localhost:3102      # The address of the Loki instance, used for logging | ||||||
|  |  | ||||||
|  | @ -26,3 +26,10 @@ Voer volgend commando uit om de unit tests uit te voeren: | ||||||
| ``` | ``` | ||||||
| npm run test:unit | 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, |             globals: globals.node, | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|  |     { | ||||||
|  |         files: ['tests/**/*.ts'], | ||||||
|  |         languageOptions: { | ||||||
|  |             globals: globals.node, | ||||||
|  |         }, | ||||||
|  |         rules: { | ||||||
|  |             'no-console': 'off', | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
|  | @ -5,35 +5,45 @@ | ||||||
|     "private": true, |     "private": true, | ||||||
|     "type": "module", |     "type": "module", | ||||||
|     "scripts": { |     "scripts": { | ||||||
|         "build": "NODE_ENV=production tsc --project tsconfig.json", |         "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", | ||||||
|         "dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", |         "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", | ||||||
|         "start": "NODE_ENV=production node --env-file=.env dist/app.js", |         "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", | ||||||
|         "format": "prettier --write src/", |         "format": "prettier --write src/", | ||||||
|         "format-check": "prettier --check src/", |         "format-check": "prettier --check src/", | ||||||
|         "lint": "eslint . --fix", |         "lint": "eslint . --fix", | ||||||
|         "test": "vitest", |  | ||||||
|         "test:unit": "vitest" |         "test:unit": "vitest" | ||||||
|     }, |     }, | ||||||
|     "dependencies": { |     "dependencies": { | ||||||
|         "@mikro-orm/core": "^6.4.9", |         "@mikro-orm/core": "6.4.9", | ||||||
|         "@mikro-orm/knex": "^6.4.9", |         "@mikro-orm/knex": "6.4.9", | ||||||
|         "@mikro-orm/postgresql": "^6.4.6", |         "@mikro-orm/postgresql": "6.4.9", | ||||||
|         "@mikro-orm/reflection": "^6.4.6", |         "@mikro-orm/reflection": "6.4.9", | ||||||
|         "@mikro-orm/sqlite": "^6.4.9", |         "@mikro-orm/sqlite": "6.4.9", | ||||||
|         "@types/js-yaml": "^4.0.9", |  | ||||||
|         "axios": "^1.8.1", |         "axios": "^1.8.1", | ||||||
|  |         "axios": "^1.8.2", | ||||||
|  |         "cors": "^2.8.5", | ||||||
|  |         "cross": "^1.0.0", | ||||||
|  |         "cross-env": "^7.0.3", | ||||||
|         "dotenv": "^16.4.7", |         "dotenv": "^16.4.7", | ||||||
|         "express": "^5.0.1", |         "express": "^5.0.1", | ||||||
|  |         "express-jwt": "^8.5.1", | ||||||
|  |         "gift-pegjs": "^1.0.2", | ||||||
|  |         "isomorphic-dompurify": "^2.22.0", | ||||||
|         "js-yaml": "^4.1.0", |         "js-yaml": "^4.1.0", | ||||||
|  |         "jsonpath-plus": "^10.3.0", | ||||||
|  |         "jwks-rsa": "^3.1.0", | ||||||
|         "loki-logger-ts": "^1.0.2", |         "loki-logger-ts": "^1.0.2", | ||||||
|  |         "marked": "^15.0.7", | ||||||
|         "response-time": "^2.3.3", |         "response-time": "^2.3.3", | ||||||
|         "uuid": "^11.1.0", |         "uuid": "^11.1.0", | ||||||
|         "winston": "^3.17.0", |         "winston": "^3.17.0", | ||||||
|         "winston-loki": "^6.1.3" |         "winston-loki": "^6.1.3" | ||||||
|     }, |     }, | ||||||
|     "devDependencies": { |     "devDependencies": { | ||||||
|         "@mikro-orm/cli": "^6.4.6", |         "@mikro-orm/cli": "6.4.9", | ||||||
|  |         "@types/cors": "^2.8.17", | ||||||
|         "@types/express": "^5.0.0", |         "@types/express": "^5.0.0", | ||||||
|  |         "@types/js-yaml": "^4.0.9", | ||||||
|         "@types/node": "^22.13.4", |         "@types/node": "^22.13.4", | ||||||
|         "@types/response-time": "^2.3.8", |         "@types/response-time": "^2.3.8", | ||||||
|         "globals": "^15.15.0", |         "globals": "^15.15.0", | ||||||
|  |  | ||||||
|  | @ -2,8 +2,8 @@ import express, { Express, Response } from 'express'; | ||||||
| import { initORM } from './orm.js'; | import { initORM } from './orm.js'; | ||||||
| 
 | 
 | ||||||
| import themeRoutes from './routes/themes.js'; | import themeRoutes from './routes/themes.js'; | ||||||
| import learningPathRoutes from './routes/learningPaths.js'; | import learningPathRoutes from './routes/learning-paths.js'; | ||||||
| import learningObjectRoutes from './routes/learningObjects.js'; | import learningObjectRoutes from './routes/learning-objects.js'; | ||||||
| 
 | 
 | ||||||
| import studentRouter from './routes/student.js'; | import studentRouter from './routes/student.js'; | ||||||
| import groupRouter from './routes/group.js'; | import groupRouter from './routes/group.js'; | ||||||
|  | @ -11,7 +11,9 @@ import assignmentRouter from './routes/assignment.js'; | ||||||
| import submissionRouter from './routes/submission.js'; | import submissionRouter from './routes/submission.js'; | ||||||
| import classRouter from './routes/class.js'; | import classRouter from './routes/class.js'; | ||||||
| import questionRouter from './routes/question.js'; | import questionRouter from './routes/question.js'; | ||||||
| import loginRouter from './routes/login.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 { getLogger, Logger } from './logging/initalize.js'; | ||||||
| import { responseTimeLogger } from './logging/responseTimeLogger.js'; | import { responseTimeLogger } from './logging/responseTimeLogger.js'; | ||||||
| import responseTime from 'response-time'; | import responseTime from 'response-time'; | ||||||
|  | @ -22,8 +24,10 @@ const logger: Logger = getLogger(); | ||||||
| const app: Express = express(); | const app: Express = express(); | ||||||
| const port: string | number = getNumericEnvVar(EnvVars.Port); | const port: string | number = getNumericEnvVar(EnvVars.Port); | ||||||
| 
 | 
 | ||||||
|  | app.use(cors); | ||||||
| app.use(express.json()); | app.use(express.json()); | ||||||
| app.use(responseTime(responseTimeLogger)); | app.use(responseTime(responseTimeLogger)); | ||||||
|  | app.use(authenticateUser); | ||||||
| 
 | 
 | ||||||
| // TODO Replace with Express routes
 | // TODO Replace with Express routes
 | ||||||
| app.get('/', (_, res: Response) => { | app.get('/', (_, res: Response) => { | ||||||
|  | @ -39,8 +43,7 @@ app.use('/assignment', assignmentRouter); | ||||||
| app.use('/submission', submissionRouter); | app.use('/submission', submissionRouter); | ||||||
| app.use('/class', classRouter); | app.use('/class', classRouter); | ||||||
| app.use('/question', questionRouter); | app.use('/question', questionRouter); | ||||||
| app.use('/login', loginRouter); | app.use('/auth', authRouter); | ||||||
| 
 |  | ||||||
| app.use('/theme', themeRoutes); | app.use('/theme', themeRoutes); | ||||||
| app.use('/learningPath', learningPathRoutes); | app.use('/learningPath', learningPathRoutes); | ||||||
| app.use('/learningObject', learningObjectRoutes); | app.use('/learningObject', learningObjectRoutes); | ||||||
|  |  | ||||||
|  | @ -1,12 +1,9 @@ | ||||||
| export const FALLBACK_LANG: string = 'nl'; | import { EnvVars, getEnvVar } from './util/envvars.js'; | ||||||
| 
 | 
 | ||||||
| // API
 | // API
 | ||||||
| 
 | export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); | ||||||
| export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api'; | export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); | ||||||
| 
 | 
 | ||||||
| // Logging
 | // Logging
 | ||||||
| 
 | export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; | ||||||
| export const LOG_LEVEL: string = | export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102'; | ||||||
|     'development' === process.env.NODE_ENV ? 'debug' : 'info'; |  | ||||||
| export const LOKI_HOST: string = |  | ||||||
|     process.env.LOKI_HOST || 'http://localhost:3102'; |  | ||||||
|  |  | ||||||
							
								
								
									
										33
									
								
								backend/src/controllers/auth.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								backend/src/controllers/auth.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | ||||||
|  | import { EnvVars, getEnvVar } from '../util/envvars.js'; | ||||||
|  | 
 | ||||||
|  | type FrontendIdpConfig = { | ||||||
|  |     authority: string; | ||||||
|  |     clientId: string; | ||||||
|  |     scope: string; | ||||||
|  |     responseType: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | type FrontendAuthConfig = { | ||||||
|  |     student: FrontendIdpConfig; | ||||||
|  |     teacher: FrontendIdpConfig; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const SCOPE = 'openid profile email'; | ||||||
|  | const RESPONSE_TYPE = 'code'; | ||||||
|  | 
 | ||||||
|  | export function getFrontendAuthConfig(): FrontendAuthConfig { | ||||||
|  |     return { | ||||||
|  |         student: { | ||||||
|  |             authority: getEnvVar(EnvVars.IdpStudentUrl), | ||||||
|  |             clientId: getEnvVar(EnvVars.IdpStudentClientId), | ||||||
|  |             scope: SCOPE, | ||||||
|  |             responseType: RESPONSE_TYPE, | ||||||
|  |         }, | ||||||
|  |         teacher: { | ||||||
|  |             authority: getEnvVar(EnvVars.IdpTeacherUrl), | ||||||
|  |             clientId: getEnvVar(EnvVars.IdpTeacherClientId), | ||||||
|  |             scope: SCOPE, | ||||||
|  |             responseType: RESPONSE_TYPE, | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | } | ||||||
							
								
								
									
										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 { Request, Response } from 'express'; | ||||||
| import { themes } from '../data/themes.js'; | import { themes } from '../data/themes.js'; | ||||||
| import { FALLBACK_LANG } from '../config.js'; | import { FALLBACK_LANG } from '../config.js'; | ||||||
| import { |  | ||||||
|     fetchLearningPaths, |  | ||||||
|     searchLearningPaths, |  | ||||||
| } from '../services/learningPaths.js'; |  | ||||||
| import { getLogger } from '../logging/initalize.js'; | import { getLogger } from '../logging/initalize.js'; | ||||||
|  | import learningPathService from '../services/learning-paths/learning-path-service.js'; | ||||||
|  | import { Language } from '../entities/content/language.js'; | ||||||
| /** | /** | ||||||
|  * Fetch learning paths based on query parameters. |  * Fetch learning paths based on query parameters. | ||||||
|  */ |  */ | ||||||
| export async function getLearningPaths( | export async function getLearningPaths(req: Request, res: Response): Promise<void> { | ||||||
|     req: Request, |  | ||||||
|     res: Response |  | ||||||
| ): Promise<void> { |  | ||||||
|     try { |     try { | ||||||
|         const hruids = req.query.hruid; |         const hruids = req.query.hruid; | ||||||
|         const themeKey = req.query.theme as string; |         const themeKey = req.query.theme as string; | ||||||
|         const searchQuery = req.query.search as string; |         const searchQuery = req.query.search as string; | ||||||
|         const language = (req.query.language as string) || FALLBACK_LANG; |         const language = (req.query.language as Language) || FALLBACK_LANG; | ||||||
| 
 | 
 | ||||||
|         let hruidList; |         let hruidList; | ||||||
| 
 | 
 | ||||||
|         if (hruids) { |         if (hruids) { | ||||||
|             hruidList = Array.isArray(hruids) |             hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; | ||||||
|                 ? hruids.map(String) |  | ||||||
|                 : [String(hruids)]; |  | ||||||
|         } else if (themeKey) { |         } else if (themeKey) { | ||||||
|             const theme = themes.find((t) => { |             const theme = themes.find((t) => t.title === themeKey); | ||||||
|                 return t.title === themeKey; |  | ||||||
|             }); |  | ||||||
|             if (theme) { |             if (theme) { | ||||||
|                 hruidList = theme.hruids; |                 hruidList = theme.hruids; | ||||||
|             } else { |             } else { | ||||||
|  | @ -38,29 +29,17 @@ export async function getLearningPaths( | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|         } else if (searchQuery) { |         } else if (searchQuery) { | ||||||
|             const searchResults = await searchLearningPaths( |             const searchResults = await learningPathService.searchLearningPaths(searchQuery, language); | ||||||
|                 searchQuery, |  | ||||||
|                 language |  | ||||||
|             ); |  | ||||||
|             res.json(searchResults); |             res.json(searchResults); | ||||||
|             return; |             return; | ||||||
|         } else { |         } else { | ||||||
|             hruidList = themes.flatMap((theme) => { |             hruidList = themes.flatMap((theme) => theme.hruids); | ||||||
|                 return theme.hruids; |  | ||||||
|             }); |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const learningPaths = await fetchLearningPaths( |         const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); | ||||||
|             hruidList, |  | ||||||
|             language, |  | ||||||
|             `HRUIDs: ${hruidList.join(', ')}` |  | ||||||
|         ); |  | ||||||
|         res.json(learningPaths.data); |         res.json(learningPaths.data); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|         getLogger().error( |         getLogger().error('❌ Unexpected error fetching learning paths:', error); | ||||||
|             '❌ Unexpected error fetching learning paths:', |  | ||||||
|             error |  | ||||||
|         ); |  | ||||||
|         res.status(500).json({ error: 'Internal server error' }); |         res.status(500).json({ error: 'Internal server error' }); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -11,24 +11,19 @@ interface Translations { | ||||||
| export function getThemes(req: Request, res: Response) { | export function getThemes(req: Request, res: Response) { | ||||||
|     const language = (req.query.language as string)?.toLowerCase() || 'nl'; |     const language = (req.query.language as string)?.toLowerCase() || 'nl'; | ||||||
|     const translations = loadTranslations<Translations>(language); |     const translations = loadTranslations<Translations>(language); | ||||||
|     const themeList = themes.map((theme) => { |     const themeList = themes.map((theme) => ({ | ||||||
|         return { |         key: theme.title, | ||||||
|             key: theme.title, |         title: translations.curricula_page[theme.title]?.title || theme.title, | ||||||
|             title: |         description: translations.curricula_page[theme.title]?.description, | ||||||
|                 translations.curricula_page[theme.title]?.title || theme.title, |         image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, | ||||||
|             description: translations.curricula_page[theme.title]?.description, |     })); | ||||||
|             image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, |  | ||||||
|         }; |  | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     res.json(themeList); |     res.json(themeList); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function getThemeByTitle(req: Request, res: Response) { | export function getThemeByTitle(req: Request, res: Response) { | ||||||
|     const themeKey = req.params.theme; |     const themeKey = req.params.theme; | ||||||
|     const theme = themes.find((t) => { |     const theme = themes.find((t) => t.title === themeKey); | ||||||
|         return t.title === themeKey; |  | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     if (theme) { |     if (theme) { | ||||||
|         res.json(theme.hruids); |         res.json(theme.hruids); | ||||||
|  |  | ||||||
|  | @ -3,10 +3,7 @@ import { Assignment } from '../../entities/assignments/assignment.entity.js'; | ||||||
| import { Class } from '../../entities/classes/class.entity.js'; | import { Class } from '../../entities/classes/class.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | ||||||
|     public findByClassAndId( |     public findByClassAndId(within: Class, id: number): Promise<Assignment | null> { | ||||||
|         within: Class, |  | ||||||
|         id: number |  | ||||||
|     ): Promise<Assignment | null> { |  | ||||||
|         return this.findOne({ within: within, id: id }); |         return this.findOne({ within: within, id: id }); | ||||||
|     } |     } | ||||||
|     public findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { |     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'; | import { Assignment } from '../../entities/assignments/assignment.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class GroupRepository extends DwengoEntityRepository<Group> { | export class GroupRepository extends DwengoEntityRepository<Group> { | ||||||
|     public findByAssignmentAndGroupNumber( |     public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise<Group | null> { | ||||||
|         assignment: Assignment, |  | ||||||
|         groupNumber: number |  | ||||||
|     ): Promise<Group | null> { |  | ||||||
|         return this.findOne({ |         return this.findOne({ | ||||||
|             assignment: assignment, |             assignment: assignment, | ||||||
|             groupNumber: groupNumber, |             groupNumber: groupNumber, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|     public findAllGroupsForAssignment( |     public findAllGroupsForAssignment(assignment: Assignment): Promise<Group[]> { | ||||||
|         assignment: Assignment |  | ||||||
|     ): Promise<Group[]> { |  | ||||||
|         return this.findAll({ where: { assignment: assignment } }); |         return this.findAll({ where: { assignment: assignment } }); | ||||||
|     } |     } | ||||||
|     public deleteByAssignmentAndGroupNumber( |     public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) { | ||||||
|         assignment: Assignment, |  | ||||||
|         groupNumber: number |  | ||||||
|     ) { |  | ||||||
|         return this.deleteWhere({ |         return this.deleteWhere({ | ||||||
|             assignment: assignment, |             assignment: assignment, | ||||||
|             groupNumber: groupNumber, |             groupNumber: groupNumber, | ||||||
|  |  | ||||||
|  | @ -5,10 +5,7 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object | ||||||
| import { Student } from '../../entities/users/student.entity.js'; | import { Student } from '../../entities/users/student.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class SubmissionRepository extends DwengoEntityRepository<Submission> { | export class SubmissionRepository extends DwengoEntityRepository<Submission> { | ||||||
|     public findSubmissionByLearningObjectAndSubmissionNumber( |     public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<Submission | null> { | ||||||
|         loId: LearningObjectIdentifier, |  | ||||||
|         submissionNumber: number |  | ||||||
|     ): Promise<Submission | null> { |  | ||||||
|         return this.findOne({ |         return this.findOne({ | ||||||
|             learningObjectHruid: loId.hruid, |             learningObjectHruid: loId.hruid, | ||||||
|             learningObjectLanguage: loId.language, |             learningObjectLanguage: loId.language, | ||||||
|  | @ -17,10 +14,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public findMostRecentSubmissionForStudent( |     public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise<Submission | null> { | ||||||
|         loId: LearningObjectIdentifier, |  | ||||||
|         submitter: Student |  | ||||||
|     ): Promise<Submission | null> { |  | ||||||
|         return this.findOne( |         return this.findOne( | ||||||
|             { |             { | ||||||
|                 learningObjectHruid: loId.hruid, |                 learningObjectHruid: loId.hruid, | ||||||
|  | @ -32,10 +26,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public findMostRecentSubmissionForGroup( |     public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission | null> { | ||||||
|         loId: LearningObjectIdentifier, |  | ||||||
|         group: Group |  | ||||||
|     ): Promise<Submission | null> { |  | ||||||
|         return this.findOne( |         return this.findOne( | ||||||
|             { |             { | ||||||
|                 learningObjectHruid: loId.hruid, |                 learningObjectHruid: loId.hruid, | ||||||
|  | @ -47,10 +38,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> { | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public deleteSubmissionByLearningObjectAndSubmissionNumber( |     public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<void> { | ||||||
|         loId: LearningObjectIdentifier, |  | ||||||
|         submissionNumber: number |  | ||||||
|     ): Promise<void> { |  | ||||||
|         return this.deleteWhere({ |         return this.deleteWhere({ | ||||||
|             learningObjectHruid: loId.hruid, |             learningObjectHruid: loId.hruid, | ||||||
|             learningObjectLanguage: loId.language, |             learningObjectLanguage: loId.language, | ||||||
|  |  | ||||||
|  | @ -4,24 +4,16 @@ import { TeacherInvitation } from '../../entities/classes/teacher-invitation.ent | ||||||
| import { Teacher } from '../../entities/users/teacher.entity.js'; | import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class TeacherInvitationRepository extends DwengoEntityRepository<TeacherInvitation> { | export class TeacherInvitationRepository extends DwengoEntityRepository<TeacherInvitation> { | ||||||
|     public findAllInvitationsForClass( |     public findAllInvitationsForClass(clazz: Class): Promise<TeacherInvitation[]> { | ||||||
|         clazz: Class |  | ||||||
|     ): Promise<TeacherInvitation[]> { |  | ||||||
|         return this.findAll({ where: { class: clazz } }); |         return this.findAll({ where: { class: clazz } }); | ||||||
|     } |     } | ||||||
|     public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> { |     public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> { | ||||||
|         return this.findAll({ where: { sender: sender } }); |         return this.findAll({ where: { sender: sender } }); | ||||||
|     } |     } | ||||||
|     public findAllInvitationsFor( |     public findAllInvitationsFor(receiver: Teacher): Promise<TeacherInvitation[]> { | ||||||
|         receiver: Teacher |  | ||||||
|     ): Promise<TeacherInvitation[]> { |  | ||||||
|         return this.findAll({ where: { receiver: receiver } }); |         return this.findAll({ where: { receiver: receiver } }); | ||||||
|     } |     } | ||||||
|     public deleteBy( |     public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise<void> { | ||||||
|         clazz: Class, |  | ||||||
|         sender: Teacher, |  | ||||||
|         receiver: Teacher |  | ||||||
|     ): Promise<void> { |  | ||||||
|         return this.deleteWhere({ |         return this.deleteWhere({ | ||||||
|             sender: sender, |             sender: sender, | ||||||
|             receiver: receiver, |             receiver: receiver, | ||||||
|  |  | ||||||
|  | @ -1,16 +1,37 @@ | ||||||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||||
| import { Attachment } from '../../entities/content/attachment.entity.js'; | import { Attachment } from '../../entities/content/attachment.entity.js'; | ||||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | import { Language } from '../../entities/content/language'; | ||||||
|  | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier'; | ||||||
| 
 | 
 | ||||||
| export class AttachmentRepository extends DwengoEntityRepository<Attachment> { | export class AttachmentRepository extends DwengoEntityRepository<Attachment> { | ||||||
|     public findByLearningObjectAndNumber( |     public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> { | ||||||
|         learningObject: LearningObject, |  | ||||||
|         sequenceNumber: number |  | ||||||
|     ) { |  | ||||||
|         return this.findOne({ |         return this.findOne({ | ||||||
|             learningObject: learningObject, |             learningObject: { | ||||||
|             sequenceNumber: sequenceNumber, |                 hruid: learningObjectId.hruid, | ||||||
|  |                 language: learningObjectId.language, | ||||||
|  |                 version: learningObjectId.version, | ||||||
|  |             }, | ||||||
|  |             name: name, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise<Attachment | null> { | ||||||
|  |         return this.findOne( | ||||||
|  |             { | ||||||
|  |                 learningObject: { | ||||||
|  |                     hruid: hruid, | ||||||
|  |                     language: language, | ||||||
|  |                 }, | ||||||
|  |                 name: attachmentName, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 orderBy: { | ||||||
|  |                     learningObject: { | ||||||
|  |                         version: 'DESC', | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 |     // This repository is read-only for now since creating own learning object is an extension feature.
 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,16 +1,34 @@ | ||||||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||||
|  | import { Language } from '../../entities/content/language'; | ||||||
| 
 | 
 | ||||||
| export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | ||||||
|     public findByIdentifier( |     public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||||
|         identifier: LearningObjectIdentifier |         return this.findOne( | ||||||
|     ): Promise<LearningObject | null> { |             { | ||||||
|         return this.findOne({ |                 hruid: identifier.hruid, | ||||||
|             hruid: identifier.hruid, |                 language: identifier.language, | ||||||
|             language: identifier.language, |                 version: identifier.version, | ||||||
|             version: identifier.version, |             }, | ||||||
|         }); |             { | ||||||
|  |                 populate: ['keywords'], | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     public findLatestByHruidAndLanguage(hruid: string, language: Language) { | ||||||
|  |         return this.findOne( | ||||||
|  |             { | ||||||
|  |                 hruid: hruid, | ||||||
|  |                 language: language, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 populate: ['keywords'], | ||||||
|  |                 orderBy: { | ||||||
|  |                     version: 'DESC', | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,11 +3,24 @@ import { LearningPath } from '../../entities/content/learning-path.entity.js'; | ||||||
| import { Language } from '../../entities/content/language.js'; | import { Language } from '../../entities/content/language.js'; | ||||||
| 
 | 
 | ||||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||||
|     public findByHruidAndLanguage( |     public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||||
|         hruid: string, |         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); | ||||||
|         language: Language |     } | ||||||
|     ): Promise<LearningPath | null> { | 
 | ||||||
|         return this.findOne({ hruid: hruid, language: language }); |     /** | ||||||
|  |      * 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'; | import { EntityRepository, FilterQuery } from '@mikro-orm/core'; | ||||||
| 
 | 
 | ||||||
| export abstract class DwengoEntityRepository< | export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> { | ||||||
|     T extends object, |  | ||||||
| > extends EntityRepository<T> { |  | ||||||
|     public async save(entity: T) { |     public async save(entity: T) { | ||||||
|         const em = this.getEntityManager(); |         const em = this.getEntityManager(); | ||||||
|         em.persist(entity); |         em.persist(entity); | ||||||
|  |  | ||||||
|  | @ -25,10 +25,7 @@ export class AnswerRepository extends DwengoEntityRepository<Answer> { | ||||||
|             orderBy: { sequenceNumber: 'ASC' }, |             orderBy: { sequenceNumber: 'ASC' }, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|     public removeAnswerByQuestionAndSequenceNumber( |     public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise<void> { | ||||||
|         question: Question, |  | ||||||
|         sequenceNumber: number |  | ||||||
|     ): Promise<void> { |  | ||||||
|         return this.deleteWhere({ |         return this.deleteWhere({ | ||||||
|             toQuestion: question, |             toQuestion: question, | ||||||
|             sequenceNumber: sequenceNumber, |             sequenceNumber: sequenceNumber, | ||||||
|  |  | ||||||
|  | @ -4,11 +4,7 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object | ||||||
| import { Student } from '../../entities/users/student.entity.js'; | import { Student } from '../../entities/users/student.entity.js'; | ||||||
| 
 | 
 | ||||||
| export class QuestionRepository extends DwengoEntityRepository<Question> { | export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||||
|     public createQuestion(question: { |     public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> { | ||||||
|         loId: LearningObjectIdentifier; |  | ||||||
|         author: Student; |  | ||||||
|         content: string; |  | ||||||
|     }): Promise<Question> { |  | ||||||
|         const questionEntity = this.create({ |         const questionEntity = this.create({ | ||||||
|             learningObjectHruid: question.loId.hruid, |             learningObjectHruid: question.loId.hruid, | ||||||
|             learningObjectLanguage: question.loId.language, |             learningObjectLanguage: question.loId.language, | ||||||
|  | @ -24,9 +20,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||||
|         questionEntity.content = question.content; |         questionEntity.content = question.content; | ||||||
|         return this.insert(questionEntity); |         return this.insert(questionEntity); | ||||||
|     } |     } | ||||||
|     public findAllQuestionsAboutLearningObject( |     public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> { | ||||||
|         loId: LearningObjectIdentifier |  | ||||||
|     ): Promise<Question[]> { |  | ||||||
|         return this.findAll({ |         return this.findAll({ | ||||||
|             where: { |             where: { | ||||||
|                 learningObjectHruid: loId.hruid, |                 learningObjectHruid: loId.hruid, | ||||||
|  | @ -38,10 +32,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||||
|             }, |             }, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|     public removeQuestionByLearningObjectAndSequenceNumber( |     public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise<void> { | ||||||
|         loId: LearningObjectIdentifier, |  | ||||||
|         sequenceNumber: number |  | ||||||
|     ): Promise<void> { |  | ||||||
|         return this.deleteWhere({ |         return this.deleteWhere({ | ||||||
|             learningObjectHruid: loId.hruid, |             learningObjectHruid: loId.hruid, | ||||||
|             learningObjectLanguage: loId.language, |             learningObjectLanguage: loId.language, | ||||||
|  |  | ||||||
|  | @ -1,9 +1,4 @@ | ||||||
| import { | import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-orm/core'; | ||||||
|     AnyEntity, |  | ||||||
|     EntityManager, |  | ||||||
|     EntityName, |  | ||||||
|     EntityRepository, |  | ||||||
| } from '@mikro-orm/core'; |  | ||||||
| import { forkEntityManager } from '../orm.js'; | import { forkEntityManager } from '../orm.js'; | ||||||
| import { StudentRepository } from './users/student-repository.js'; | import { StudentRepository } from './users/student-repository.js'; | ||||||
| import { Student } from '../entities/users/student.entity.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 { LearningPathRepository } from './content/learning-path-repository.js'; | ||||||
| import { AttachmentRepository } from './content/attachment-repository.js'; | import { AttachmentRepository } from './content/attachment-repository.js'; | ||||||
| import { Attachment } from '../entities/content/attachment.entity.js'; | import { Attachment } from '../entities/content/attachment.entity.js'; | ||||||
|  | import { LearningPathNode } from '../entities/content/learning-path-node.entity.js'; | ||||||
|  | import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js'; | ||||||
| 
 | 
 | ||||||
| let entityManager: EntityManager | undefined; | let entityManager: EntityManager | undefined; | ||||||
| 
 | 
 | ||||||
|  | @ -43,9 +40,7 @@ export function transactional<T>(f: () => Promise<T>) { | ||||||
|     entityManager?.transactional(f); |     entityManager?.transactional(f); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>( | function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(entity: EntityName<T>): () => R { | ||||||
|     entity: EntityName<T> |  | ||||||
| ): () => R { |  | ||||||
|     let cachedRepo: R | undefined; |     let cachedRepo: R | undefined; | ||||||
|     return (): R => { |     return (): R => { | ||||||
|         if (!cachedRepo) { |         if (!cachedRepo) { | ||||||
|  | @ -60,60 +55,26 @@ function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>( | ||||||
| 
 | 
 | ||||||
| /* Users */ | /* Users */ | ||||||
| export const getUserRepository = repositoryGetter<User, UserRepository>(User); | export const getUserRepository = repositoryGetter<User, UserRepository>(User); | ||||||
| export const getStudentRepository = repositoryGetter< | export const getStudentRepository = repositoryGetter<Student, StudentRepository>(Student); | ||||||
|     Student, | export const getTeacherRepository = repositoryGetter<Teacher, TeacherRepository>(Teacher); | ||||||
|     StudentRepository |  | ||||||
| >(Student); |  | ||||||
| export const getTeacherRepository = repositoryGetter< |  | ||||||
|     Teacher, |  | ||||||
|     TeacherRepository |  | ||||||
| >(Teacher); |  | ||||||
| 
 | 
 | ||||||
| /* Classes */ | /* Classes */ | ||||||
| export const getClassRepository = repositoryGetter<Class, ClassRepository>( | export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class); | ||||||
|     Class | export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest); | ||||||
| ); | export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitation); | ||||||
| export const getClassJoinRequestRepository = repositoryGetter< |  | ||||||
|     ClassJoinRequest, |  | ||||||
|     ClassJoinRequestRepository |  | ||||||
| >(ClassJoinRequest); |  | ||||||
| export const getTeacherInvitationRepository = repositoryGetter< |  | ||||||
|     TeacherInvitation, |  | ||||||
|     TeacherInvitationRepository |  | ||||||
| >(TeacherInvitation); |  | ||||||
| 
 | 
 | ||||||
| /* Assignments */ | /* Assignments */ | ||||||
| export const getAssignmentRepository = repositoryGetter< | export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment); | ||||||
|     Assignment, | export const getGroupRepository = repositoryGetter<Group, GroupRepository>(Group); | ||||||
|     AssignmentRepository | export const getSubmissionRepository = repositoryGetter<Submission, SubmissionRepository>(Submission); | ||||||
| >(Assignment); |  | ||||||
| export const getGroupRepository = repositoryGetter<Group, GroupRepository>( |  | ||||||
|     Group |  | ||||||
| ); |  | ||||||
| export const getSubmissionRepository = repositoryGetter< |  | ||||||
|     Submission, |  | ||||||
|     SubmissionRepository |  | ||||||
| >(Submission); |  | ||||||
| 
 | 
 | ||||||
| /* Questions and answers */ | /* Questions and answers */ | ||||||
| export const getQuestionRepository = repositoryGetter< | export const getQuestionRepository = repositoryGetter<Question, QuestionRepository>(Question); | ||||||
|     Question, | export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(Answer); | ||||||
|     QuestionRepository |  | ||||||
| >(Question); |  | ||||||
| export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>( |  | ||||||
|     Answer |  | ||||||
| ); |  | ||||||
| 
 | 
 | ||||||
| /* Learning content */ | /* Learning content */ | ||||||
| export const getLearningObjectRepository = repositoryGetter< | export const getLearningObjectRepository = repositoryGetter<LearningObject, LearningObjectRepository>(LearningObject); | ||||||
|     LearningObject, | export const getLearningPathRepository = repositoryGetter<LearningPath, LearningPathRepository>(LearningPath); | ||||||
|     LearningObjectRepository | export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode); | ||||||
| >(LearningObject); | export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition); | ||||||
| export const getLearningPathRepository = repositoryGetter< | export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Attachment); | ||||||
|     LearningPath, |  | ||||||
|     LearningPathRepository |  | ||||||
| >(LearningPath); |  | ||||||
| export const getAttachmentRepository = repositoryGetter< |  | ||||||
|     Attachment, |  | ||||||
|     AttachmentRepository |  | ||||||
| >(Attachment); |  | ||||||
|  |  | ||||||
|  | @ -23,13 +23,7 @@ export const themes: Theme[] = [ | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'art', |         title: 'art', | ||||||
|         hruids: [ |         hruids: ['pn_werking', 'un_artificiele_intelligentie', 'art1', 'art2', 'art3'], | ||||||
|             'pn_werking', |  | ||||||
|             'un_artificiele_intelligentie', |  | ||||||
|             'art1', |  | ||||||
|             'art2', |  | ||||||
|             'art3', |  | ||||||
|         ], |  | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'socialrobot', |         title: 'socialrobot', | ||||||
|  | @ -37,12 +31,7 @@ export const themes: Theme[] = [ | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'agriculture', |         title: 'agriculture', | ||||||
|         hruids: [ |         hruids: ['pn_werking', 'un_artificiele_intelligentie', 'agri_landbouw', 'agri_lopendeband'], | ||||||
|             'pn_werking', |  | ||||||
|             'un_artificiele_intelligentie', |  | ||||||
|             'agri_landbouw', |  | ||||||
|             'agri_lopendeband', |  | ||||||
|         ], |  | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'wegostem', |         title: 'wegostem', | ||||||
|  | @ -83,16 +72,7 @@ export const themes: Theme[] = [ | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'python_programming', |         title: 'python_programming', | ||||||
|         hruids: [ |         hruids: ['pn_werking', 'pn_datatypes', 'pn_operatoren', 'pn_structuren', 'pn_functies', 'art2', 'stem_insectbooks', 'un_algoenprog'], | ||||||
|             'pn_werking', |  | ||||||
|             'pn_datatypes', |  | ||||||
|             'pn_operatoren', |  | ||||||
|             'pn_structuren', |  | ||||||
|             'pn_functies', |  | ||||||
|             'art2', |  | ||||||
|             'stem_insectbooks', |  | ||||||
|             'un_algoenprog', |  | ||||||
|         ], |  | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'stem', |         title: 'stem', | ||||||
|  | @ -110,15 +90,7 @@ export const themes: Theme[] = [ | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'care', |         title: 'care', | ||||||
|         hruids: [ |         hruids: ['pn_werking', 'un_artificiele_intelligentie', 'aiz1_zorg', 'aiz2_grafen', 'aiz3_unplugged', 'aiz4_eindtermen', 'aiz5_triage'], | ||||||
|             'pn_werking', |  | ||||||
|             'un_artificiele_intelligentie', |  | ||||||
|             'aiz1_zorg', |  | ||||||
|             'aiz2_grafen', |  | ||||||
|             'aiz3_unplugged', |  | ||||||
|             'aiz4_eindtermen', |  | ||||||
|             'aiz5_triage', |  | ||||||
|         ], |  | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         title: 'chatbot', |         title: 'chatbot', | ||||||
|  |  | ||||||
|  | @ -1,28 +1,12 @@ | ||||||
| import { | import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
|     Entity, |  | ||||||
|     Enum, |  | ||||||
|     ManyToOne, |  | ||||||
|     OneToMany, |  | ||||||
|     PrimaryKey, |  | ||||||
|     Property, |  | ||||||
| } from '@mikro-orm/core'; |  | ||||||
| import { Class } from '../classes/class.entity.js'; | import { Class } from '../classes/class.entity.js'; | ||||||
| import { Group } from './group.entity.js'; | import { Group } from './group.entity.js'; | ||||||
| import { Language } from '../content/language.js'; | import { Language } from '../content/language.js'; | ||||||
| import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; | import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => AssignmentRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return AssignmentRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Assignment { | export class Assignment { | ||||||
|     @ManyToOne({ |     @ManyToOne({ entity: () => Class, primary: true }) | ||||||
|         entity: () => { |  | ||||||
|             return Class; |  | ||||||
|         }, |  | ||||||
|         primary: true, |  | ||||||
|     }) |  | ||||||
|     within!: Class; |     within!: Class; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'number' }) |     @PrimaryKey({ type: 'number' }) | ||||||
|  | @ -37,18 +21,9 @@ export class Assignment { | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     learningPathHruid!: string; |     learningPathHruid!: string; | ||||||
| 
 | 
 | ||||||
|     @Enum({ |     @Enum({ items: () => Language }) | ||||||
|         items: () => { |  | ||||||
|             return Language; |  | ||||||
|         }, |  | ||||||
|     }) |  | ||||||
|     learningPathLanguage!: Language; |     learningPathLanguage!: Language; | ||||||
| 
 | 
 | ||||||
|     @OneToMany({ |     @OneToMany({ entity: () => Group, mappedBy: 'assignment' }) | ||||||
|         entity: () => { |  | ||||||
|             return Group; |  | ||||||
|         }, |  | ||||||
|         mappedBy: 'assignment', |  | ||||||
|     }) |  | ||||||
|     groups!: Group[]; |     groups!: Group[]; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,16 +3,10 @@ import { Assignment } from './assignment.entity.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
| import { GroupRepository } from '../../data/assignments/group-repository.js'; | import { GroupRepository } from '../../data/assignments/group-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => GroupRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return GroupRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Group { | export class Group { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Assignment, | ||||||
|             return Assignment; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     assignment!: Assignment; |     assignment!: Assignment; | ||||||
|  | @ -21,9 +15,7 @@ export class Group { | ||||||
|     groupNumber!: number; |     groupNumber!: number; | ||||||
| 
 | 
 | ||||||
|     @ManyToMany({ |     @ManyToMany({ | ||||||
|         entity: () => { |         entity: () => Student, | ||||||
|             return Student; |  | ||||||
|         }, |  | ||||||
|     }) |     }) | ||||||
|     members!: Student[]; |     members!: Student[]; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,33 +4,25 @@ import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { Language } from '../content/language.js'; | import { Language } from '../content/language.js'; | ||||||
| import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; | import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => SubmissionRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return SubmissionRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Submission { | export class Submission { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     learningObjectHruid!: string; |     learningObjectHruid!: string; | ||||||
| 
 | 
 | ||||||
|     @Enum({ |     @Enum({ | ||||||
|         items: () => { |         items: () => Language, | ||||||
|             return Language; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     learningObjectLanguage!: Language; |     learningObjectLanguage!: Language; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'numeric' }) | ||||||
|     learningObjectVersion: string = '1'; |     learningObjectVersion: number = 1; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer' }) |     @PrimaryKey({ type: 'integer' }) | ||||||
|     submissionNumber!: number; |     submissionNumber!: number; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Student, | ||||||
|             return Student; |  | ||||||
|         }, |  | ||||||
|     }) |     }) | ||||||
|     submitter!: Student; |     submitter!: Student; | ||||||
| 
 | 
 | ||||||
|  | @ -38,9 +30,7 @@ export class Submission { | ||||||
|     submissionTime!: Date; |     submissionTime!: Date; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Group, | ||||||
|             return Group; |  | ||||||
|         }, |  | ||||||
|         nullable: true, |         nullable: true, | ||||||
|     }) |     }) | ||||||
|     onBehalfOf?: Group; |     onBehalfOf?: Group; | ||||||
|  |  | ||||||
|  | @ -3,31 +3,21 @@ import { Student } from '../users/student.entity.js'; | ||||||
| import { Class } from './class.entity.js'; | import { Class } from './class.entity.js'; | ||||||
| import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; | import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => ClassJoinRequestRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return ClassJoinRequestRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class ClassJoinRequest { | export class ClassJoinRequest { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Student, | ||||||
|             return Student; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     requester!: Student; |     requester!: Student; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Class, | ||||||
|             return Class; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     class!: Class; |     class!: Class; | ||||||
| 
 | 
 | ||||||
|     @Enum(() => { |     @Enum(() => ClassJoinRequestStatus) | ||||||
|         return ClassJoinRequestStatus; |  | ||||||
|     }) |  | ||||||
|     status!: ClassJoinRequestStatus; |     status!: ClassJoinRequestStatus; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,20 +1,10 @@ | ||||||
| import { | import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
|     Collection, |  | ||||||
|     Entity, |  | ||||||
|     ManyToMany, |  | ||||||
|     PrimaryKey, |  | ||||||
|     Property, |  | ||||||
| } from '@mikro-orm/core'; |  | ||||||
| import { v4 } from 'uuid'; | import { v4 } from 'uuid'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
| import { ClassRepository } from '../../data/classes/class-repository.js'; | import { ClassRepository } from '../../data/classes/class-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => ClassRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return ClassRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Class { | export class Class { | ||||||
|     @PrimaryKey() |     @PrimaryKey() | ||||||
|     classId = v4(); |     classId = v4(); | ||||||
|  | @ -22,13 +12,9 @@ export class Class { | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     displayName!: string; |     displayName!: string; | ||||||
| 
 | 
 | ||||||
|     @ManyToMany(() => { |     @ManyToMany(() => Teacher) | ||||||
|         return Teacher; |  | ||||||
|     }) |  | ||||||
|     teachers!: Collection<Teacher>; |     teachers!: Collection<Teacher>; | ||||||
| 
 | 
 | ||||||
|     @ManyToMany(() => { |     @ManyToMany(() => Student) | ||||||
|         return Student; |  | ||||||
|     }) |  | ||||||
|     students!: Collection<Student>; |     students!: Collection<Student>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ import { TeacherInvitationRepository } from '../../data/classes/teacher-invitati | ||||||
| /** | /** | ||||||
|  * Invitation of a teacher into a class (in order to teach it). |  * Invitation of a teacher into a class (in order to teach it). | ||||||
|  */ |  */ | ||||||
|  | @Entity({ repository: () => TeacherInvitationRepository }) | ||||||
| @Entity({ | @Entity({ | ||||||
|     repository: () => { |     repository: () => { | ||||||
|         return TeacherInvitationRepository; |         return TeacherInvitationRepository; | ||||||
|  | @ -13,25 +14,19 @@ import { TeacherInvitationRepository } from '../../data/classes/teacher-invitati | ||||||
| }) | }) | ||||||
| export class TeacherInvitation { | export class TeacherInvitation { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Teacher, | ||||||
|             return Teacher; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     sender!: Teacher; |     sender!: Teacher; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Teacher, | ||||||
|             return Teacher; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     receiver!: Teacher; |     receiver!: Teacher; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Class, | ||||||
|             return Class; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     class!: Class; |     class!: Class; | ||||||
|  |  | ||||||
|  | @ -2,22 +2,16 @@ import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
| import { LearningObject } from './learning-object.entity.js'; | import { LearningObject } from './learning-object.entity.js'; | ||||||
| import { AttachmentRepository } from '../../data/content/attachment-repository.js'; | import { AttachmentRepository } from '../../data/content/attachment-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => AttachmentRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return AttachmentRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Attachment { | export class Attachment { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => LearningObject, | ||||||
|             return LearningObject; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     learningObject!: LearningObject; |     learningObject!: LearningObject; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     sequenceNumber!: number; |     name!: string; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|     mimeType!: string; |     mimeType!: string; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,186 @@ | ||||||
| export enum Language { | export enum Language { | ||||||
|  |     Afar = 'aa', | ||||||
|  |     Abkhazian = 'ab', | ||||||
|  |     Afrikaans = 'af', | ||||||
|  |     Akan = 'ak', | ||||||
|  |     Albanian = 'sq', | ||||||
|  |     Amharic = 'am', | ||||||
|  |     Arabic = 'ar', | ||||||
|  |     Aragonese = 'an', | ||||||
|  |     Armenian = 'hy', | ||||||
|  |     Assamese = 'as', | ||||||
|  |     Avaric = 'av', | ||||||
|  |     Avestan = 'ae', | ||||||
|  |     Aymara = 'ay', | ||||||
|  |     Azerbaijani = 'az', | ||||||
|  |     Bashkir = 'ba', | ||||||
|  |     Bambara = 'bm', | ||||||
|  |     Basque = 'eu', | ||||||
|  |     Belarusian = 'be', | ||||||
|  |     Bengali = 'bn', | ||||||
|  |     Bihari = 'bh', | ||||||
|  |     Bislama = 'bi', | ||||||
|  |     Bosnian = 'bs', | ||||||
|  |     Breton = 'br', | ||||||
|  |     Bulgarian = 'bg', | ||||||
|  |     Burmese = 'my', | ||||||
|  |     Catalan = 'ca', | ||||||
|  |     Chamorro = 'ch', | ||||||
|  |     Chechen = 'ce', | ||||||
|  |     Chinese = 'zh', | ||||||
|  |     ChurchSlavic = 'cu', | ||||||
|  |     Chuvash = 'cv', | ||||||
|  |     Cornish = 'kw', | ||||||
|  |     Corsican = 'co', | ||||||
|  |     Cree = 'cr', | ||||||
|  |     Czech = 'cs', | ||||||
|  |     Danish = 'da', | ||||||
|  |     Divehi = 'dv', | ||||||
|     Dutch = 'nl', |     Dutch = 'nl', | ||||||
|     French = 'fr', |     Dzongkha = 'dz', | ||||||
|     English = 'en', |     English = 'en', | ||||||
|     Germany = 'de', |     Esperanto = 'eo', | ||||||
|  |     Estonian = 'et', | ||||||
|  |     Ewe = 'ee', | ||||||
|  |     Faroese = 'fo', | ||||||
|  |     Fijian = 'fj', | ||||||
|  |     Finnish = 'fi', | ||||||
|  |     French = 'fr', | ||||||
|  |     Frisian = 'fy', | ||||||
|  |     Fulah = 'ff', | ||||||
|  |     Georgian = 'ka', | ||||||
|  |     German = 'de', | ||||||
|  |     Gaelic = 'gd', | ||||||
|  |     Irish = 'ga', | ||||||
|  |     Galician = 'gl', | ||||||
|  |     Manx = 'gv', | ||||||
|  |     Greek = 'el', | ||||||
|  |     Guarani = 'gn', | ||||||
|  |     Gujarati = 'gu', | ||||||
|  |     Haitian = 'ht', | ||||||
|  |     Hausa = 'ha', | ||||||
|  |     Hebrew = 'he', | ||||||
|  |     Herero = 'hz', | ||||||
|  |     Hindi = 'hi', | ||||||
|  |     HiriMotu = 'ho', | ||||||
|  |     Croatian = 'hr', | ||||||
|  |     Hungarian = 'hu', | ||||||
|  |     Igbo = 'ig', | ||||||
|  |     Icelandic = 'is', | ||||||
|  |     Ido = 'io', | ||||||
|  |     SichuanYi = 'ii', | ||||||
|  |     Inuktitut = 'iu', | ||||||
|  |     Interlingue = 'ie', | ||||||
|  |     Interlingua = 'ia', | ||||||
|  |     Indonesian = 'id', | ||||||
|  |     Inupiaq = 'ik', | ||||||
|  |     Italian = 'it', | ||||||
|  |     Javanese = 'jv', | ||||||
|  |     Japanese = 'ja', | ||||||
|  |     Kalaallisut = 'kl', | ||||||
|  |     Kannada = 'kn', | ||||||
|  |     Kashmiri = 'ks', | ||||||
|  |     Kanuri = 'kr', | ||||||
|  |     Kazakh = 'kk', | ||||||
|  |     Khmer = 'km', | ||||||
|  |     Kikuyu = 'ki', | ||||||
|  |     Kinyarwanda = 'rw', | ||||||
|  |     Kirghiz = 'ky', | ||||||
|  |     Komi = 'kv', | ||||||
|  |     Kongo = 'kg', | ||||||
|  |     Korean = 'ko', | ||||||
|  |     Kuanyama = 'kj', | ||||||
|  |     Kurdish = 'ku', | ||||||
|  |     Lao = 'lo', | ||||||
|  |     Latin = 'la', | ||||||
|  |     Latvian = 'lv', | ||||||
|  |     Limburgan = 'li', | ||||||
|  |     Lingala = 'ln', | ||||||
|  |     Lithuanian = 'lt', | ||||||
|  |     Luxembourgish = 'lb', | ||||||
|  |     LubaKatanga = 'lu', | ||||||
|  |     Ganda = 'lg', | ||||||
|  |     Macedonian = 'mk', | ||||||
|  |     Marshallese = 'mh', | ||||||
|  |     Malayalam = 'ml', | ||||||
|  |     Maori = 'mi', | ||||||
|  |     Marathi = 'mr', | ||||||
|  |     Malay = 'ms', | ||||||
|  |     Malagasy = 'mg', | ||||||
|  |     Maltese = 'mt', | ||||||
|  |     Mongolian = 'mn', | ||||||
|  |     Nauru = 'na', | ||||||
|  |     Navajo = 'nv', | ||||||
|  |     SouthNdebele = 'nr', | ||||||
|  |     NorthNdebele = 'nd', | ||||||
|  |     Ndonga = 'ng', | ||||||
|  |     Nepali = 'ne', | ||||||
|  |     NorwegianNynorsk = 'nn', | ||||||
|  |     NorwegianBokmal = 'nb', | ||||||
|  |     Norwegian = 'no', | ||||||
|  |     Chichewa = 'ny', | ||||||
|  |     Occitan = 'oc', | ||||||
|  |     Ojibwa = 'oj', | ||||||
|  |     Oriya = 'or', | ||||||
|  |     Oromo = 'om', | ||||||
|  |     Ossetian = 'os', | ||||||
|  |     Punjabi = 'pa', | ||||||
|  |     Persian = 'fa', | ||||||
|  |     Pali = 'pi', | ||||||
|  |     Polish = 'pl', | ||||||
|  |     Portuguese = 'pt', | ||||||
|  |     Pashto = 'ps', | ||||||
|  |     Quechua = 'qu', | ||||||
|  |     Romansh = 'rm', | ||||||
|  |     Romanian = 'ro', | ||||||
|  |     Rundi = 'rn', | ||||||
|  |     Russian = 'ru', | ||||||
|  |     Sango = 'sg', | ||||||
|  |     Sanskrit = 'sa', | ||||||
|  |     Sinhala = 'si', | ||||||
|  |     Slovak = 'sk', | ||||||
|  |     Slovenian = 'sl', | ||||||
|  |     NorthernSami = 'se', | ||||||
|  |     Samoan = 'sm', | ||||||
|  |     Shona = 'sn', | ||||||
|  |     Sindhi = 'sd', | ||||||
|  |     Somali = 'so', | ||||||
|  |     Sotho = 'st', | ||||||
|  |     Spanish = 'es', | ||||||
|  |     Sardinian = 'sc', | ||||||
|  |     Serbian = 'sr', | ||||||
|  |     Swati = 'ss', | ||||||
|  |     Sundanese = 'su', | ||||||
|  |     Swahili = 'sw', | ||||||
|  |     Swedish = 'sv', | ||||||
|  |     Tahitian = 'ty', | ||||||
|  |     Tamil = 'ta', | ||||||
|  |     Tatar = 'tt', | ||||||
|  |     Telugu = 'te', | ||||||
|  |     Tajik = 'tg', | ||||||
|  |     Tagalog = 'tl', | ||||||
|  |     Thai = 'th', | ||||||
|  |     Tibetan = 'bo', | ||||||
|  |     Tigrinya = 'ti', | ||||||
|  |     Tonga = 'to', | ||||||
|  |     Tswana = 'tn', | ||||||
|  |     Tsonga = 'ts', | ||||||
|  |     Turkmen = 'tk', | ||||||
|  |     Turkish = 'tr', | ||||||
|  |     Twi = 'tw', | ||||||
|  |     Uighur = 'ug', | ||||||
|  |     Ukrainian = 'uk', | ||||||
|  |     Urdu = 'ur', | ||||||
|  |     Uzbek = 'uz', | ||||||
|  |     Venda = 've', | ||||||
|  |     Vietnamese = 'vi', | ||||||
|  |     Volapuk = 'vo', | ||||||
|  |     Welsh = 'cy', | ||||||
|  |     Walloon = 'wa', | ||||||
|  |     Wolof = 'wo', | ||||||
|  |     Xhosa = 'xh', | ||||||
|  |     Yiddish = 'yi', | ||||||
|  |     Yoruba = 'yo', | ||||||
|  |     Zhuang = 'za', | ||||||
|  |     Zulu = 'zu', | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -4,6 +4,6 @@ export class LearningObjectIdentifier { | ||||||
|     constructor( |     constructor( | ||||||
|         public hruid: string, |         public hruid: string, | ||||||
|         public language: Language, |         public language: Language, | ||||||
|         public version: string |         public version: number | ||||||
|     ) {} |     ) {} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,111 +1,11 @@ | ||||||
| import { | import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
|     Embeddable, |  | ||||||
|     Embedded, |  | ||||||
|     Entity, |  | ||||||
|     Enum, |  | ||||||
|     ManyToMany, |  | ||||||
|     OneToMany, |  | ||||||
|     PrimaryKey, |  | ||||||
|     Property, |  | ||||||
| } from '@mikro-orm/core'; |  | ||||||
| import { Language } from './language.js'; | import { Language } from './language.js'; | ||||||
| import { Attachment } from './attachment.entity.js'; | import { Attachment } from './attachment.entity.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
|  | import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; | ||||||
|  | import { v4 } from 'uuid'; | ||||||
| import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; | import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ |  | ||||||
|     repository: () => { |  | ||||||
|         return LearningObjectRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| 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; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @Embeddable() | @Embeddable() | ||||||
| export class EducationalGoal { | export class EducationalGoal { | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|  | @ -124,11 +24,84 @@ export class ReturnValue { | ||||||
|     callbackSchema!: string; |     callbackSchema!: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export enum ContentType { | @Entity({ repository: () => LearningObjectRepository }) | ||||||
|     Markdown = 'text/markdown', | export class LearningObject { | ||||||
|     Image = 'image/image', |     @PrimaryKey({ type: 'string' }) | ||||||
|     Mpeg = 'audio/mpeg', |     hruid!: string; | ||||||
|     Pdf = 'application/pdf', | 
 | ||||||
|     Extern = 'extern', |     @Enum({ | ||||||
|     Blockly = 'Blockly', |         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,35 +1,18 @@ | ||||||
| import { | import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; | ||||||
|     Embeddable, |  | ||||||
|     Embedded, |  | ||||||
|     Entity, |  | ||||||
|     Enum, |  | ||||||
|     ManyToMany, |  | ||||||
|     OneToOne, |  | ||||||
|     PrimaryKey, |  | ||||||
|     Property, |  | ||||||
| } from '@mikro-orm/core'; |  | ||||||
| import { Language } from './language.js'; | import { Language } from './language.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; | ||||||
|  | import { LearningPathNode } from './learning-path-node.entity.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ repository : () => {return LearningPathRepository;}}) | @Entity({ repository: () => LearningPathRepository }) | ||||||
| export class LearningPath { | export class LearningPath { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     hruid!: string; |     hruid!: string; | ||||||
| 
 | 
 | ||||||
|     @Enum({ |     @Enum({ items: () => Language, primary: true }) | ||||||
|         items: () => { |  | ||||||
|             return Language; |  | ||||||
|         }, |  | ||||||
|         primary: true, |  | ||||||
|     }) |  | ||||||
|     language!: Language; |     language!: Language; | ||||||
| 
 | 
 | ||||||
|     @ManyToMany({ |     @ManyToMany({ entity: () => Teacher }) | ||||||
|         entity: () => { |  | ||||||
|             return Teacher; |  | ||||||
|         }, |  | ||||||
|     }) |  | ||||||
|     admins!: Teacher[]; |     admins!: Teacher[]; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'string' }) | ||||||
|  | @ -38,57 +21,9 @@ export class LearningPath { | ||||||
|     @Property({ type: 'text' }) |     @Property({ type: 'text' }) | ||||||
|     description!: string; |     description!: string; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'blob' }) |     @Property({ type: 'blob', nullable: true }) | ||||||
|     image!: string; |     image: Buffer | null = null; | ||||||
| 
 | 
 | ||||||
|     @Embedded({ |     @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) | ||||||
|         entity: () => { |  | ||||||
|             return LearningPathNode; |  | ||||||
|         }, |  | ||||||
|         array: true, |  | ||||||
|     }) |  | ||||||
|     nodes: LearningPathNode[] = []; |     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; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -3,24 +3,16 @@ import { Question } from './question.entity.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import { AnswerRepository } from '../../data/questions/answer-repository.js'; | import { AnswerRepository } from '../../data/questions/answer-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => AnswerRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return AnswerRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Answer { | export class Answer { | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Teacher, | ||||||
|             return Teacher; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     author!: Teacher; |     author!: Teacher; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Question, | ||||||
|             return Question; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     toQuestion!: Question; |     toQuestion!: Question; | ||||||
|  |  | ||||||
|  | @ -3,33 +3,25 @@ import { Language } from '../content/language.js'; | ||||||
| import { Student } from '../users/student.entity.js'; | import { Student } from '../users/student.entity.js'; | ||||||
| import { QuestionRepository } from '../../data/questions/question-repository.js'; | import { QuestionRepository } from '../../data/questions/question-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => QuestionRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return QuestionRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Question { | export class Question { | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'string' }) | ||||||
|     learningObjectHruid!: string; |     learningObjectHruid!: string; | ||||||
| 
 | 
 | ||||||
|     @Enum({ |     @Enum({ | ||||||
|         items: () => { |         items: () => Language, | ||||||
|             return Language; |  | ||||||
|         }, |  | ||||||
|         primary: true, |         primary: true, | ||||||
|     }) |     }) | ||||||
|     learningObjectLanguage!: Language; |     learningObjectLanguage!: Language; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'number' }) | ||||||
|     learningObjectVersion: string = '1'; |     learningObjectVersion: number = 1; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'integer', autoincrement: true }) |     @PrimaryKey({ type: 'integer', autoincrement: true }) | ||||||
|     sequenceNumber?: number; |     sequenceNumber?: number; | ||||||
| 
 | 
 | ||||||
|     @ManyToOne({ |     @ManyToOne({ | ||||||
|         entity: () => { |         entity: () => Student, | ||||||
|             return Student; |  | ||||||
|         }, |  | ||||||
|     }) |     }) | ||||||
|     author!: Student; |     author!: Student; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,19 +5,13 @@ import { Group } from '../assignments/group.entity.js'; | ||||||
| import { StudentRepository } from '../../data/users/student-repository.js'; | import { StudentRepository } from '../../data/users/student-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ | ||||||
|     repository: () => { |     repository: () => StudentRepository, | ||||||
|         return StudentRepository; |  | ||||||
|     }, |  | ||||||
| }) | }) | ||||||
| export class Student extends User { | export class Student extends User { | ||||||
|     @ManyToMany(() => { |     @ManyToMany(() => Class) | ||||||
|         return Class; |  | ||||||
|     }) |  | ||||||
|     classes!: Collection<Class>; |     classes!: Collection<Class>; | ||||||
| 
 | 
 | ||||||
|     @ManyToMany(() => { |     @ManyToMany(() => Group) | ||||||
|         return Group; |  | ||||||
|     }) |  | ||||||
|     groups!: Collection<Group>; |     groups!: Collection<Group>; | ||||||
| 
 | 
 | ||||||
|     constructor( |     constructor( | ||||||
|  |  | ||||||
|  | @ -3,15 +3,9 @@ import { User } from './user.entity.js'; | ||||||
| import { Class } from '../classes/class.entity.js'; | import { Class } from '../classes/class.entity.js'; | ||||||
| import { TeacherRepository } from '../../data/users/teacher-repository.js'; | import { TeacherRepository } from '../../data/users/teacher-repository.js'; | ||||||
| 
 | 
 | ||||||
| @Entity({ | @Entity({ repository: () => TeacherRepository }) | ||||||
|     repository: () => { |  | ||||||
|         return TeacherRepository; |  | ||||||
|     }, |  | ||||||
| }) |  | ||||||
| export class Teacher extends User { | export class Teacher extends User { | ||||||
|     @ManyToMany(() => { |     @ManyToMany(() => Class) | ||||||
|         return Class; |  | ||||||
|     }) |  | ||||||
|     classes!: Collection<Class>; |     classes!: Collection<Class>; | ||||||
| 
 | 
 | ||||||
|     constructor( |     constructor( | ||||||
|  |  | ||||||
							
								
								
									
										42
									
								
								backend/src/exceptions.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								backend/src/exceptions.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | ||||||
|  | /** | ||||||
|  |  * 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') { | ||||||
|  |         super(message); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 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 { | export interface Transition { | ||||||
|     default: boolean; |     default: boolean; | ||||||
|     _id: string; |     _id: string; | ||||||
|  | @ -9,15 +11,22 @@ export interface Transition { | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export interface LearningObjectIdentifier { | ||||||
|  |     hruid: string; | ||||||
|  |     language: Language; | ||||||
|  |     version?: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface LearningObjectNode { | export interface LearningObjectNode { | ||||||
|     _id: string; |     _id: string; | ||||||
|     learningobject_hruid: string; |     learningobject_hruid: string; | ||||||
|     version: number; |     version: number; | ||||||
|     language: string; |     language: Language; | ||||||
|     start_node?: boolean; |     start_node?: boolean; | ||||||
|     transitions: Transition[]; |     transitions: Transition[]; | ||||||
|     created_at: string; |     created_at: string; | ||||||
|     updatedAt: string; |     updatedAt: string; | ||||||
|  |     done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface LearningPath { | export interface LearningPath { | ||||||
|  | @ -37,6 +46,11 @@ export interface LearningPath { | ||||||
|     __order: number; |     __order: number; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export interface LearningPathIdentifier { | ||||||
|  |     hruid: string; | ||||||
|  |     language: Language; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface EducationalGoal { | export interface EducationalGoal { | ||||||
|     source: string; |     source: string; | ||||||
|     id: string; |     id: string; | ||||||
|  | @ -52,7 +66,7 @@ export interface LearningObjectMetadata { | ||||||
|     uuid: string; |     uuid: string; | ||||||
|     hruid: string; |     hruid: string; | ||||||
|     version: number; |     version: number; | ||||||
|     language: string; |     language: Language; | ||||||
|     title: string; |     title: string; | ||||||
|     description: string; |     description: string; | ||||||
|     difficulty: number; |     difficulty: number; | ||||||
|  | @ -75,9 +89,9 @@ export interface FilteredLearningObject { | ||||||
|     version: number; |     version: number; | ||||||
|     title: string; |     title: string; | ||||||
|     htmlUrl: string; |     htmlUrl: string; | ||||||
|     language: string; |     language: Language; | ||||||
|     difficulty: number; |     difficulty: number; | ||||||
|     estimatedTime: number; |     estimatedTime?: number; | ||||||
|     available: boolean; |     available: boolean; | ||||||
|     teacherExclusive: boolean; |     teacherExclusive: boolean; | ||||||
|     educationalGoals: EducationalGoal[]; |     educationalGoals: EducationalGoal[]; | ||||||
|  | @ -1,9 +1,4 @@ | ||||||
| import { | import { createLogger, format, Logger as WinstonLogger, transports } from 'winston'; | ||||||
|     createLogger, |  | ||||||
|     format, |  | ||||||
|     Logger as WinstonLogger, |  | ||||||
|     transports, |  | ||||||
| } from 'winston'; |  | ||||||
| import LokiTransport from 'winston-loki'; | import LokiTransport from 'winston-loki'; | ||||||
| import { LokiLabels } from 'loki-logger-ts'; | import { LokiLabels } from 'loki-logger-ts'; | ||||||
| import { LOG_LEVEL, LOKI_HOST } from '../config.js'; | import { LOG_LEVEL, LOKI_HOST } from '../config.js'; | ||||||
|  | @ -48,9 +43,7 @@ function initializeLogger(): Logger { | ||||||
|         transports: [lokiTransport, consoleTransport], |         transports: [lokiTransport, consoleTransport], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     logger.debug( |     logger.debug(`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`); | ||||||
|         `Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}` |  | ||||||
|     ); |  | ||||||
|     return logger; |     return logger; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -12,42 +12,28 @@ export class MikroOrmLogger extends DefaultLogger { | ||||||
| 
 | 
 | ||||||
|         switch (namespace) { |         switch (namespace) { | ||||||
|             case 'query': |             case 'query': | ||||||
|                 this.logger.debug( |                 this.logger.debug(this.createMessage(namespace, message, context)); | ||||||
|                     this.createMessage(namespace, message, context) |  | ||||||
|                 ); |  | ||||||
|                 break; |                 break; | ||||||
|             case 'query-params': |             case 'query-params': | ||||||
|                 // TODO Which log level should this be?
 |                 // TODO Which log level should this be?
 | ||||||
|                 this.logger.info( |                 this.logger.info(this.createMessage(namespace, message, context)); | ||||||
|                     this.createMessage(namespace, message, context) |  | ||||||
|                 ); |  | ||||||
|                 break; |                 break; | ||||||
|             case 'schema': |             case 'schema': | ||||||
|                 this.logger.info( |                 this.logger.info(this.createMessage(namespace, message, context)); | ||||||
|                     this.createMessage(namespace, message, context) |  | ||||||
|                 ); |  | ||||||
|                 break; |                 break; | ||||||
|             case 'discovery': |             case 'discovery': | ||||||
|                 this.logger.debug( |                 this.logger.debug(this.createMessage(namespace, message, context)); | ||||||
|                     this.createMessage(namespace, message, context) |  | ||||||
|                 ); |  | ||||||
|                 break; |                 break; | ||||||
|             case 'info': |             case 'info': | ||||||
|                 this.logger.info( |                 this.logger.info(this.createMessage(namespace, message, context)); | ||||||
|                     this.createMessage(namespace, message, context) |  | ||||||
|                 ); |  | ||||||
|                 break; |                 break; | ||||||
|             case 'deprecated': |             case 'deprecated': | ||||||
|                 this.logger.warn( |                 this.logger.warn(this.createMessage(namespace, message, context)); | ||||||
|                     this.createMessage(namespace, message, context) |  | ||||||
|                 ); |  | ||||||
|                 break; |                 break; | ||||||
|             default: |             default: | ||||||
|                 switch (context?.level) { |                 switch (context?.level) { | ||||||
|                     case 'info': |                     case 'info': | ||||||
|                         this.logger.info( |                         this.logger.info(this.createMessage(namespace, message, context)); | ||||||
|                             this.createMessage(namespace, message, context) |  | ||||||
|                         ); |  | ||||||
|                         break; |                         break; | ||||||
|                     case 'warning': |                     case 'warning': | ||||||
|                         this.logger.warn(message); |                         this.logger.warn(message); | ||||||
|  | @ -62,11 +48,7 @@ export class MikroOrmLogger extends DefaultLogger { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private createMessage( |     private createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext) { | ||||||
|         namespace: LoggerNamespace, |  | ||||||
|         messageArg: string, |  | ||||||
|         context?: LogContext |  | ||||||
|     ) { |  | ||||||
|         const labels: LokiLabels = { |         const labels: LokiLabels = { | ||||||
|             service: 'ORM', |             service: 'ORM', | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
							
								
								
									
										141
									
								
								backend/src/middleware/auth/auth.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								backend/src/middleware/auth/auth.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,141 @@ | ||||||
|  | import { EnvVars, getEnvVar } from '../../util/envvars.js'; | ||||||
|  | import { expressjwt } from 'express-jwt'; | ||||||
|  | import { JwtPayload } from 'jsonwebtoken'; | ||||||
|  | import jwksClient from 'jwks-rsa'; | ||||||
|  | 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.js'; | ||||||
|  | 
 | ||||||
|  | const JWKS_CACHE = true; | ||||||
|  | const JWKS_RATE_LIMIT = true; | ||||||
|  | const REQUEST_PROPERTY_FOR_JWT_PAYLOAD = 'jwtPayload'; | ||||||
|  | const JWT_ALGORITHM = 'RS256'; // Not configurable via env vars since supporting other algorithms would
 | ||||||
|  | // Require additional libraries to be added.
 | ||||||
|  | 
 | ||||||
|  | const JWT_PROPERTY_NAMES = { | ||||||
|  |     username: 'preferred_username', | ||||||
|  |     firstName: 'given_name', | ||||||
|  |     lastName: 'family_name', | ||||||
|  |     name: 'name', | ||||||
|  |     email: 'email', | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function createJwksClient(uri: string): jwksClient.JwksClient { | ||||||
|  |     return jwksClient({ | ||||||
|  |         cache: JWKS_CACHE, | ||||||
|  |         rateLimit: JWKS_RATE_LIMIT, | ||||||
|  |         jwksUri: uri, | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const idpConfigs = { | ||||||
|  |     student: { | ||||||
|  |         issuer: getEnvVar(EnvVars.IdpStudentUrl), | ||||||
|  |         jwksClient: createJwksClient(getEnvVar(EnvVars.IdpStudentJwksEndpoint)), | ||||||
|  |     }, | ||||||
|  |     teacher: { | ||||||
|  |         issuer: getEnvVar(EnvVars.IdpTeacherUrl), | ||||||
|  |         jwksClient: createJwksClient(getEnvVar(EnvVars.IdpTeacherJwksEndpoint)), | ||||||
|  |     }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Express middleware which verifies the JWT Bearer token if one is given in the request. | ||||||
|  |  */ | ||||||
|  | const verifyJwtToken = expressjwt({ | ||||||
|  |     secret: async (_: express.Request, token: jwt.Jwt | undefined) => { | ||||||
|  |         if (!token?.payload || !(token.payload as JwtPayload).iss) { | ||||||
|  |             throw new Error('Invalid token'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const issuer = (token.payload as JwtPayload).iss; | ||||||
|  | 
 | ||||||
|  |         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); | ||||||
|  |         if (!signingKey) { | ||||||
|  |             throw new Error('Signing key not found.'); | ||||||
|  |         } | ||||||
|  |         return signingKey.getPublicKey(); | ||||||
|  |     }, | ||||||
|  |     audience: getEnvVar(EnvVars.IdpAudience), | ||||||
|  |     algorithms: [JWT_ALGORITHM], | ||||||
|  |     credentialsRequired: false, | ||||||
|  |     requestProperty: REQUEST_PROPERTY_FOR_JWT_PAYLOAD, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Get an object with information about the authenticated user from a given authenticated request. | ||||||
|  |  */ | ||||||
|  | function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined { | ||||||
|  |     if (!req.jwtPayload) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     const issuer = req.jwtPayload.iss; | ||||||
|  |     let accountType: 'student' | 'teacher'; | ||||||
|  | 
 | ||||||
|  |     if (issuer === idpConfigs.student.issuer) { | ||||||
|  |         accountType = 'student'; | ||||||
|  |     } else if (issuer === idpConfigs.teacher.issuer) { | ||||||
|  |         accountType = 'teacher'; | ||||||
|  |     } else { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |         accountType: accountType, | ||||||
|  |         username: req.jwtPayload[JWT_PROPERTY_NAMES.username]!, | ||||||
|  |         name: req.jwtPayload[JWT_PROPERTY_NAMES.name], | ||||||
|  |         firstName: req.jwtPayload[JWT_PROPERTY_NAMES.firstName], | ||||||
|  |         lastName: req.jwtPayload[JWT_PROPERTY_NAMES.lastName], | ||||||
|  |         email: req.jwtPayload[JWT_PROPERTY_NAMES.email], | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 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) => { | ||||||
|  |     req.auth = getAuthenticationInfo(req); | ||||||
|  |     next(); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill | ||||||
|  |  * the given access condition. | ||||||
|  |  * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates | ||||||
|  |  *                        to true. | ||||||
|  |  */ | ||||||
|  | export 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)) { | ||||||
|  |             throw new ForbiddenException(); | ||||||
|  |         } else { | ||||||
|  |             next(); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects all unauthenticated users, but accepts all authenticated users. | ||||||
|  |  */ | ||||||
|  | export const authenticatedOnly = authorize((_) => true); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects requests from unauthenticated users or users that aren't students. | ||||||
|  |  */ | ||||||
|  | export const studentsOnly = authorize((auth) => auth.accountType === 'student'); | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Middleware which rejects requests from unauthenticated users or users that aren't teachers. | ||||||
|  |  */ | ||||||
|  | export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); | ||||||
							
								
								
									
										9
									
								
								backend/src/middleware/auth/authenticated-request.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								backend/src/middleware/auth/authenticated-request.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | import { Request } from 'express'; | ||||||
|  | import { JwtPayload } from 'jsonwebtoken'; | ||||||
|  | import { AuthenticationInfo } from './authentication-info.js'; | ||||||
|  | 
 | ||||||
|  | export interface AuthenticatedRequest extends Request { | ||||||
|  |     // Properties are optional since the user is not necessarily authenticated.
 | ||||||
|  |     jwtPayload?: JwtPayload; | ||||||
|  |     auth?: AuthenticationInfo; | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								backend/src/middleware/auth/authentication-info.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/src/middleware/auth/authentication-info.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | ||||||
|  | /** | ||||||
|  |  * Object with information about the user who is currently logged in. | ||||||
|  |  */ | ||||||
|  | export type AuthenticationInfo = { | ||||||
|  |     accountType: 'student' | 'teacher'; | ||||||
|  |     username: string; | ||||||
|  |     name?: string; | ||||||
|  |     firstName?: string; | ||||||
|  |     lastName?: string; | ||||||
|  |     email?: string; | ||||||
|  | }; | ||||||
							
								
								
									
										7
									
								
								backend/src/middleware/cors.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								backend/src/middleware/cors.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | import cors from 'cors'; | ||||||
|  | import { EnvVars, getEnvVar } from '../util/envvars.js'; | ||||||
|  | 
 | ||||||
|  | export default cors({ | ||||||
|  |     origin: getEnvVar(EnvVars.CorsAllowedOrigins).split(','), | ||||||
|  |     allowedHeaders: getEnvVar(EnvVars.CorsAllowedHeaders).split(','), | ||||||
|  | }); | ||||||
|  | @ -54,9 +54,7 @@ function config(testingMode: boolean = false): Options { | ||||||
| 
 | 
 | ||||||
|             // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
 |             // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
 | ||||||
|             // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
 |             // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
 | ||||||
|             dynamicImportProvider: (id) => { |             dynamicImportProvider: (id) => import(id), | ||||||
|                 return import(id); |  | ||||||
|             }, |  | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -72,9 +70,7 @@ function config(testingMode: boolean = false): Options { | ||||||
| 
 | 
 | ||||||
|         // Logging
 |         // Logging
 | ||||||
|         debug: LOG_LEVEL === 'debug', |         debug: LOG_LEVEL === 'debug', | ||||||
|         loggerFactory: (options: LoggerOptions) => { |         loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options), | ||||||
|             return new MikroOrmLogger(options); |  | ||||||
|         }, |  | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -28,9 +28,7 @@ export async function initORM(testingMode: boolean = false) { | ||||||
| } | } | ||||||
| export function forkEntityManager(): EntityManager { | export function forkEntityManager(): EntityManager { | ||||||
|     if (!orm) { |     if (!orm) { | ||||||
|         throw Error( |         throw Error('Accessing the Entity Manager before the ORM is fully initialized.'); | ||||||
|             'Accessing the Entity Manager before the ORM is fully initialized.' |  | ||||||
|         ); |  | ||||||
|     } |     } | ||||||
|     return orm.em.fork(); |     return orm.em.fork(); | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										23
									
								
								backend/src/routes/auth.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								backend/src/routes/auth.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | import express from 'express'; | ||||||
|  | import { getFrontendAuthConfig } from '../controllers/auth.js'; | ||||||
|  | import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; | ||||||
|  | const router = express.Router(); | ||||||
|  | 
 | ||||||
|  | // Returns auth configuration for frontend
 | ||||||
|  | router.get('/config', (req, res) => { | ||||||
|  |     res.json(getFrontendAuthConfig()); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | router.get('/testAuthenticatedOnly', authenticatedOnly, (req, res) => { | ||||||
|  |     res.json({ message: 'If you see this, you should be authenticated!' }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | router.get('/testStudentsOnly', studentsOnly, (req, res) => { | ||||||
|  |     res.json({ message: 'If you see this, you should be a student!' }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | router.get('/testTeachersOnly', teachersOnly, (req, res) => { | ||||||
|  |     res.json({ message: 'If you see this, you should be a teacher!' }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export default router; | ||||||
|  | @ -1,8 +1,5 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { | import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; | ||||||
|     getAllLearningObjects, |  | ||||||
|     getLearningObject, |  | ||||||
| } from '../controllers/learningObjects.js'; |  | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
|  | @ -24,4 +21,16 @@ router.get('/', getAllLearningObjects); | ||||||
| // Example: http://localhost:3000/learningObject/un_ai7
 | // Example: http://localhost:3000/learningObject/un_ai7
 | ||||||
| router.get('/:hruid', getLearningObject); | router.get('/:hruid', getLearningObject); | ||||||
| 
 | 
 | ||||||
|  | // Parameter: hruid of learning object
 | ||||||
|  | // Query: language, version (optional)
 | ||||||
|  | // Route to fetch the HTML rendering of one learning object based on its hruid.
 | ||||||
|  | // Example: http://localhost:3000/learningObject/un_ai7/html
 | ||||||
|  | router.get('/:hruid/html', getLearningObjectHTML); | ||||||
|  | 
 | ||||||
|  | // Parameter: hruid of learning object, name of attachment.
 | ||||||
|  | // Query: language, version (optional).
 | ||||||
|  | // Route to get the raw data of the attachment for one learning object based on its hruid.
 | ||||||
|  | // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
 | ||||||
|  | router.get('/:hruid/html/:attachmentName', getAttachment); | ||||||
|  | 
 | ||||||
| export default router; | export default router; | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| import express from 'express'; | import express from 'express'; | ||||||
| import { getLearningPaths } from '../controllers/learningPaths.js'; | import { getLearningPaths } from '../controllers/learning-paths.js'; | ||||||
| 
 | 
 | ||||||
| const router = express.Router(); | const router = express.Router(); | ||||||
| 
 | 
 | ||||||
|  | @ -1,14 +0,0 @@ | ||||||
| import express from 'express'; |  | ||||||
| const router = express.Router(); |  | ||||||
| 
 |  | ||||||
| // Returns login paths for IDP
 |  | ||||||
| router.get('/', (req, res) => { |  | ||||||
|     res.json({ |  | ||||||
|         // Dummy variables, needs to be changed
 |  | ||||||
|         // With IDP endpoints
 |  | ||||||
|         leerkracht: '/login-leerkracht', |  | ||||||
|         leerling: '/login-leerling', |  | ||||||
|     }); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| export default router; |  | ||||||
|  | @ -15,8 +15,7 @@ router.get('/:id', (req, res) => { | ||||||
|         student: '0', |         student: '0', | ||||||
|         group: '0', |         group: '0', | ||||||
|         time: new Date(2025, 1, 1), |         time: new Date(2025, 1, 1), | ||||||
|         content: |         content: 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', | ||||||
|             'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', |  | ||||||
|         learningObject: '0', |         learningObject: '0', | ||||||
|         links: { |         links: { | ||||||
|             self: `${req.baseUrl}/${req.params.id}`, |             self: `${req.baseUrl}/${req.params.id}`, | ||||||
|  |  | ||||||
							
								
								
									
										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 ?? []; |  | ||||||
| } |  | ||||||
|  | @ -9,35 +9,34 @@ const logger: Logger = getLogger(); | ||||||
|  * |  * | ||||||
|  * @param url The API endpoint to fetch from. |  * @param url The API endpoint to fetch from. | ||||||
|  * @param description A short description of what is being fetched (for logging). |  * @param description A short description of what is being fetched (for logging). | ||||||
|  * @param params |  * @param options Contains further options such as params (the query params) and responseType (whether the response | ||||||
|  |  *                should be parsed as JSON ("json") or whether it should be returned as plain text ("text") | ||||||
|  * @returns The response data if successful, or null if an error occurs. |  * @returns The response data if successful, or null if an error occurs. | ||||||
|  */ |  */ | ||||||
| export async function fetchWithLogging<T>( | export async function fetchWithLogging<T>( | ||||||
|     url: string, |     url: string, | ||||||
|     description: string, |     description: string, | ||||||
|     params?: Record<string, any> |     options?: { | ||||||
|  |         params?: Record<string, any>; | ||||||
|  |         query?: Record<string, any>; | ||||||
|  |         responseType?: 'json' | 'text'; | ||||||
|  |     } | ||||||
| ): Promise<T | null> { | ): Promise<T | null> { | ||||||
|     try { |     try { | ||||||
|         const config: AxiosRequestConfig = params ? { params } : {}; |         const config: AxiosRequestConfig = options || {}; | ||||||
| 
 |  | ||||||
|         const response = await axios.get<T>(url, config); |         const response = await axios.get<T>(url, config); | ||||||
|         return response.data; |         return response.data; | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|         if (error.response) { |         if (error.response) { | ||||||
|             if (error.response.status === 404) { |             if (error.response.status === 404) { | ||||||
|                 logger.debug( |                 logger.debug(`❌ ERROR: ${description} not found (404) at "${url}".`); | ||||||
|                     `❌ ERROR: ${description} not found (404) at "${url}".` |  | ||||||
|                 ); |  | ||||||
|             } else { |             } else { | ||||||
|                 logger.debug( |                 logger.debug( | ||||||
|                     `❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")` |                     `❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")` | ||||||
|                 ); |                 ); | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
|             logger.debug( |             logger.debug(`❌ ERROR: Network or unexpected error when fetching ${description}:`, error.message); | ||||||
|                 `❌ ERROR: Network or unexpected error when fetching ${description}:`, |  | ||||||
|                 error.message |  | ||||||
|             ); |  | ||||||
|         } |         } | ||||||
|         return null; |         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()!); | ||||||
|  | } | ||||||
|  | @ -1,5 +1,9 @@ | ||||||
| const PREFIX = 'DWENGO_'; | const PREFIX = 'DWENGO_'; | ||||||
| const DB_PREFIX = PREFIX + 'DB_'; | const DB_PREFIX = PREFIX + 'DB_'; | ||||||
|  | const IDP_PREFIX = PREFIX + 'AUTH_'; | ||||||
|  | const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_'; | ||||||
|  | const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_'; | ||||||
|  | const CORS_PREFIX = PREFIX + 'CORS_'; | ||||||
| 
 | 
 | ||||||
| type EnvVar = { key: string; required?: boolean; defaultValue?: any }; | type EnvVar = { key: string; required?: boolean; defaultValue?: any }; | ||||||
| 
 | 
 | ||||||
|  | @ -11,6 +15,18 @@ export const EnvVars: { [key: string]: EnvVar } = { | ||||||
|     DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, |     DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, | ||||||
|     DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, |     DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, | ||||||
|     DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, |     DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, | ||||||
|  |     LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' }, | ||||||
|  |     FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' }, | ||||||
|  |     UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' }, | ||||||
|  |     IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, | ||||||
|  |     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 }, | ||||||
|  |     IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' }, | ||||||
|  |     CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' }, | ||||||
|  |     CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' }, | ||||||
| } as const; | } as const; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | @ -36,9 +52,7 @@ export function getNumericEnvVar(envVar: EnvVar): number { | ||||||
|     const valueString = getEnvVar(envVar); |     const valueString = getEnvVar(envVar); | ||||||
|     const value = parseInt(valueString); |     const value = parseInt(valueString); | ||||||
|     if (isNaN(value)) { |     if (isNaN(value)) { | ||||||
|         throw new Error( |         throw new Error(`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`); | ||||||
|             `Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.` |  | ||||||
|         ); |  | ||||||
|     } else { |     } else { | ||||||
|         return value; |         return value; | ||||||
|     } |     } | ||||||
|  |  | ||||||
							
								
								
									
										26
									
								
								backend/src/util/links.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/util/links.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | ||||||
|  | import { LearningObjectIdentifier } from '../interfaces/learning-content'; | ||||||
|  | 
 | ||||||
|  | export function isValidHttpUrl(url: string): boolean { | ||||||
|  |     try { | ||||||
|  |         const parsedUrl = new URL(url, 'http://test.be'); | ||||||
|  |         return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; | ||||||
|  |     } catch (e) { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier) { | ||||||
|  |     let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`; | ||||||
|  |     if (learningObjectId.version) { | ||||||
|  |         url += `&version=${learningObjectId.version}`; | ||||||
|  |     } | ||||||
|  |     return url; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string { | ||||||
|  |     let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; | ||||||
|  |     if (learningObjectIdentifier.version) { | ||||||
|  |         url += `&version=${learningObjectIdentifier.version}`; | ||||||
|  |     } | ||||||
|  |     return url; | ||||||
|  | } | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import fs from 'fs'; | import fs from 'fs'; | ||||||
| import path from 'path'; | import path from 'path'; | ||||||
| import yaml from 'js-yaml'; | import yaml from 'js-yaml'; | ||||||
| import { FALLBACK_LANG } from '../../config.js'; | import { FALLBACK_LANG } from '../config.js'; | ||||||
| import { getLogger, Logger } from '../logging/initalize.js'; | import { getLogger, Logger } from '../logging/initalize.js'; | ||||||
| 
 | 
 | ||||||
| const logger: Logger = getLogger(); | const logger: Logger = getLogger(); | ||||||
|  | @ -12,15 +12,8 @@ export function loadTranslations<T>(language: string): T { | ||||||
|         const yamlFile = fs.readFileSync(filePath, 'utf8'); |         const yamlFile = fs.readFileSync(filePath, 'utf8'); | ||||||
|         return yaml.load(yamlFile) as T; |         return yaml.load(yamlFile) as T; | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|         logger.warn( |         logger.warn(`Cannot load translation for ${language}, fallen back to dutch`, error); | ||||||
|             `Cannot load translation for ${language}, fallen back to dutch`, |         const fallbackPath = path.join(process.cwd(), '_i18n', `${FALLBACK_LANG}.yml`); | ||||||
|             error |  | ||||||
|         ); |  | ||||||
|         const fallbackPath = path.join( |  | ||||||
|             process.cwd(), |  | ||||||
|             '_i18n', |  | ||||||
|             `${FALLBACK_LANG}.yml` |  | ||||||
|         ); |  | ||||||
|         return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T; |         return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show more
		Reference in a new issue