Merge branch 'dev' into chore/logging
This commit is contained in:
		
						commit
						f82668148c
					
				
					 122 changed files with 6026 additions and 14446 deletions
				
			
		|  | @ -1,14 +1,28 @@ | |||
| import express, { Express, Response } from 'express'; | ||||
| import initORM from './orm.js'; | ||||
| import { initORM } from './orm.js'; | ||||
| 
 | ||||
| import themeRoutes from './routes/themes.js'; | ||||
| import learningPathRoutes from './routes/learningPaths.js'; | ||||
| import learningObjectRoutes from './routes/learningObjects.js'; | ||||
| 
 | ||||
| import studentRouter from './routes/student.js'; | ||||
| import groupRouter from './routes/group.js'; | ||||
| import assignmentRouter from './routes/assignment.js'; | ||||
| import submissionRouter from './routes/submission.js'; | ||||
| import classRouter from './routes/class.js'; | ||||
| import questionRouter from './routes/question.js'; | ||||
| import loginRouter from './routes/login.js'; | ||||
| import { getLogger } from './logging/initalize.js'; | ||||
| import { responseTimeLogger } from './logging/responseTimeLogger.js'; | ||||
| import responseTime from 'response-time'; | ||||
| import { Logger } from 'winston'; | ||||
| import { EnvVars, getNumericEnvVar } from './util/envvars.js'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
| const app: Express = express(); | ||||
| const port: string | number = process.env.PORT || 3000; | ||||
| const port: string | number = getNumericEnvVar(EnvVars.Port); | ||||
| 
 | ||||
| 
 | ||||
| app.use(express.json()); | ||||
| app.use(responseTime(responseTimeLogger)); | ||||
|  | @ -17,10 +31,22 @@ app.use(responseTime(responseTimeLogger)); | |||
| app.get('/', (_, res: Response) => { | ||||
|     logger.debug('GET /'); | ||||
|     res.json({ | ||||
|         message: 'Hello Dwengo!', | ||||
|         message: 'Hello Dwengo!🚀', | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| app.use('/student', studentRouter); | ||||
| app.use('/group', groupRouter); | ||||
| app.use('/assignment', assignmentRouter); | ||||
| app.use('/submission', submissionRouter); | ||||
| app.use('/class', classRouter); | ||||
| app.use('/question', questionRouter); | ||||
| app.use('/login', loginRouter); | ||||
| 
 | ||||
| app.use('/theme', themeRoutes); | ||||
| app.use('/learningPath', learningPathRoutes); | ||||
| app.use('/learningObject', learningObjectRoutes); | ||||
| 
 | ||||
| async function startServer() { | ||||
|     await initORM(); | ||||
| 
 | ||||
|  | @ -29,4 +55,4 @@ async function startServer() { | |||
|     }); | ||||
| } | ||||
| 
 | ||||
| startServer(); | ||||
| await startServer(); | ||||
|  |  | |||
|  | @ -1,6 +1,12 @@ | |||
| export const FALLBACK_LANG: string = 'nl'; | ||||
| 
 | ||||
| // API
 | ||||
| 
 | ||||
| export const DWENGO_API_BASE: string = 'https://dwengo.org/backend/api'; | ||||
| 
 | ||||
| // Logging
 | ||||
| 
 | ||||
| export const LOG_LEVEL: string = | ||||
|     'development' === process.env.NODE_ENV ? 'debug' : 'info'; | ||||
|   'development' === process.env.NODE_ENV ? 'debug' : 'info'; | ||||
| export const LOKI_HOST: string = | ||||
|     process.env.LOKI_HOST || 'http://localhost:3102'; | ||||
|   process.env.LOKI_HOST || 'http://localhost:3102'; | ||||
|  |  | |||
							
								
								
									
										60
									
								
								backend/src/controllers/learningObjects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								backend/src/controllers/learningObjects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| import { Request, Response } from 'express'; | ||||
| import { | ||||
|     getLearningObjectById, | ||||
|     getLearningObjectIdsFromPath, | ||||
|     getLearningObjectsFromPath, | ||||
| } from '../services/learningObjects.js'; | ||||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| import { FilteredLearningObject } from '../interfaces/learningPath'; | ||||
| 
 | ||||
| export async function getAllLearningObjects( | ||||
|     req: Request, | ||||
|     res: Response | ||||
| ): Promise<void> { | ||||
|     try { | ||||
|         const hruid = req.query.hruid as string; | ||||
|         const full = req.query.full === 'true'; | ||||
|         const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|         if (!hruid) { | ||||
|             res.status(400).json({ error: 'HRUID query is required.' }); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let learningObjects: FilteredLearningObject[] | string[]; | ||||
|         if (full) { | ||||
|             learningObjects = await getLearningObjectsFromPath(hruid, language); | ||||
|         } else { | ||||
|             learningObjects = await getLearningObjectIdsFromPath( | ||||
|                 hruid, | ||||
|                 language | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         res.json(learningObjects); | ||||
|     } catch (error) { | ||||
|         console.error('Error fetching learning objects:', error); | ||||
|         res.status(500).json({ error: 'Internal server error' }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export async function getLearningObject( | ||||
|     req: Request, | ||||
|     res: Response | ||||
| ): Promise<void> { | ||||
|     try { | ||||
|         const { hruid } = req.params; | ||||
|         const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|         if (!hruid) { | ||||
|             res.status(400).json({ error: 'HRUID parameter is required.' }); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const learningObject = await getLearningObjectById(hruid, language); | ||||
|         res.json(learningObject); | ||||
|     } catch (error) { | ||||
|         console.error('Error fetching learning object:', error); | ||||
|         res.status(500).json({ error: 'Internal server error' }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										62
									
								
								backend/src/controllers/learningPaths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								backend/src/controllers/learningPaths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | |||
| import { Request, Response } from 'express'; | ||||
| import { themes } from '../data/themes.js'; | ||||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| import { | ||||
|     fetchLearningPaths, | ||||
|     searchLearningPaths, | ||||
| } from '../services/learningPaths.js'; | ||||
| /** | ||||
|  * Fetch learning paths based on query parameters. | ||||
|  */ | ||||
| export async function getLearningPaths( | ||||
|     req: Request, | ||||
|     res: Response | ||||
| ): Promise<void> { | ||||
|     try { | ||||
|         const hruids = req.query.hruid; | ||||
|         const themeKey = req.query.theme as string; | ||||
|         const searchQuery = req.query.search as string; | ||||
|         const language = (req.query.language as string) || FALLBACK_LANG; | ||||
| 
 | ||||
|         let hruidList; | ||||
| 
 | ||||
|         if (hruids) { | ||||
|             hruidList = Array.isArray(hruids) | ||||
|                 ? hruids.map(String) | ||||
|                 : [String(hruids)]; | ||||
|         } else if (themeKey) { | ||||
|             const theme = themes.find((t) => { | ||||
|                 return t.title === themeKey; | ||||
|             }); | ||||
|             if (theme) { | ||||
|                 hruidList = theme.hruids; | ||||
|             } else { | ||||
|                 res.status(404).json({ | ||||
|                     error: `Theme "${themeKey}" not found.`, | ||||
|                 }); | ||||
|                 return; | ||||
|             } | ||||
|         } else if (searchQuery) { | ||||
|             const searchResults = await searchLearningPaths( | ||||
|                 searchQuery, | ||||
|                 language | ||||
|             ); | ||||
|             res.json(searchResults); | ||||
|             return; | ||||
|         } else { | ||||
|             hruidList = themes.flatMap((theme) => { | ||||
|                 return theme.hruids; | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         const learningPaths = await fetchLearningPaths( | ||||
|             hruidList, | ||||
|             language, | ||||
|             `HRUIDs: ${hruidList.join(', ')}` | ||||
|         ); | ||||
|         res.json(learningPaths.data); | ||||
|     } catch (error) { | ||||
|         console.error('❌ Unexpected error fetching learning paths:', error); | ||||
|         res.status(500).json({ error: 'Internal server error' }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								backend/src/controllers/themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								backend/src/controllers/themes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| import fs from 'fs'; | ||||
| import path from 'path'; | ||||
| import yaml from 'js-yaml'; | ||||
| import { Request, Response } from 'express'; | ||||
| import { themes } from '../data/themes.js'; | ||||
| import { FALLBACK_LANG } from '../config.js'; | ||||
| 
 | ||||
| interface Translations { | ||||
|     curricula_page: { | ||||
|         [key: string]: { title: string; description?: string }; // Optioneel veld description
 | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| function loadTranslations(language: string): Translations { | ||||
|     try { | ||||
|         const filePath = path.join(process.cwd(), '_i18n', `${language}.yml`); | ||||
|         const yamlFile = fs.readFileSync(filePath, 'utf8'); | ||||
|         return yaml.load(yamlFile) as Translations; | ||||
|     } catch (error) { | ||||
|         console.error( | ||||
|             `Cannot load translation for: ${language}, fallen back to Dutch` | ||||
|         ); | ||||
|         console.error(error); | ||||
|         const fallbackPath = path.join(process.cwd(), '_i18n', 'nl.yml'); | ||||
|         return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as Translations; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export function getThemes(req: Request, res: Response) { | ||||
|     const language = | ||||
|         (req.query.language as string)?.toLowerCase() || FALLBACK_LANG; | ||||
|     const translations = loadTranslations(language); | ||||
| 
 | ||||
|     const themeList = themes.map((theme) => { | ||||
|         return { | ||||
|             key: theme.title, | ||||
|             title: | ||||
|                 translations.curricula_page[theme.title]?.title || theme.title, | ||||
|             description: translations.curricula_page[theme.title]?.description, | ||||
|             image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, | ||||
|         }; | ||||
|     }); | ||||
| 
 | ||||
|     res.json(themeList); | ||||
| } | ||||
| 
 | ||||
| export function getThemeByTitle(req: Request, res: Response) { | ||||
|     const themeKey = req.params.theme; | ||||
|     const theme = themes.find((t) => { | ||||
|         return t.title === themeKey; | ||||
|     }); | ||||
| 
 | ||||
|     if (theme) { | ||||
|         res.json(theme.hruids); | ||||
|     } else { | ||||
|         res.status(404).json({ error: 'Theme not found' }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										18
									
								
								backend/src/data/assignments/assignment-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								backend/src/data/assignments/assignment-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Assignment } from '../../entities/assignments/assignment.entity.js'; | ||||
| import { Class } from '../../entities/classes/class.entity.js'; | ||||
| 
 | ||||
| export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | ||||
|     public findByClassAndId( | ||||
|         within: Class, | ||||
|         id: number | ||||
|     ): Promise<Assignment | null> { | ||||
|         return this.findOne({ within: within, id: id }); | ||||
|     } | ||||
|     public findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { | ||||
|         return this.findAll({ where: { within: within } }); | ||||
|     } | ||||
|     public deleteByClassAndId(within: Class, id: number): Promise<void> { | ||||
|         return this.deleteWhere({ within: within, id: id }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								backend/src/data/assignments/group-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/src/data/assignments/group-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Group } from '../../entities/assignments/group.entity.js'; | ||||
| import { Assignment } from '../../entities/assignments/assignment.entity.js'; | ||||
| 
 | ||||
| export class GroupRepository extends DwengoEntityRepository<Group> { | ||||
|     public findByAssignmentAndGroupNumber( | ||||
|         assignment: Assignment, | ||||
|         groupNumber: number | ||||
|     ): Promise<Group | null> { | ||||
|         return this.findOne({ | ||||
|             assignment: assignment, | ||||
|             groupNumber: groupNumber, | ||||
|         }); | ||||
|     } | ||||
|     public findAllGroupsForAssignment( | ||||
|         assignment: Assignment | ||||
|     ): Promise<Group[]> { | ||||
|         return this.findAll({ where: { assignment: assignment } }); | ||||
|     } | ||||
|     public deleteByAssignmentAndGroupNumber( | ||||
|         assignment: Assignment, | ||||
|         groupNumber: number | ||||
|     ) { | ||||
|         return this.deleteWhere({ | ||||
|             assignment: assignment, | ||||
|             groupNumber: groupNumber, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										61
									
								
								backend/src/data/assignments/submission-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/data/assignments/submission-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Group } from '../../entities/assignments/group.entity.js'; | ||||
| import { Submission } from '../../entities/assignments/submission.entity.js'; | ||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| 
 | ||||
| export class SubmissionRepository extends DwengoEntityRepository<Submission> { | ||||
|     public findSubmissionByLearningObjectAndSubmissionNumber( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         submissionNumber: number | ||||
|     ): Promise<Submission | null> { | ||||
|         return this.findOne({ | ||||
|             learningObjectHruid: loId.hruid, | ||||
|             learningObjectLanguage: loId.language, | ||||
|             learningObjectVersion: loId.version, | ||||
|             submissionNumber: submissionNumber, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     public findMostRecentSubmissionForStudent( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         submitter: Student | ||||
|     ): Promise<Submission | null> { | ||||
|         return this.findOne( | ||||
|             { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|                 learningObjectLanguage: loId.language, | ||||
|                 learningObjectVersion: loId.version, | ||||
|                 submitter: submitter, | ||||
|             }, | ||||
|             { orderBy: { submissionNumber: 'DESC' } } | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public findMostRecentSubmissionForGroup( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         group: Group | ||||
|     ): Promise<Submission | null> { | ||||
|         return this.findOne( | ||||
|             { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|                 learningObjectLanguage: loId.language, | ||||
|                 learningObjectVersion: loId.version, | ||||
|                 onBehalfOf: group, | ||||
|             }, | ||||
|             { orderBy: { submissionNumber: 'DESC' } } | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public deleteSubmissionByLearningObjectAndSubmissionNumber( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         submissionNumber: number | ||||
|     ): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             learningObjectHruid: loId.hruid, | ||||
|             learningObjectLanguage: loId.language, | ||||
|             learningObjectVersion: loId.version, | ||||
|             submissionNumber: submissionNumber, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								backend/src/data/classes/class-join-request-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/src/data/classes/class-join-request-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Class } from '../../entities/classes/class.entity.js'; | ||||
| import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js'; | ||||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| 
 | ||||
| export class ClassJoinRequestRepository extends DwengoEntityRepository<ClassJoinRequest> { | ||||
|     public findAllRequestsBy(requester: Student): Promise<ClassJoinRequest[]> { | ||||
|         return this.findAll({ where: { requester: requester } }); | ||||
|     } | ||||
|     public findAllOpenRequestsTo(clazz: Class): Promise<ClassJoinRequest[]> { | ||||
|         return this.findAll({ where: { class: clazz } }); | ||||
|     } | ||||
|     public deleteBy(requester: Student, clazz: Class): Promise<void> { | ||||
|         return this.deleteWhere({ requester: requester, class: clazz }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								backend/src/data/classes/class-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/src/data/classes/class-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Class } from '../../entities/classes/class.entity.js'; | ||||
| 
 | ||||
| export class ClassRepository extends DwengoEntityRepository<Class> { | ||||
|     public findById(id: string): Promise<Class | null> { | ||||
|         return this.findOne({ classId: id }); | ||||
|     } | ||||
|     public deleteById(id: string): Promise<void> { | ||||
|         return this.deleteWhere({ classId: id }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								backend/src/data/classes/teacher-invitation-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								backend/src/data/classes/teacher-invitation-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Class } from '../../entities/classes/class.entity.js'; | ||||
| import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||
| 
 | ||||
| export class TeacherInvitationRepository extends DwengoEntityRepository<TeacherInvitation> { | ||||
|     public findAllInvitationsForClass( | ||||
|         clazz: Class | ||||
|     ): Promise<TeacherInvitation[]> { | ||||
|         return this.findAll({ where: { class: clazz } }); | ||||
|     } | ||||
|     public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> { | ||||
|         return this.findAll({ where: { sender: sender } }); | ||||
|     } | ||||
|     public findAllInvitationsFor( | ||||
|         receiver: Teacher | ||||
|     ): Promise<TeacherInvitation[]> { | ||||
|         return this.findAll({ where: { receiver: receiver } }); | ||||
|     } | ||||
|     public deleteBy( | ||||
|         clazz: Class, | ||||
|         sender: Teacher, | ||||
|         receiver: Teacher | ||||
|     ): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             sender: sender, | ||||
|             receiver: receiver, | ||||
|             class: clazz, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								backend/src/data/content/attachment-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/src/data/content/attachment-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Attachment } from '../../entities/content/attachment.entity.js'; | ||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||
| 
 | ||||
| export class AttachmentRepository extends DwengoEntityRepository<Attachment> { | ||||
|     public findByLearningObjectAndNumber( | ||||
|         learningObject: LearningObject, | ||||
|         sequenceNumber: number | ||||
|     ) { | ||||
|         return this.findOne({ | ||||
|             learningObject: learningObject, | ||||
|             sequenceNumber: sequenceNumber, | ||||
|         }); | ||||
|     } | ||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 | ||||
| } | ||||
							
								
								
									
										16
									
								
								backend/src/data/content/learning-object-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/src/data/content/learning-object-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { LearningObject } from '../../entities/content/learning-object.entity.js'; | ||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||
| 
 | ||||
| export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { | ||||
|     public findByIdentifier( | ||||
|         identifier: LearningObjectIdentifier | ||||
|     ): Promise<LearningObject | null> { | ||||
|         return this.findOne({ | ||||
|             hruid: identifier.hruid, | ||||
|             language: identifier.language, | ||||
|             version: identifier.version, | ||||
|         }); | ||||
|     } | ||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 | ||||
| } | ||||
							
								
								
									
										13
									
								
								backend/src/data/content/learning-path-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/src/data/content/learning-path-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { LearningPath } from '../../entities/content/learning-path.entity.js'; | ||||
| import { Language } from '../../entities/content/language.js'; | ||||
| 
 | ||||
| export class LearningPathRepository extends DwengoEntityRepository<LearningPath> { | ||||
|     public findByHruidAndLanguage( | ||||
|         hruid: string, | ||||
|         language: Language | ||||
|     ): Promise<LearningPath | null> { | ||||
|         return this.findOne({ hruid: hruid, language: language }); | ||||
|     } | ||||
|     // This repository is read-only for now since creating own learning object is an extension feature.
 | ||||
| } | ||||
							
								
								
									
										19
									
								
								backend/src/data/dwengo-entity-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								backend/src/data/dwengo-entity-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| import { EntityRepository, FilterQuery } from '@mikro-orm/core'; | ||||
| 
 | ||||
| export abstract class DwengoEntityRepository< | ||||
|     T extends object, | ||||
| > extends EntityRepository<T> { | ||||
|     public async save(entity: T) { | ||||
|         const em = this.getEntityManager(); | ||||
|         em.persist(entity); | ||||
|         await em.flush(); | ||||
|     } | ||||
|     public async deleteWhere(query: FilterQuery<T>) { | ||||
|         const toDelete = await this.findOne(query); | ||||
|         const em = this.getEntityManager(); | ||||
|         if (toDelete) { | ||||
|             em.remove(toDelete); | ||||
|             await em.flush(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										33
									
								
								backend/src/data/questions/answer-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								backend/src/data/questions/answer-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Answer } from '../../entities/questions/answer.entity.js'; | ||||
| import { Question } from '../../entities/questions/question.entity.js'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||
| 
 | ||||
| export class AnswerRepository extends DwengoEntityRepository<Answer> { | ||||
|     public createAnswer(answer: { | ||||
|         toQuestion: Question; | ||||
|         author: Teacher; | ||||
|         content: string; | ||||
|     }): Promise<Answer> { | ||||
|         const answerEntity = new Answer(); | ||||
|         answerEntity.toQuestion = answer.toQuestion; | ||||
|         answerEntity.author = answer.author; | ||||
|         answerEntity.content = answer.content; | ||||
|         return this.insert(answerEntity); | ||||
|     } | ||||
|     public findAllAnswersToQuestion(question: Question): Promise<Answer[]> { | ||||
|         return this.findAll({ | ||||
|             where: { toQuestion: question }, | ||||
|             orderBy: { sequenceNumber: 'ASC' }, | ||||
|         }); | ||||
|     } | ||||
|     public removeAnswerByQuestionAndSequenceNumber( | ||||
|         question: Question, | ||||
|         sequenceNumber: number | ||||
|     ): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             toQuestion: question, | ||||
|             sequenceNumber: sequenceNumber, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										45
									
								
								backend/src/data/questions/question-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								backend/src/data/questions/question-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Question } from '../../entities/questions/question.entity.js'; | ||||
| import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; | ||||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| 
 | ||||
| export class QuestionRepository extends DwengoEntityRepository<Question> { | ||||
|     public createQuestion(question: { | ||||
|         loId: LearningObjectIdentifier; | ||||
|         author: Student; | ||||
|         content: string; | ||||
|     }): Promise<Question> { | ||||
|         const questionEntity = new Question(); | ||||
|         questionEntity.learningObjectHruid = question.loId.hruid; | ||||
|         questionEntity.learningObjectLanguage = question.loId.language; | ||||
|         questionEntity.learningObjectVersion = question.loId.version; | ||||
|         questionEntity.author = question.author; | ||||
|         questionEntity.content = question.content; | ||||
|         return this.insert(questionEntity); | ||||
|     } | ||||
|     public findAllQuestionsAboutLearningObject( | ||||
|         loId: LearningObjectIdentifier | ||||
|     ): Promise<Question[]> { | ||||
|         return this.findAll({ | ||||
|             where: { | ||||
|                 learningObjectHruid: loId.hruid, | ||||
|                 learningObjectLanguage: loId.language, | ||||
|                 learningObjectVersion: loId.version, | ||||
|             }, | ||||
|             orderBy: { | ||||
|                 sequenceNumber: 'ASC', | ||||
|             }, | ||||
|         }); | ||||
|     } | ||||
|     public removeQuestionByLearningObjectAndSequenceNumber( | ||||
|         loId: LearningObjectIdentifier, | ||||
|         sequenceNumber: number | ||||
|     ): Promise<void> { | ||||
|         return this.deleteWhere({ | ||||
|             learningObjectHruid: loId.hruid, | ||||
|             learningObjectLanguage: loId.language, | ||||
|             learningObjectVersion: loId.version, | ||||
|             sequenceNumber: sequenceNumber, | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										119
									
								
								backend/src/data/repositories.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								backend/src/data/repositories.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,119 @@ | |||
| import { | ||||
|     AnyEntity, | ||||
|     EntityManager, | ||||
|     EntityName, | ||||
|     EntityRepository, | ||||
| } from '@mikro-orm/core'; | ||||
| import { forkEntityManager } from '../orm.js'; | ||||
| import { StudentRepository } from './users/student-repository.js'; | ||||
| import { Student } from '../entities/users/student.entity.js'; | ||||
| import { User } from '../entities/users/user.entity.js'; | ||||
| import { UserRepository } from './users/user-repository.js'; | ||||
| import { Teacher } from '../entities/users/teacher.entity.js'; | ||||
| import { TeacherRepository } from './users/teacher-repository.js'; | ||||
| import { Class } from '../entities/classes/class.entity.js'; | ||||
| import { ClassRepository } from './classes/class-repository.js'; | ||||
| import { ClassJoinRequest } from '../entities/classes/class-join-request.entity.js'; | ||||
| import { ClassJoinRequestRepository } from './classes/class-join-request-repository.js'; | ||||
| import { TeacherInvitationRepository } from './classes/teacher-invitation-repository.js'; | ||||
| import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; | ||||
| import { Assignment } from '../entities/assignments/assignment.entity.js'; | ||||
| import { AssignmentRepository } from './assignments/assignment-repository.js'; | ||||
| import { GroupRepository } from './assignments/group-repository.js'; | ||||
| import { Group } from '../entities/assignments/group.entity.js'; | ||||
| import { Submission } from '../entities/assignments/submission.entity.js'; | ||||
| import { SubmissionRepository } from './assignments/submission-repository.js'; | ||||
| import { Question } from '../entities/questions/question.entity.js'; | ||||
| import { QuestionRepository } from './questions/question-repository.js'; | ||||
| import { Answer } from '../entities/questions/answer.entity.js'; | ||||
| import { AnswerRepository } from './questions/answer-repository.js'; | ||||
| import { LearningObject } from '../entities/content/learning-object.entity.js'; | ||||
| import { LearningObjectRepository } from './content/learning-object-repository.js'; | ||||
| import { LearningPath } from '../entities/content/learning-path.entity.js'; | ||||
| import { LearningPathRepository } from './content/learning-path-repository.js'; | ||||
| import { AttachmentRepository } from './content/attachment-repository.js'; | ||||
| import { Attachment } from '../entities/content/attachment.entity.js'; | ||||
| 
 | ||||
| let entityManager: EntityManager | undefined; | ||||
| 
 | ||||
| /** | ||||
|  * Execute all the database operations within the function f in a single transaction. | ||||
|  */ | ||||
| export function transactional<T>(f: () => Promise<T>) { | ||||
|     entityManager?.transactional(f); | ||||
| } | ||||
| 
 | ||||
| function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>( | ||||
|     entity: EntityName<T> | ||||
| ): () => R { | ||||
|     let cachedRepo: R | undefined; | ||||
|     return (): R => { | ||||
|         if (!cachedRepo) { | ||||
|             if (!entityManager) { | ||||
|                 entityManager = forkEntityManager(); | ||||
|             } | ||||
|             cachedRepo = entityManager.getRepository(entity) as R; | ||||
|         } | ||||
|         return cachedRepo; | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| /* Users */ | ||||
| export const getUserRepository = repositoryGetter<User, UserRepository>(User); | ||||
| export const getStudentRepository = repositoryGetter< | ||||
|     Student, | ||||
|     StudentRepository | ||||
| >(Student); | ||||
| export const getTeacherRepository = repositoryGetter< | ||||
|     Teacher, | ||||
|     TeacherRepository | ||||
| >(Teacher); | ||||
| 
 | ||||
| /* Classes */ | ||||
| export const getClassRepository = repositoryGetter<Class, ClassRepository>( | ||||
|     Class | ||||
| ); | ||||
| export const getClassJoinRequestRepository = repositoryGetter< | ||||
|     ClassJoinRequest, | ||||
|     ClassJoinRequestRepository | ||||
| >(ClassJoinRequest); | ||||
| export const getTeacherInvitationRepository = repositoryGetter< | ||||
|     TeacherInvitation, | ||||
|     TeacherInvitationRepository | ||||
| >(TeacherInvitationRepository); | ||||
| 
 | ||||
| /* Assignments */ | ||||
| export const getAssignmentRepository = repositoryGetter< | ||||
|     Assignment, | ||||
|     AssignmentRepository | ||||
| >(Assignment); | ||||
| export const getGroupRepository = repositoryGetter<Group, GroupRepository>( | ||||
|     Group | ||||
| ); | ||||
| export const getSubmissionRepository = repositoryGetter< | ||||
|     Submission, | ||||
|     SubmissionRepository | ||||
| >(Submission); | ||||
| 
 | ||||
| /* Questions and answers */ | ||||
| export const getQuestionRepository = repositoryGetter< | ||||
|     Question, | ||||
|     QuestionRepository | ||||
| >(Question); | ||||
| export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>( | ||||
|     Answer | ||||
| ); | ||||
| 
 | ||||
| /* Learning content */ | ||||
| export const getLearningObjectRepository = repositoryGetter< | ||||
|     LearningObject, | ||||
|     LearningObjectRepository | ||||
| >(LearningObject); | ||||
| export const getLearningPathRepository = repositoryGetter< | ||||
|     LearningPath, | ||||
|     LearningPathRepository | ||||
| >(LearningPath); | ||||
| export const getAttachmentRepository = repositoryGetter< | ||||
|     Attachment, | ||||
|     AttachmentRepository | ||||
| >(Assignment); | ||||
							
								
								
									
										196
									
								
								backend/src/data/themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								backend/src/data/themes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,196 @@ | |||
| export interface Theme { | ||||
|     title: string; | ||||
|     hruids: string[]; | ||||
| } | ||||
| 
 | ||||
| export const themes: Theme[] = [ | ||||
|     { | ||||
|         title: 'kiks', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'pn_klimaatverandering', | ||||
|             'kiks1_microscopie', | ||||
|             'kiks2_practicum', | ||||
|             'pn_digitalebeelden', | ||||
|             'kiks3_dl_basis', | ||||
|             'kiks4_dl_gevorderd', | ||||
|             'kiks5_classificatie', | ||||
|             'kiks6_regressie', | ||||
|             'kiks7_ethiek', | ||||
|             'kiks8_eindtermen', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'art', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'art1', | ||||
|             'art2', | ||||
|             'art3', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'socialrobot', | ||||
|         hruids: ['sr0_lkr', 'sr0_lln', 'sr1', 'sr2', 'sr3', 'sr4'], | ||||
|     }, | ||||
|     { | ||||
|         title: 'agriculture', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'agri_landbouw', | ||||
|             'agri_lopendeband', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'wegostem', | ||||
|         hruids: ['wegostem'], | ||||
|     }, | ||||
|     { | ||||
|         title: 'computational_thinking', | ||||
|         hruids: [ | ||||
|             'ct1_concepten', | ||||
|             'ct2_concreet', | ||||
|             'ct3_voorbeelden', | ||||
|             'ct6_cases', | ||||
|             'ct9_impact', | ||||
|             'ct10_bebras', | ||||
|             'ct8_eindtermen', | ||||
|             'ct7_historiek', | ||||
|             'ct5_kijkwijzer', | ||||
|             'ct4_evaluatiekader', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'math_with_python', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'maths_pythagoras', | ||||
|             'maths_spreidingsdiagrammen', | ||||
|             'maths_rechten', | ||||
|             'maths_lineaireregressie', | ||||
|             'maths_epidemie', | ||||
|             'pn_digitalebeelden', | ||||
|             'maths_logica', | ||||
|             'maths_parameters', | ||||
|             'maths_parabolen', | ||||
|             'pn_regressie', | ||||
|             'maths7_grafen', | ||||
|             'maths8_statistiek', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'python_programming', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'pn_datatypes', | ||||
|             'pn_operatoren', | ||||
|             'pn_structuren', | ||||
|             'pn_functies', | ||||
|             'art2', | ||||
|             'stem_insectbooks', | ||||
|             'un_algoenprog', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'stem', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'maths_spreidingsdiagrammen', | ||||
|             'pn_digitalebeelden', | ||||
|             'maths_epidemie', | ||||
|             'stem_ipadres', | ||||
|             'pn_klimaatverandering', | ||||
|             'stem_rechten', | ||||
|             'stem_lineaireregressie', | ||||
|             'stem_insectbooks', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'care', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'aiz1_zorg', | ||||
|             'aiz2_grafen', | ||||
|             'aiz3_unplugged', | ||||
|             'aiz4_eindtermen', | ||||
|             'aiz5_triage', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'chatbot', | ||||
|         hruids: [ | ||||
|             'pn_werking', | ||||
|             'un_artificiele_intelligentie', | ||||
|             'cb5_chatbotunplugged', | ||||
|             'cb1_chatbot', | ||||
|             'cb2_sentimentanalyse', | ||||
|             'cb3_vervoegmachine', | ||||
|             'cb4_eindtermen', | ||||
|             'cb6', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'physical_computing', | ||||
|         hruids: [ | ||||
|             'pc_starttodwenguino', | ||||
|             'pc_rijdenderobot', | ||||
|             'pc_theremin', | ||||
|             'pc_leerlijn_introductie', | ||||
|             'pc_leerlijn_invoer_verwerking_uitvoer', | ||||
|             'pc_leerlijn_basisprincipes_digitale_elektronica', | ||||
|             'pc_leerlijn_grafisch_naar_tekstueel', | ||||
|             'pc_leerlijn_basis_programmeren', | ||||
|             'pc_leerlijn_van_µc_naar_plc', | ||||
|             'pc_leerlijn_fiches_dwenguino', | ||||
|             'pc_leerlijn_seriele_monitor', | ||||
|             'pc_leerlijn_bus_protocollen', | ||||
|             'pc_leerlijn_wifi', | ||||
|             'pc_leerlijn_fiches_arduino', | ||||
|             'pc_leerlijn_project_lijnvolger', | ||||
|             'pc_leerlijn_project_bluetooth', | ||||
|             'pc_leerlijn_hddclock', | ||||
|             'pc_leerlijn_fysica_valbeweging', | ||||
|             'pc_leerlijn_luchtkwaliteit', | ||||
|             'pc_leerlijn_weerstation', | ||||
|             'pc_leerlijn_g0', | ||||
|             'pc_leerlijn_g1', | ||||
|             'pc_leerlijn_g3', | ||||
|             'pc_leerlijn_g4', | ||||
|             'pc_leerlijn_g5', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'algorithms', | ||||
|         hruids: [ | ||||
|             'art2', | ||||
|             'anm1', | ||||
|             'anm2', | ||||
|             'anm3', | ||||
|             'anm4', | ||||
|             'anm11', | ||||
|             'anm12', | ||||
|             'anm13', | ||||
|             'anm14', | ||||
|             'anm15', | ||||
|             'anm16', | ||||
|             'anm17', | ||||
|             'maths_epidemie', | ||||
|             'stem_insectbooks', | ||||
|         ], | ||||
|     }, | ||||
|     { | ||||
|         title: 'basics_ai', | ||||
|         hruids: [ | ||||
|             'un_artificiele_intelligentie', | ||||
|             'org-dwengo-waisda-taal-murder-mistery', | ||||
|             'art1', | ||||
|             'org-dwengo-waisda-beelden-emoties-herkennen', | ||||
|             'org-dwengo-waisda-beelden-unplugged-fax-lp', | ||||
|             'org-dwengo-waisda-beelden-teachable-machine', | ||||
|         ], | ||||
|     }, | ||||
| ]; | ||||
							
								
								
									
										11
									
								
								backend/src/data/users/student-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/src/data/users/student-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Student } from '../../entities/users/student.entity.js'; | ||||
| 
 | ||||
| export class StudentRepository extends DwengoEntityRepository<Student> { | ||||
|     public findByUsername(username: string): Promise<Student | null> { | ||||
|         return this.findOne({ username: username }); | ||||
|     } | ||||
|     public deleteByUsername(username: string): Promise<void> { | ||||
|         return this.deleteWhere({ username: username }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								backend/src/data/users/teacher-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/src/data/users/teacher-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { Teacher } from '../../entities/users/teacher.entity.js'; | ||||
| 
 | ||||
| export class TeacherRepository extends DwengoEntityRepository<Teacher> { | ||||
|     public findByUsername(username: string): Promise<Teacher | null> { | ||||
|         return this.findOne({ username: username }); | ||||
|     } | ||||
|     public deleteByUsername(username: string): Promise<void> { | ||||
|         return this.deleteWhere({ username: username }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								backend/src/data/users/user-repository.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/src/data/users/user-repository.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; | ||||
| import { User } from '../../entities/users/user.entity.js'; | ||||
| 
 | ||||
| export class UserRepository extends DwengoEntityRepository<User> { | ||||
|     public findByUsername(username: string): Promise<User | null> { | ||||
|         return this.findOne({ username: username }); | ||||
|     } | ||||
|     public deleteByUsername(username: string): Promise<void> { | ||||
|         return this.deleteWhere({ username: username }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								backend/src/entities/assignments/assignment.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/src/entities/assignments/assignment.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import { | ||||
|     Entity, | ||||
|     Enum, | ||||
|     ManyToOne, | ||||
|     OneToMany, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { Class } from '../classes/class.entity.js'; | ||||
| import { Group } from './group.entity.js'; | ||||
| import { Language } from '../content/language.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Assignment { | ||||
|     @ManyToOne({ entity: () => {return Class}, primary: true }) | ||||
|     within!: Class; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'number' }) | ||||
|     id!: number; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     description!: string; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     learningPathHruid!: string; | ||||
| 
 | ||||
|     @Enum({ items: () => {return Language} }) | ||||
|     learningPathLanguage!: Language; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => {return Group}, mappedBy: 'assignment' }) | ||||
|     groups!: Group[]; | ||||
| } | ||||
							
								
								
									
										15
									
								
								backend/src/entities/assignments/group.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/src/entities/assignments/group.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; | ||||
| import { Assignment } from './assignment.entity.js'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Group { | ||||
|     @ManyToOne({ entity: () => {return Assignment}, primary: true }) | ||||
|     assignment!: Assignment; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     groupNumber!: number; | ||||
| 
 | ||||
|     @ManyToMany({ entity: () => {return Student} }) | ||||
|     members!: Student[]; | ||||
| } | ||||
							
								
								
									
										31
									
								
								backend/src/entities/assignments/submission.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								backend/src/entities/assignments/submission.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | |||
| import { Student } from '../users/student.entity.js'; | ||||
| import { Group } from './group.entity.js'; | ||||
| import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Language } from '../content/language.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Submission { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|     @Enum({ items: () => {return Language}, primary: true }) | ||||
|     learningObjectLanguage!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectVersion: string = '1'; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     submissionNumber!: number; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Student} }) | ||||
|     submitter!: Student; | ||||
| 
 | ||||
|     @Property({ type: 'datetime' }) | ||||
|     submissionTime!: Date; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Group}, nullable: true }) | ||||
|     onBehalfOf?: Group; | ||||
| 
 | ||||
|     @Property({ type: 'json' }) | ||||
|     content!: string; | ||||
| } | ||||
							
								
								
									
										21
									
								
								backend/src/entities/classes/class-join-request.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								backend/src/entities/classes/class-join-request.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| import { Class } from './class.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class ClassJoinRequest { | ||||
|     @ManyToOne({ entity: () => {return Student}, primary: true }) | ||||
|     requester!: Student; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Class}, primary: true }) | ||||
|     class!: Class; | ||||
| 
 | ||||
|     @Enum(() => {return ClassJoinRequestStatus}) | ||||
|     status!: ClassJoinRequestStatus; | ||||
| } | ||||
| 
 | ||||
| export enum ClassJoinRequestStatus { | ||||
|     Open = 'open', | ||||
|     Accepted = 'accepted', | ||||
|     Declined = 'declined', | ||||
| } | ||||
							
								
								
									
										25
									
								
								backend/src/entities/classes/class.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								backend/src/entities/classes/class.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import { | ||||
|     Collection, | ||||
|     Entity, | ||||
|     ManyToMany, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { v4 } from 'uuid'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Class { | ||||
|     @PrimaryKey() | ||||
|     classId = v4(); | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     displayName!: string; | ||||
| 
 | ||||
|     @ManyToMany(() => {return Teacher}) | ||||
|     teachers!: Collection<Teacher>; | ||||
| 
 | ||||
|     @ManyToMany(() => {return Student}) | ||||
|     students!: Collection<Student>; | ||||
| } | ||||
							
								
								
									
										18
									
								
								backend/src/entities/classes/teacher-invitation.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								backend/src/entities/classes/teacher-invitation.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| import { Entity, ManyToOne } from '@mikro-orm/core'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import { Class } from './class.entity.js'; | ||||
| 
 | ||||
| /** | ||||
|  * Invitation of a teacher into a class (in order to teach it). | ||||
|  */ | ||||
| @Entity() | ||||
| export class TeacherInvitation { | ||||
|     @ManyToOne({ entity: () => {return Teacher}, primary: true }) | ||||
|     sender!: Teacher; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Teacher}, primary: true }) | ||||
|     receiver!: Teacher; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Class}, primary: true }) | ||||
|     class!: Class; | ||||
| } | ||||
							
								
								
									
										17
									
								
								backend/src/entities/content/attachment.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/src/entities/content/attachment.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { LearningObject } from './learning-object.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Attachment { | ||||
|     @ManyToOne({ entity: () => {return LearningObject}, primary: true }) | ||||
|     learningObject!: LearningObject; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     sequenceNumber!: number; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     mimeType!: string; | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     content!: Buffer; | ||||
| } | ||||
							
								
								
									
										6
									
								
								backend/src/entities/content/language.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								backend/src/entities/content/language.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| export enum Language { | ||||
|     Dutch = 'nl', | ||||
|     French = 'fr', | ||||
|     English = 'en', | ||||
|     Germany = 'de', | ||||
| } | ||||
|  | @ -0,0 +1,9 @@ | |||
| import { Language } from './language.js'; | ||||
| 
 | ||||
| export class LearningObjectIdentifier { | ||||
|     constructor( | ||||
|         public hruid: string, | ||||
|         public language: Language, | ||||
|         public version: string | ||||
|     ) {} | ||||
| } | ||||
							
								
								
									
										106
									
								
								backend/src/entities/content/learning-object.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								backend/src/entities/content/learning-object.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,106 @@ | |||
| import { | ||||
|     Embeddable, | ||||
|     Embedded, | ||||
|     Entity, | ||||
|     Enum, | ||||
|     ManyToMany, | ||||
|     OneToMany, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { Language } from './language.js'; | ||||
| import { Attachment } from './attachment.entity.js'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class LearningObject { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     hruid!: string; | ||||
| 
 | ||||
|     @Enum({ items: () => {return Language}, primary: true }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     version: string = '1'; | ||||
| 
 | ||||
|     @ManyToMany({ entity: () => {return Teacher} }) | ||||
|     admins!: Teacher[]; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     description!: string; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     contentType!: string; | ||||
| 
 | ||||
|     @Property({ type: 'array' }) | ||||
|     keywords: string[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'array', nullable: true }) | ||||
|     targetAges?: number[]; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     teacherExclusive: boolean = false; | ||||
| 
 | ||||
|     @Property({ type: 'array' }) | ||||
|     skosConcepts!: string[]; | ||||
| 
 | ||||
|     @Embedded({ entity: () => {return EducationalGoal}, array: true }) | ||||
|     educationalGoals: EducationalGoal[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     copyright: string = ''; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     license: string = ''; | ||||
| 
 | ||||
|     @Property({ type: 'smallint', nullable: true }) | ||||
|     difficulty?: number; | ||||
| 
 | ||||
|     @Property({ type: 'integer' }) | ||||
|     estimatedTime!: number; | ||||
| 
 | ||||
|     @Embedded({ entity: () => {return ReturnValue} }) | ||||
|     returnValue!: ReturnValue; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     available: boolean = true; | ||||
| 
 | ||||
|     @Property({ type: 'string', nullable: true }) | ||||
|     contentLocation?: string; | ||||
| 
 | ||||
|     @OneToMany({ entity: () => {return Attachment}, mappedBy: 'learningObject' }) | ||||
|     attachments: Attachment[] = []; | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     content!: Buffer; | ||||
| } | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class EducationalGoal { | ||||
|     @Property({ type: 'string' }) | ||||
|     source!: string; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     id!: string; | ||||
| } | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class ReturnValue { | ||||
|     @Property({ type: 'string' }) | ||||
|     callbackUrl!: string; | ||||
| 
 | ||||
|     @Property({ type: 'json' }) | ||||
|     callbackSchema!: string; | ||||
| } | ||||
| 
 | ||||
| export enum ContentType { | ||||
|     Markdown = 'text/markdown', | ||||
|     Image = 'image/image', | ||||
|     Mpeg = 'audio/mpeg', | ||||
|     Pdf = 'application/pdf', | ||||
|     Extern = 'extern', | ||||
|     Blockly = 'Blockly', | ||||
| } | ||||
							
								
								
									
										66
									
								
								backend/src/entities/content/learning-path.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								backend/src/entities/content/learning-path.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | |||
| import { | ||||
|     Embeddable, | ||||
|     Embedded, | ||||
|     Entity, | ||||
|     Enum, | ||||
|     ManyToMany, | ||||
|     OneToOne, | ||||
|     PrimaryKey, | ||||
|     Property, | ||||
| } from '@mikro-orm/core'; | ||||
| import { Language } from './language.js'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class LearningPath { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     hruid!: string; | ||||
| 
 | ||||
|     @Enum({ items: () => {return Language}, primary: true }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @ManyToMany({ entity: () => {return Teacher} }) | ||||
|     admins!: Teacher[]; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     title!: string; | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     description!: string; | ||||
| 
 | ||||
|     @Property({ type: 'blob' }) | ||||
|     image!: string; | ||||
| 
 | ||||
|     @Embedded({ entity: () => {return LearningPathNode}, array: true }) | ||||
|     nodes: LearningPathNode[] = []; | ||||
| } | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class LearningPathNode { | ||||
|     @Property({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|     @Enum({ items: () => {return Language} }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     version!: string; | ||||
| 
 | ||||
|     @Property({ type: 'longtext' }) | ||||
|     instruction!: string; | ||||
| 
 | ||||
|     @Property({ type: 'bool' }) | ||||
|     startNode!: boolean; | ||||
| 
 | ||||
|     @Embedded({ entity: () => {return LearningPathTransition}, array: true }) | ||||
|     transitions!: LearningPathTransition[]; | ||||
| } | ||||
| 
 | ||||
| @Embeddable() | ||||
| export class LearningPathTransition { | ||||
|     @Property({ type: 'string' }) | ||||
|     condition!: string; | ||||
| 
 | ||||
|     @OneToOne({ entity: () => {return LearningPathNode} }) | ||||
|     next!: LearningPathNode; | ||||
| } | ||||
							
								
								
									
										21
									
								
								backend/src/entities/questions/answer.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								backend/src/entities/questions/answer.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Question } from './question.entity'; | ||||
| import { Teacher } from '../users/teacher.entity'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Answer { | ||||
|     @ManyToOne({ entity: () => {return Teacher}, primary: true }) | ||||
|     author!: Teacher; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Question}, primary: true }) | ||||
|     toQuestion!: Question; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     sequenceNumber!: number; | ||||
| 
 | ||||
|     @Property({ type: 'datetime' }) | ||||
|     timestamp: Date = new Date(); | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     content!: string; | ||||
| } | ||||
							
								
								
									
										27
									
								
								backend/src/entities/questions/question.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/src/entities/questions/question.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| import { Language } from '../content/language.js'; | ||||
| import { Student } from '../users/student.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Question { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectHruid!: string; | ||||
| 
 | ||||
|     @Enum({ items: () => {return Language}, primary: true }) | ||||
|     learningObjectLanguage!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     learningObjectVersion: string = '1'; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'integer' }) | ||||
|     sequenceNumber!: number; | ||||
| 
 | ||||
|     @ManyToOne({ entity: () => {return Student} }) | ||||
|     author!: Student; | ||||
| 
 | ||||
|     @Property({ type: 'datetime' }) | ||||
|     timestamp: Date = new Date(); | ||||
| 
 | ||||
|     @Property({ type: 'text' }) | ||||
|     content!: string; | ||||
| } | ||||
							
								
								
									
										22
									
								
								backend/src/entities/users/student.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								backend/src/entities/users/student.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| import { User } from './user.entity.js'; | ||||
| import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; | ||||
| import { Class } from '../classes/class.entity.js'; | ||||
| import { Group } from '../assignments/group.entity.js'; | ||||
| import { StudentRepository } from '../../data/users/student-repository.js'; | ||||
| 
 | ||||
| @Entity({ repository: () => {return StudentRepository} }) | ||||
| export class Student extends User { | ||||
|     @ManyToMany(() => {return Class}) | ||||
|     classes!: Collection<Class>; | ||||
| 
 | ||||
|     @ManyToMany(() => {return Group}) | ||||
|     groups!: Collection<Group>; | ||||
| 
 | ||||
|     constructor( | ||||
|         public username: string, | ||||
|         public firstName: string, | ||||
|         public lastName: string | ||||
|     ) { | ||||
|         super(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								backend/src/entities/users/teacher.entity.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								backend/src/entities/users/teacher.entity.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | |||
| import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; | ||||
| import { User } from './user.entity.js'; | ||||
| import { Class } from '../classes/class.entity.js'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class Teacher extends User { | ||||
|     @ManyToMany(() => {return Class}) | ||||
|     classes!: Collection<Class>; | ||||
| } | ||||
|  | @ -1,9 +1,9 @@ | |||
| import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; | ||||
| 
 | ||||
| @Entity() | ||||
| export class User { | ||||
|     @PrimaryKey({ type: 'number' }) | ||||
|     id!: number; | ||||
| @Entity({ abstract: true }) | ||||
| export abstract class User { | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     username!: string; | ||||
| 
 | ||||
|     @Property() | ||||
|     firstName: string = ''; | ||||
							
								
								
									
										98
									
								
								backend/src/interfaces/learningPath.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								backend/src/interfaces/learningPath.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,98 @@ | |||
| export interface Transition { | ||||
|     default: boolean; | ||||
|     _id: string; | ||||
|     next: { | ||||
|         _id: string; | ||||
|         hruid: string; | ||||
|         version: number; | ||||
|         language: string; | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export interface LearningObjectNode { | ||||
|     _id: string; | ||||
|     learningobject_hruid: string; | ||||
|     version: number; | ||||
|     language: string; | ||||
|     start_node?: boolean; | ||||
|     transitions: Transition[]; | ||||
|     created_at: string; | ||||
|     updatedAt: string; | ||||
| } | ||||
| 
 | ||||
| export interface LearningPath { | ||||
|     _id: string; | ||||
|     language: string; | ||||
|     hruid: string; | ||||
|     title: string; | ||||
|     description: string; | ||||
|     image?: string; // Image might be missing, so it's optional
 | ||||
|     num_nodes: number; | ||||
|     num_nodes_left: number; | ||||
|     nodes: LearningObjectNode[]; | ||||
|     keywords: string; | ||||
|     target_ages: number[]; | ||||
|     min_age: number; | ||||
|     max_age: number; | ||||
|     __order: number; | ||||
| } | ||||
| 
 | ||||
| export interface EducationalGoal { | ||||
|     source: string; | ||||
|     id: string; | ||||
| } | ||||
| 
 | ||||
| export interface ReturnValue { | ||||
|     callback_url: string; | ||||
|     callback_schema: Record<string, any>; | ||||
| } | ||||
| 
 | ||||
| export interface LearningObjectMetadata { | ||||
|     _id: string; | ||||
|     uuid: string; | ||||
|     hruid: string; | ||||
|     version: number; | ||||
|     language: string; | ||||
|     title: string; | ||||
|     description: string; | ||||
|     difficulty: number; | ||||
|     estimated_time: number; | ||||
|     available: boolean; | ||||
|     teacher_exclusive: boolean; | ||||
|     educational_goals: EducationalGoal[]; | ||||
|     keywords: string[]; | ||||
|     target_ages: number[]; | ||||
|     content_type: string; // Markdown, image, etc.
 | ||||
|     content_location?: string; | ||||
|     skos_concepts?: string[]; | ||||
|     return_value?: ReturnValue; | ||||
| } | ||||
| 
 | ||||
| export interface FilteredLearningObject { | ||||
|     key: string; | ||||
|     _id: string; | ||||
|     uuid: string; | ||||
|     version: number; | ||||
|     title: string; | ||||
|     htmlUrl: string; | ||||
|     language: string; | ||||
|     difficulty: number; | ||||
|     estimatedTime: number; | ||||
|     available: boolean; | ||||
|     teacherExclusive: boolean; | ||||
|     educationalGoals: EducationalGoal[]; | ||||
|     keywords: string[]; | ||||
|     description: string; | ||||
|     targetAges: number[]; | ||||
|     contentType: string; | ||||
|     contentLocation?: string; | ||||
|     skosConcepts?: string[]; | ||||
|     returnValue?: ReturnValue; | ||||
| } | ||||
| 
 | ||||
| export interface LearningPathResponse { | ||||
|     success: boolean; | ||||
|     source: string; | ||||
|     data: LearningPath[] | null; | ||||
|     message?: string; | ||||
| } | ||||
|  | @ -1,20 +1,45 @@ | |||
| import { LoggerOptions, Options } from '@mikro-orm/core'; | ||||
| import { PostgreSqlDriver } from '@mikro-orm/postgresql'; | ||||
| import { EnvVars, getEnvVar, getNumericEnvVar } from './util/envvars.js'; | ||||
| import { SqliteDriver } from '@mikro-orm/sqlite'; | ||||
| import { MikroOrmLogger } from './logging/mikroOrmLogger.js'; | ||||
| import { LOG_LEVEL } from './config.js'; | ||||
| 
 | ||||
| const config: Options = { | ||||
|     driver: PostgreSqlDriver, | ||||
|     dbName: 'dwengo', | ||||
|     password: 'postgres', | ||||
|     entities: ['dist/**/*.entity.js'], | ||||
|     entitiesTs: ['src/**/*.entity.ts'], | ||||
| const entities = ['dist/**/*.entity.js']; | ||||
| const entitiesTs = ['src/**/*.entity.ts']; | ||||
| 
 | ||||
|     // Logging
 | ||||
|     debug: LOG_LEVEL === 'debug', | ||||
|     loggerFactory: (options: LoggerOptions) => { | ||||
|         return new MikroOrmLogger(options); | ||||
|     }, | ||||
| }; | ||||
| function config(testingMode: boolean = false): Options { | ||||
|     if (testingMode) { | ||||
|         return { | ||||
|             driver: SqliteDriver, | ||||
|             dbName: getEnvVar(EnvVars.DbName), | ||||
|             entities: entities, | ||||
|             entitiesTs: entitiesTs, | ||||
| 
 | ||||
|             // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
 | ||||
|             // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
 | ||||
|             dynamicImportProvider: (id) => { | ||||
|                 return import(id); | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|         driver: PostgreSqlDriver, | ||||
|         host: getEnvVar(EnvVars.DbHost), | ||||
|         port: getNumericEnvVar(EnvVars.DbPort), | ||||
|         dbName: getEnvVar(EnvVars.DbName), | ||||
|         user: getEnvVar(EnvVars.DbUsername), | ||||
|         password: getEnvVar(EnvVars.DbPassword), | ||||
|         entities: entities, | ||||
|         entitiesTs: entitiesTs, | ||||
| 
 | ||||
|         // Logging
 | ||||
|         debug: LOG_LEVEL === 'debug', | ||||
|         loggerFactory: (options: LoggerOptions) => { | ||||
|             return new MikroOrmLogger(options); | ||||
|         }, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export default config; | ||||
|  |  | |||
|  | @ -1,13 +1,37 @@ | |||
| import { MikroORM } from '@mikro-orm/core'; | ||||
| import { EntityManager, MikroORM } from '@mikro-orm/core'; | ||||
| import config from './mikro-orm.config.js'; | ||||
| import { EnvVars, getEnvVar } from './util/envvars.js'; | ||||
| import { getLogger } from './logging/initalize.js'; | ||||
| import { Logger } from 'winston'; | ||||
| 
 | ||||
| export default async function initORM() { | ||||
| let orm: MikroORM | undefined; | ||||
| export async function initORM(testingMode: boolean = false) { | ||||
|     const logger: Logger = getLogger(); | ||||
| 
 | ||||
|     logger.info('Initializing ORM'); | ||||
|     logger.debug('MikroORM config is', config); | ||||
| 
 | ||||
|     await MikroORM.init(config); | ||||
|     orm = await MikroORM.init(config(testingMode)); | ||||
|     // Update the database scheme if necessary and enabled.
 | ||||
|     if (getEnvVar(EnvVars.DbUpdate)) { | ||||
|         await orm.schema.updateSchema(); | ||||
|     } else { | ||||
|         const diff = await orm.schema.getUpdateSchemaSQL(); | ||||
|         if (diff) { | ||||
|             throw Error( | ||||
|                 'The database structure needs to be updated in order to fit the new database structure ' + | ||||
|                     'of the app. In order to do so automatically, set the environment variable DWENGO_DB_UPDATE to true. ' + | ||||
|                     'The following queries will then be executed:\n' + | ||||
|                     diff | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| export function forkEntityManager(): EntityManager { | ||||
|     if (!orm) { | ||||
|         throw Error( | ||||
|             'Accessing the Entity Manager before the ORM is fully initialized.' | ||||
|         ); | ||||
|     } | ||||
|     return orm.em.fork(); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										54
									
								
								backend/src/routes/assignment.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								backend/src/routes/assignment.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         assignments: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about an assignment with id 'id'
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         title: 'Dit is een test assignment', | ||||
|         description: 'Een korte beschrijving', | ||||
|         groups: [ '0' ], | ||||
|         learningPath: '0', | ||||
|         class: '0', | ||||
|         links: { | ||||
|             self: `${req.baseUrl}/${req.params.id}`, | ||||
|             submissions: `${req.baseUrl}/${req.params.id}`, | ||||
|         }, | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| router.get('/:id/submissions', (req, res) => { | ||||
|     res.json({ | ||||
|         submissions: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| router.get('/:id/groups', (req, res) => { | ||||
|     res.json({ | ||||
|         groups: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| router.get('/:id/questions', (req, res) => { | ||||
|     res.json({ | ||||
|         questions: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										55
									
								
								backend/src/routes/class.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								backend/src/routes/class.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         classes: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about an class with id 'id'
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         displayName: 'Klas 4B', | ||||
|         teachers: [ '0' ], | ||||
|         students: [ '0' ], | ||||
|         joinRequests: [ '0' ], | ||||
|         links: { | ||||
|             self: `${req.baseUrl}/${req.params.id}`, | ||||
|             classes: `${req.baseUrl}/${req.params.id}/invitations`, | ||||
|             questions: `${req.baseUrl}/${req.params.id}/assignments`, | ||||
|             students: `${req.baseUrl}/${req.params.id}/students`, | ||||
|         } | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| router.get('/:id/invitations', (req, res) => { | ||||
|     res.json({ | ||||
|         invitations: [  | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| router.get('/:id/assignments', (req, res) => { | ||||
|     res.json({ | ||||
|         assignments: [  | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| router.get('/:id/students', (req, res) => { | ||||
|     res.json({ | ||||
|         students: [  | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										34
									
								
								backend/src/routes/group.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								backend/src/routes/group.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         groups: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about a group (members, ... [TODO DOC])
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         assignment: '0', | ||||
|         students: [ '0' ], | ||||
|         submissions: [ '0' ], | ||||
|         // Reference to other endpoint
 | ||||
|         // Should be less hardcoded
 | ||||
|         questions: `/group/${req.params.id}/question`,  | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| // The list of questions a group has made
 | ||||
| router.get('/:id/question', (req, res) => { | ||||
|     res.json({ | ||||
|         questions: [ '0' ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										27
									
								
								backend/src/routes/learningObjects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/src/routes/learningObjects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import express from 'express'; | ||||
| import { | ||||
|     getAllLearningObjects, | ||||
|     getLearningObject, | ||||
| } from '../controllers/learningObjects.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // DWENGO learning objects
 | ||||
| 
 | ||||
| // Queries: hruid(path), full, language
 | ||||
| // Route to fetch list of learning objects based on hruid of learning path
 | ||||
| 
 | ||||
| // Route 1: list of object hruids
 | ||||
| // Example 1: http://localhost:3000/learningObject?hruid=un_artificiele_intelligentie
 | ||||
| 
 | ||||
| // Route 2: list of object data
 | ||||
| // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
 | ||||
| router.get('/', getAllLearningObjects); | ||||
| 
 | ||||
| // Parameter: hruid of learning object
 | ||||
| // Query: language
 | ||||
| // Route to fetch data of one learning object based on its hruid
 | ||||
| // Example: http://localhost:3000/learningObject/un_ai7
 | ||||
| router.get('/:hruid', getLearningObject); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										27
									
								
								backend/src/routes/learningPaths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/src/routes/learningPaths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import express from 'express'; | ||||
| import { getLearningPaths } from '../controllers/learningPaths.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // DWENGO learning paths
 | ||||
| 
 | ||||
| // Route 1: no query
 | ||||
| // Fetch all learning paths
 | ||||
| // Example 1: http://localhost:3000/learningPath
 | ||||
| 
 | ||||
| // Unified route for fetching learning paths
 | ||||
| // Route 2: Query: hruid (list), language
 | ||||
| // Fetch learning paths based on hruid list
 | ||||
| // Example 2: http://localhost:3000/learningPath?hruid=pn_werking&hruid=art1
 | ||||
| 
 | ||||
| // Query: search, language
 | ||||
| // Route to fetch learning paths based on a searchterm
 | ||||
| // Example 3: http://localhost:3000/learningPath?search=robot
 | ||||
| 
 | ||||
| // Query: theme, anguage
 | ||||
| // Route to fetch learning paths based on a theme
 | ||||
| // Example: http://localhost:3000/learningPath?theme=kiks
 | ||||
| 
 | ||||
| router.get('/', getLearningPaths); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										14
									
								
								backend/src/routes/login.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								backend/src/routes/login.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Returns login paths for IDP
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         // Dummy variables, needs to be changed
 | ||||
|         // With IDP endpoints
 | ||||
|         leerkracht: '/login-leerkracht', | ||||
|         leerling: '/login-leerling', | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										38
									
								
								backend/src/routes/question.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								backend/src/routes/question.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         questions: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about an question with id 'id'
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         student: '0', | ||||
|         group: '0', | ||||
|         time: new Date(2025, 1, 1), | ||||
|         content: 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', | ||||
|         learningObject: '0', | ||||
|         links: { | ||||
|             self: `${req.baseUrl}/${req.params.id}`, | ||||
|             answers: `${req.baseUrl}/${req.params.id}/answers`, | ||||
|         } | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| router.get('/:id/answers', (req, res) => { | ||||
|     res.json({ | ||||
|         answers: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }) | ||||
| }) | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										59
									
								
								backend/src/routes/student.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								backend/src/routes/student.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,59 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         students: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about a student's profile
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         firstName: 'Jimmy', | ||||
|         lastName: 'Faster', | ||||
|         username: 'JimmyFaster2', | ||||
|         endpoints: { | ||||
|             classes: `/student/${req.params.id}/classes`, | ||||
|             questions: `/student/${req.params.id}/submissions`, | ||||
|             invitations: `/student/${req.params.id}/assignments`, | ||||
|             groups: `/student/${req.params.id}/groups`, | ||||
|         }, | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // The list of classes a student is in
 | ||||
| router.get('/:id/classes', (req, res) => { | ||||
|     res.json({ | ||||
|         classes: [ '0' ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| // The list of submissions a student has made
 | ||||
| router.get('/:id/submissions', (req, res) => { | ||||
|     res.json({ | ||||
|         submissions: [ '0' ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
|    | ||||
| // The list of assignments a student has
 | ||||
| router.get('/:id/assignments', (req, res) => { | ||||
|     res.json({ | ||||
|         assignments: [ '0' ], | ||||
|     }); | ||||
| }) | ||||
|    | ||||
| // The list of groups a student is in
 | ||||
| router.get('/:id/groups', (req, res) => { | ||||
|     res.json({ | ||||
|         groups: [ '0' ], | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										26
									
								
								backend/src/routes/submission.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/routes/submission.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         submissions: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about an submission with id 'id'
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         student: '0', | ||||
|         group: '0', | ||||
|         time: new Date(2025, 1, 1), | ||||
|         content: 'Wortel 2 is rationeel', | ||||
|         learningObject: '0', | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										58
									
								
								backend/src/routes/teacher.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								backend/src/routes/teacher.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| import express from 'express' | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Root endpoint used to search objects
 | ||||
| router.get('/', (req, res) => { | ||||
|     res.json({ | ||||
|         teachers: [ | ||||
|             '0', | ||||
|             '1', | ||||
|         ] | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Information about a teacher
 | ||||
| router.get('/:id', (req, res) => { | ||||
|     res.json({ | ||||
|         id: req.params.id, | ||||
|         firstName: 'John', | ||||
|         lastName: 'Doe', | ||||
|         username: 'JohnDoe1', | ||||
|         links: { | ||||
|             self: `${req.baseUrl}/${req.params.id}`, | ||||
|             classes: `${req.baseUrl}/${req.params.id}/classes`, | ||||
|             questions: `${req.baseUrl}/${req.params.id}/questions`, | ||||
|             invitations: `${req.baseUrl}/${req.params.id}/invitations`, | ||||
|         }, | ||||
|     }); | ||||
| }) | ||||
| 
 | ||||
| // The questions students asked a teacher
 | ||||
| router.get('/:id/questions', (req, res) => { | ||||
|     res.json({ | ||||
|         questions: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // Invitations to other classes a teacher received
 | ||||
| router.get('/:id/invitations', (req, res) => { | ||||
|     res.json({ | ||||
|         invitations: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| // A list with ids of classes a teacher is in
 | ||||
| router.get('/:id/classes', (req, res) => { | ||||
|     res.json({ | ||||
|         classes: [ | ||||
|             '0' | ||||
|         ], | ||||
|     }); | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
| export default router | ||||
							
								
								
									
										14
									
								
								backend/src/routes/themes.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								backend/src/routes/themes.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| import express from 'express'; | ||||
| import { getThemes, getThemeByTitle } from '../controllers/themes.js'; | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Query: language
 | ||||
| //  Route to fetch list of {key, title, description, image} themes in their respective language
 | ||||
| router.get('/', getThemes); | ||||
| 
 | ||||
| // Arg: theme (key)
 | ||||
| //  Route to fetch list of hruids based on theme
 | ||||
| router.get('/:theme', getThemeByTitle); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										134
									
								
								backend/src/services/learningObjects.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								backend/src/services/learningObjects.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,134 @@ | |||
| import { DWENGO_API_BASE } from '../config.js'; | ||||
| import { fetchWithLogging } from '../util/apiHelper.js'; | ||||
| import { | ||||
|     FilteredLearningObject, | ||||
|     LearningObjectMetadata, | ||||
|     LearningObjectNode, | ||||
|     LearningPathResponse, | ||||
| } from '../interfaces/learningPath.js'; | ||||
| import { fetchLearningPaths } from './learningPaths.js'; | ||||
| 
 | ||||
| function filterData( | ||||
|     data: LearningObjectMetadata, | ||||
|     htmlUrl: string | ||||
| ): FilteredLearningObject { | ||||
|     return { | ||||
|         key: data.hruid, // Hruid learningObject (not path)
 | ||||
|         _id: data._id, | ||||
|         uuid: data.uuid, | ||||
|         version: data.version, | ||||
|         title: data.title, | ||||
|         htmlUrl, // Url to fetch html content
 | ||||
|         language: data.language, | ||||
|         difficulty: data.difficulty, | ||||
|         estimatedTime: data.estimated_time, | ||||
|         available: data.available, | ||||
|         teacherExclusive: data.teacher_exclusive, | ||||
|         educationalGoals: data.educational_goals, // List with learningObjects
 | ||||
|         keywords: data.keywords, // For search
 | ||||
|         description: data.description, // For search (not an actual description)
 | ||||
|         targetAges: data.target_ages, | ||||
|         contentType: data.content_type, // Markdown, image, audio, etc.
 | ||||
|         contentLocation: data.content_location, // If content type extern
 | ||||
|         skosConcepts: data.skos_concepts, | ||||
|         returnValue: data.return_value, // Callback response information
 | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Fetches a single learning object by its HRUID | ||||
|  */ | ||||
| export async function getLearningObjectById( | ||||
|     hruid: string, | ||||
|     language: string | ||||
| ): Promise<FilteredLearningObject | null> { | ||||
|     const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; | ||||
|     const metadata = await fetchWithLogging<LearningObjectMetadata>( | ||||
|         metadataUrl, | ||||
|         `Metadata for Learning Object HRUID "${hruid}" (language ${language})` | ||||
|     ); | ||||
| 
 | ||||
|     if (!metadata) { | ||||
|         console.error(`⚠️ WARNING: Learning object "${hruid}" not found.`); | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`; | ||||
|     return filterData(metadata, htmlUrl); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Generic function to fetch learning objects (full data or just HRUIDs) | ||||
|  */ | ||||
| async function fetchLearningObjects( | ||||
|     hruid: string, | ||||
|     full: boolean, | ||||
|     language: string | ||||
| ): Promise<FilteredLearningObject[] | string[]> { | ||||
|     try { | ||||
|         const learningPathResponse: LearningPathResponse = | ||||
|             await fetchLearningPaths( | ||||
|                 [hruid], | ||||
|                 language, | ||||
|                 `Learning path for HRUID "${hruid}"` | ||||
|             ); | ||||
| 
 | ||||
|         if ( | ||||
|             !learningPathResponse.success || | ||||
|             !learningPathResponse.data?.length | ||||
|         ) { | ||||
|             console.error( | ||||
|                 `⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.` | ||||
|             ); | ||||
|             return []; | ||||
|         } | ||||
| 
 | ||||
|         const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; | ||||
| 
 | ||||
|         if (!full) { | ||||
|             return nodes.map((node) => { | ||||
|                 return node.learningobject_hruid; | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         return await Promise.all( | ||||
|             nodes.map(async (node) => { | ||||
|                 return getLearningObjectById( | ||||
|                     node.learningobject_hruid, | ||||
|                     language | ||||
|                 ); | ||||
|             }) | ||||
|         ).then((objects) => { | ||||
|             return objects.filter((obj): obj is FilteredLearningObject => { | ||||
|                 return obj !== null; | ||||
|             }); | ||||
|         }); | ||||
|     } catch (error) { | ||||
|         console.error('❌ Error fetching learning objects:', error); | ||||
|         return []; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Fetch full learning object data (metadata) | ||||
|  */ | ||||
| export async function getLearningObjectsFromPath( | ||||
|     hruid: string, | ||||
|     language: string | ||||
| ): Promise<FilteredLearningObject[]> { | ||||
|     return (await fetchLearningObjects( | ||||
|         hruid, | ||||
|         true, | ||||
|         language | ||||
|     )) as FilteredLearningObject[]; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Fetch only learning object HRUIDs | ||||
|  */ | ||||
| export async function getLearningObjectIdsFromPath( | ||||
|     hruid: string, | ||||
|     language: string | ||||
| ): Promise<string[]> { | ||||
|     return (await fetchLearningObjects(hruid, false, language)) as string[]; | ||||
| } | ||||
							
								
								
									
										61
									
								
								backend/src/services/learningPaths.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								backend/src/services/learningPaths.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | |||
| import { fetchWithLogging } from '../util/apiHelper.js'; | ||||
| import { DWENGO_API_BASE } from '../config.js'; | ||||
| import { | ||||
|     LearningPath, | ||||
|     LearningPathResponse, | ||||
| } from '../interfaces/learningPath.js'; | ||||
| 
 | ||||
| export async function fetchLearningPaths( | ||||
|     hruids: string[], | ||||
|     language: string, | ||||
|     source: string | ||||
| ): Promise<LearningPathResponse> { | ||||
|     if (hruids.length === 0) { | ||||
|         return { | ||||
|             success: false, | ||||
|             source, | ||||
|             data: null, | ||||
|             message: `No HRUIDs provided for ${source}.`, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; | ||||
|     const params = { pathIdList: JSON.stringify({ hruids }), language }; | ||||
| 
 | ||||
|     const learningPaths = await fetchWithLogging<LearningPath[]>( | ||||
|         apiUrl, | ||||
|         `Learning paths for ${source}`, | ||||
|         params | ||||
|     ); | ||||
| 
 | ||||
|     if (!learningPaths || learningPaths.length === 0) { | ||||
|         console.error(`⚠️ WARNING: No learning paths found for ${source}.`); | ||||
|         return { | ||||
|             success: false, | ||||
|             source, | ||||
|             data: [], | ||||
|             message: `No learning paths found for ${source}.`, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|         success: true, | ||||
|         source, | ||||
|         data: learningPaths, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export async function searchLearningPaths( | ||||
|     query: string, | ||||
|     language: string | ||||
| ): Promise<LearningPath[]> { | ||||
|     const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; | ||||
|     const params = { all: query, language }; | ||||
| 
 | ||||
|     const searchResults = await fetchWithLogging<LearningPath[]>( | ||||
|         apiUrl, | ||||
|         `Search learning paths with query "${query}"`, | ||||
|         params | ||||
|     ); | ||||
|     return searchResults ?? []; | ||||
| } | ||||
							
								
								
									
										43
									
								
								backend/src/util/apiHelper.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								backend/src/util/apiHelper.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import axios, { AxiosRequestConfig } from 'axios'; | ||||
| 
 | ||||
| // !!!! when logger is done -> change
 | ||||
| 
 | ||||
| /** | ||||
|  * Utility function to fetch data from an API endpoint with error handling. | ||||
|  * Logs errors but does NOT throw exceptions to keep the system running. | ||||
|  * | ||||
|  * @param url The API endpoint to fetch from. | ||||
|  * @param description A short description of what is being fetched (for logging). | ||||
|  * @param params | ||||
|  * @returns The response data if successful, or null if an error occurs. | ||||
|  */ | ||||
| export async function fetchWithLogging<T>( | ||||
|     url: string, | ||||
|     description: string, | ||||
|     params?: Record<string, any> | ||||
| ): Promise<T | null> { | ||||
|     try { | ||||
|         const config: AxiosRequestConfig = params ? { params } : {}; | ||||
| 
 | ||||
|         const response = await axios.get<T>(url, config); | ||||
|         return response.data; | ||||
|     } catch (error: any) { | ||||
|         if (error.response) { | ||||
|             if (error.response.status === 404) { | ||||
|                 console.error( | ||||
|                     `❌ ERROR: ${description} not found (404) at "${url}".` | ||||
|                 ); | ||||
|             } else { | ||||
|                 console.error( | ||||
|                     `❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")` | ||||
|                 ); | ||||
|             } | ||||
|         } else { | ||||
|             console.error( | ||||
|                 `❌ ERROR: Network or unexpected error when fetching ${description}:`, | ||||
|                 error.message | ||||
|             ); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										45
									
								
								backend/src/util/envvars.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								backend/src/util/envvars.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | |||
| const PREFIX = 'DWENGO_'; | ||||
| const DB_PREFIX = PREFIX + 'DB_'; | ||||
| 
 | ||||
| type EnvVar = { key: string; required?: boolean; defaultValue?: any }; | ||||
| 
 | ||||
| export const EnvVars: { [key: string]: EnvVar } = { | ||||
|     Port: { key: PREFIX + 'PORT', defaultValue: 3000 }, | ||||
|     DbHost: { key: DB_PREFIX + 'HOST', required: true }, | ||||
|     DbPort: { key: DB_PREFIX + 'PORT', defaultValue: 5432 }, | ||||
|     DbName: { key: DB_PREFIX + 'NAME', defaultValue: 'dwengo' }, | ||||
|     DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, | ||||
|     DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, | ||||
|     DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, | ||||
| } as const; | ||||
| 
 | ||||
| /** | ||||
|  * Returns the value of the given environment variable if it is set. | ||||
|  * Otherwise, | ||||
|  * - throw an error if the environment variable was required, | ||||
|  * - return the default value if there is one and it was not required, | ||||
|  * - return an empty string if the environment variable is not required and there is also no default. | ||||
|  * @param envVar The properties of the environment variable (from the EnvVar object). | ||||
|  */ | ||||
| export function getEnvVar(envVar: EnvVar): string { | ||||
|     const value: string | undefined = process.env[envVar.key]; | ||||
|     if (value) { | ||||
|         return value; | ||||
|     } else if (envVar.required) { | ||||
|         throw new Error(`Missing environment variable: ${envVar.key}`); | ||||
|     } else { | ||||
|         return envVar.defaultValue || ''; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export function getNumericEnvVar(envVar: EnvVar): number { | ||||
|     const valueString = getEnvVar(envVar); | ||||
|     const value = parseInt(valueString); | ||||
|     if (isNaN(value)) { | ||||
|         throw new Error( | ||||
|             `Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.` | ||||
|         ); | ||||
|     } else { | ||||
|         return value; | ||||
|     } | ||||
| } | ||||
		Reference in a new issue