diff --git a/backend/src/entities/content/learning-object-identifier.ts b/backend/src/entities/content/learning-object-identifier.ts index 48d173c1..3c020bd7 100644 --- a/backend/src/entities/content/learning-object-identifier.ts +++ b/backend/src/entities/content/learning-object-identifier.ts @@ -4,6 +4,6 @@ export class LearningObjectIdentifier { constructor( public hruid: string, public language: Language, - public version: string + public version: number ) {} } diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index f3a30ad6..7c805810 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -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() diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 388bd8da..d5d42a4c 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -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 { 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. } } diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index 292bb79e..11f6e1bc 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -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> { + // 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( + 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; +} + +/** + * Convert the given learning path entity to an object which conforms to the learning path content. + */ +async function convertLearningPath(learningPath: LearningPathEntity, order: number): Promise { + const nodesToLearningObjects: Map = + 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 +): 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 +): 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 { - throw new Error("Not yet implemented"); // TODO + async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise { + 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 + }; }, /** diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index e9948963..f7cb1c12 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -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; + fetchLearningPaths(hruids: string[], language: Language, source: string): Promise; /** * Search learning paths in the data source using the given search string. */ - searchLearningPaths(query: string, language: string): Promise; + searchLearningPaths(query: string, language: Language): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index a2776554..601790c9 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -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 { + async fetchLearningPaths(hruids: string[], language: Language, source: string): Promise { 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 { + async searchLearningPaths(query: string, language: Language): Promise { const providerResponses = await Promise.all( allProviders.map( provider => provider.searchLearningPaths(query, language)