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; |         return; | ||||||
|     } else { |     } else { | ||||||
|         hruidList = themes.flatMap((theme) => theme.hruids); |         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); |     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 { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||||
| import { LearningPath } from '../../entities/content/learning-path.entity.js'; | import { LearningPath } from '../../entities/content/learning-path.entity.js'; | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | 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 { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||||
| import { RequiredEntityData } from '@mikro-orm/core'; | import { RequiredEntityData } from '@mikro-orm/core'; | ||||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||||
| import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; | import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.js'; | ||||||
|  | import { Teacher } from '../../entities/users/teacher.entity'; | ||||||
| 
 | 
 | ||||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||||
|     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { |     public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> { | ||||||
|         return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); |         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 |      * Returns all learning paths which have the given language and whose title OR description contains the | ||||||
|      * query string. |      * 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. |      * @param language The language of the learning paths we want to find. | ||||||
|      */ |      */ | ||||||
|     public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> { |     public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> { | ||||||
|  |  | ||||||
|  | @ -13,9 +13,11 @@ import { | ||||||
|     Transition, |     Transition, | ||||||
| } from '@dwengo-1/common/interfaces/learning-content'; | } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | 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 { Group } from '../../entities/assignments/group.entity'; | ||||||
| import { Collection } from '@mikro-orm/core'; | import { Collection } from '@mikro-orm/core'; | ||||||
| import { v4 } from 'uuid'; | 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 |  * 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, |                         version: node.version, | ||||||
|                         language: node.language, |                         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)) { |     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.'); |         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, |     node: LearningPathNode, | ||||||
|     learningObject: FilteredLearningObject, |     learningObject: FilteredLearningObject, | ||||||
|     personalizedFor: Group | undefined, |     personalizedFor: Group | undefined, | ||||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> |     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||||
| ): Promise<LearningObjectNode> { | ): Promise<LearningObjectNode> { | ||||||
|     const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; |     const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; | ||||||
|     const transitions = node.transitions |     const transitions = node.transitions | ||||||
|         .filter( |         .filter( | ||||||
|             (trans) => |             (trans) => | ||||||
|                 !personalizedFor || // If we do not want a personalized learning path, keep all transitions
 |                 !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)); |         .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)); | ||||||
|     return { |     return { | ||||||
|  | @ -125,10 +127,10 @@ async function convertNode( | ||||||
|  */ |  */ | ||||||
| async function convertNodes( | async function convertNodes( | ||||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, |     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||||
|     personalizedFor?: Group |     personalizedFor?: Group, | ||||||
| ): Promise<LearningObjectNode[]> { | ): Promise<LearningObjectNode[]> { | ||||||
|     const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) => |     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); |     return await Promise.all(nodesPromise); | ||||||
| } | } | ||||||
|  | @ -155,7 +157,7 @@ function optionalJsonStringToObject(jsonString?: string): object | null { | ||||||
| function convertTransition( | function convertTransition( | ||||||
|     transition: LearningPathTransition, |     transition: LearningPathTransition, | ||||||
|     index: number, |     index: number, | ||||||
|     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> |     nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>, | ||||||
| ): Transition { | ): Transition { | ||||||
|     const nextNode = nodesToLearningObjects.get(transition.next); |     const nextNode = nodesToLearningObjects.get(transition.next); | ||||||
|     if (!nextNode) { |     if (!nextNode) { | ||||||
|  | @ -185,10 +187,10 @@ const databaseLearningPathProvider: LearningPathProvider = { | ||||||
|         const learningPathRepo = getLearningPathRepository(); |         const learningPathRepo = getLearningPathRepository(); | ||||||
| 
 | 
 | ||||||
|         const learningPaths = (await Promise.all(hruids.map(async (hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( |         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( |         const filteredLearningPaths = await Promise.all( | ||||||
|             learningPaths.map(async (learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) |             learningPaths.map(async (learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)), | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         return { |         return { | ||||||
|  | @ -207,6 +209,13 @@ const databaseLearningPathProvider: LearningPathProvider = { | ||||||
|         const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); |         const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); | ||||||
|         return await Promise.all(searchResults.map(async (result, index) => convertLearningPath(result, index, personalizedFor))); |         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; | export default databaseLearningPathProvider; | ||||||
|  |  | ||||||
|  | @ -3,6 +3,8 @@ import { DWENGO_API_BASE } from '../../config.js'; | ||||||
| import { LearningPathProvider } from './learning-path-provider.js'; | import { LearningPathProvider } from './learning-path-provider.js'; | ||||||
| import { getLogger, Logger } from '../../logging/initalize.js'; | import { getLogger, Logger } from '../../logging/initalize.js'; | ||||||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | 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(); | const logger: Logger = getLogger(); | ||||||
| 
 | 
 | ||||||
|  | @ -38,6 +40,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | ||||||
|             data: learningPaths, |             data: learningPaths, | ||||||
|         }; |         }; | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|     async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { |     async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { | ||||||
|         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; |         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; | ||||||
|         const params = { all: query, language }; |         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 }); |         const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); | ||||||
|         return searchResults ?? []; |         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; | export default dwengoApiLearningPathProvider; | ||||||
|  |  | ||||||
|  | @ -1,6 +1,8 @@ | ||||||
| import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; | ||||||
| import { Language } from '@dwengo-1/common/util/language'; | 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 { 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. |  * 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. |      * Search learning paths in the data source using the given search string. | ||||||
|      */ |      */ | ||||||
|     searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>; |     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 { Language } from '@dwengo-1/common/util/language'; | ||||||
| import { Group } from '../../entities/assignments/group.entity.js'; | import { Group } from '../../entities/assignments/group.entity.js'; | ||||||
| import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.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 { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; | ||||||
| import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; | ||||||
| import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.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, |             startNode: nodeDto.start_node ?? false, | ||||||
|             createdAt: new Date(), |             createdAt: new Date(), | ||||||
|             updatedAt: new Date(), |             updatedAt: new Date(), | ||||||
|         }) |         }), | ||||||
|     ); |     ); | ||||||
|     dto.nodes.forEach((nodeDto) => { |     dto.nodes.forEach((nodeDto) => { | ||||||
|         const fromNode = nodes.find( |         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 |         const transitions = nodeDto.transitions | ||||||
|             .map((transDto, i) => { |             .map((transDto, i) => { | ||||||
|  | @ -49,7 +49,7 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L | ||||||
|                     (it) => |                     (it) => | ||||||
|                         it.learningObjectHruid === transDto.next.hruid && |                         it.learningObjectHruid === transDto.next.hruid && | ||||||
|                         it.language === transDto.next.language && |                         it.language === transDto.next.language && | ||||||
|                         it.version === transDto.next.version |                         it.version === transDto.next.version, | ||||||
|                 ); |                 ); | ||||||
| 
 | 
 | ||||||
|                 if (toNode) { |                 if (toNode) { | ||||||
|  | @ -93,7 +93,7 @@ const learningPathService = { | ||||||
|             nonUserContentHruids, |             nonUserContentHruids, | ||||||
|             language, |             language, | ||||||
|             source, |             source, | ||||||
|             personalizedFor |             personalizedFor, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); |         const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); | ||||||
|  | @ -110,7 +110,28 @@ const learningPathService = { | ||||||
|      */ |      */ | ||||||
|     async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> { |     async searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]> { | ||||||
|         const providerResponses = await Promise.all( |         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(); |         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