Merge branch 'dev' into chore/docker

This commit is contained in:
Tibo De Peuter 2025-03-13 21:11:28 +01:00
commit 441bf990e7
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
149 changed files with 5420 additions and 780 deletions

View file

@ -24,6 +24,14 @@ Een duidelijke, beknopte beschrijving van wat je verwacht dat er gebeurt.
Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt.
**Extra context**
Voeg extra context over het probleem toe. Was je ergens bijzonder mee bezig of naar op zoek?
Was je ergens bijzonder mee bezig of naar op zoek? Welke documentatie of links heb je (al) geraadpleegd?
- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
<!--
## Richtlijnen
Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt:
- Het is duidelijk waar de bug vandaan komt.
- Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
- Ik heb de issue toegekend aan de juiste milestone.
-->

View file

@ -6,7 +6,7 @@ labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
**Is jouw feature request gerelateerd tot een probleem? Beschrijf.**
Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter?
**Beschrijf de oplossing die je zou willen**
@ -15,4 +15,11 @@ Een duidelijke, beknopte beschrijving van wat je zou willen dat er gebeurt.
**Extra context**
Extra context of screenshots bij de feature.
- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
<!--
## Richtlijnen
Zorg ervoor dat het volgende in orde is voordat je de issue aanmaakt:
- Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ...
- Ik heb de issue toegekend aan de juiste milestone.
-->

View file

@ -9,29 +9,26 @@ Figma</a></span>
Projectopgave</a></span>
</p>
<ul align="center" style="list-style-type: none">
<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>Systeembeheerder: Timo De Meyst (<a href="https://github.com/kloep1">@kloep1</a>)</li>
<li>Customer relations officer: Adriaan Jacquet (<a href="https://github.com/WhisperinCheetah">@WhisperinCheetah</a>)</li>
</ul>
Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten
en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen.
## Installatie
Om de applicatie in te stellen voor een productieomgeving, volg de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving).
Om de applicatie in te stellen voor een productieomgeving, volg
de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving).
Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien.
### Quick start
1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) en [Docker Compose](https://docs.docker.com/compose/)).
1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/)
en [Docker Compose](https://docs.docker.com/compose/)).
2. Clone deze repository.
3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar nodig.
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).
5. Optioneel: Configureer de applicatie aan de hand van
de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie).
```bash
docker compose version
@ -47,7 +44,8 @@ docker compose up
### Handmatige installatie
Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) en [backend](./backend/README.md).
Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md)
en [backend](./backend/README.md).
## Architectuur
@ -59,8 +57,31 @@ De tech-stack bestaat uit:
- **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/Developer:-Design-keuzes).
Voor meer informatie over de keuze van deze tech-stack,
zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes).
## Testen
Voer volgende commando's uit om de <frontend/backend> te testen:
```
npm run test:unit
```
## Bijdragen aan Dwengo-1
Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1.
Deze rocksterren hebben bijgedragen aan Dwengo-1:
| Naam | Functie |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| [<img src="https://github.com/WhisperinCheetah.png" width="100px"/><br/><sub><b>Adriaan Jacquet</b></sub>](https://github.com/WhisperinCheetah) | Backend Lead |
| [<img src="https://github.com/Gabriellvl.png" width="100px"/><br/><sub><b>Francisco Gabriel Van Langenhove</b></sub>](https://github.com/Gabriellvl) | Team Lead |
| [<img src="https://github.com/geraldschmittinger.png" width="100px"/><br/><sub><b>Gerald Schmittinger</b></sub>](https://github.com/geraldschmittinger) | Database Administrator |
| [<img src="https://github.com/joyelle436.png" width="100px"/><br/><sub><b>Joyelle Ndagijimana</b></sub>](https://github.com/joyelle436) | Frontend Lead |
| [<img src="https://github.com/laurejablonski.png" width="100px"/><br><sub><b>Laure Jablonski</b></sub>](https://github.com/laurejablonski) | Documentatie- en Test Lead |
| [<img src="https://github.com/tdpeuter.png" width="100px"/><br/><sub><b>Tibo De Peuter</b></sub>](https://github.com/tdpeuter) | Technische Lead |
| [<img src="https://github.com/kloep1.png" width="100px"/><br/><sub><b>Timo De Meyst</b></sub>](https://github.com/kloep1) | System Administrator |
En in de toekomst misschien jij ook?

View file

@ -21,6 +21,14 @@ npm run build
npm run start
```
### Tests
Voer volgend commando uit om de unit tests uit te voeren:
```
npm run test:unit
```
## Keycloak configuratie
Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt.

View file

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

View file

@ -5,40 +5,46 @@
"private": true,
"type": "module",
"scripts": {
"build": "NODE_ENV=production tsc --project tsconfig.json",
"dev": "NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts",
"start": "NODE_ENV=production node --env-file=.env dist/app.js",
"build": "cross-env NODE_ENV=production tsc --project tsconfig.json",
"dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts",
"start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js",
"format": "prettier --write src/",
"format-check": "prettier --check src/",
"lint": "eslint . --fix",
"test:unit": "vitest"
},
"dependencies": {
"@mikro-orm/core": "6.4.6",
"@mikro-orm/postgresql": "6.4.6",
"@mikro-orm/reflection": "6.4.6",
"@mikro-orm/sqlite": "6.4.6",
"@types/js-yaml": "^4.0.9",
"@mikro-orm/core": "6.4.9",
"@mikro-orm/knex": "6.4.9",
"@mikro-orm/postgresql": "6.4.9",
"@mikro-orm/reflection": "6.4.9",
"@mikro-orm/sqlite": "6.4.9",
"axios": "^1.8.2",
"cors": "^2.8.5",
"cross": "^1.0.0",
"cross-env": "^7.0.3",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"express-jwt": "^8.5.1",
"jwks-rsa": "^3.1.0",
"uuid": "^11.1.0",
"gift-pegjs": "^1.0.2",
"isomorphic-dompurify": "^2.22.0",
"js-yaml": "^4.1.0",
"jsonpath-plus": "^10.3.0",
"jwks-rsa": "^3.1.0",
"loki-logger-ts": "^1.0.2",
"marked": "^15.0.7",
"response-time": "^2.3.3",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-loki": "^6.1.3",
"cors": "^2.8.5",
"@types/cors": "^2.8.17"
"winston-loki": "^6.1.3"
},
"devDependencies": {
"@mikro-orm/cli": "6.4.6",
"@mikro-orm/cli": "6.4.9",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.4",
"@types/response-time": "^2.3.8",
"@types/js-yaml": "^4.0.9",
"globals": "^15.15.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.3",

View file

@ -2,8 +2,8 @@ import express, { Express, Response } from 'express';
import { initORM } from './orm.js';
import themeRoutes from './routes/themes.js';
import learningPathRoutes from './routes/learningPaths.js';
import learningObjectRoutes from './routes/learningObjects.js';
import learningPathRoutes from './routes/learning-paths.js';
import learningObjectRoutes from './routes/learning-objects.js';
import studentRouter from './routes/student.js';
import groupRouter from './routes/group.js';

View file

@ -1,10 +1,9 @@
export const FALLBACK_LANG: string = 'nl';
import { EnvVars, getEnvVar } from './util/envvars.js';
// API
export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api';
export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl);
export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage);
// Logging
export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info';
export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102';

View file

@ -0,0 +1,69 @@
import { Request, Response } from 'express';
import { FALLBACK_LANG } from '../config.js';
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js';
import learningObjectService from '../services/learning-objects/learning-object-service.js';
import { EnvVars, getEnvVar } from '../util/envvars.js';
import { Language } from '../entities/content/language.js';
import { BadRequestException } from '../exceptions.js';
import attachmentService from '../services/learning-objects/attachment-service.js';
import { NotFoundError } from '@mikro-orm/core';
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier {
if (!req.params.hruid) {
throw new BadRequestException('HRUID is required.');
}
return {
hruid: req.params.hruid as string,
language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language,
version: parseInt(req.query.version as string),
};
}
function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier {
if (!req.query.hruid) {
throw new BadRequestException('HRUID is required.');
}
return {
hruid: req.params.hruid as string,
language: (req.query.language as Language) || FALLBACK_LANG,
};
}
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
const learningPathId = getLearningPathIdentifierFromRequest(req);
const full = req.query.full;
let learningObjects: FilteredLearningObject[] | string[];
if (full) {
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
} else {
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId);
}
res.json(learningObjects);
}
export async function getLearningObject(req: Request, res: Response): Promise<void> {
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
const learningObject = await learningObjectService.getLearningObjectById(learningObjectId);
res.json(learningObject);
}
export async function getLearningObjectHTML(req: Request, res: Response): Promise<void> {
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId);
res.send(learningObject);
}
export async function getAttachment(req: Request, res: Response): Promise<void> {
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
const name = req.params.attachmentName;
const attachment = await attachmentService.getAttachment(learningObjectId, name);
if (!attachment) {
throw new NotFoundError(`Attachment ${name} not found`);
}
res.setHeader('Content-Type', attachment.mimeType).send(attachment.content);
}

View file

@ -0,0 +1,64 @@
import { Request, Response } from 'express';
import { themes } from '../data/themes.js';
import { FALLBACK_LANG } from '../config.js';
import learningPathService from '../services/learning-paths/learning-path-service.js';
import { BadRequestException, NotFoundException } from '../exceptions.js';
import { Language } from '../entities/content/language.js';
import {
PersonalizationTarget,
personalizedForGroup,
personalizedForStudent,
} from '../services/learning-paths/learning-path-personalization-util.js';
/**
* Fetch learning paths based on query parameters.
*/
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
const hruids = req.query.hruid;
const themeKey = req.query.theme as string;
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
const forStudent = req.query.forStudent as string;
const forGroupNo = req.query.forGroup as string;
const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string;
let personalizationTarget: PersonalizationTarget | undefined;
if (forStudent) {
personalizationTarget = await personalizedForStudent(forStudent);
} else if (forGroupNo) {
if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
}
personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo));
}
let hruidList;
if (hruids) {
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
} else if (themeKey) {
const theme = themes.find((t) => t.title === themeKey);
if (theme) {
hruidList = theme.hruids;
} else {
throw new NotFoundException(`Theme "${themeKey}" not found.`);
}
} else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget);
res.json(searchResults);
return;
} else {
hruidList = themes.flatMap((theme) => theme.hruids);
}
const learningPaths = await learningPathService.fetchLearningPaths(
hruidList,
language as Language,
`HRUIDs: ${hruidList.join(', ')}`,
personalizationTarget
);
res.json(learningPaths.data);
}

View file

@ -1,48 +0,0 @@
import { Request, Response } from 'express';
import { getLearningObjectById, getLearningObjectIdsFromPath, getLearningObjectsFromPath } from '../services/learningObjects.js';
import { FALLBACK_LANG } from '../config.js';
import { FilteredLearningObject } from '../interfaces/learningPath.js';
import { getLogger } from '../logging/initalize.js';
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
try {
const hruid = req.query.hruid as string;
const full = req.query.full === 'true';
const language = (req.query.language as string) || FALLBACK_LANG;
if (!hruid) {
res.status(400).json({ error: 'HRUID query is required.' });
return;
}
let learningObjects: FilteredLearningObject[] | string[];
if (full) {
learningObjects = await getLearningObjectsFromPath(hruid, language);
} else {
learningObjects = await getLearningObjectIdsFromPath(hruid, language);
}
res.json(learningObjects);
} catch (error) {
getLogger().error('Error fetching learning objects:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function getLearningObject(req: Request, res: Response): Promise<void> {
try {
const { hruid } = req.params;
const language = (req.query.language as string) || FALLBACK_LANG;
if (!hruid) {
res.status(400).json({ error: 'HRUID parameter is required.' });
return;
}
const learningObject = await getLearningObjectById(hruid, language);
res.json(learningObject);
} catch (error) {
getLogger().error('Error fetching learning object:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -1,8 +1,9 @@
import { Request, Response } from 'express';
import { themes } from '../data/themes.js';
import { FALLBACK_LANG } from '../config.js';
import { fetchLearningPaths, searchLearningPaths } from '../services/learningPaths.js';
import { getLogger } from '../logging/initalize.js';
import learningPathService from '../services/learning-paths/learning-path-service.js';
import { Language } from '../entities/content/language.js';
/**
* Fetch learning paths based on query parameters.
*/
@ -11,7 +12,7 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
const hruids = req.query.hruid;
const themeKey = req.query.theme as string;
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
const language = (req.query.language as Language) || FALLBACK_LANG;
let hruidList;
@ -28,14 +29,14 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
return;
}
} else if (searchQuery) {
const searchResults = await searchLearningPaths(searchQuery, language);
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language);
res.json(searchResults);
return;
} else {
hruidList = themes.flatMap((theme) => theme.hruids);
}
const learningPaths = await fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`);
const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`);
res.json(learningPaths.data);
} catch (error) {
getLogger().error('❌ Unexpected error fetching learning paths:', error);

View file

@ -1,13 +1,37 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Attachment } from '../../entities/content/attachment.entity.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { Language } from '../../entities/content/language';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier';
export class AttachmentRepository extends DwengoEntityRepository<Attachment> {
public findByLearningObjectAndNumber(learningObject: LearningObject, sequenceNumber: number) {
public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> {
return this.findOne({
learningObject: learningObject,
sequenceNumber: sequenceNumber,
learningObject: {
hruid: learningObjectId.hruid,
language: learningObjectId.language,
version: learningObjectId.version,
},
name: name,
});
}
public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise<Attachment | null> {
return this.findOne(
{
learningObject: {
hruid: hruid,
language: language,
},
name: attachmentName,
},
{
orderBy: {
learningObject: {
version: 'DESC',
},
},
}
);
}
// This repository is read-only for now since creating own learning object is an extension feature.
}

View file

@ -1,14 +1,34 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Language } from '../../entities/content/language';
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
return this.findOne({
hruid: identifier.hruid,
language: identifier.language,
version: identifier.version,
});
return this.findOne(
{
hruid: identifier.hruid,
language: identifier.language,
version: identifier.version,
},
{
populate: ['keywords'],
}
);
}
public findLatestByHruidAndLanguage(hruid: string, language: Language) {
return this.findOne(
{
hruid: hruid,
language: language,
},
{
populate: ['keywords'],
orderBy: {
version: 'DESC',
},
}
);
}
// This repository is read-only for now since creating own learning object is an extension feature.
}

View file

@ -4,7 +4,23 @@ import { Language } from '../../entities/content/language.js';
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
return this.findOne({ hruid: hruid, language: language });
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] });
}
/**
* Returns all learning paths which have the given language and whose title OR description contains the
* query string.
*
* @param query The query string we want to seach for in the title or description.
* @param language The language of the learning paths we want to find.
*/
public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> {
return this.findAll({
where: {
language: language,
$or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }],
},
populate: ['nodes', 'nodes.transitions'],
});
}
// This repository is read-only for now since creating own learning object is an extension feature.
}

View file

@ -5,10 +5,12 @@ import { Teacher } from '../../entities/users/teacher.entity.js';
export class AnswerRepository extends DwengoEntityRepository<Answer> {
public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> {
const answerEntity = new Answer();
answerEntity.toQuestion = answer.toQuestion;
answerEntity.author = answer.author;
answerEntity.content = answer.content;
const answerEntity = this.create({
toQuestion: answer.toQuestion,
author: answer.author,
content: answer.content,
timestamp: new Date(),
});
return this.insert(answerEntity);
}
public findAllAnswersToQuestion(question: Question): Promise<Answer[]> {

View file

@ -5,7 +5,14 @@ import { Student } from '../../entities/users/student.entity.js';
export class QuestionRepository extends DwengoEntityRepository<Question> {
public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> {
const questionEntity = new Question();
const questionEntity = this.create({
learningObjectHruid: question.loId.hruid,
learningObjectLanguage: question.loId.language,
learningObjectVersion: question.loId.version,
author: question.author,
content: question.content,
timestamp: new Date(),
});
questionEntity.learningObjectHruid = question.loId.hruid;
questionEntity.learningObjectLanguage = question.loId.language;
questionEntity.learningObjectVersion = question.loId.version;

View file

@ -28,6 +28,8 @@ import { LearningPath } from '../entities/content/learning-path.entity.js';
import { LearningPathRepository } from './content/learning-path-repository.js';
import { AttachmentRepository } from './content/attachment-repository.js';
import { Attachment } from '../entities/content/attachment.entity.js';
import { LearningPathNode } from '../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js';
let entityManager: EntityManager | undefined;
@ -59,7 +61,7 @@ export const getTeacherRepository = repositoryGetter<Teacher, TeacherRepository>
/* Classes */
export const getClassRepository = repositoryGetter<Class, ClassRepository>(Class);
export const getClassJoinRequestRepository = repositoryGetter<ClassJoinRequest, ClassJoinRequestRepository>(ClassJoinRequest);
export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitationRepository);
export const getTeacherInvitationRepository = repositoryGetter<TeacherInvitation, TeacherInvitationRepository>(TeacherInvitation);
/* Assignments */
export const getAssignmentRepository = repositoryGetter<Assignment, AssignmentRepository>(Assignment);
@ -73,4 +75,6 @@ export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(An
/* Learning content */
export const getLearningObjectRepository = repositoryGetter<LearningObject, LearningObjectRepository>(LearningObject);
export const getLearningPathRepository = repositoryGetter<LearningPath, LearningPathRepository>(LearningPath);
export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Assignment);
export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode);
export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition);
export const getAttachmentRepository = repositoryGetter<Attachment, AttachmentRepository>(Attachment);

View file

@ -2,8 +2,9 @@ import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro
import { Class } from '../classes/class.entity.js';
import { Group } from './group.entity.js';
import { Language } from '../content/language.js';
import { AssignmentRepository } from '../../data/assignments/assignment-repository.js';
@Entity()
@Entity({ repository: () => AssignmentRepository })
export class Assignment {
@ManyToOne({ entity: () => Class, primary: true })
within!: Class;

View file

@ -1,8 +1,9 @@
import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core';
import { Assignment } from './assignment.entity.js';
import { Student } from '../users/student.entity.js';
import { GroupRepository } from '../../data/assignments/group-repository.js';
@Entity()
@Entity({ repository: () => GroupRepository })
export class Group {
@ManyToOne({
entity: () => Assignment,

View file

@ -2,8 +2,9 @@ import { Student } from '../users/student.entity.js';
import { Group } from './group.entity.js';
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Language } from '../content/language.js';
import { SubmissionRepository } from '../../data/assignments/submission-repository.js';
@Entity()
@Entity({ repository: () => SubmissionRepository })
export class Submission {
@PrimaryKey({ type: 'string' })
learningObjectHruid!: string;
@ -14,8 +15,8 @@ export class Submission {
})
learningObjectLanguage!: Language;
@PrimaryKey({ type: 'string' })
learningObjectVersion: string = '1';
@PrimaryKey({ type: 'numeric' })
learningObjectVersion: number = 1;
@PrimaryKey({ type: 'integer' })
submissionNumber!: number;

View file

@ -1,8 +1,9 @@
import { Entity, Enum, ManyToOne } from '@mikro-orm/core';
import { Student } from '../users/student.entity.js';
import { Class } from './class.entity.js';
import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js';
@Entity()
@Entity({ repository: () => ClassJoinRequestRepository })
export class ClassJoinRequest {
@ManyToOne({
entity: () => Student,

View file

@ -2,8 +2,9 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm
import { v4 } from 'uuid';
import { Teacher } from '../users/teacher.entity.js';
import { Student } from '../users/student.entity.js';
import { ClassRepository } from '../../data/classes/class-repository.js';
@Entity()
@Entity({ repository: () => ClassRepository })
export class Class {
@PrimaryKey()
classId = v4();

View file

@ -1,11 +1,15 @@
import { Entity, ManyToOne } from '@mikro-orm/core';
import { Teacher } from '../users/teacher.entity.js';
import { Class } from './class.entity.js';
import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js';
/**
* Invitation of a teacher into a class (in order to teach it).
*/
@Entity()
@Entity({ repository: () => TeacherInvitationRepository })
@Entity({
repository: () => TeacherInvitationRepository,
})
export class TeacherInvitation {
@ManyToOne({
entity: () => Teacher,

View file

@ -1,7 +1,8 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { LearningObject } from './learning-object.entity.js';
import { AttachmentRepository } from '../../data/content/attachment-repository.js';
@Entity()
@Entity({ repository: () => AttachmentRepository })
export class Attachment {
@ManyToOne({
entity: () => LearningObject,
@ -9,8 +10,8 @@ export class Attachment {
})
learningObject!: LearningObject;
@PrimaryKey({ type: 'integer' })
sequenceNumber!: number;
@PrimaryKey({ type: 'string' })
name!: string;
@Property({ type: 'string' })
mimeType!: string;

View file

@ -1,6 +1,186 @@
export enum Language {
Afar = 'aa',
Abkhazian = 'ab',
Afrikaans = 'af',
Akan = 'ak',
Albanian = 'sq',
Amharic = 'am',
Arabic = 'ar',
Aragonese = 'an',
Armenian = 'hy',
Assamese = 'as',
Avaric = 'av',
Avestan = 'ae',
Aymara = 'ay',
Azerbaijani = 'az',
Bashkir = 'ba',
Bambara = 'bm',
Basque = 'eu',
Belarusian = 'be',
Bengali = 'bn',
Bihari = 'bh',
Bislama = 'bi',
Bosnian = 'bs',
Breton = 'br',
Bulgarian = 'bg',
Burmese = 'my',
Catalan = 'ca',
Chamorro = 'ch',
Chechen = 'ce',
Chinese = 'zh',
ChurchSlavic = 'cu',
Chuvash = 'cv',
Cornish = 'kw',
Corsican = 'co',
Cree = 'cr',
Czech = 'cs',
Danish = 'da',
Divehi = 'dv',
Dutch = 'nl',
French = 'fr',
Dzongkha = 'dz',
English = 'en',
Germany = 'de',
Esperanto = 'eo',
Estonian = 'et',
Ewe = 'ee',
Faroese = 'fo',
Fijian = 'fj',
Finnish = 'fi',
French = 'fr',
Frisian = 'fy',
Fulah = 'ff',
Georgian = 'ka',
German = 'de',
Gaelic = 'gd',
Irish = 'ga',
Galician = 'gl',
Manx = 'gv',
Greek = 'el',
Guarani = 'gn',
Gujarati = 'gu',
Haitian = 'ht',
Hausa = 'ha',
Hebrew = 'he',
Herero = 'hz',
Hindi = 'hi',
HiriMotu = 'ho',
Croatian = 'hr',
Hungarian = 'hu',
Igbo = 'ig',
Icelandic = 'is',
Ido = 'io',
SichuanYi = 'ii',
Inuktitut = 'iu',
Interlingue = 'ie',
Interlingua = 'ia',
Indonesian = 'id',
Inupiaq = 'ik',
Italian = 'it',
Javanese = 'jv',
Japanese = 'ja',
Kalaallisut = 'kl',
Kannada = 'kn',
Kashmiri = 'ks',
Kanuri = 'kr',
Kazakh = 'kk',
Khmer = 'km',
Kikuyu = 'ki',
Kinyarwanda = 'rw',
Kirghiz = 'ky',
Komi = 'kv',
Kongo = 'kg',
Korean = 'ko',
Kuanyama = 'kj',
Kurdish = 'ku',
Lao = 'lo',
Latin = 'la',
Latvian = 'lv',
Limburgan = 'li',
Lingala = 'ln',
Lithuanian = 'lt',
Luxembourgish = 'lb',
LubaKatanga = 'lu',
Ganda = 'lg',
Macedonian = 'mk',
Marshallese = 'mh',
Malayalam = 'ml',
Maori = 'mi',
Marathi = 'mr',
Malay = 'ms',
Malagasy = 'mg',
Maltese = 'mt',
Mongolian = 'mn',
Nauru = 'na',
Navajo = 'nv',
SouthNdebele = 'nr',
NorthNdebele = 'nd',
Ndonga = 'ng',
Nepali = 'ne',
NorwegianNynorsk = 'nn',
NorwegianBokmal = 'nb',
Norwegian = 'no',
Chichewa = 'ny',
Occitan = 'oc',
Ojibwa = 'oj',
Oriya = 'or',
Oromo = 'om',
Ossetian = 'os',
Punjabi = 'pa',
Persian = 'fa',
Pali = 'pi',
Polish = 'pl',
Portuguese = 'pt',
Pashto = 'ps',
Quechua = 'qu',
Romansh = 'rm',
Romanian = 'ro',
Rundi = 'rn',
Russian = 'ru',
Sango = 'sg',
Sanskrit = 'sa',
Sinhala = 'si',
Slovak = 'sk',
Slovenian = 'sl',
NorthernSami = 'se',
Samoan = 'sm',
Shona = 'sn',
Sindhi = 'sd',
Somali = 'so',
Sotho = 'st',
Spanish = 'es',
Sardinian = 'sc',
Serbian = 'sr',
Swati = 'ss',
Sundanese = 'su',
Swahili = 'sw',
Swedish = 'sv',
Tahitian = 'ty',
Tamil = 'ta',
Tatar = 'tt',
Telugu = 'te',
Tajik = 'tg',
Tagalog = 'tl',
Thai = 'th',
Tibetan = 'bo',
Tigrinya = 'ti',
Tonga = 'to',
Tswana = 'tn',
Tsonga = 'ts',
Turkmen = 'tk',
Turkish = 'tr',
Twi = 'tw',
Uighur = 'ug',
Ukrainian = 'uk',
Urdu = 'ur',
Uzbek = 'uz',
Venda = 've',
Vietnamese = 'vi',
Volapuk = 'vo',
Welsh = 'cy',
Walloon = 'wa',
Wolof = 'wo',
Xhosa = 'xh',
Yiddish = 'yi',
Yoruba = 'yo',
Zhuang = 'za',
Zulu = 'zu',
}

View file

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

View file

@ -2,6 +2,9 @@ import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey,
import { Language } from './language.js';
import { Attachment } from './attachment.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';
@Embeddable()
export class EducationalGoal {
@ -21,7 +24,7 @@ export class ReturnValue {
callbackSchema!: string;
}
@Entity()
@Entity({ repository: () => LearningObjectRepository })
export class LearningObject {
@PrimaryKey({ type: 'string' })
hruid!: string;
@ -32,8 +35,11 @@ export class LearningObject {
})
language!: Language;
@PrimaryKey({ type: 'string' })
version: string = '1';
@PrimaryKey({ type: 'number' })
version: number = 1;
@Property({ type: 'uuid', unique: true })
uuid = v4();
@ManyToMany({
entity: () => Teacher,
@ -47,19 +53,19 @@ export class LearningObject {
description!: string;
@Property({ type: 'string' })
contentType!: string;
contentType!: DwengoContentType;
@Property({ type: 'array' })
keywords: string[] = [];
@Property({ type: 'array', nullable: true })
targetAges?: number[];
targetAges?: number[] = [];
@Property({ type: 'bool' })
teacherExclusive: boolean = false;
@Property({ type: 'array' })
skosConcepts!: string[];
skosConcepts: string[] = [];
@Embedded({
entity: () => EducationalGoal,
@ -76,8 +82,8 @@ export class LearningObject {
@Property({ type: 'smallint', nullable: true })
difficulty?: number;
@Property({ type: 'integer' })
estimatedTime!: number;
@Property({ type: 'integer', nullable: true })
estimatedTime?: number;
@Embedded({
entity: () => ReturnValue,
@ -99,12 +105,3 @@ export class LearningObject {
@Property({ type: 'blob' })
content!: Buffer;
}
export enum ContentType {
Markdown = 'text/markdown',
Image = 'image/image',
Mpeg = 'audio/mpeg',
Pdf = 'application/pdf',
Extern = 'extern',
Blockly = 'Blockly',
}

View file

@ -0,0 +1,37 @@
import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
import { Language } from './language.js';
import { LearningPath } from './learning-path.entity.js';
import { LearningPathTransition } from './learning-path-transition.entity.js';
@Entity()
export class LearningPathNode {
@ManyToOne({ entity: () => LearningPath, primary: true })
learningPath!: Rel<LearningPath>;
@PrimaryKey({ type: 'integer', autoincrement: true })
nodeNumber!: number;
@Property({ type: 'string' })
learningObjectHruid!: string;
@Enum({ items: () => Language })
language!: Language;
@Property({ type: 'number' })
version!: number;
@Property({ type: 'text', nullable: true })
instruction?: string;
@Property({ type: 'bool' })
startNode!: boolean;
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' })
transitions: LearningPathTransition[] = [];
@Property({ length: 3 })
createdAt: Date = new Date();
@Property({ length: 3, onUpdate: () => new Date() })
updatedAt: Date = new Date();
}

View file

@ -0,0 +1,17 @@
import { Entity, ManyToOne, PrimaryKey, Property, Rel } from '@mikro-orm/core';
import { LearningPathNode } from './learning-path-node.entity.js';
@Entity()
export class LearningPathTransition {
@ManyToOne({ entity: () => LearningPathNode, primary: true })
node!: Rel<LearningPathNode>;
@PrimaryKey({ type: 'numeric' })
transitionNumber!: number;
@Property({ type: 'string' })
condition!: string;
@ManyToOne({ entity: () => LearningPathNode })
next!: Rel<LearningPathNode>;
}

View file

@ -1,21 +1,18 @@
import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { Language } from './language.js';
import { Teacher } from '../users/teacher.entity.js';
import { LearningPathRepository } from '../../data/content/learning-path-repository.js';
import { LearningPathNode } from './learning-path-node.entity.js';
@Entity()
@Entity({ repository: () => LearningPathRepository })
export class LearningPath {
@PrimaryKey({ type: 'string' })
hruid!: string;
@Enum({
items: () => Language,
primary: true,
})
@Enum({ items: () => Language, primary: true })
language!: Language;
@ManyToMany({
entity: () => Teacher,
})
@ManyToMany({ entity: () => Teacher })
admins!: Teacher[];
@Property({ type: 'string' })
@ -24,49 +21,9 @@ export class LearningPath {
@Property({ type: 'text' })
description!: string;
@Property({ type: 'blob' })
image!: string;
@Property({ type: 'blob', nullable: true })
image: Buffer | null = null;
@Embedded({
entity: () => LearningPathNode,
array: true,
})
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
nodes: LearningPathNode[] = [];
}
@Embeddable()
export class LearningPathNode {
@Property({ type: 'string' })
learningObjectHruid!: string;
@Enum({
items: () => Language,
})
language!: Language;
@Property({ type: 'string' })
version!: string;
@Property({ type: 'longtext' })
instruction!: string;
@Property({ type: 'bool' })
startNode!: boolean;
@Embedded({
entity: () => LearningPathTransition,
array: true,
})
transitions!: LearningPathTransition[];
}
@Embeddable()
export class LearningPathTransition {
@Property({ type: 'string' })
condition!: string;
@OneToOne({
entity: () => LearningPathNode,
})
next!: LearningPathNode;
}

View file

@ -1,8 +1,9 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Question } from './question.entity.js';
import { Teacher } from '../users/teacher.entity.js';
import { AnswerRepository } from '../../data/questions/answer-repository.js';
@Entity()
@Entity({ repository: () => AnswerRepository })
export class Answer {
@ManyToOne({
entity: () => Teacher,
@ -16,8 +17,8 @@ export class Answer {
})
toQuestion!: Question;
@PrimaryKey({ type: 'integer' })
sequenceNumber!: number;
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@Property({ type: 'datetime' })
timestamp: Date = new Date();

View file

@ -1,8 +1,9 @@
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Language } from '../content/language.js';
import { Student } from '../users/student.entity.js';
import { QuestionRepository } from '../../data/questions/question-repository.js';
@Entity()
@Entity({ repository: () => QuestionRepository })
export class Question {
@PrimaryKey({ type: 'string' })
learningObjectHruid!: string;
@ -13,11 +14,11 @@ export class Question {
})
learningObjectLanguage!: Language;
@PrimaryKey({ type: 'string' })
learningObjectVersion: string = '1';
@PrimaryKey({ type: 'number' })
learningObjectVersion: number = 1;
@PrimaryKey({ type: 'integer' })
sequenceNumber!: number;
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@ManyToOne({
entity: () => Student,

View file

@ -1,9 +1,18 @@
import { Collection, Entity, ManyToMany } from '@mikro-orm/core';
import { User } from './user.entity.js';
import { Class } from '../classes/class.entity.js';
import { TeacherRepository } from '../../data/users/teacher-repository.js';
@Entity()
@Entity({ repository: () => TeacherRepository })
export class Teacher extends User {
@ManyToMany(() => Class)
classes!: Collection<Class>;
constructor(
public username: string,
public firstName: string,
public lastName: string
) {
super();
}
}

View file

@ -1,3 +1,17 @@
/**
* Exception for HTTP 400 Bad Request
*/
export class BadRequestException extends Error {
public status = 400;
constructor(error: string) {
super(error);
}
}
/**
* Exception for HTTP 401 Unauthorized
*/
export class UnauthorizedException extends Error {
status = 401;
constructor(message: string = 'Unauthorized') {
@ -5,9 +19,24 @@ export class UnauthorizedException extends Error {
}
}
/**
* Exception for HTTP 403 Forbidden
*/
export class ForbiddenException extends Error {
status = 403;
constructor(message: string = 'Forbidden') {
super(message);
}
}
/**
* Exception for HTTP 404 Not Found
*/
export class NotFoundException extends Error {
public status = 404;
constructor(error: string) {
super(error);
}
}

View file

@ -1,3 +1,5 @@
import { Language } from '../entities/content/language';
export interface Transition {
default: boolean;
_id: string;
@ -9,15 +11,22 @@ export interface Transition {
};
}
export interface LearningObjectIdentifier {
hruid: string;
language: Language;
version?: number;
}
export interface LearningObjectNode {
_id: string;
learningobject_hruid: string;
version: number;
language: string;
language: Language;
start_node?: boolean;
transitions: Transition[];
created_at: string;
updatedAt: string;
done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized.
}
export interface LearningPath {
@ -37,6 +46,11 @@ export interface LearningPath {
__order: number;
}
export interface LearningPathIdentifier {
hruid: string;
language: Language;
}
export interface EducationalGoal {
source: string;
id: string;
@ -52,7 +66,7 @@ export interface LearningObjectMetadata {
uuid: string;
hruid: string;
version: number;
language: string;
language: Language;
title: string;
description: string;
difficulty: number;
@ -75,9 +89,9 @@ export interface FilteredLearningObject {
version: number;
title: string;
htmlUrl: string;
language: string;
language: Language;
difficulty: number;
estimatedTime: number;
estimatedTime?: number;
available: boolean;
teacherExclusive: boolean;
educationalGoals: EducationalGoal[];

View file

@ -24,6 +24,7 @@ import { LearningPath } from './entities/content/learning-path.entity.js';
import { Answer } from './entities/questions/answer.entity.js';
import { Question } from './entities/questions/question.entity.js';
import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js';
const entities = [
User,
@ -47,6 +48,7 @@ function config(testingMode: boolean = false): Options {
return {
driver: SqliteDriver,
dbName: getEnvVar(EnvVars.DbName),
subscribers: [new SqliteAutoincrementSubscriber()],
entities: entities,
// EntitiesTs: entitiesTs,

View file

@ -1,5 +1,5 @@
import express from 'express';
import { getAllLearningObjects, getLearningObject } from '../controllers/learningObjects.js';
import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js';
const router = express.Router();
@ -21,4 +21,16 @@ router.get('/', getAllLearningObjects);
// Example: http://localhost:3000/learningObject/un_ai7
router.get('/:hruid', getLearningObject);
// Parameter: hruid of learning object
// Query: language, version (optional)
// Route to fetch the HTML rendering of one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7/html
router.get('/:hruid/html', getLearningObjectHTML);
// Parameter: hruid of learning object, name of attachment.
// Query: language, version (optional).
// Route to get the raw data of the attachment for one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
router.get('/:hruid/html/:attachmentName', getAttachment);
export default router;

View file

@ -1,5 +1,5 @@
import express from 'express';
import { getLearningPaths } from '../controllers/learningPaths.js';
import { getLearningPaths } from '../controllers/learning-paths.js';
const router = express.Router();

View file

@ -0,0 +1,23 @@
import { getAttachmentRepository } from '../../data/repositories.js';
import { Attachment } from '../../entities/content/attachment.entity.js';
import { LearningObjectIdentifier } from '../../interfaces/learning-content.js';
const attachmentService = {
getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise<Attachment | null> {
const attachmentRepo = getAttachmentRepository();
if (learningObjectId.version) {
return attachmentRepo.findByLearningObjectIdAndName(
{
hruid: learningObjectId.hruid,
language: learningObjectId.language,
version: learningObjectId.version,
},
attachmentName
);
}
return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName);
},
};
export default attachmentService;

View file

@ -0,0 +1,115 @@
import { LearningObjectProvider } from './learning-object-provider.js';
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js';
import { Language } from '../../entities/content/language.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { getUrlStringForLearningObject } from '../../util/links.js';
import processingService from './processing/processing-service.js';
import { NotFoundError } from '@mikro-orm/core';
import learningObjectService from './learning-object-service.js';
import { getLogger, Logger } from '../../logging/initalize.js';
const logger: Logger = getLogger();
function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null {
if (!learningObject) {
return null;
}
return {
key: learningObject.hruid,
_id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field.
uuid: learningObject.uuid,
language: learningObject.language,
version: learningObject.version,
title: learningObject.title,
description: learningObject.description,
htmlUrl: getUrlStringForLearningObject(learningObject),
available: learningObject.available,
contentType: learningObject.contentType,
contentLocation: learningObject.contentLocation,
difficulty: learningObject.difficulty || 1,
estimatedTime: learningObject.estimatedTime,
keywords: learningObject.keywords,
educationalGoals: learningObject.educationalGoals,
returnValue: {
callback_url: learningObject.returnValue.callbackUrl,
callback_schema: JSON.parse(learningObject.returnValue.callbackSchema),
},
skosConcepts: learningObject.skosConcepts,
targetAges: learningObject.targetAges || [],
teacherExclusive: learningObject.teacherExclusive,
};
}
function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise<LearningObject | null> {
const learningObjectRepo = getLearningObjectRepository();
return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language);
}
/**
* Service providing access to data about learning objects from the database
*/
const databaseLearningObjectProvider: LearningObjectProvider = {
/**
* Fetches a single learning object by its HRUID
*/
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
const learningObject = await findLearningObjectEntityById(id);
return convertLearningObject(learningObject);
},
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
const learningObjectRepo = getLearningObjectRepository();
const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language);
if (!learningObject) {
return null;
}
return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id));
},
/**
* Fetch the HRUIDs of all learning objects on this path.
*/
async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
const learningPathRepo = getLearningPathRepository();
const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language);
if (!learningPath) {
throw new NotFoundError('The learning path with the given ID could not be found.');
}
return learningPath.nodes.map((it) => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user.
},
/**
* Fetch the full metadata of all learning objects on this path.
*/
async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
const learningPathRepo = getLearningPathRepository();
const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language);
if (!learningPath) {
throw new NotFoundError('The learning path with the given ID could not be found.');
}
const learningObjects = await Promise.all(
learningPath.nodes.map((it) => {
const learningObject = learningObjectService.getLearningObjectById({
hruid: it.learningObjectHruid,
language: it.language,
version: it.version,
});
if (learningObject === null) {
logger.warn(`WARN: Learning object corresponding with node ${it} not found!`);
}
return learningObject;
})
);
return learningObjects.filter((it) => it !== null);
},
};
export default databaseLearningObjectProvider;

View file

@ -0,0 +1,138 @@
import { DWENGO_API_BASE } from '../../config.js';
import { fetchWithLogging } from '../../util/apiHelper.js';
import {
FilteredLearningObject,
LearningObjectIdentifier,
LearningObjectMetadata,
LearningObjectNode,
LearningPathIdentifier,
LearningPathResponse,
} from '../../interfaces/learning-content.js';
import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js';
import { LearningObjectProvider } from './learning-object-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
const logger: Logger = getLogger();
/**
* Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which
* our API should return.
* @param data
*/
function filterData(data: LearningObjectMetadata): FilteredLearningObject {
return {
key: data.hruid, // Hruid learningObject (not path)
_id: data._id,
uuid: data.uuid,
version: data.version,
title: data.title,
htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content
language: data.language,
difficulty: data.difficulty,
estimatedTime: data.estimated_time,
available: data.available,
teacherExclusive: data.teacher_exclusive,
educationalGoals: data.educational_goals, // List with learningObjects
keywords: data.keywords, // For search
description: data.description, // For search (not an actual description)
targetAges: data.target_ages,
contentType: data.content_type, // Markdown, image, audio, etc.
contentLocation: data.content_location, // If content type extern
skosConcepts: data.skos_concepts,
returnValue: data.return_value, // Callback response information
};
}
/**
* Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs)
*/
async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise<FilteredLearningObject[] | string[]> {
try {
const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths(
[learningPathId.hruid],
learningPathId.language,
`Learning path for HRUID "${learningPathId.hruid}"`
);
if (!learningPathResponse.success || !learningPathResponse.data?.length) {
logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`);
return [];
}
const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes;
if (!full) {
return nodes.map((node) => node.learningobject_hruid);
}
const objects = await Promise.all(
nodes.map(async (node) =>
dwengoApiLearningObjectProvider.getLearningObjectById({
hruid: node.learningobject_hruid,
language: learningPathId.language,
})
)
);
return objects.filter((obj): obj is FilteredLearningObject => obj !== null);
} catch (error) {
logger.error('❌ Error fetching learning objects:', error);
return [];
}
}
const dwengoApiLearningObjectProvider: LearningObjectProvider = {
/**
* Fetches a single learning object by its HRUID
*/
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`,
{
params: id,
}
);
if (!metadata || typeof metadata !== 'object') {
logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`);
return null;
}
return filterData(metadata);
},
/**
* Fetch full learning object data (metadata)
*/
async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
return (await fetchLearningObjects(id, true)) as FilteredLearningObject[];
},
/**
* Fetch only learning object HRUIDs
*/
async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
return (await fetchLearningObjects(id, false)) as string[];
},
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects
* from the Dwengo API, this means passing through the HTML rendering from there.
*/
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
params: id,
});
if (!html) {
logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`);
return null;
}
return html;
},
};
export default dwengoApiLearningObjectProvider;

View file

@ -0,0 +1,23 @@
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
export interface LearningObjectProvider {
/**
* Fetches a single learning object by its HRUID
*/
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null>;
/**
* Fetch full learning object data (metadata)
*/
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]>;
/**
* Fetch only learning object HRUIDs
*/
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]>;
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null>;
}

View file

@ -0,0 +1,47 @@
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js';
import { LearningObjectProvider } from './learning-object-provider.js';
import { EnvVars, getEnvVar } from '../../util/envvars.js';
import databaseLearningObjectProvider from './database-learning-object-provider.js';
function getProvider(id: LearningObjectIdentifier): LearningObjectProvider {
if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) {
return databaseLearningObjectProvider;
}
return dwengoApiLearningObjectProvider;
}
/**
* Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api)
*/
const learningObjectService = {
/**
* Fetches a single learning object by its HRUID
*/
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
return getProvider(id).getLearningObjectById(id);
},
/**
* Fetch full learning object data (metadata)
*/
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
return getProvider(id).getLearningObjectsFromPath(id);
},
/**
* Fetch only learning object HRUIDs
*/
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
return getProvider(id).getLearningObjectIdsFromPath(id);
},
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
return getProvider(id).getLearningObjectHTML(id);
},
};
export default learningObjectService;

View file

@ -0,0 +1,25 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js
*
* WARNING: The support for audio learning objects is currently still experimental.
*/
import DOMPurify from 'isomorphic-dompurify';
import { type } from 'node:os';
import { DwengoContentType } from '../content-type.js';
import { StringProcessor } from '../string-processor.js';
class AudioProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.AUDIO_MPEG);
}
protected renderFn(audioUrl: string): string {
return DOMPurify.sanitize(`<audio controls>
<source src="${audioUrl}" type=${type}>
Your browser does not support the audio element.
</audio>`);
}
}
export default AudioProcessor;

View file

@ -0,0 +1,18 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js
*/
enum DwengoContentType {
TEXT_PLAIN = 'text/plain',
TEXT_MARKDOWN = 'text/markdown',
IMAGE_BLOCK = 'image/image-block',
IMAGE_INLINE = 'image/image',
AUDIO_MPEG = 'audio/mpeg',
APPLICATION_PDF = 'application/pdf',
EXTERN = 'extern',
BLOCKLY = 'blockly',
GIFT = 'text/gift',
CT_SCHEMA = 'text/ct-schema',
}
export { DwengoContentType };

View file

@ -0,0 +1,40 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js
*
* WARNING: The support for external content is currently still experimental.
*/
import DOMPurify from 'isomorphic-dompurify';
import { ProcessingError } from '../processing-error.js';
import { isValidHttpUrl } from '../../../../util/links.js';
import { DwengoContentType } from '../content-type.js';
import { StringProcessor } from '../string-processor.js';
class ExternProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.EXTERN);
}
override renderFn(externURL: string) {
if (!isValidHttpUrl(externURL)) {
throw new ProcessingError('The url is not valid: ' + externURL);
}
// If a seperate youtube-processor would be added, this code would need to move to that processor
// Converts youtube urls to youtube-embed urls
const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL);
if (match) {
externURL = match[1] + 'embed/' + match[2];
}
return DOMPurify.sanitize(
`
<div class="iframe-container">
<iframe src="${externURL}" allowfullscreen></iframe>
</div>`,
{ ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] }
);
}
}
export default ExternProcessor;

View file

@ -0,0 +1,61 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js
*/
import DOMPurify from 'isomorphic-dompurify';
import { GIFTQuestion, parse } from 'gift-pegjs';
import { DwengoContentType } from '../content-type.js';
import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js';
import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js';
import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js';
import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js';
import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js';
import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js';
import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js';
import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js';
import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js';
import { StringProcessor } from '../string-processor.js';
class GiftProcessor extends StringProcessor {
private renderers: RendererMap = {
Category: new CategoryQuestionRenderer(),
Description: new DescriptionQuestionRenderer(),
Essay: new EssayQuestionRenderer(),
Matching: new MatchingQuestionRenderer(),
Numerical: new NumericalQuestionRenderer(),
Short: new ShortQuestionRenderer(),
TF: new TrueFalseQuestionRenderer(),
MC: new MultipleChoiceQuestionRenderer(),
};
constructor() {
super(DwengoContentType.GIFT);
}
override renderFn(giftString: string) {
const quizQuestions: GIFTQuestion[] = parse(giftString);
let html = "<div class='learning-object-gift'>\n";
let i = 1;
for (const question of quizQuestions) {
html += ` <div class='gift-question' id='gift-q${i}'>\n`;
html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation.
html += ` </div>\n`;
i++;
}
html += '</div>\n';
return DOMPurify.sanitize(html);
}
private renderQuestion<T extends GIFTQuestion>(question: T, questionNumber: number): string {
const renderer = this.renderers[question.type] as GIFTQuestionRenderer<T>;
return renderer.render(question, questionNumber);
}
}
type RendererMap = {
[K in GIFTQuestion['type']]: GIFTQuestionRenderer<Extract<GIFTQuestion, { type: K }>>;
};
export default GiftProcessor;

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Category } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class CategoryQuestionRenderer extends GIFTQuestionRenderer<Category> {
render(question: Category, questionNumber: number): string {
throw new ProcessingError("The question type 'Category' is not supported yet!");
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Description } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class DescriptionQuestionRenderer extends GIFTQuestionRenderer<Description> {
render(question: Description, questionNumber: number): string {
throw new ProcessingError("The question type 'Description' is not supported yet!");
}
}

View file

@ -0,0 +1,16 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Essay } from 'gift-pegjs';
export class EssayQuestionRenderer extends GIFTQuestionRenderer<Essay> {
render(question: Essay, questionNumber: number): string {
let renderedHtml = '';
if (question.title) {
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
}
if (question.stem) {
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
}
renderedHtml += `<textarea class='gift-essay-answer' id='gift-q${questionNumber}-answer'></textarea>\n`;
return renderedHtml;
}
}

View file

@ -0,0 +1,14 @@
import { GIFTQuestion } from 'gift-pegjs';
/**
* Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML.
*/
export abstract class GIFTQuestionRenderer<T extends GIFTQuestion> {
/**
* Render the given question to HTML.
* @param question The question.
* @param questionNumber The index number of the question.
* @returns The question rendered as HTML.
*/
abstract render(question: T, questionNumber: number): string;
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Matching } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class MatchingQuestionRenderer extends GIFTQuestionRenderer<Matching> {
render(question: Matching, questionNumber: number): string {
throw new ProcessingError("The question type 'Matching' is not supported yet!");
}
}

View file

@ -0,0 +1,23 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { MultipleChoice } from 'gift-pegjs';
export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<MultipleChoice> {
render(question: MultipleChoice, questionNumber: number): string {
let renderedHtml = '';
if (question.title) {
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
}
if (question.stem) {
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
}
let i = 0;
for (const choice of question.choices) {
renderedHtml += `<div class="gift-choice-div">\n`;
renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`;
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`;
renderedHtml += `</div>\n`;
i++;
}
return renderedHtml;
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Numerical } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class NumericalQuestionRenderer extends GIFTQuestionRenderer<Numerical> {
render(question: Numerical, questionNumber: number): string {
throw new ProcessingError("The question type 'Numerical' is not supported yet!");
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { ShortAnswer } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class ShortQuestionRenderer extends GIFTQuestionRenderer<ShortAnswer> {
render(question: ShortAnswer, questionNumber: number): string {
throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!");
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { TrueFalse } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer<TrueFalse> {
render(question: TrueFalse, questionNumber: number): string {
throw new ProcessingError("The question type 'TrueFalse' is not supported yet!");
}
}

View file

@ -0,0 +1,19 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js
*/
import InlineImageProcessor from './inline-image-processor.js';
import DOMPurify from 'isomorphic-dompurify';
class BlockImageProcessor extends InlineImageProcessor {
constructor() {
super();
}
override renderFn(imageUrl: string) {
const inlineHtml = super.render(imageUrl);
return DOMPurify.sanitize(`<div>${inlineHtml}</div>`);
}
}
export default BlockImageProcessor;

View file

@ -0,0 +1,24 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js
*/
import DOMPurify from 'isomorphic-dompurify';
import { DwengoContentType } from '../content-type.js';
import { ProcessingError } from '../processing-error.js';
import { isValidHttpUrl } from '../../../../util/links.js';
import { StringProcessor } from '../string-processor.js';
class InlineImageProcessor extends StringProcessor {
constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) {
super(contentType);
}
override renderFn(imageUrl: string) {
if (!isValidHttpUrl(imageUrl)) {
throw new ProcessingError(`Image URL is invalid: ${imageUrl}`);
}
return DOMPurify.sanitize(`<img src="${imageUrl}" alt="">`);
}
}
export default InlineImageProcessor;

View file

@ -0,0 +1,109 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!]
*/
import PdfProcessor from '../pdf/pdf-processor.js';
import AudioProcessor from '../audio/audio-processor.js';
import ExternProcessor from '../extern/extern-processor.js';
import InlineImageProcessor from '../image/inline-image-processor.js';
import * as marked from 'marked';
import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js';
import { ProcessingError } from '../processing-error.js';
import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js';
import { Language } from '../../../../entities/content/language.js';
import Image = marked.Tokens.Image;
import Heading = marked.Tokens.Heading;
import Link = marked.Tokens.Link;
import RendererObject = marked.RendererObject;
const prefixes = {
learningObject: '@learning-object',
pdf: '@pdf',
audio: '@audio',
extern: '@extern',
video: '@youtube',
notebook: '@notebook',
blockly: '@blockly',
};
function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier {
const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/');
return {
hruid,
language: language as Language,
version: parseInt(version),
};
}
/**
* An extension for the renderer of the Marked Markdown renderer which adds support for
* - a custom heading,
* - links to other learning objects,
* - embeddings of other learning objects.
*/
const dwengoMarkedRenderer: RendererObject = {
heading(heading: Heading): string {
const text = heading.text;
const level = heading.depth;
const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');
return (
`<h${level}>\n` +
` <a name="${escapedText}" class="anchor" href="#${escapedText}">\n` +
` <span class="header-link"></span>\n` +
` </a>\n` +
` ${text}\n` +
`</h${level}>\n`
);
},
// When the syntax for a link is used => [text](href "title")
// Render a custom link when the prefix for a learning object is used.
link(link: Link): string {
const href = link.href;
const title = link.title || '';
const text = marked.parseInline(link.text); // There could for example be an image in the link.
if (href.startsWith(prefixes.learningObject)) {
// Link to learning-object
const learningObjectId = extractLearningObjectIdFromHref(href);
return `<a href="${getUrlStringForLearningObjectHTML(learningObjectId)}" target="_blank" title="${title}">${text}</a>`;
}
// Any other link
if (!isValidHttpUrl(href)) {
throw new ProcessingError('Link is not a valid HTTP URL!');
}
//<a href="https://kiks.ilabt.imec.be/hub/tmplogin?id=0101" title="Notebooks Werking"><img src="Knop.png" alt="" title="Knop"></a>
return `<a href="${href}" target="_blank" title="${title}">${text}</a>`;
},
// When the syntax for an image is used => ![text](href "title")
// Render a learning object, pdf, audio or video if a prefix is used.
image(img: Image): string {
const href = img.href;
if (href.startsWith(prefixes.learningObject)) {
// Embedded learning-object
const learningObjectId = extractLearningObjectIdFromHref(href);
return `
<learning-object hruid="${learningObjectId.hruid}" language="${learningObjectId.language}" version="${learningObjectId.version}"/>
`; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!)
} else if (href.startsWith(prefixes.pdf)) {
// Embedded pdf
const proc = new PdfProcessor();
return proc.render(href.split(/\/(.+)/, 2)[1]);
} else if (href.startsWith(prefixes.audio)) {
// Embedded audio
const proc = new AudioProcessor();
return proc.render(href.split(/\/(.+)/, 2)[1]);
} else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) {
// Embedded youtube video or notebook (or other extern content)
const proc = new ExternProcessor();
return proc.render(href.split(/\/(.+)/, 2)[1]);
}
// Embedded image
const proc = new InlineImageProcessor();
return proc.render(href);
},
};
export default dwengoMarkedRenderer;

View file

@ -0,0 +1,39 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js
*/
import { marked } from 'marked';
import InlineImageProcessor from '../image/inline-image-processor.js';
import { DwengoContentType } from '../content-type.js';
import dwengoMarkedRenderer from './dwengo-marked-renderer.js';
import { StringProcessor } from '../string-processor.js';
import { ProcessingError } from '../processing-error.js';
class MarkdownProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.TEXT_MARKDOWN);
}
override renderFn(mdText: string) {
let html = '';
try {
marked.use({ renderer: dwengoMarkedRenderer });
html = marked(mdText, { async: false });
html = this.replaceLinks(html); // Replace html image links path
} catch (e: any) {
throw new ProcessingError(e.message);
}
return html;
}
replaceLinks(html: string) {
const proc = new InlineImageProcessor();
html = html.replace(
/<img.*?src="(.*?)".*?(alt="(.*?)")?.*?(title="(.*?)")?.*?>/g,
(match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src)
);
return html;
}
}
export { MarkdownProcessor };

View file

@ -0,0 +1,32 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js
*
* WARNING: The support for PDF learning objects is currently still experimental.
*/
import DOMPurify from 'isomorphic-dompurify';
import { DwengoContentType } from '../content-type.js';
import { isValidHttpUrl } from '../../../../util/links.js';
import { ProcessingError } from '../processing-error.js';
import { StringProcessor } from '../string-processor.js';
class PdfProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.APPLICATION_PDF);
}
override renderFn(pdfUrl: string) {
if (!isValidHttpUrl(pdfUrl)) {
throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`);
}
return DOMPurify.sanitize(
`
<embed src="${pdfUrl}" type="application/pdf" width="100%" height="800px"/>
`,
{ ADD_TAGS: ['embed'] }
);
}
}
export default PdfProcessor;

View file

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

View file

@ -0,0 +1,83 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js
*/
import BlockImageProcessor from './image/block-image-processor.js';
import InlineImageProcessor from './image/inline-image-processor.js';
import { MarkdownProcessor } from './markdown/markdown-processor.js';
import TextProcessor from './text/text-processor.js';
import AudioProcessor from './audio/audio-processor.js';
import PdfProcessor from './pdf/pdf-processor.js';
import ExternProcessor from './extern/extern-processor.js';
import GiftProcessor from './gift/gift-processor.js';
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
import Processor from './processor.js';
import { DwengoContentType } from './content-type.js';
import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js';
import { Language } from '../../../entities/content/language.js';
import { replaceAsync } from '../../../util/async.js';
const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g;
const LEARNING_OBJECT_DOES_NOT_EXIST = "<div class='non-existing-learning-object' />";
class ProcessingService {
private processors!: Map<DwengoContentType, Processor<any>>;
constructor() {
const processors = [
new InlineImageProcessor(),
new BlockImageProcessor(),
new MarkdownProcessor(),
new TextProcessor(),
new AudioProcessor(),
new PdfProcessor(),
new ExternProcessor(),
new GiftProcessor(),
];
this.processors = new Map(processors.map((processor) => [processor.contentType, processor]));
}
/**
* Render the given learning object.
* @param learningObject The learning object to render
* @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and
* returns the corresponding learning object. This is used to fetch learning
* objects embedded into this one.
* If this argument is omitted, embedded learning objects will be represented
* by placeholders.
* @returns Rendered HTML for this LearningObject as a string.
*/
async render(
learningObject: LearningObject,
fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise<LearningObject | null>
): Promise<string> {
const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject);
if (fetchEmbeddedLearningObjects) {
// Replace all embedded learning objects.
return replaceAsync(
html,
EMBEDDED_LEARNING_OBJECT_PLACEHOLDER,
async (_, hruid: string, language: string, version: string): Promise<string> => {
// Fetch the embedded learning object...
const learningObject = await fetchEmbeddedLearningObjects({
hruid,
language: language as Language,
version: parseInt(version),
});
// If it does not exist, replace it by a placeholder.
if (!learningObject) {
return LEARNING_OBJECT_DOES_NOT_EXIST;
}
// ... and render it.
return this.render(learningObject);
}
);
}
return html;
}
}
export default new ProcessingService();

View file

@ -0,0 +1,61 @@
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
import { ProcessingError } from './processing-error.js';
import { DwengoContentType } from './content-type.js';
/**
* Abstract base class for all processors.
* Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML.
*
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js
*/
abstract class Processor<T> {
protected constructor(public contentType: DwengoContentType) {}
/**
* Render the given object.
*
* @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this
* Processor is responsible.
* @return Rendered HTML-string
* @throws ProcessingError if the rendering fails.
*/
render(toRender: T): string {
return this.renderFn(toRender);
}
/**
* Render a learning object with the content type for which this processor is responsible.
* @param toRender
*/
renderLearningObject(toRender: LearningObject): string {
if (toRender.contentType !== this.contentType) {
throw new ProcessingError(
`Unsupported content type: ${toRender.contentType}.
This processor is only responsible for content of type ${this.contentType}.`
);
}
return this.renderLearningObjectFn(toRender);
}
/**
* Function which actually renders the content.
*
* @param toRender Content to be rendered
* @return Rendered HTML as a string
* @protected
*/
protected abstract renderFn(toRender: T): string;
/**
* Function which actually executes the rendering of a learning object.
*
* When implementing this function, we may assume that we are responsible for the content type of the learning
* object.
*
* @param toRender Learning object to render
* @protected
*/
protected abstract renderLearningObjectFn(toRender: LearningObject): string;
}
export default Processor;

View file

@ -0,0 +1,19 @@
import Processor from './processor.js';
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
export abstract class StringProcessor extends Processor<string> {
/**
* Function which actually executes the rendering of a learning object.
* By default, this just means rendering the content in the content property of the learning object (interpreted
* as string)
*
* When implementing this function, we may assume that we are responsible for the content type of the learning
* object.
*
* @param toRender Learning object to render
* @protected
*/
protected renderLearningObjectFn(toRender: LearningObject): string {
return this.render(toRender.content.toString('ascii'));
}
}

View file

@ -0,0 +1,20 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js
*/
import DOMPurify from 'isomorphic-dompurify';
import { DwengoContentType } from '../content-type.js';
import { StringProcessor } from '../string-processor.js';
class TextProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.TEXT_PLAIN);
}
override renderFn(text: string) {
// Sanitize plain text to prevent xss.
return DOMPurify.sanitize(text);
}
}
export default TextProcessor;

View file

@ -0,0 +1,190 @@
import { LearningPathProvider } from './learning-path-provider.js';
import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js';
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
import { getLearningPathRepository } from '../../data/repositories.js';
import { Language } from '../../entities/content/language.js';
import learningObjectService from '../learning-objects/learning-object-service.js';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js';
/**
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
* corresponding learning object.
* @param nodes The nodes to find the learning object for.
*/
async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> {
// Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
// Its corresponding learning object.
const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>(
await Promise.all(
nodes.map((node) =>
learningObjectService
.getLearningObjectById({
hruid: node.learningObjectHruid,
version: node.version,
language: node.language,
})
.then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject])
)
)
);
if (nullableNodesToLearningObjects.values().some((it) => it === null)) {
throw new Error('At least one of the learning objects on this path could not be found.');
}
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
}
/**
* Convert the given learning path entity to an object which conforms to the learning path content.
*/
async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> {
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
const targetAges = nodesToLearningObjects
.values()
.flatMap((it) => it.targetAges || [])
.toArray();
const keywords = nodesToLearningObjects
.values()
.flatMap((it) => it.keywords || [])
.toArray();
const image = learningPath.image ? learningPath.image.toString('base64') : undefined;
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order,
hruid: learningPath.hruid,
language: learningPath.language,
description: learningPath.description,
image: image,
title: learningPath.title,
nodes: convertedNodes,
num_nodes: learningPath.nodes.length,
num_nodes_left: convertedNodes.filter((it) => !it.done).length,
keywords: keywords.join(' '),
target_ages: targetAges,
max_age: Math.max(...targetAges),
min_age: Math.min(...targetAges),
};
}
/**
* Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding
* learning objects into a list of learning path nodes as they should be represented in the API.
* @param nodesToLearningObjects
* @param personalizedFor
*/
async function convertNodes(
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>,
personalizedFor?: PersonalizationTarget
): Promise<LearningObjectNode[]> {
const nodesPromise = nodesToLearningObjects
.entries()
.map(async (entry) => {
const [node, learningObject] = entry;
const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null;
return {
_id: learningObject.uuid,
language: learningObject.language,
start_node: node.startNode,
created_at: node.createdAt.toISOString(),
updatedAt: node.updatedAt.toISOString(),
learningobject_hruid: node.learningObjectHruid,
version: learningObject.version,
transitions: node.transitions
.filter(
(trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible.
)
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition
};
})
.toArray();
return await Promise.all(nodesPromise);
}
/**
* Helper method to convert a json string to an object, or null if it is undefined.
*/
function optionalJsonStringToObject(jsonString?: string): object | null {
if (!jsonString) {
return null;
}
return JSON.parse(jsonString);
}
/**
* Helper function which converts a transition in the database representation to a transition in the representation
* the Dwengo API uses.
*
* @param transition
* @param index
* @param nodesToLearningObjects
*/
function convertTransition(
transition: LearningPathTransition,
index: number,
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): Transition {
const nextNode = nodesToLearningObjects.get(transition.next);
if (!nextNode) {
throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`);
} else {
return {
_id: '' + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
default: false, // We don't work with default transitions but retain this for backwards compatibility.
next: {
_id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
hruid: transition.next.learningObjectHruid,
language: nextNode.language,
version: nextNode.version,
},
};
}
}
/**
* Service providing access to data about learning paths from the database.
*/
const databaseLearningPathProvider: LearningPathProvider = {
/**
* Fetch the learning paths with the given hruids from the database.
*/
async fetchLearningPaths(
hruids: string[],
language: Language,
source: string,
personalizedFor?: PersonalizationTarget
): Promise<LearningPathResponse> {
const learningPathRepo = getLearningPathRepository();
const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter(
(learningPath) => learningPath !== null
);
const filteredLearningPaths = await Promise.all(
learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor))
);
return {
success: filteredLearningPaths.length > 0,
data: await Promise.all(filteredLearningPaths),
source,
};
},
/**
* Search learning paths in the database using the given search string.
*/
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
const learningPathRepo = getLearningPathRepository();
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);
return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor)));
},
};
export default databaseLearningPathProvider;

View file

@ -0,0 +1,50 @@
import { fetchWithLogging } from '../../util/apiHelper.js';
import { DWENGO_API_BASE } from '../../config.js';
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import { LearningPathProvider } from './learning-path-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
const logger: Logger = getLogger();
const dwengoApiLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
source,
data: null,
message: `No HRUIDs provided for ${source}.`,
};
}
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
if (!learningPaths || learningPaths.length === 0) {
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
return {
success: false,
source,
data: [],
message: `No learning paths found for ${source}.`,
};
}
return {
success: true,
source,
data: learningPaths,
};
},
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
return searchResults ?? [];
},
};
export default dwengoApiLearningPathProvider;

View file

@ -0,0 +1,90 @@
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { Student } from '../../entities/users/student.entity.js';
import { Group } from '../../entities/assignments/group.entity.js';
import { Submission } from '../../entities/assignments/submission.entity.js';
import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { JSONPath } from 'jsonpath-plus';
export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group };
/**
* Shortcut function to easily create a PersonalizationTarget object for a student by his/her username.
* @param username Username of the student we want to generate a personalized learning path for.
* If there is no student with this username, return undefined.
*/
export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> {
const student = await getStudentRepository().findByUsername(username);
if (student) {
return {
type: 'student',
student: student,
};
}
return undefined;
}
/**
* Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and
* group number.
* @param classId Id of the class in which this group was created
* @param assignmentNumber Number of the assignment for which this group was created
* @param groupNumber Number of the group for which we want to personalize the learning path.
*/
export async function personalizedForGroup(
classId: string,
assignmentNumber: number,
groupNumber: number
): Promise<PersonalizationTarget | undefined> {
const clazz = await getClassRepository().findById(classId);
if (!clazz) {
return undefined;
}
const group = await getGroupRepository().findOne({
assignment: {
within: clazz,
id: assignmentNumber,
},
groupNumber: groupNumber,
});
if (group) {
return {
type: 'group',
group: group,
};
}
return undefined;
}
/**
* Returns the last submission for the learning object associated with the given node and for the student or group
*/
export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = {
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
};
if (pathFor.type === 'group') {
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
}
return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
}
/**
* Checks whether the condition of the given transaction is fulfilled by the given submission.
* @param transition
* @param submitted
*/
export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean {
if (transition.condition === 'true' || !transition.condition) {
return true; // If the transition is unconditional, we can go on.
}
if (submitted === null) {
return false; // If the transition is not unconditional and there was no submission, the transition is not possible.
}
const match = JSONPath({ path: transition.condition, json: { submission: submitted } });
return match.length === 1;
}

View file

@ -0,0 +1,18 @@
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import { Language } from '../../entities/content/language.js';
import { PersonalizationTarget } from './learning-path-personalization-util.js';
/**
* Generic interface for a service which provides access to learning paths from a data source.
*/
export interface LearningPathProvider {
/**
* Fetch the learning paths with the given hruids from the data source.
*/
fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>;
/**
* Search learning paths in the data source using the given search string.
*/
searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>;
}

View file

@ -0,0 +1,57 @@
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
import databaseLearningPathProvider from './database-learning-path-provider.js';
import { EnvVars, getEnvVar } from '../../util/envvars.js';
import { Language } from '../../entities/content/language.js';
import { PersonalizationTarget } from './learning-path-personalization-util.js';
const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
/**
* Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api)
*/
const learningPathService = {
/**
* Fetch the learning paths with the given hruids from the data source.
* @param hruids For each of the hruids, the learning path will be fetched.
* @param language This is the language each of the learning paths will use.
* @param source
* @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned.
*/
async fetchLearningPaths(
hruids: string[],
language: Language,
source: string,
personalizedFor?: PersonalizationTarget
): Promise<LearningPathResponse> {
const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix));
const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix));
const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor);
const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(
nonUserContentHruids,
language,
source,
personalizedFor
);
const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []);
return {
data: result,
source: source,
success: userContentLearningPaths.success || nonUserContentLearningPaths.success,
};
},
/**
* Search learning paths in the data source using the given search string.
*/
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor)));
return providerResponses.flat();
},
};
export default learningPathService;

View file

@ -1,91 +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) => node.learningobject_hruid);
}
return await Promise.all(nodes.map(async (node) => getLearningObjectById(node.learningobject_hruid, language))).then((objects) =>
objects.filter((obj): obj is FilteredLearningObject => obj !== null)
);
} catch (error) {
logger.error('❌ Error fetching learning objects:', error);
return [];
}
}
/**
* Fetch full learning object data (metadata)
*/
export async function getLearningObjectsFromPath(hruid: string, language: string): Promise<FilteredLearningObject[]> {
return (await fetchLearningObjects(hruid, true, language)) as FilteredLearningObject[];
}
/**
* Fetch only learning object HRUIDs
*/
export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise<string[]> {
return (await fetchLearningObjects(hruid, false, language)) as string[];
}

View file

@ -1,46 +0,0 @@
import { fetchWithLogging } from '../util/apiHelper.js';
import { DWENGO_API_BASE } from '../config.js';
import { LearningPath, LearningPathResponse } from '../interfaces/learningPath.js';
import { getLogger, Logger } from '../logging/initalize.js';
const logger: Logger = getLogger();
export async function fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
source,
data: null,
message: `No HRUIDs provided for ${source}.`,
};
}
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, params);
if (!learningPaths || learningPaths.length === 0) {
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
return {
success: false,
source,
data: [],
message: `No learning paths found for ${source}.`,
};
}
return {
success: true,
source,
data: learningPaths,
};
}
export async function searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, params);
return searchResults ?? [];
}

View file

@ -0,0 +1,41 @@
import { EntityProperty, EventArgs, EventSubscriber } from '@mikro-orm/core';
/**
* The tests are ran on an in-memory SQLite database. However, SQLite does not allow fields which are part of composite
* primary keys to be autoincremented (while PostgreSQL, which we use in production, does). This Subscriber works around
* the issue by remembering the highest values for every autoincremented part of a primary key and assigning them when
* creating a new entity.
*
* However, it is important to note the following limitations:
* - this class can only be used for in-memory SQLite databases since the information on what the highest sequence
* number for each of the properties is, is only saved transiently.
* - automatically setting the generated "autoincremented" value for properties only works when the entity is created
* via an entityManager.create(...) or repo.create(...) method. Otherwise, onInit will not be called and therefore,
* the sequence number will not be filled in.
*/
export class SqliteAutoincrementSubscriber implements EventSubscriber {
private sequenceNumbersForEntityType: Map<string, number> = new Map();
/**
* When an entity with an autoincremented property which is part of the composite private key is created,
* automatically fill this property so we won't face not-null-constraint exceptions when persisting it.
*/
onInit<T extends object>(args: EventArgs<T>): void {
if (!args.meta.compositePK) {
return; // If there is not a composite primary key, autoincrement works fine with SQLite anyway.
}
for (const prop of Object.values(args.meta.properties)) {
const property = prop as EntityProperty<T>;
if (property.primary && property.autoincrement && !(args.entity as Record<string, any>)[property.name]) {
// Obtain and increment sequence number of this entity.
const propertyKey = args.meta.class.name + '.' + property.name;
const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0;
this.sequenceNumbersForEntityType.set(propertyKey, nextSeqNumber + 1);
// Set the property accordingly.
(args.entity as Record<string, any>)[property.name] = nextSeqNumber + 1;
}
}
}
}

View file

@ -9,13 +9,21 @@ const logger: Logger = getLogger();
*
* @param url The API endpoint to fetch from.
* @param description A short description of what is being fetched (for logging).
* @param params
* @param options Contains further options such as params (the query params) and responseType (whether the response
* should be parsed as JSON ("json") or whether it should be returned as plain text ("text")
* @returns The response data if successful, or null if an error occurs.
*/
export async function fetchWithLogging<T>(url: string, description: string, params?: Record<string, any>): Promise<T | null> {
export async function fetchWithLogging<T>(
url: string,
description: string,
options?: {
params?: Record<string, any>;
query?: Record<string, any>;
responseType?: 'json' | 'text';
}
): Promise<T | null> {
try {
const config: AxiosRequestConfig = params ? { params } : {};
const config: AxiosRequestConfig = options || {};
const response = await axios.get<T>(url, config);
return response.data;
} catch (error: any) {

23
backend/src/util/async.ts Normal file
View file

@ -0,0 +1,23 @@
/**
* Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of
* the parts matched by a group in the regex as arguments.
*
* @param str The string where to replace the occurrences
* @param regex
* @param replacementFn
*/
export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise<string>) {
const promises: Promise<string>[] = [];
// First run through matches: add all Promises resulting from the replacement function
str.replace(regex, (full, ...args) => {
promises.push(replacementFn(full, ...args));
return full;
});
// Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner.
const replacements: string[] = await Promise.all(promises);
// Second run through matches: Replace them by their previously computed replacements.
return str.replace(regex, () => replacements.pop()!);
}

View file

@ -15,6 +15,9 @@ export const EnvVars: { [key: string]: EnvVar } = {
DbUsername: { key: DB_PREFIX + 'USERNAME', required: true },
DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true },
DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false },
LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' },
FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' },
UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' },
IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true },
IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true },
IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true },

26
backend/src/util/links.ts Normal file
View 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;
}

View file

@ -0,0 +1,42 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories';
import { ClassRepository } from '../../../src/data/classes/class-repository';
describe('AssignmentRepository', () => {
let assignmentRepository: AssignmentRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
assignmentRepository = getAssignmentRepository();
classRepository = getClassRepository();
});
it('should return the requested assignment', async () => {
const class_ = await classRepository.findById('id02');
const assignment = await assignmentRepository.findByClassAndId(class_!, 2);
expect(assignment).toBeTruthy();
expect(assignment!.title).toBe('tool');
});
it('should return all assignments for a class', async () => {
const class_ = await classRepository.findById('id02');
const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!);
expect(assignments).toBeTruthy();
expect(assignments).toHaveLength(1);
expect(assignments[0].title).toBe('tool');
});
it('should not find removed assignment', async () => {
const class_ = await classRepository.findById('id01');
await assignmentRepository.deleteByClassAndId(class_!, 3);
const assignment = await assignmentRepository.findByClassAndId(class_!, 3);
expect(assignment).toBeNull();
});
});

View file

@ -0,0 +1,49 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { GroupRepository } from '../../../src/data/assignments/group-repository';
import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
describe('GroupRepository', () => {
let groupRepository: GroupRepository;
let assignmentRepository: AssignmentRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
groupRepository = getGroupRepository();
assignmentRepository = getAssignmentRepository();
classRepository = getClassRepository();
});
it('should return the requested group', async () => {
const class_ = await classRepository.findById('id01');
const assignment = await assignmentRepository.findByClassAndId(class_!, 1);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1);
expect(group).toBeTruthy();
});
it('should return all groups for assignment', async () => {
const class_ = await classRepository.findById('id01');
const assignment = await assignmentRepository.findByClassAndId(class_!, 1);
const groups = await groupRepository.findAllGroupsForAssignment(assignment!);
expect(groups).toBeTruthy();
expect(groups).toHaveLength(3);
});
it('should not find removed group', async () => {
const class_ = await classRepository.findById('id02');
const assignment = await assignmentRepository.findByClassAndId(class_!, 2);
await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1);
expect(group).toBeNull();
});
});

View file

@ -0,0 +1,70 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { SubmissionRepository } from '../../../src/data/assignments/submission-repository';
import {
getAssignmentRepository,
getClassRepository,
getGroupRepository,
getStudentRepository,
getSubmissionRepository,
} from '../../../src/data/repositories';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '../../../src/entities/content/language';
import { StudentRepository } from '../../../src/data/users/student-repository';
import { GroupRepository } from '../../../src/data/assignments/group-repository';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
describe('SubmissionRepository', () => {
let submissionRepository: SubmissionRepository;
let studentRepository: StudentRepository;
let groupRepository: GroupRepository;
let assignmentRepository: AssignmentRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
submissionRepository = getSubmissionRepository();
studentRepository = getStudentRepository();
groupRepository = getGroupRepository();
assignmentRepository = getAssignmentRepository();
classRepository = getClassRepository();
});
it('should find the requested submission', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, '1');
const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1);
expect(submission).toBeTruthy();
expect(submission?.content).toBe('sub1');
});
it('should find the most recent submission for a student', async () => {
const id = new LearningObjectIdentifier('id02', Language.English, '1');
const student = await studentRepository.findByUsername('Noordkaap');
const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, student!);
expect(submission).toBeTruthy();
expect(submission?.submissionTime.getDate()).toBe(25);
});
it('should find the most recent submission for a group', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, '1');
const class_ = await classRepository.findById('id01');
const assignment = await assignmentRepository.findByClassAndId(class_!, 1);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1);
const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!);
expect(submission).toBeTruthy();
expect(submission?.submissionTime.getDate()).toBe(25);
});
it('should not find a deleted submission', async () => {
const id = new LearningObjectIdentifier('id01', Language.English, '1');
await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1);
const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1);
expect(submission).toBeNull();
});
});

View file

@ -0,0 +1,47 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join-request-repository';
import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories';
import { StudentRepository } from '../../../src/data/users/student-repository';
import { Class } from '../../../src/entities/classes/class.entity';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { Student } from '../../../src/entities/users/student.entity';
describe('ClassJoinRequestRepository', () => {
let classJoinRequestRepository: ClassJoinRequestRepository;
let studentRepository: StudentRepository;
let cassRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
classJoinRequestRepository = getClassJoinRequestRepository();
studentRepository = getStudentRepository();
cassRepository = getClassRepository();
});
it('should list all requests from student to join classes', async () => {
const student = await studentRepository.findByUsername('PinkFloyd');
const requests = await classJoinRequestRepository.findAllRequestsBy(student!);
expect(requests).toBeTruthy();
expect(requests).toHaveLength(2);
});
it('should list all requests to a single class', async () => {
const class_ = await cassRepository.findById('id02');
const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!);
expect(requests).toBeTruthy();
expect(requests).toHaveLength(2);
});
it('should not find a removed request', async () => {
const student = await studentRepository.findByUsername('SmashingPumpkins');
const class_ = await cassRepository.findById('id03');
await classJoinRequestRepository.deleteBy(student!, class_!);
const request = await classJoinRequestRepository.findAllRequestsBy(student!);
expect(request).toHaveLength(0);
});
});

View file

@ -0,0 +1,34 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { setupTestApp } from '../../setup-tests';
import { getClassRepository } from '../../../src/data/repositories';
describe('ClassRepository', () => {
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
classRepository = getClassRepository();
});
it('should return nothing because id does not exist', async () => {
const classVar = await classRepository.findById('test_id');
expect(classVar).toBeNull();
});
it('should return requested class', async () => {
const classVar = await classRepository.findById('id01');
expect(classVar).toBeTruthy();
expect(classVar?.displayName).toBe('class01');
});
it('class should be gone after deletion', async () => {
await classRepository.deleteById('id04');
const classVar = await classRepository.findById('id04');
expect(classVar).toBeNull();
});
});

View file

@ -0,0 +1,54 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { getClassRepository, getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories';
import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository';
import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
describe('ClassRepository', () => {
let teacherInvitationRepository: TeacherInvitationRepository;
let teacherRepository: TeacherRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
teacherInvitationRepository = getTeacherInvitationRepository();
teacherRepository = getTeacherRepository();
classRepository = getClassRepository();
});
it('should return all invitations from a teacher', async () => {
const teacher = await teacherRepository.findByUsername('LimpBizkit');
const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!);
expect(invitations).toBeTruthy();
expect(invitations).toHaveLength(2);
});
it('should return all invitations for a teacher', async () => {
const teacher = await teacherRepository.findByUsername('FooFighters');
const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!);
expect(invitations).toBeTruthy();
expect(invitations).toHaveLength(2);
});
it('should return all invitations for a class', async () => {
const class_ = await classRepository.findById('id02');
const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!);
expect(invitations).toBeTruthy();
expect(invitations).toHaveLength(2);
});
it('should not find a removed invitation', async () => {
const class_ = await classRepository.findById('id01');
const sender = await teacherRepository.findByUsername('FooFighters');
const receiver = await teacherRepository.findByUsername('LimpBizkit');
await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!);
const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!);
expect(invitation).toHaveLength(0);
});
});

View file

@ -0,0 +1,79 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js';
import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js';
import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js';
import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js';
import { LearningObject } from '../../../src/entities/content/learning-object.entity.js';
import { Attachment } from '../../../src/entities/content/attachment.entity.js';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js';
const NEWER_TEST_SUFFIX = 'nEweR';
function createTestLearningObjects(learningObjectRepo: LearningObjectRepository): { older: LearningObject; newer: LearningObject } {
const olderExample = example.createLearningObject();
learningObjectRepo.save(olderExample);
const newerExample = example.createLearningObject();
newerExample.title = 'Newer example';
newerExample.version = 100;
return {
older: olderExample,
newer: newerExample,
};
}
describe('AttachmentRepository', () => {
let attachmentRepo: AttachmentRepository;
let exampleLearningObjects: { older: LearningObject; newer: LearningObject };
let attachmentsOlderLearningObject: Attachment[];
beforeAll(async () => {
await setupTestApp();
attachmentRepo = getAttachmentRepository();
exampleLearningObjects = createTestLearningObjects(getLearningObjectRepository());
});
it('can add attachments to learning objects without throwing an error', () => {
attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older));
for (const attachment of attachmentsOlderLearningObject) {
attachmentRepo.save(attachment);
}
});
let attachmentOnlyNewer: Attachment;
it('allows us to add attachments with the same name to a different learning object without throwing an error', () => {
attachmentOnlyNewer = Object.values(example.createAttachment)[0](exampleLearningObjects.newer);
attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX);
attachmentRepo.save(attachmentOnlyNewer);
});
let olderLearningObjectId: LearningObjectIdentifier;
it('returns the correct attachment when queried by learningObjectId and attachment name', async () => {
olderLearningObjectId = {
hruid: exampleLearningObjects.older.hruid,
language: exampleLearningObjects.older.language,
version: exampleLearningObjects.older.version,
};
const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name);
expect(result).toBe(attachmentsOlderLearningObject[0]);
});
it('returns null when queried by learningObjectId and non-existing attachment name', async () => {
const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, 'non-existing name');
expect(result).toBe(null);
});
it('returns the newer version of the attachment when only queried by hruid, language and attachment name (but not version)', async () => {
const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(
exampleLearningObjects.older.hruid,
exampleLearningObjects.older.language,
attachmentOnlyNewer.name
);
expect(result).toBe(attachmentOnlyNewer);
});
});

View file

@ -0,0 +1,31 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js';
import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js';
import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js';
import { Language } from '../../../src/entities/content/language.js';
describe('AttachmentRepository', () => {
let attachmentRepository: AttachmentRepository;
let learningObjectRepository: LearningObjectRepository;
beforeAll(async () => {
await setupTestApp();
attachmentRepository = getAttachmentRepository();
learningObjectRepository = getLearningObjectRepository();
});
it('should return the requested attachment', async () => {
const id = new LearningObjectIdentifier('id02', Language.English, '1');
const learningObject = await learningObjectRepository.findByIdentifier(id);
const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName(
learningObject!,
Language.English,
'attachment01'
);
expect(attachment).toBeTruthy();
});
});

View file

@ -0,0 +1,72 @@
import { beforeAll, describe, it, expect } from 'vitest';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js';
import { setupTestApp } from '../../setup-tests.js';
import { getLearningObjectRepository } from '../../../src/data/repositories.js';
import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js';
import { LearningObject } from '../../../src/entities/content/learning-object.entity.js';
import { expectToBeCorrectEntity } from '../../test-utils/expectations.js';
describe('LearningObjectRepository', () => {
let learningObjectRepository: LearningObjectRepository;
let exampleLearningObject: LearningObject;
beforeAll(async () => {
await setupTestApp();
learningObjectRepository = getLearningObjectRepository();
});
it('should be able to add a learning object to it without an error', async () => {
exampleLearningObject = example.createLearningObject();
await learningObjectRepository.insert(exampleLearningObject);
});
it('should return the learning object when queried by id', async () => {
const result = await learningObjectRepository.findByIdentifier({
hruid: exampleLearningObject.hruid,
language: exampleLearningObject.language,
version: exampleLearningObject.version,
});
expect(result).toBeInstanceOf(LearningObject);
expectToBeCorrectEntity(
{
name: 'actual',
entity: result!,
},
{
name: 'expected',
entity: exampleLearningObject,
}
);
});
it('should return null when non-existing version is queried', async () => {
const result = await learningObjectRepository.findByIdentifier({
hruid: exampleLearningObject.hruid,
language: exampleLearningObject.language,
version: 100,
});
expect(result).toBe(null);
});
let newerExample: LearningObject;
it('should allow a learning object with the same id except a different version to be added', async () => {
newerExample = example.createLearningObject();
newerExample.version = 10;
newerExample.title += ' (nieuw)';
await learningObjectRepository.save(newerExample);
});
it('should return the newest version of the learning object when queried by only hruid and language', async () => {
const result = await learningObjectRepository.findLatestByHruidAndLanguage(newerExample.hruid, newerExample.language);
expect(result).toBeInstanceOf(LearningObject);
expect(result?.version).toBe(10);
expect(result?.title).toContain('(nieuw)');
});
it('should return null when queried by non-existing hruid or language', async () => {
const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', exampleLearningObject.language);
expect(result).toBe(null);
});
});

View file

@ -0,0 +1,32 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository';
import { getLearningObjectRepository } from '../../../src/data/repositories';
import { setupTestApp } from '../../setup-tests';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '../../../src/entities/content/language';
describe('LearningObjectRepository', () => {
let learningObjectRepository: LearningObjectRepository;
beforeAll(async () => {
await setupTestApp();
learningObjectRepository = getLearningObjectRepository();
});
const id01 = new LearningObjectIdentifier('id01', Language.English, '1');
const id02 = new LearningObjectIdentifier('test_id', Language.English, '1');
it('should return the learning object that matches identifier 1', async () => {
const learningObject = await learningObjectRepository.findByIdentifier(id01);
expect(learningObject).toBeTruthy();
expect(learningObject?.title).toBe('Undertow');
expect(learningObject?.description).toBe('debute');
});
it('should return nothing because the identifier does not exist in the database', async () => {
const learningObject = await learningObjectRepository.findByIdentifier(id02);
expect(learningObject).toBeNull();
});
});

View file

@ -0,0 +1,66 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js';
import { getLearningPathRepository } from '../../../src/data/repositories.js';
import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.js';
import example from '../../test-assets/learning-paths/pn-werking-example.js';
import { LearningPath } from '../../../src/entities/content/learning-path.entity.js';
import { expectToBeCorrectEntity } from '../../test-utils/expectations.js';
import { Language } from '../../../src/entities/content/language.js';
function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void {
expect(result).toHaveProperty('length');
expect(result.length).toBe(1);
expectToBeCorrectEntity({ entity: result[0]! }, { entity: expected });
}
function expectToHaveFoundNothing(result: LearningPath[]): void {
expect(result).toHaveProperty('length');
expect(result.length).toBe(0);
}
describe('LearningPathRepository', () => {
let learningPathRepo: LearningPathRepository;
beforeAll(async () => {
await setupTestApp();
learningPathRepo = getLearningPathRepository();
});
let examplePath: LearningPath;
it('should be able to add a learning path without throwing an error', async () => {
examplePath = example.createLearningPath();
await learningPathRepo.insert(examplePath);
});
it('should return the added path when it is queried by hruid and language', async () => {
const result = await learningPathRepo.findByHruidAndLanguage(examplePath.hruid, examplePath.language);
expect(result).toBeInstanceOf(LearningPath);
expectToBeCorrectEntity({ entity: result! }, { entity: examplePath });
});
it('should return null to a query on a non-existing hruid or language', async () => {
const result = await learningPathRepo.findByHruidAndLanguage('not_existing_hruid', examplePath.language);
expect(result).toBe(null);
});
it('should return the learning path when we search for a search term occurring in its title', async () => {
const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(4, 9), examplePath.language);
expectToHaveFoundPrecisely(examplePath, result);
});
it('should return the learning path when we search for a search term occurring in its description', async () => {
const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(8, 15), examplePath.language);
expectToHaveFoundPrecisely(examplePath, result);
});
it('should return null when we search for something not occurring in its title or description', async () => {
const result = await learningPathRepo.findByQueryStringAndLanguage('something not occurring in the path', examplePath.language);
expectToHaveFoundNothing(result);
});
it('should return null when we search for something occurring in its title, but another language', async () => {
const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(1, 3), Language.Kalaallisut);
expectToHaveFoundNothing(result);
});
});

View file

@ -0,0 +1,28 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { getLearningPathRepository } from '../../../src/data/repositories';
import { LearningPathRepository } from '../../../src/data/content/learning-path-repository';
import { setupTestApp } from '../../setup-tests';
import { Language } from '../../../src/entities/content/language';
describe('LearningPathRepository', () => {
let learningPathRepository: LearningPathRepository;
beforeAll(async () => {
await setupTestApp();
learningPathRepository = getLearningPathRepository();
});
it('should return nothing because no match for hruid and language', async () => {
const learningPath = await learningPathRepository.findByHruidAndLanguage('test_id', Language.Dutch);
expect(learningPath).toBeNull();
});
it('should return requested learning path', async () => {
const learningPath = await learningPathRepository.findByHruidAndLanguage('id01', Language.English);
expect(learningPath).toBeTruthy();
expect(learningPath?.title).toBe('repertoire Tool');
expect(learningPath?.description).toBe('all about Tool');
});
});

View file

@ -0,0 +1,66 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { AnswerRepository } from '../../../src/data/questions/answer-repository';
import { getAnswerRepository, getQuestionRepository, getTeacherRepository } from '../../../src/data/repositories';
import { QuestionRepository } from '../../../src/data/questions/question-repository';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '../../../src/entities/content/language';
import { TeacherRepository } from '../../../src/data/users/teacher-repository';
describe('AnswerRepository', () => {
let answerRepository: AnswerRepository;
let questionRepository: QuestionRepository;
let teacherRepository: TeacherRepository;
beforeAll(async () => {
await setupTestApp();
answerRepository = getAnswerRepository();
questionRepository = getQuestionRepository();
teacherRepository = getTeacherRepository();
});
it('should find all answers to a question', async () => {
const id = new LearningObjectIdentifier('id05', Language.English, '1');
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
const question = questions.filter((it) => it.sequenceNumber == 2)[0];
const answers = await answerRepository.findAllAnswersToQuestion(question);
expect(answers).toBeTruthy();
expect(answers).toHaveLength(2);
expect(answers[0].content).toBeOneOf(['answer', 'answer2']);
expect(answers[1].content).toBeOneOf(['answer', 'answer2']);
});
it('should create an answer to a question', async () => {
const teacher = await teacherRepository.findByUsername('FooFighters');
const id = new LearningObjectIdentifier('id05', Language.English, '1');
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
const question = questions[0];
await answerRepository.createAnswer({
toQuestion: question,
author: teacher!,
content: 'created answer',
});
const answers = await answerRepository.findAllAnswersToQuestion(question);
expect(answers).toBeTruthy();
expect(answers).toHaveLength(1);
expect(answers[0].content).toBe('created answer');
});
it('should not find a removed answer', async () => {
const id = new LearningObjectIdentifier('id04', Language.English, '1');
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
await answerRepository.removeAnswerByQuestionAndSequenceNumber(questions[0], 1);
const emptyList = await answerRepository.findAllAnswersToQuestion(questions[0]);
expect(emptyList).toHaveLength(0);
});
});

View file

@ -0,0 +1,52 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { QuestionRepository } from '../../../src/data/questions/question-repository';
import { getLearningObjectRepository, getQuestionRepository, getStudentRepository } from '../../../src/data/repositories';
import { StudentRepository } from '../../../src/data/users/student-repository';
import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '../../../src/entities/content/language';
describe('QuestionRepository', () => {
let questionRepository: QuestionRepository;
let studentRepository: StudentRepository;
let learningObjectRepository: LearningObjectRepository;
beforeAll(async () => {
await setupTestApp();
questionRepository = getQuestionRepository();
studentRepository = getStudentRepository();
learningObjectRepository = getLearningObjectRepository();
});
it('should return all questions part of the given learning object', async () => {
const id = new LearningObjectIdentifier('id05', Language.English, '1');
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
expect(questions).toBeTruthy();
expect(questions).toHaveLength(2);
});
it('should create new question', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, '1');
const student = await studentRepository.findByUsername('Noordkaap');
await questionRepository.createQuestion({
loId: id,
author: student!,
content: 'question?',
});
const question = await questionRepository.findAllQuestionsAboutLearningObject(id);
expect(question).toBeTruthy();
expect(question).toHaveLength(1);
});
it('should not find removed question', async () => {
const id = new LearningObjectIdentifier('id04', Language.English, '1');
await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1);
const question = await questionRepository.findAllQuestionsAboutLearningObject(id);
expect(question).toHaveLength(0);
});
});

View file

@ -1,8 +1,8 @@
import { setupTestApp } from '../setup-tests.js';
import { Student } from '../../src/entities/users/student.entity.js';
import { setupTestApp } from '../../setup-tests.js';
import { Student } from '../../../src/entities/users/student.entity.js';
import { describe, it, expect, beforeAll } from 'vitest';
import { StudentRepository } from '../../src/data/users/student-repository.js';
import { getStudentRepository } from '../../src/data/repositories.js';
import { StudentRepository } from '../../../src/data/users/student-repository.js';
import { getStudentRepository } from '../../../src/data/repositories.js';
const username = 'teststudent';
const firstName = 'John';
@ -15,6 +15,20 @@ describe('StudentRepository', () => {
studentRepository = getStudentRepository();
});
it('should not return a student because username does not exist', async () => {
const student = await studentRepository.findByUsername('test');
expect(student).toBeNull();
});
it('should return student from the datbase', async () => {
const student = await studentRepository.findByUsername('Noordkaap');
expect(student).toBeTruthy();
expect(student?.firstName).toBe('Stijn');
expect(student?.lastName).toBe('Meuris');
});
it('should return the queried student after he was added', async () => {
await studentRepository.insert(new Student(username, firstName, lastName));

View file

@ -0,0 +1,47 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { setupTestApp } from '../../setup-tests';
import { getTeacherRepository } from '../../../src/data/repositories';
import { Teacher } from '../../../src/entities/users/teacher.entity';
const username = 'testteacher';
const firstName = 'John';
const lastName = 'Doe';
describe('TeacherRepository', () => {
let teacherRepository: TeacherRepository;
beforeAll(async () => {
await setupTestApp();
teacherRepository = getTeacherRepository();
});
it('should not return a teacher because username does not exist', async () => {
const teacher = await teacherRepository.findByUsername('test');
expect(teacher).toBeNull();
});
it('should return teacher from the datbase', async () => {
const teacher = await teacherRepository.findByUsername('FooFighters');
expect(teacher).toBeTruthy();
expect(teacher?.firstName).toBe('Dave');
expect(teacher?.lastName).toBe('Grohl');
});
it('should return the queried teacher after he was added', async () => {
await teacherRepository.insert(new Teacher(username, firstName, lastName));
const retrievedTeacher = await teacherRepository.findByUsername(username);
expect(retrievedTeacher).toBeTruthy();
expect(retrievedTeacher?.firstName).toBe(firstName);
expect(retrievedTeacher?.lastName).toBe(lastName);
});
it('should no longer return the queried teacher after he was removed again', async () => {
await teacherRepository.deleteByUsername('ZesdeMetaal');
const retrievedTeacher = await teacherRepository.findByUsername('ZesdeMetaal');
expect(retrievedTeacher).toBeNull();
});
});

View file

@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { LearningObjectMetadata, LearningPath } from '../../src/interfaces/learningPath';
import { fetchWithLogging } from '../../src/util/apiHelper';
import { getLearningObjectById, getLearningObjectsFromPath } from '../../src/services/learningObjects';
import { fetchLearningPaths } from '../../src/services/learningPaths';
// Mock API functions
vi.mock('../../src/util/apiHelper', () => ({
fetchWithLogging: vi.fn(),
}));
vi.mock('../../src/services/learningPaths', () => ({
fetchLearningPaths: vi.fn(),
}));
describe('getLearningObjectById', () => {
const hruid = 'test-object';
const language = 'en';
const mockMetadata: LearningObjectMetadata = {
hruid,
_id: '123',
uuid: 'uuid-123',
version: 1,
title: 'Test Object',
language,
difficulty: 5,
estimated_time: 120,
available: true,
teacher_exclusive: false,
educational_goals: [{ source: 'source', id: 'id' }],
keywords: ['robotics'],
description: 'A test object',
target_ages: [10, 12],
content_type: 'markdown',
content_location: '',
};
it('✅ Should return a filtered learning object when API provides data', async () => {
vi.mocked(fetchWithLogging).mockResolvedValueOnce(mockMetadata);
const result = await getLearningObjectById(hruid, language);
expect(result).toEqual({
key: hruid,
_id: '123',
uuid: 'uuid-123',
version: 1,
title: 'Test Object',
htmlUrl: expect.stringContaining('/learningObject/getRaw?hruid=test-object&language=en'),
language,
difficulty: 5,
estimatedTime: 120,
available: true,
teacherExclusive: false,
educationalGoals: [{ source: 'source', id: 'id' }],
keywords: ['robotics'],
description: 'A test object',
targetAges: [10, 12],
contentType: 'markdown',
contentLocation: '',
});
});
it('⚠️ Should return null if API returns no metadata', async () => {
vi.mocked(fetchWithLogging).mockResolvedValueOnce(null);
const result = await getLearningObjectById(hruid, language);
expect(result).toBeNull();
});
});
describe('getLearningObjectsFromPath', () => {
const hruid = 'test-path';
const language = 'en';
it('✅ Should not give error or warning', async () => {
const mockPathResponse: LearningPath[] = [
{
_id: 'path-1',
hruid,
language,
title: 'Test Path',
description: '',
num_nodes: 1,
num_nodes_left: 0,
nodes: [],
keywords: '',
target_ages: [],
min_age: 10,
max_age: 12,
__order: 1,
},
];
vi.mocked(fetchLearningPaths).mockResolvedValueOnce({
success: true,
source: 'Test Source',
data: mockPathResponse,
});
const result = await getLearningObjectsFromPath(hruid, language);
expect(result).toEqual([]);
});
it('⚠️ Should give a warning', async () => {
vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ success: false, source: 'Test Source', data: [] });
const result = await getLearningObjectsFromPath(hruid, language);
expect(result).toEqual([]);
});
it('❌ Should give an error', async () => {
vi.mocked(fetchLearningPaths).mockRejectedValueOnce(new Error('API Error'));
const result = await getLearningObjectsFromPath(hruid, language);
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,90 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchLearningPaths, searchLearningPaths } from '../../src/services/learningPaths';
import { fetchWithLogging } from '../../src/util/apiHelper';
import { LearningPathResponse } from '../../src/interfaces/learningPath';
// Mock the fetchWithLogging module using vi
vi.mock('../../src/util/apiHelper', () => ({
fetchWithLogging: vi.fn(),
}));
describe('fetchLearningPaths', () => {
// Mock data and response
const mockHruids = ['pn_werking', 'art1'];
const language = 'en';
const source = 'Test Source';
const mockResponse = [{ title: 'Test Path', hruids: mockHruids }];
it('✅ Should return a successful response when HRUIDs are provided', async () => {
// Mock the function to return mockResponse
vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse);
const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResponse);
expect(result.source).toBe(source);
});
it('⚠️ Should return an error when no HRUIDs are provided', async () => {
vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse);
const result: LearningPathResponse = await fetchLearningPaths([], language, source);
expect(result.success).toBe(false);
expect(result.data).toBeNull();
expect(result.message).toBe(`No HRUIDs provided for ${source}.`);
});
it('⚠️ Should return a failure response when no learning paths are found', async () => {
// Mock fetchWithLogging to return an empty array
vi.mocked(fetchWithLogging).mockResolvedValue([]);
const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source);
expect(result.success).toBe(false);
expect(result.data).toEqual([]);
expect(result.message).toBe(`No learning paths found for ${source}.`);
});
});
describe('searchLearningPaths', () => {
const query =
'https://dwengo.org/backend/api/learningPath/getPathsFromIdList?pathIdList=%7B%22hruids%22:%5B%22pn_werking%22,%22un_artificiele_intelligentie%22%5D%7D&language=nl';
const language = 'nl';
it('✅ Should return search results when API responds with data', async () => {
const mockResults = [
{
_id: '67b4488c9dadb305c4104618',
language: 'nl',
hruid: 'pn_werking',
title: 'Werken met notebooks',
description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?',
num_nodes: 0,
num_nodes_left: 0,
nodes: [],
keywords: 'Python KIKS Wiskunde STEM AI',
target_ages: [14, 15, 16, 17, 18],
min_age: 14,
max_age: 18,
__order: 0,
},
];
// Mock fetchWithLogging to return search results
vi.mocked(fetchWithLogging).mockResolvedValue(mockResults);
const result = await searchLearningPaths(query, language);
expect(result).toEqual(mockResults);
});
it('⚠️ Should return an empty array when API returns no results', async () => {
vi.mocked(fetchWithLogging).mockResolvedValue([]);
const result = await searchLearningPaths(query, language);
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,108 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories';
import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example';
import { LearningObject } from '../../../src/entities/content/learning-object.entity';
import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider';
import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations';
import { FilteredLearningObject } from '../../../src/interfaces/learning-content';
import { Language } from '../../../src/entities/content/language';
import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example';
import learningPathExample from '../../test-assets/learning-paths/pn-werking-example';
import { LearningPath } from '../../../src/entities/content/learning-path.entity';
async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> {
const learningObjectRepo = getLearningObjectRepository();
const learningPathRepo = getLearningPathRepository();
const learningObject = learningObjectExample.createLearningObject();
const learningPath = learningPathExample.createLearningPath();
await learningObjectRepo.save(learningObject);
await learningPathRepo.save(learningPath);
return { learningObject, learningPath };
}
const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan';
describe('DatabaseLearningObjectProvider', () => {
let exampleLearningObject: LearningObject;
let exampleLearningPath: LearningPath;
beforeAll(async () => {
await setupTestApp();
const exampleData = await initExampleData();
exampleLearningObject = exampleData.learningObject;
exampleLearningPath = exampleData.learningPath;
});
describe('getLearningObjectById', () => {
it('should return the learning object when it is queried by its id', async () => {
const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById(exampleLearningObject);
expect(result).toBeTruthy();
expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject);
});
it('should return the learning object when it is queried by only hruid and language (but not version)', async () => {
const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({
hruid: exampleLearningObject.hruid,
language: exampleLearningObject.language,
});
expect(result).toBeTruthy();
expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject);
});
it('should return null when queried with an id that does not exist', async () => {
const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({
hruid: 'non_existing_hruid',
language: Language.Dutch,
});
expect(result).toBeNull();
});
});
describe('getLearningObjectHTML', () => {
it('should return the correct rendering of the learning object', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject);
expect(result).toEqual(example.getHTMLRendering());
});
it('should return null for a non-existing learning object', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectHTML({
hruid: 'non_existing_hruid',
language: Language.Dutch,
});
expect(result).toBeNull();
});
});
describe('getLearningObjectIdsFromPath', () => {
it('should return all learning object IDs from a path', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath);
expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)));
});
it('should throw an error if queried with a path identifier for which there is no learning path', async () => {
await expect(
(async () => {
await databaseLearningObjectProvider.getLearningObjectIdsFromPath({
hruid: 'non_existing_hruid',
language: Language.Dutch,
});
})()
).rejects.toThrowError();
});
});
describe('getLearningObjectsFromPath', () => {
it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => {
const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPath);
expect(result.length).toBe(exampleLearningPath.nodes.length);
expect(new Set(result.map((it) => it.key))).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)));
expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT);
});
it('should throw an error if queried with a path identifier for which there is no learning path', async () => {
await expect(
(async () => {
await databaseLearningObjectProvider.getLearningObjectsFromPath({
hruid: 'non_existing_hruid',
language: Language.Dutch,
});
})()
).rejects.toThrowError();
});
});
});

View file

@ -0,0 +1,126 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { LearningObject } from '../../../src/entities/content/learning-object.entity';
import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories';
import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example';
import learningObjectService from '../../../src/services/learning-objects/learning-object-service';
import { LearningObjectIdentifier, LearningPathIdentifier } from '../../../src/interfaces/learning-content';
import { Language } from '../../../src/entities/content/language';
import { EnvVars, getEnvVar } from '../../../src/util/envvars';
import { LearningPath } from '../../../src/entities/content/learning-path.entity';
import learningPathExample from '../../test-assets/learning-paths/pn-werking-example';
const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks';
const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifier = {
hruid: 'pn_werkingnotebooks',
language: Language.Dutch,
version: 3,
};
const DWENGO_TEST_LEARNING_PATH_ID: LearningPathIdentifier = {
hruid: 'pn_werking',
language: Language.Dutch,
};
const DWENGO_TEST_LEARNING_PATH_HRUIDS = new Set(['pn_werkingnotebooks', 'pn_werkingnotebooks2', 'pn_werkingnotebooks3']);
async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> {
const learningObjectRepo = getLearningObjectRepository();
const learningPathRepo = getLearningPathRepository();
const learningObject = learningObjectExample.createLearningObject();
const learningPath = learningPathExample.createLearningPath();
await learningObjectRepo.save(learningObject);
await learningPathRepo.save(learningPath);
return { learningObject, learningPath };
}
describe('LearningObjectService', () => {
let exampleLearningObject: LearningObject;
let exampleLearningPath: LearningPath;
beforeAll(async () => {
await setupTestApp();
const exampleData = await initExampleData();
exampleLearningObject = exampleData.learningObject;
exampleLearningPath = exampleData.learningPath;
});
describe('getLearningObjectById', () => {
it('returns the learning object from the Dwengo API if it does not have the user content prefix', async () => {
const result = await learningObjectService.getLearningObjectById(DWENGO_TEST_LEARNING_OBJECT_ID);
expect(result).not.toBeNull();
expect(result?.title).toBe(EXPECTED_DWENGO_LEARNING_OBJECT_TITLE);
});
it('returns the learning object from the database if it does have the user content prefix', async () => {
const result = await learningObjectService.getLearningObjectById(exampleLearningObject);
expect(result).not.toBeNull();
expect(result?.title).toBe(exampleLearningObject.title);
});
it('returns null if the hruid does not have the user content prefix and does not exist in the Dwengo repo', async () => {
const result = await learningObjectService.getLearningObjectById({
hruid: 'non-existing',
language: Language.Dutch,
});
expect(result).toBeNull();
});
});
describe('getLearningObjectHTML', () => {
it('returns the expected HTML when queried with the identifier of a learning object saved in the database', async () => {
const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject);
expect(result).not.toBeNull();
expect(result).toEqual(learningObjectExample.getHTMLRendering());
});
it(
'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' +
'not start with the user content prefix',
async () => {
const result = await learningObjectService.getLearningObjectHTML(DWENGO_TEST_LEARNING_OBJECT_ID);
expect(result).not.toBeNull();
const responseFromDwengoApi = await fetch(
getEnvVar(EnvVars.LearningContentRepoApiBaseUrl) +
`/learningObject/getRaw?hruid=${DWENGO_TEST_LEARNING_OBJECT_ID.hruid}&language=${DWENGO_TEST_LEARNING_OBJECT_ID.language}&version=${DWENGO_TEST_LEARNING_OBJECT_ID.version}`
);
const responseHtml = await responseFromDwengoApi.text();
expect(result).toEqual(responseHtml);
}
);
it('returns null when queried with a non-existing identifier', async () => {
const result = await learningObjectService.getLearningObjectHTML({
hruid: 'non_existing_hruid',
language: Language.Dutch,
});
expect(result).toBeNull();
});
});
describe('getLearningObjectsFromPath', () => {
it('returns all learning objects when a learning path in the database is queried', async () => {
const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath);
expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid));
});
it('also returns all learning objects when a learning path from the Dwengo API is queried', async () => {
const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID);
expect(new Set(result.map((it) => it.key))).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS);
});
it('returns an empty list when queried with a non-existing learning path id', async () => {
const result = await learningObjectService.getLearningObjectsFromPath({ hruid: 'non_existing', language: Language.Dutch });
expect(result).toEqual([]);
});
});
describe('getLearningObjectIdsFromPath', () => {
it('returns all learning objects when a learning path in the database is queried', async () => {
const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath);
expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid));
});
it('also returns all learning object hruids when a learning path from the Dwengo API is queried', async () => {
const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID);
expect(new Set(result)).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS);
});
it('returns an empty list when queried with a non-existing learning path id', async () => {
const result = await learningObjectService.getLearningObjectIdsFromPath({ hruid: 'non_existing', language: Language.Dutch });
expect(result).toEqual([]);
});
});
});

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import mdExample from '../../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example';
import multipleChoiceExample from '../../../test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example';
import essayExample from '../../../test-assets/learning-objects/test-essay/test-essay-example';
import processingService from '../../../../src/services/learning-objects/processing/processing-service';
describe('ProcessingService', () => {
it('renders a markdown learning object correctly', async () => {
const markdownLearningObject = mdExample.createLearningObject();
const result = await processingService.render(markdownLearningObject);
expect(result).toEqual(mdExample.getHTMLRendering());
});
it('renders a multiple choice question correctly', async () => {
const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject();
const result = await processingService.render(multipleChoiceLearningObject);
expect(result).toEqual(multipleChoiceExample.getHTMLRendering());
});
it('renders an essay question correctly', async () => {
const essayLearningObject = essayExample.createLearningObject();
const result = await processingService.render(essayLearningObject);
expect(result).toEqual(essayExample.getHTMLRendering());
});
});

Some files were not shown because too many files have changed in this diff Show more