Merge branch 'dev' into docs/swagger-autogen
This commit is contained in:
commit
5986ca57bf
189 changed files with 6160 additions and 1581 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
@ -0,0 +1,7 @@
|
|||
**/node_modules/
|
||||
**/dist
|
||||
.git
|
||||
npm-debug.log
|
||||
.coverage
|
||||
.coverage.*
|
||||
.env
|
12
.github/ISSUE_TEMPLATE/bug-report.md
vendored
12
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
@ -24,6 +24,14 @@ Een duidelijke, beknopte beschrijving van wat je verwacht dat er gebeurt.
|
|||
Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt.
|
||||
|
||||
**Extra context**
|
||||
Voeg extra context over het probleem toe. Was je ergens bijzonder mee bezig of naar op zoek?
|
||||
Was je ergens bijzonder mee bezig of naar op zoek? Welke documentatie of links heb je (al) geraadpleegd?
|
||||
|
||||
- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
|
||||
<!--
|
||||
## Richtlijnen
|
||||
|
||||
Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt:
|
||||
|
||||
- Het is duidelijk waar de bug vandaan komt.
|
||||
- Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
|
||||
- Ik heb de issue toegekend aan de juiste milestone.
|
||||
-->
|
||||
|
|
11
.github/ISSUE_TEMPLATE/feature-request.md
vendored
11
.github/ISSUE_TEMPLATE/feature-request.md
vendored
|
@ -6,7 +6,7 @@ labels: enhancement
|
|||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
**Is jouw feature request gerelateerd tot een probleem? Beschrijf.**
|
||||
Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter?
|
||||
|
||||
**Beschrijf de oplossing die je zou willen**
|
||||
|
@ -15,4 +15,11 @@ Een duidelijke, beknopte beschrijving van wat je zou willen dat er gebeurt.
|
|||
**Extra context**
|
||||
Extra context of screenshots bij de feature.
|
||||
|
||||
- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
|
||||
<!--
|
||||
## Richtlijnen
|
||||
|
||||
Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt:
|
||||
|
||||
- Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
|
||||
- Ik heb de issue toegekend aan de juiste milestone.
|
||||
-->
|
||||
|
|
57
README.md
57
README.md
|
@ -9,34 +9,43 @@ Figma</a></span>
|
|||
Projectopgave</a></span>
|
||||
</p>
|
||||
|
||||
<ul align="center" style="list-style-type: none">
|
||||
<li>Projectleider: Fransisco Van Langenhove (<a href="https://github.com/Gabriellvl">@Gabriellvl</a>)</li>
|
||||
<li>Technische lead: Tibo De Peuter (<a href="https://github.com/tdpeuter">@tdpeuter</a>)</li>
|
||||
<li>Systeembeheerder: Timo De Meyst (<a href="https://github.com/kloep1">@kloep1</a>)</li>
|
||||
<li>Customer relations officer: Adriaan Jacquet (<a href="https://github.com/WhisperinCheetah">@WhisperinCheetah</a>)</li>
|
||||
</ul>
|
||||
|
||||
Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten
|
||||
en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen.
|
||||
|
||||
## Installatie
|
||||
|
||||
Om de applicatie in te stellen voor een productieomgeving, volg
|
||||
de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving).
|
||||
|
||||
Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien.
|
||||
|
||||
### Quick start
|
||||
|
||||
1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)).
|
||||
1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/)
|
||||
en [Docker Compose](https://docs.docker.com/compose/)).
|
||||
2. Clone deze repository.
|
||||
3. Voer `docker compose up` uit in de root van de repository.
|
||||
3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar
|
||||
nodig.
|
||||
4. Voer `docker compose up` uit in de root van de repository.
|
||||
5. Optioneel: Configureer de applicatie aan de hand van
|
||||
de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie).
|
||||
|
||||
```bash
|
||||
docker compose version
|
||||
git clone https://github.com/SELab-2/Dwengo-1.git
|
||||
cd Dwengo-1
|
||||
cd Dwengo-1/backend
|
||||
cp .env.example .env
|
||||
# Pas .env aan
|
||||
nano .env
|
||||
cd ..
|
||||
docker compose up
|
||||
# Configureer de applicatie
|
||||
```
|
||||
|
||||
### Handmatige installatie
|
||||
|
||||
Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) en [backend](./backend/README.md).
|
||||
Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md)
|
||||
en [backend](./backend/README.md).
|
||||
|
||||
## Architectuur
|
||||
|
||||
|
@ -46,9 +55,33 @@ De tech-stack bestaat uit:
|
|||
|
||||
- **Frontend**: TypeScript + Vue.js + Vuetify
|
||||
- **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL
|
||||
- **Identity provider**: Keycloak
|
||||
|
||||
Voor meer informatie over de keuze van deze tech-stack, zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Design-keuzes).
|
||||
Voor meer informatie over de keuze van deze tech-stack,
|
||||
zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes).
|
||||
|
||||
## Testen
|
||||
|
||||
Voer volgende commando's uit om de <frontend/backend> te testen:
|
||||
|
||||
```
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
## Bijdragen aan Dwengo-1
|
||||
|
||||
Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1.
|
||||
|
||||
Deze rocksterren hebben bijgedragen aan Dwengo-1:
|
||||
|
||||
| Naam | Functie |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| [<img src="https://github.com/WhisperinCheetah.png" width="100px"/><br/><sub><b>Adriaan Jacquet</b></sub>](https://github.com/WhisperinCheetah) | Backend Lead |
|
||||
| [<img src="https://github.com/Gabriellvl.png" width="100px"/><br/><sub><b>Francisco Gabriel Van Langenhove</b></sub>](https://github.com/Gabriellvl) | Team Lead |
|
||||
| [<img src="https://github.com/geraldschmittinger.png" width="100px"/><br/><sub><b>Gerald Schmittinger</b></sub>](https://github.com/geraldschmittinger) | Database Administrator |
|
||||
| [<img src="https://github.com/joyelle436.png" width="100px"/><br/><sub><b>Joyelle Ndagijimana</b></sub>](https://github.com/joyelle436) | Frontend Lead |
|
||||
| [<img src="https://github.com/laurejablonski.png" width="100px"/><br><sub><b>Laure Jablonski</b></sub>](https://github.com/laurejablonski) | Documentatie- en Test Lead |
|
||||
| [<img src="https://github.com/tdpeuter.png" width="100px"/><br/><sub><b>Tibo De Peuter</b></sub>](https://github.com/tdpeuter) | Technische Lead |
|
||||
| [<img src="https://github.com/kloep1.png" width="100px"/><br/><sub><b>Timo De Meyst</b></sub>](https://github.com/kloep1) | System Administrator |
|
||||
|
||||
En in de toekomst misschien jij ook?
|
||||
|
|
BIN
assets/img/keycloak.png
Normal file
BIN
assets/img/keycloak.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
|
@ -1,10 +1,16 @@
|
|||
DWENGO_PORT=3000
|
||||
#
|
||||
# Basic configuration
|
||||
#
|
||||
|
||||
DWENGO_PORT=3000 # The port the backend will listen on
|
||||
DWENGO_DB_HOST=localhost
|
||||
DWENGO_DB_PORT=5431
|
||||
DWENGO_DB_USERNAME=postgres
|
||||
DWENGO_DB_PASSWORD=postgres
|
||||
DWENGO_DB_UPDATE=true
|
||||
|
||||
# Auth
|
||||
|
||||
DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student
|
||||
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
|
||||
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs
|
||||
|
@ -14,3 +20,9 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/
|
|||
|
||||
# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production!
|
||||
DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||
|
||||
#
|
||||
# Advanced configuration
|
||||
#
|
||||
|
||||
# LOKI_HOST=http://localhost:9001 # The address of the Loki instance, used for logging
|
||||
|
|
|
@ -2,10 +2,9 @@
|
|||
# Basic configuration
|
||||
#
|
||||
|
||||
# The port the backend will listen on
|
||||
DWENGO_PORT=3000
|
||||
DWENGO_PORT=3000 # The port the backend will listen on
|
||||
DWENGO_DB_HOST=domain-or-ip-of-database
|
||||
DWENGO_DB_PORT=5432
|
||||
DWENGO_DB_PORT=5431
|
||||
|
||||
# Change this to the actual credentials of the user Dwengo should use in the backend
|
||||
DWENGO_DB_USERNAME=postgres
|
||||
|
@ -24,9 +23,5 @@ DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher
|
|||
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
|
||||
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs
|
||||
|
||||
#
|
||||
# Advanced configuration
|
||||
#
|
||||
|
||||
# The address of the Lokiinstance, used for logging
|
||||
# LOKI_HOST=http://localhost:3102
|
||||
|
|
28
backend/.env.production.example
Normal file
28
backend/.env.production.example
Normal file
|
@ -0,0 +1,28 @@
|
|||
DWENGO_PORT=3000 # The port the backend will listen on
|
||||
DWENGO_DB_HOST=db # Name of the database container
|
||||
DWENGO_DB_PORT=5431
|
||||
|
||||
# Change this to the actual credentials of the user Dwengo should use in the backend
|
||||
DWENGO_DB_NAME=postgres
|
||||
DWENGO_DB_USERNAME=postgres
|
||||
DWENGO_DB_PASSWORD=postgres
|
||||
|
||||
# Set this to true when the database scheme needs to be updated. In that case, take a backup first.
|
||||
DWENGO_DB_UPDATE=false
|
||||
|
||||
# Data for the identity provider via which the students authenticate.
|
||||
DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student
|
||||
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
|
||||
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container
|
||||
# Data for the identity provider via which the teachers authenticate.
|
||||
DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher
|
||||
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
|
||||
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container
|
||||
|
||||
#
|
||||
# Advanced configuration
|
||||
#
|
||||
|
||||
# Logging and monitoring
|
||||
|
||||
# LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging
|
35
backend/Dockerfile
Normal file
35
backend/Dockerfile
Normal file
|
@ -0,0 +1,35 @@
|
|||
FROM node:22 AS build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
|
||||
COPY package*.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
|
||||
RUN npm install --silent
|
||||
|
||||
# Build the backend
|
||||
|
||||
# Root tsconfig.json
|
||||
COPY tsconfig.json ./
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
COPY backend ./
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22 AS production-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package-lock.json backend/package.json ./
|
||||
|
||||
RUN npm install --silent --only=production
|
||||
|
||||
COPY --from=build-stage /app/backend/dist ./dist/
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "--env-file=.env", "dist/app.js"]
|
|
@ -20,3 +20,18 @@ npm run dev
|
|||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
Voer volgend commando uit om de unit tests uit te voeren:
|
||||
|
||||
```
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
## Keycloak configuratie
|
||||
|
||||
Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt.
|
||||
|
||||
Voor productie is het ten sterkste aangeraden om keycloak manueel te configureren.
|
||||
Voor meer informatie, zie de [administrator-handleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#installatie-en-server-configuratie).
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
// Can be placed in dotenv but found it redundant
|
||||
|
||||
// Import dotenv from "dotenv";
|
||||
|
||||
// Load .env file
|
||||
// Dotenv.config();
|
||||
|
||||
export const DWENGO_API_BASE = 'https://dwengo.org/backend/api';
|
||||
|
||||
export const FALLBACK_LANG = 'nl';
|
|
@ -8,4 +8,14 @@ export default [
|
|||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['tests/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -5,27 +5,34 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production tsc --project tsconfig.json",
|
||||
"dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts",
|
||||
"start": "NODE_ENV=production node --env-file=.env dist/app.js",
|
||||
"build": "cross-env NODE_ENV=production tsc --project tsconfig.json",
|
||||
"dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts",
|
||||
"start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js",
|
||||
"format": "prettier --write src/",
|
||||
"format-check": "prettier --check src/",
|
||||
"lint": "eslint . --fix",
|
||||
"test:unit": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mikro-orm/core": "6.4.6",
|
||||
"@mikro-orm/postgresql": "6.4.6",
|
||||
"@mikro-orm/reflection": "6.4.6",
|
||||
"@mikro-orm/sqlite": "6.4.6",
|
||||
"axios": "^1.8.1",
|
||||
"@mikro-orm/core": "6.4.9",
|
||||
"@mikro-orm/knex": "6.4.9",
|
||||
"@mikro-orm/postgresql": "6.4.9",
|
||||
"@mikro-orm/reflection": "6.4.9",
|
||||
"@mikro-orm/sqlite": "6.4.9",
|
||||
"axios": "^1.8.2",
|
||||
"cors": "^2.8.5",
|
||||
"cross": "^1.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.0.1",
|
||||
"express-jwt": "^8.5.1",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"gift-pegjs": "^1.0.2",
|
||||
"isomorphic-dompurify": "^2.22.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"loki-logger-ts": "^1.0.2",
|
||||
"marked": "^15.0.7",
|
||||
"response-time": "^2.3.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^11.1.0",
|
||||
|
@ -33,13 +40,13 @@
|
|||
"winston-loki": "^6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@mikro-orm/cli": "^6.4.6",
|
||||
"@mikro-orm/cli": "6.4.9",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/response-time": "^2.3.8",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"globals": "^15.15.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.3",
|
||||
|
|
|
@ -1,25 +1,12 @@
|
|||
import express, { Express, Response } from 'express';
|
||||
import express, { Express } from 'express';
|
||||
import { initORM } from './orm.js';
|
||||
|
||||
import themeRoutes from './routes/themes.js';
|
||||
import learningPathRoutes from './routes/learningPaths.js';
|
||||
import learningObjectRoutes from './routes/learningObjects.js';
|
||||
|
||||
import studentRouter from './routes/student.js';
|
||||
import groupRouter from './routes/group.js';
|
||||
import assignmentRouter from './routes/assignment.js';
|
||||
import submissionRouter from './routes/submission.js';
|
||||
import classRouter from './routes/class.js';
|
||||
import questionRouter from './routes/question.js';
|
||||
import authRouter from './routes/auth.js';
|
||||
import { authenticateUser } from './middleware/auth/auth.js';
|
||||
import cors from './middleware/cors.js';
|
||||
import { getLogger, Logger } from './logging/initalize.js';
|
||||
import { responseTimeLogger } from './logging/responseTimeLogger.js';
|
||||
import responseTime from 'response-time';
|
||||
import { EnvVars, getNumericEnvVar } from './util/envvars.js';
|
||||
import swaggerMiddleware from './swagger';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import apiRouter from './routes/router.js';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
|
@ -32,41 +19,13 @@ app.use(authenticateUser);
|
|||
// Add response time logging
|
||||
app.use(responseTime(responseTimeLogger));
|
||||
|
||||
// TODO Replace with Express routes
|
||||
app.get('/', (_, res: Response) => {
|
||||
logger.debug('GET /');
|
||||
res.json({
|
||||
message: 'Hello Dwengo!🚀',
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
|
||||
app.use('/group', groupRouter /* #swagger.tags = ['Group'] */);
|
||||
app.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */);
|
||||
app.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */);
|
||||
app.use('/class', classRouter /* #swagger.tags = ['Class'] */);
|
||||
app.use('/question', questionRouter /* #swagger.tags = ['Question'] */);
|
||||
app.use('/auth', authRouter /* #swagger.tags = ['Auth'] */);
|
||||
app.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */);
|
||||
|
||||
app.use(
|
||||
'/learningPath',
|
||||
learningPathRoutes /* #swagger.tags = ['Learning Path'] */
|
||||
);
|
||||
app.use(
|
||||
'/learningObject',
|
||||
learningObjectRoutes /* #swagger.tags = ['Learning Object'] */
|
||||
);
|
||||
|
||||
// Swagger UI for API documentation
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerMiddleware);
|
||||
app.get('/api', apiRouter);
|
||||
|
||||
async function startServer() {
|
||||
await initORM();
|
||||
|
||||
app.listen(port, () => {
|
||||
logger.info(`Server is running at http://localhost:${port}`);
|
||||
logger.info(`Server is running at http://localhost:${port}/api`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
export const FALLBACK_LANG: string = 'nl';
|
||||
import { EnvVars, getEnvVar } from './util/envvars.js';
|
||||
|
||||
// API
|
||||
|
||||
export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api';
|
||||
export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl);
|
||||
export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage);
|
||||
|
||||
// Logging
|
||||
|
||||
export const LOG_LEVEL: string =
|
||||
'development' === process.env.NODE_ENV ? 'debug' : 'info';
|
||||
export const LOKI_HOST: string =
|
||||
process.env.LOKI_HOST || 'http://localhost:3102';
|
||||
export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info';
|
||||
export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102';
|
||||
|
|
69
backend/src/controllers/learning-objects.ts
Normal file
69
backend/src/controllers/learning-objects.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { FALLBACK_LANG } from '../config.js';
|
||||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js';
|
||||
import learningObjectService from '../services/learning-objects/learning-object-service.js';
|
||||
import { EnvVars, getEnvVar } from '../util/envvars.js';
|
||||
import { Language } from '../entities/content/language.js';
|
||||
import { BadRequestException } from '../exceptions.js';
|
||||
import attachmentService from '../services/learning-objects/attachment-service.js';
|
||||
import { NotFoundError } from '@mikro-orm/core';
|
||||
|
||||
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier {
|
||||
if (!req.params.hruid) {
|
||||
throw new BadRequestException('HRUID is required.');
|
||||
}
|
||||
return {
|
||||
hruid: req.params.hruid as string,
|
||||
language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language,
|
||||
version: parseInt(req.query.version as string),
|
||||
};
|
||||
}
|
||||
|
||||
function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier {
|
||||
if (!req.query.hruid) {
|
||||
throw new BadRequestException('HRUID is required.');
|
||||
}
|
||||
return {
|
||||
hruid: req.params.hruid as string,
|
||||
language: (req.query.language as Language) || FALLBACK_LANG,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
|
||||
const learningPathId = getLearningPathIdentifierFromRequest(req);
|
||||
const full = req.query.full;
|
||||
|
||||
let learningObjects: FilteredLearningObject[] | string[];
|
||||
if (full) {
|
||||
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
|
||||
} else {
|
||||
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId);
|
||||
}
|
||||
|
||||
res.json(learningObjects);
|
||||
}
|
||||
|
||||
export async function getLearningObject(req: Request, res: Response): Promise<void> {
|
||||
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
|
||||
|
||||
const learningObject = await learningObjectService.getLearningObjectById(learningObjectId);
|
||||
res.json(learningObject);
|
||||
}
|
||||
|
||||
export async function getLearningObjectHTML(req: Request, res: Response): Promise<void> {
|
||||
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
|
||||
|
||||
const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId);
|
||||
res.send(learningObject);
|
||||
}
|
||||
|
||||
export async function getAttachment(req: Request, res: Response): Promise<void> {
|
||||
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
|
||||
const name = req.params.attachmentName;
|
||||
const attachment = await attachmentService.getAttachment(learningObjectId, name);
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundError(`Attachment ${name} not found`);
|
||||
}
|
||||
res.setHeader('Content-Type', attachment.mimeType).send(attachment.content);
|
||||
}
|
64
backend/src/controllers/learning-paths.ts
Normal file
64
backend/src/controllers/learning-paths.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { themes } from '../data/themes.js';
|
||||
import { FALLBACK_LANG } from '../config.js';
|
||||
import learningPathService from '../services/learning-paths/learning-path-service.js';
|
||||
import { BadRequestException, NotFoundException } from '../exceptions.js';
|
||||
import { Language } from '../entities/content/language.js';
|
||||
import {
|
||||
PersonalizationTarget,
|
||||
personalizedForGroup,
|
||||
personalizedForStudent,
|
||||
} from '../services/learning-paths/learning-path-personalization-util.js';
|
||||
|
||||
/**
|
||||
* Fetch learning paths based on query parameters.
|
||||
*/
|
||||
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
|
||||
const hruids = req.query.hruid;
|
||||
const themeKey = req.query.theme as string;
|
||||
const searchQuery = req.query.search as string;
|
||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
||||
|
||||
const forStudent = req.query.forStudent as string;
|
||||
const forGroupNo = req.query.forGroup as string;
|
||||
const assignmentNo = req.query.assignmentNo as string;
|
||||
const classId = req.query.classId as string;
|
||||
|
||||
let personalizationTarget: PersonalizationTarget | undefined;
|
||||
|
||||
if (forStudent) {
|
||||
personalizationTarget = await personalizedForStudent(forStudent);
|
||||
} else if (forGroupNo) {
|
||||
if (!assignmentNo || !classId) {
|
||||
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
|
||||
}
|
||||
personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo));
|
||||
}
|
||||
|
||||
let hruidList;
|
||||
|
||||
if (hruids) {
|
||||
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
|
||||
} else if (themeKey) {
|
||||
const theme = themes.find((t) => t.title === themeKey);
|
||||
if (theme) {
|
||||
hruidList = theme.hruids;
|
||||
} else {
|
||||
throw new NotFoundException(`Theme "${themeKey}" not found.`);
|
||||
}
|
||||
} else if (searchQuery) {
|
||||
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget);
|
||||
res.json(searchResults);
|
||||
return;
|
||||
} else {
|
||||
hruidList = themes.flatMap((theme) => theme.hruids);
|
||||
}
|
||||
|
||||
const learningPaths = await learningPathService.fetchLearningPaths(
|
||||
hruidList,
|
||||
language as Language,
|
||||
`HRUIDs: ${hruidList.join(', ')}`,
|
||||
personalizationTarget
|
||||
);
|
||||
res.json(learningPaths.data);
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getLearningObjectById,
|
||||
getLearningObjectIdsFromPath,
|
||||
getLearningObjectsFromPath,
|
||||
} from '../services/learningObjects.js';
|
||||
import { FALLBACK_LANG } from '../config.js';
|
||||
import { FilteredLearningObject } from '../interfaces/learningPath.js';
|
||||
import { getLogger } from '../logging/initalize.js';
|
||||
|
||||
export async function getAllLearningObjects(
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const hruid = req.query.hruid as string;
|
||||
const full = req.query.full === 'true';
|
||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
||||
|
||||
if (!hruid) {
|
||||
res.status(400).json({ error: 'HRUID query is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let learningObjects: FilteredLearningObject[] | string[];
|
||||
if (full) {
|
||||
learningObjects = await getLearningObjectsFromPath(hruid, language);
|
||||
} else {
|
||||
learningObjects = await getLearningObjectIdsFromPath(
|
||||
hruid,
|
||||
language
|
||||
);
|
||||
}
|
||||
|
||||
res.json(learningObjects);
|
||||
} catch (error) {
|
||||
getLogger().error('Error fetching learning objects:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLearningObject(
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { hruid } = req.params;
|
||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
||||
|
||||
if (!hruid) {
|
||||
res.status(400).json({ error: 'HRUID parameter is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const learningObject = await getLearningObjectById(hruid, language);
|
||||
res.json(learningObject);
|
||||
} catch (error) {
|
||||
getLogger().error('Error fetching learning object:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
|
@ -1,34 +1,25 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { themes } from '../data/themes.js';
|
||||
import { FALLBACK_LANG } from '../config.js';
|
||||
import {
|
||||
fetchLearningPaths,
|
||||
searchLearningPaths,
|
||||
} from '../services/learningPaths.js';
|
||||
import { getLogger } from '../logging/initalize.js';
|
||||
import learningPathService from '../services/learning-paths/learning-path-service.js';
|
||||
import { Language } from '../entities/content/language.js';
|
||||
/**
|
||||
* Fetch learning paths based on query parameters.
|
||||
*/
|
||||
export async function getLearningPaths(
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const hruids = req.query.hruid;
|
||||
const themeKey = req.query.theme as string;
|
||||
const searchQuery = req.query.search as string;
|
||||
const language = (req.query.language as string) || FALLBACK_LANG;
|
||||
const language = (req.query.language as Language) || FALLBACK_LANG;
|
||||
|
||||
let hruidList;
|
||||
|
||||
if (hruids) {
|
||||
hruidList = Array.isArray(hruids)
|
||||
? hruids.map(String)
|
||||
: [String(hruids)];
|
||||
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
|
||||
} else if (themeKey) {
|
||||
const theme = themes.find((t) => {
|
||||
return t.title === themeKey;
|
||||
});
|
||||
const theme = themes.find((t) => t.title === themeKey);
|
||||
if (theme) {
|
||||
hruidList = theme.hruids;
|
||||
} else {
|
||||
|
@ -38,29 +29,17 @@ export async function getLearningPaths(
|
|||
return;
|
||||
}
|
||||
} else if (searchQuery) {
|
||||
const searchResults = await searchLearningPaths(
|
||||
searchQuery,
|
||||
language
|
||||
);
|
||||
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language);
|
||||
res.json(searchResults);
|
||||
return;
|
||||
} else {
|
||||
hruidList = themes.flatMap((theme) => {
|
||||
return theme.hruids;
|
||||
});
|
||||
hruidList = themes.flatMap((theme) => theme.hruids);
|
||||
}
|
||||
|
||||
const learningPaths = await fetchLearningPaths(
|
||||
hruidList,
|
||||
language,
|
||||
`HRUIDs: ${hruidList.join(', ')}`
|
||||
);
|
||||
const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`);
|
||||
res.json(learningPaths.data);
|
||||
} catch (error) {
|
||||
getLogger().error(
|
||||
'❌ Unexpected error fetching learning paths:',
|
||||
error
|
||||
);
|
||||
getLogger().error('❌ Unexpected error fetching learning paths:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,24 +11,19 @@ interface Translations {
|
|||
export function getThemes(req: Request, res: Response) {
|
||||
const language = (req.query.language as string)?.toLowerCase() || 'nl';
|
||||
const translations = loadTranslations<Translations>(language);
|
||||
const themeList = themes.map((theme) => {
|
||||
return {
|
||||
key: theme.title,
|
||||
title:
|
||||
translations.curricula_page[theme.title]?.title || theme.title,
|
||||
description: translations.curricula_page[theme.title]?.description,
|
||||
image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`,
|
||||
};
|
||||
});
|
||||
const themeList = themes.map((theme) => ({
|
||||
key: theme.title,
|
||||
title: translations.curricula_page[theme.title]?.title || theme.title,
|
||||
description: translations.curricula_page[theme.title]?.description,
|
||||
image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`,
|
||||
}));
|
||||
|
||||
res.json(themeList);
|
||||
}
|
||||
|
||||
export function getThemeByTitle(req: Request, res: Response) {
|
||||
const themeKey = req.params.theme;
|
||||
const theme = themes.find((t) => {
|
||||
return t.title === themeKey;
|
||||
});
|
||||
const theme = themes.find((t) => t.title === themeKey);
|
||||
|
||||
if (theme) {
|
||||
res.json(theme.hruids);
|
||||
|
|
|
@ -3,10 +3,7 @@ import { Assignment } from '../../entities/assignments/assignment.entity.js';
|
|||
import { Class } from '../../entities/classes/class.entity.js';
|
||||
|
||||
export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
|
||||
public findByClassAndId(
|
||||
within: Class,
|
||||
id: number
|
||||
): Promise<Assignment | null> {
|
||||
public findByClassAndId(within: Class, id: number): Promise<Assignment | null> {
|
||||
return this.findOne({ within: within, id: id });
|
||||
}
|
||||
public findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {
|
||||
|
|
|
@ -3,24 +3,16 @@ import { Group } from '../../entities/assignments/group.entity.js';
|
|||
import { Assignment } from '../../entities/assignments/assignment.entity.js';
|
||||
|
||||
export class GroupRepository extends DwengoEntityRepository<Group> {
|
||||
public findByAssignmentAndGroupNumber(
|
||||
assignment: Assignment,
|
||||
groupNumber: number
|
||||
): Promise<Group | null> {
|
||||
public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise<Group | null> {
|
||||
return this.findOne({
|
||||
assignment: assignment,
|
||||
groupNumber: groupNumber,
|
||||
});
|
||||
}
|
||||
public findAllGroupsForAssignment(
|
||||
assignment: Assignment
|
||||
): Promise<Group[]> {
|
||||
public findAllGroupsForAssignment(assignment: Assignment): Promise<Group[]> {
|
||||
return this.findAll({ where: { assignment: assignment } });
|
||||
}
|
||||
public deleteByAssignmentAndGroupNumber(
|
||||
assignment: Assignment,
|
||||
groupNumber: number
|
||||
) {
|
||||
public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) {
|
||||
return this.deleteWhere({
|
||||
assignment: assignment,
|
||||
groupNumber: groupNumber,
|
||||
|
|
|
@ -5,10 +5,7 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object
|
|||
import { Student } from '../../entities/users/student.entity.js';
|
||||
|
||||
export class SubmissionRepository extends DwengoEntityRepository<Submission> {
|
||||
public findSubmissionByLearningObjectAndSubmissionNumber(
|
||||
loId: LearningObjectIdentifier,
|
||||
submissionNumber: number
|
||||
): Promise<Submission | null> {
|
||||
public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<Submission | null> {
|
||||
return this.findOne({
|
||||
learningObjectHruid: loId.hruid,
|
||||
learningObjectLanguage: loId.language,
|
||||
|
@ -17,10 +14,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
|
|||
});
|
||||
}
|
||||
|
||||
public findMostRecentSubmissionForStudent(
|
||||
loId: LearningObjectIdentifier,
|
||||
submitter: Student
|
||||
): Promise<Submission | null> {
|
||||
public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise<Submission | null> {
|
||||
return this.findOne(
|
||||
{
|
||||
learningObjectHruid: loId.hruid,
|
||||
|
@ -32,10 +26,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
|
|||
);
|
||||
}
|
||||
|
||||
public findMostRecentSubmissionForGroup(
|
||||
loId: LearningObjectIdentifier,
|
||||
group: Group
|
||||
): Promise<Submission | null> {
|
||||
public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission | null> {
|
||||
return this.findOne(
|
||||
{
|
||||
learningObjectHruid: loId.hruid,
|
||||
|
@ -47,10 +38,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
|
|||
);
|
||||
}
|
||||
|
||||
public deleteSubmissionByLearningObjectAndSubmissionNumber(
|
||||
loId: LearningObjectIdentifier,
|
||||
submissionNumber: number
|
||||
): Promise<void> {
|
||||
public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<void> {
|
||||
return this.deleteWhere({
|
||||
learningObjectHruid: loId.hruid,
|
||||
learningObjectLanguage: loId.language,
|
||||
|
|
|
@ -4,24 +4,16 @@ import { TeacherInvitation } from '../../entities/classes/teacher-invitation.ent
|
|||
import { Teacher } from '../../entities/users/teacher.entity.js';
|
||||
|
||||
export class TeacherInvitationRepository extends DwengoEntityRepository<TeacherInvitation> {
|
||||
public findAllInvitationsForClass(
|
||||
clazz: Class
|
||||
): Promise<TeacherInvitation[]> {
|
||||
public findAllInvitationsForClass(clazz: Class): Promise<TeacherInvitation[]> {
|
||||
return this.findAll({ where: { class: clazz } });
|
||||
}
|
||||
public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> {
|
||||
return this.findAll({ where: { sender: sender } });
|
||||
}
|
||||
public findAllInvitationsFor(
|
||||
receiver: Teacher
|
||||
): Promise<TeacherInvitation[]> {
|
||||
public findAllInvitationsFor(receiver: Teacher): Promise<TeacherInvitation[]> {
|
||||
return this.findAll({ where: { receiver: receiver } });
|
||||
}
|
||||
public deleteBy(
|
||||
clazz: Class,
|
||||
sender: Teacher,
|
||||
receiver: Teacher
|
||||
): Promise<void> {
|
||||
public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise<void> {
|
||||
return this.deleteWhere({
|
||||
sender: sender,
|
||||
receiver: receiver,
|
||||
|
|
|
@ -1,16 +1,37 @@
|
|||
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
|
||||
import { Attachment } from '../../entities/content/attachment.entity.js';
|
||||
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||
import { Language } from '../../entities/content/language';
|
||||
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier';
|
||||
|
||||
export class AttachmentRepository extends DwengoEntityRepository<Attachment> {
|
||||
public findByLearningObjectAndNumber(
|
||||
learningObject: LearningObject,
|
||||
sequenceNumber: number
|
||||
) {
|
||||
public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> {
|
||||
return this.findOne({
|
||||
learningObject: learningObject,
|
||||
sequenceNumber: sequenceNumber,
|
||||
learningObject: {
|
||||
hruid: learningObjectId.hruid,
|
||||
language: learningObjectId.language,
|
||||
version: learningObjectId.version,
|
||||
},
|
||||
name: name,
|
||||
});
|
||||
}
|
||||
|
||||
public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise<Attachment | null> {
|
||||
return this.findOne(
|
||||
{
|
||||
learningObject: {
|
||||
hruid: hruid,
|
||||
language: language,
|
||||
},
|
||||
name: attachmentName,
|
||||
},
|
||||
{
|
||||
orderBy: {
|
||||
learningObject: {
|
||||
version: 'DESC',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
// This repository is read-only for now since creating own learning object is an extension feature.
|
||||
}
|
||||
|
|
|
@ -1,16 +1,34 @@
|
|||
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
|
||||
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
|
||||
import { Language } from '../../entities/content/language';
|
||||
|
||||
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
|
||||
public findByIdentifier(
|
||||
identifier: LearningObjectIdentifier
|
||||
): Promise<LearningObject | null> {
|
||||
return this.findOne({
|
||||
hruid: identifier.hruid,
|
||||
language: identifier.language,
|
||||
version: identifier.version,
|
||||
});
|
||||
public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
|
||||
return this.findOne(
|
||||
{
|
||||
hruid: identifier.hruid,
|
||||
language: identifier.language,
|
||||
version: identifier.version,
|
||||
},
|
||||
{
|
||||
populate: ['keywords'],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public findLatestByHruidAndLanguage(hruid: string, language: Language) {
|
||||
return this.findOne(
|
||||
{
|
||||
hruid: hruid,
|
||||
language: language,
|
||||
},
|
||||
{
|
||||
populate: ['keywords'],
|
||||
orderBy: {
|
||||
version: 'DESC',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
// This repository is read-only for now since creating own learning object is an extension feature.
|
||||
}
|
||||
|
|
|
@ -3,11 +3,24 @@ import { LearningPath } from '../../entities/content/learning-path.entity.js';
|
|||
import { Language } from '../../entities/content/language.js';
|
||||
|
||||
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
|
||||
public findByHruidAndLanguage(
|
||||
hruid: string,
|
||||
language: Language
|
||||
): Promise<LearningPath | null> {
|
||||
return this.findOne({ hruid: hruid, language: language });
|
||||
public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
|
||||
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all learning paths which have the given language and whose title OR description contains the
|
||||
* query string.
|
||||
*
|
||||
* @param query The query string we want to seach for in the title or description.
|
||||
* @param language The language of the learning paths we want to find.
|
||||
*/
|
||||
public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> {
|
||||
return this.findAll({
|
||||
where: {
|
||||
language: language,
|
||||
$or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }],
|
||||
},
|
||||
populate: ['nodes', 'nodes.transitions'],
|
||||
});
|
||||
}
|
||||
// This repository is read-only for now since creating own learning object is an extension feature.
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
|
||||
|
||||
export abstract class DwengoEntityRepository<
|
||||
T extends object,
|
||||
> extends EntityRepository<T> {
|
||||
export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> {
|
||||
public async save(entity: T) {
|
||||
const em = this.getEntityManager();
|
||||
em.persist(entity);
|
||||
|
|
|
@ -4,15 +4,13 @@ import { Question } from '../../entities/questions/question.entity.js';
|
|||
import { Teacher } from '../../entities/users/teacher.entity.js';
|
||||
|
||||
export class AnswerRepository extends DwengoEntityRepository<Answer> {
|
||||
public createAnswer(answer: {
|
||||
toQuestion: Question;
|
||||
author: Teacher;
|
||||
content: string;
|
||||
}): Promise<Answer> {
|
||||
const answerEntity = new Answer();
|
||||
answerEntity.toQuestion = answer.toQuestion;
|
||||
answerEntity.author = answer.author;
|
||||
answerEntity.content = answer.content;
|
||||
public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> {
|
||||
const answerEntity = this.create({
|
||||
toQuestion: answer.toQuestion,
|
||||
author: answer.author,
|
||||
content: answer.content,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return this.insert(answerEntity);
|
||||
}
|
||||
public findAllAnswersToQuestion(question: Question): Promise<Answer[]> {
|
||||
|
@ -21,10 +19,7 @@ export class AnswerRepository extends DwengoEntityRepository<Answer> {
|
|||
orderBy: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
public removeAnswerByQuestionAndSequenceNumber(
|
||||
question: Question,
|
||||
sequenceNumber: number
|
||||
): Promise<void> {
|
||||
public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise<void> {
|
||||
return this.deleteWhere({
|
||||
toQuestion: question,
|
||||
sequenceNumber: sequenceNumber,
|
||||
|
|
|
@ -4,12 +4,15 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object
|
|||
import { Student } from '../../entities/users/student.entity.js';
|
||||
|
||||
export class QuestionRepository extends DwengoEntityRepository<Question> {
|
||||
public createQuestion(question: {
|
||||
loId: LearningObjectIdentifier;
|
||||
author: Student;
|
||||
content: string;
|
||||
}): Promise<Question> {
|
||||
const questionEntity = new Question();
|
||||
public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> {
|
||||
const questionEntity = this.create({
|
||||
learningObjectHruid: question.loId.hruid,
|
||||
learningObjectLanguage: question.loId.language,
|
||||
learningObjectVersion: question.loId.version,
|
||||
author: question.author,
|
||||
content: question.content,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
questionEntity.learningObjectHruid = question.loId.hruid;
|
||||
questionEntity.learningObjectLanguage = question.loId.language;
|
||||
questionEntity.learningObjectVersion = question.loId.version;
|
||||
|
@ -17,9 +20,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
|
|||
questionEntity.content = question.content;
|
||||
return this.insert(questionEntity);
|
||||
}
|
||||
public findAllQuestionsAboutLearningObject(
|
||||
loId: LearningObjectIdentifier
|
||||
): Promise<Question[]> {
|
||||
public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {
|
||||
return this.findAll({
|
||||
where: {
|
||||
learningObjectHruid: loId.hruid,
|
||||
|
@ -31,10 +32,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
|
|||
},
|
||||
});
|
||||
}
|
||||
public removeQuestionByLearningObjectAndSequenceNumber(
|
||||
loId: LearningObjectIdentifier,
|
||||
sequenceNumber: number
|
||||
): Promise<void> {
|
||||
public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise<void> {
|
||||
return this.deleteWhere({
|
||||
learningObjectHruid: loId.hruid,
|
||||
learningObjectLanguage: loId.language,
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
AnyEntity,
|
||||
EntityManager,
|
||||
EntityName,
|
||||
EntityRepository,
|
||||
} from '@mikro-orm/core';
|
||||
import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-orm/core';
|
||||
import { forkEntityManager } from '../orm.js';
|
||||
import { StudentRepository } from './users/student-repository.js';
|
||||
import { Student } from '../entities/users/student.entity.js';
|
||||
|
@ -33,6 +28,8 @@ import { LearningPath } from '../entities/content/learning-path.entity.js';
|
|||
import { LearningPathRepository } from './content/learning-path-repository.js';
|
||||
import { AttachmentRepository } from './content/attachment-repository.js';
|
||||
import { Attachment } from '../entities/content/attachment.entity.js';
|
||||
import { LearningPathNode } from '../entities/content/learning-path-node.entity.js';
|
||||
import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js';
|
||||
|
||||
let entityManager: EntityManager | undefined;
|
||||
|
||||
|
@ -43,9 +40,7 @@ export function transactional<T>(f: () => Promise<T>) {
|
|||
entityManager?.transactional(f);
|
||||
}
|
||||
|
||||
function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(
|
||||
entity: EntityName<T>
|
||||
): () => R {
|
||||
function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(entity: EntityName<T>): () => R {
|
||||
let cachedRepo: R | undefined;
|
||||
return (): R => {
|
||||
if (!cachedRepo) {
|
||||
|
@ -60,60 +55,26 @@ function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(
|
|||
|
||||
/* Users */
|
||||
export const getUserRepository = repositoryGetter<User, UserRepository>(User);
|
||||
export const getStudentRepository = repositoryGetter<
|
||||
Student,
|
||||
StudentRepository
|
||||
>(Student);
|
||||
export const getTeacherRepository = repositoryGetter<
|
||||
Teacher,
|
||||
TeacherRepository
|
||||
>(Teacher);
|
||||
export const getStudentRepository = repositoryGetter<Student, StudentRepository>(Student);
|
||||
export const getTeacherRepository = repositoryGetter<Teacher, TeacherRepository>(Teacher);
|
||||
|
||||
/* Classes */
|
||||
export const getClassRepository = repositoryGetter<Class, ClassRepository>(
|
||||
Class
|
||||
);
|
||||
export const getClassJoinRequestRepository = repositoryGetter<
|
||||
ClassJoinRequest,
|
||||
ClassJoinRequestRepository
|
||||
>(ClassJoinRequest);
|
||||
export const getTeacherInvitationRepository = repositoryGetter<
|
||||
TeacherInvitation,
|
||||
TeacherInvitationRepository
|
||||
>(TeacherInvitationRepository);
|
||||
export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class);
|
||||
export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest);
|
||||
export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitation);
|
||||
|
||||
/* Assignments */
|
||||
export const getAssignmentRepository = repositoryGetter<
|
||||
Assignment,
|
||||
AssignmentRepository
|
||||
>(Assignment);
|
||||
export const getGroupRepository = repositoryGetter<Group, GroupRepository>(
|
||||
Group
|
||||
);
|
||||
export const getSubmissionRepository = repositoryGetter<
|
||||
Submission,
|
||||
SubmissionRepository
|
||||
>(Submission);
|
||||
export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment);
|
||||
export const getGroupRepository = repositoryGetter<Group, GroupRepository>(Group);
|
||||
export const getSubmissionRepository = repositoryGetter<Submission, SubmissionRepository>(Submission);
|
||||
|
||||
/* Questions and answers */
|
||||
export const getQuestionRepository = repositoryGetter<
|
||||
Question,
|
||||
QuestionRepository
|
||||
>(Question);
|
||||
export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(
|
||||
Answer
|
||||
);
|
||||
export const getQuestionRepository = repositoryGetter<Question, QuestionRepository>(Question);
|
||||
export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(Answer);
|
||||
|
||||
/* Learning content */
|
||||
export const getLearningObjectRepository = repositoryGetter<
|
||||
LearningObject,
|
||||
LearningObjectRepository
|
||||
>(LearningObject);
|
||||
export const getLearningPathRepository = repositoryGetter<
|
||||
LearningPath,
|
||||
LearningPathRepository
|
||||
>(LearningPath);
|
||||
export const getAttachmentRepository = repositoryGetter<
|
||||
Attachment,
|
||||
AttachmentRepository
|
||||
>(Assignment);
|
||||
export const getLearningObjectRepository = repositoryGetter<LearningObject, LearningObjectRepository>(LearningObject);
|
||||
export const getLearningPathRepository = repositoryGetter<LearningPath, LearningPathRepository>(LearningPath);
|
||||
export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode);
|
||||
export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition);
|
||||
export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Attachment);
|
||||
|
|
|
@ -23,13 +23,7 @@ export const themes: Theme[] = [
|
|||
},
|
||||
{
|
||||
title: 'art',
|
||||
hruids: [
|
||||
'pn_werking',
|
||||
'un_artificiele_intelligentie',
|
||||
'art1',
|
||||
'art2',
|
||||
'art3',
|
||||
],
|
||||
hruids: ['pn_werking', 'un_artificiele_intelligentie', 'art1', 'art2', 'art3'],
|
||||
},
|
||||
{
|
||||
title: 'socialrobot',
|
||||
|
@ -37,12 +31,7 @@ export const themes: Theme[] = [
|
|||
},
|
||||
{
|
||||
title: 'agriculture',
|
||||
hruids: [
|
||||
'pn_werking',
|
||||
'un_artificiele_intelligentie',
|
||||
'agri_landbouw',
|
||||
'agri_lopendeband',
|
||||
],
|
||||
hruids: ['pn_werking', 'un_artificiele_intelligentie', 'agri_landbouw', 'agri_lopendeband'],
|
||||
},
|
||||
{
|
||||
title: 'wegostem',
|
||||
|
@ -83,16 +72,7 @@ export const themes: Theme[] = [
|
|||
},
|
||||
{
|
||||
title: 'python_programming',
|
||||
hruids: [
|
||||
'pn_werking',
|
||||
'pn_datatypes',
|
||||
'pn_operatoren',
|
||||
'pn_structuren',
|
||||
'pn_functies',
|
||||
'art2',
|
||||
'stem_insectbooks',
|
||||
'un_algoenprog',
|
||||
],
|
||||
hruids: ['pn_werking', 'pn_datatypes', 'pn_operatoren', 'pn_structuren', 'pn_functies', 'art2', 'stem_insectbooks', 'un_algoenprog'],
|
||||
},
|
||||
{
|
||||
title: 'stem',
|
||||
|
@ -110,15 +90,7 @@ export const themes: Theme[] = [
|
|||
},
|
||||
{
|
||||
title: 'care',
|
||||
hruids: [
|
||||
'pn_werking',
|
||||
'un_artificiele_intelligentie',
|
||||
'aiz1_zorg',
|
||||
'aiz2_grafen',
|
||||
'aiz3_unplugged',
|
||||
'aiz4_eindtermen',
|
||||
'aiz5_triage',
|
||||
],
|
||||
hruids: ['pn_werking', 'un_artificiele_intelligentie', 'aiz1_zorg', 'aiz2_grafen', 'aiz3_unplugged', 'aiz4_eindtermen', 'aiz5_triage'],
|
||||
},
|
||||
{
|
||||
title: 'chatbot',
|
||||
|
|
|
@ -1,23 +1,12 @@
|
|||
import {
|
||||
Entity,
|
||||
Enum,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from '@mikro-orm/core';
|
||||
import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Class } from '../classes/class.entity.js';
|
||||
import { Group } from './group.entity.js';
|
||||
import { Language } from '../content/language.js';
|
||||
import { AssignmentRepository } from '../../data/assignments/assignment-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => AssignmentRepository })
|
||||
export class Assignment {
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Class;
|
||||
},
|
||||
primary: true,
|
||||
})
|
||||
@ManyToOne({ entity: () => Class, primary: true })
|
||||
within!: Class;
|
||||
|
||||
@PrimaryKey({ type: 'number' })
|
||||
|
@ -32,18 +21,9 @@ export class Assignment {
|
|||
@Property({ type: 'string' })
|
||||
learningPathHruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => {
|
||||
return Language;
|
||||
},
|
||||
})
|
||||
@Enum({ items: () => Language })
|
||||
learningPathLanguage!: Language;
|
||||
|
||||
@OneToMany({
|
||||
entity: () => {
|
||||
return Group;
|
||||
},
|
||||
mappedBy: 'assignment',
|
||||
})
|
||||
@OneToMany({ entity: () => Group, mappedBy: 'assignment' })
|
||||
groups!: Group[];
|
||||
}
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core';
|
||||
import { Assignment } from './assignment.entity.js';
|
||||
import { Student } from '../users/student.entity.js';
|
||||
import { GroupRepository } from '../../data/assignments/group-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => GroupRepository })
|
||||
export class Group {
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Assignment;
|
||||
},
|
||||
entity: () => Assignment,
|
||||
primary: true,
|
||||
})
|
||||
assignment!: Assignment;
|
||||
|
@ -16,9 +15,7 @@ export class Group {
|
|||
groupNumber!: number;
|
||||
|
||||
@ManyToMany({
|
||||
entity: () => {
|
||||
return Student;
|
||||
},
|
||||
entity: () => Student,
|
||||
})
|
||||
members!: Student[];
|
||||
}
|
||||
|
|
|
@ -2,30 +2,27 @@ import { Student } from '../users/student.entity.js';
|
|||
import { Group } from './group.entity.js';
|
||||
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Language } from '../content/language.js';
|
||||
import { SubmissionRepository } from '../../data/assignments/submission-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => SubmissionRepository })
|
||||
export class Submission {
|
||||
@PrimaryKey({ type: 'string' })
|
||||
learningObjectHruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => {
|
||||
return Language;
|
||||
},
|
||||
items: () => Language,
|
||||
primary: true,
|
||||
})
|
||||
learningObjectLanguage!: Language;
|
||||
|
||||
@PrimaryKey({ type: 'string' })
|
||||
learningObjectVersion: string = '1';
|
||||
@PrimaryKey({ type: 'numeric' })
|
||||
learningObjectVersion: number = 1;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
submissionNumber!: number;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Student;
|
||||
},
|
||||
entity: () => Student,
|
||||
})
|
||||
submitter!: Student;
|
||||
|
||||
|
@ -33,9 +30,7 @@ export class Submission {
|
|||
submissionTime!: Date;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Group;
|
||||
},
|
||||
entity: () => Group,
|
||||
nullable: true,
|
||||
})
|
||||
onBehalfOf?: Group;
|
||||
|
|
|
@ -1,28 +1,23 @@
|
|||
import { Entity, Enum, ManyToOne } from '@mikro-orm/core';
|
||||
import { Student } from '../users/student.entity.js';
|
||||
import { Class } from './class.entity.js';
|
||||
import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => ClassJoinRequestRepository })
|
||||
export class ClassJoinRequest {
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Student;
|
||||
},
|
||||
entity: () => Student,
|
||||
primary: true,
|
||||
})
|
||||
requester!: Student;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Class;
|
||||
},
|
||||
entity: () => Class,
|
||||
primary: true,
|
||||
})
|
||||
class!: Class;
|
||||
|
||||
@Enum(() => {
|
||||
return ClassJoinRequestStatus;
|
||||
})
|
||||
@Enum(() => ClassJoinRequestStatus)
|
||||
status!: ClassJoinRequestStatus;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
import {
|
||||
Collection,
|
||||
Entity,
|
||||
ManyToMany,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from '@mikro-orm/core';
|
||||
import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { v4 } from 'uuid';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
import { Student } from '../users/student.entity.js';
|
||||
import { ClassRepository } from '../../data/classes/class-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => ClassRepository })
|
||||
export class Class {
|
||||
@PrimaryKey()
|
||||
classId = v4();
|
||||
|
@ -17,13 +12,9 @@ export class Class {
|
|||
@Property({ type: 'string' })
|
||||
displayName!: string;
|
||||
|
||||
@ManyToMany(() => {
|
||||
return Teacher;
|
||||
})
|
||||
@ManyToMany(() => Teacher)
|
||||
teachers!: Collection<Teacher>;
|
||||
|
||||
@ManyToMany(() => {
|
||||
return Student;
|
||||
})
|
||||
@ManyToMany(() => Student)
|
||||
students!: Collection<Student>;
|
||||
}
|
||||
|
|
|
@ -1,32 +1,30 @@
|
|||
import { Entity, ManyToOne } from '@mikro-orm/core';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
import { Class } from './class.entity.js';
|
||||
import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js';
|
||||
|
||||
/**
|
||||
* Invitation of a teacher into a class (in order to teach it).
|
||||
*/
|
||||
@Entity()
|
||||
@Entity({ repository: () => TeacherInvitationRepository })
|
||||
@Entity({
|
||||
repository: () => TeacherInvitationRepository,
|
||||
})
|
||||
export class TeacherInvitation {
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Teacher;
|
||||
},
|
||||
entity: () => Teacher,
|
||||
primary: true,
|
||||
})
|
||||
sender!: Teacher;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Teacher;
|
||||
},
|
||||
entity: () => Teacher,
|
||||
primary: true,
|
||||
})
|
||||
receiver!: Teacher;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Class;
|
||||
},
|
||||
entity: () => Class,
|
||||
primary: true,
|
||||
})
|
||||
class!: Class;
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { LearningObject } from './learning-object.entity.js';
|
||||
import { AttachmentRepository } from '../../data/content/attachment-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => AttachmentRepository })
|
||||
export class Attachment {
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return LearningObject;
|
||||
},
|
||||
entity: () => LearningObject,
|
||||
primary: true,
|
||||
})
|
||||
learningObject!: LearningObject;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
sequenceNumber!: number;
|
||||
@PrimaryKey({ type: 'string' })
|
||||
name!: string;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
mimeType!: string;
|
||||
|
|
|
@ -1,6 +1,186 @@
|
|||
export enum Language {
|
||||
Afar = 'aa',
|
||||
Abkhazian = 'ab',
|
||||
Afrikaans = 'af',
|
||||
Akan = 'ak',
|
||||
Albanian = 'sq',
|
||||
Amharic = 'am',
|
||||
Arabic = 'ar',
|
||||
Aragonese = 'an',
|
||||
Armenian = 'hy',
|
||||
Assamese = 'as',
|
||||
Avaric = 'av',
|
||||
Avestan = 'ae',
|
||||
Aymara = 'ay',
|
||||
Azerbaijani = 'az',
|
||||
Bashkir = 'ba',
|
||||
Bambara = 'bm',
|
||||
Basque = 'eu',
|
||||
Belarusian = 'be',
|
||||
Bengali = 'bn',
|
||||
Bihari = 'bh',
|
||||
Bislama = 'bi',
|
||||
Bosnian = 'bs',
|
||||
Breton = 'br',
|
||||
Bulgarian = 'bg',
|
||||
Burmese = 'my',
|
||||
Catalan = 'ca',
|
||||
Chamorro = 'ch',
|
||||
Chechen = 'ce',
|
||||
Chinese = 'zh',
|
||||
ChurchSlavic = 'cu',
|
||||
Chuvash = 'cv',
|
||||
Cornish = 'kw',
|
||||
Corsican = 'co',
|
||||
Cree = 'cr',
|
||||
Czech = 'cs',
|
||||
Danish = 'da',
|
||||
Divehi = 'dv',
|
||||
Dutch = 'nl',
|
||||
French = 'fr',
|
||||
Dzongkha = 'dz',
|
||||
English = 'en',
|
||||
Germany = 'de',
|
||||
Esperanto = 'eo',
|
||||
Estonian = 'et',
|
||||
Ewe = 'ee',
|
||||
Faroese = 'fo',
|
||||
Fijian = 'fj',
|
||||
Finnish = 'fi',
|
||||
French = 'fr',
|
||||
Frisian = 'fy',
|
||||
Fulah = 'ff',
|
||||
Georgian = 'ka',
|
||||
German = 'de',
|
||||
Gaelic = 'gd',
|
||||
Irish = 'ga',
|
||||
Galician = 'gl',
|
||||
Manx = 'gv',
|
||||
Greek = 'el',
|
||||
Guarani = 'gn',
|
||||
Gujarati = 'gu',
|
||||
Haitian = 'ht',
|
||||
Hausa = 'ha',
|
||||
Hebrew = 'he',
|
||||
Herero = 'hz',
|
||||
Hindi = 'hi',
|
||||
HiriMotu = 'ho',
|
||||
Croatian = 'hr',
|
||||
Hungarian = 'hu',
|
||||
Igbo = 'ig',
|
||||
Icelandic = 'is',
|
||||
Ido = 'io',
|
||||
SichuanYi = 'ii',
|
||||
Inuktitut = 'iu',
|
||||
Interlingue = 'ie',
|
||||
Interlingua = 'ia',
|
||||
Indonesian = 'id',
|
||||
Inupiaq = 'ik',
|
||||
Italian = 'it',
|
||||
Javanese = 'jv',
|
||||
Japanese = 'ja',
|
||||
Kalaallisut = 'kl',
|
||||
Kannada = 'kn',
|
||||
Kashmiri = 'ks',
|
||||
Kanuri = 'kr',
|
||||
Kazakh = 'kk',
|
||||
Khmer = 'km',
|
||||
Kikuyu = 'ki',
|
||||
Kinyarwanda = 'rw',
|
||||
Kirghiz = 'ky',
|
||||
Komi = 'kv',
|
||||
Kongo = 'kg',
|
||||
Korean = 'ko',
|
||||
Kuanyama = 'kj',
|
||||
Kurdish = 'ku',
|
||||
Lao = 'lo',
|
||||
Latin = 'la',
|
||||
Latvian = 'lv',
|
||||
Limburgan = 'li',
|
||||
Lingala = 'ln',
|
||||
Lithuanian = 'lt',
|
||||
Luxembourgish = 'lb',
|
||||
LubaKatanga = 'lu',
|
||||
Ganda = 'lg',
|
||||
Macedonian = 'mk',
|
||||
Marshallese = 'mh',
|
||||
Malayalam = 'ml',
|
||||
Maori = 'mi',
|
||||
Marathi = 'mr',
|
||||
Malay = 'ms',
|
||||
Malagasy = 'mg',
|
||||
Maltese = 'mt',
|
||||
Mongolian = 'mn',
|
||||
Nauru = 'na',
|
||||
Navajo = 'nv',
|
||||
SouthNdebele = 'nr',
|
||||
NorthNdebele = 'nd',
|
||||
Ndonga = 'ng',
|
||||
Nepali = 'ne',
|
||||
NorwegianNynorsk = 'nn',
|
||||
NorwegianBokmal = 'nb',
|
||||
Norwegian = 'no',
|
||||
Chichewa = 'ny',
|
||||
Occitan = 'oc',
|
||||
Ojibwa = 'oj',
|
||||
Oriya = 'or',
|
||||
Oromo = 'om',
|
||||
Ossetian = 'os',
|
||||
Punjabi = 'pa',
|
||||
Persian = 'fa',
|
||||
Pali = 'pi',
|
||||
Polish = 'pl',
|
||||
Portuguese = 'pt',
|
||||
Pashto = 'ps',
|
||||
Quechua = 'qu',
|
||||
Romansh = 'rm',
|
||||
Romanian = 'ro',
|
||||
Rundi = 'rn',
|
||||
Russian = 'ru',
|
||||
Sango = 'sg',
|
||||
Sanskrit = 'sa',
|
||||
Sinhala = 'si',
|
||||
Slovak = 'sk',
|
||||
Slovenian = 'sl',
|
||||
NorthernSami = 'se',
|
||||
Samoan = 'sm',
|
||||
Shona = 'sn',
|
||||
Sindhi = 'sd',
|
||||
Somali = 'so',
|
||||
Sotho = 'st',
|
||||
Spanish = 'es',
|
||||
Sardinian = 'sc',
|
||||
Serbian = 'sr',
|
||||
Swati = 'ss',
|
||||
Sundanese = 'su',
|
||||
Swahili = 'sw',
|
||||
Swedish = 'sv',
|
||||
Tahitian = 'ty',
|
||||
Tamil = 'ta',
|
||||
Tatar = 'tt',
|
||||
Telugu = 'te',
|
||||
Tajik = 'tg',
|
||||
Tagalog = 'tl',
|
||||
Thai = 'th',
|
||||
Tibetan = 'bo',
|
||||
Tigrinya = 'ti',
|
||||
Tonga = 'to',
|
||||
Tswana = 'tn',
|
||||
Tsonga = 'ts',
|
||||
Turkmen = 'tk',
|
||||
Turkish = 'tr',
|
||||
Twi = 'tw',
|
||||
Uighur = 'ug',
|
||||
Ukrainian = 'uk',
|
||||
Urdu = 'ur',
|
||||
Uzbek = 'uz',
|
||||
Venda = 've',
|
||||
Vietnamese = 'vi',
|
||||
Volapuk = 'vo',
|
||||
Welsh = 'cy',
|
||||
Walloon = 'wa',
|
||||
Wolof = 'wo',
|
||||
Xhosa = 'xh',
|
||||
Yiddish = 'yi',
|
||||
Yoruba = 'yo',
|
||||
Zhuang = 'za',
|
||||
Zulu = 'zu',
|
||||
}
|
||||
|
|
|
@ -4,6 +4,6 @@ export class LearningObjectIdentifier {
|
|||
constructor(
|
||||
public hruid: string,
|
||||
public language: Language,
|
||||
public version: string
|
||||
public version: number
|
||||
) {}
|
||||
}
|
||||
|
|
|
@ -1,105 +1,10 @@
|
|||
import {
|
||||
Embeddable,
|
||||
Embedded,
|
||||
Entity,
|
||||
Enum,
|
||||
ManyToMany,
|
||||
OneToMany,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from '@mikro-orm/core';
|
||||
import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Language } from './language.js';
|
||||
import { Attachment } from './attachment.entity.js';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
|
||||
@Entity()
|
||||
export class LearningObject {
|
||||
@PrimaryKey({ type: 'string' })
|
||||
hruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => {
|
||||
return Language;
|
||||
},
|
||||
primary: true,
|
||||
})
|
||||
language!: Language;
|
||||
|
||||
@PrimaryKey({ type: 'string' })
|
||||
version: string = '1';
|
||||
|
||||
@ManyToMany({
|
||||
entity: () => {
|
||||
return Teacher;
|
||||
},
|
||||
})
|
||||
admins!: Teacher[];
|
||||
|
||||
@Property({ type: 'string' })
|
||||
title!: string;
|
||||
|
||||
@Property({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
contentType!: string;
|
||||
|
||||
@Property({ type: 'array' })
|
||||
keywords: string[] = [];
|
||||
|
||||
@Property({ type: 'array', nullable: true })
|
||||
targetAges?: number[];
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
teacherExclusive: boolean = false;
|
||||
|
||||
@Property({ type: 'array' })
|
||||
skosConcepts!: string[];
|
||||
|
||||
@Embedded({
|
||||
entity: () => {
|
||||
return EducationalGoal;
|
||||
},
|
||||
array: true,
|
||||
})
|
||||
educationalGoals: EducationalGoal[] = [];
|
||||
|
||||
@Property({ type: 'string' })
|
||||
copyright: string = '';
|
||||
|
||||
@Property({ type: 'string' })
|
||||
license: string = '';
|
||||
|
||||
@Property({ type: 'smallint', nullable: true })
|
||||
difficulty?: number;
|
||||
|
||||
@Property({ type: 'integer' })
|
||||
estimatedTime!: number;
|
||||
|
||||
@Embedded({
|
||||
entity: () => {
|
||||
return ReturnValue;
|
||||
},
|
||||
})
|
||||
returnValue!: ReturnValue;
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
available: boolean = true;
|
||||
|
||||
@Property({ type: 'string', nullable: true })
|
||||
contentLocation?: string;
|
||||
|
||||
@OneToMany({
|
||||
entity: () => {
|
||||
return Attachment;
|
||||
},
|
||||
mappedBy: 'learningObject',
|
||||
})
|
||||
attachments: Attachment[] = [];
|
||||
|
||||
@Property({ type: 'blob' })
|
||||
content!: Buffer;
|
||||
}
|
||||
import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js';
|
||||
import { v4 } from 'uuid';
|
||||
import { LearningObjectRepository } from '../../data/content/learning-object-repository.js';
|
||||
|
||||
@Embeddable()
|
||||
export class EducationalGoal {
|
||||
|
@ -119,11 +24,84 @@ export class ReturnValue {
|
|||
callbackSchema!: string;
|
||||
}
|
||||
|
||||
export enum ContentType {
|
||||
Markdown = 'text/markdown',
|
||||
Image = 'image/image',
|
||||
Mpeg = 'audio/mpeg',
|
||||
Pdf = 'application/pdf',
|
||||
Extern = 'extern',
|
||||
Blockly = 'Blockly',
|
||||
@Entity({ repository: () => LearningObjectRepository })
|
||||
export class LearningObject {
|
||||
@PrimaryKey({ type: 'string' })
|
||||
hruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => Language,
|
||||
primary: true,
|
||||
})
|
||||
language!: Language;
|
||||
|
||||
@PrimaryKey({ type: 'number' })
|
||||
version: number = 1;
|
||||
|
||||
@Property({ type: 'uuid', unique: true })
|
||||
uuid = v4();
|
||||
|
||||
@ManyToMany({
|
||||
entity: () => Teacher,
|
||||
})
|
||||
admins!: Teacher[];
|
||||
|
||||
@Property({ type: 'string' })
|
||||
title!: string;
|
||||
|
||||
@Property({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
contentType!: DwengoContentType;
|
||||
|
||||
@Property({ type: 'array' })
|
||||
keywords: string[] = [];
|
||||
|
||||
@Property({ type: 'array', nullable: true })
|
||||
targetAges?: number[] = [];
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
teacherExclusive: boolean = false;
|
||||
|
||||
@Property({ type: 'array' })
|
||||
skosConcepts: string[] = [];
|
||||
|
||||
@Embedded({
|
||||
entity: () => EducationalGoal,
|
||||
array: true,
|
||||
})
|
||||
educationalGoals: EducationalGoal[] = [];
|
||||
|
||||
@Property({ type: 'string' })
|
||||
copyright: string = '';
|
||||
|
||||
@Property({ type: 'string' })
|
||||
license: string = '';
|
||||
|
||||
@Property({ type: 'smallint', nullable: true })
|
||||
difficulty?: number;
|
||||
|
||||
@Property({ type: 'integer', nullable: true })
|
||||
estimatedTime?: number;
|
||||
|
||||
@Embedded({
|
||||
entity: () => ReturnValue,
|
||||
})
|
||||
returnValue!: ReturnValue;
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
available: boolean = true;
|
||||
|
||||
@Property({ type: 'string', nullable: true })
|
||||
contentLocation?: string;
|
||||
|
||||
@OneToMany({
|
||||
entity: () => Attachment,
|
||||
mappedBy: 'learningObject',
|
||||
})
|
||||
attachments: Attachment[] = [];
|
||||
|
||||
@Property({ type: 'blob' })
|
||||
content!: Buffer;
|
||||
}
|
||||
|
|
37
backend/src/entities/content/learning-path-node.entity.ts
Normal file
37
backend/src/entities/content/learning-path-node.entity.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
|
||||
import { Language } from './language.js';
|
||||
import { LearningPath } from './learning-path.entity.js';
|
||||
import { LearningPathTransition } from './learning-path-transition.entity.js';
|
||||
|
||||
@Entity()
|
||||
export class LearningPathNode {
|
||||
@ManyToOne({ entity: () => LearningPath, primary: true })
|
||||
learningPath!: Rel<LearningPath>;
|
||||
|
||||
@PrimaryKey({ type: 'integer', autoincrement: true })
|
||||
nodeNumber!: number;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
learningObjectHruid!: string;
|
||||
|
||||
@Enum({ items: () => Language })
|
||||
language!: Language;
|
||||
|
||||
@Property({ type: 'number' })
|
||||
version!: number;
|
||||
|
||||
@Property({ type: 'text', nullable: true })
|
||||
instruction?: string;
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
startNode!: boolean;
|
||||
|
||||
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' })
|
||||
transitions: LearningPathTransition[] = [];
|
||||
|
||||
@Property({ length: 3 })
|
||||
createdAt: Date = new Date();
|
||||
|
||||
@Property({ length: 3, onUpdate: () => new Date() })
|
||||
updatedAt: Date = new Date();
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { Entity, ManyToOne, PrimaryKey, Property, Rel } from '@mikro-orm/core';
|
||||
import { LearningPathNode } from './learning-path-node.entity.js';
|
||||
|
||||
@Entity()
|
||||
export class LearningPathTransition {
|
||||
@ManyToOne({ entity: () => LearningPathNode, primary: true })
|
||||
node!: Rel<LearningPathNode>;
|
||||
|
||||
@PrimaryKey({ type: 'numeric' })
|
||||
transitionNumber!: number;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
condition!: string;
|
||||
|
||||
@ManyToOne({ entity: () => LearningPathNode })
|
||||
next!: Rel<LearningPathNode>;
|
||||
}
|
|
@ -1,34 +1,18 @@
|
|||
import {
|
||||
Embeddable,
|
||||
Embedded,
|
||||
Entity,
|
||||
Enum,
|
||||
ManyToMany,
|
||||
OneToOne,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from '@mikro-orm/core';
|
||||
import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Language } from './language.js';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
|
||||
import { LearningPathNode } from './learning-path-node.entity.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => LearningPathRepository })
|
||||
export class LearningPath {
|
||||
@PrimaryKey({ type: 'string' })
|
||||
hruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => {
|
||||
return Language;
|
||||
},
|
||||
primary: true,
|
||||
})
|
||||
@Enum({ items: () => Language, primary: true })
|
||||
language!: Language;
|
||||
|
||||
@ManyToMany({
|
||||
entity: () => {
|
||||
return Teacher;
|
||||
},
|
||||
})
|
||||
@ManyToMany({ entity: () => Teacher })
|
||||
admins!: Teacher[];
|
||||
|
||||
@Property({ type: 'string' })
|
||||
|
@ -37,57 +21,9 @@ export class LearningPath {
|
|||
@Property({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Property({ type: 'blob' })
|
||||
image!: string;
|
||||
@Property({ type: 'blob', nullable: true })
|
||||
image: Buffer | null = null;
|
||||
|
||||
@Embedded({
|
||||
entity: () => {
|
||||
return LearningPathNode;
|
||||
},
|
||||
array: true,
|
||||
})
|
||||
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
|
||||
nodes: LearningPathNode[] = [];
|
||||
}
|
||||
|
||||
@Embeddable()
|
||||
export class LearningPathNode {
|
||||
@Property({ type: 'string' })
|
||||
learningObjectHruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => {
|
||||
return Language;
|
||||
},
|
||||
})
|
||||
language!: Language;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
version!: string;
|
||||
|
||||
@Property({ type: 'longtext' })
|
||||
instruction!: string;
|
||||
|
||||
@Property({ type: 'bool' })
|
||||
startNode!: boolean;
|
||||
|
||||
@Embedded({
|
||||
entity: () => {
|
||||
return LearningPathTransition;
|
||||
},
|
||||
array: true,
|
||||
})
|
||||
transitions!: LearningPathTransition[];
|
||||
}
|
||||
|
||||
@Embeddable()
|
||||
export class LearningPathTransition {
|
||||
@Property({ type: 'string' })
|
||||
condition!: string;
|
||||
|
||||
@OneToOne({
|
||||
entity: () => {
|
||||
return LearningPathNode;
|
||||
},
|
||||
})
|
||||
next!: LearningPathNode;
|
||||
}
|
||||
|
|
|
@ -1,27 +1,24 @@
|
|||
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Question } from './question.entity.js';
|
||||
import { Teacher } from '../users/teacher.entity.js';
|
||||
import { AnswerRepository } from '../../data/questions/answer-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => AnswerRepository })
|
||||
export class Answer {
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Teacher;
|
||||
},
|
||||
entity: () => Teacher,
|
||||
primary: true,
|
||||
})
|
||||
author!: Teacher;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Question;
|
||||
},
|
||||
entity: () => Question,
|
||||
primary: true,
|
||||
})
|
||||
toQuestion!: Question;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
sequenceNumber!: number;
|
||||
@PrimaryKey({ type: 'integer', autoincrement: true })
|
||||
sequenceNumber?: number;
|
||||
|
||||
@Property({ type: 'datetime' })
|
||||
timestamp: Date = new Date();
|
||||
|
|
|
@ -1,30 +1,27 @@
|
|||
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import { Language } from '../content/language.js';
|
||||
import { Student } from '../users/student.entity.js';
|
||||
import { QuestionRepository } from '../../data/questions/question-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => QuestionRepository })
|
||||
export class Question {
|
||||
@PrimaryKey({ type: 'string' })
|
||||
learningObjectHruid!: string;
|
||||
|
||||
@Enum({
|
||||
items: () => {
|
||||
return Language;
|
||||
},
|
||||
items: () => Language,
|
||||
primary: true,
|
||||
})
|
||||
learningObjectLanguage!: Language;
|
||||
|
||||
@PrimaryKey({ type: 'string' })
|
||||
learningObjectVersion: string = '1';
|
||||
@PrimaryKey({ type: 'number' })
|
||||
learningObjectVersion: number = 1;
|
||||
|
||||
@PrimaryKey({ type: 'integer' })
|
||||
sequenceNumber!: number;
|
||||
@PrimaryKey({ type: 'integer', autoincrement: true })
|
||||
sequenceNumber?: number;
|
||||
|
||||
@ManyToOne({
|
||||
entity: () => {
|
||||
return Student;
|
||||
},
|
||||
entity: () => Student,
|
||||
})
|
||||
author!: Student;
|
||||
|
||||
|
|
|
@ -5,19 +5,13 @@ import { Group } from '../assignments/group.entity.js';
|
|||
import { StudentRepository } from '../../data/users/student-repository.js';
|
||||
|
||||
@Entity({
|
||||
repository: () => {
|
||||
return StudentRepository;
|
||||
},
|
||||
repository: () => StudentRepository,
|
||||
})
|
||||
export class Student extends User {
|
||||
@ManyToMany(() => {
|
||||
return Class;
|
||||
})
|
||||
@ManyToMany(() => Class)
|
||||
classes!: Collection<Class>;
|
||||
|
||||
@ManyToMany(() => {
|
||||
return Group;
|
||||
})
|
||||
@ManyToMany(() => Group)
|
||||
groups!: Collection<Group>;
|
||||
|
||||
constructor(
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import { Collection, Entity, ManyToMany } from '@mikro-orm/core';
|
||||
import { User } from './user.entity.js';
|
||||
import { Class } from '../classes/class.entity.js';
|
||||
import { TeacherRepository } from '../../data/users/teacher-repository.js';
|
||||
|
||||
@Entity()
|
||||
@Entity({ repository: () => TeacherRepository })
|
||||
export class Teacher extends User {
|
||||
@ManyToMany(() => {
|
||||
return Class;
|
||||
})
|
||||
@ManyToMany(() => Class)
|
||||
classes!: Collection<Class>;
|
||||
|
||||
constructor(
|
||||
public username: string,
|
||||
public firstName: string,
|
||||
public lastName: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,17 @@
|
|||
/**
|
||||
* Exception for HTTP 400 Bad Request
|
||||
*/
|
||||
export class BadRequestException extends Error {
|
||||
public status = 400;
|
||||
|
||||
constructor(error: string) {
|
||||
super(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for HTTP 401 Unauthorized
|
||||
*/
|
||||
export class UnauthorizedException extends Error {
|
||||
status = 401;
|
||||
constructor(message: string = 'Unauthorized') {
|
||||
|
@ -5,9 +19,24 @@ export class UnauthorizedException extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for HTTP 403 Forbidden
|
||||
*/
|
||||
export class ForbiddenException extends Error {
|
||||
status = 403;
|
||||
|
||||
constructor(message: string = 'Forbidden') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for HTTP 404 Not Found
|
||||
*/
|
||||
export class NotFoundException extends Error {
|
||||
public status = 404;
|
||||
|
||||
constructor(error: string) {
|
||||
super(error);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { Language } from '../entities/content/language';
|
||||
|
||||
export interface Transition {
|
||||
default: boolean;
|
||||
_id: string;
|
||||
|
@ -9,15 +11,22 @@ export interface Transition {
|
|||
};
|
||||
}
|
||||
|
||||
export interface LearningObjectIdentifier {
|
||||
hruid: string;
|
||||
language: Language;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export interface LearningObjectNode {
|
||||
_id: string;
|
||||
learningobject_hruid: string;
|
||||
version: number;
|
||||
language: string;
|
||||
language: Language;
|
||||
start_node?: boolean;
|
||||
transitions: Transition[];
|
||||
created_at: string;
|
||||
updatedAt: string;
|
||||
done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
|
||||
}
|
||||
|
||||
export interface LearningPath {
|
||||
|
@ -37,6 +46,11 @@ export interface LearningPath {
|
|||
__order: number;
|
||||
}
|
||||
|
||||
export interface LearningPathIdentifier {
|
||||
hruid: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface EducationalGoal {
|
||||
source: string;
|
||||
id: string;
|
||||
|
@ -52,7 +66,7 @@ export interface LearningObjectMetadata {
|
|||
uuid: string;
|
||||
hruid: string;
|
||||
version: number;
|
||||
language: string;
|
||||
language: Language;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: number;
|
||||
|
@ -75,9 +89,9 @@ export interface FilteredLearningObject {
|
|||
version: number;
|
||||
title: string;
|
||||
htmlUrl: string;
|
||||
language: string;
|
||||
language: Language;
|
||||
difficulty: number;
|
||||
estimatedTime: number;
|
||||
estimatedTime?: number;
|
||||
available: boolean;
|
||||
teacherExclusive: boolean;
|
||||
educationalGoals: EducationalGoal[];
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
createLogger,
|
||||
format,
|
||||
Logger as WinstonLogger,
|
||||
transports,
|
||||
} from 'winston';
|
||||
import { createLogger, format, Logger as WinstonLogger, transports } from 'winston';
|
||||
import LokiTransport from 'winston-loki';
|
||||
import { LokiLabels } from 'loki-logger-ts';
|
||||
import { LOG_LEVEL, LOKI_HOST } from '../config.js';
|
||||
|
@ -48,9 +43,7 @@ function initializeLogger(): Logger {
|
|||
transports: [lokiTransport, consoleTransport],
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`
|
||||
);
|
||||
logger.debug(`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`);
|
||||
return logger;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,42 +12,28 @@ export class MikroOrmLogger extends DefaultLogger {
|
|||
|
||||
switch (namespace) {
|
||||
case 'query':
|
||||
this.logger.debug(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.debug(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
case 'query-params':
|
||||
// TODO Which log level should this be?
|
||||
this.logger.info(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.info(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
case 'schema':
|
||||
this.logger.info(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.info(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
case 'discovery':
|
||||
this.logger.debug(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.debug(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
case 'info':
|
||||
this.logger.info(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.info(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
case 'deprecated':
|
||||
this.logger.warn(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.warn(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
default:
|
||||
switch (context?.level) {
|
||||
case 'info':
|
||||
this.logger.info(
|
||||
this.createMessage(namespace, message, context)
|
||||
);
|
||||
this.logger.info(this.createMessage(namespace, message, context));
|
||||
break;
|
||||
case 'warning':
|
||||
this.logger.warn(message);
|
||||
|
@ -62,11 +48,7 @@ export class MikroOrmLogger extends DefaultLogger {
|
|||
}
|
||||
}
|
||||
|
||||
private createMessage(
|
||||
namespace: LoggerNamespace,
|
||||
messageArg: string,
|
||||
context?: LogContext
|
||||
) {
|
||||
private createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext) {
|
||||
const labels: LokiLabels = {
|
||||
service: 'ORM',
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import * as express from 'express';
|
|||
import * as jwt from 'jsonwebtoken';
|
||||
import { AuthenticatedRequest } from './authenticated-request.js';
|
||||
import { AuthenticationInfo } from './authentication-info.js';
|
||||
import { ForbiddenException, UnauthorizedException } from '../../exceptions';
|
||||
import { ForbiddenException, UnauthorizedException } from '../../exceptions.js';
|
||||
|
||||
const JWKS_CACHE = true;
|
||||
const JWKS_RATE_LIMIT = true;
|
||||
|
@ -52,16 +52,12 @@ const verifyJwtToken = expressjwt({
|
|||
|
||||
const issuer = (token.payload as JwtPayload).iss;
|
||||
|
||||
const idpConfig = Object.values(idpConfigs).find((config) => {
|
||||
return config.issuer === issuer;
|
||||
});
|
||||
const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer);
|
||||
if (!idpConfig) {
|
||||
throw new Error('Issuer not accepted.');
|
||||
}
|
||||
|
||||
const signingKey = await idpConfig.jwksClient.getSigningKey(
|
||||
token.header.kid
|
||||
);
|
||||
const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid);
|
||||
if (!signingKey) {
|
||||
throw new Error('Signing key not found.');
|
||||
}
|
||||
|
@ -76,9 +72,7 @@ const verifyJwtToken = expressjwt({
|
|||
/**
|
||||
* Get an object with information about the authenticated user from a given authenticated request.
|
||||
*/
|
||||
function getAuthenticationInfo(
|
||||
req: AuthenticatedRequest
|
||||
): AuthenticationInfo | undefined {
|
||||
function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined {
|
||||
if (!req.jwtPayload) {
|
||||
return;
|
||||
}
|
||||
|
@ -106,11 +100,7 @@ function getAuthenticationInfo(
|
|||
* Add the AuthenticationInfo object with the information about the current authentication to the request in order
|
||||
* to avoid that the routers have to deal with the JWT token.
|
||||
*/
|
||||
const addAuthenticationInfo = (
|
||||
req: AuthenticatedRequest,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
) => {
|
||||
const addAuthenticationInfo = (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => {
|
||||
req.auth = getAuthenticationInfo(req);
|
||||
next();
|
||||
};
|
||||
|
@ -123,14 +113,9 @@ export const authenticateUser = [verifyJwtToken, addAuthenticationInfo];
|
|||
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
|
||||
* to true.
|
||||
*/
|
||||
export const authorize = (
|
||||
accessCondition: (auth: AuthenticationInfo) => boolean
|
||||
) => {
|
||||
return (
|
||||
req: AuthenticatedRequest,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
): void => {
|
||||
export const authorize =
|
||||
(accessCondition: (auth: AuthenticationInfo) => boolean) =>
|
||||
(req: AuthenticatedRequest, res: express.Response, next: express.NextFunction): void => {
|
||||
if (!req.auth) {
|
||||
throw new UnauthorizedException();
|
||||
} else if (!accessCondition(req.auth)) {
|
||||
|
@ -139,25 +124,18 @@ export const authorize = (
|
|||
next();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
|
||||
*/
|
||||
export const authenticatedOnly = authorize((_) => {
|
||||
return true;
|
||||
});
|
||||
export const authenticatedOnly = authorize((_) => true);
|
||||
|
||||
/**
|
||||
* Middleware which rejects requests from unauthenticated users or users that aren't students.
|
||||
*/
|
||||
export const studentsOnly = authorize((auth) => {
|
||||
return auth.accountType === 'student';
|
||||
});
|
||||
export const studentsOnly = authorize((auth) => auth.accountType === 'student');
|
||||
|
||||
/**
|
||||
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
|
||||
*/
|
||||
export const teachersOnly = authorize((auth) => {
|
||||
return auth.accountType === 'teacher';
|
||||
});
|
||||
export const teachersOnly = authorize((auth) => auth.accountType === 'teacher');
|
||||
|
|
|
@ -24,6 +24,7 @@ import { LearningPath } from './entities/content/learning-path.entity.js';
|
|||
|
||||
import { Answer } from './entities/questions/answer.entity.js';
|
||||
import { Question } from './entities/questions/question.entity.js';
|
||||
import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js';
|
||||
|
||||
const entities = [
|
||||
User,
|
||||
|
@ -47,14 +48,13 @@ function config(testingMode: boolean = false): Options {
|
|||
return {
|
||||
driver: SqliteDriver,
|
||||
dbName: getEnvVar(EnvVars.DbName),
|
||||
subscribers: [new SqliteAutoincrementSubscriber()],
|
||||
entities: entities,
|
||||
// EntitiesTs: entitiesTs,
|
||||
|
||||
// Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
|
||||
// (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
|
||||
dynamicImportProvider: (id) => {
|
||||
return import(id);
|
||||
},
|
||||
dynamicImportProvider: (id) => import(id),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -70,9 +70,7 @@ function config(testingMode: boolean = false): Options {
|
|||
|
||||
// Logging
|
||||
debug: LOG_LEVEL === 'debug',
|
||||
loggerFactory: (options: LoggerOptions) => {
|
||||
return new MikroOrmLogger(options);
|
||||
},
|
||||
loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -28,9 +28,7 @@ export async function initORM(testingMode: boolean = false) {
|
|||
}
|
||||
export function forkEntityManager(): EntityManager {
|
||||
if (!orm) {
|
||||
throw Error(
|
||||
'Accessing the Entity Manager before the ORM is fully initialized.'
|
||||
);
|
||||
throw Error('Accessing the Entity Manager before the ORM is fully initialized.');
|
||||
}
|
||||
return orm.em.fork();
|
||||
}
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import express from 'express';
|
||||
import {
|
||||
getAllLearningObjects,
|
||||
getLearningObject,
|
||||
} from '../controllers/learningObjects.js';
|
||||
import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
@ -24,4 +21,16 @@ router.get('/', getAllLearningObjects);
|
|||
// Example: http://localhost:3000/learningObject/un_ai7
|
||||
router.get('/:hruid', getLearningObject);
|
||||
|
||||
// Parameter: hruid of learning object
|
||||
// Query: language, version (optional)
|
||||
// Route to fetch the HTML rendering of one learning object based on its hruid.
|
||||
// Example: http://localhost:3000/learningObject/un_ai7/html
|
||||
router.get('/:hruid/html', getLearningObjectHTML);
|
||||
|
||||
// Parameter: hruid of learning object, name of attachment.
|
||||
// Query: language, version (optional).
|
||||
// Route to get the raw data of the attachment for one learning object based on its hruid.
|
||||
// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
|
||||
router.get('/:hruid/html/:attachmentName', getAttachment);
|
||||
|
||||
export default router;
|
|
@ -1,5 +1,5 @@
|
|||
import express from 'express';
|
||||
import { getLearningPaths } from '../controllers/learningPaths.js';
|
||||
import { getLearningPaths } from '../controllers/learning-paths.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -15,8 +15,7 @@ router.get('/:id', (req, res) => {
|
|||
student: '0',
|
||||
group: '0',
|
||||
time: new Date(2025, 1, 1),
|
||||
content:
|
||||
'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????',
|
||||
content: 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????',
|
||||
learningObject: '0',
|
||||
links: {
|
||||
self: `${req.baseUrl}/${req.params.id}`,
|
||||
|
|
35
backend/src/routes/router.ts
Normal file
35
backend/src/routes/router.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Response, Router } from 'express';
|
||||
import studentRouter from './student';
|
||||
import groupRouter from './group';
|
||||
import assignmentRouter from './assignment';
|
||||
import submissionRouter from './submission';
|
||||
import classRouter from './class';
|
||||
import questionRouter from './question';
|
||||
import authRouter from './auth';
|
||||
import themeRoutes from './themes';
|
||||
import learningPathRoutes from './learning-paths';
|
||||
import learningObjectRoutes from './learning-objects';
|
||||
import { getLogger, Logger } from '../logging/initalize';
|
||||
|
||||
const router = Router();
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
router.get('/', (_, res: Response) => {
|
||||
logger.debug('GET /');
|
||||
res.json({
|
||||
message: 'Hello Dwengo!🚀',
|
||||
});
|
||||
});
|
||||
|
||||
router.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
|
||||
router.use('/group', groupRouter /* #swagger.tags = ['Group'] */);
|
||||
router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */);
|
||||
router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */);
|
||||
router.use('/class', classRouter /* #swagger.tags = ['Class'] */);
|
||||
router.use('/question', questionRouter /* #swagger.tags = ['Question'] */);
|
||||
router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */);
|
||||
router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */);
|
||||
router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */);
|
||||
router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */);
|
||||
|
||||
export default router;
|
23
backend/src/services/learning-objects/attachment-service.ts
Normal file
23
backend/src/services/learning-objects/attachment-service.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { getAttachmentRepository } from '../../data/repositories.js';
|
||||
import { Attachment } from '../../entities/content/attachment.entity.js';
|
||||
import { LearningObjectIdentifier } from '../../interfaces/learning-content.js';
|
||||
|
||||
const attachmentService = {
|
||||
getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise<Attachment | null> {
|
||||
const attachmentRepo = getAttachmentRepository();
|
||||
|
||||
if (learningObjectId.version) {
|
||||
return attachmentRepo.findByLearningObjectIdAndName(
|
||||
{
|
||||
hruid: learningObjectId.hruid,
|
||||
language: learningObjectId.language,
|
||||
version: learningObjectId.version,
|
||||
},
|
||||
attachmentName
|
||||
);
|
||||
}
|
||||
return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName);
|
||||
},
|
||||
};
|
||||
|
||||
export default attachmentService;
|
|
@ -0,0 +1,115 @@
|
|||
import { LearningObjectProvider } from './learning-object-provider.js';
|
||||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
|
||||
import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js';
|
||||
import { Language } from '../../entities/content/language.js';
|
||||
import { LearningObject } from '../../entities/content/learning-object.entity.js';
|
||||
import { getUrlStringForLearningObject } from '../../util/links.js';
|
||||
import processingService from './processing/processing-service.js';
|
||||
import { NotFoundError } from '@mikro-orm/core';
|
||||
import learningObjectService from './learning-object-service.js';
|
||||
import { getLogger, Logger } from '../../logging/initalize.js';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null {
|
||||
if (!learningObject) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: learningObject.hruid,
|
||||
_id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field.
|
||||
uuid: learningObject.uuid,
|
||||
language: learningObject.language,
|
||||
version: learningObject.version,
|
||||
title: learningObject.title,
|
||||
description: learningObject.description,
|
||||
htmlUrl: getUrlStringForLearningObject(learningObject),
|
||||
available: learningObject.available,
|
||||
contentType: learningObject.contentType,
|
||||
contentLocation: learningObject.contentLocation,
|
||||
difficulty: learningObject.difficulty || 1,
|
||||
estimatedTime: learningObject.estimatedTime,
|
||||
keywords: learningObject.keywords,
|
||||
educationalGoals: learningObject.educationalGoals,
|
||||
returnValue: {
|
||||
callback_url: learningObject.returnValue.callbackUrl,
|
||||
callback_schema: JSON.parse(learningObject.returnValue.callbackSchema),
|
||||
},
|
||||
skosConcepts: learningObject.skosConcepts,
|
||||
targetAges: learningObject.targetAges || [],
|
||||
teacherExclusive: learningObject.teacherExclusive,
|
||||
};
|
||||
}
|
||||
|
||||
function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise<LearningObject | null> {
|
||||
const learningObjectRepo = getLearningObjectRepository();
|
||||
|
||||
return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service providing access to data about learning objects from the database
|
||||
*/
|
||||
const databaseLearningObjectProvider: LearningObjectProvider = {
|
||||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
|
||||
const learningObject = await findLearningObjectEntityById(id);
|
||||
return convertLearningObject(learningObject);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
const learningObjectRepo = getLearningObjectRepository();
|
||||
|
||||
const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language);
|
||||
if (!learningObject) {
|
||||
return null;
|
||||
}
|
||||
return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id));
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the HRUIDs of all learning objects on this path.
|
||||
*/
|
||||
async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
|
||||
const learningPathRepo = getLearningPathRepository();
|
||||
|
||||
const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language);
|
||||
if (!learningPath) {
|
||||
throw new NotFoundError('The learning path with the given ID could not be found.');
|
||||
}
|
||||
return learningPath.nodes.map((it) => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user.
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the full metadata of all learning objects on this path.
|
||||
*/
|
||||
async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
|
||||
const learningPathRepo = getLearningPathRepository();
|
||||
|
||||
const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language);
|
||||
if (!learningPath) {
|
||||
throw new NotFoundError('The learning path with the given ID could not be found.');
|
||||
}
|
||||
const learningObjects = await Promise.all(
|
||||
learningPath.nodes.map((it) => {
|
||||
const learningObject = learningObjectService.getLearningObjectById({
|
||||
hruid: it.learningObjectHruid,
|
||||
language: it.language,
|
||||
version: it.version,
|
||||
});
|
||||
if (learningObject === null) {
|
||||
logger.warn(`WARN: Learning object corresponding with node ${it} not found!`);
|
||||
}
|
||||
return learningObject;
|
||||
})
|
||||
);
|
||||
return learningObjects.filter((it) => it !== null);
|
||||
},
|
||||
};
|
||||
|
||||
export default databaseLearningObjectProvider;
|
|
@ -0,0 +1,138 @@
|
|||
import { DWENGO_API_BASE } from '../../config.js';
|
||||
import { fetchWithLogging } from '../../util/apiHelper.js';
|
||||
import {
|
||||
FilteredLearningObject,
|
||||
LearningObjectIdentifier,
|
||||
LearningObjectMetadata,
|
||||
LearningObjectNode,
|
||||
LearningPathIdentifier,
|
||||
LearningPathResponse,
|
||||
} from '../../interfaces/learning-content.js';
|
||||
import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js';
|
||||
import { LearningObjectProvider } from './learning-object-provider.js';
|
||||
import { getLogger, Logger } from '../../logging/initalize.js';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
/**
|
||||
* Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which
|
||||
* our API should return.
|
||||
* @param data
|
||||
*/
|
||||
function filterData(data: LearningObjectMetadata): FilteredLearningObject {
|
||||
return {
|
||||
key: data.hruid, // Hruid learningObject (not path)
|
||||
_id: data._id,
|
||||
uuid: data.uuid,
|
||||
version: data.version,
|
||||
title: data.title,
|
||||
htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content
|
||||
language: data.language,
|
||||
difficulty: data.difficulty,
|
||||
estimatedTime: data.estimated_time,
|
||||
available: data.available,
|
||||
teacherExclusive: data.teacher_exclusive,
|
||||
educationalGoals: data.educational_goals, // List with learningObjects
|
||||
keywords: data.keywords, // For search
|
||||
description: data.description, // For search (not an actual description)
|
||||
targetAges: data.target_ages,
|
||||
contentType: data.content_type, // Markdown, image, audio, etc.
|
||||
contentLocation: data.content_location, // If content type extern
|
||||
skosConcepts: data.skos_concepts,
|
||||
returnValue: data.return_value, // Callback response information
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs)
|
||||
*/
|
||||
async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise<FilteredLearningObject[] | string[]> {
|
||||
try {
|
||||
const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths(
|
||||
[learningPathId.hruid],
|
||||
learningPathId.language,
|
||||
`Learning path for HRUID "${learningPathId.hruid}"`
|
||||
);
|
||||
|
||||
if (!learningPathResponse.success || !learningPathResponse.data?.length) {
|
||||
logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes;
|
||||
|
||||
if (!full) {
|
||||
return nodes.map((node) => node.learningobject_hruid);
|
||||
}
|
||||
|
||||
const objects = await Promise.all(
|
||||
nodes.map(async (node) =>
|
||||
dwengoApiLearningObjectProvider.getLearningObjectById({
|
||||
hruid: node.learningobject_hruid,
|
||||
language: learningPathId.language,
|
||||
})
|
||||
)
|
||||
);
|
||||
return objects.filter((obj): obj is FilteredLearningObject => obj !== null);
|
||||
} catch (error) {
|
||||
logger.error('❌ Error fetching learning objects:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const dwengoApiLearningObjectProvider: LearningObjectProvider = {
|
||||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
|
||||
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
|
||||
const metadata = await fetchWithLogging<LearningObjectMetadata>(
|
||||
metadataUrl,
|
||||
`Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`,
|
||||
{
|
||||
params: id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return filterData(metadata);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
*/
|
||||
async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
|
||||
return (await fetchLearningObjects(id, true)) as FilteredLearningObject[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
|
||||
return (await fetchLearningObjects(id, false)) as string[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects
|
||||
* from the Dwengo API, this means passing through the HTML rendering from there.
|
||||
*/
|
||||
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
|
||||
const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
|
||||
params: id,
|
||||
});
|
||||
|
||||
if (!html) {
|
||||
logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
};
|
||||
|
||||
export default dwengoApiLearningObjectProvider;
|
|
@ -0,0 +1,23 @@
|
|||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
|
||||
|
||||
export interface LearningObjectProvider {
|
||||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null>;
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
*/
|
||||
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]>;
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null>;
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
|
||||
import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js';
|
||||
import { LearningObjectProvider } from './learning-object-provider.js';
|
||||
import { EnvVars, getEnvVar } from '../../util/envvars.js';
|
||||
import databaseLearningObjectProvider from './database-learning-object-provider.js';
|
||||
|
||||
function getProvider(id: LearningObjectIdentifier): LearningObjectProvider {
|
||||
if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) {
|
||||
return databaseLearningObjectProvider;
|
||||
}
|
||||
return dwengoApiLearningObjectProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api)
|
||||
*/
|
||||
const learningObjectService = {
|
||||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
|
||||
return getProvider(id).getLearningObjectById(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
*/
|
||||
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
|
||||
return getProvider(id).getLearningObjectsFromPath(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
|
||||
return getProvider(id).getLearningObjectIdsFromPath(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
|
||||
*/
|
||||
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
|
||||
return getProvider(id).getLearningObjectHTML(id);
|
||||
},
|
||||
};
|
||||
|
||||
export default learningObjectService;
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js
|
||||
*
|
||||
* WARNING: The support for audio learning objects is currently still experimental.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { type } from 'node:os';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
|
||||
class AudioProcessor extends StringProcessor {
|
||||
constructor() {
|
||||
super(DwengoContentType.AUDIO_MPEG);
|
||||
}
|
||||
|
||||
protected renderFn(audioUrl: string): string {
|
||||
return DOMPurify.sanitize(`<audio controls>
|
||||
<source src="${audioUrl}" type=${type}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>`);
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioProcessor;
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js
|
||||
*/
|
||||
|
||||
enum DwengoContentType {
|
||||
TEXT_PLAIN = 'text/plain',
|
||||
TEXT_MARKDOWN = 'text/markdown',
|
||||
IMAGE_BLOCK = 'image/image-block',
|
||||
IMAGE_INLINE = 'image/image',
|
||||
AUDIO_MPEG = 'audio/mpeg',
|
||||
APPLICATION_PDF = 'application/pdf',
|
||||
EXTERN = 'extern',
|
||||
BLOCKLY = 'blockly',
|
||||
GIFT = 'text/gift',
|
||||
CT_SCHEMA = 'text/ct-schema',
|
||||
}
|
||||
|
||||
export { DwengoContentType };
|
40
backend/src/services/learning-objects/processing/extern/extern-processor.ts
vendored
Normal file
40
backend/src/services/learning-objects/processing/extern/extern-processor.ts
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js
|
||||
*
|
||||
* WARNING: The support for external content is currently still experimental.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { ProcessingError } from '../processing-error.js';
|
||||
import { isValidHttpUrl } from '../../../../util/links.js';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
|
||||
class ExternProcessor extends StringProcessor {
|
||||
constructor() {
|
||||
super(DwengoContentType.EXTERN);
|
||||
}
|
||||
|
||||
override renderFn(externURL: string) {
|
||||
if (!isValidHttpUrl(externURL)) {
|
||||
throw new ProcessingError('The url is not valid: ' + externURL);
|
||||
}
|
||||
|
||||
// If a seperate youtube-processor would be added, this code would need to move to that processor
|
||||
// Converts youtube urls to youtube-embed urls
|
||||
const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL);
|
||||
if (match) {
|
||||
externURL = match[1] + 'embed/' + match[2];
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(
|
||||
`
|
||||
<div class="iframe-container">
|
||||
<iframe src="${externURL}" allowfullscreen></iframe>
|
||||
</div>`,
|
||||
{ ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExternProcessor;
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { GIFTQuestion, parse } from 'gift-pegjs';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js';
|
||||
import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js';
|
||||
import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js';
|
||||
import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js';
|
||||
import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js';
|
||||
import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js';
|
||||
import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js';
|
||||
import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js';
|
||||
import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
|
||||
class GiftProcessor extends StringProcessor {
|
||||
private renderers: RendererMap = {
|
||||
Category: new CategoryQuestionRenderer(),
|
||||
Description: new DescriptionQuestionRenderer(),
|
||||
Essay: new EssayQuestionRenderer(),
|
||||
Matching: new MatchingQuestionRenderer(),
|
||||
Numerical: new NumericalQuestionRenderer(),
|
||||
Short: new ShortQuestionRenderer(),
|
||||
TF: new TrueFalseQuestionRenderer(),
|
||||
MC: new MultipleChoiceQuestionRenderer(),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super(DwengoContentType.GIFT);
|
||||
}
|
||||
|
||||
override renderFn(giftString: string) {
|
||||
const quizQuestions: GIFTQuestion[] = parse(giftString);
|
||||
|
||||
let html = "<div class='learning-object-gift'>\n";
|
||||
let i = 1;
|
||||
for (const question of quizQuestions) {
|
||||
html += ` <div class='gift-question' id='gift-q${i}'>\n`;
|
||||
html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation.
|
||||
html += ` </div>\n`;
|
||||
i++;
|
||||
}
|
||||
html += '</div>\n';
|
||||
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
private renderQuestion<T extends GIFTQuestion>(question: T, questionNumber: number): string {
|
||||
const renderer = this.renderers[question.type] as GIFTQuestionRenderer<T>;
|
||||
return renderer.render(question, questionNumber);
|
||||
}
|
||||
}
|
||||
|
||||
type RendererMap = {
|
||||
[K in GIFTQuestion['type']]: GIFTQuestionRenderer<Extract<GIFTQuestion, { type: K }>>;
|
||||
};
|
||||
|
||||
export default GiftProcessor;
|
|
@ -0,0 +1,9 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { Category } from 'gift-pegjs';
|
||||
import { ProcessingError } from '../../processing-error.js';
|
||||
|
||||
export class CategoryQuestionRenderer extends GIFTQuestionRenderer<Category> {
|
||||
render(question: Category, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Category' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { Description } from 'gift-pegjs';
|
||||
import { ProcessingError } from '../../processing-error.js';
|
||||
|
||||
export class DescriptionQuestionRenderer extends GIFTQuestionRenderer<Description> {
|
||||
render(question: Description, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Description' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { Essay } from 'gift-pegjs';
|
||||
|
||||
export class EssayQuestionRenderer extends GIFTQuestionRenderer<Essay> {
|
||||
render(question: Essay, questionNumber: number): string {
|
||||
let renderedHtml = '';
|
||||
if (question.title) {
|
||||
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
|
||||
}
|
||||
if (question.stem) {
|
||||
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
|
||||
}
|
||||
renderedHtml += `<textarea class='gift-essay-answer' id='gift-q${questionNumber}-answer'></textarea>\n`;
|
||||
return renderedHtml;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { GIFTQuestion } from 'gift-pegjs';
|
||||
|
||||
/**
|
||||
* Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML.
|
||||
*/
|
||||
export abstract class GIFTQuestionRenderer<T extends GIFTQuestion> {
|
||||
/**
|
||||
* Render the given question to HTML.
|
||||
* @param question The question.
|
||||
* @param questionNumber The index number of the question.
|
||||
* @returns The question rendered as HTML.
|
||||
*/
|
||||
abstract render(question: T, questionNumber: number): string;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { Matching } from 'gift-pegjs';
|
||||
import { ProcessingError } from '../../processing-error.js';
|
||||
|
||||
export class MatchingQuestionRenderer extends GIFTQuestionRenderer<Matching> {
|
||||
render(question: Matching, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Matching' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { MultipleChoice } from 'gift-pegjs';
|
||||
|
||||
export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<MultipleChoice> {
|
||||
render(question: MultipleChoice, questionNumber: number): string {
|
||||
let renderedHtml = '';
|
||||
if (question.title) {
|
||||
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
|
||||
}
|
||||
if (question.stem) {
|
||||
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
|
||||
}
|
||||
let i = 0;
|
||||
for (const choice of question.choices) {
|
||||
renderedHtml += `<div class="gift-choice-div">\n`;
|
||||
renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`;
|
||||
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`;
|
||||
renderedHtml += `</div>\n`;
|
||||
i++;
|
||||
}
|
||||
return renderedHtml;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { Numerical } from 'gift-pegjs';
|
||||
import { ProcessingError } from '../../processing-error.js';
|
||||
|
||||
export class NumericalQuestionRenderer extends GIFTQuestionRenderer<Numerical> {
|
||||
render(question: Numerical, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'Numerical' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { ShortAnswer } from 'gift-pegjs';
|
||||
import { ProcessingError } from '../../processing-error.js';
|
||||
|
||||
export class ShortQuestionRenderer extends GIFTQuestionRenderer<ShortAnswer> {
|
||||
render(question: ShortAnswer, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
|
||||
import { TrueFalse } from 'gift-pegjs';
|
||||
import { ProcessingError } from '../../processing-error.js';
|
||||
|
||||
export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer<TrueFalse> {
|
||||
render(question: TrueFalse, questionNumber: number): string {
|
||||
throw new ProcessingError("The question type 'TrueFalse' is not supported yet!");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js
|
||||
*/
|
||||
|
||||
import InlineImageProcessor from './inline-image-processor.js';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
class BlockImageProcessor extends InlineImageProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override renderFn(imageUrl: string) {
|
||||
const inlineHtml = super.render(imageUrl);
|
||||
return DOMPurify.sanitize(`<div>${inlineHtml}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
export default BlockImageProcessor;
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import { ProcessingError } from '../processing-error.js';
|
||||
import { isValidHttpUrl } from '../../../../util/links.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
|
||||
class InlineImageProcessor extends StringProcessor {
|
||||
constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) {
|
||||
super(contentType);
|
||||
}
|
||||
|
||||
override renderFn(imageUrl: string) {
|
||||
if (!isValidHttpUrl(imageUrl)) {
|
||||
throw new ProcessingError(`Image URL is invalid: ${imageUrl}`);
|
||||
}
|
||||
return DOMPurify.sanitize(`<img src="${imageUrl}" alt="">`);
|
||||
}
|
||||
}
|
||||
|
||||
export default InlineImageProcessor;
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!]
|
||||
*/
|
||||
import PdfProcessor from '../pdf/pdf-processor.js';
|
||||
import AudioProcessor from '../audio/audio-processor.js';
|
||||
import ExternProcessor from '../extern/extern-processor.js';
|
||||
import InlineImageProcessor from '../image/inline-image-processor.js';
|
||||
import * as marked from 'marked';
|
||||
import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js';
|
||||
import { ProcessingError } from '../processing-error.js';
|
||||
import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js';
|
||||
import { Language } from '../../../../entities/content/language.js';
|
||||
|
||||
import Image = marked.Tokens.Image;
|
||||
import Heading = marked.Tokens.Heading;
|
||||
import Link = marked.Tokens.Link;
|
||||
import RendererObject = marked.RendererObject;
|
||||
|
||||
const prefixes = {
|
||||
learningObject: '@learning-object',
|
||||
pdf: '@pdf',
|
||||
audio: '@audio',
|
||||
extern: '@extern',
|
||||
video: '@youtube',
|
||||
notebook: '@notebook',
|
||||
blockly: '@blockly',
|
||||
};
|
||||
|
||||
function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier {
|
||||
const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/');
|
||||
return {
|
||||
hruid,
|
||||
language: language as Language,
|
||||
version: parseInt(version),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An extension for the renderer of the Marked Markdown renderer which adds support for
|
||||
* - a custom heading,
|
||||
* - links to other learning objects,
|
||||
* - embeddings of other learning objects.
|
||||
*/
|
||||
const dwengoMarkedRenderer: RendererObject = {
|
||||
heading(heading: Heading): string {
|
||||
const text = heading.text;
|
||||
const level = heading.depth;
|
||||
const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');
|
||||
|
||||
return (
|
||||
`<h${level}>\n` +
|
||||
` <a name="${escapedText}" class="anchor" href="#${escapedText}">\n` +
|
||||
` <span class="header-link"></span>\n` +
|
||||
` </a>\n` +
|
||||
` ${text}\n` +
|
||||
`</h${level}>\n`
|
||||
);
|
||||
},
|
||||
|
||||
// When the syntax for a link is used => [text](href "title")
|
||||
// Render a custom link when the prefix for a learning object is used.
|
||||
link(link: Link): string {
|
||||
const href = link.href;
|
||||
const title = link.title || '';
|
||||
const text = marked.parseInline(link.text); // There could for example be an image in the link.
|
||||
|
||||
if (href.startsWith(prefixes.learningObject)) {
|
||||
// Link to learning-object
|
||||
const learningObjectId = extractLearningObjectIdFromHref(href);
|
||||
return `<a href="${getUrlStringForLearningObjectHTML(learningObjectId)}" target="_blank" title="${title}">${text}</a>`;
|
||||
}
|
||||
// Any other link
|
||||
if (!isValidHttpUrl(href)) {
|
||||
throw new ProcessingError('Link is not a valid HTTP URL!');
|
||||
}
|
||||
//<a href="https://kiks.ilabt.imec.be/hub/tmplogin?id=0101" title="Notebooks Werking"><img src="Knop.png" alt="" title="Knop"></a>
|
||||
return `<a href="${href}" target="_blank" title="${title}">${text}</a>`;
|
||||
},
|
||||
|
||||
// When the syntax for an image is used => 
|
||||
// Render a learning object, pdf, audio or video if a prefix is used.
|
||||
image(img: Image): string {
|
||||
const href = img.href;
|
||||
if (href.startsWith(prefixes.learningObject)) {
|
||||
// Embedded learning-object
|
||||
const learningObjectId = extractLearningObjectIdFromHref(href);
|
||||
return `
|
||||
<learning-object hruid="${learningObjectId.hruid}" language="${learningObjectId.language}" version="${learningObjectId.version}"/>
|
||||
`; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!)
|
||||
} else if (href.startsWith(prefixes.pdf)) {
|
||||
// Embedded pdf
|
||||
const proc = new PdfProcessor();
|
||||
return proc.render(href.split(/\/(.+)/, 2)[1]);
|
||||
} else if (href.startsWith(prefixes.audio)) {
|
||||
// Embedded audio
|
||||
const proc = new AudioProcessor();
|
||||
return proc.render(href.split(/\/(.+)/, 2)[1]);
|
||||
} else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) {
|
||||
// Embedded youtube video or notebook (or other extern content)
|
||||
const proc = new ExternProcessor();
|
||||
return proc.render(href.split(/\/(.+)/, 2)[1]);
|
||||
}
|
||||
// Embedded image
|
||||
const proc = new InlineImageProcessor();
|
||||
return proc.render(href);
|
||||
},
|
||||
};
|
||||
|
||||
export default dwengoMarkedRenderer;
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js
|
||||
*/
|
||||
|
||||
import { marked } from 'marked';
|
||||
import InlineImageProcessor from '../image/inline-image-processor.js';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import dwengoMarkedRenderer from './dwengo-marked-renderer.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
import { ProcessingError } from '../processing-error.js';
|
||||
|
||||
class MarkdownProcessor extends StringProcessor {
|
||||
constructor() {
|
||||
super(DwengoContentType.TEXT_MARKDOWN);
|
||||
}
|
||||
|
||||
override renderFn(mdText: string) {
|
||||
let html = '';
|
||||
try {
|
||||
marked.use({ renderer: dwengoMarkedRenderer });
|
||||
html = marked(mdText, { async: false });
|
||||
html = this.replaceLinks(html); // Replace html image links path
|
||||
} catch (e: any) {
|
||||
throw new ProcessingError(e.message);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
replaceLinks(html: string) {
|
||||
const proc = new InlineImageProcessor();
|
||||
html = html.replace(
|
||||
/<img.*?src="(.*?)".*?(alt="(.*?)")?.*?(title="(.*?)")?.*?>/g,
|
||||
(match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src)
|
||||
);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
export { MarkdownProcessor };
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js
|
||||
*
|
||||
* WARNING: The support for PDF learning objects is currently still experimental.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import { isValidHttpUrl } from '../../../../util/links.js';
|
||||
import { ProcessingError } from '../processing-error.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
|
||||
class PdfProcessor extends StringProcessor {
|
||||
constructor() {
|
||||
super(DwengoContentType.APPLICATION_PDF);
|
||||
}
|
||||
|
||||
override renderFn(pdfUrl: string) {
|
||||
if (!isValidHttpUrl(pdfUrl)) {
|
||||
throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`);
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(
|
||||
`
|
||||
<embed src="${pdfUrl}" type="application/pdf" width="100%" height="800px"/>
|
||||
`,
|
||||
{ ADD_TAGS: ['embed'] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PdfProcessor;
|
|
@ -0,0 +1,5 @@
|
|||
export class ProcessingError extends Error {
|
||||
constructor(error: string) {
|
||||
super(error);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js
|
||||
*/
|
||||
|
||||
import BlockImageProcessor from './image/block-image-processor.js';
|
||||
import InlineImageProcessor from './image/inline-image-processor.js';
|
||||
import { MarkdownProcessor } from './markdown/markdown-processor.js';
|
||||
import TextProcessor from './text/text-processor.js';
|
||||
import AudioProcessor from './audio/audio-processor.js';
|
||||
import PdfProcessor from './pdf/pdf-processor.js';
|
||||
import ExternProcessor from './extern/extern-processor.js';
|
||||
import GiftProcessor from './gift/gift-processor.js';
|
||||
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
|
||||
import Processor from './processor.js';
|
||||
import { DwengoContentType } from './content-type.js';
|
||||
import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js';
|
||||
import { Language } from '../../../entities/content/language.js';
|
||||
import { replaceAsync } from '../../../util/async.js';
|
||||
|
||||
const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g;
|
||||
const LEARNING_OBJECT_DOES_NOT_EXIST = "<div class='non-existing-learning-object' />";
|
||||
|
||||
class ProcessingService {
|
||||
private processors!: Map<DwengoContentType, Processor<any>>;
|
||||
|
||||
constructor() {
|
||||
const processors = [
|
||||
new InlineImageProcessor(),
|
||||
new BlockImageProcessor(),
|
||||
new MarkdownProcessor(),
|
||||
new TextProcessor(),
|
||||
new AudioProcessor(),
|
||||
new PdfProcessor(),
|
||||
new ExternProcessor(),
|
||||
new GiftProcessor(),
|
||||
];
|
||||
|
||||
this.processors = new Map(processors.map((processor) => [processor.contentType, processor]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the given learning object.
|
||||
* @param learningObject The learning object to render
|
||||
* @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and
|
||||
* returns the corresponding learning object. This is used to fetch learning
|
||||
* objects embedded into this one.
|
||||
* If this argument is omitted, embedded learning objects will be represented
|
||||
* by placeholders.
|
||||
* @returns Rendered HTML for this LearningObject as a string.
|
||||
*/
|
||||
async render(
|
||||
learningObject: LearningObject,
|
||||
fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise<LearningObject | null>
|
||||
): Promise<string> {
|
||||
const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject);
|
||||
if (fetchEmbeddedLearningObjects) {
|
||||
// Replace all embedded learning objects.
|
||||
return replaceAsync(
|
||||
html,
|
||||
EMBEDDED_LEARNING_OBJECT_PLACEHOLDER,
|
||||
async (_, hruid: string, language: string, version: string): Promise<string> => {
|
||||
// Fetch the embedded learning object...
|
||||
const learningObject = await fetchEmbeddedLearningObjects({
|
||||
hruid,
|
||||
language: language as Language,
|
||||
version: parseInt(version),
|
||||
});
|
||||
|
||||
// If it does not exist, replace it by a placeholder.
|
||||
if (!learningObject) {
|
||||
return LEARNING_OBJECT_DOES_NOT_EXIST;
|
||||
}
|
||||
|
||||
// ... and render it.
|
||||
return this.render(learningObject);
|
||||
}
|
||||
);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ProcessingService();
|
|
@ -0,0 +1,61 @@
|
|||
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
|
||||
import { ProcessingError } from './processing-error.js';
|
||||
import { DwengoContentType } from './content-type.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for all processors.
|
||||
* Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML.
|
||||
*
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js
|
||||
*/
|
||||
abstract class Processor<T> {
|
||||
protected constructor(public contentType: DwengoContentType) {}
|
||||
|
||||
/**
|
||||
* Render the given object.
|
||||
*
|
||||
* @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this
|
||||
* Processor is responsible.
|
||||
* @return Rendered HTML-string
|
||||
* @throws ProcessingError if the rendering fails.
|
||||
*/
|
||||
render(toRender: T): string {
|
||||
return this.renderFn(toRender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a learning object with the content type for which this processor is responsible.
|
||||
* @param toRender
|
||||
*/
|
||||
renderLearningObject(toRender: LearningObject): string {
|
||||
if (toRender.contentType !== this.contentType) {
|
||||
throw new ProcessingError(
|
||||
`Unsupported content type: ${toRender.contentType}.
|
||||
This processor is only responsible for content of type ${this.contentType}.`
|
||||
);
|
||||
}
|
||||
return this.renderLearningObjectFn(toRender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function which actually renders the content.
|
||||
*
|
||||
* @param toRender Content to be rendered
|
||||
* @return Rendered HTML as a string
|
||||
* @protected
|
||||
*/
|
||||
protected abstract renderFn(toRender: T): string;
|
||||
|
||||
/**
|
||||
* Function which actually executes the rendering of a learning object.
|
||||
*
|
||||
* When implementing this function, we may assume that we are responsible for the content type of the learning
|
||||
* object.
|
||||
*
|
||||
* @param toRender Learning object to render
|
||||
* @protected
|
||||
*/
|
||||
protected abstract renderLearningObjectFn(toRender: LearningObject): string;
|
||||
}
|
||||
|
||||
export default Processor;
|
|
@ -0,0 +1,19 @@
|
|||
import Processor from './processor.js';
|
||||
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
|
||||
|
||||
export abstract class StringProcessor extends Processor<string> {
|
||||
/**
|
||||
* Function which actually executes the rendering of a learning object.
|
||||
* By default, this just means rendering the content in the content property of the learning object (interpreted
|
||||
* as string)
|
||||
*
|
||||
* When implementing this function, we may assume that we are responsible for the content type of the learning
|
||||
* object.
|
||||
*
|
||||
* @param toRender Learning object to render
|
||||
* @protected
|
||||
*/
|
||||
protected renderLearningObjectFn(toRender: LearningObject): string {
|
||||
return this.render(toRender.content.toString('ascii'));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js
|
||||
*/
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { DwengoContentType } from '../content-type.js';
|
||||
import { StringProcessor } from '../string-processor.js';
|
||||
|
||||
class TextProcessor extends StringProcessor {
|
||||
constructor() {
|
||||
super(DwengoContentType.TEXT_PLAIN);
|
||||
}
|
||||
|
||||
override renderFn(text: string) {
|
||||
// Sanitize plain text to prevent xss.
|
||||
return DOMPurify.sanitize(text);
|
||||
}
|
||||
}
|
||||
|
||||
export default TextProcessor;
|
|
@ -0,0 +1,190 @@
|
|||
import { LearningPathProvider } from './learning-path-provider.js';
|
||||
import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js';
|
||||
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
|
||||
import { getLearningPathRepository } from '../../data/repositories.js';
|
||||
import { Language } from '../../entities/content/language.js';
|
||||
import learningObjectService from '../learning-objects/learning-object-service.js';
|
||||
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||
import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js';
|
||||
|
||||
/**
|
||||
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
|
||||
* corresponding learning object.
|
||||
* @param nodes The nodes to find the learning object for.
|
||||
*/
|
||||
async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> {
|
||||
// Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
|
||||
// Its corresponding learning object.
|
||||
const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>(
|
||||
await Promise.all(
|
||||
nodes.map((node) =>
|
||||
learningObjectService
|
||||
.getLearningObjectById({
|
||||
hruid: node.learningObjectHruid,
|
||||
version: node.version,
|
||||
language: node.language,
|
||||
})
|
||||
.then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject])
|
||||
)
|
||||
)
|
||||
);
|
||||
if (nullableNodesToLearningObjects.values().some((it) => it === null)) {
|
||||
throw new Error('At least one of the learning objects on this path could not be found.');
|
||||
}
|
||||
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given learning path entity to an object which conforms to the learning path content.
|
||||
*/
|
||||
async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> {
|
||||
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
|
||||
|
||||
const targetAges = nodesToLearningObjects
|
||||
.values()
|
||||
.flatMap((it) => it.targetAges || [])
|
||||
.toArray();
|
||||
|
||||
const keywords = nodesToLearningObjects
|
||||
.values()
|
||||
.flatMap((it) => it.keywords || [])
|
||||
.toArray();
|
||||
|
||||
const image = learningPath.image ? learningPath.image.toString('base64') : undefined;
|
||||
|
||||
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
|
||||
|
||||
return {
|
||||
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
|
||||
__order: order,
|
||||
hruid: learningPath.hruid,
|
||||
language: learningPath.language,
|
||||
description: learningPath.description,
|
||||
image: image,
|
||||
title: learningPath.title,
|
||||
nodes: convertedNodes,
|
||||
num_nodes: learningPath.nodes.length,
|
||||
num_nodes_left: convertedNodes.filter((it) => !it.done).length,
|
||||
keywords: keywords.join(' '),
|
||||
target_ages: targetAges,
|
||||
max_age: Math.max(...targetAges),
|
||||
min_age: Math.min(...targetAges),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding
|
||||
* learning objects into a list of learning path nodes as they should be represented in the API.
|
||||
* @param nodesToLearningObjects
|
||||
* @param personalizedFor
|
||||
*/
|
||||
async function convertNodes(
|
||||
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
): Promise<LearningObjectNode[]> {
|
||||
const nodesPromise = nodesToLearningObjects
|
||||
.entries()
|
||||
.map(async (entry) => {
|
||||
const [node, learningObject] = entry;
|
||||
const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null;
|
||||
return {
|
||||
_id: learningObject.uuid,
|
||||
language: learningObject.language,
|
||||
start_node: node.startNode,
|
||||
created_at: node.createdAt.toISOString(),
|
||||
updatedAt: node.updatedAt.toISOString(),
|
||||
learningobject_hruid: node.learningObjectHruid,
|
||||
version: learningObject.version,
|
||||
transitions: node.transitions
|
||||
.filter(
|
||||
(trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible.
|
||||
)
|
||||
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition
|
||||
};
|
||||
})
|
||||
.toArray();
|
||||
return await Promise.all(nodesPromise);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to convert a json string to an object, or null if it is undefined.
|
||||
*/
|
||||
function optionalJsonStringToObject(jsonString?: string): object | null {
|
||||
if (!jsonString) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function which converts a transition in the database representation to a transition in the representation
|
||||
* the Dwengo API uses.
|
||||
*
|
||||
* @param transition
|
||||
* @param index
|
||||
* @param nodesToLearningObjects
|
||||
*/
|
||||
function convertTransition(
|
||||
transition: LearningPathTransition,
|
||||
index: number,
|
||||
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
|
||||
): Transition {
|
||||
const nextNode = nodesToLearningObjects.get(transition.next);
|
||||
if (!nextNode) {
|
||||
throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`);
|
||||
} else {
|
||||
return {
|
||||
_id: '' + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
|
||||
default: false, // We don't work with default transitions but retain this for backwards compatibility.
|
||||
next: {
|
||||
_id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
|
||||
hruid: transition.next.learningObjectHruid,
|
||||
language: nextNode.language,
|
||||
version: nextNode.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service providing access to data about learning paths from the database.
|
||||
*/
|
||||
const databaseLearningPathProvider: LearningPathProvider = {
|
||||
/**
|
||||
* Fetch the learning paths with the given hruids from the database.
|
||||
*/
|
||||
async fetchLearningPaths(
|
||||
hruids: string[],
|
||||
language: Language,
|
||||
source: string,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
): Promise<LearningPathResponse> {
|
||||
const learningPathRepo = getLearningPathRepository();
|
||||
|
||||
const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter(
|
||||
(learningPath) => learningPath !== null
|
||||
);
|
||||
const filteredLearningPaths = await Promise.all(
|
||||
learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor))
|
||||
);
|
||||
|
||||
return {
|
||||
success: filteredLearningPaths.length > 0,
|
||||
data: await Promise.all(filteredLearningPaths),
|
||||
source,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Search learning paths in the database using the given search string.
|
||||
*/
|
||||
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
|
||||
const learningPathRepo = getLearningPathRepository();
|
||||
|
||||
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);
|
||||
return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor)));
|
||||
},
|
||||
};
|
||||
|
||||
export default databaseLearningPathProvider;
|
|
@ -0,0 +1,50 @@
|
|||
import { fetchWithLogging } from '../../util/apiHelper.js';
|
||||
import { DWENGO_API_BASE } from '../../config.js';
|
||||
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
|
||||
import { LearningPathProvider } from './learning-path-provider.js';
|
||||
import { getLogger, Logger } from '../../logging/initalize.js';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
const dwengoApiLearningPathProvider: LearningPathProvider = {
|
||||
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
|
||||
if (hruids.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
source,
|
||||
data: null,
|
||||
message: `No HRUIDs provided for ${source}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
|
||||
const params = { pathIdList: JSON.stringify({ hruids }), language };
|
||||
|
||||
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
|
||||
|
||||
if (!learningPaths || learningPaths.length === 0) {
|
||||
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
|
||||
return {
|
||||
success: false,
|
||||
source,
|
||||
data: [],
|
||||
message: `No learning paths found for ${source}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
source,
|
||||
data: learningPaths,
|
||||
};
|
||||
},
|
||||
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
|
||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
|
||||
const params = { all: query, language };
|
||||
|
||||
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
|
||||
return searchResults ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
export default dwengoApiLearningPathProvider;
|
|
@ -0,0 +1,90 @@
|
|||
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
|
||||
import { Student } from '../../entities/users/student.entity.js';
|
||||
import { Group } from '../../entities/assignments/group.entity.js';
|
||||
import { Submission } from '../../entities/assignments/submission.entity.js';
|
||||
import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js';
|
||||
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
|
||||
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
|
||||
import { JSONPath } from 'jsonpath-plus';
|
||||
|
||||
export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group };
|
||||
|
||||
/**
|
||||
* Shortcut function to easily create a PersonalizationTarget object for a student by his/her username.
|
||||
* @param username Username of the student we want to generate a personalized learning path for.
|
||||
* If there is no student with this username, return undefined.
|
||||
*/
|
||||
export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> {
|
||||
const student = await getStudentRepository().findByUsername(username);
|
||||
if (student) {
|
||||
return {
|
||||
type: 'student',
|
||||
student: student,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and
|
||||
* group number.
|
||||
* @param classId Id of the class in which this group was created
|
||||
* @param assignmentNumber Number of the assignment for which this group was created
|
||||
* @param groupNumber Number of the group for which we want to personalize the learning path.
|
||||
*/
|
||||
export async function personalizedForGroup(
|
||||
classId: string,
|
||||
assignmentNumber: number,
|
||||
groupNumber: number
|
||||
): Promise<PersonalizationTarget | undefined> {
|
||||
const clazz = await getClassRepository().findById(classId);
|
||||
if (!clazz) {
|
||||
return undefined;
|
||||
}
|
||||
const group = await getGroupRepository().findOne({
|
||||
assignment: {
|
||||
within: clazz,
|
||||
id: assignmentNumber,
|
||||
},
|
||||
groupNumber: groupNumber,
|
||||
});
|
||||
if (group) {
|
||||
return {
|
||||
type: 'group',
|
||||
group: group,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last submission for the learning object associated with the given node and for the student or group
|
||||
*/
|
||||
export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> {
|
||||
const submissionRepo = getSubmissionRepository();
|
||||
const learningObjectId: LearningObjectIdentifier = {
|
||||
hruid: node.learningObjectHruid,
|
||||
language: node.language,
|
||||
version: node.version,
|
||||
};
|
||||
if (pathFor.type === 'group') {
|
||||
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
|
||||
}
|
||||
return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the condition of the given transaction is fulfilled by the given submission.
|
||||
* @param transition
|
||||
* @param submitted
|
||||
*/
|
||||
export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean {
|
||||
if (transition.condition === 'true' || !transition.condition) {
|
||||
return true; // If the transition is unconditional, we can go on.
|
||||
}
|
||||
if (submitted === null) {
|
||||
return false; // If the transition is not unconditional and there was no submission, the transition is not possible.
|
||||
}
|
||||
const match = JSONPath({ path: transition.condition, json: { submission: submitted } });
|
||||
return match.length === 1;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
|
||||
import { Language } from '../../entities/content/language.js';
|
||||
import { PersonalizationTarget } from './learning-path-personalization-util.js';
|
||||
|
||||
/**
|
||||
* Generic interface for a service which provides access to learning paths from a data source.
|
||||
*/
|
||||
export interface LearningPathProvider {
|
||||
/**
|
||||
* Fetch the learning paths with the given hruids from the data source.
|
||||
*/
|
||||
fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>;
|
||||
|
||||
/**
|
||||
* Search learning paths in the data source using the given search string.
|
||||
*/
|
||||
searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>;
|
||||
}
|
57
backend/src/services/learning-paths/learning-path-service.ts
Normal file
57
backend/src/services/learning-paths/learning-path-service.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
|
||||
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
|
||||
import databaseLearningPathProvider from './database-learning-path-provider.js';
|
||||
import { EnvVars, getEnvVar } from '../../util/envvars.js';
|
||||
import { Language } from '../../entities/content/language.js';
|
||||
import { PersonalizationTarget } from './learning-path-personalization-util.js';
|
||||
|
||||
const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix);
|
||||
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
|
||||
|
||||
/**
|
||||
* Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api)
|
||||
*/
|
||||
const learningPathService = {
|
||||
/**
|
||||
* Fetch the learning paths with the given hruids from the data source.
|
||||
* @param hruids For each of the hruids, the learning path will be fetched.
|
||||
* @param language This is the language each of the learning paths will use.
|
||||
* @param source
|
||||
* @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned.
|
||||
*/
|
||||
async fetchLearningPaths(
|
||||
hruids: string[],
|
||||
language: Language,
|
||||
source: string,
|
||||
personalizedFor?: PersonalizationTarget
|
||||
): Promise<LearningPathResponse> {
|
||||
const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix));
|
||||
const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix));
|
||||
|
||||
const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor);
|
||||
const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(
|
||||
nonUserContentHruids,
|
||||
language,
|
||||
source,
|
||||
personalizedFor
|
||||
);
|
||||
|
||||
const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []);
|
||||
|
||||
return {
|
||||
data: result,
|
||||
source: source,
|
||||
success: userContentLearningPaths.success || nonUserContentLearningPaths.success,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Search learning paths in the data source using the given search string.
|
||||
*/
|
||||
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
|
||||
const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor)));
|
||||
return providerResponses.flat();
|
||||
},
|
||||
};
|
||||
|
||||
export default learningPathService;
|
|
@ -1,137 +0,0 @@
|
|||
import { DWENGO_API_BASE } from '../config.js';
|
||||
import { fetchWithLogging } from '../util/apiHelper.js';
|
||||
import {
|
||||
FilteredLearningObject,
|
||||
LearningObjectMetadata,
|
||||
LearningObjectNode,
|
||||
LearningPathResponse,
|
||||
} from '../interfaces/learningPath.js';
|
||||
import { fetchLearningPaths } from './learningPaths.js';
|
||||
import { getLogger, Logger } from '../logging/initalize.js';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
function filterData(
|
||||
data: LearningObjectMetadata,
|
||||
htmlUrl: string
|
||||
): FilteredLearningObject {
|
||||
return {
|
||||
key: data.hruid, // Hruid learningObject (not path)
|
||||
_id: data._id,
|
||||
uuid: data.uuid,
|
||||
version: data.version,
|
||||
title: data.title,
|
||||
htmlUrl, // Url to fetch html content
|
||||
language: data.language,
|
||||
difficulty: data.difficulty,
|
||||
estimatedTime: data.estimated_time,
|
||||
available: data.available,
|
||||
teacherExclusive: data.teacher_exclusive,
|
||||
educationalGoals: data.educational_goals, // List with learningObjects
|
||||
keywords: data.keywords, // For search
|
||||
description: data.description, // For search (not an actual description)
|
||||
targetAges: data.target_ages,
|
||||
contentType: data.content_type, // Markdown, image, audio, etc.
|
||||
contentLocation: data.content_location, // If content type extern
|
||||
skosConcepts: data.skos_concepts,
|
||||
returnValue: data.return_value, // Callback response information
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single learning object by its HRUID
|
||||
*/
|
||||
export async function getLearningObjectById(
|
||||
hruid: string,
|
||||
language: string
|
||||
): Promise<FilteredLearningObject | null> {
|
||||
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`;
|
||||
const metadata = await fetchWithLogging<LearningObjectMetadata>(
|
||||
metadataUrl,
|
||||
`Metadata for Learning Object HRUID "${hruid}" (language ${language})`
|
||||
);
|
||||
|
||||
if (!metadata) {
|
||||
logger.warn(`⚠️ WARNING: Learning object "${hruid}" not found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`;
|
||||
return filterData(metadata, htmlUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to fetch learning objects (full data or just HRUIDs)
|
||||
*/
|
||||
async function fetchLearningObjects(
|
||||
hruid: string,
|
||||
full: boolean,
|
||||
language: string
|
||||
): Promise<FilteredLearningObject[] | string[]> {
|
||||
try {
|
||||
const learningPathResponse: LearningPathResponse =
|
||||
await fetchLearningPaths(
|
||||
[hruid],
|
||||
language,
|
||||
`Learning path for HRUID "${hruid}"`
|
||||
);
|
||||
|
||||
if (
|
||||
!learningPathResponse.success ||
|
||||
!learningPathResponse.data?.length
|
||||
) {
|
||||
logger.warn(
|
||||
`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes;
|
||||
|
||||
if (!full) {
|
||||
return nodes.map((node) => {
|
||||
return node.learningobject_hruid;
|
||||
});
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
nodes.map(async (node) => {
|
||||
return getLearningObjectById(
|
||||
node.learningobject_hruid,
|
||||
language
|
||||
);
|
||||
})
|
||||
).then((objects) => {
|
||||
return objects.filter((obj): obj is FilteredLearningObject => {
|
||||
return obj !== null;
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Error fetching learning objects:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full learning object data (metadata)
|
||||
*/
|
||||
export async function getLearningObjectsFromPath(
|
||||
hruid: string,
|
||||
language: string
|
||||
): Promise<FilteredLearningObject[]> {
|
||||
return (await fetchLearningObjects(
|
||||
hruid,
|
||||
true,
|
||||
language
|
||||
)) as FilteredLearningObject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch only learning object HRUIDs
|
||||
*/
|
||||
export async function getLearningObjectIdsFromPath(
|
||||
hruid: string,
|
||||
language: string
|
||||
): Promise<string[]> {
|
||||
return (await fetchLearningObjects(hruid, false, language)) as string[];
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import { fetchWithLogging } from '../util/apiHelper.js';
|
||||
import { DWENGO_API_BASE } from '../config.js';
|
||||
import {
|
||||
LearningPath,
|
||||
LearningPathResponse,
|
||||
} from '../interfaces/learningPath.js';
|
||||
import { getLogger, Logger } from '../logging/initalize.js';
|
||||
|
||||
const logger: Logger = getLogger();
|
||||
|
||||
export async function fetchLearningPaths(
|
||||
hruids: string[],
|
||||
language: string,
|
||||
source: string
|
||||
): Promise<LearningPathResponse> {
|
||||
if (hruids.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
source,
|
||||
data: null,
|
||||
message: `No HRUIDs provided for ${source}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
|
||||
const params = { pathIdList: JSON.stringify({ hruids }), language };
|
||||
|
||||
const learningPaths = await fetchWithLogging<LearningPath[]>(
|
||||
apiUrl,
|
||||
`Learning paths for ${source}`,
|
||||
params
|
||||
);
|
||||
|
||||
if (!learningPaths || learningPaths.length === 0) {
|
||||
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
|
||||
return {
|
||||
success: false,
|
||||
source,
|
||||
data: [],
|
||||
message: `No learning paths found for ${source}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
source,
|
||||
data: learningPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchLearningPaths(
|
||||
query: string,
|
||||
language: string
|
||||
): Promise<LearningPath[]> {
|
||||
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
|
||||
const params = { all: query, language };
|
||||
|
||||
const searchResults = await fetchWithLogging<LearningPath[]>(
|
||||
apiUrl,
|
||||
`Search learning paths with query "${query}"`,
|
||||
params
|
||||
);
|
||||
return searchResults ?? [];
|
||||
}
|
41
backend/src/sqlite-autoincrement-workaround.ts
Normal file
41
backend/src/sqlite-autoincrement-workaround.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { EntityProperty, EventArgs, EventSubscriber } from '@mikro-orm/core';
|
||||
|
||||
/**
|
||||
* The tests are ran on an in-memory SQLite database. However, SQLite does not allow fields which are part of composite
|
||||
* primary keys to be autoincremented (while PostgreSQL, which we use in production, does). This Subscriber works around
|
||||
* the issue by remembering the highest values for every autoincremented part of a primary key and assigning them when
|
||||
* creating a new entity.
|
||||
*
|
||||
* However, it is important to note the following limitations:
|
||||
* - this class can only be used for in-memory SQLite databases since the information on what the highest sequence
|
||||
* number for each of the properties is, is only saved transiently.
|
||||
* - automatically setting the generated "autoincremented" value for properties only works when the entity is created
|
||||
* via an entityManager.create(...) or repo.create(...) method. Otherwise, onInit will not be called and therefore,
|
||||
* the sequence number will not be filled in.
|
||||
*/
|
||||
export class SqliteAutoincrementSubscriber implements EventSubscriber {
|
||||
private sequenceNumbersForEntityType: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* When an entity with an autoincremented property which is part of the composite private key is created,
|
||||
* automatically fill this property so we won't face not-null-constraint exceptions when persisting it.
|
||||
*/
|
||||
onInit<T extends object>(args: EventArgs<T>): void {
|
||||
if (!args.meta.compositePK) {
|
||||
return; // If there is not a composite primary key, autoincrement works fine with SQLite anyway.
|
||||
}
|
||||
|
||||
for (const prop of Object.values(args.meta.properties)) {
|
||||
const property = prop as EntityProperty<T>;
|
||||
if (property.primary && property.autoincrement && !(args.entity as Record<string, any>)[property.name]) {
|
||||
// Obtain and increment sequence number of this entity.
|
||||
const propertyKey = args.meta.class.name + '.' + property.name;
|
||||
const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0;
|
||||
this.sequenceNumbersForEntityType.set(propertyKey, nextSeqNumber + 1);
|
||||
|
||||
// Set the property accordingly.
|
||||
(args.entity as Record<string, any>)[property.name] = nextSeqNumber + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,35 +9,34 @@ const logger: Logger = getLogger();
|
|||
*
|
||||
* @param url The API endpoint to fetch from.
|
||||
* @param description A short description of what is being fetched (for logging).
|
||||
* @param params
|
||||
* @param options Contains further options such as params (the query params) and responseType (whether the response
|
||||
* should be parsed as JSON ("json") or whether it should be returned as plain text ("text")
|
||||
* @returns The response data if successful, or null if an error occurs.
|
||||
*/
|
||||
export async function fetchWithLogging<T>(
|
||||
url: string,
|
||||
description: string,
|
||||
params?: Record<string, any>
|
||||
options?: {
|
||||
params?: Record<string, any>;
|
||||
query?: Record<string, any>;
|
||||
responseType?: 'json' | 'text';
|
||||
}
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const config: AxiosRequestConfig = params ? { params } : {};
|
||||
|
||||
const config: AxiosRequestConfig = options || {};
|
||||
const response = await axios.get<T>(url, config);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
if (error.response.status === 404) {
|
||||
logger.debug(
|
||||
`❌ ERROR: ${description} not found (404) at "${url}".`
|
||||
);
|
||||
logger.debug(`❌ ERROR: ${description} not found (404) at "${url}".`);
|
||||
} else {
|
||||
logger.debug(
|
||||
`❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`❌ ERROR: Network or unexpected error when fetching ${description}:`,
|
||||
error.message
|
||||
);
|
||||
logger.debug(`❌ ERROR: Network or unexpected error when fetching ${description}:`, error.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
23
backend/src/util/async.ts
Normal file
23
backend/src/util/async.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of
|
||||
* the parts matched by a group in the regex as arguments.
|
||||
*
|
||||
* @param str The string where to replace the occurrences
|
||||
* @param regex
|
||||
* @param replacementFn
|
||||
*/
|
||||
export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise<string>) {
|
||||
const promises: Promise<string>[] = [];
|
||||
|
||||
// First run through matches: add all Promises resulting from the replacement function
|
||||
str.replace(regex, (full, ...args) => {
|
||||
promises.push(replacementFn(full, ...args));
|
||||
return full;
|
||||
});
|
||||
|
||||
// Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner.
|
||||
const replacements: string[] = await Promise.all(promises);
|
||||
|
||||
// Second run through matches: Replace them by their previously computed replacements.
|
||||
return str.replace(regex, () => replacements.pop()!);
|
||||
}
|
|
@ -15,33 +15,18 @@ export const EnvVars: { [key: string]: EnvVar } = {
|
|||
DbUsername: { key: DB_PREFIX + 'USERNAME', required: true },
|
||||
DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true },
|
||||
DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false },
|
||||
LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' },
|
||||
FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' },
|
||||
UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' },
|
||||
IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true },
|
||||
IdpStudentClientId: {
|
||||
key: STUDENT_IDP_PREFIX + 'CLIENT_ID',
|
||||
required: true,
|
||||
},
|
||||
IdpStudentJwksEndpoint: {
|
||||
key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT',
|
||||
required: true,
|
||||
},
|
||||
IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true },
|
||||
IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true },
|
||||
IdpTeacherUrl: { key: TEACHER_IDP_PREFIX + 'URL', required: true },
|
||||
IdpTeacherClientId: {
|
||||
key: TEACHER_IDP_PREFIX + 'CLIENT_ID',
|
||||
required: true,
|
||||
},
|
||||
IdpTeacherJwksEndpoint: {
|
||||
key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT',
|
||||
required: true,
|
||||
},
|
||||
IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true },
|
||||
IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true },
|
||||
IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' },
|
||||
CorsAllowedOrigins: {
|
||||
key: CORS_PREFIX + 'ALLOWED_ORIGINS',
|
||||
defaultValue: '',
|
||||
},
|
||||
CorsAllowedHeaders: {
|
||||
key: CORS_PREFIX + 'ALLOWED_HEADERS',
|
||||
defaultValue: 'Authorization,Content-Type',
|
||||
},
|
||||
CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' },
|
||||
CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
@ -67,9 +52,7 @@ export function getNumericEnvVar(envVar: EnvVar): number {
|
|||
const valueString = getEnvVar(envVar);
|
||||
const value = parseInt(valueString);
|
||||
if (isNaN(value)) {
|
||||
throw new Error(
|
||||
`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`
|
||||
);
|
||||
throw new Error(`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue