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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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