Merge remote-tracking branch 'refs/remotes/origin/fix/databank-laat-toevoegen-van-meerdere-studenten-met-dezelfde-username-toe-#153' into feat/user-routes

# Conflicts:
#	backend/src/controllers/students.ts
#	backend/src/controllers/teachers.ts
#	backend/src/exceptions.ts
#	backend/src/interfaces/student.ts
#	backend/src/routes/router.ts
#	backend/src/routes/students.ts
#	backend/src/services/students.ts
#	backend/src/services/teachers.ts
#	frontend/src/controllers/controllers.ts
This commit is contained in:
Gabriellvl 2025-03-30 15:41:40 +02:00
commit 7b65d2a5b8
78 changed files with 1939 additions and 1094 deletions

View file

@ -21,14 +21,16 @@ Alternatief kan je één van de volgende methodes gebruiken om de applicatie lok
### Quick start ### Quick start
Om de applicatie lokaal te draaien als kant-en-klare Docker-containers:
1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) 1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/)
en [Docker Compose](https://docs.docker.com/compose/)). en [Docker Compose](https://docs.docker.com/compose/)).
2. Clone deze repository. 2. Clone deze repository.
3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar 3. In de backend, kopieer `.env.example` naar `.env` en pas de variabelen aan waar nodig.
nodig. 4. Voer `docker compose -f compose.staging.yml up --build` uit in de root van de repository.
4. Voer `docker compose up` uit in de root van de repository.
5. Optioneel: Configureer de applicatie aan de hand van 5. Optioneel: Configureer de applicatie aan de hand van
de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie).
6. De applicatie is nu beschikbaar op [`http://localhost/`](http://localhost/) en [`http://localhost/api`](http://localhost/api).
```bash ```bash
docker compose version docker compose version
@ -38,14 +40,13 @@ cp .env.example .env
# Pas .env aan # Pas .env aan
nano .env nano .env
cd .. cd ..
docker compose up docker compose -f compose.staging.yml up --build
# Configureer de applicatie
``` ```
### Handmatige installatie ### Handmatige installatie en ontwikkeling
Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md)
en [backend](./backend/README.md). en [backend](./backend/README.md) en instructies voor het opzetten van een ontwikkelomgeving.
## Architectuur ## Architectuur

View file

@ -1,15 +1,24 @@
# #
# Basic configuration # Development environment configuration
#
# You probably don't need to change these values, as this configuration takes
# the docker services and their default ports into account.
# #
DWENGO_PORT=3000 # The port the backend will listen on ### Dwengo ###
#DWENGO_PORT=3000
#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api
#DWENGO_FALLBACK_LANGUAGE=nl
#DWENGO_RUN_MODE=dev
DWENGO_DB_HOST=localhost DWENGO_DB_HOST=localhost
DWENGO_DB_PORT=5431 DWENGO_DB_PORT=5431
#DWENGO_DB_NAME=dwengo
DWENGO_DB_USERNAME=postgres DWENGO_DB_USERNAME=postgres
DWENGO_DB_PASSWORD=postgres DWENGO_DB_PASSWORD=postgres
DWENGO_DB_UPDATE=true DWENGO_DB_UPDATE=true
#DWENGO_DB_CONTENT_PREFIX=u_
# Auth
DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
@ -17,12 +26,12 @@ DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/
DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs
#DWENGO_AUTH_AUDIENCE=account
# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production!
DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173
#DWENGO_CORS_ALLOWED_HEADERS=Authorization,Content-Type
# ### Advanced configuration ###
# Advanced configuration
#
# LOKI_HOST=http://localhost:9001 # The address of the Loki instance, used for logging DWENGO_LOGGING_LEVEL=debug
#DWENGO_LOGGING_LOKI_HOST=http://localhost:3102

View file

@ -1,27 +1,68 @@
# #
# Basic configuration # Basic configuration
# #
# Change the values of the variables below to match your environment!
# Default values are commented out.
#
DWENGO_PORT=3000 # The port the backend will listen on ### Dwengo ###
# Port the backend will listen on
#DWENGO_PORT=3000
# The hostname or IP address of the remote learning content API.
#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api
# The default fallback language.
#DWENGO_FALLBACK_LANGUAGE=nl
# Whether running in production mode or not. Possible values are "prod" or "dev".
#DWENGO_RUN_MODE=dev
# ! Change this! The hostname or IP address of the database
# If running your stack in docker, this should use the docker service name.
DWENGO_DB_HOST=domain-or-ip-of-database DWENGO_DB_HOST=domain-or-ip-of-database
DWENGO_DB_PORT=5431 # The port of the database.
#DWENGO_DB_PORT=5432
# Change this to the actual credentials of the user Dwengo should use in the backend # The name of the database.
DWENGO_DB_USERNAME=postgres #DWENGO_DB_NAME=dwengo
DWENGO_DB_PASSWORD=postgres # ! Change this! The username of the database user.
DWENGO_DB_USERNAME=username
# ! Change this! The password of the database user.
DWENGO_DB_PASSWORD=password
# Whether the database scheme needs to be updated.
# Set this to true when the database scheme needs to be updated. In that case, take a backup first. # Set this to true when the database scheme needs to be updated. In that case, take a backup first.
DWENGO_DB_UPDATE=false #DWENGO_DB_UPDATE=false
# The prefix used for custom user content.
#DWENGO_DB_CONTENT_PREFIX=u_
# Data for the identity provider via which the students authenticate. # ! Change this! The external URL for student authentication. Should be reachable by the client.
DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student # E.g. https://sel2-1.ugent.be/idp/realms/student
DWENGO_AUTH_STUDENT_URL=http://hostname/idp/realms/student
# ! Change this! The client ID for student authentication.
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs # ! Change this! The internal URL for retrieving the JWKS for student authentication.
# Should be reachable by the backend. If running your stack in docker, this should use the docker service name.
# Data for the identity provider via which the teachers authenticate. # E.g. http://idp:7080/realms/student/protocol/openid-connect/certs
DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://hostname/realms/student/protocol/openid-connect/certs
# ! Change this! The external URL for teacher authentication. Should be reachable by the client.
# E.g. https://sel2-1.ugent.be/idp/realms/teacher
DWENGO_AUTH_TEACHER_URL=http://hostname/idp/realms/teacher
# ! Change this! The client ID for teacher authentication.
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs # ! Change this! The internal URL for retrieving the JWKS for teacher authentication.
# Should be reachable by the backend. If running your stack in docker, this should use the docker service name.
# E.g. http://idp:7080/realms/teacher/protocol/openid-connect/certs
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://hostname/realms/teacher/protocol/openid-connect/certs
# The IDP audience
#DWENGO_AUTH_AUDIENCE=account
# The address of the Lokiinstance, used for logging # Allowed origins for CORS requests. Separate multiple origins with a comma.
# LOKI_HOST=http://localhost:3102 #DWENGO_CORS_ALLOWED_ORIGINS=
# Allowed headers for CORS requests. Separate multiple headers with a comma.
#DWENGO_CORS_ALLOWED_HEADERS=Authorization,Content-Type
### Advanced configuration ###
# The logging level. Possible values are "debug", "info", "warn", "error".
#DWENGO_LOGGING_LEVEL=info
# The address of the Loki instance, a log aggregation system.
# If running your stack in docker, this should use the docker service name.
#DWENGO_LOGGING_LOKI_HOST=http://localhost:3102

View file

@ -1,28 +1,37 @@
DWENGO_PORT=3000 # The port the backend will listen on #
DWENGO_DB_HOST=db # Name of the database container # Production environment configuration
DWENGO_DB_PORT=5431 #
# Change the values of the variables below to match your production environment!
# See .env.example for more information.
#
# Change this to the actual credentials of the user Dwengo should use in the backend ### Dwengo ###
DWENGO_PORT=3000
#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api
#DWENGO_FALLBACK_LANGUAGE=nl
DWENGO_RUN_MODE=prod
DWENGO_DB_HOST=db
DWENGO_DB_PORT=5432
DWENGO_DB_NAME=postgres DWENGO_DB_NAME=postgres
DWENGO_DB_USERNAME=postgres DWENGO_DB_USERNAME=postgres
DWENGO_DB_PASSWORD=postgres DWENGO_DB_PASSWORD=postgres
# Set this to true when the database scheme needs to be updated. In that case, take a backup first.
DWENGO_DB_UPDATE=false DWENGO_DB_UPDATE=false
#DWENGO_DB_CONTENT_PREFIX=u_
# Data for the identity provider via which the students authenticate.
DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container
# Data for the identity provider via which the teachers authenticate.
DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container
#DWENGO_AUTH_AUDIENCE=account
# #DWENGO_CORS_ALLOWED_ORIGINS=
# Advanced configuration #DWENGO_CORS_ALLOWED_HEADERS=Authorization,Content-Type
#
# Logging and monitoring ### Advanced configuration ###
# LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging DWENGO_LOGGING_LEVEL=info
DWENGO_LOGGING_LOKI_HOST=http://logging:3102

View file

@ -1,3 +1,13 @@
PORT=3000 #
DWENGO_DB_UPDATE=true # Test environment configuration
#
# Should not need to be modified.
# See .env.example for more information.
#
### Dwengo ###
DWENGO_PORT=3000
DWENGO_DB_NAME=":memory:" DWENGO_DB_NAME=":memory:"
DWENGO_DB_UPDATE=true

View file

@ -30,6 +30,7 @@ COPY package-lock.json backend/package.json ./
RUN npm install --silent --only=production RUN npm install --silent --only=production
COPY ./docs /docs COPY ./docs /docs
COPY ./backend/i18n /app/i18n
COPY --from=build-stage /app/backend/dist ./dist/ COPY --from=build-stage /app/backend/dist ./dist/
EXPOSE 3000 EXPOSE 3000

View file

@ -4,23 +4,24 @@
```shell ```shell
npm install npm install
# Start de nodige services voor ontwikkeling
cd ../ # Ga naar de root van de repository
docker compose up -d
``` ```
Setup the environment variables in a `.env` file in the root of the project. You can use the `.env.example` file as a template. Zet de omgevingsvariabelen in een `.env` bestand in de root van het project.
Je kan het `.env.example` bestand als template gebruiken.
### Development ### Ontwikkeling
```shell ```shell
# Omgevingsvariabelen
cp .env.development.example .env.development.local
npm run dev npm run dev
``` ```
### Production
```shell
npm run build
npm run start
```
### Tests ### Tests
Voer volgend commando uit om de unit tests uit te voeren: Voer volgend commando uit om de unit tests uit te voeren:
@ -29,6 +30,18 @@ Voer volgend commando uit om de unit tests uit te voeren:
npm run test:unit npm run test:unit
``` ```
### Productie
```shell
# Omgevingsvariabelen
cp .env.development.example .env
npm run build
npm run start
```
Zie ook de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving).
## Keycloak configuratie ## Keycloak configuratie
Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt.

View file

@ -9,6 +9,7 @@ import { EnvVars, getNumericEnvVar } from './util/envvars.js';
import apiRouter from './routes/router.js'; import apiRouter from './routes/router.js';
import swaggerMiddleware from './swagger.js'; import swaggerMiddleware from './swagger.js';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import { errorHandler } from './middleware/error-handling/error-handler';
const logger: Logger = getLogger(); const logger: Logger = getLogger();
@ -26,6 +27,8 @@ app.use('/api', apiRouter);
// Swagger // Swagger
app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); app.use('/api-docs', swaggerUi.serve, swaggerMiddleware);
app.use(errorHandler);
async function startServer() { async function startServer() {
await initORM(); await initORM();

View file

@ -1,12 +1,7 @@
import { EnvVars, getEnvVar } from './util/envvars.js'; import { EnvVars, getEnvVar } from './util/envvars.js';
import { Language } from './entities/content/language.js';
// API // API
export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl);
export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage);
// Logging
export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info';
export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102';
export const FALLBACK_SEQ_NUM = 1; export const FALLBACK_SEQ_NUM = 1;

View file

@ -37,7 +37,7 @@ export async function createAssignmentHandler(req: Request<AssignmentParams>, re
return; return;
} }
res.status(201).json({ assignment: assignment }); res.status(201).json(assignment);
} }
export async function getAssignmentHandler(req: Request<AssignmentParams>, res: Response): Promise<void> { export async function getAssignmentHandler(req: Request<AssignmentParams>, res: Response): Promise<void> {
@ -62,13 +62,14 @@ export async function getAssignmentHandler(req: Request<AssignmentParams>, res:
export async function getAssignmentsSubmissionsHandler(req: Request<AssignmentParams>, res: Response): Promise<void> { export async function getAssignmentsSubmissionsHandler(req: Request<AssignmentParams>, res: Response): Promise<void> {
const classid = req.params.classid; const classid = req.params.classid;
const assignmentNumber = +req.params.id; const assignmentNumber = +req.params.id;
const full = req.query.full === 'true';
if (isNaN(assignmentNumber)) { if (isNaN(assignmentNumber)) {
res.status(400).json({ error: 'Assignment id must be a number' }); res.status(400).json({ error: 'Assignment id must be a number' });
return; return;
} }
const submissions = await getAssignmentsSubmissions(classid, assignmentNumber); const submissions = await getAssignmentsSubmissions(classid, assignmentNumber, full);
res.json({ res.json({
submissions: submissions, submissions: submissions,

View file

@ -1,5 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { createClass, getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/class.js'; import { createClass, getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/classes.js';
import { ClassDTO } from '../interfaces/class.js'; import { ClassDTO } from '../interfaces/class.js';
export async function getAllClassesHandler(req: Request, res: Response): Promise<void> { export async function getAllClassesHandler(req: Request, res: Response): Promise<void> {
@ -28,30 +28,19 @@ export async function createClassHandler(req: Request, res: Response): Promise<v
return; return;
} }
res.status(201).json({ class: cls }); res.status(201).json(cls);
} }
export async function getClassHandler(req: Request, res: Response): Promise<void> { export async function getClassHandler(req: Request, res: Response): Promise<void> {
try { const classId = req.params.id;
const classId = req.params.id; const cls = await getClass(classId);
const cls = await getClass(classId);
if (!cls) { if (!cls) {
res.status(404).json({ error: 'Class not found' }); res.status(404).json({ error: 'Class not found' });
return; return;
}
cls.endpoints = {
self: `${req.baseUrl}/${req.params.id}`,
invitations: `${req.baseUrl}/${req.params.id}/invitations`,
assignments: `${req.baseUrl}/${req.params.id}/assignments`,
students: `${req.baseUrl}/${req.params.id}/students`,
};
res.json(cls);
} catch (error) {
console.error('Error fetching learning objects:', error);
res.status(500).json({ error: 'Internal server error' });
} }
res.json(cls);
} }
export async function getClassStudentsHandler(req: Request, res: Response): Promise<void> { export async function getClassStudentsHandler(req: Request, res: Response): Promise<void> {
@ -67,7 +56,7 @@ export async function getClassStudentsHandler(req: Request, res: Response): Prom
export async function getTeacherInvitationsHandler(req: Request, res: Response): Promise<void> { export async function getTeacherInvitationsHandler(req: Request, res: Response): Promise<void> {
const classId = req.params.id; const classId = req.params.id;
const full = req.query.full === 'true'; // TODO: not implemented yet const full = req.query.full === 'true';
const invitations = await getClassTeacherInvitations(classId, full); const invitations = await getClassTeacherInvitations(classId, full);

View file

@ -28,6 +28,11 @@ export async function getGroupHandler(req: Request<GroupParams>, res: Response):
const group = await getGroup(classId, assignmentId, groupId, full); const group = await getGroup(classId, assignmentId, groupId, full);
if (!group) {
res.status(404).json({ error: 'Group not found' });
return;
}
res.json(group); res.json(group);
} }
@ -66,12 +71,12 @@ export async function createGroupHandler(req: Request, res: Response): Promise<v
return; return;
} }
res.status(201).json({ group: group }); res.status(201).json(group);
} }
export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise<void> { export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise<void> {
const classId = req.params.classid; const classId = req.params.classid;
// Const full = req.query.full === 'true'; const full = req.query.full === 'true';
const assignmentId = +req.params.assignmentid; const assignmentId = +req.params.assignmentid;
@ -87,7 +92,7 @@ export async function getGroupSubmissionsHandler(req: Request, res: Response): P
return; return;
} }
const submissions = await getGroupSubmissions(classId, assignmentId, groupId); const submissions = await getGroupSubmissions(classId, assignmentId, groupId, full);
res.json({ res.json({
submissions: submissions, submissions: submissions,

View file

@ -4,9 +4,9 @@ import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifie
import learningObjectService from '../services/learning-objects/learning-object-service.js'; import learningObjectService from '../services/learning-objects/learning-object-service.js';
import { EnvVars, getEnvVar } from '../util/envvars.js'; import { EnvVars, getEnvVar } from '../util/envvars.js';
import { Language } from '../entities/content/language.js'; import { Language } from '../entities/content/language.js';
import { BadRequestException } from '../exceptions.js';
import attachmentService from '../services/learning-objects/attachment-service.js'; import attachmentService from '../services/learning-objects/attachment-service.js';
import { NotFoundError } from '@mikro-orm/core'; import { NotFoundError } from '@mikro-orm/core';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier {
if (!req.params.hruid) { if (!req.params.hruid) {
@ -40,7 +40,7 @@ export async function getAllLearningObjects(req: Request, res: Response): Promis
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId);
} }
res.json(learningObjects); res.json({ learningObjects: learningObjects });
} }
export async function getLearningObject(req: Request, res: Response): Promise<void> { export async function getLearningObject(req: Request, res: Response): Promise<void> {

View file

@ -2,13 +2,14 @@ import { Request, Response } from 'express';
import { themes } from '../data/themes.js'; import { themes } from '../data/themes.js';
import { FALLBACK_LANG } from '../config.js'; import { FALLBACK_LANG } from '../config.js';
import learningPathService from '../services/learning-paths/learning-path-service.js'; import learningPathService from '../services/learning-paths/learning-path-service.js';
import { BadRequestException, NotFoundException } from '../exceptions.js';
import { Language } from '../entities/content/language.js'; import { Language } from '../entities/content/language.js';
import { import {
PersonalizationTarget, PersonalizationTarget,
personalizedForGroup, personalizedForGroup,
personalizedForStudent, personalizedForStudent,
} from '../services/learning-paths/learning-path-personalization-util.js'; } from '../services/learning-paths/learning-path-personalization-util.js';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { NotFoundException } from '../exceptions/not-found-exception.js';
/** /**
* Fetch learning paths based on query parameters. * Fetch learning paths based on query parameters.

View file

@ -48,7 +48,7 @@ export async function getAllQuestionsHandler(req: Request, res: Response): Promi
if (!questions) { if (!questions) {
res.status(404).json({ error: `Questions not found.` }); res.status(404).json({ error: `Questions not found.` });
} else { } else {
res.json(questions); res.json({ questions: questions });
} }
} }
@ -76,12 +76,12 @@ export async function getQuestionAnswersHandler(req: Request, res: Response): Pr
return; return;
} }
const answers = getAnswersByQuestion(questionId, full); const answers = await getAnswersByQuestion(questionId, full);
if (!answers) { if (!answers) {
res.status(404).json({ error: `Questions not found.` }); res.status(404).json({ error: `Questions not found` });
} else { } else {
res.json(answers); res.json({ answers: answers });
} }
} }
@ -96,7 +96,7 @@ export async function createQuestionHandler(req: Request, res: Response): Promis
const question = await createQuestion(questionDTO); const question = await createQuestion(questionDTO);
if (!question) { if (!question) {
res.status(400).json({ error: 'Could not add question' }); res.status(400).json({ error: 'Could not create question' });
} else { } else {
res.json(question); res.json(question);
} }

View file

@ -93,9 +93,10 @@ export async function getStudentGroupsHandler(req: Request, res: Response): Prom
export async function getStudentSubmissionsHandler(req: Request, res: Response): Promise<void> { export async function getStudentSubmissionsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username; const username = req.params.username;
const full = req.query.full === 'true';
requireFields({ username }); requireFields({ username });
const submissions = await getStudentSubmissions(username); const submissions = await getStudentSubmissions(username, full);
res.json({ res.json({
submissions, submissions,

View file

@ -36,10 +36,11 @@ export async function createSubmissionHandler(req: Request, res: Response) {
const submission = await createSubmission(submissionDTO); const submission = await createSubmission(submissionDTO);
if (!submission) { if (!submission) {
res.status(404).json({ error: 'Submission not added' }); res.status(400).json({ error: 'Failed to create submission' });
} else { return;
res.json(submission);
} }
res.json(submission);
} }
export async function deleteSubmissionHandler(req: Request, res: Response) { export async function deleteSubmissionHandler(req: Request, res: Response) {
@ -53,7 +54,8 @@ export async function deleteSubmissionHandler(req: Request, res: Response) {
if (!submission) { if (!submission) {
res.status(404).json({ error: 'Submission not found' }); res.status(404).json({ error: 'Submission not found' });
} else { return;
res.json(submission);
} }
res.json(submission);
} }

View file

@ -1,10 +1,12 @@
import { EntityRepository, FilterQuery } from '@mikro-orm/core'; import { EntityRepository, FilterQuery } from '@mikro-orm/core';
import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception';
export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> { export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> {
public async save(entity: T) { public async save(entity: T, options?: { preventOverwrite?: boolean }): Promise<void> {
const em = this.getEntityManager(); if (options?.preventOverwrite && (await this.findOne(entity))) {
em.persist(entity); throw new EntityAlreadyExistsException(`A ${this.getEntityName()} with this identifier already exists.`);
await em.flush(); }
await this.getEntityManager().persistAndFlush(entity);
} }
public async deleteWhere(query: FilterQuery<T>) { public async deleteWhere(query: FilterQuery<T>) {
const toDelete = await this.findOne(query); const toDelete = await this.findOne(query);

View file

@ -2,8 +2,6 @@ import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-o
import { forkEntityManager } from '../orm.js'; import { forkEntityManager } from '../orm.js';
import { StudentRepository } from './users/student-repository.js'; import { StudentRepository } from './users/student-repository.js';
import { Student } from '../entities/users/student.entity.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 { Teacher } from '../entities/users/teacher.entity.js';
import { TeacherRepository } from './users/teacher-repository.js'; import { TeacherRepository } from './users/teacher-repository.js';
import { Class } from '../entities/classes/class.entity.js'; import { Class } from '../entities/classes/class.entity.js';

View file

@ -1,5 +1,4 @@
import { Student } from '../../entities/users/student.entity.js'; import { Student } from '../../entities/users/student.entity.js';
import { User } from '../../entities/users/user.entity.js';
import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
// Import { UserRepository } from './user-repository.js'; // Import { UserRepository } from './user-repository.js';

View file

@ -1,6 +1,5 @@
import { Teacher } from '../../entities/users/teacher.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js';
import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { UserRepository } from './user-repository.js';
export class TeacherRepository extends DwengoEntityRepository<Teacher> { export class TeacherRepository extends DwengoEntityRepository<Teacher> {
public findByUsername(username: string): Promise<Teacher | null> { public findByUsername(username: string): Promise<Teacher | null> {

View file

@ -1,4 +1,4 @@
import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { Class } from '../classes/class.entity.js'; import { Class } from '../classes/class.entity.js';
import { Group } from './group.entity.js'; import { Group } from './group.entity.js';
import { Language } from '../content/language.js'; import { Language } from '../content/language.js';

View file

@ -1,4 +1,4 @@
import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core';
import { Assignment } from './assignment.entity.js'; import { Assignment } from './assignment.entity.js';
import { Student } from '../users/student.entity.js'; import { Student } from '../users/student.entity.js';
import { GroupRepository } from '../../data/assignments/group-repository.js'; import { GroupRepository } from '../../data/assignments/group-repository.js';

View file

@ -3,6 +3,12 @@ import { Student } from '../users/student.entity.js';
import { Class } from './class.entity.js'; import { Class } from './class.entity.js';
import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js';
export enum ClassJoinRequestStatus {
Open = 'open',
Accepted = 'accepted',
Declined = 'declined',
}
@Entity({ @Entity({
repository: () => ClassJoinRequestRepository, repository: () => ClassJoinRequestRepository,
}) })
@ -22,9 +28,3 @@ export class ClassJoinRequest {
@Enum(() => ClassJoinRequestStatus) @Enum(() => ClassJoinRequestStatus)
status!: ClassJoinRequestStatus; status!: ClassJoinRequestStatus;
} }
export enum ClassJoinRequestStatus {
Open = 'open',
Accepted = 'accepted',
Declined = 'declined',
}

View file

@ -13,12 +13,4 @@ export class Student extends User {
@ManyToMany(() => Group) @ManyToMany(() => Group)
groups!: Collection<Group>; groups!: Collection<Group>;
constructor(
public username: string,
public firstName: string,
public lastName: string
) {
super();
}
} }

View file

@ -7,12 +7,4 @@ import { TeacherRepository } from '../../data/users/teacher-repository.js';
export class Teacher extends User { export class Teacher extends User {
@ManyToMany(() => Class) @ManyToMany(() => Class)
classes!: Collection<Class>; classes!: Collection<Class>;
constructor(
public username: string,
public firstName: string,
public lastName: string
) {
super();
}
} }

View file

@ -1,56 +0,0 @@
/**
* Exception for HTTP 400 Bad Request
*/
export class BadRequestException extends Error {
public status = 400;
constructor(error: string) {
super(error);
}
}
/**
* Exception for HTTP 401 Unauthorized
*/
export class UnauthorizedException extends Error {
status = 401;
constructor(message: string = 'Unauthorized') {
super(message);
}
}
/**
* Exception for HTTP 403 Forbidden
*/
export class ForbiddenException extends Error {
status = 403;
constructor(message: string = 'Forbidden') {
super(message);
}
}
/**
* Exception for HTTP 404 Not Found
*/
export class NotFoundException extends Error {
public status = 404;
constructor(error: string) {
super(error);
}
}
export class ConflictException extends Error {
public status = 409;
constructor(message: string = 'Conflict') {
super(message);
}
}
export class InternalServerError extends Error {
public status = 500;
constructor(message: string = 'Internal Server Error') {
super(message);
}
}

View file

@ -0,0 +1,10 @@
import { ExceptionWithHttpState } from './exception-with-http-state.js';
/**
* Exception for HTTP 400 Bad Request
*/
export class BadRequestException extends ExceptionWithHttpState {
constructor(error: string) {
super(400, error);
}
}

View file

@ -0,0 +1,12 @@
import { ExceptionWithHttpState } from './exception-with-http-state.js';
/**
* Exception for HTTP 409 Conflict
*/
export class ConflictException extends ExceptionWithHttpState {
public status = 409;
constructor(error: string) {
super(409, error);
}
}

View file

@ -0,0 +1,7 @@
import { ConflictException } from './conflict-exception';
export class EntityAlreadyExistsException extends ConflictException {
constructor(message: string) {
super(message);
}
}

View file

@ -0,0 +1,11 @@
/**
* Exceptions which are associated with a HTTP error code.
*/
export abstract class ExceptionWithHttpState extends Error {
constructor(
public status: number,
public error: string
) {
super(error);
}
}

View file

@ -0,0 +1,12 @@
import { ExceptionWithHttpState } from './exception-with-http-state.js';
/**
* Exception for HTTP 403 Forbidden
*/
export class ForbiddenException extends ExceptionWithHttpState {
status = 403;
constructor(message: string = 'Forbidden') {
super(403, message);
}
}

View file

@ -0,0 +1,12 @@
import { ExceptionWithHttpState } from './exception-with-http-state.js';
/**
* Exception for HTTP 404 Not Found
*/
export class NotFoundException extends ExceptionWithHttpState {
public status = 404;
constructor(error: string) {
super(404, error);
}
}

View file

@ -0,0 +1,10 @@
import { ExceptionWithHttpState } from './exception-with-http-state.js';
/**
* Exception for HTTP 401 Unauthorized
*/
export class UnauthorizedException extends ExceptionWithHttpState {
constructor(message: string = 'Unauthorized') {
super(401, message);
}
}

View file

@ -2,7 +2,7 @@ import { FALLBACK_LANG } from '../config.js';
import { Assignment } from '../entities/assignments/assignment.entity.js'; import { Assignment } from '../entities/assignments/assignment.entity.js';
import { Class } from '../entities/classes/class.entity.js'; import { Class } from '../entities/classes/class.entity.js';
import { languageMap } from '../entities/content/language.js'; import { languageMap } from '../entities/content/language.js';
import { GroupDTO, mapToGroupDTO } from './group.js'; import { GroupDTO } from './group.js';
export interface AssignmentDTO { export interface AssignmentDTO {
id: number; id: number;
@ -46,7 +46,5 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi
assignment.learningPathLanguage = languageMap[assignmentData.language] || FALLBACK_LANG; assignment.learningPathLanguage = languageMap[assignmentData.language] || FALLBACK_LANG;
assignment.within = cls; assignment.within = cls;
console.log(assignment);
return assignment; return assignment;
} }

View file

@ -9,12 +9,6 @@ export interface ClassDTO {
teachers: string[]; teachers: string[];
students: string[]; students: string[];
joinRequests: string[]; joinRequests: string[];
endpoints?: {
self: string;
invitations: string;
assignments: string;
students: string;
};
} }
export function mapToClassDTO(cls: Class): ClassDTO { export function mapToClassDTO(cls: Class): ClassDTO {

View file

@ -1,8 +1,6 @@
import { Question } from '../entities/questions/question.entity.js'; import { Question } from '../entities/questions/question.entity.js';
import { UserDTO } from './user.js';
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
import { mapToStudentDTO, StudentDTO } from './student.js'; import { mapToStudentDTO, StudentDTO } from './student.js';
import { TeacherDTO } from './teacher.js';
export interface QuestionDTO { export interface QuestionDTO {
learningObjectIdentifier: LearningObjectIdentifier; learningObjectIdentifier: LearningObjectIdentifier;

View file

@ -1,7 +1,8 @@
import {Student} from '../entities/users/student.entity.js'; import { Student } from '../entities/users/student.entity.js';
import { getStudentRepository } from '../data/repositories';
export interface StudentDTO { export interface StudentDTO {
id?: string; id: string;
username: string; username: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
@ -17,5 +18,9 @@ export function mapToStudentDTO(student: Student): StudentDTO {
} }
export function mapToStudent(studentData: StudentDTO): Student { export function mapToStudent(studentData: StudentDTO): Student {
return new Student(studentData.username, studentData.firstName, studentData.lastName); return getStudentRepository().create({
username: studentData.username,
firstName: studentData.firstName,
lastName: studentData.lastName,
});
} }

View file

@ -2,13 +2,10 @@ import { Submission } from '../entities/assignments/submission.entity.js';
import { Language } from '../entities/content/language.js'; import { Language } from '../entities/content/language.js';
import { GroupDTO, mapToGroupDTO } from './group.js'; import { GroupDTO, mapToGroupDTO } from './group.js';
import { mapToStudent, mapToStudentDTO, StudentDTO } from './student.js'; import { mapToStudent, mapToStudentDTO, StudentDTO } from './student.js';
import { mapToUser } from './user'; import { LearningObjectIdentifier } from './learning-content.js';
import { Student } from '../entities/users/student.entity';
export interface SubmissionDTO { export interface SubmissionDTO {
learningObjectHruid: string; learningObjectIdentifier: LearningObjectIdentifier;
learningObjectLanguage: Language;
learningObjectVersion: number;
submissionNumber?: number; submissionNumber?: number;
submitter: StudentDTO; submitter: StudentDTO;
@ -17,11 +14,21 @@ export interface SubmissionDTO {
content: string; content: string;
} }
export interface SubmissionDTOId {
learningObjectHruid: string;
learningObjectLanguage: Language;
learningObjectVersion: number;
submissionNumber?: number;
}
export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { export function mapToSubmissionDTO(submission: Submission): SubmissionDTO {
return { return {
learningObjectHruid: submission.learningObjectHruid, learningObjectIdentifier: {
learningObjectLanguage: submission.learningObjectLanguage, hruid: submission.learningObjectHruid,
learningObjectVersion: submission.learningObjectVersion, language: submission.learningObjectLanguage,
version: submission.learningObjectVersion,
},
submissionNumber: submission.submissionNumber, submissionNumber: submission.submissionNumber,
submitter: mapToStudentDTO(submission.submitter), submitter: mapToStudentDTO(submission.submitter),
@ -31,11 +38,21 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO {
}; };
} }
export function mapToSubmissionDTOId(submission: Submission): SubmissionDTOId {
return {
learningObjectHruid: submission.learningObjectHruid,
learningObjectLanguage: submission.learningObjectLanguage,
learningObjectVersion: submission.learningObjectVersion,
submissionNumber: submission.submissionNumber,
};
}
export function mapToSubmission(submissionDTO: SubmissionDTO): Submission { export function mapToSubmission(submissionDTO: SubmissionDTO): Submission {
const submission = new Submission(); const submission = new Submission();
submission.learningObjectHruid = submissionDTO.learningObjectHruid; submission.learningObjectHruid = submissionDTO.learningObjectIdentifier.hruid;
submission.learningObjectLanguage = submissionDTO.learningObjectLanguage; submission.learningObjectLanguage = submissionDTO.learningObjectIdentifier.language;
submission.learningObjectVersion = submissionDTO.learningObjectVersion; submission.learningObjectVersion = submissionDTO.learningObjectIdentifier.version!;
// Submission.submissionNumber = submissionDTO.submissionNumber; // Submission.submissionNumber = submissionDTO.submissionNumber;
submission.submitter = mapToStudent(submissionDTO.submitter); submission.submitter = mapToStudent(submissionDTO.submitter);
// Submission.submissionTime = submissionDTO.time; // Submission.submissionTime = submissionDTO.time;

View file

@ -1,4 +1,5 @@
import { Teacher } from '../entities/users/teacher.entity.js'; import { Teacher } from '../entities/users/teacher.entity.js';
import { getTeacherRepository } from '../data/repositories';
export interface TeacherDTO { export interface TeacherDTO {
id: string; id: string;
@ -22,8 +23,10 @@ export function mapToTeacherDTO(teacher: Teacher): TeacherDTO {
}; };
} }
export function mapToTeacher(TeacherData: TeacherDTO): Teacher { export function mapToTeacher(teacherData: TeacherDTO): Teacher {
const teacher = new Teacher(TeacherData.username, TeacherData.firstName, TeacherData.lastName); return getTeacherRepository().create({
username: teacherData.username,
return teacher; firstName: teacherData.firstName,
lastName: teacherData.lastName,
});
} }

View file

@ -1,7 +1,7 @@
import { createLogger, format, Logger as WinstonLogger, transports } from 'winston'; import { createLogger, format, Logger as WinstonLogger, transports } from 'winston';
import LokiTransport from 'winston-loki'; import LokiTransport from 'winston-loki';
import { LokiLabels } from 'loki-logger-ts'; import { LokiLabels } from 'loki-logger-ts';
import { LOG_LEVEL, LOKI_HOST } from '../config.js'; import { EnvVars, getEnvVar } from '../util/envvars.js';
export class Logger extends WinstonLogger { export class Logger extends WinstonLogger {
constructor() { constructor() {
@ -22,10 +22,25 @@ function initializeLogger(): Logger {
return logger; return logger;
} }
const logLevel = getEnvVar(EnvVars.LogLevel);
const consoleTransport = new transports.Console({
level: getEnvVar(EnvVars.LogLevel),
format: format.combine(format.cli(), format.colorize()),
});
if (getEnvVar(EnvVars.RunMode) === 'dev') {
return createLogger({
transports: [consoleTransport],
});
}
const lokiHost = getEnvVar(EnvVars.LokiHost);
const lokiTransport: LokiTransport = new LokiTransport({ const lokiTransport: LokiTransport = new LokiTransport({
host: LOKI_HOST, host: lokiHost,
labels: Labels, labels: Labels,
level: LOG_LEVEL, level: logLevel,
json: true, json: true,
format: format.combine(format.timestamp(), format.json()), format: format.combine(format.timestamp(), format.json()),
onConnectionError: (err) => { onConnectionError: (err) => {
@ -34,16 +49,11 @@ function initializeLogger(): Logger {
}, },
}); });
const consoleTransport = new transports.Console({
level: LOG_LEVEL,
format: format.combine(format.cli(), format.colorize()),
});
logger = createLogger({ logger = createLogger({
transports: [lokiTransport, consoleTransport], transports: [lokiTransport, consoleTransport],
}); });
logger.debug(`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`); logger.debug(`Logger initialized with level ${logLevel} to Loki host ${lokiHost}`);
return logger; return logger;
} }

View file

@ -6,7 +6,8 @@ import * as express from 'express';
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticatedRequest } from './authenticated-request.js';
import { AuthenticationInfo } from './authentication-info.js'; import { AuthenticationInfo } from './authentication-info.js';
import { ForbiddenException, UnauthorizedException } from '../../exceptions.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception';
import { ForbiddenException } from '../../exceptions/forbidden-exception';
const JWKS_CACHE = true; const JWKS_CACHE = true;
const JWKS_RATE_LIMIT = true; const JWKS_RATE_LIMIT = true;

View file

@ -0,0 +1,15 @@
import { NextFunction, Request, Response } from 'express';
import { getLogger, Logger } from '../../logging/initalize';
import { ExceptionWithHttpState } from '../../exceptions/exception-with-http-state';
const logger: Logger = getLogger();
export function errorHandler(err: unknown, req: Request, res: Response, _: NextFunction): void {
if (err instanceof ExceptionWithHttpState) {
logger.warn(`An error occurred while handling a request: ${err} (-> HTTP ${err.status})`);
res.status(err.status).json(err);
} else {
logger.error(`Unexpected error occurred while handing a request: ${JSON.stringify(err)}`);
res.status(500).json(err);
}
}

View file

@ -3,7 +3,6 @@ import { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { EnvVars, getEnvVar, getNumericEnvVar } from './util/envvars.js'; import { EnvVars, getEnvVar, getNumericEnvVar } from './util/envvars.js';
import { SqliteDriver } from '@mikro-orm/sqlite'; import { SqliteDriver } from '@mikro-orm/sqlite';
import { MikroOrmLogger } from './logging/mikroOrmLogger.js'; import { MikroOrmLogger } from './logging/mikroOrmLogger.js';
import { LOG_LEVEL } from './config.js';
// Import alle entity-bestanden handmatig // Import alle entity-bestanden handmatig
import { User } from './entities/users/user.entity.js'; import { User } from './entities/users/user.entity.js';
@ -66,10 +65,12 @@ function config(testingMode: boolean = false): Options {
user: getEnvVar(EnvVars.DbUsername), user: getEnvVar(EnvVars.DbUsername),
password: getEnvVar(EnvVars.DbPassword), password: getEnvVar(EnvVars.DbPassword),
entities: entities, entities: entities,
persistOnCreate: false, // Entities should not be implicitly persisted when calling create(...), but only after
// They were saved explicitly.
// EntitiesTs: entitiesTs, // EntitiesTs: entitiesTs,
// Logging // Logging
debug: LOG_LEVEL === 'debug', debug: getEnvVar(EnvVars.LogLevel) === 'debug',
loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options), loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options),
}; };
} }

View file

@ -1,11 +1,7 @@
import { Response, Router } from 'express'; import { Response, Router } from 'express';
import studentRouter from './students.js'; import studentRouter from './students.js';
import teacherRouter from './teachers.js'; import teacherRouter from './teachers.js';
import groupRouter from './groups.js';
import assignmentRouter from './assignments.js';
import submissionRouter from './submissions.js';
import classRouter from './classes.js'; import classRouter from './classes.js';
import questionRouter from './questions.js';
import authRouter from './auth.js'; import authRouter from './auth.js';
import themeRoutes from './themes.js'; import themeRoutes from './themes.js';
import learningPathRoutes from './learning-paths.js'; import learningPathRoutes from './learning-paths.js';
@ -24,11 +20,7 @@ router.get('/', (_, res: Response) => {
router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); router.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */);
router.use('/group', groupRouter /* #swagger.tags = ['Group'] */);
router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */);
router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */);
router.use('/class', classRouter /* #swagger.tags = ['Class'] */); router.use('/class', classRouter /* #swagger.tags = ['Class'] */);
router.use('/question', questionRouter /* #swagger.tags = ['Question'] */);
router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */);
router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */);
router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */);

View file

@ -1,7 +1,6 @@
import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js';
import { Assignment } from '../entities/assignments/assignment.entity.js';
import { AssignmentDTO, mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; import { AssignmentDTO, mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } from '../interfaces/submission.js';
export async function getAllAssignments(classid: string, full: boolean): Promise<AssignmentDTO[]> { export async function getAllAssignments(classid: string, full: boolean): Promise<AssignmentDTO[]> {
const classRepository = getClassRepository(); const classRepository = getClassRepository();
@ -21,7 +20,7 @@ export async function getAllAssignments(classid: string, full: boolean): Promise
return assignments.map(mapToAssignmentDTOId); return assignments.map(mapToAssignmentDTOId);
} }
export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise<Assignment | null> { export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise<AssignmentDTO | null> {
const classRepository = getClassRepository(); const classRepository = getClassRepository();
const cls = await classRepository.findById(classid); const cls = await classRepository.findById(classid);
@ -36,8 +35,9 @@ export async function createAssignment(classid: string, assignmentData: Assignme
const newAssignment = assignmentRepository.create(assignment); const newAssignment = assignmentRepository.create(assignment);
await assignmentRepository.save(newAssignment); await assignmentRepository.save(newAssignment);
return newAssignment; return mapToAssignmentDTO(newAssignment);
} catch (e) { } catch (e) {
console.error(e);
return null; return null;
} }
} }
@ -60,7 +60,11 @@ export async function getAssignment(classid: string, id: number): Promise<Assign
return mapToAssignmentDTO(assignment); return mapToAssignmentDTO(assignment);
} }
export async function getAssignmentsSubmissions(classid: string, assignmentNumber: number): Promise<SubmissionDTO[]> { export async function getAssignmentsSubmissions(
classid: string,
assignmentNumber: number,
full: boolean
): Promise<SubmissionDTO[] | SubmissionDTOId[]> {
const classRepository = getClassRepository(); const classRepository = getClassRepository();
const cls = await classRepository.findById(classid); const cls = await classRepository.findById(classid);
@ -81,5 +85,9 @@ export async function getAssignmentsSubmissions(classid: string, assignmentNumbe
const submissionRepository = getSubmissionRepository(); const submissionRepository = getSubmissionRepository();
const submissions = (await Promise.all(groups.map((group) => submissionRepository.findAllSubmissionsForGroup(group)))).flat(); const submissions = (await Promise.all(groups.map((group) => submissionRepository.findAllSubmissionsForGroup(group)))).flat();
return submissions.map(mapToSubmissionDTO); if (full) {
return submissions.map(mapToSubmissionDTO);
}
return submissions.map(mapToSubmissionDTOId);
} }

View file

@ -1,5 +1,4 @@
import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js'; import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js';
import { Class } from '../entities/classes/class.entity.js';
import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; import { ClassDTO, mapToClassDTO } from '../interfaces/class.js';
import { mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; import { mapToStudentDTO, StudentDTO } from '../interfaces/student.js';
import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds, TeacherInvitationDTO } from '../interfaces/teacher-invitation.js'; import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds, TeacherInvitationDTO } from '../interfaces/teacher-invitation.js';
@ -21,16 +20,14 @@ export async function getAllClasses(full: boolean): Promise<ClassDTO[] | string[
return classes.map((cls) => cls.classId!); return classes.map((cls) => cls.classId!);
} }
export async function createClass(classData: ClassDTO): Promise<Class | null> { export async function createClass(classData: ClassDTO): Promise<ClassDTO | null> {
const teacherRepository = getTeacherRepository(); const teacherRepository = getTeacherRepository();
const teacherUsernames = classData.teachers || []; const teacherUsernames = classData.teachers || [];
const teachers = (await Promise.all(teacherUsernames.map((id) => teacherRepository.findByUsername(id)))).filter((teacher) => teacher != null); const teachers = (await Promise.all(teacherUsernames.map((id) => teacherRepository.findByUsername(id)))).filter((teacher) => teacher !== null);
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
const studentUsernames = classData.students || []; const studentUsernames = classData.students || [];
const students = (await Promise.all(studentUsernames.map((id) => studentRepository.findByUsername(id)))).filter((student) => student != null); const students = (await Promise.all(studentUsernames.map((id) => studentRepository.findByUsername(id)))).filter((student) => student !== null);
//Const cls = mapToClass(classData, teachers, students);
const classRepository = getClassRepository(); const classRepository = getClassRepository();
@ -42,7 +39,7 @@ export async function createClass(classData: ClassDTO): Promise<Class | null> {
}); });
await classRepository.save(newClass); await classRepository.save(newClass);
return newClass; return mapToClassDTO(newClass);
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);
return null; return null;

View file

@ -1,4 +1,3 @@
import { GroupRepository } from '../data/assignments/group-repository.js';
import { import {
getAssignmentRepository, getAssignmentRepository,
getClassRepository, getClassRepository,
@ -8,7 +7,7 @@ import {
} from '../data/repositories.js'; } from '../data/repositories.js';
import { Group } from '../entities/assignments/group.entity.js'; import { Group } from '../entities/assignments/group.entity.js';
import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js';
import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } from '../interfaces/submission.js';
export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number, full: boolean): Promise<GroupDTO | null> { export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number, full: boolean): Promise<GroupDTO | null> {
const classRepository = getClassRepository(); const classRepository = getClassRepository();
@ -43,7 +42,7 @@ export async function createGroup(groupData: GroupDTO, classid: string, assignme
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
const memberUsernames = (groupData.members as string[]) || []; // TODO check if groupdata.members is a list const memberUsernames = (groupData.members as string[]) || []; // TODO check if groupdata.members is a list
const members = (await Promise.all([...memberUsernames].map((id) => studentRepository.findByUsername(id)))).filter((student) => student != null); const members = (await Promise.all([...memberUsernames].map((id) => studentRepository.findByUsername(id)))).filter((student) => student !== null);
console.log(members); console.log(members);
@ -103,7 +102,12 @@ export async function getAllGroups(classId: string, assignmentNumber: number, fu
return groups.map(mapToGroupDTOId); return groups.map(mapToGroupDTOId);
} }
export async function getGroupSubmissions(classId: string, assignmentNumber: number, groupNumber: number): Promise<SubmissionDTO[]> { export async function getGroupSubmissions(
classId: string,
assignmentNumber: number,
groupNumber: number,
full: boolean
): Promise<SubmissionDTO[] | SubmissionDTOId[]> {
const classRepository = getClassRepository(); const classRepository = getClassRepository();
const cls = await classRepository.findById(classId); const cls = await classRepository.findById(classId);
@ -128,5 +132,9 @@ export async function getGroupSubmissions(classId: string, assignmentNumber: num
const submissionRepository = getSubmissionRepository(); const submissionRepository = getSubmissionRepository();
const submissions = await submissionRepository.findAllSubmissionsForGroup(group); const submissions = await submissionRepository.findAllSubmissionsForGroup(group);
return submissions.map(mapToSubmissionDTO); if (full) {
return submissions.map(mapToSubmissionDTO);
}
return submissions.map(mapToSubmissionDTOId);
} }

View file

@ -45,6 +45,13 @@ export async function getLearningObjectById(hruid: string, language: string): Pr
return filterData(metadata, htmlUrl); return filterData(metadata, htmlUrl);
} }
/**
* Generic function to fetch learning paths
*/
function fetchLearningPaths(arg0: string[], language: string, arg2: string): LearningPathResponse | PromiseLike<LearningPathResponse> {
throw new Error('Function not implemented.');
}
/** /**
* Generic function to fetch learning objects (full data or just HRUIDs) * Generic function to fetch learning objects (full data or just HRUIDs)
*/ */
@ -85,6 +92,3 @@ export async function getLearningObjectsFromPath(hruid: string, language: string
export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise<string[]> { export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise<string[]> {
return (await fetchLearningObjects(hruid, false, language)) as string[]; return (await fetchLearningObjects(hruid, false, language)) as string[];
} }
function fetchLearningPaths(arg0: string[], language: string, arg2: string): LearningPathResponse | PromiseLike<LearningPathResponse> {
throw new Error('Function not implemented.');
}

View file

@ -103,5 +103,5 @@ export async function deleteQuestion(questionId: QuestionId) {
return null; return null;
} }
return question; return mapToQuestionDTO(question);
} }

View file

@ -1,21 +1,13 @@
import { import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js';
getClassJoinRequestRepository,
getClassRepository,
getGroupRepository,
getQuestionRepository,
getStudentRepository,
getSubmissionRepository,
} from '../data/repositories.js';
import { AssignmentDTO } from '../interfaces/assignment.js'; import { AssignmentDTO } from '../interfaces/assignment.js';
import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; import { ClassDTO, mapToClassDTO } from '../interfaces/class.js';
import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js';
import { mapToStudent, mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; import { mapToStudent, mapToStudentDTO, StudentDTO } from '../interfaces/student.js';
import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; import { mapToSubmissionDTO, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } from '../interfaces/submission.js';
import { getAllAssignments } from './assignments.js'; import { getAllAssignments } from './assignments.js';
import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question'; import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question';
import {ClassJoinRequestStatus} from "../entities/classes/class-join-request.entity"; import {ClassJoinRequestStatus} from "../entities/classes/class-join-request.entity";
import {ConflictException, NotFoundException} from "../exceptions"; import {ConflictException, NotFoundException} from "../exceptions";
import {Student} from "../entities/users/student.entity";
import {mapToStudentRequestDTO} from "../interfaces/student-request"; import {mapToStudentRequestDTO} from "../interfaces/student-request";
export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
@ -47,14 +39,8 @@ export async function getStudent(username: string): Promise<StudentDTO> {
export async function createStudent(userData: StudentDTO): Promise<void> { export async function createStudent(userData: StudentDTO): Promise<void> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
const user = await studentRepository.findByUsername(userData.username); const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: true });
if (user) {
throw new ConflictException("Student with that sername already exists");
}
const newStudent = studentRepository.create(mapToStudent(userData));
await studentRepository.save(newStudent);
} }
export async function deleteStudent(username: string): Promise<void> { export async function deleteStudent(username: string): Promise<void> {
@ -100,82 +86,15 @@ export async function getStudentGroups(username: string, full: boolean): Promise
return groups.map(mapToGroupDTOId); return groups.map(mapToGroupDTOId);
} }
export async function getStudentSubmissions(username: string): Promise<SubmissionDTO[]> { export async function getStudentSubmissions(username: string, full: boolean): Promise<SubmissionDTO[] | SubmissionDTOId[]> {
const student = await fetchStudent(username); const student = await fetchStudent(username);
const submissionRepository = getSubmissionRepository(); const submissionRepository = getSubmissionRepository();
const submissions = await submissionRepository.findAllSubmissionsForStudent(student); const submissions = await submissionRepository.findAllSubmissionsForStudent(student);
return submissions.map(mapToSubmissionDTO); if (full) {
} return submissions.map(mapToSubmissionDTO);
export async function getStudentQuestions(username: string, full: boolean): Promise<QuestionDTO[] | QuestionId[]> {
const student = await fetchStudent(username);
const questionRepository = getQuestionRepository();
const questions = await questionRepository.findAllByAuthor(student);
const questionsDTO = questions.map(mapToQuestionDTO);
if (full)
return questionsDTO;
return questionsDTO.map(mapToQuestionId);
}
export async function createClassJoinRequest(studentUsername: string, classId: string) {
const classRepo = getClassRepository();
const requestRepo = getClassJoinRequestRepository();
const student = await fetchStudent(studentUsername);
const cls = await classRepo.findById(classId);
if (!cls){
throw new NotFoundException("Class with id not found");
} }
const req = await requestRepo.findByStudentAndClass(student, cls); return submissions.map(mapToSubmissionDTOId);
if (req){
throw new ConflictException("Request with student and class already exist");
}
const request = requestRepo.create({
requester: student,
class: cls,
status: ClassJoinRequestStatus.Open,
});
await requestRepo.save(request);
} }
export async function getJoinRequestsByStudent(studentUsername: string) {
const requestRepo = getClassJoinRequestRepository();
const student = await fetchStudent(studentUsername);
const requests = await requestRepo.findAllRequestsBy(student);
return requests.map(mapToStudentRequestDTO);
}
export async function deleteClassJoinRequest(studentUsername: string, classId: string) {
const requestRepo = getClassJoinRequestRepository();
const classRepo = getClassRepository();
const student = await fetchStudent(studentUsername);
const cls = await classRepo.findById(classId);
if (!cls) {
throw new NotFoundException('Class not found');
}
const request = await requestRepo.findByStudentAndClass(student, cls);
if (!request) {
throw new NotFoundException('Join request not found');
}
await requestRepo.deleteBy(student, cls);
}

View file

@ -32,7 +32,7 @@ export async function createSubmission(submissionDTO: SubmissionDTO) {
return null; return null;
} }
return submission; return mapToSubmissionDTO(submission);
} }
export async function deleteSubmission(learningObjectHruid: string, language: Language, version: number, submissionNumber: number) { export async function deleteSubmission(learningObjectHruid: string, language: Language, version: number, submissionNumber: number) {

View file

@ -54,14 +54,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> {
export async function createTeacher(userData: TeacherDTO): Promise<void> { export async function createTeacher(userData: TeacherDTO): Promise<void> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
const user: Teacher | null = await teacherRepository.findByUsername(userData.username); const newTeacher = mapToTeacher(userData);
await teacherRepository.save(newTeacher, { preventOverwrite: true });
if (user){
throw new ConflictException("Teacher with that username already exists");
}
const newTeacher: Teacher = teacherRepository.create(mapToTeacher(userData));
await teacherRepository.save(newTeacher);
} }
export async function deleteTeacher(username: string): Promise<void> { export async function deleteTeacher(username: string): Promise<void> {

View file

@ -1,41 +0,0 @@
import { UserRepository } from '../data/users/user-repository.js';
import { UserDTO, mapToUser, mapToUserDTO } from '../interfaces/user.js';
import { User } from '../entities/users/user.entity.js';
export class UserService<T extends User> {
protected repository: UserRepository<T>;
constructor(repository: UserRepository<T>) {
this.repository = repository;
}
async getAllUsers(): Promise<UserDTO[]> {
const users = await this.repository.findAll();
return users.map(mapToUserDTO);
}
async getAllUserIds(): Promise<string[]> {
const users = await this.getAllUsers();
return users.map((user) => user.username);
}
async getUserByUsername(username: string): Promise<UserDTO | null> {
const user = await this.repository.findByUsername(username);
return user ? mapToUserDTO(user) : null;
}
async createUser(userData: UserDTO, UserClass: new () => T): Promise<T> {
const newUser = mapToUser(userData, new UserClass());
await this.repository.save(newUser);
return newUser;
}
async deleteUser(username: string): Promise<UserDTO | null> {
const user = await this.getUserByUsername(username);
if (!user) {
return null;
}
await this.repository.deleteByUsername(username);
return mapToUserDTO(user);
}
}

View file

@ -4,20 +4,24 @@ const IDP_PREFIX = PREFIX + 'AUTH_';
const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_'; const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_';
const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_'; const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_';
const CORS_PREFIX = PREFIX + 'CORS_'; const CORS_PREFIX = PREFIX + 'CORS_';
const LOGGING_PREFIX = PREFIX + 'LOGGING_';
type EnvVar = { key: string; required?: boolean; defaultValue?: any }; type EnvVar = { key: string; required?: boolean; defaultValue?: any };
export const EnvVars: { [key: string]: EnvVar } = { export const EnvVars: { [key: string]: EnvVar } = {
Port: { key: PREFIX + 'PORT', defaultValue: 3000 }, Port: { key: PREFIX + 'PORT', defaultValue: 3000 },
LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' },
FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' },
RunMode: { key: PREFIX + 'RUN_MODE', defaultValue: 'dev' },
DbHost: { key: DB_PREFIX + 'HOST', required: true }, DbHost: { key: DB_PREFIX + 'HOST', required: true },
DbPort: { key: DB_PREFIX + 'PORT', defaultValue: 5432 }, DbPort: { key: DB_PREFIX + 'PORT', defaultValue: 5432 },
DbName: { key: DB_PREFIX + 'NAME', defaultValue: 'dwengo' }, DbName: { key: DB_PREFIX + 'NAME', defaultValue: 'dwengo' },
DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, DbUsername: { key: DB_PREFIX + 'USERNAME', required: true },
DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true },
DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false },
LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' },
FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' },
UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' }, UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' },
IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true },
IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true },
IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true },
@ -25,8 +29,12 @@ export const EnvVars: { [key: string]: EnvVar } = {
IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true }, IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true },
IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true },
IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' }, IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' },
CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' }, CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' },
CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' }, CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' },
LogLevel: { key: LOGGING_PREFIX + 'LEVEL', defaultValue: 'info' },
LokiHost: { key: LOGGING_PREFIX + 'LOKI_HOST', defaultValue: 'http://localhost:3102' },
} as const; } as const;
/** /**

View file

@ -8,12 +8,12 @@ const logger: Logger = getLogger();
export function loadTranslations<T>(language: string): T { export function loadTranslations<T>(language: string): T {
try { try {
const filePath = path.join(process.cwd(), '_i18n', `${language}.yml`); const filePath = path.join(process.cwd(), 'i18n', `${language}.yml`);
const yamlFile = fs.readFileSync(filePath, 'utf8'); const yamlFile = fs.readFileSync(filePath, 'utf8');
return yaml.load(yamlFile) as T; return yaml.load(yamlFile) as T;
} catch (error) { } catch (error) {
logger.warn(`Cannot load translation for ${language}, fallen back to dutch`, error); logger.warn(`Cannot load translation for ${language}, fallen back to dutch`, error);
const fallbackPath = path.join(process.cwd(), '_i18n', `${FALLBACK_LANG}.yml`); const fallbackPath = path.join(process.cwd(), 'i18n', `${FALLBACK_LANG}.yml`);
return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T; return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T;
} }
} }

View file

@ -1,5 +1,4 @@
import { setupTestApp } from '../../setup-tests.js'; import { setupTestApp } from '../../setup-tests.js';
import { Student } from '../../../src/entities/users/student.entity.js';
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll } from 'vitest';
import { StudentRepository } from '../../../src/data/users/student-repository.js'; import { StudentRepository } from '../../../src/data/users/student-repository.js';
import { getStudentRepository } from '../../../src/data/repositories.js'; import { getStudentRepository } from '../../../src/data/repositories.js';
@ -30,7 +29,7 @@ describe('StudentRepository', () => {
}); });
it('should return the queried student after he was added', async () => { it('should return the queried student after he was added', async () => {
await studentRepository.insert(new Student(username, firstName, lastName)); await studentRepository.insert(studentRepository.create({ username, firstName, lastName }));
const retrievedStudent = await studentRepository.findByUsername(username); const retrievedStudent = await studentRepository.findByUsername(username);
expect(retrievedStudent).toBeTruthy(); expect(retrievedStudent).toBeTruthy();

View file

@ -2,7 +2,6 @@ import { describe, it, expect, beforeAll } from 'vitest';
import { TeacherRepository } from '../../../src/data/users/teacher-repository'; import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { setupTestApp } from '../../setup-tests'; import { setupTestApp } from '../../setup-tests';
import { getTeacherRepository } from '../../../src/data/repositories'; import { getTeacherRepository } from '../../../src/data/repositories';
import { Teacher } from '../../../src/entities/users/teacher.entity';
const username = 'testteacher'; const username = 'testteacher';
const firstName = 'John'; const firstName = 'John';
@ -30,7 +29,7 @@ describe('TeacherRepository', () => {
}); });
it('should return the queried teacher after he was added', async () => { it('should return the queried teacher after he was added', async () => {
await teacherRepository.insert(new Teacher(username, firstName, lastName)); await teacherRepository.insert(teacherRepository.create({ username, firstName, lastName }));
const retrievedTeacher = await teacherRepository.findByUsername(username); const retrievedTeacher = await teacherRepository.findByUsername(username);
expect(retrievedTeacher).toBeTruthy(); expect(retrievedTeacher).toBeTruthy();

View file

@ -1,7 +1,8 @@
# #
# This file is used to define the production environment for the project. # Use this configuration to deploy the project on a server.
# It is used to deploy the project on a server. #
# Should not be used for local development. # This configuration builds the frontend and backend services as Docker images,
# and uses the paths for the services, instead of ports, and enables SSL.
# #
services: services:
web: web:
@ -35,12 +36,16 @@ services:
- 'traefik.http.services.api.loadbalancer.server.port=3000' - 'traefik.http.services.api.loadbalancer.server.port=3000'
db: db:
# Also see compose.yml extends:
file: ./compose.yml
service: db
networks: networks:
- dwengo-1 - dwengo-1
idp: idp:
# Also see compose.yml extends:
file: ./compose.yml
service: idp
# TODO Replace with proper production command # TODO Replace with proper production command
command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm'] command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm']
networks: networks:
@ -92,7 +97,15 @@ services:
- dwengo-1 - dwengo-1
logging: logging:
# Also see compose.yml image: grafana/loki:latest
ports:
- '9001:3102'
- '9095:9095'
command: -config.file=/etc/loki/config.yaml
restart: unless-stopped
volumes:
- ./config/loki/config.yml:/etc/loki/config.yaml
- dwengo_loki_data:/loki
networks: networks:
- dwengo-1 - dwengo-1
@ -107,6 +120,7 @@ services:
volumes: volumes:
dwengo_grafana_data: dwengo_grafana_data:
dwengo_letsencrypt: dwengo_letsencrypt:
dwengo_loki_data:
networks: networks:
dwengo-1: dwengo-1:

View file

@ -32,8 +32,15 @@ services:
- 'traefik.http.routers.api.rule=PathPrefix(`/api`)' - 'traefik.http.routers.api.rule=PathPrefix(`/api`)'
- 'traefik.http.services.api.loadbalancer.server.port=3000' - 'traefik.http.services.api.loadbalancer.server.port=3000'
db:
extends:
file: ./compose.yml
service: db
idp: idp:
# Also see compose.yml extends:
file: ./compose.yml
service: idp
labels: labels:
- 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)'
- 'traefik.http.services.idp.loadbalancer.server.port=7080' - 'traefik.http.services.idp.loadbalancer.server.port=7080'
@ -60,6 +67,17 @@ services:
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
logging:
image: grafana/loki:latest
ports:
- '9001:3102'
- '9095:9095'
command: -config.file=/etc/loki/config.yaml
restart: unless-stopped
volumes:
- ./config/loki/config.yml:/etc/loki/config.yaml
- dwengo_loki_data:/loki
dashboards: dashboards:
image: grafana/grafana:latest image: grafana/grafana:latest
ports: ports:
@ -70,3 +88,5 @@ services:
volumes: volumes:
dwengo_grafana_data: dwengo_grafana_data:
dwengo_loki_data:
dwengo_postgres_data:

View file

@ -36,17 +36,5 @@ services:
KC_HEALTH_ENABLED: 'true' KC_HEALTH_ENABLED: 'true'
KC_LOG_LEVEL: info KC_LOG_LEVEL: info
logging:
image: grafana/loki:latest
ports:
- '9001:3102'
- '9095:9095'
command: -config.file=/etc/loki/config.yaml
restart: unless-stopped
volumes:
- ./config/loki/config.yml:/etc/loki/config.yaml
- dwengo_loki_data:/loki
volumes: volumes:
dwengo_loki_data:
dwengo_postgres_data: dwengo_postgres_data:

View file

@ -19,7 +19,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup ## Project Setup
```sh ```sh
# Install dependencies
npm install npm install
# Start necessary services for development
cd ../ # Go to the root of the repository
docker compose up -d
# Start the backend
cd backend
cp .env.development.example .env.development.local
npm run dev # or npm run build && npm run start
``` ```
### Compile and Hot-Reload for Development ### Compile and Hot-Reload for Development

View file

@ -1,47 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
import ThemeCard from "@/components/ThemeCard.vue"; import ThemeCard from "@/components/ThemeCard.vue";
import { ref, watchEffect, computed } from "vue"; import { ref, watchEffect, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts"; import { AGE_TO_THEMES, THEMESITEMS } from "@/utils/constants.ts";
import { useThemeQuery } from "@/queries/themes.ts"; import { useThemeQuery } from "@/queries/themes.ts";
const props = defineProps({ const props = defineProps({
selectedTheme: { type: String, required: true }, selectedTheme: { type: String, required: true },
selectedAge: { type: String, required: true } selectedAge: { type: String, required: true },
}); });
const { locale } = useI18n(); const { locale } = useI18n();
const language = computed(() => locale.value); const language = computed(() => locale.value);
const { data: allThemes, isLoading, error } = useThemeQuery(language); const { data: allThemes, isLoading, error } = useThemeQuery(language);
const allCards = ref([]); const allCards = ref([]);
const cards = ref([]); const cards = ref([]);
watchEffect(() => { watchEffect(() => {
const themes = allThemes.value ?? []; const themes = allThemes.value ?? [];
allCards.value = themes; allCards.value = themes;
if (props.selectedTheme) { if (props.selectedTheme) {
cards.value = themes.filter((theme) => cards.value = themes.filter(
THEMESITEMS[props.selectedTheme]?.includes(theme.key) && (theme) =>
AGE_TO_THEMES[props.selectedAge]?.includes(theme.key) THEMESITEMS[props.selectedTheme]?.includes(theme.key) &&
); AGE_TO_THEMES[props.selectedAge]?.includes(theme.key),
} else { );
cards.value = themes; } else {
} cards.value = themes;
}); }
});
</script> </script>
<template> <template>
<v-container> <v-container>
<div v-if="isLoading" class="text-center py-10"> <div
<v-progress-circular indeterminate color="primary" /> v-if="isLoading"
class="text-center py-10"
>
<v-progress-circular
indeterminate
color="primary"
/>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
<div v-else-if="error" class="text-center py-10 text-error"> <div
v-else-if="error"
class="text-center py-10 text-error"
>
<v-icon large>mdi-alert-circle</v-icon> <v-icon large>mdi-alert-circle</v-icon>
<p>Error loading: {{ error.message }}</p> <p>Error loading: {{ error.message }}</p>
</div> </div>

View file

@ -22,7 +22,7 @@
{ name: "English", code: "en" }, { name: "English", code: "en" },
{ name: "Nederlands", code: "nl" }, { name: "Nederlands", code: "nl" },
{ name: "Français", code: "fr" }, { name: "Français", code: "fr" },
{ name: "Deutsch", code: "de" } { name: "Deutsch", code: "de" },
]); ]);
// Logic to change the language of the website to the selected language // Logic to change the language of the website to the selected language

View file

@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
const { t } = useI18n(); const { t } = useI18n();
defineProps<{ defineProps<{
path: string; path: string;
title: string; title: string;
description: string; description: string;
image: string; image: string;
}>(); }>();
</script> </script>
<template> <template>
@ -31,7 +31,10 @@ defineProps<{
</v-card-title> </v-card-title>
<v-card-text class="description flex-grow-1">{{ description }}</v-card-text> <v-card-text class="description flex-grow-1">{{ description }}</v-card-text>
<v-card-actions> <v-card-actions>
<v-btn :to="`theme/${path}`" variant="text"> <v-btn
:to="`theme/${path}`"
variant="text"
>
{{ t("read-more") }} {{ t("read-more") }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
@ -39,36 +42,36 @@ defineProps<{
</template> </template>
<style scoped> <style scoped>
.theme-card { .theme-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
padding: 1rem; padding: 1rem;
cursor: pointer; cursor: pointer;
} }
.theme-card:hover { .theme-card:hover {
background-color: rgba(0, 0, 0, 0.03); background-color: rgba(0, 0, 0, 0.03);
} }
.title-container { .title-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
text-align: left; text-align: left;
justify-content: flex-start; justify-content: flex-start;
} }
.title-image { .title-image {
flex-shrink: 0; flex-shrink: 0;
border-radius: 5px; border-radius: 5px;
margin-left: 0; margin-left: 0;
} }
.title { .title {
flex-grow: 1; flex-grow: 1;
white-space: normal; white-space: normal;
overflow-wrap: break-word; overflow-wrap: break-word;
word-break: break-word; word-break: break-word;
} }
</style> </style>

View file

@ -1,4 +1,4 @@
import {apiConfig} from "@/config.ts"; import { apiConfig } from "@/config.ts";
export class BaseController { export class BaseController {
protected baseUrl: string; protected baseUrl: string;

View file

@ -1,6 +1,5 @@
import { StudentController } from "@/controllers/students.ts"; import { StudentController } from "@/controllers/students.ts";
import { TeacherController } from "@/controllers/teachers.ts"; import { TeacherController } from "@/controllers/teachers.ts";
import {ThemeController} from "@/controllers/themes.ts"; import {ThemeController} from "@/controllers/themes.ts";
export function controllerGetter<T>(Factory: new () => T): () => T { export function controllerGetter<T>(Factory: new () => T): () => T {

View file

@ -1,4 +1,4 @@
import {BaseController} from "@/controllers/base-controller.ts"; import { BaseController } from "@/controllers/base-controller.ts";
export class ThemeController extends BaseController { export class ThemeController extends BaseController {
constructor() { constructor() {

View file

@ -10,7 +10,7 @@ import i18n from "./i18n/i18n.ts";
// Components // Components
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";
const app = createApp(App); const app = createApp(App);

View file

@ -1,25 +1,22 @@
import { useQuery } from '@tanstack/vue-query'; import { useQuery } from "@tanstack/vue-query";
import { getThemeController } from '@/controllers/controllers'; import { getThemeController } from "@/controllers/controllers";
import {type MaybeRefOrGetter, toValue} from "vue"; import { type MaybeRefOrGetter, toValue } from "vue";
const themeController = getThemeController(); const themeController = getThemeController();
export const useThemeQuery = (language: MaybeRefOrGetter<string>) => { export const useThemeQuery = (language: MaybeRefOrGetter<string>) =>
return useQuery({ useQuery({
queryKey: ['themes', language], queryKey: ["themes", language],
queryFn: () => { queryFn: () => {
const lang = toValue(language); const lang = toValue(language);
return themeController.getAll(lang); return themeController.getAll(lang);
}, },
enabled: () => !!toValue(language), enabled: () => Boolean(toValue(language)),
}); });
};
export const useThemeHruidsQuery = (themeKey: string | null) => { export const useThemeHruidsQuery = (themeKey: string | null) =>
return useQuery({ useQuery({
queryKey: ['theme-hruids', themeKey], queryKey: ["theme-hruids", themeKey],
queryFn: () => themeController.getHruidsByKey(themeKey!), queryFn: () => themeController.getHruidsByKey(themeKey!),
enabled: !!themeKey, enabled: Boolean(themeKey),
}); });
};

View file

@ -1,37 +1,64 @@
export const THEMES_KEYS = [ export const THEMES_KEYS = [
"kiks", "art", "socialrobot", "agriculture", "wegostem", "kiks",
"computational_thinking", "math_with_python", "python_programming", "art",
"stem", "care", "chatbot", "physical_computing", "algorithms", "basics_ai" "socialrobot",
"agriculture",
"wegostem",
"computational_thinking",
"math_with_python",
"python_programming",
"stem",
"care",
"chatbot",
"physical_computing",
"algorithms",
"basics_ai",
]; ];
export const THEMESITEMS: Record<string, string[]> = { export const THEMESITEMS: Record<string, string[]> = {
"all": THEMES_KEYS, all: THEMES_KEYS,
"culture": ["art", "wegostem", "chatbot"], culture: ["art", "wegostem", "chatbot"],
"electricity-and-mechanics": ["socialrobot", "wegostem", "stem", "physical_computing"], "electricity-and-mechanics": ["socialrobot", "wegostem", "stem", "physical_computing"],
"nature-and-climate": ["kiks", "agriculture"], "nature-and-climate": ["kiks", "agriculture"],
"agriculture": ["agriculture"], agriculture: ["agriculture"],
"society": ["kiks", "socialrobot", "care", "chatbot"], society: ["kiks", "socialrobot", "care", "chatbot"],
"math": ["kiks", "math_with_python", "python_programming", "stem", "care", "basics_ai"], math: ["kiks", "math_with_python", "python_programming", "stem", "care", "basics_ai"],
"technology": ["socialrobot", "wegostem", "computational_thinking", "stem", "physical_computing", "basics_ai"], technology: ["socialrobot", "wegostem", "computational_thinking", "stem", "physical_computing", "basics_ai"],
"algorithms": ["math_with_python", "python_programming", "stem", "algorithms", "basics_ai"], algorithms: ["math_with_python", "python_programming", "stem", "algorithms", "basics_ai"],
}; };
export const AGEITEMS = [ export const AGEITEMS = ["all", "primary-school", "lower-secondary", "upper-secondary", "high-school", "older"];
"all", "primary-school", "lower-secondary", "upper-secondary", "high-school", "older"
];
export const AGE_TO_THEMES: Record<string, string[]> = { export const AGE_TO_THEMES: Record<string, string[]> = {
"all": THEMES_KEYS, all: THEMES_KEYS,
"primary-school": ["wegostem", "computational_thinking", "physical_computing"], "primary-school": ["wegostem", "computational_thinking", "physical_computing"],
"lower-secondary": ["socialrobot", "art", "wegostem", "computational_thinking", "physical_computing"], "lower-secondary": ["socialrobot", "art", "wegostem", "computational_thinking", "physical_computing"],
"upper-secondary": ["kiks", "art", "socialrobot", "agriculture", "upper-secondary": [
"computational_thinking", "math_with_python", "python_programming", "kiks",
"stem", "care", "chatbot", "algorithms", "basics_ai"], "art",
"high-school": [ "socialrobot",
"kiks", "art", "agriculture", "computational_thinking", "math_with_python", "python_programming", "agriculture",
"stem", "care", "chatbot", "algorithms", "basics_ai" "computational_thinking",
"math_with_python",
"python_programming",
"stem",
"care",
"chatbot",
"algorithms",
"basics_ai",
], ],
"older": [ "high-school": [
"kiks", "computational_thinking", "algorithms", "basics_ai" "kiks",
] "art",
"agriculture",
"computational_thinking",
"math_with_python",
"python_programming",
"stem",
"care",
"chatbot",
"algorithms",
"basics_ai",
],
older: ["kiks", "computational_thinking", "algorithms", "basics_ai"],
}; };

View file

@ -1,11 +1,7 @@
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<main></main> <main></main>
</template> </template>
<style scoped> <style scoped></style>
</style>

View file

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, watch} from "vue"; import { ref, watch } from "vue";
import {useI18n} from "vue-i18n"; import { useI18n } from "vue-i18n";
import {THEMESITEMS, AGE_TO_THEMES} from "@/utils/constants.ts"; import { THEMESITEMS, AGE_TO_THEMES } from "@/utils/constants.ts";
import BrowseThemes from "@/components/BrowseThemes.vue"; import BrowseThemes from "@/components/BrowseThemes.vue";
const {t, locale} = useI18n(); const { t, locale } = useI18n();
const selectedThemeKey = ref<string>('all'); const selectedThemeKey = ref<string>("all");
const selectedAgeKey = ref<string>('all'); const selectedAgeKey = ref<string>("all");
const allThemes = ref(Object.keys(THEMESITEMS)); const allThemes = ref(Object.keys(THEMESITEMS));
const availableThemes = ref([...allThemes.value]); const availableThemes = ref([...allThemes.value]);
@ -17,18 +17,17 @@ import {ref, watch} from "vue";
// Reset selection when language changes // Reset selection when language changes
watch(locale, () => { watch(locale, () => {
selectedThemeKey.value = 'all'; selectedThemeKey.value = "all";
selectedAgeKey.value = 'all'; selectedAgeKey.value = "all";
}); });
watch(selectedThemeKey, () => { watch(selectedThemeKey, () => {
if (selectedThemeKey.value === "all") { if (selectedThemeKey.value === "all") {
availableAges.value = [...allAges.value]; // Reset to all ages availableAges.value = [...allAges.value]; // Reset to all ages
} else { } else {
const themes = THEMESITEMS[selectedThemeKey.value]; const themes = THEMESITEMS[selectedThemeKey.value];
availableAges.value = allAges.value.filter(age => availableAges.value = allAges.value.filter((age) =>
AGE_TO_THEMES[age]?.some(theme => themes.includes(theme)) AGE_TO_THEMES[age]?.some((theme) => themes.includes(theme)),
); );
} }
}); });
@ -38,32 +37,31 @@ import {ref, watch} from "vue";
availableThemes.value = [...allThemes.value]; // Reset to all themes availableThemes.value = [...allThemes.value]; // Reset to all themes
} else { } else {
const themes = AGE_TO_THEMES[selectedAgeKey.value]; const themes = AGE_TO_THEMES[selectedAgeKey.value];
availableThemes.value = allThemes.value.filter(theme => availableThemes.value = allThemes.value.filter((theme) =>
THEMESITEMS[theme]?.some(theme => themes.includes(theme)) THEMESITEMS[theme]?.some((theme) => themes.includes(theme)),
); );
} }
}); });
</script> </script>
<template> <template>
<div class="main-container"> <div class="main-container">
<h1 class="title">{{ t("themes") }}</h1> <h1 class="title">{{ t("themes") }}</h1>
<v-container class="dropdowns"> <v-container class="dropdowns">
<v-select class="v-select" <v-select
:label="t('choose-theme')" class="v-select"
:items="availableThemes.map(theme => ({ title: t(`theme-options.${theme}`), value: theme }))" :label="t('choose-theme')"
v-model="selectedThemeKey" :items="availableThemes.map((theme) => ({ title: t(`theme-options.${theme}`), value: theme }))"
item-title="title" v-model="selectedThemeKey"
item-value="value" item-title="title"
variant="outlined" item-value="value"
variant="outlined"
/> />
<v-select <v-select
class="v-select" class="v-select"
:label="t('choose-age')" :label="t('choose-age')"
:items="availableAges.map(age => ({ key: age, label: t(`age-options.${age}`), value: age }))" :items="availableAges.map((age) => ({ key: age, label: t(`age-options.${age}`), value: age }))"
v-model="selectedAgeKey" v-model="selectedAgeKey"
item-title="label" item-title="label"
item-value="key" item-value="key"
@ -71,55 +69,55 @@ import {ref, watch} from "vue";
></v-select> ></v-select>
</v-container> </v-container>
<BrowseThemes :selectedTheme="selectedThemeKey ?? ''" :selectedAge="selectedAgeKey ?? ''"/> <BrowseThemes
:selectedTheme="selectedThemeKey ?? ''"
:selectedAge="selectedAgeKey ?? ''"
/>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.main-container {
min-height: 100vh;
min-width: 100vw;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
.title {
max-width: 50rem;
margin-left: 1rem;
margin-top: 1rem;
text-align: center;
display: flex;
justify-content: center;
}
.dropdowns {
display: flex;
justify-content: space-between;
gap: 5rem;
width: 80%;
}
.v-select {
flex: 1;
min-width: 100px;
}
@media (max-width: 768px) {
.main-container { .main-container {
padding: 1rem; min-height: 100vh;
} min-width: 100vw;
} display: flex;
@media (max-width: 700px) {
.dropdowns {
flex-direction: column; flex-direction: column;
gap: 1rem; align-items: flex-start;
justify-content: flex-start;
}
.title {
max-width: 50rem;
margin-left: 1rem;
margin-top: 1rem;
text-align: center;
display: flex;
justify-content: center;
}
.dropdowns {
display: flex;
justify-content: space-between;
gap: 5rem;
width: 80%; width: 80%;
} }
}
.v-select {
flex: 1;
min-width: 100px;
}
@media (max-width: 768px) {
.main-container {
padding: 1rem;
}
}
@media (max-width: 700px) {
.dropdowns {
flex-direction: column;
gap: 1rem;
width: 80%;
}
}
</style> </style>

1747
package-lock.json generated

File diff suppressed because it is too large Load diff