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