Merge branch 'dev' into docs/swagger-autogen

This commit is contained in:
Tibo De Peuter 2025-03-13 21:39:35 +01:00
commit 5986ca57bf
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
189 changed files with 6160 additions and 1581 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
**/node_modules/
**/dist
.git
npm-debug.log
.coverage
.coverage.*
.env

View file

@ -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.
-->

View file

@ -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.
-->

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -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

View file

@ -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

View 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
View 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"]

View file

@ -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).

View file

@ -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';

View file

@ -8,4 +8,14 @@ export default [
globals: globals.node,
},
},
{
files: ['tests/**/*.ts'],
languageOptions: {
globals: globals.node,
},
rules: {
'no-console': 'off',
},
},
];

View file

@ -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",

View file

@ -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`);
});
}

View file

@ -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';

View 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);
}

View 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);
}

View file

@ -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' });
}
}

View file

@ -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' });
}
}

View file

@ -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);

View file

@ -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[]> {

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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.
}

View file

@ -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.
}

View file

@ -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.
}

View file

@ -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);

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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',

View file

@ -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[];
}

View file

@ -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[];
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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>;
}

View file

@ -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;

View file

@ -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;

View file

@ -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',
}

View file

@ -4,6 +4,6 @@ export class LearningObjectIdentifier {
constructor(
public hruid: string,
public language: Language,
public version: string
public version: number
) {}
}

View file

@ -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;
}

View 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();
}

View file

@ -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>;
}

View file

@ -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;
}

View file

@ -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();

View file

@ -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;

View file

@ -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(

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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[];

View file

@ -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;
}

View file

@ -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',
};

View file

@ -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');

View file

@ -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),
};
}

View file

@ -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();
}

View file

@ -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;

View file

@ -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();

View file

@ -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}`,

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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>;
}

View file

@ -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;

View file

@ -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;

View file

@ -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 };

View 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;

View file

@ -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;

View file

@ -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!");
}
}

View file

@ -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!");
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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!");
}
}

View file

@ -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;
}
}

View file

@ -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!");
}
}

View file

@ -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!");
}
}

View file

@ -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!");
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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 => ![text](href "title")
// 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;

View file

@ -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 };

View file

@ -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;

View file

@ -0,0 +1,5 @@
export class ProcessingError extends Error {
constructor(error: string) {
super(error);
}
}

View file

@ -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();

View file

@ -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;

View file

@ -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'));
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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[]>;
}

View 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;

View file

@ -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[];
}

View file

@ -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 ?? [];
}

View 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;
}
}
}
}

View file

@ -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
View 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()!);
}

View file

@ -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