Merging origin/dev into feat/assignment-page

This commit is contained in:
Joyelle Ndagijimana 2025-04-07 18:11:13 +02:00
commit 2868435c62
87 changed files with 989 additions and 1002 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.js';
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

@ -9,29 +9,21 @@ import {
getStudentGroups, getStudentGroups,
getStudentSubmissions, getStudentSubmissions,
} from '../services/students.js'; } from '../services/students.js';
import { ClassDTO } from '../interfaces/class.js';
import { getAllAssignments } from '../services/assignments.js';
import { getUserHandler } from './users.js';
import { Student } from '../entities/users/student.entity.js';
import { StudentDTO } from '../interfaces/student.js'; import { StudentDTO } from '../interfaces/student.js';
import { getStudentRepository } from '../data/repositories.js';
import { UserDTO } from '../interfaces/user.js';
// TODO: accept arguments (full, ...) // TODO: accept arguments (full, ...)
// TODO: endpoints // TODO: endpoints
export async function getAllStudentsHandler(req: Request, res: Response): Promise<void> { export async function getAllStudentsHandler(req: Request, res: Response): Promise<void> {
const full = req.query.full === 'true'; const full = req.query.full === 'true';
const studentRepository = getStudentRepository(); const students = await getAllStudents(full);
const students: StudentDTO[] | string[] = full ? await getAllStudents() : await getAllStudents();
if (!students) { if (!students) {
res.status(404).json({ error: `Student not found.` }); res.status(404).json({ error: `Student not found.` });
return; return;
} }
res.status(201).json(students); res.json({ students: students });
} }
export async function getStudentHandler(req: Request, res: Response): Promise<void> { export async function getStudentHandler(req: Request, res: Response): Promise<void> {
@ -51,7 +43,7 @@ export async function getStudentHandler(req: Request, res: Response): Promise<vo
return; return;
} }
res.status(201).json(user); res.json(user);
} }
export async function createStudentHandler(req: Request, res: Response) { export async function createStudentHandler(req: Request, res: Response) {
@ -65,6 +57,14 @@ export async function createStudentHandler(req: Request, res: Response) {
} }
const newUser = await createStudent(userData); const newUser = await createStudent(userData);
if (!newUser) {
res.status(500).json({
error: 'Something went wrong while creating student',
});
return;
}
res.status(201).json(newUser); res.status(201).json(newUser);
} }
@ -88,25 +88,14 @@ export async function deleteStudentHandler(req: Request, res: Response) {
} }
export async function getStudentClassesHandler(req: Request, res: Response): Promise<void> { export async function getStudentClassesHandler(req: Request, res: Response): Promise<void> {
try { const full = req.query.full === 'true';
const full = req.query.full === 'true'; const username = req.params.id;
const username = req.params.id;
const classes = await getStudentClasses(username, full); const classes = await getStudentClasses(username, full);
res.json({ res.json({
classes: classes, classes: classes,
endpoints: { });
self: `${req.baseUrl}/${req.params.id}`,
classes: `${req.baseUrl}/${req.params.id}/invitations`,
questions: `${req.baseUrl}/${req.params.id}/assignments`,
students: `${req.baseUrl}/${req.params.id}/students`,
},
});
} catch (error) {
console.error('Error fetching learning objects:', error);
res.status(500).json({ error: 'Internal server error' });
}
} }
// TODO // TODO
@ -137,8 +126,9 @@ 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.id; const username = req.params.id;
const full = req.query.full === 'true';
const submissions = await getStudentSubmissions(username); const submissions = await getStudentSubmissions(username, full);
res.json({ res.json({
submissions: submissions, 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

@ -4,33 +4,23 @@ import {
deleteTeacher, deleteTeacher,
getAllTeachers, getAllTeachers,
getClassesByTeacher, getClassesByTeacher,
getClassIdsByTeacher,
getQuestionIdsByTeacher,
getQuestionsByTeacher, getQuestionsByTeacher,
getStudentIdsByTeacher,
getStudentsByTeacher, getStudentsByTeacher,
getTeacher, getTeacher,
} from '../services/teachers.js'; } from '../services/teachers.js';
import { ClassDTO } from '../interfaces/class.js';
import { StudentDTO } from '../interfaces/student.js';
import { QuestionDTO, QuestionId } from '../interfaces/question.js';
import { Teacher } from '../entities/users/teacher.entity.js';
import { TeacherDTO } from '../interfaces/teacher.js'; import { TeacherDTO } from '../interfaces/teacher.js';
import { getTeacherRepository } from '../data/repositories.js';
export async function getAllTeachersHandler(req: Request, res: Response): Promise<void> { export async function getAllTeachersHandler(req: Request, res: Response): Promise<void> {
const full = req.query.full === 'true'; const full = req.query.full === 'true';
const teacherRepository = getTeacherRepository(); const teachers = await getAllTeachers(full);
const teachers: TeacherDTO[] | string[] = full ? await getAllTeachers() : await getAllTeachers();
if (!teachers) { if (!teachers) {
res.status(404).json({ error: `Teacher not found.` }); res.status(404).json({ error: `Teacher not found.` });
return; return;
} }
res.status(201).json(teachers); res.json({ teachers: teachers });
} }
export async function getTeacherHandler(req: Request, res: Response): Promise<void> { export async function getTeacherHandler(req: Request, res: Response): Promise<void> {
@ -45,12 +35,12 @@ export async function getTeacherHandler(req: Request, res: Response): Promise<vo
if (!user) { if (!user) {
res.status(404).json({ res.status(404).json({
error: `User with username '${username}' not found.`, error: `Teacher '${username}' not found.`,
}); });
return; return;
} }
res.status(201).json(user); res.json(user);
} }
export async function createTeacherHandler(req: Request, res: Response) { export async function createTeacherHandler(req: Request, res: Response) {
@ -64,6 +54,12 @@ export async function createTeacherHandler(req: Request, res: Response) {
} }
const newUser = await createTeacher(userData); const newUser = await createTeacher(userData);
if (!newUser) {
res.status(400).json({ error: 'Failed to create teacher' });
return;
}
res.status(201).json(newUser); res.status(201).json(newUser);
} }
@ -78,7 +74,7 @@ export async function deleteTeacherHandler(req: Request, res: Response) {
const deletedUser = await deleteTeacher(username); const deletedUser = await deleteTeacher(username);
if (!deletedUser) { if (!deletedUser) {
res.status(404).json({ res.status(404).json({
error: `User with username '${username}' not found.`, error: `User '${username}' not found.`,
}); });
return; return;
} }
@ -87,58 +83,58 @@ export async function deleteTeacherHandler(req: Request, res: Response) {
} }
export async function getTeacherClassHandler(req: Request, res: Response): Promise<void> { export async function getTeacherClassHandler(req: Request, res: Response): Promise<void> {
try { const username = req.params.username as string;
const username = req.params.username as string; const full = req.query.full === 'true';
const full = req.query.full === 'true';
if (!username) { if (!username) {
res.status(400).json({ error: 'Missing required field: username' }); res.status(400).json({ error: 'Missing required field: username' });
return; return;
}
const classes: ClassDTO[] | string[] = full ? await getClassesByTeacher(username) : await getClassIdsByTeacher(username);
res.status(201).json(classes);
} catch (error) {
console.error('Error fetching classes by teacher:', error);
res.status(500).json({ error: 'Internal server error' });
} }
const classes = await getClassesByTeacher(username, full);
if (!classes) {
res.status(404).json({ error: 'Teacher not found' });
return;
}
res.json({ classes: classes });
} }
export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> { export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> {
try { const username = req.params.username as string;
const username = req.params.username as string; const full = req.query.full === 'true';
const full = req.query.full === 'true';
if (!username) { if (!username) {
res.status(400).json({ error: 'Missing required field: username' }); res.status(400).json({ error: 'Missing required field: username' });
return; return;
}
const students: StudentDTO[] | string[] = full ? await getStudentsByTeacher(username) : await getStudentIdsByTeacher(username);
res.status(201).json(students);
} catch (error) {
console.error('Error fetching students by teacher:', error);
res.status(500).json({ error: 'Internal server error' });
} }
const students = await getStudentsByTeacher(username, full);
if (!students) {
res.status(404).json({ error: 'Teacher not found' });
return;
}
res.json({ students: students });
} }
export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> { export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> {
try { const username = req.params.username as string;
const username = req.params.username as string; const full = req.query.full === 'true';
const full = req.query.full === 'true';
if (!username) { if (!username) {
res.status(400).json({ error: 'Missing required field: username' }); res.status(400).json({ error: 'Missing required field: username' });
return; return;
}
const questions: QuestionDTO[] | QuestionId[] = full ? await getQuestionsByTeacher(username) : await getQuestionIdsByTeacher(username);
res.status(201).json(questions);
} catch (error) {
console.error('Error fetching questions by teacher:', error);
res.status(500).json({ error: 'Internal server error' });
} }
const questions = await getQuestionsByTeacher(username, full);
if (!questions) {
res.status(404).json({ error: 'Teacher not found' });
return;
}
res.json({ questions: questions });
} }

View file

@ -1,91 +0,0 @@
import { Request, Response } from 'express';
import { UserService } from '../services/users.js';
import { UserDTO } from '../interfaces/user.js';
import { User } from '../entities/users/user.entity.js';
export async function getAllUsersHandler<T extends User>(req: Request, res: Response, service: UserService<T>): Promise<void> {
try {
const full = req.query.full === 'true';
const users: UserDTO[] | string[] = full ? await service.getAllUsers() : await service.getAllUserIds();
if (!users) {
res.status(404).json({ error: `Users not found.` });
return;
}
res.status(201).json(users);
} catch (error) {
console.error('❌ Error fetching users:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function getUserHandler<T extends User>(req: Request, res: Response, service: UserService<T>): Promise<void> {
try {
const username = req.params.username as string;
if (!username) {
res.status(400).json({ error: 'Missing required field: username' });
return;
}
const user = await service.getUserByUsername(username);
if (!user) {
res.status(404).json({
error: `User with username '${username}' not found.`,
});
return;
}
res.status(201).json(user);
} catch (error) {
console.error('❌ Error fetching users:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function createUserHandler<T extends User>(req: Request, res: Response, service: UserService<T>, UserClass: new () => T) {
try {
console.log('req', req);
const userData = req.body as UserDTO;
if (!userData.username || !userData.firstName || !userData.lastName) {
res.status(400).json({
error: 'Missing required fields: username, firstName, lastName',
});
return;
}
const newUser = await service.createUser(userData, UserClass);
res.status(201).json(newUser);
} catch (error) {
console.error('❌ Error creating user:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
export async function deleteUserHandler<T extends User>(req: Request, res: Response, service: UserService<T>) {
try {
const username = req.params.username;
if (!username) {
res.status(400).json({ error: 'Missing required field: username' });
return;
}
const deletedUser = await service.deleteUser(username);
if (!deletedUser) {
res.status(404).json({
error: `User with username '${username}' not found.`,
});
return;
}
res.status(200).json(deletedUser);
} catch (error) {
console.error('❌ Error deleting user:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

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.js';
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,42 +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);
}
}

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.js';
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,4 +1,5 @@
import { Student } from '../entities/users/student.entity.js'; import { Student } from '../entities/users/student.entity.js';
import { getStudentRepository } from '../data/repositories.js';
export interface StudentDTO { export interface StudentDTO {
id: string; id: string;
@ -23,7 +24,9 @@ export function mapToStudentDTO(student: Student): StudentDTO {
} }
export function mapToStudent(studentData: StudentDTO): Student { export function mapToStudent(studentData: StudentDTO): Student {
const student = new Student(studentData.username, studentData.firstName, studentData.lastName); return getStudentRepository().create({
username: studentData.username,
return student; 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.js';
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.js';
import { ForbiddenException } from '../../exceptions/forbidden-exception.js';
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.js';
import { ExceptionWithHttpState } from '../../exceptions/exception-with-http-state.js';
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';
@ -50,6 +49,7 @@ function config(testingMode: boolean = false): Options {
dbName: getEnvVar(EnvVars.DbName), dbName: getEnvVar(EnvVars.DbName),
subscribers: [new SqliteAutoincrementSubscriber()], subscribers: [new SqliteAutoincrementSubscriber()],
entities: entities, entities: entities,
persistOnCreate: false, // Do not implicitly save entities when they are created via `create`.
// EntitiesTs: entitiesTs, // EntitiesTs: entitiesTs,
// Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION) // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
@ -66,10 +66,11 @@ 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, // Do not implicitly save entities when they are created via `create`.
// 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,10 +1,7 @@
import { Response, Router } from 'express'; import { Response, Router } from 'express';
import studentRouter from './students.js'; import studentRouter from './students.js';
import groupRouter from './groups.js'; import teacherRouter from './teachers.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';
@ -22,11 +19,8 @@ router.get('/', (_, res: Response) => {
}); });
router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); router.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
router.use('/group', groupRouter /* #swagger.tags = ['Group'] */); router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */);
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

@ -9,7 +9,6 @@ import {
getStudentHandler, getStudentHandler,
getStudentSubmissionsHandler, getStudentSubmissionsHandler,
} from '../controllers/students.js'; } from '../controllers/students.js';
import { getStudentGroups } from '../services/students.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects // Root endpoint used to search objects

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,23 +1,20 @@
import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js'; import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js';
import { Class } from '../entities/classes/class.entity.js';
import { Student } from '../entities/users/student.entity.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 { UserService } from './users.js';
export async function getAllStudents(): Promise<StudentDTO[]> { export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
const users = await studentRepository.findAll(); const students = await studentRepository.findAll();
return users.map(mapToStudentDTO);
}
export async function getAllStudentIds(): Promise<string[]> { if (full) {
const users = await getAllStudents(); return students.map(mapToStudentDTO);
return users.map((user) => user.username); }
return students.map((student) => student.username);
} }
export async function getStudent(username: string): Promise<StudentDTO | null> { export async function getStudent(username: string): Promise<StudentDTO | null> {
@ -29,15 +26,9 @@ export async function getStudent(username: string): Promise<StudentDTO | null> {
export async function createStudent(userData: StudentDTO): Promise<StudentDTO | null> { export async function createStudent(userData: StudentDTO): Promise<StudentDTO | null> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
try { const newStudent = mapToStudent(userData);
const newStudent = studentRepository.create(mapToStudent(userData)); await studentRepository.save(newStudent, { preventOverwrite: true });
await studentRepository.save(newStudent); return mapToStudentDTO(newStudent);
return mapToStudentDTO(newStudent);
} catch (e) {
console.log(e);
return null;
}
} }
export async function deleteStudent(username: string): Promise<StudentDTO | null> { export async function deleteStudent(username: string): Promise<StudentDTO | null> {
@ -88,9 +79,7 @@ export async function getStudentAssignments(username: string, full: boolean): Pr
const classRepository = getClassRepository(); const classRepository = getClassRepository();
const classes = await classRepository.findByStudent(student); const classes = await classRepository.findByStudent(student);
const assignments = (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat();
return assignments;
} }
export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[]> { export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[]> {
@ -111,7 +100,7 @@ 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 studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
const student = await studentRepository.findByUsername(username); const student = await studentRepository.findByUsername(username);
@ -122,5 +111,9 @@ export async function getStudentSubmissions(username: string): Promise<Submissio
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);
}
return submissions.map(mapToSubmissionDTOId);
} }

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

@ -1,28 +1,19 @@
import { import { getClassRepository, getLearningObjectRepository, getQuestionRepository, getTeacherRepository } from '../data/repositories.js';
getClassRepository,
getLearningObjectRepository,
getQuestionRepository,
getStudentRepository,
getTeacherRepository,
} from '../data/repositories.js';
import { Teacher } from '../entities/users/teacher.entity.js';
import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; import { ClassDTO, mapToClassDTO } from '../interfaces/class.js';
import { getClassStudents } from './class.js'; import { getClassStudents } from './classes.js';
import { StudentDTO } from '../interfaces/student.js'; import { StudentDTO } from '../interfaces/student.js';
import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question.js'; import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question.js';
import { UserService } from './users.js';
import { mapToUser } from '../interfaces/user.js';
import { mapToTeacher, mapToTeacherDTO, TeacherDTO } from '../interfaces/teacher.js'; import { mapToTeacher, mapToTeacherDTO, TeacherDTO } from '../interfaces/teacher.js';
export async function getAllTeachers(): Promise<TeacherDTO[]> { export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> {
const teacherRepository = getTeacherRepository(); const teacherRepository = getTeacherRepository();
const users = await teacherRepository.findAll(); const teachers = await teacherRepository.findAll();
return users.map(mapToTeacherDTO);
}
export async function getAllTeacherIds(): Promise<string[]> { if (full) {
const users = await getAllTeachers(); return teachers.map(mapToTeacherDTO);
return users.map((user) => user.username); }
return teachers.map((teacher) => teacher.username);
} }
export async function getTeacher(username: string): Promise<TeacherDTO | null> { export async function getTeacher(username: string): Promise<TeacherDTO | null> {
@ -34,15 +25,10 @@ export async function getTeacher(username: string): Promise<TeacherDTO | null> {
export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO | null> { export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO | null> {
const teacherRepository = getTeacherRepository(); const teacherRepository = getTeacherRepository();
try { const newTeacher = mapToTeacher(userData);
const newTeacher = teacherRepository.create(mapToTeacher(userData)); await teacherRepository.save(newTeacher, { preventOverwrite: true });
await teacherRepository.save(newTeacher);
return mapToTeacherDTO(newTeacher); return mapToTeacherDTO(newTeacher);
} catch (e) {
console.log(e);
return null;
}
} }
export async function deleteTeacher(username: string): Promise<TeacherDTO | null> { export async function deleteTeacher(username: string): Promise<TeacherDTO | null> {
@ -64,11 +50,11 @@ export async function deleteTeacher(username: string): Promise<TeacherDTO | null
} }
} }
export async function fetchClassesByTeacher(username: string): Promise<ClassDTO[]> { export async function fetchClassesByTeacher(username: string): Promise<ClassDTO[] | null> {
const teacherRepository = getTeacherRepository(); const teacherRepository = getTeacherRepository();
const teacher = await teacherRepository.findByUsername(username); const teacher = await teacherRepository.findByUsername(username);
if (!teacher) { if (!teacher) {
return []; return null;
} }
const classRepository = getClassRepository(); const classRepository = getClassRepository();
@ -76,35 +62,49 @@ export async function fetchClassesByTeacher(username: string): Promise<ClassDTO[
return classes.map(mapToClassDTO); return classes.map(mapToClassDTO);
} }
export async function getClassesByTeacher(username: string): Promise<ClassDTO[]> { export async function getClassesByTeacher(username: string, full: boolean): Promise<ClassDTO[] | string[] | null> {
return await fetchClassesByTeacher(username);
}
export async function getClassIdsByTeacher(username: string): Promise<string[]> {
const classes = await fetchClassesByTeacher(username); const classes = await fetchClassesByTeacher(username);
if (!classes) {
return null;
}
if (full) {
return classes;
}
return classes.map((cls) => cls.id); return classes.map((cls) => cls.id);
} }
export async function fetchStudentsByTeacher(username: string) { export async function fetchStudentsByTeacher(username: string): Promise<StudentDTO[] | null> {
const classes = await getClassIdsByTeacher(username); const classes = (await getClassesByTeacher(username, false)) as string[];
if (!classes) {
return null;
}
return (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); return (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat();
} }
export async function getStudentsByTeacher(username: string): Promise<StudentDTO[]> { export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[] | null> {
return await fetchStudentsByTeacher(username);
}
export async function getStudentIdsByTeacher(username: string): Promise<string[]> {
const students = await fetchStudentsByTeacher(username); const students = await fetchStudentsByTeacher(username);
if (!students) {
return null;
}
if (full) {
return students;
}
return students.map((student) => student.username); return students.map((student) => student.username);
} }
export async function fetchTeacherQuestions(username: string): Promise<QuestionDTO[]> { export async function fetchTeacherQuestions(username: string): Promise<QuestionDTO[] | null> {
const teacherRepository = getTeacherRepository(); const teacherRepository = getTeacherRepository();
const teacher = await teacherRepository.findByUsername(username); const teacher = await teacherRepository.findByUsername(username);
if (!teacher) { if (!teacher) {
throw new Error(`Teacher with username '${username}' not found.`); return null;
} }
// Find all learning objects that this teacher manages // Find all learning objects that this teacher manages
@ -118,12 +118,16 @@ export async function fetchTeacherQuestions(username: string): Promise<QuestionD
return questions.map(mapToQuestionDTO); return questions.map(mapToQuestionDTO);
} }
export async function getQuestionsByTeacher(username: string): Promise<QuestionDTO[]> { export async function getQuestionsByTeacher(username: string, full: boolean): Promise<QuestionDTO[] | QuestionId[] | null> {
return await fetchTeacherQuestions(username);
}
export async function getQuestionIdsByTeacher(username: string): Promise<QuestionId[]> {
const questions = await fetchTeacherQuestions(username); const questions = await fetchTeacherQuestions(username);
if (!questions) {
return null;
}
if (full) {
return questions;
}
return questions.map(mapToQuestionId); return questions.map(mapToQuestionId);
} }

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

@ -42,7 +42,7 @@
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"npm-run-all2": "^7.0.2", "npm-run-all2": "^7.0.2",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"vite": "^6.1.0", "vite": "^6.1.2",
"vite-plugin-vue-devtools": "^7.7.2", "vite-plugin-vue-devtools": "^7.7.2",
"vitest": "^3.0.5", "vitest": "^3.0.5",
"vue-tsc": "^2.2.2" "vue-tsc": "^2.2.2"

View file

@ -1,10 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
import MenuBar from "@/components/MenuBar.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
auth.loadUser(); auth.loadUser();
interface RouteMeta {
requiresAuth?: boolean;
}
const showMenuBar = computed(() => (route.meta as RouteMeta).requiresAuth && auth.authState.user);
</script> </script>
<template> <template>
<router-view /> <v-app>
<menu-bar v-if="showMenuBar"></menu-bar>
<v-main>
<router-view />
</v-main>
</v-app>
</template> </template>
<style scoped></style> <style scoped></style>

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
@ -31,79 +31,95 @@
localStorage.setItem("user-lang", langCode); localStorage.setItem("user-lang", langCode);
}; };
// contains functionality to let the collapsed menu appear and disappear // Contains functionality to let the collapsed menu appear and disappear
// when the screen size varies // When the screen size varies
const drawer = ref(false); const drawer = ref(false);
// when the user wants to logout, a popup is shown to verify this // When the user wants to logout, a popup is shown to verify this
// if verified, the user should be logged out // If verified, the user should be logged out
const performLogout = () => { const performLogout = () => {
auth.logout(); auth.logout();
}; };
</script> </script>
<template> <template>
<main> <v-app-bar
<v-app class="menu_collapsed"> class="app-bar"
<v-app-bar app
app >
style="background-color: #f6faf2" <v-app-bar-nav-icon
class="menu_collapsed"
@click="drawer = !drawer"
/>
<router-link
to="/user"
class="dwengo_home"
>
<div>
<img
class="dwengo_logo"
alt="Dwengo logo"
:src="dwengoLogo"
/>
<p class="caption">
{{ t(`${role}`) }}
</p>
</div>
</router-link>
<v-toolbar-items class="menu">
<v-btn
class="menu_item"
variant="text"
to="/user/assignment"
> >
<template v-slot:prepend> {{ t("assignments") }}
<v-app-bar-nav-icon @click="drawer = !drawer" /> </v-btn>
</template> <v-btn
class="menu_item"
<v-app-bar-title> variant="text"
<router-link to="/user/class"
to="/user" >
class="dwengo_home" {{ t("classes") }}
</v-btn>
<v-btn
class="menu_item"
variant="text"
to="/user/discussion"
>
{{ t("discussions") }}
</v-btn>
<v-menu open-on-hover>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
> >
<div> <v-icon
<img icon="mdi-translate"
class="dwengo_logo" size="small"
:src="dwengoLogo" color="#0e6942"
style="width: 100px" ></v-icon>
/> </v-btn>
<p </template>
class="caption" <v-list>
style="font-size: smaller" <v-list-item
> v-for="(language, index) in languages"
{{ t(`${role}`) }} :key="index"
</p> @click="changeLanguage(language.code)"
</div> >
</router-link> <v-list-item-title>{{ language.name }}</v-list-item-title>
</v-app-bar-title> </v-list-item>
</v-list>
<v-spacer></v-spacer> </v-menu>
</v-toolbar-items>
<v-menu open-on-hover> <v-spacer></v-spacer>
<template v-slot:activator="{ props }"> <v-dialog max-width="500">
<v-btn <template v-slot:activator="{ props: activatorProps }">
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn <v-btn
@click="performLogout" v-bind="activatorProps"
text :rounded="true"
variant="text"
> >
<v-tooltip <v-tooltip
:text="t('logout')" :text="t('logout')"
@ -115,201 +131,81 @@
icon="mdi-logout" icon="mdi-logout"
size="x-large" size="x-large"
color="#0e6942" color="#0e6942"
/> >
</v-icon>
</template> </template>
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
</v-app-bar> </template>
<v-navigation-drawer <template v-slot:default="{ isActive }">
v-model="drawer" <v-card :title="t('logoutVerification')">
app <v-card-actions>
> <v-spacer></v-spacer>
<v-list>
<v-list-item
to="/user/assignment"
link
>
<v-list-item-content>
<v-list-item-title class="menu_item">{{ t("assignments") }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item <v-btn
to="/user/class" :text="t('cancel')"
link @click="isActive.value = false"
> ></v-btn>
<v-list-item-content> <v-btn
<v-list-item-title class="menu_item">{{ t("classes") }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item
to="/user/discussion"
link
>
<v-list-item-content>
<v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
</v-app>
<nav class="menu">
<div class="left">
<ul>
<li>
<router-link
to="/user"
class="dwengo_home"
>
<img
class="dwengo_logo"
:src="dwengoLogo"
/>
<p class="caption">
{{ t(`${role}`) }}
</p>
</router-link>
</li>
<li>
<router-link
:to="`/user/assignment`"
class="menu_item"
>
{{ t("assignments") }}
</router-link>
</li>
<li>
<router-link
to="/user/class"
class="menu_item"
>{{ t("classes") }}</router-link
>
</li>
<li>
<router-link
to="/user/discussion"
class="menu_item"
>{{ t("discussions") }}
</router-link>
</li>
<li>
<v-menu open-on-hover>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
>
<v-icon
icon="mdi-translate"
size="small"
color="#0e6942"
></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(language, index) in languages"
:key="index"
@click="changeLanguage(language.code)"
>
<v-list-item-title>{{ language.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</li>
</ul>
</div>
<div class="right">
<li>
<!-- <v-btn
@click="performLogout"
to="/login"
style="background-color: transparent; box-shadow: none !important"
>
<v-tooltip
:text="t('logout')" :text="t('logout')"
location="bottom" @click="performLogout"
> to="/login"
<template v-slot:activator="{ props }"> ></v-btn>
<v-icon </v-card-actions>
v-bind="props" </v-card>
icon="mdi-logout" </template>
size="x-large" </v-dialog>
color="#0e6942" <v-avatar
></v-icon> size="large"
</template> color="#0e6942"
</v-tooltip> class="user-button"
</v-btn> --> >{{ initials }}</v-avatar
<v-dialog max-width="500"> >
<template v-slot:activator="{ props: activatorProps }"> </v-app-bar>
<v-btn <v-navigation-drawer
v-bind="activatorProps" v-model="drawer"
style="background-color: transparent; box-shadow: none !important" temporary
> app
<v-tooltip >
:text="t('logout')" <v-list>
location="bottom" <v-list-item
> to="/user/assignment"
<template v-slot:activator="{ props }"> link
<v-icon >
v-bind="props" <v-list-item-title class="menu_item">{{ t("assignments") }}</v-list-item-title>
icon="mdi-logout" </v-list-item>
size="x-large"
color="#0e6942"
>
</v-icon>
</template>
</v-tooltip>
</v-btn>
</template>
<template v-slot:default="{ isActive }"> <v-list-item
<v-card :title="t('logoutVerification')"> to="/user/class"
<v-card-actions> link
<v-spacer></v-spacer> >
<v-list-item-title class="menu_item">{{ t("classes") }}</v-list-item-title>
</v-list-item>
<v-btn <v-list-item
:text="t('cancel')" to="/user/discussion"
@click="isActive.value = false" link
></v-btn> >
<v-btn <v-list-item-title class="menu_item">{{ t("discussions") }}</v-list-item-title>
:text="t('logout')" </v-list-item>
@click="performLogout" </v-list>
to="/login" </v-navigation-drawer>
></v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</li>
<li>
<v-avatar
size="large"
color="#0e6942"
style="font-size: large; font-weight: bold"
>{{ initials }}</v-avatar
>
</li>
</div>
</nav>
<router-view />
</main>
</template> </template>
<style scoped> <style scoped>
.app-bar {
background-color: #f6faf2;
}
.menu { .menu {
background-color: #f6faf2; background-color: #f6faf2;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.user-button {
.right { margin-right: 10px;
align-items: center; font-size: large;
padding: 10px; font-weight: bold;
} }
.right li { .right li {
@ -347,16 +243,19 @@
color: #0e6942; color: #0e6942;
text-decoration: none; text-decoration: none;
font-size: large; font-size: large;
} text-transform: none;
nav a.router-link-active {
font-weight: bold;
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.menu { .menu {
display: none; display: none;
} }
.caption {
font-size: smaller;
}
.dwengo_logo {
width: 100px;
}
} }
@media (min-width: 701px) { @media (min-width: 701px) {

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,4 +1,4 @@
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 {
let instance: T | undefined; let instance: T | undefined;

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,11 +1,14 @@
import apiClient from "@/services/api-client.ts"; import apiClient from "@/services/api-client.ts";
import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts"; import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts";
export const AUTH_CONFIG_ENDPOINT = "auth/config";
/** /**
* Fetch the authentication configuration from the backend. * Fetch the authentication configuration from the backend.
*/ */
export async function loadAuthConfig() { export async function loadAuthConfig() {
const authConfig = (await apiClient.get<FrontendAuthConfig>("auth/config")).data; const authConfigResponse = await apiClient.get<FrontendAuthConfig>(AUTH_CONFIG_ENDPOINT);
const authConfig = authConfigResponse.data;
return { return {
student: { student: {
authority: authConfig.student.authority, authority: authConfig.student.authority,

View file

@ -5,7 +5,7 @@
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import type { AuthState, Role, UserManagersForRoles } from "@/services/auth/auth.d.ts"; import type { AuthState, Role, UserManagersForRoles } from "@/services/auth/auth.d.ts";
import { User, UserManager } from "oidc-client-ts"; import { User, UserManager } from "oidc-client-ts";
import { loadAuthConfig } from "@/services/auth/auth-config-loader.ts"; import { AUTH_CONFIG_ENDPOINT, loadAuthConfig } from "@/services/auth/auth-config-loader.ts";
import authStorage from "./auth-storage.ts"; import authStorage from "./auth-storage.ts";
import { loginRoute } from "@/config.ts"; import { loginRoute } from "@/config.ts";
import apiClient from "@/services/api-client.ts"; import apiClient from "@/services/api-client.ts";
@ -108,7 +108,7 @@ async function logout(): Promise<void> {
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
async (reqConfig) => { async (reqConfig) => {
const token = authState?.user?.access_token; const token = authState?.user?.access_token;
if (token) { if (token && reqConfig.url !== AUTH_CONFIG_ENDPOINT) {
reqConfig.headers.Authorization = `Bearer ${token}`; reqConfig.headers.Authorization = `Bearer ${token}`;
} }
return reqConfig; return reqConfig;

View file

@ -1,33 +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 = ["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

@ -8,7 +8,7 @@
onMounted(async () => { onMounted(async () => {
try { try {
await auth.handleLoginCallback(); await auth.handleLoginCallback();
await router.replace("/"); // Redirect to home (or dashboard) await router.replace("/user"); // Redirect to theme page
} catch (error) { } catch (error) {
console.error("OIDC callback error:", error); console.error("OIDC callback error:", error);
} }

View file

@ -25,9 +25,10 @@
<div class="container_left"> <div class="container_left">
<img <img
:src="dwengoLogo" :src="dwengoLogo"
alt="Dwengo logo"
style="align-self: center" style="align-self: center"
/> />
<h> {{ t("homeTitle") }}</h> <h1>{{ t("homeTitle") }}</h1>
<p class="info"> <p class="info">
{{ t("homeIntroduction1") }} {{ t("homeIntroduction1") }}
</p> </p>
@ -55,7 +56,7 @@
width="125" width="125"
src="/assets/home/innovative.png" src="/assets/home/innovative.png"
></v-img> ></v-img>
<h class="big">{{ t("innovative") }}</h> <h2 class="big">{{ t("innovative") }}</h2>
</div> </div>
<div class="img_small"> <div class="img_small">
<v-img <v-img
@ -63,7 +64,7 @@
width="125" width="125"
src="/assets/home/research_based.png" src="/assets/home/research_based.png"
></v-img> ></v-img>
<h class="big">{{ t("researchBased") }}</h> <h2 class="big">{{ t("researchBased") }}</h2>
</div> </div>
<div class="img_small"> <div class="img_small">
<v-img <v-img
@ -71,7 +72,7 @@
width="125" width="125"
src="/assets/home/inclusive.png" src="/assets/home/inclusive.png"
></v-img> ></v-img>
<h class="big">{{ t("sociallyRelevant") }}</h> <h2 class="big">{{ t("sociallyRelevant") }}</h2>
</div> </div>
<div class="img_small"> <div class="img_small">
<v-img <v-img
@ -79,7 +80,7 @@
width="125" width="125"
src="/assets/home/socially_relevant.png" src="/assets/home/socially_relevant.png"
></v-img> ></v-img>
<h class="big">{{ t("inclusive") }}</h> <h2 class="big">{{ t("inclusive") }}</h2>
</div> </div>
</div> </div>
<div class="container_right"> <div class="container_right">
@ -158,7 +159,7 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
h { h2 {
font-size: large; font-size: large;
font-weight: bold; font-weight: bold;
align-self: center; align-self: center;

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>

6
package-lock.json generated
View file

@ -118,7 +118,7 @@
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"npm-run-all2": "^7.0.2", "npm-run-all2": "^7.0.2",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"vite": "^6.1.0", "vite": "^6.1.2",
"vite-plugin-vue-devtools": "^7.7.2", "vite-plugin-vue-devtools": "^7.7.2",
"vitest": "^3.0.5", "vitest": "^3.0.5",
"vue-tsc": "^2.2.2" "vue-tsc": "^2.2.2"
@ -9821,7 +9821,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.1.1", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.1.2.tgz",
"integrity": "sha512-EiXfDyO/uNKhYOSlZ6+9qBz4H46A8Lr07pyjmb88KTbJ+xkXvnqtxvgtg2VxPU6Kfj8Ep0un9JLqdrCWLqIanw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {