feat(backend): SearchByAdmins service
This commit is contained in:
		
							parent
							
								
									0d2b486a2c
								
							
						
					
					
						commit
						1639fbdabf
					
				
					 7 changed files with 99 additions and 17 deletions
				
			
		|  | @ -50,6 +50,15 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi | |||
|         return; | ||||
|     } else { | ||||
|         hruidList = themes.flatMap((theme) => theme.hruids); | ||||
|         const apiLearningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup); | ||||
|         // TODO Remove hardcoding
 | ||||
|         const userLearningPaths = await learningPathService.searchLearningPathsByAdmin(['testleerkracht1'], language as Language, forGroup); | ||||
|         if (!apiLearningPaths.data) { | ||||
|             res.json(userLearningPaths); | ||||
|             return; | ||||
|         } | ||||
|         res.json(apiLearningPaths.data.concat(userLearningPaths)); | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); | ||||
|  |  | |||
|  | @ -1,21 +1,35 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { LearningPath } from '../../entities/content/learning-path.entity.js'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { MatchMode } from '@dwengo-1/common/util/match-mode'; | ||||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||
| import { RequiredEntityData } from '@mikro-orm/core'; | ||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||
| import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity'; | ||||
| 
 | ||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||
|     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||
|         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); | ||||
|     } | ||||
| 
 | ||||
|     public async findByAdmins(admins: Teacher[], language: Language, _matchMode?: MatchMode): Promise<LearningPath[]> { | ||||
|         return this.findAll({ | ||||
|             where: { | ||||
|                 language: language, | ||||
|                 admins: { | ||||
|                     $in: admins, | ||||
|                 }, | ||||
|             }, | ||||
|             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 query The query string we want to search 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[]> { | ||||
|  |  | |||
|  | @ -13,9 +13,11 @@ import { | |||
|     Transition, | ||||
| } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { MatchMode } from '@dwengo-1/common/util/match-mode'; | ||||
| import { Group } from '../../entities/assignments/group.entity'; | ||||
| import { Collection } from '@mikro-orm/core'; | ||||
| import { v4 } from 'uuid'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity'; | ||||
| 
 | ||||
| /** | ||||
|  * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its | ||||
|  | @ -34,9 +36,9 @@ async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>): | |||
|                         version: node.version, | ||||
|                         language: node.language, | ||||
|                     }) | ||||
|                     .then((learningObject) => [node, learningObject] as [LearningPathNode, FilteredLearningObject | null]) | ||||
|             ) | ||||
|         ) | ||||
|                     .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.'); | ||||
|  | @ -93,14 +95,14 @@ async function convertNode( | |||
|     node: LearningPathNode, | ||||
|     learningObject: FilteredLearningObject, | ||||
|     personalizedFor: Group | undefined, | ||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> | ||||
|     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.
 | ||||
|                 isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)), // Otherwise remove all transitions that aren't possible.
 | ||||
|         ) | ||||
|         .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)); | ||||
|     return { | ||||
|  | @ -125,10 +127,10 @@ async function convertNode( | |||
|  */ | ||||
| async function convertNodes( | ||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||
|     personalizedFor?: Group | ||||
|     personalizedFor?: Group, | ||||
| ): Promise<LearningObjectNode[]> { | ||||
|     const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) => | ||||
|         convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects) | ||||
|         convertNode(entry[0], entry[1], personalizedFor, nodesToLearningObjects), | ||||
|     ); | ||||
|     return await Promise.all(nodesPromise); | ||||
| } | ||||
|  | @ -155,7 +157,7 @@ function optionalJsonStringToObject(jsonString?: string): object | null { | |||
| function convertTransition( | ||||
|     transition: LearningPathTransition, | ||||
|     index: number, | ||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> | ||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||
| ): Transition { | ||||
|     const nextNode = nodesToLearningObjects.get(transition.next); | ||||
|     if (!nextNode) { | ||||
|  | @ -185,10 +187,10 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|         const learningPathRepo = getLearningPathRepository(); | ||||
| 
 | ||||
|         const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( | ||||
|             (learningPath) => learningPath !== null | ||||
|             (learningPath) => learningPath !== null, | ||||
|         ); | ||||
|         const filteredLearningPaths = await Promise.all( | ||||
|             learningPaths.map(async (learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) | ||||
|             learningPaths.map(async (learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)), | ||||
|         ); | ||||
| 
 | ||||
|         return { | ||||
|  | @ -207,6 +209,13 @@ const databaseLearningPathProvider: LearningPathProvider = { | |||
|         const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); | ||||
|         return await Promise.all(searchResults.map(async (result, index) => convertLearningPath(result, index, personalizedFor))); | ||||
|     }, | ||||
| 
 | ||||
|     async searchLearningPathsByAdmin(admins: Teacher[], language: Language, personalizedFor?: Group, matchMode?: MatchMode): Promise<LearningPath[]> { | ||||
|         const learningPathRepo = getLearningPathRepository(); | ||||
| 
 | ||||
|         const searchResults = await learningPathRepo.findByAdmins(admins, language, matchMode); | ||||
|         return await Promise.all(searchResults.map(async (result, index) => convertLearningPath(result, index, personalizedFor))); | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default databaseLearningPathProvider; | ||||
|  |  | |||
|  | @ -3,6 +3,8 @@ import { DWENGO_API_BASE } from '../../config.js'; | |||
| import { LearningPathProvider } from './learning-path-provider.js'; | ||||
| import { getLogger, Logger } from '../../logging/initalize.js'; | ||||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
|  | @ -38,6 +40,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
|             data: learningPaths, | ||||
|         }; | ||||
|     }, | ||||
| 
 | ||||
|     async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { | ||||
|         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; | ||||
|         const params = { all: query, language }; | ||||
|  | @ -45,6 +48,15 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
|         const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); | ||||
|         return searchResults ?? []; | ||||
|     }, | ||||
| 
 | ||||
|     async searchLearningPathsByAdmin(admins: Teacher[], language: string): Promise<LearningPath[]> { | ||||
|         if (!admins || admins.length === 0) { | ||||
|             return this.searchLearningPaths('', language as Language); | ||||
|         } | ||||
| 
 | ||||
|         // Dwengo API does not have the concept of admins, so we cannot filter by them.
 | ||||
|         return [] | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| export default dwengoApiLearningPathProvider; | ||||
|  |  | |||
|  | @ -1,6 +1,8 @@ | |||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { MatchMode } from '@dwengo-1/common/util/match-mode'; | ||||
| import { Group } from '../../entities/assignments/group.entity'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity'; | ||||
| 
 | ||||
| /** | ||||
|  * Generic interface for a service which provides access to learning paths from a data source. | ||||
|  | @ -15,4 +17,9 @@ export interface LearningPathProvider { | |||
|      * Search learning paths in the data source using the given search string. | ||||
|      */ | ||||
|     searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the learning paths for the given admins from the data source. | ||||
|      */ | ||||
|     searchLearningPathsByAdmin(admins: Teacher[], language: Language, personalizedFor?: Group, matchMode?: MatchMode): Promise<LearningPath[]>; | ||||
| } | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo- | |||
| import { Language } from '@dwengo-1/common/util/language'; | ||||
| import { Group } from '../../entities/assignments/group.entity.js'; | ||||
| import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; | ||||
| import { getLearningPathRepository } from '../../data/repositories.js'; | ||||
| import { getLearningPathRepository, getTeacherRepository } from '../../data/repositories.js'; | ||||
| import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||
| import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js'; | ||||
|  | @ -37,11 +37,11 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | |||
|             startNode: nodeDto.start_node ?? false, | ||||
|             createdAt: new Date(), | ||||
|             updatedAt: new Date(), | ||||
|         }) | ||||
|         }), | ||||
|     ); | ||||
|     dto.nodes.forEach((nodeDto) => { | ||||
|         const fromNode = nodes.find( | ||||
|             (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version | ||||
|             (it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version, | ||||
|         )!; | ||||
|         const transitions = nodeDto.transitions | ||||
|             .map((transDto, i) => { | ||||
|  | @ -49,7 +49,7 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | |||
|                     (it) => | ||||
|                         it.learningObjectHruid === transDto.next.hruid && | ||||
|                         it.language === transDto.next.language && | ||||
|                         it.version === transDto.next.version | ||||
|                         it.version === transDto.next.version, | ||||
|                 ); | ||||
| 
 | ||||
|                 if (toNode) { | ||||
|  | @ -93,7 +93,7 @@ const learningPathService = { | |||
|             nonUserContentHruids, | ||||
|             language, | ||||
|             source, | ||||
|             personalizedFor | ||||
|             personalizedFor, | ||||
|         ); | ||||
| 
 | ||||
|         const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); | ||||
|  | @ -110,7 +110,28 @@ const learningPathService = { | |||
|      */ | ||||
|     async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> { | ||||
|         const providerResponses = await Promise.all( | ||||
|             allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor)) | ||||
|             allProviders.map(async (provider) => provider.searchLearningPaths(query, language, personalizedFor)), | ||||
|         ); | ||||
|         return providerResponses.flat(); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the learning paths for the given admins from the data source. | ||||
|      */ | ||||
|     async searchLearningPathsByAdmin(adminsIds: string[], language: Language, personalizedFor?: Group): Promise<LearningPath[]> { | ||||
|         const teacherRepo = getTeacherRepository(); | ||||
|         const admins = await Promise.all( | ||||
|             adminsIds.map(async (adminId) => { | ||||
|                 const admin = await teacherRepo.findByUsername(adminId); | ||||
|                 if (!admin) { | ||||
|                     throw new Error(`Admin with ID ${adminId} not found.`); | ||||
|                 } | ||||
|                 return admin; | ||||
|             }), | ||||
|         ); | ||||
| 
 | ||||
|         const providerResponses = await Promise.all( | ||||
|             allProviders.map(async (provider) => provider.searchLearningPathsByAdmin(admins, language, personalizedFor)), | ||||
|         ); | ||||
|         return providerResponses.flat(); | ||||
|     }, | ||||
|  |  | |||
							
								
								
									
										10
									
								
								common/src/util/match-mode.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								common/src/util/match-mode.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,10 @@ | |||
| export enum MatchMode { | ||||
|     /** | ||||
|      * Match any | ||||
|      */ | ||||
|     ANY = 'ANY', | ||||
|     /** | ||||
|      * Match all | ||||
|      */ | ||||
|     ALL = 'ALL', | ||||
| } | ||||
		Reference in a new issue