212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
import { LearningPathProvider } from './learning-path-provider.js';
|
|
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
|
|
import { getLearningPathRepository } from '../../data/repositories.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 { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js';
|
|
import {
|
|
FilteredLearningObject,
|
|
LearningObjectNode,
|
|
LearningPath,
|
|
LearningPathResponse,
|
|
Transition,
|
|
} from '@dwengo-1/common/interfaces/learning-content';
|
|
import { Language } from '@dwengo-1/common/util/language';
|
|
import { Group } from '../../entities/assignments/group.entity';
|
|
import { Collection } from '@mikro-orm/core';
|
|
import { v4 } from 'uuid';
|
|
|
|
/**
|
|
* 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: Collection<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(async (node) =>
|
|
learningObjectService
|
|
.getLearningObjectById({
|
|
hruid: node.learningObjectHruid,
|
|
version: node.version,
|
|
language: node.language,
|
|
})
|
|
.then((learningObject) => [node, learningObject] as [LearningPathNode, FilteredLearningObject | null])
|
|
)
|
|
)
|
|
);
|
|
if (Array.from(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?: Group): Promise<LearningPath> {
|
|
// Fetch the corresponding learning object for each node since some parts of the expected response contains parts
|
|
// With information which is not available in the LearningPathNodes themselves.
|
|
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
|
|
|
|
// The target ages of a learning path are the union of the target ages of all learning objects.
|
|
const targetAges = [...new Set(Array.from(nodesToLearningObjects.values()).flatMap((it) => it.targetAges || []))];
|
|
// The keywords of the learning path consist of the union of the keywords of all learning objects.
|
|
const keywords = [...new Set(Array.from(nodesToLearningObjects.values()).flatMap((it) => it.keywords || []))];
|
|
|
|
const image = learningPath.image ? learningPath.image.toString('base64') : undefined;
|
|
|
|
// Convert the learning object notes as retrieved from the database into the expected response format-
|
|
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 a single learning path node (as represented in the database) and the corresponding
|
|
* learning object into a learning path node as it should be represented in the API.
|
|
*
|
|
* @param node Learning path node as represented in the database.
|
|
* @param learningObject Learning object the learning path node refers to.
|
|
* @param personalizedFor Personalization target if a personalized learning path is desired.
|
|
* @param nodesToLearningObjects Mapping from learning path nodes to the corresponding learning objects.
|
|
*/
|
|
async function convertNode(
|
|
node: LearningPathNode,
|
|
learningObject: FilteredLearningObject,
|
|
personalizedFor: Group | undefined,
|
|
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
|
|
): Promise<LearningObjectNode> {
|
|
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null;
|
|
const transitions = node.transitions
|
|
.filter(
|
|
(trans) =>
|
|
!personalizedFor || // If we do not want a personalized learning path, keep all transitions
|
|
isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
|
|
)
|
|
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects));
|
|
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,
|
|
done: personalizedFor ? lastSubmission !== null : undefined,
|
|
transitions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 Mapping from learning path nodes to the corresponding learning objects.
|
|
* @param personalizedFor Personalization target if a personalized learning path is desired.
|
|
*/
|
|
async function convertNodes(
|
|
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>,
|
|
personalizedFor?: Group
|
|
): Promise<LearningObjectNode[]> {
|
|
const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) =>
|
|
convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects)
|
|
);
|
|
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 The transition to convert
|
|
* @param index The sequence number of the transition to convert
|
|
* @param nodesToLearningObjects Map which maps each learning path node of the current learning path to the learning
|
|
* object it refers to.
|
|
*/
|
|
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: String(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 ? nextNode._id + index : v4(), // 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?: Group): Promise<LearningPathResponse> {
|
|
const learningPathRepo = getLearningPathRepository();
|
|
|
|
const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter(
|
|
(learningPath) => learningPath !== null
|
|
);
|
|
const filteredLearningPaths = await Promise.all(
|
|
learningPaths.map(async (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?: Group): Promise<LearningPath[]> {
|
|
const learningPathRepo = getLearningPathRepository();
|
|
|
|
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);
|
|
return await Promise.all(searchResults.map(async (result, index) => convertLearningPath(result, index, personalizedFor)));
|
|
},
|
|
};
|
|
|
|
export default databaseLearningPathProvider;
|