Merge branch 'feat/service-layer' into feat/service-layer-adriaan

# Conflicts:
#	backend/src/controllers/classes.ts
#	backend/src/controllers/students.ts
#	backend/src/data/users/teacher-repository.ts
#	backend/src/interfaces/assignment.ts
#	backend/src/interfaces/teacher.ts
#	backend/src/routes/classes.ts
#	backend/src/services/assignments.ts
#	backend/src/services/class.ts
#	backend/src/services/students.ts
#	backend/src/util/translation-helper.ts
This commit is contained in:
Gabriellvl 2025-03-09 22:30:15 +01:00
commit 6c4ea0eefb
33 changed files with 454 additions and 137 deletions

View file

@ -3,21 +3,23 @@ import { initORM } from './orm.js';
import { EnvVars, getNumericEnvVar } from './util/envvars.js';
import themeRoutes from './routes/themes.js';
import learningPathRoutes from './routes/learningPaths.js';
import learningObjectRoutes from './routes/learningObjects.js';
import learningPathRoutes from './routes/learning-paths.js';
import learningObjectRoutes from './routes/learning-objects.js';
import studentRouter from './routes/student.js';
import groupRouter from './routes/group.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 studentRoutes from './routes/students.js';
import teacherRoutes from './routes/teachers.js'
import groupRoutes from './routes/groups.js';
import submissionRoutes from './routes/submissions.js';
import classRoutes from './routes/classes.js';
import questionRoutes from './routes/questions.js';
const app: Express = express();
const port: string | number = getNumericEnvVar(EnvVars.Port);
app.use(express.json());
app.use(express.json());
// TODO Replace with Express routes
app.get('/', (_, res: Response) => {
res.json({
@ -25,12 +27,12 @@ app.get('/', (_, res: Response) => {
});
});
app.use('/student', studentRouter);
app.use('/group', groupRouter);
app.use('/submission', submissionRouter);
app.use('/class', classRouter);
app.use('/question', questionRouter);
app.use('/login', loginRouter);
app.use('/student', studentRoutes);
app.use('/teacher', teacherRoutes);
app.use('/group', groupRoutes);
app.use('/submission', submissionRoutes);
app.use('/class', classRoutes);
app.use('/question', questionRoutes);
app.use('/theme', themeRoutes);
app.use('/learningPath', learningPathRoutes);

View file

@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { getAllClasses, getClass, getClassStudents, getClassTeacherInvitations } from '../services/class.js';
import { getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/class.js';
import { ClassDTO } from '../interfaces/classes.js';
export async function getAllClassesHandler(
req: Request,
@ -47,7 +48,9 @@ export async function getClassStudentsHandler(
const classId = req.params.id;
const full = req.query.full === "true";
const students = await getClassStudents(classId, full);
let students = full
? await getClassStudents(classId)
: await getClassStudentsIds(classId);
res.json({
students: students,

View file

@ -3,9 +3,9 @@ import {
getLearningObjectById,
getLearningObjectIdsFromPath,
getLearningObjectsFromPath,
} from '../services/learningObjects.js';
} from '../services/learning-objects.js';
import { FALLBACK_LANG } from '../config.js';
import { FilteredLearningObject } from '../interfaces/learningPath';
import { FilteredLearningObject } from '../interfaces/learning-path';
export async function getAllLearningObjects(
req: Request,

View file

@ -4,7 +4,7 @@ import { FALLBACK_LANG } from '../config.js';
import {
fetchLearningPaths,
searchLearningPaths,
} from '../services/learningPaths.js';
} from '../services/learning-paths.js';
/**
* Fetch learning paths based on query parameters.
*/

View file

@ -0,0 +1,155 @@
import { Request, Response } from 'express';
import {
createTeacher,
deleteTeacher,
getTeacherByUsername,
getClassesByTeacher,
getClassIdsByTeacher,
getAllTeachers,
getAllTeachersIds, getStudentsByTeacher, getStudentIdsByTeacher, getQuestionsByTeacher, getQuestionIdsByTeacher
} from '../services/teachers.js';
import {TeacherDTO} from "../interfaces/teacher";
import {ClassDTO} from "../interfaces/class";
import {StudentDTO} from "../interfaces/student";
import {QuestionDTO, QuestionId} from "../interfaces/question";
export async function getTeacherHandler(req: Request, res: Response): Promise<void> {
try {
const full = req.query.full === 'true';
const username = req.query.username as string;
if (username){
const teacher = await getTeacherByUsername(username);
if (!teacher){
res.status(404).json({ error: `Teacher with username '${username}' not found.` });
return;
}
res.json(teacher);
return;
}
let teachers: TeacherDTO[] | string[];
if (full) teachers = await getAllTeachers();
else teachers = await getAllTeachersIds();
res.json(teachers);
} catch (error) {
console.error("❌ Error fetching teachers:", error);
res.status(500).json({ error: "Internal server error" });
}
}
export async function createTeacherHandler(
req: Request,
res: Response
): Promise<void> {
try {
const teacherData = req.body as TeacherDTO;
if (!teacherData.username || !teacherData.firstName || !teacherData.lastName) {
res.status(400).json({ error: 'Missing required fields: username, firstName, lastName' });
return;
}
const newTeacher = await createTeacher(teacherData);
res.status(201).json(newTeacher);
} catch (error) {
console.error('Error creating teacher:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function deleteTeacherHandler(
req: Request,
res: Response
): Promise<void> {
try {
const username = req.params.username as string;
if (!username) {
res.status(400).json({ error: 'Missing required field: username' });
return;
}
const deletedTeacher = await deleteTeacher(username);
if (!deletedTeacher) {
res.status(400).json({ error: `Did not find teacher with username ${username}` });
return;
}
res.status(201).json(deletedTeacher);
} catch (error) {
console.error('Error deleting teacher:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function getTeacherClassHandler(req: Request, res: Response): Promise<void> {
try {
const username = req.params.username as string;
const full = req.query.full === 'true';
if (!username) {
res.status(400).json({ error: 'Missing required field: username' });
return;
}
let classes: ClassDTO[] | string[];
if (full) classes = await getClassesByTeacher(username);
else classes = await getClassIdsByTeacher(username);
res.status(201).json(classes);
} catch (error) {
console.error('Error fetching classes by teacher:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> {
try {
const username = req.params.username as string;
const full = req.query.full === 'true';
if (!username) {
res.status(400).json({ error: 'Missing required field: username' });
return;
}
let students: StudentDTO[] | string[];
if (full) students = await getStudentsByTeacher(username);
else students = await getStudentIdsByTeacher(username);
res.status(201).json(students);
} catch (error) {
console.error('Error fetching students by teacher:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> {
try {
const username = req.params.username as string;
const full = req.query.full === 'true';
if (!username) {
res.status(400).json({ error: 'Missing required field: username' });
return;
}
let questions: QuestionDTO[] | QuestionId[];
if (full) questions = await getQuestionsByTeacher(username);
else questions = await getQuestionIdsByTeacher(username);
res.status(201).json(questions);
} catch (error) {
console.error('Error fetching questions by teacher:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import { themes } from '../data/themes.js';
import { loadTranslations } from "../util/translationHelper.js";
import { loadTranslations } from "../util/translation-helper.js";
import { FALLBACK_LANG } from '../config.js';
interface Translations {

View file

@ -1,6 +1,7 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Class } from '../../entities/classes/class.entity.js';
import { Student } from '../../entities/users/student.entity.js';
import {Teacher} from "../../entities/users/teacher.entity";
export class ClassRepository extends DwengoEntityRepository<Class> {
public findById(id: string): Promise<Class | null> {
@ -18,4 +19,11 @@ export class ClassRepository extends DwengoEntityRepository<Class> {
{ populate: ["students", "teachers"] } // voegt student en teacher objecten toe
)
}
public findByTeacher(teacher: Teacher): Promise<Class[]> {
return this.find(
{ teachers: teacher },
{ populate: ["students", "teachers"] }
);
}
}

View file

@ -1,6 +1,7 @@
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';
import {Teacher} from "../../entities/users/teacher.entity";
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
public findByIdentifier(
@ -13,4 +14,11 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
});
}
// This repository is read-only for now since creating own learning object is an extension feature.
public findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> {
return this.find(
{ admins: teacher },
{ populate: ['admins'] } // Make sure to load admin relations
);
}
}

View file

@ -2,6 +2,7 @@ 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';
import {LearningObject} from "../../entities/content/learning-object.entity";
export class QuestionRepository extends DwengoEntityRepository<Question> {
public createQuestion(question: {
@ -42,4 +43,17 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
sequenceNumber: sequenceNumber,
});
}
public async findAllByLearningObjects(learningObjects: LearningObject[]): Promise<Question[]> {
const objectIdentifiers = learningObjects.map(lo => ({
learningObjectHruid: lo.hruid,
learningObjectLanguage: lo.language,
learningObjectVersion: lo.version
}));
return this.findAll({
where: { $or: objectIdentifiers },
orderBy: { timestamp: 'ASC' },
});
}
}

View file

@ -0,0 +1,41 @@
import {Question} from "../entities/questions/question.entity";
import {Enum, PrimaryKey} from "@mikro-orm/core";
import {Language} from "../entities/content/language";
export interface QuestionDTO {
learningObjectHruid: string;
learningObjectLanguage: string;
learningObjectVersion: string;
sequenceNumber: number;
authorUsername: string;
timestamp: string;
content: string;
endpoints?: {
classes: string;
questions: string;
invitations: string;
groups: string;
};
}
/**
* Convert a Question entity to a DTO format.
*/
export function mapToQuestionDTO(question: Question): QuestionDTO {
return {
learningObjectHruid: question.learningObjectHruid,
learningObjectLanguage: question.learningObjectLanguage,
learningObjectVersion: question.learningObjectVersion,
sequenceNumber: question.sequenceNumber,
authorUsername: question.author.username,
timestamp: question.timestamp.toISOString(),
content: question.content,
};
}
export interface QuestionId {
learningObjectHruid: string,
learningObjectLanguage: Language,
learningObjectVersion: string,
sequenceNumber: number
}

View file

@ -1,23 +1,36 @@
import { Teacher } from "../entities/users/teacher.entity.js";
/**
* Teacher Data Transfer Object
*/
export interface TeacherDTO {
id: string;
username: string;
firstName: string;
lastName: string;
endpoints?: {
self: string;
classes: string;
questions: string;
invitations: string;
groups: string;
};
}
/**
* Maps a Teacher entity to a TeacherDTO
*/
export function mapToTeacherDTO(teacher: Teacher): TeacherDTO {
return {
id: teacher.username,
username: teacher.username,
firstName: teacher.firstName,
lastName: teacher.lastName,
};
}
export function mapToTeacher(teacherData: TeacherDTO): Teacher {
const teacher = new Teacher();
teacher.username = teacherData.username;
teacher.firstName = teacherData.firstName;
teacher.lastName = teacherData.lastName;
return teacher;
}

View file

@ -2,7 +2,7 @@ import express from 'express';
import {
getAllLearningObjects,
getLearningObject,
} from '../controllers/learningObjects.js';
} from '../controllers/learning-objects.js';
const router = express.Router();

View file

@ -1,5 +1,5 @@
import express from 'express';
import { getLearningPaths } from '../controllers/learningPaths.js';
import { getLearningPaths } from '../controllers/learning-paths.js';
const router = express.Router();

View file

@ -1,14 +0,0 @@
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

View file

@ -1,58 +0,0 @@
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

View file

@ -0,0 +1,34 @@
import express from 'express'
import {
createTeacherHandler,
deleteTeacherHandler,
getTeacherClassHandler,
getTeacherHandler, getTeacherQuestionHandler, getTeacherStudentHandler
} from "../controllers/teachers.js";
const router = express.Router();
// root endpoint used to search objects
router.get('/', getTeacherHandler);
router.post('/', createTeacherHandler);
router.delete('/:username', deleteTeacherHandler);
router.get('/:username/classes', getTeacherClassHandler);
router.get('/:username/students', getTeacherStudentHandler);
router.get('/:username/questions', getTeacherQuestionHandler);
// invitations to other classes a teacher received
router.get('/:id/invitations', (req, res) => {
res.json({
invitations: [
'0'
],
});
});
export default router

View file

@ -1,7 +1,7 @@
import { getClassRepository, getTeacherInvitationRepository } from "../data/repositories.js";
import { ClassDTO, mapToClassDTO } from "../interfaces/classes.js";
import { mapToStudentDTO, StudentDTO } from "../interfaces/students.js";
import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds, TeacherInvitationDTO } from "../interfaces/teacher-invitation.js";
import { getClassRepository } from "../data/repositories";
import { Class } from "../entities/classes/class.entity";
import { ClassDTO, mapToClassDTO } from "../interfaces/class";
import { mapToStudentDTO, StudentDTO } from "../interfaces/student";
export async function getAllClasses(full: boolean): Promise<ClassDTO[] | string[]> {
const classRepository = getClassRepository();
@ -28,41 +28,21 @@ export async function getClass(classId: string): Promise<ClassDTO | null> {
}
}
export async function getClassStudents(classId: string, full: boolean): Promise<StudentDTO[] | string[]> {
async function fetchClassStudents(classId: string, full: boolean): Promise<StudentDTO[] | string[]> {
const classRepository = getClassRepository();
const cls = await classRepository.findById(classId);
if (!cls) {
if (!cls)
return [];
}
if (full) {
return cls.students.map(mapToStudentDTO);
} else {
return cls.students.map((student) => student.username);
}
return cls.students.map(mapToStudentDTO);
}
export async function getClassTeacherInvitations(classId: string, full: boolean): Promise<TeacherInvitationDTO[]> {
const classRepository = getClassRepository();
const cls = await classRepository.findById(classId);
if (!cls) {
return [];
}
const teacherInvitationRepository = getTeacherInvitationRepository();
const invitations = await teacherInvitationRepository.findAllInvitationsForClass(cls);
console.log(invitations);
if (!invitations) {
return [];
}
if (full) {
return invitations.map(mapToTeacherInvitationDTO);
}
return invitations.map(mapToTeacherInvitationDTOIds);
export async function getClassStudents(classId: string): Promise<StudentDTO[]> {
return await fetchClassStudents(classId);
}
export async function getClassStudentsIds(classId: string): Promise<string[]> {
return await fetchClassStudents(classId).map((student) => student.username);
}

View file

@ -1,12 +1,12 @@
import { DWENGO_API_BASE } from '../config.js';
import { fetchWithLogging } from '../util/apiHelper.js';
import { fetchWithLogging } from '../util/api-helper.js';
import {
FilteredLearningObject,
LearningObjectMetadata,
LearningObjectNode,
LearningPathResponse,
} from '../interfaces/learningPath.js';
import { fetchLearningPaths } from './learningPaths.js';
} from '../interfaces/learning-path.js';
import { fetchLearningPaths } from './learning-paths.js';
function filterData(
data: LearningObjectMetadata,

View file

@ -1,9 +1,9 @@
import { fetchWithLogging } from '../util/apiHelper.js';
import { fetchWithLogging } from '../util/api-helper.js';
import { DWENGO_API_BASE } from '../config.js';
import {
LearningPath,
LearningPathResponse,
} from '../interfaces/learningPath.js';
} from '../interfaces/learning-path.js';
export async function fetchLearningPaths(
hruids: string[],

View file

@ -0,0 +1,131 @@
import {
getClassRepository,
getLearningObjectRepository,
getQuestionRepository,
getTeacherRepository
} from "../data/repositories.js";
import {mapToTeacher, mapToTeacherDTO, TeacherDTO} from "../interfaces/teacher.js";
import { Teacher } from "../entities/users/teacher.entity";
import {ClassDTO, mapToClassDTO} from "../interfaces/class";
import {getClassStudents, getClassStudentsIds} from "./class";
import {StudentDTO} from "../interfaces/student";
import {mapToQuestionDTO, QuestionDTO, QuestionId} from "../interfaces/question";
async function fetchAllTeachers(): Promise<TeacherDTO[]> {
const teacherRepository = getTeacherRepository();
const teachers = await teacherRepository.find({});
return teachers.map(mapToTeacherDTO);
}
export async function getAllTeachers(): Promise<TeacherDTO[]> {
return await fetchAllTeachers();
}
export async function getAllTeachersIds(): Promise<string[]> {
return await fetchAllTeachers().map((teacher) => teacher.username)
}
export async function createTeacher(teacherData: TeacherDTO): Promise<Teacher> {
const teacherRepository = getTeacherRepository();
const newTeacher = mapToTeacher(teacherData);
await teacherRepository.addTeacher(newTeacher);
return newTeacher;
}
export async function getTeacherByUsername(username: string): Promise<TeacherDTO | null> {
const teacherRepository = getTeacherRepository();
const teacher = await teacherRepository.findByUsername(username);
return teacher ? mapToTeacherDTO(teacher) : null;
}
export async function deleteTeacher(username: string): Promise<TeacherDTO | null> {
const teacherRepository = getTeacherRepository();
const teacher = await teacherRepository.findByUsername(username);
if (!teacher)
return null;
await teacherRepository.deleteByUsername(username);
return teacher;
}
async function fetchClassesByTeacher(username: string): Promise<ClassDTO[]> {
const teacherRepository = getTeacherRepository();
const classRepository = getClassRepository();
const teacher = await teacherRepository.findByUsername(username);
if (!teacher) {
return [];
}
const classes = await classRepository.findByTeacher(teacher);
return classes.map(mapToClassDTO);
}
export async function getClassesByTeacher(username: string): Promise<ClassDTO[]> {
return await fetchClassesByTeacher(username)
}
export async function getClassIdsByTeacher(): Promise<string[]> {
return await fetchClassesByTeacher(username).map((cls) => cls.id);
}
async function fetchStudentsByTeacher(username: string) {
const classes = await getClassIdsByTeacher();
return Promise.all(
classes.map( async (id) => getClassStudents(id))
);
}
export async function getStudentsByTeacher(username: string): Promise<StudentDTO[]> {
return await fetchStudentsByTeacher(username);
}
export async function getStudentIdsByTeacher(): Promise<string[]> {
return await fetchStudentsByTeacher(username).map((student) => student.username);
}
async function fetchTeacherQuestions(username: string): Promise<QuestionDTO[]> {
const learningObjectRepository = getLearningObjectRepository();
const questionRepository = getQuestionRepository();
const teacher = getTeacherByUsername(username);
if (!teacher) {
throw new Error(`Teacher with username '${username}' not found.`);
}
// Find all learning objects that this teacher manages
const learningObjects = await learningObjectRepository.findAllByTeacher(teacher);
// Fetch all questions related to these learning objects
const questions = await questionRepository.findAllByLearningObjects(learningObjects);
return questions.map(mapToQuestionDTO);
}
export async function getQuestionsByTeacher(username: string): Promise<QuestionDTO[]> {
return await fetchTeacherQuestions(username);
}
export async function getQuestionIdsByTeacher(username: string): Promise<QuestionId[]> {
const questions = await fetchTeacherQuestions(username);
return questions.map((question) => ({
learningObjectHruid: question.learningObjectHruid,
learningObjectLanguage: question.learningObjectLanguage,
learningObjectVersion: question.learningObjectVersion,
sequenceNumber: question.sequenceNumber
}));
}

View file

@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import {FALLBACK_LANG} from "../config.js";
import { FALLBACK_LANG } from "../config.js";
export function loadTranslations<T>(language: string): T {
try {