feat(backend): SearchByAdmins service

This commit is contained in:
Tibo De Peuter 2025-05-17 18:14:02 +02:00
parent 0d2b486a2c
commit 1639fbdabf
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
7 changed files with 99 additions and 17 deletions

View file

@ -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);

View file

@ -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[]> {

View file

@ -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;

View file

@ -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;

View file

@ -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[]>;
} }

View file

@ -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();
}, },

View file

@ -0,0 +1,10 @@
export enum MatchMode {
/**
* Match any
*/
ANY = 'ANY',
/**
* Match all
*/
ALL = 'ALL',
}