From 1639fbdabfbf7f2cb848909f53b2d1b9e78f39ca Mon Sep 17 00:00:00 2001 From: Tibo De Peuter Date: Sat, 17 May 2025 18:14:02 +0200 Subject: [PATCH] feat(backend): SearchByAdmins service --- backend/src/controllers/learning-paths.ts | 9 +++++ .../data/content/learning-path-repository.ts | 16 ++++++++- .../database-learning-path-provider.ts | 29 ++++++++++------ .../dwengo-api-learning-path-provider.ts | 12 +++++++ .../learning-paths/learning-path-provider.ts | 7 ++++ .../learning-paths/learning-path-service.ts | 33 +++++++++++++++---- common/src/util/match-mode.ts | 10 ++++++ 7 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 common/src/util/match-mode.ts diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 1bd3f2b1..7cd9e82c 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -50,6 +50,15 @@ export async function getLearningPaths(req: Request, res: Response): Promise 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); diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 67f08a03..546fe404 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -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 { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); } + public async findByAdmins(admins: Teacher[], language: Language, _matchMode?: MatchMode): Promise { + 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 { 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 fe05dda1..21c096e1 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -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): 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 + nodesToLearningObjects: Map, ): Promise { 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, - personalizedFor?: Group + personalizedFor?: Group, ): Promise { 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 + nodesToLearningObjects: Map, ): 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 { + 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; diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts index 110cd570..551feda4 100644 --- a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -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 { const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; const params = { all: query, language }; @@ -45,6 +48,15 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); return searchResults ?? []; }, + + async searchLearningPathsByAdmin(admins: Teacher[], language: string): Promise { + 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; diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts index 086777bd..72182db3 100644 --- a/backend/src/services/learning-paths/learning-path-provider.ts +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -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; + + /** + * Fetch the learning paths for the given admins from the data source. + */ + searchLearningPathsByAdmin(admins: Teacher[], language: Language, personalizedFor?: Group, matchMode?: MatchMode): Promise; } diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index b20d8f97..b0985c05 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -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 { 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 { + 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(); }, diff --git a/common/src/util/match-mode.ts b/common/src/util/match-mode.ts new file mode 100644 index 00000000..5b261f01 --- /dev/null +++ b/common/src/util/match-mode.ts @@ -0,0 +1,10 @@ +export enum MatchMode { + /** + * Match any + */ + ANY = 'ANY', + /** + * Match all + */ + ALL = 'ALL', +}