feat(backend): databaseLearningPathProvider.fetchLearningPaths geïmplementeerd

This commit is contained in:
Gerald Schmittinger 2025-03-08 18:10:49 +01:00
parent 0fe42f73b2
commit 02be44fe53
6 changed files with 161 additions and 11 deletions

View file

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

View file

@ -54,6 +54,12 @@ export class LearningPathNode {
@Embedded({ entity: () => LearningPathTransition, array: true })
transitions!: LearningPathTransition[];
@Property({ length: 3 })
createdAt: Date = new Date();
@Property({ length: 3, onUpdate: () => new Date() })
updatedAt: Date = new Date();
}
@Embeddable()

View file

@ -14,7 +14,7 @@ import {NotFoundError} from "@mikro-orm/core";
const learningObjectRepo = getLearningObjectRepository();
const learningPathRepo = getLearningPathRepository();
function filter(learningObject: LearningObject | null): FilteredLearningObject | null {
function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null {
if (!learningObject) {
return null;
}
@ -59,7 +59,7 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
*/
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
const learningObject = await findLearningObjectEntityById(id);
return filter(learningObject);
return convertLearningObject(learningObject);
},
/**
@ -110,7 +110,7 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
return learningObject;
})
);
return learningObjects.filter(it => it !== null);
return learningObjects.filter(it => it !== null); // TODO: Determine this based on the submissions of the user.
}
}

View file

@ -1,5 +1,134 @@
import {LearningPathProvider} from "./learning-path-provider";
import {LearningPath, LearningPathResponse} from "../../interfaces/learning-content";
import {
FilteredLearningObject,
LearningObjectNode,
LearningPath,
LearningPathResponse,
Transition
} from "../../interfaces/learning-content";
import {
LearningPath as LearningPathEntity,
LearningPathNode,
LearningPathTransition
} from "../../entities/content/learning-path.entity"
import {getLearningPathRepository} from "../../data/repositories";
import {Language} from "../../entities/content/language";
import learningObjectService from "../learning-objects/learning-object-service";
const learningPathRepo = getLearningPathRepository();
/**
* 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): 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();
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: learningPath.image,
title: learningPath.title,
nodes: convertNodes(nodesToLearningObjects),
num_nodes: learningPath.nodes.length,
num_nodes_left: learningPath.nodes.length, // TODO: Adjust when submissions are added.
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
*/
function convertNodes(
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): LearningObjectNode[] {
return nodesToLearningObjects.entries().map((entry) => {
const [node, learningObject] = entry;
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.map((trans, i) =>
convertTransition(trans, i, nodesToLearningObjects)
)
}
}).toArray();
}
/**
* 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.
@ -8,8 +137,21 @@ const databaseLearningPathProvider: LearningPathProvider = {
/**
* Fetch the learning paths with the given hruids from the database.
*/
fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
throw new Error("Not yet implemented"); // TODO
async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse> {
const learningPaths = await Promise.all(
hruids.map(hruid => learningPathRepo.findByHruidAndLanguage(hruid, language))
);
const filteredLearningPaths = await Promise.all(
learningPaths
.filter(learningPath => learningPath !== null)
.map((learningPath, index) => convertLearningPath(learningPath, index))
);
return {
success: filteredLearningPaths.length > 0,
data: await Promise.all(filteredLearningPaths),
source
};
},
/**

View file

@ -1,4 +1,5 @@
import {LearningPath, LearningPathResponse} from "../../interfaces/learning-content";
import {Language} from "../../entities/content/language";
/**
* Generic interface for a service which provides access to learning paths from a data source.
@ -7,10 +8,10 @@ export interface LearningPathProvider {
/**
* Fetch the learning paths with the given hruids from the data source.
*/
fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse>;
fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse>;
/**
* Search learning paths in the data source using the given search string.
*/
searchLearningPaths(query: string, language: string): Promise<LearningPath[]>;
searchLearningPaths(query: string, language: Language): Promise<LearningPath[]>;
}

View file

@ -5,6 +5,7 @@ import {
import dwengoApiLearningPathProvider from "./dwengo-api-learning-path-provider";
import databaseLearningPathProvider from "./database-learning-path-provider";
import {EnvVars, getEnvVar} from "../../util/envvars";
import {Language} from "../../entities/content/language";
const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]
@ -16,7 +17,7 @@ const learningPathService = {
/**
* Fetch the learning paths with the given hruids from the data source.
*/
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise<LearningPathResponse> {
const userContentHruids = hruids.filter(hruid => hruid.startsWith(userContentPrefix));
const nonUserContentHruids = hruids.filter(hruid => !hruid.startsWith(userContentPrefix));
@ -35,7 +36,7 @@ const learningPathService = {
/**
* Search learning paths in the data source using the given search string.
*/
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
async searchLearningPaths(query: string, language: Language): Promise<LearningPath[]> {
const providerResponses = await Promise.all(
allProviders.map(
provider => provider.searchLearningPaths(query, language)