diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5fbca23a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules/ +**/dist +.git +npm-debug.log +.coverage +.coverage.* +.env \ No newline at end of file diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 00000000..865f4524 --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,19 @@ +name: Deployment + +on: + push: + branches: + - main + +jobs: + docker: + name: Deploy with docker + runs-on: [self-hosted, Linux, X64] + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Start docker + run: docker compose -f compose.yml -f compose.prod.yml up --build -d + \ No newline at end of file diff --git a/.github/workflows/lint-action.yml b/.github/workflows/lint-action.yml index c59191ed..32823417 100644 --- a/.github/workflows/lint-action.yml +++ b/.github/workflows/lint-action.yml @@ -45,4 +45,4 @@ jobs: eslint: true eslint_args: '--config eslint.config.ts' prettier: true - commit_message: 'style: fix linting issues met ${linter}' + commit_message: 'style: fix linting issues met ${linter}' \ No newline at end of file diff --git a/README.md b/README.md index dc09bbfc..3526b53d 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,16 @@ Alternatief kan je één van de volgende methodes gebruiken om de applicatie lok ### 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/) en [Docker Compose](https://docs.docker.com/compose/)). 2. Clone deze repository. -3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar - nodig. -4. Voer `docker compose up` uit in de root van de repository. +3. In de backend, kopieer `.env.example` naar `.env` en pas de variabelen aan waar nodig. +4. Voer `docker compose -f compose.staging.yml up --build` uit in de root van de repository. 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). +6. De applicatie is nu beschikbaar op [`http://localhost/`](http://localhost/) en [`http://localhost/api`](http://localhost/api). ```bash docker compose version @@ -38,14 +40,13 @@ cp .env.example .env # Pas .env aan nano .env cd .. -docker compose up -# Configureer de applicatie +docker compose -f compose.staging.yml up --build ``` -### Handmatige installatie +### Handmatige installatie en ontwikkeling 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 diff --git a/backend/.env.development.example b/backend/.env.development.example index 247ff054..d03a6744 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -1,9 +1,24 @@ -DWENGO_PORT=3000 +# +# 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 ### + +#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_PORT=5431 +#DWENGO_DB_NAME=dwengo DWENGO_DB_USERNAME=postgres DWENGO_DB_PASSWORD=postgres DWENGO_DB_UPDATE=true +#DWENGO_DB_CONTENT_PREFIX=u_ DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo @@ -11,6 +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_CLIENT_ID=dwengo 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_HEADERS=Authorization,Content-Type + +### Advanced configuration ### + +DWENGO_LOGGING_LEVEL=debug +#DWENGO_LOGGING_LOKI_HOST=http://localhost:3102 diff --git a/backend/.env.example b/backend/.env.example index 105a1654..8873515c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,22 +1,68 @@ -DWENGO_PORT=3000 # The port the backend will listen on +# +# Basic configuration +# +# Change the values of the variables below to match your environment! +# Default values are commented out. +# + +### 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_PORT=5432 - -# Change this to the actual credentials of the user Dwengo should use in the backend -DWENGO_DB_USERNAME=postgres -DWENGO_DB_PASSWORD=postgres - +# The port of the database. +#DWENGO_DB_PORT=5432 +# The name of the database. +#DWENGO_DB_NAME=dwengo +# ! 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. -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. -DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +# ! Change this! The external URL for student authentication. Should be reachable by the client. +# 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_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs - -# Data for the identity provider via which the teachers authenticate. -DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher +# ! 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. +# E.g. http://idp:7080/realms/student/protocol/openid-connect/certs +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_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 -# LOKI_HOST=http://localhost:3102 # The address of the Loki instance, used for logging +# Allowed origins for CORS requests. Separate multiple origins with a comma. +#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 diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 00000000..4f36cf53 --- /dev/null +++ b/backend/.env.production.example @@ -0,0 +1,37 @@ +# +# Production environment configuration +# +# Change the values of the variables below to match your production environment! +# See .env.example for more information. +# + +### 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_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres +DWENGO_DB_UPDATE=false +#DWENGO_DB_CONTENT_PREFIX=u_ + +DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student +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_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher +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_AUDIENCE=account + +#DWENGO_CORS_ALLOWED_ORIGINS= +#DWENGO_CORS_ALLOWED_HEADERS=Authorization,Content-Type + +### Advanced configuration ### + +DWENGO_LOGGING_LEVEL=info +DWENGO_LOGGING_LOKI_HOST=http://logging:3102 diff --git a/backend/.env.test.example b/backend/.env.test.example new file mode 100644 index 00000000..535628cd --- /dev/null +++ b/backend/.env.test.example @@ -0,0 +1,13 @@ +# +# 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_UPDATE=true diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..7c63c4b8 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,38 @@ +FROM node:22 AS build-stage + +WORKDIR /app + +# Install dependencies + +COPY package*.json ./ +COPY backend/package.json ./backend/ + +RUN npm install --silent + +# Build the backend + +# Root tsconfig.json +COPY tsconfig.json ./ + +WORKDIR /app/backend + +COPY backend ./ +COPY docs /app/docs + +RUN npm run build + +FROM node:22 AS production-stage + +WORKDIR /app + +COPY package-lock.json backend/package.json ./ + +RUN npm install --silent --only=production + +COPY ./docs /docs +COPY ./backend/i18n /app/i18n +COPY --from=build-stage /app/backend/dist ./dist/ + +EXPOSE 3000 + +CMD ["node", "--env-file=.env", "dist/app.js"] diff --git a/backend/README.md b/backend/README.md index 442cea82..8a78ed14 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,23 +4,24 @@ ```shell 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 +# Omgevingsvariabelen +cp .env.development.example .env.development.local + npm run dev ``` -### Production - -```shell -npm run build -npm run start -``` - ### Tests 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 ``` +### 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 Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. diff --git a/backend/config.js b/backend/config.js new file mode 100644 index 00000000..be42027c --- /dev/null +++ b/backend/config.js @@ -0,0 +1,7 @@ +// Can be placed in dotenv but found it redundant +// Import dotenv from "dotenv"; +// Load .env file +// Dotenv.config(); +export const DWENGO_API_BASE = 'https://dwengo.org/backend/api'; +export const FALLBACK_LANG = 'nl'; +export const FALLBACK_SEQ_NUM = 1; diff --git a/backend/eslint.config.ts b/backend/eslint.config.ts index 6b696021..f5f225b2 100644 --- a/backend/eslint.config.ts +++ b/backend/eslint.config.ts @@ -8,14 +8,4 @@ export default [ globals: globals.node, }, }, - - { - files: ['tests/**/*.ts'], - languageOptions: { - globals: globals.node, - }, - rules: { - 'no-console': 'off', - }, - }, ]; diff --git a/backend/_i18n/de.yml b/backend/i18n/de.yml similarity index 98% rename from backend/_i18n/de.yml rename to backend/i18n/de.yml index f38c16e8..ab088320 100644 --- a/backend/_i18n/de.yml +++ b/backend/i18n/de.yml @@ -28,9 +28,9 @@ curricula_page: contact: '' teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y basics_ai: - title: Basisprincipes van AI - sub_title: Basisprincipes van AI - description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + title: Grundlagen der KI + sub_title: Grundlagen der KI + description: 'Dieses Thema bündelt verschiedene Aktivitäten, in denen die grundlegenden Prinzipien der künstlichen Intelligenz (KI) behandelt werden. Die Schüler lernen, was KI ist, wie sie funktioniert und wie sie in verschiedenen Bereichen angewendet werden kann.' contact: '' kiks: title: KI und Klima diff --git a/backend/_i18n/en.yml b/backend/i18n/en.yml similarity index 98% rename from backend/_i18n/en.yml rename to backend/i18n/en.yml index 20a34e77..6033b56f 100644 --- a/backend/_i18n/en.yml +++ b/backend/i18n/en.yml @@ -28,10 +28,11 @@ curricula_page: contact: '' teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y basics_ai: - title: Basisprincipes van AI - sub_title: Basisprincipes van AI - description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + title: Basics of AI + sub_title: Basics of AI + description: 'This theme brings together various activities covering the basic principles of Artificial Intelligence (AI). Students learn what AI is, how it works, and how it can be applied in different domains.' contact: '' + kiks: title: AI and Climate sub_title: KIKS diff --git a/backend/_i18n/fr.yml b/backend/i18n/fr.yml similarity index 98% rename from backend/_i18n/fr.yml rename to backend/i18n/fr.yml index 08a0b1d7..d97c43b2 100644 --- a/backend/_i18n/fr.yml +++ b/backend/i18n/fr.yml @@ -28,9 +28,9 @@ curricula_page: contact: '' teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y basics_ai: - title: Basisprincipes van AI - sub_title: Basisprincipes van AI - description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + title: Principes de base de l’IA + sub_title: Principes de base de l’IA + description: 'Ce thème rassemble différentes activités portant sur les principes fondamentaux de l’intelligence artificielle (IA). Les élèves apprennent ce qu’est l’IA, comment elle fonctionne et comment elle peut être appliquée dans divers domaines.' contact: '' kiks: title: 'IA et changement climatique' diff --git a/backend/_i18n/nl.yml b/backend/i18n/nl.yml similarity index 100% rename from backend/_i18n/nl.yml rename to backend/i18n/nl.yml diff --git a/backend/package.json b/backend/package.json index d548b52f..c08fb1dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "dwengo-1-backend", - "version": "0.0.1", + "version": "0.1.1", "description": "Backend for Dwengo-1", "private": true, "type": "module", @@ -11,7 +11,7 @@ "format": "prettier --write src/", "format-check": "prettier --check src/", "lint": "eslint . --fix", - "test:unit": "vitest" + "test:unit": "vitest --run" }, "dependencies": { "@mikro-orm/core": "6.4.9", @@ -34,6 +34,7 @@ "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", "response-time": "^2.3.3", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.17.0", "winston-loki": "^6.1.3" @@ -45,6 +46,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.13.4", "@types/response-time": "^2.3.8", + "@types/swagger-ui-express": "^4.1.8", "globals": "^15.15.0", "ts-node": "^10.9.2", "tsx": "^4.19.3", diff --git a/backend/src/app.ts b/backend/src/app.ts index 6307793d..cf10a6df 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,58 +1,39 @@ -import express, { Express, Response } from 'express'; +import express, { Express } from 'express'; import { initORM } from './orm.js'; - -import themeRoutes from './routes/themes.js'; -import learningPathRoutes from './routes/learning-paths.js'; -import learningObjectRoutes from './routes/learning-objects.js'; - -import studentRouter from './routes/student.js'; -import groupRouter from './routes/group.js'; -import assignmentRouter from './routes/assignment.js'; -import submissionRouter from './routes/submission.js'; -import classRouter from './routes/class.js'; -import questionRouter from './routes/question.js'; -import authRouter from './routes/auth.js'; import { authenticateUser } from './middleware/auth/auth.js'; import cors from './middleware/cors.js'; import { getLogger, Logger } from './logging/initalize.js'; import { responseTimeLogger } from './logging/responseTimeLogger.js'; import responseTime from 'response-time'; -import { EnvVars, getNumericEnvVar } from './util/envvars.js'; +import { envVars, getNumericEnvVar } from './util/envVars.js'; +import apiRouter from './routes/router.js'; +import swaggerMiddleware from './swagger.js'; +import swaggerUi from 'swagger-ui-express'; +import { errorHandler } from './middleware/error-handling/error-handler.js'; const logger: Logger = getLogger(); const app: Express = express(); -const port: string | number = getNumericEnvVar(EnvVars.Port); +const port: string | number = getNumericEnvVar(envVars.Port); -app.use(cors); app.use(express.json()); -app.use(responseTime(responseTimeLogger)); +app.use(cors); app.use(authenticateUser); +// Add response time logging +app.use(responseTime(responseTimeLogger)); -// TODO Replace with Express routes -app.get('/', (_, res: Response) => { - logger.debug('GET /'); - res.json({ - message: 'Hello Dwengo!🚀', - }); -}); +app.use('/api', apiRouter); -app.use('/student', studentRouter); -app.use('/group', groupRouter); -app.use('/assignment', assignmentRouter); -app.use('/submission', submissionRouter); -app.use('/class', classRouter); -app.use('/question', questionRouter); -app.use('/auth', authRouter); -app.use('/theme', themeRoutes); -app.use('/learningPath', learningPathRoutes); -app.use('/learningObject', learningObjectRoutes); +// Swagger +app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); -async function startServer() { +app.use(errorHandler); + +async function startServer(): Promise { await initORM(); app.listen(port, () => { - logger.info(`Server is running at http://localhost:${port}`); + logger.info(`Server is running at http://localhost:${port}/api`); }); } diff --git a/backend/src/config.ts b/backend/src/config.ts index 6cf388cc..9b4702b5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,9 +1,7 @@ -import { EnvVars, getEnvVar } from './util/envvars.js'; +import { envVars, getEnvVar } from './util/envVars.js'; // API -export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); -export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); +export const DWENGO_API_BASE = getEnvVar(envVars.LearningContentRepoApiBaseUrl); +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; diff --git a/backend/src/controllers/assignments.ts b/backend/src/controllers/assignments.ts new file mode 100644 index 00000000..c8dd1ec8 --- /dev/null +++ b/backend/src/controllers/assignments.ts @@ -0,0 +1,77 @@ +import { Request, Response } from 'express'; +import { createAssignment, getAllAssignments, getAssignment, getAssignmentsSubmissions } from '../services/assignments.js'; +import { AssignmentDTO } from '../interfaces/assignment.js'; + +// Typescript is annoying with parameter forwarding from class.ts +interface AssignmentParams { + classid: string; + id: string; +} + +export async function getAllAssignmentsHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const full = req.query.full === 'true'; + + const assignments = await getAllAssignments(classid, full); + + res.json({ + assignments: assignments, + }); +} + +export async function createAssignmentHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const assignmentData = req.body as AssignmentDTO; + + if (!assignmentData.description || !assignmentData.language || !assignmentData.learningPath || !assignmentData.title) { + res.status(400).json({ + error: 'Missing one or more required fields: title, description, learningPath, language', + }); + return; + } + + const assignment = await createAssignment(classid, assignmentData); + + if (!assignment) { + res.status(500).json({ error: 'Could not create assignment ' }); + return; + } + + res.status(201).json(assignment); +} + +export async function getAssignmentHandler(req: Request, res: Response): Promise { + const id = Number(req.params.id); + const classid = req.params.classid; + + if (isNaN(id)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const assignment = await getAssignment(classid, id); + + if (!assignment) { + res.status(404).json({ error: 'Assignment not found' }); + return; + } + + res.json(assignment); +} + +export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const assignmentNumber = Number(req.params.id); + const full = req.query.full === 'true'; + + if (isNaN(assignmentNumber)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const submissions = await getAssignmentsSubmissions(classid, assignmentNumber, full); + + res.json({ + submissions: submissions, + }); +} diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 409ead0c..b87eaf7b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -1,16 +1,16 @@ -import { EnvVars, getEnvVar } from '../util/envvars.js'; +import { envVars, getEnvVar } from '../util/envVars.js'; -type FrontendIdpConfig = { +interface FrontendIdpConfig { authority: string; clientId: string; scope: string; responseType: string; -}; +} -type FrontendAuthConfig = { +interface FrontendAuthConfig { student: FrontendIdpConfig; teacher: FrontendIdpConfig; -}; +} const SCOPE = 'openid profile email'; const RESPONSE_TYPE = 'code'; @@ -18,14 +18,14 @@ const RESPONSE_TYPE = 'code'; export function getFrontendAuthConfig(): FrontendAuthConfig { return { student: { - authority: getEnvVar(EnvVars.IdpStudentUrl), - clientId: getEnvVar(EnvVars.IdpStudentClientId), + authority: getEnvVar(envVars.IdpStudentUrl), + clientId: getEnvVar(envVars.IdpStudentClientId), scope: SCOPE, responseType: RESPONSE_TYPE, }, teacher: { - authority: getEnvVar(EnvVars.IdpTeacherUrl), - clientId: getEnvVar(EnvVars.IdpTeacherClientId), + authority: getEnvVar(envVars.IdpTeacherUrl), + clientId: getEnvVar(envVars.IdpTeacherClientId), scope: SCOPE, responseType: RESPONSE_TYPE, }, diff --git a/backend/src/controllers/classes.ts b/backend/src/controllers/classes.ts new file mode 100644 index 00000000..7526f7c4 --- /dev/null +++ b/backend/src/controllers/classes.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { createClass, getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/classes.js'; +import { ClassDTO } from '../interfaces/class.js'; + +export async function getAllClassesHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const classes = await getAllClasses(full); + + res.json({ + classes: classes, + }); +} + +export async function createClassHandler(req: Request, res: Response): Promise { + const classData = req.body as ClassDTO; + + if (!classData.displayName) { + res.status(400).json({ + error: 'Missing one or more required fields: displayName', + }); + return; + } + + const cls = await createClass(classData); + + if (!cls) { + res.status(500).json({ error: 'Something went wrong while creating class' }); + return; + } + + res.status(201).json(cls); +} + +export async function getClassHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const cls = await getClass(classId); + + if (!cls) { + res.status(404).json({ error: 'Class not found' }); + return; + } + + res.json(cls); +} + +export async function getClassStudentsHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const full = req.query.full === 'true'; + + const students = full ? await getClassStudents(classId) : await getClassStudentsIds(classId); + + res.json({ + students: students, + }); +} + +export async function getTeacherInvitationsHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const full = req.query.full === 'true'; + + const invitations = await getClassTeacherInvitations(classId, full); + + res.json({ + invitations: invitations, + }); +} diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts new file mode 100644 index 00000000..7de3e114 --- /dev/null +++ b/backend/src/controllers/groups.ts @@ -0,0 +1,100 @@ +import { Request, Response } from 'express'; +import { createGroup, getAllGroups, getGroup, getGroupSubmissions } from '../services/groups.js'; +import { GroupDTO } from '../interfaces/group.js'; + +// Typescript is annoywith with parameter forwarding from class.ts +interface GroupParams { + classid: string; + assignmentid: string; + groupid?: string; +} + +export async function getGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const full = req.query.full === 'true'; + const assignmentId = Number(req.params.assignmentid); + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groupId = Number(req.params.groupid!); // Can't be undefined + + if (isNaN(groupId)) { + res.status(400).json({ error: 'Group id must be a number' }); + return; + } + + const group = await getGroup(classId, assignmentId, groupId, full); + + if (!group) { + res.status(404).json({ error: 'Group not found' }); + return; + } + + res.json(group); +} + +export async function getAllGroupsHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const full = req.query.full === 'true'; + + const assignmentId = Number(req.params.assignmentid); + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groups = await getAllGroups(classId, assignmentId, full); + + res.json({ + groups: groups, + }); +} + +export async function createGroupHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const assignmentId = Number(req.params.assignmentid); + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groupData = req.body as GroupDTO; + const group = await createGroup(groupData, classid, assignmentId); + + if (!group) { + res.status(500).json({ error: 'Something went wrong while creating group' }); + return; + } + + res.status(201).json(group); +} + +export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const full = req.query.full === 'true'; + + const assignmentId = Number(req.params.assignmentid); + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groupId = Number(req.params.groupid); // Can't be undefined + + if (isNaN(groupId)) { + res.status(400).json({ error: 'Group id must be a number' }); + return; + } + + const submissions = await getGroupSubmissions(classId, assignmentId, groupId, full); + + res.json({ + submissions: submissions, + }); +} diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 455a4006..fc79ef0d 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -2,19 +2,19 @@ import { Request, Response } from 'express'; import { FALLBACK_LANG } from '../config.js'; import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.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 { BadRequestException } from '../exceptions.js'; import attachmentService from '../services/learning-objects/attachment-service.js'; import { NotFoundError } from '@mikro-orm/core'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { if (!req.params.hruid) { throw new BadRequestException('HRUID is required.'); } return { - hruid: req.params.hruid as string, - language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language, + hruid: req.params.hruid, + language: (req.query.language || getEnvVar(envVars.FallbackLanguage)) as Language, version: parseInt(req.query.version as string), }; } @@ -24,7 +24,7 @@ function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentif throw new BadRequestException('HRUID is required.'); } return { - hruid: req.params.hruid as string, + hruid: req.params.hruid, language: (req.query.language as Language) || FALLBACK_LANG, }; } @@ -40,7 +40,7 @@ export async function getAllLearningObjects(req: Request, res: Response): Promis learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); } - res.json(learningObjects); + res.json({ learningObjects: learningObjects }); } export async function getLearningObject(req: Request, res: Response): Promise { diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 37f92d91..04e44b59 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -2,13 +2,14 @@ import { Request, Response } from 'express'; import { themes } from '../data/themes.js'; import { FALLBACK_LANG } from '../config.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 { PersonalizationTarget, personalizedForGroup, personalizedForStudent, } 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. diff --git a/backend/src/controllers/learningObjects.ts b/backend/src/controllers/learningObjects.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/src/controllers/learningPaths.ts b/backend/src/controllers/learningPaths.ts deleted file mode 100644 index 0a7ff0ae..00000000 --- a/backend/src/controllers/learningPaths.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Request, Response } from 'express'; -import { themes } from '../data/themes.js'; -import { FALLBACK_LANG } from '../config.js'; -import { getLogger } from '../logging/initalize.js'; -import learningPathService from '../services/learning-paths/learning-path-service.js'; -import { Language } from '../entities/content/language.js'; -/** - * Fetch learning paths based on query parameters. - */ -export async function getLearningPaths(req: Request, res: Response): Promise { - try { - const hruids = req.query.hruid; - const themeKey = req.query.theme as string; - const searchQuery = req.query.search as string; - const language = (req.query.language as Language) || FALLBACK_LANG; - - let hruidList; - - if (hruids) { - hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; - } else if (themeKey) { - const theme = themes.find((t) => t.title === themeKey); - if (theme) { - hruidList = theme.hruids; - } else { - res.status(404).json({ - error: `Theme "${themeKey}" not found.`, - }); - return; - } - } else if (searchQuery) { - const searchResults = await learningPathService.searchLearningPaths(searchQuery, language); - res.json(searchResults); - return; - } else { - hruidList = themes.flatMap((theme) => theme.hruids); - } - - const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language, `HRUIDs: ${hruidList.join(', ')}`); - res.json(learningPaths.data); - } catch (error) { - getLogger().error('❌ Unexpected error fetching learning paths:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} diff --git a/backend/src/controllers/questions.ts b/backend/src/controllers/questions.ts new file mode 100644 index 00000000..6735c305 --- /dev/null +++ b/backend/src/controllers/questions.ts @@ -0,0 +1,119 @@ +import { Request, Response } from 'express'; +import { createQuestion, deleteQuestion, getAllQuestions, getAnswersByQuestion, getQuestion } from '../services/questions.js'; +import { QuestionDTO, QuestionId } from '../interfaces/question.js'; +import { FALLBACK_LANG, FALLBACK_SEQ_NUM } from '../config.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { Language } from '../entities/content/language.js'; + +function getObjectId(req: Request, res: Response): LearningObjectIdentifier | null { + const { hruid, version } = req.params; + const lang = req.query.lang; + + if (!hruid || !version) { + res.status(400).json({ error: 'Missing required parameters.' }); + return null; + } + + return { + hruid, + language: (lang as Language) || FALLBACK_LANG, + version: Number(version), + }; +} + +function getQuestionId(req: Request, res: Response): QuestionId | null { + const seq = req.params.seq; + const learningObjectIdentifier = getObjectId(req, res); + + if (!learningObjectIdentifier) { + return null; + } + + return { + learningObjectIdentifier, + sequenceNumber: seq ? Number(seq) : FALLBACK_SEQ_NUM, + }; +} + +export async function getAllQuestionsHandler(req: Request, res: Response): Promise { + const objectId = getObjectId(req, res); + const full = req.query.full === 'true'; + + if (!objectId) { + return; + } + + const questions = await getAllQuestions(objectId, full); + + if (!questions) { + res.status(404).json({ error: `Questions not found.` }); + } else { + res.json({ questions: questions }); + } +} + +export async function getQuestionHandler(req: Request, res: Response): Promise { + const questionId = getQuestionId(req, res); + + if (!questionId) { + return; + } + + const question = await getQuestion(questionId); + + if (!question) { + res.status(404).json({ error: `Question not found.` }); + } else { + res.json(question); + } +} + +export async function getQuestionAnswersHandler(req: Request, res: Response): Promise { + const questionId = getQuestionId(req, res); + const full = req.query.full === 'true'; + + if (!questionId) { + return; + } + + const answers = await getAnswersByQuestion(questionId, full); + + if (!answers) { + res.status(404).json({ error: `Questions not found` }); + } else { + res.json({ answers: answers }); + } +} + +export async function createQuestionHandler(req: Request, res: Response): Promise { + const questionDTO = req.body as QuestionDTO; + + if (!questionDTO.learningObjectIdentifier || !questionDTO.author || !questionDTO.content) { + res.status(400).json({ error: 'Missing required fields: identifier and content' }); + return; + } + + const question = await createQuestion(questionDTO); + + if (!question) { + res.status(400).json({ error: 'Could not create question' }); + } else { + res.json(question); + } +} + +export async function deleteQuestionHandler(req: Request, res: Response): Promise { + const questionId = getQuestionId(req, res); + + if (!questionId) { + return; + } + + const question = await deleteQuestion(questionId); + + if (!question) { + res.status(400).json({ error: 'Could not find nor delete question' }); + } else { + res.json(question); + } +} diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts new file mode 100644 index 00000000..7e0b5565 --- /dev/null +++ b/backend/src/controllers/students.ts @@ -0,0 +1,134 @@ +import { Request, Response } from 'express'; +import { + createStudent, + deleteStudent, + getAllStudents, + getStudent, + getStudentAssignments, + getStudentClasses, + getStudentGroups, + getStudentSubmissions, +} from '../services/students.js'; +import { StudentDTO } from '../interfaces/student.js'; + +// TODO: accept arguments (full, ...) +// TODO: endpoints +export async function getAllStudentsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + + const students = await getAllStudents(full); + + if (!students) { + res.status(404).json({ error: `Student not found.` }); + return; + } + + res.json({ students: students }); +} + +export async function getStudentHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const user = await getStudent(username); + + if (!user) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.json(user); +} + +export async function createStudentHandler(req: Request, res: Response): Promise { + const userData = req.body as StudentDTO; + + if (!userData.username || !userData.firstName || !userData.lastName) { + res.status(400).json({ + error: 'Missing required fields: username, firstName, lastName', + }); + return; + } + + const newUser = await createStudent(userData); + + if (!newUser) { + res.status(500).json({ + error: 'Something went wrong while creating student', + }); + return; + } + + res.status(201).json(newUser); +} + +export async function deleteStudentHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const deletedUser = await deleteStudent(username); + if (!deletedUser) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(200).json(deletedUser); +} + +export async function getStudentClassesHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const username = req.params.id; + + const classes = await getStudentClasses(username, full); + + res.json({ classes: classes }); +} + +// TODO +// Might not be fully correct depending on if +// A class has an assignment, that all students +// Have this assignment. +export async function getStudentAssignmentsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const username = req.params.id; + + const assignments = getStudentAssignments(username, full); + + res.json({ + assignments: assignments, + }); +} + +export async function getStudentGroupsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const username = req.params.id; + + const groups = await getStudentGroups(username, full); + + res.json({ + groups: groups, + }); +} + +export async function getStudentSubmissionsHandler(req: Request, res: Response): Promise { + const username = req.params.id; + const full = req.query.full === 'true'; + + const submissions = await getStudentSubmissions(username, full); + + res.json({ + submissions: submissions, + }); +} diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts new file mode 100644 index 00000000..67c1d3a9 --- /dev/null +++ b/backend/src/controllers/submissions.ts @@ -0,0 +1,61 @@ +import { Request, Response } from 'express'; +import { createSubmission, deleteSubmission, getSubmission } from '../services/submissions.js'; +import { Language, languageMap } from '../entities/content/language.js'; +import { SubmissionDTO } from '../interfaces/submission'; + +interface SubmissionParams { + hruid: string; + id: number; +} + +export async function getSubmissionHandler(req: Request, res: Response): Promise { + const lohruid = req.params.hruid; + const submissionNumber = Number(req.params.id); + + if (isNaN(submissionNumber)) { + res.status(400).json({ error: 'Submission number is not a number' }); + return; + } + + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + + const submission = await getSubmission(lohruid, lang, version, submissionNumber); + + if (!submission) { + res.status(404).json({ error: 'Submission not found' }); + return; + } + + res.json(submission); +} + +export async function createSubmissionHandler(req: Request, res: Response): Promise { + const submissionDTO = req.body as SubmissionDTO; + + const submission = await createSubmission(submissionDTO); + + if (!submission) { + res.status(400).json({ error: 'Failed to create submission' }); + return; + } + + res.json(submission); +} + +export async function deleteSubmissionHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const submissionNumber = Number(req.params.id); + + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + + const submission = await deleteSubmission(hruid, lang, version, submissionNumber); + + if (!submission) { + res.status(404).json({ error: 'Submission not found' }); + return; + } + + res.json(submission); +} diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts new file mode 100644 index 00000000..06316681 --- /dev/null +++ b/backend/src/controllers/teachers.ts @@ -0,0 +1,140 @@ +import { Request, Response } from 'express'; +import { + createTeacher, + deleteTeacher, + getAllTeachers, + getClassesByTeacher, + getQuestionsByTeacher, + getStudentsByTeacher, + getTeacher, +} from '../services/teachers.js'; +import { TeacherDTO } from '../interfaces/teacher.js'; + +export async function getAllTeachersHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + + const teachers = await getAllTeachers(full); + + if (!teachers) { + res.status(404).json({ error: `Teacher not found.` }); + return; + } + + res.json({ teachers: teachers }); +} + +export async function getTeacherHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const user = await getTeacher(username); + + if (!user) { + res.status(404).json({ + error: `Teacher '${username}' not found.`, + }); + return; + } + + res.json(user); +} + +export async function createTeacherHandler(req: Request, res: Response): Promise { + const userData = req.body as TeacherDTO; + + if (!userData.username || !userData.firstName || !userData.lastName) { + res.status(400).json({ + error: 'Missing required fields: username, firstName, lastName', + }); + return; + } + + const newUser = await createTeacher(userData); + + if (!newUser) { + res.status(400).json({ error: 'Failed to create teacher' }); + return; + } + + res.status(201).json(newUser); +} + +export async function deleteTeacherHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const deletedUser = await deleteTeacher(username); + if (!deletedUser) { + res.status(404).json({ + error: `User '${username}' not found.`, + }); + return; + } + + res.status(200).json(deletedUser); +} + +export async function getTeacherClassHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const full = req.query.full === 'true'; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + 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 { + const username = req.params.username; + const full = req.query.full === 'true'; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + 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 { + const username = req.params.username; + const full = req.query.full === 'true'; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const questions = await getQuestionsByTeacher(username, full); + + if (!questions) { + res.status(404).json({ error: 'Teacher not found' }); + return; + } + + res.json({ questions: questions }); +} diff --git a/backend/src/controllers/themes.ts b/backend/src/controllers/themes.ts index fe1eb818..b34d0c80 100644 --- a/backend/src/controllers/themes.ts +++ b/backend/src/controllers/themes.ts @@ -1,28 +1,32 @@ import { Request, Response } from 'express'; import { themes } from '../data/themes.js'; -import { loadTranslations } from '../util/translationHelper.js'; +import { loadTranslations } from '../util/translation-helper.js'; interface Translations { - curricula_page: { - [key: string]: { title: string; description?: string }; - }; + curricula_page: Record; } -export function getThemes(req: Request, res: Response) { - const language = (req.query.language as string)?.toLowerCase() || 'nl'; +export function getThemesHandler(req: Request, res: Response): void { + const language = ((req.query.language as string) || 'nl').toLowerCase(); const translations = loadTranslations(language); const themeList = themes.map((theme) => ({ key: theme.title, - title: translations.curricula_page[theme.title]?.title || theme.title, - description: translations.curricula_page[theme.title]?.description, + title: translations.curricula_page[theme.title].title || theme.title, + description: translations.curricula_page[theme.title].description, image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, })); res.json(themeList); } -export function getThemeByTitle(req: Request, res: Response) { +export function getHruidsByThemeHandler(req: Request, res: Response): void { const themeKey = req.params.theme; + + if (!themeKey) { + res.status(400).json({ error: 'Missing required field: theme' }); + return; + } + const theme = themes.find((t) => t.title === themeKey); if (theme) { diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts index c3c457d3..3de5031d 100644 --- a/backend/src/data/assignments/assignment-repository.ts +++ b/backend/src/data/assignments/assignment-repository.ts @@ -3,13 +3,13 @@ import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Class } from '../../entities/classes/class.entity.js'; export class AssignmentRepository extends DwengoEntityRepository { - public findByClassAndId(within: Class, id: number): Promise { + public async findByClassAndId(within: Class, id: number): Promise { return this.findOne({ within: within, id: id }); } - public findAllAssignmentsInClass(within: Class): Promise { + public async findAllAssignmentsInClass(within: Class): Promise { return this.findAll({ where: { within: within } }); } - public deleteByClassAndId(within: Class, id: number): Promise { + public async deleteByClassAndId(within: Class, id: number): Promise { return this.deleteWhere({ within: within, id: id }); } } diff --git a/backend/src/data/assignments/group-repository.ts b/backend/src/data/assignments/group-repository.ts index df92eaae..f06080f7 100644 --- a/backend/src/data/assignments/group-repository.ts +++ b/backend/src/data/assignments/group-repository.ts @@ -1,18 +1,28 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Group } from '../../entities/assignments/group.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; export class GroupRepository extends DwengoEntityRepository { - public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise { - return this.findOne({ - assignment: assignment, - groupNumber: groupNumber, + public async findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise { + return this.findOne( + { + assignment: assignment, + groupNumber: groupNumber, + }, + { populate: ['members'] } + ); + } + public async findAllGroupsForAssignment(assignment: Assignment): Promise { + return this.findAll({ + where: { assignment: assignment }, + populate: ['members'], }); } - public findAllGroupsForAssignment(assignment: Assignment): Promise { - return this.findAll({ where: { assignment: assignment } }); + public async findAllGroupsWithStudent(student: Student): Promise { + return this.find({ members: student }, { populate: ['members'] }); } - public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) { + public async deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise { return this.deleteWhere({ assignment: assignment, groupNumber: groupNumber, diff --git a/backend/src/data/assignments/submission-repository.ts b/backend/src/data/assignments/submission-repository.ts index faa9fef1..f5090adc 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -5,7 +5,10 @@ import { LearningObjectIdentifier } from '../../entities/content/learning-object import { Student } from '../../entities/users/student.entity.js'; export class SubmissionRepository extends DwengoEntityRepository { - public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + public async findSubmissionByLearningObjectAndSubmissionNumber( + loId: LearningObjectIdentifier, + submissionNumber: number + ): Promise { return this.findOne({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, @@ -14,7 +17,7 @@ export class SubmissionRepository extends DwengoEntityRepository { }); } - public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { + public async findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { return this.findOne( { learningObjectHruid: loId.hruid, @@ -26,7 +29,7 @@ export class SubmissionRepository extends DwengoEntityRepository { ); } - public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise { + public async findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise { return this.findOne( { learningObjectHruid: loId.hruid, @@ -38,7 +41,15 @@ export class SubmissionRepository extends DwengoEntityRepository { ); } - public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + public async findAllSubmissionsForGroup(group: Group): Promise { + return this.find({ onBehalfOf: group }); + } + + public async findAllSubmissionsForStudent(student: Student): Promise { + return this.find({ submitter: student }); + } + + public async deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { return this.deleteWhere({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, diff --git a/backend/src/data/classes/class-join-request-repository.ts b/backend/src/data/classes/class-join-request-repository.ts index c1443c1c..1cd0288c 100644 --- a/backend/src/data/classes/class-join-request-repository.ts +++ b/backend/src/data/classes/class-join-request-repository.ts @@ -4,13 +4,13 @@ import { ClassJoinRequest } from '../../entities/classes/class-join-request.enti import { Student } from '../../entities/users/student.entity.js'; export class ClassJoinRequestRepository extends DwengoEntityRepository { - public findAllRequestsBy(requester: Student): Promise { + public async findAllRequestsBy(requester: Student): Promise { return this.findAll({ where: { requester: requester } }); } - public findAllOpenRequestsTo(clazz: Class): Promise { + public async findAllOpenRequestsTo(clazz: Class): Promise { return this.findAll({ where: { class: clazz } }); } - public deleteBy(requester: Student, clazz: Class): Promise { + public async deleteBy(requester: Student, clazz: Class): Promise { return this.deleteWhere({ requester: requester, class: clazz }); } } diff --git a/backend/src/data/classes/class-repository.ts b/backend/src/data/classes/class-repository.ts index e3b9f959..f4e0723f 100644 --- a/backend/src/data/classes/class-repository.ts +++ b/backend/src/data/classes/class-repository.ts @@ -1,11 +1,23 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; +import { Teacher } from '../../entities/users/teacher.entity'; export class ClassRepository extends DwengoEntityRepository { - public findById(id: string): Promise { - return this.findOne({ classId: id }); + public async findById(id: string): Promise { + return this.findOne({ classId: id }, { populate: ['students', 'teachers'] }); } - public deleteById(id: string): Promise { + public async deleteById(id: string): Promise { return this.deleteWhere({ classId: id }); } + public async findByStudent(student: Student): Promise { + return this.find( + { students: student }, + { populate: ['students', 'teachers'] } // Voegt student en teacher objecten toe + ); + } + + public async findByTeacher(teacher: Teacher): Promise { + return this.find({ teachers: teacher }, { populate: ['students', 'teachers'] }); + } } diff --git a/backend/src/data/classes/teacher-invitation-repository.ts b/backend/src/data/classes/teacher-invitation-repository.ts index 6b94deec..ce059ca8 100644 --- a/backend/src/data/classes/teacher-invitation-repository.ts +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -4,16 +4,16 @@ import { TeacherInvitation } from '../../entities/classes/teacher-invitation.ent import { Teacher } from '../../entities/users/teacher.entity.js'; export class TeacherInvitationRepository extends DwengoEntityRepository { - public findAllInvitationsForClass(clazz: Class): Promise { + public async findAllInvitationsForClass(clazz: Class): Promise { return this.findAll({ where: { class: clazz } }); } - public findAllInvitationsBy(sender: Teacher): Promise { + public async findAllInvitationsBy(sender: Teacher): Promise { return this.findAll({ where: { sender: sender } }); } - public findAllInvitationsFor(receiver: Teacher): Promise { + public async findAllInvitationsFor(receiver: Teacher): Promise { return this.findAll({ where: { receiver: receiver } }); } - public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { + public async deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { return this.deleteWhere({ sender: sender, receiver: receiver, diff --git a/backend/src/data/content/attachment-repository.ts b/backend/src/data/content/attachment-repository.ts index 95c5ab1c..73baa943 100644 --- a/backend/src/data/content/attachment-repository.ts +++ b/backend/src/data/content/attachment-repository.ts @@ -4,7 +4,7 @@ import { Language } from '../../entities/content/language'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier'; export class AttachmentRepository extends DwengoEntityRepository { - public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise { + public async findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise { return this.findOne({ learningObject: { hruid: learningObjectId.hruid, @@ -15,7 +15,11 @@ export class AttachmentRepository extends DwengoEntityRepository { }); } - public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise { + public async findByMostRecentVersionOfLearningObjectAndName( + hruid: string, + language: Language, + attachmentName: string + ): Promise { return this.findOne( { learningObject: { diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts index f9b6bfcb..4684c6cc 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -1,10 +1,11 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; -import { Language } from '../../entities/content/language'; +import { Language } from '../../entities/content/language.js'; +import { Teacher } from '../../entities/users/teacher.entity.js'; export class LearningObjectRepository extends DwengoEntityRepository { - public findByIdentifier(identifier: LearningObjectIdentifier): Promise { + public async findByIdentifier(identifier: LearningObjectIdentifier): Promise { return this.findOne( { hruid: identifier.hruid, @@ -17,7 +18,7 @@ export class LearningObjectRepository extends DwengoEntityRepository { return this.findOne( { hruid: hruid, @@ -31,4 +32,11 @@ export class LearningObjectRepository extends DwengoEntityRepository { + return this.find( + { admins: teacher }, + { populate: ['admins'] } // Make sure to load admin relations + ); + } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index a2f9b47e..e34508ec 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -3,7 +3,7 @@ import { LearningPath } from '../../entities/content/learning-path.entity.js'; import { Language } from '../../entities/content/language.js'; export class LearningPathRepository extends DwengoEntityRepository { - public findByHruidAndLanguage(hruid: string, language: Language): Promise { + public async findByHruidAndLanguage(hruid: string, language: Language): Promise { return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); } diff --git a/backend/src/data/dwengo-entity-repository.ts b/backend/src/data/dwengo-entity-repository.ts index 6538d6f5..1267c726 100644 --- a/backend/src/data/dwengo-entity-repository.ts +++ b/backend/src/data/dwengo-entity-repository.ts @@ -1,12 +1,14 @@ import { EntityRepository, FilterQuery } from '@mikro-orm/core'; +import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception.js'; export abstract class DwengoEntityRepository extends EntityRepository { - public async save(entity: T) { - const em = this.getEntityManager(); - em.persist(entity); - await em.flush(); + public async save(entity: T, options?: { preventOverwrite?: boolean }): Promise { + if (options?.preventOverwrite && (await this.findOne(entity))) { + throw new EntityAlreadyExistsException(`A ${this.getEntityName()} with this identifier already exists.`); + } + await this.getEntityManager().persistAndFlush(entity); } - public async deleteWhere(query: FilterQuery) { + public async deleteWhere(query: FilterQuery): Promise { const toDelete = await this.findOne(query); const em = this.getEntityManager(); if (toDelete) { diff --git a/backend/src/data/questions/answer-repository.ts b/backend/src/data/questions/answer-repository.ts index a28342bd..a50bfd28 100644 --- a/backend/src/data/questions/answer-repository.ts +++ b/backend/src/data/questions/answer-repository.ts @@ -4,7 +4,7 @@ import { Question } from '../../entities/questions/question.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; export class AnswerRepository extends DwengoEntityRepository { - public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise { + public async createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise { const answerEntity = this.create({ toQuestion: answer.toQuestion, author: answer.author, @@ -13,13 +13,13 @@ export class AnswerRepository extends DwengoEntityRepository { }); return this.insert(answerEntity); } - public findAllAnswersToQuestion(question: Question): Promise { + public async findAllAnswersToQuestion(question: Question): Promise { return this.findAll({ where: { toQuestion: question }, orderBy: { sequenceNumber: 'ASC' }, }); } - public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise { + public async removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise { return this.deleteWhere({ toQuestion: question, sequenceNumber: sequenceNumber, diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 4099c528..596b562c 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -2,9 +2,10 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Question } from '../../entities/questions/question.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; export class QuestionRepository extends DwengoEntityRepository { - public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { + public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { const questionEntity = this.create({ learningObjectHruid: question.loId.hruid, learningObjectLanguage: question.loId.language, @@ -20,7 +21,7 @@ export class QuestionRepository extends DwengoEntityRepository { questionEntity.content = question.content; return this.insert(questionEntity); } - public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { + public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { return this.findAll({ where: { learningObjectHruid: loId.hruid, @@ -32,7 +33,7 @@ export class QuestionRepository extends DwengoEntityRepository { }, }); } - public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise { + public async removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise { return this.deleteWhere({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, @@ -40,4 +41,17 @@ export class QuestionRepository extends DwengoEntityRepository { sequenceNumber: sequenceNumber, }); } + + public async findAllByLearningObjects(learningObjects: LearningObject[]): Promise { + const objectIdentifiers = learningObjects.map((lo) => ({ + learningObjectHruid: lo.hruid, + learningObjectLanguage: lo.language, + learningObjectVersion: lo.version, + })); + + return this.findAll({ + where: { $or: objectIdentifiers }, + orderBy: { timestamp: 'ASC' }, + }); + } } diff --git a/backend/src/data/repositories.ts b/backend/src/data/repositories.ts index 3daa026d..f09c3c75 100644 --- a/backend/src/data/repositories.ts +++ b/backend/src/data/repositories.ts @@ -2,8 +2,6 @@ import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-o import { forkEntityManager } from '../orm.js'; import { StudentRepository } from './users/student-repository.js'; import { Student } from '../entities/users/student.entity.js'; -import { User } from '../entities/users/user.entity.js'; -import { UserRepository } from './users/user-repository.js'; import { Teacher } from '../entities/users/teacher.entity.js'; import { TeacherRepository } from './users/teacher-repository.js'; import { Class } from '../entities/classes/class.entity.js'; @@ -36,8 +34,8 @@ let entityManager: EntityManager | undefined; /** * Execute all the database operations within the function f in a single transaction. */ -export function transactional(f: () => Promise) { - entityManager?.transactional(f); +export async function transactional(f: () => Promise): Promise { + await entityManager?.transactional(f); } function repositoryGetter>(entity: EntityName): () => R { @@ -54,7 +52,6 @@ function repositoryGetter>(en } /* Users */ -export const getUserRepository = repositoryGetter(User); export const getStudentRepository = repositoryGetter(Student); export const getTeacherRepository = repositoryGetter(Teacher); diff --git a/backend/src/data/users/student-repository.ts b/backend/src/data/users/student-repository.ts index 1c3a6fae..2efca048 100644 --- a/backend/src/data/users/student-repository.ts +++ b/backend/src/data/users/student-repository.ts @@ -1,11 +1,11 @@ -import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Student } from '../../entities/users/student.entity.js'; +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; export class StudentRepository extends DwengoEntityRepository { - public findByUsername(username: string): Promise { + public async findByUsername(username: string): Promise { return this.findOne({ username: username }); } - public deleteByUsername(username: string): Promise { + public async deleteByUsername(username: string): Promise { return this.deleteWhere({ username: username }); } } diff --git a/backend/src/data/users/teacher-repository.ts b/backend/src/data/users/teacher-repository.ts index 704ef409..aa915627 100644 --- a/backend/src/data/users/teacher-repository.ts +++ b/backend/src/data/users/teacher-repository.ts @@ -1,11 +1,11 @@ -import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; export class TeacherRepository extends DwengoEntityRepository { - public findByUsername(username: string): Promise { + public async findByUsername(username: string): Promise { return this.findOne({ username: username }); } - public deleteByUsername(username: string): Promise { + public async deleteByUsername(username: string): Promise { return this.deleteWhere({ username: username }); } } diff --git a/backend/src/data/users/user-repository.ts b/backend/src/data/users/user-repository.ts index 7e2a42ad..44eb0bc7 100644 --- a/backend/src/data/users/user-repository.ts +++ b/backend/src/data/users/user-repository.ts @@ -1,11 +1,11 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { User } from '../../entities/users/user.entity.js'; -export class UserRepository extends DwengoEntityRepository { - public findByUsername(username: string): Promise { - return this.findOne({ username: username }); +export class UserRepository extends DwengoEntityRepository { + public async findByUsername(username: string): Promise { + return this.findOne({ username } as Partial); } - public deleteByUsername(username: string): Promise { - return this.deleteWhere({ username: username }); + public async deleteByUsername(username: string): Promise { + return this.deleteWhere({ username } as Partial); } } diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts index cda27d66..daa71ed6 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -4,13 +4,18 @@ import { Group } from './group.entity.js'; import { Language } from '../content/language.js'; import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; -@Entity({ repository: () => AssignmentRepository }) +@Entity({ + repository: () => AssignmentRepository, +}) export class Assignment { - @ManyToOne({ entity: () => Class, primary: true }) + @ManyToOne({ + entity: () => Class, + primary: true, + }) within!: Class; - @PrimaryKey({ type: 'number' }) - id!: number; + @PrimaryKey({ type: 'number', autoincrement: true }) + id?: number; @Property({ type: 'string' }) title!: string; @@ -21,9 +26,14 @@ export class Assignment { @Property({ type: 'string' }) learningPathHruid!: string; - @Enum({ items: () => Language }) + @Enum({ + items: () => Language, + }) learningPathLanguage!: Language; - @OneToMany({ entity: () => Group, mappedBy: 'assignment' }) + @OneToMany({ + entity: () => Group, + mappedBy: 'assignment', + }) groups!: Group[]; } diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts index 632ad722..cfe21f7f 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -3,7 +3,9 @@ import { Assignment } from './assignment.entity.js'; import { Student } from '../users/student.entity.js'; import { GroupRepository } from '../../data/assignments/group-repository.js'; -@Entity({ repository: () => GroupRepository }) +@Entity({ + repository: () => GroupRepository, +}) export class Group { @ManyToOne({ entity: () => Assignment, @@ -11,8 +13,8 @@ export class Group { }) assignment!: Assignment; - @PrimaryKey({ type: 'integer' }) - groupNumber!: number; + @PrimaryKey({ type: 'integer', autoincrement: true }) + groupNumber?: number; @ManyToMany({ entity: () => Student, diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index f6f8b3c7..e4330e0d 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -16,10 +16,10 @@ export class Submission { learningObjectLanguage!: Language; @PrimaryKey({ type: 'numeric' }) - learningObjectVersion: number = 1; + learningObjectVersion = 1; - @PrimaryKey({ type: 'integer' }) - submissionNumber!: number; + @PrimaryKey({ type: 'integer', autoincrement: true }) + submissionNumber?: number; @ManyToOne({ entity: () => Student, diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index 62ed37d0..fdf13aa9 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -3,7 +3,15 @@ import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; -@Entity({ repository: () => ClassJoinRequestRepository }) +export enum ClassJoinRequestStatus { + Open = 'open', + Accepted = 'accepted', + Declined = 'declined', +} + +@Entity({ + repository: () => ClassJoinRequestRepository, +}) export class ClassJoinRequest { @ManyToOne({ entity: () => Student, @@ -20,9 +28,3 @@ export class ClassJoinRequest { @Enum(() => ClassJoinRequestStatus) status!: ClassJoinRequestStatus; } - -export enum ClassJoinRequestStatus { - Open = 'open', - Accepted = 'accepted', - Declined = 'declined', -} diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index d4e44bf9..63315304 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -4,10 +4,12 @@ import { Teacher } from '../users/teacher.entity.js'; import { Student } from '../users/student.entity.js'; import { ClassRepository } from '../../data/classes/class-repository.js'; -@Entity({ repository: () => ClassRepository }) +@Entity({ + repository: () => ClassRepository, +}) export class Class { @PrimaryKey() - classId = v4(); + classId? = v4(); @Property({ type: 'string' }) displayName!: string; diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index eb57d98a..668a0a1c 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -7,9 +7,6 @@ import { TeacherInvitationRepository } from '../../data/classes/teacher-invitati * Invitation of a teacher into a class (in order to teach it). */ @Entity({ repository: () => TeacherInvitationRepository }) -@Entity({ - repository: () => TeacherInvitationRepository, -}) export class TeacherInvitation { @ManyToOne({ entity: () => Teacher, diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts index 0c0f53c4..80104f28 100644 --- a/backend/src/entities/content/attachment.entity.ts +++ b/backend/src/entities/content/attachment.entity.ts @@ -2,7 +2,9 @@ import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; import { LearningObject } from './learning-object.entity.js'; import { AttachmentRepository } from '../../data/content/attachment-repository.js'; -@Entity({ repository: () => AttachmentRepository }) +@Entity({ + repository: () => AttachmentRepository, +}) export class Attachment { @ManyToOne({ entity: () => LearningObject, diff --git a/backend/src/entities/content/educational-goal.entity.ts b/backend/src/entities/content/educational-goal.entity.ts new file mode 100644 index 00000000..fafe1a01 --- /dev/null +++ b/backend/src/entities/content/educational-goal.entity.ts @@ -0,0 +1,10 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +@Embeddable() +export class EducationalGoal { + @Property({ type: 'string' }) + source!: string; + + @Property({ type: 'string' }) + id!: string; +} diff --git a/backend/src/entities/content/language.ts b/backend/src/entities/content/language.ts index d7687331..7e7b42d2 100644 --- a/backend/src/entities/content/language.ts +++ b/backend/src/entities/content/language.ts @@ -184,3 +184,10 @@ export enum Language { Zhuang = 'za', Zulu = 'zu', } + +export const languageMap: Record = { + nl: Language.Dutch, + fr: Language.French, + en: Language.English, + de: Language.German, +}; diff --git a/backend/src/entities/content/learning-object-identifier.ts b/backend/src/entities/content/learning-object-identifier.ts index 3c020bd7..9234afa7 100644 --- a/backend/src/entities/content/learning-object-identifier.ts +++ b/backend/src/entities/content/learning-object-identifier.ts @@ -5,5 +5,7 @@ export class LearningObjectIdentifier { public hruid: string, public language: Language, public version: number - ) {} + ) { + // Do nothing + } } diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index 9eda22ba..e352a10a 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -1,28 +1,12 @@ -import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Language } from './language.js'; import { Attachment } from './attachment.entity.js'; import { Teacher } from '../users/teacher.entity.js'; import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; import { v4 } from 'uuid'; import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; - -@Embeddable() -export class EducationalGoal { - @Property({ type: 'string' }) - source!: string; - - @Property({ type: 'string' }) - id!: string; -} - -@Embeddable() -export class ReturnValue { - @Property({ type: 'string' }) - callbackUrl!: string; - - @Property({ type: 'json' }) - callbackSchema!: string; -} +import { EducationalGoal } from './educational-goal.entity.js'; +import { ReturnValue } from './return-value.entity.js'; @Entity({ repository: () => LearningObjectRepository }) export class LearningObject { @@ -36,7 +20,7 @@ export class LearningObject { language!: Language; @PrimaryKey({ type: 'number' }) - version: number = 1; + version = 1; @Property({ type: 'uuid', unique: true }) uuid = v4(); @@ -62,7 +46,7 @@ export class LearningObject { targetAges?: number[] = []; @Property({ type: 'bool' }) - teacherExclusive: boolean = false; + teacherExclusive = false; @Property({ type: 'array' }) skosConcepts: string[] = []; @@ -74,10 +58,10 @@ export class LearningObject { educationalGoals: EducationalGoal[] = []; @Property({ type: 'string' }) - copyright: string = ''; + copyright = ''; @Property({ type: 'string' }) - license: string = ''; + license = ''; @Property({ type: 'smallint', nullable: true }) difficulty?: number; @@ -91,7 +75,7 @@ export class LearningObject { returnValue!: ReturnValue; @Property({ type: 'bool' }) - available: boolean = true; + available = true; @Property({ type: 'string', nullable: true }) contentLocation?: string; diff --git a/backend/src/entities/content/return-value.entity.ts b/backend/src/entities/content/return-value.entity.ts new file mode 100644 index 00000000..d38b0693 --- /dev/null +++ b/backend/src/entities/content/return-value.entity.ts @@ -0,0 +1,10 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +@Embeddable() +export class ReturnValue { + @Property({ type: 'string' }) + callbackUrl!: string; + + @Property({ type: 'json' }) + callbackSchema!: string; +} diff --git a/backend/src/entities/questions/question.entity.ts b/backend/src/entities/questions/question.entity.ts index 058ba6b3..09e3cd46 100644 --- a/backend/src/entities/questions/question.entity.ts +++ b/backend/src/entities/questions/question.entity.ts @@ -15,7 +15,7 @@ export class Question { learningObjectLanguage!: Language; @PrimaryKey({ type: 'number' }) - learningObjectVersion: number = 1; + learningObjectVersion = 1; @PrimaryKey({ type: 'integer', autoincrement: true }) sequenceNumber?: number; diff --git a/backend/src/entities/users/student.entity.ts b/backend/src/entities/users/student.entity.ts index da5b4367..58e82765 100644 --- a/backend/src/entities/users/student.entity.ts +++ b/backend/src/entities/users/student.entity.ts @@ -13,12 +13,4 @@ export class Student extends User { @ManyToMany(() => Group) groups!: Collection; - - constructor( - public username: string, - public firstName: string, - public lastName: string - ) { - super(); - } } diff --git a/backend/src/entities/users/teacher.entity.ts b/backend/src/entities/users/teacher.entity.ts index 8e22d1de..d53ca603 100644 --- a/backend/src/entities/users/teacher.entity.ts +++ b/backend/src/entities/users/teacher.entity.ts @@ -7,12 +7,4 @@ import { TeacherRepository } from '../../data/users/teacher-repository.js'; export class Teacher extends User { @ManyToMany(() => Class) classes!: Collection; - - constructor( - public username: string, - public firstName: string, - public lastName: string - ) { - super(); - } } diff --git a/backend/src/entities/users/user.entity.ts b/backend/src/entities/users/user.entity.ts index 1f35a0f8..15637110 100644 --- a/backend/src/entities/users/user.entity.ts +++ b/backend/src/entities/users/user.entity.ts @@ -6,8 +6,8 @@ export abstract class User { username!: string; @Property() - firstName: string = ''; + firstName = ''; @Property() - lastName: string = ''; + lastName = ''; } diff --git a/backend/src/exceptions.ts b/backend/src/exceptions.ts deleted file mode 100644 index e93a6c93..00000000 --- a/backend/src/exceptions.ts +++ /dev/null @@ -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); - } -} diff --git a/backend/src/exceptions/bad-request-exception.ts b/backend/src/exceptions/bad-request-exception.ts new file mode 100644 index 00000000..f6672a62 --- /dev/null +++ b/backend/src/exceptions/bad-request-exception.ts @@ -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); + } +} diff --git a/backend/src/exceptions/conflict-exception.ts b/backend/src/exceptions/conflict-exception.ts new file mode 100644 index 00000000..ed1d0b24 --- /dev/null +++ b/backend/src/exceptions/conflict-exception.ts @@ -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); + } +} diff --git a/backend/src/exceptions/entity-already-exists-exception.ts b/backend/src/exceptions/entity-already-exists-exception.ts new file mode 100644 index 00000000..2769b814 --- /dev/null +++ b/backend/src/exceptions/entity-already-exists-exception.ts @@ -0,0 +1,7 @@ +import { ConflictException } from './conflict-exception.js'; + +export class EntityAlreadyExistsException extends ConflictException { + constructor(message: string) { + super(message); + } +} diff --git a/backend/src/exceptions/exception-with-http-state.ts b/backend/src/exceptions/exception-with-http-state.ts new file mode 100644 index 00000000..e5b9b9bd --- /dev/null +++ b/backend/src/exceptions/exception-with-http-state.ts @@ -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); + } +} diff --git a/backend/src/exceptions/forbidden-exception.ts b/backend/src/exceptions/forbidden-exception.ts new file mode 100644 index 00000000..4c58d1d5 --- /dev/null +++ b/backend/src/exceptions/forbidden-exception.ts @@ -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 = 'Forbidden') { + super(403, message); + } +} diff --git a/backend/src/exceptions/not-found-exception.ts b/backend/src/exceptions/not-found-exception.ts new file mode 100644 index 00000000..a3e7d762 --- /dev/null +++ b/backend/src/exceptions/not-found-exception.ts @@ -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); + } +} diff --git a/backend/src/exceptions/unauthorized-exception.ts b/backend/src/exceptions/unauthorized-exception.ts new file mode 100644 index 00000000..54aa7cf9 --- /dev/null +++ b/backend/src/exceptions/unauthorized-exception.ts @@ -0,0 +1,10 @@ +import { ExceptionWithHttpState } from './exception-with-http-state.js'; + +/** + * Exception for HTTP 401 Unauthorized + */ +export class UnauthorizedException extends ExceptionWithHttpState { + constructor(message = 'Unauthorized') { + super(401, message); + } +} diff --git a/backend/src/interfaces/answer.ts b/backend/src/interfaces/answer.ts new file mode 100644 index 00000000..493fd3c0 --- /dev/null +++ b/backend/src/interfaces/answer.ts @@ -0,0 +1,38 @@ +import { mapToUserDTO, UserDTO } from './user.js'; +import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from './question.js'; +import { Answer } from '../entities/questions/answer.entity.js'; + +export interface AnswerDTO { + author: UserDTO; + toQuestion: QuestionDTO; + sequenceNumber: number; + timestamp: string; + content: string; +} + +/** + * Convert a Question entity to a DTO format. + */ +export function mapToAnswerDTO(answer: Answer): AnswerDTO { + return { + author: mapToUserDTO(answer.author), + toQuestion: mapToQuestionDTO(answer.toQuestion), + sequenceNumber: answer.sequenceNumber!, + timestamp: answer.timestamp.toISOString(), + content: answer.content, + }; +} + +export interface AnswerId { + author: string; + toQuestion: QuestionId; + sequenceNumber: number; +} + +export function mapToAnswerId(answer: AnswerDTO): AnswerId { + return { + author: answer.author.username, + toQuestion: mapToQuestionId(answer.toQuestion), + sequenceNumber: answer.sequenceNumber, + }; +} diff --git a/backend/src/interfaces/assignment.ts b/backend/src/interfaces/assignment.ts new file mode 100644 index 00000000..698b5b40 --- /dev/null +++ b/backend/src/interfaces/assignment.ts @@ -0,0 +1,53 @@ +import { FALLBACK_LANG } from '../config.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { languageMap } from '../entities/content/language.js'; +import { GroupDTO } from './group.js'; +import { getLogger } from '../logging/initalize.js'; + +export interface AssignmentDTO { + id: number; + class: string; // Id of class 'within' + title: string; + description: string; + learningPath: string; + language: string; + groups?: GroupDTO[] | string[]; // TODO +} + +export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTO { + return { + id: assignment.id!, + class: assignment.within.classId!, + title: assignment.title, + description: assignment.description, + learningPath: assignment.learningPathHruid, + language: assignment.learningPathLanguage, + // Groups: assignment.groups.map(group => group.groupNumber), + }; +} + +export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { + return { + id: assignment.id!, + class: assignment.within.classId!, + title: assignment.title, + description: assignment.description, + learningPath: assignment.learningPathHruid, + language: assignment.learningPathLanguage, + // Groups: assignment.groups.map(mapToGroupDTO), + }; +} + +export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assignment { + const assignment = new Assignment(); + assignment.title = assignmentData.title; + assignment.description = assignmentData.description; + assignment.learningPathHruid = assignmentData.learningPath; + assignment.learningPathLanguage = languageMap[assignmentData.language] || FALLBACK_LANG; + assignment.within = cls; + + getLogger().debug(assignment); + + return assignment; +} diff --git a/backend/src/interfaces/class.ts b/backend/src/interfaces/class.ts new file mode 100644 index 00000000..ea1d4901 --- /dev/null +++ b/backend/src/interfaces/class.ts @@ -0,0 +1,31 @@ +import { Collection } from '@mikro-orm/core'; +import { Class } from '../entities/classes/class.entity.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; + +export interface ClassDTO { + id: string; + displayName: string; + teachers: string[]; + students: string[]; + joinRequests: string[]; +} + +export function mapToClassDTO(cls: Class): ClassDTO { + return { + id: cls.classId!, + displayName: cls.displayName, + teachers: cls.teachers.map((teacher) => teacher.username), + students: cls.students.map((student) => student.username), + joinRequests: [], // TODO + }; +} + +export function mapToClass(classData: ClassDTO, students: Collection, teachers: Collection): Class { + const cls = new Class(); + cls.displayName = classData.displayName; + cls.students = students; + cls.teachers = teachers; + + return cls; +} diff --git a/backend/src/interfaces/group.ts b/backend/src/interfaces/group.ts new file mode 100644 index 00000000..a25c5b8e --- /dev/null +++ b/backend/src/interfaces/group.ts @@ -0,0 +1,25 @@ +import { Group } from '../entities/assignments/group.entity.js'; +import { AssignmentDTO, mapToAssignmentDTO } from './assignment.js'; +import { mapToStudentDTO, StudentDTO } from './student.js'; + +export interface GroupDTO { + assignment: number | AssignmentDTO; + groupNumber: number; + members: string[] | StudentDTO[]; +} + +export function mapToGroupDTO(group: Group): GroupDTO { + return { + assignment: mapToAssignmentDTO(group.assignment), // ERROR: , group.assignment.within), + groupNumber: group.groupNumber!, + members: group.members.map(mapToStudentDTO), + }; +} + +export function mapToGroupDTOId(group: Group): GroupDTO { + return { + assignment: group.assignment.id!, + groupNumber: group.groupNumber!, + members: group.members.map((member) => member.username), + }; +} diff --git a/backend/src/interfaces/learning-content.ts b/backend/src/interfaces/learning-content.ts index 51474917..693aec37 100644 --- a/backend/src/interfaces/learning-content.ts +++ b/backend/src/interfaces/learning-content.ts @@ -58,7 +58,7 @@ export interface EducationalGoal { export interface ReturnValue { callback_url: string; - callback_schema: Record; + callback_schema: Record; } export interface LearningObjectMetadata { diff --git a/backend/src/interfaces/list.ts b/backend/src/interfaces/list.ts new file mode 100644 index 00000000..6892fb9d --- /dev/null +++ b/backend/src/interfaces/list.ts @@ -0,0 +1,5 @@ +// TODO: implement something like this but with named endpoints +export interface List { + items: T[]; + endpoints?: string[]; +} diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts new file mode 100644 index 00000000..0da87eb7 --- /dev/null +++ b/backend/src/interfaces/question.ts @@ -0,0 +1,42 @@ +import { Question } from '../entities/questions/question.entity.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { mapToStudentDTO, StudentDTO } from './student.js'; + +export interface QuestionDTO { + learningObjectIdentifier: LearningObjectIdentifier; + sequenceNumber?: number; + author: StudentDTO; + timestamp?: string; + content: string; +} + +/** + * Convert a Question entity to a DTO format. + */ +export function mapToQuestionDTO(question: Question): QuestionDTO { + const learningObjectIdentifier = { + hruid: question.learningObjectHruid, + language: question.learningObjectLanguage, + version: question.learningObjectVersion, + }; + + return { + learningObjectIdentifier, + sequenceNumber: question.sequenceNumber!, + author: mapToStudentDTO(question.author), + timestamp: question.timestamp.toISOString(), + content: question.content, + }; +} + +export interface QuestionId { + learningObjectIdentifier: LearningObjectIdentifier; + sequenceNumber: number; +} + +export function mapToQuestionId(question: QuestionDTO): QuestionId { + return { + learningObjectIdentifier: question.learningObjectIdentifier, + sequenceNumber: question.sequenceNumber!, + }; +} diff --git a/backend/src/interfaces/student.ts b/backend/src/interfaces/student.ts new file mode 100644 index 00000000..ecce8f89 --- /dev/null +++ b/backend/src/interfaces/student.ts @@ -0,0 +1,32 @@ +import { Student } from '../entities/users/student.entity.js'; +import { getStudentRepository } from '../data/repositories.js'; + +export interface StudentDTO { + id: string; + username: string; + firstName: string; + lastName: string; + endpoints?: { + classes: string; + questions: string; + invitations: string; + groups: string; + }; +} + +export function mapToStudentDTO(student: Student): StudentDTO { + return { + id: student.username, + username: student.username, + firstName: student.firstName, + lastName: student.lastName, + }; +} + +export function mapToStudent(studentData: StudentDTO): Student { + return getStudentRepository().create({ + username: studentData.username, + firstName: studentData.firstName, + lastName: studentData.lastName, + }); +} diff --git a/backend/src/interfaces/submission.ts b/backend/src/interfaces/submission.ts new file mode 100644 index 00000000..98cc4f22 --- /dev/null +++ b/backend/src/interfaces/submission.ts @@ -0,0 +1,64 @@ +import { Submission } from '../entities/assignments/submission.entity.js'; +import { Language } from '../entities/content/language.js'; +import { GroupDTO, mapToGroupDTO } from './group.js'; +import { mapToStudent, mapToStudentDTO, StudentDTO } from './student.js'; +import { LearningObjectIdentifier } from './learning-content.js'; + +export interface SubmissionDTO { + learningObjectIdentifier: LearningObjectIdentifier; + + submissionNumber?: number; + submitter: StudentDTO; + time?: Date; + group?: GroupDTO; + content: string; +} + +export interface SubmissionDTOId { + learningObjectHruid: string; + learningObjectLanguage: Language; + learningObjectVersion: number; + + submissionNumber?: number; +} + +export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { + return { + learningObjectIdentifier: { + hruid: submission.learningObjectHruid, + language: submission.learningObjectLanguage, + version: submission.learningObjectVersion, + }, + + submissionNumber: submission.submissionNumber, + submitter: mapToStudentDTO(submission.submitter), + time: submission.submissionTime, + group: submission.onBehalfOf ? mapToGroupDTO(submission.onBehalfOf) : undefined, + content: submission.content, + }; +} + +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 { + const submission = new Submission(); + submission.learningObjectHruid = submissionDTO.learningObjectIdentifier.hruid; + submission.learningObjectLanguage = submissionDTO.learningObjectIdentifier.language; + submission.learningObjectVersion = submissionDTO.learningObjectIdentifier.version!; + // Submission.submissionNumber = submissionDTO.submissionNumber; + submission.submitter = mapToStudent(submissionDTO.submitter); + // Submission.submissionTime = submissionDTO.time; + // Submission.onBehalfOf = submissionDTO.group!; + // TODO fix group + submission.content = submissionDTO.content; + + return submission; +} diff --git a/backend/src/interfaces/teacher-invitation.ts b/backend/src/interfaces/teacher-invitation.ts new file mode 100644 index 00000000..cddef566 --- /dev/null +++ b/backend/src/interfaces/teacher-invitation.ts @@ -0,0 +1,25 @@ +import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; +import { ClassDTO, mapToClassDTO } from './class.js'; +import { mapToUserDTO, UserDTO } from './user.js'; + +export interface TeacherInvitationDTO { + sender: string | UserDTO; + receiver: string | UserDTO; + class: string | ClassDTO; +} + +export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO { + return { + sender: mapToUserDTO(invitation.sender), + receiver: mapToUserDTO(invitation.receiver), + class: mapToClassDTO(invitation.class), + }; +} + +export function mapToTeacherInvitationDTOIds(invitation: TeacherInvitation): TeacherInvitationDTO { + return { + sender: invitation.sender.username, + receiver: invitation.receiver.username, + class: invitation.class.classId!, + }; +} diff --git a/backend/src/interfaces/teacher.ts b/backend/src/interfaces/teacher.ts new file mode 100644 index 00000000..31b4723f --- /dev/null +++ b/backend/src/interfaces/teacher.ts @@ -0,0 +1,32 @@ +import { Teacher } from '../entities/users/teacher.entity.js'; +import { getTeacherRepository } from '../data/repositories.js'; + +export interface TeacherDTO { + id: string; + username: string; + firstName: string; + lastName: string; + endpoints?: { + classes: string; + questions: string; + invitations: string; + groups: string; + }; +} + +export function mapToTeacherDTO(teacher: Teacher): TeacherDTO { + return { + id: teacher.username, + username: teacher.username, + firstName: teacher.firstName, + lastName: teacher.lastName, + }; +} + +export function mapToTeacher(teacherData: TeacherDTO): Teacher { + return getTeacherRepository().create({ + username: teacherData.username, + firstName: teacherData.firstName, + lastName: teacherData.lastName, + }); +} diff --git a/backend/src/interfaces/user.ts b/backend/src/interfaces/user.ts new file mode 100644 index 00000000..58f0dd5a --- /dev/null +++ b/backend/src/interfaces/user.ts @@ -0,0 +1,30 @@ +import { User } from '../entities/users/user.entity.js'; + +export interface UserDTO { + id?: string; + username: string; + firstName: string; + lastName: string; + endpoints?: { + self: string; + classes: string; + questions: string; + invitations: string; + }; +} + +export function mapToUserDTO(user: User): UserDTO { + return { + id: user.username, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + }; +} + +export function mapToUser(userData: UserDTO, userInstance: T): T { + userInstance.username = userData.username; + userInstance.firstName = userData.firstName; + userInstance.lastName = userData.lastName; + return userInstance; +} diff --git a/backend/src/logging/initalize.ts b/backend/src/logging/initalize.ts index 1ff761c9..5c94a25f 100644 --- a/backend/src/logging/initalize.ts +++ b/backend/src/logging/initalize.ts @@ -1,7 +1,7 @@ import { createLogger, format, Logger as WinstonLogger, transports } from 'winston'; import LokiTransport from 'winston-loki'; 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 { constructor() { @@ -9,7 +9,7 @@ export class Logger extends WinstonLogger { } } -const Labels: LokiLabels = { +const lokiLabels: LokiLabels = { source: 'Dwengo-Backend', service: 'API', host: 'localhost', @@ -22,28 +22,38 @@ function initializeLogger(): 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({ - host: LOKI_HOST, - labels: Labels, - level: LOG_LEVEL, + host: lokiHost, + labels: lokiLabels, + level: logLevel, json: true, format: format.combine(format.timestamp(), format.json()), - onConnectionError: (err) => { + onConnectionError: (err): void => { // eslint-disable-next-line no-console console.error(`Connection error: ${err}`); }, }); - const consoleTransport = new transports.Console({ - level: LOG_LEVEL, - format: format.combine(format.cli(), format.colorize()), - }); - logger = createLogger({ 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; } diff --git a/backend/src/logging/mikroOrmLogger.ts b/backend/src/logging/mikroOrmLogger.ts index 25bbac13..9cb797a8 100644 --- a/backend/src/logging/mikroOrmLogger.ts +++ b/backend/src/logging/mikroOrmLogger.ts @@ -5,35 +5,54 @@ import { LokiLabels } from 'loki-logger-ts'; export class MikroOrmLogger extends DefaultLogger { private logger: Logger = getLogger(); - log(namespace: LoggerNamespace, message: string, context?: LogContext) { + static createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext): unknown { + const labels: LokiLabels = { + service: 'ORM', + }; + + let message: string; + if (context?.label) { + message = `[${namespace}] (${context.label}) ${messageArg}`; + } else { + message = `[${namespace}] ${messageArg}`; + } + + return { + message: message, + labels: labels, + context: context, + }; + } + + log(namespace: LoggerNamespace, message: string, context?: LogContext): void { if (!this.isEnabled(namespace, context)) { return; } switch (namespace) { case 'query': - this.logger.debug(this.createMessage(namespace, message, context)); + this.logger.debug(MikroOrmLogger.createMessage(namespace, message, context)); break; case 'query-params': // TODO Which log level should this be? - this.logger.info(this.createMessage(namespace, message, context)); + this.logger.info(MikroOrmLogger.createMessage(namespace, message, context)); break; case 'schema': - this.logger.info(this.createMessage(namespace, message, context)); + this.logger.info(MikroOrmLogger.createMessage(namespace, message, context)); break; case 'discovery': - this.logger.debug(this.createMessage(namespace, message, context)); + this.logger.debug(MikroOrmLogger.createMessage(namespace, message, context)); break; case 'info': - this.logger.info(this.createMessage(namespace, message, context)); + this.logger.info(MikroOrmLogger.createMessage(namespace, message, context)); break; case 'deprecated': - this.logger.warn(this.createMessage(namespace, message, context)); + this.logger.warn(MikroOrmLogger.createMessage(namespace, message, context)); break; default: switch (context?.level) { case 'info': - this.logger.info(this.createMessage(namespace, message, context)); + this.logger.info(MikroOrmLogger.createMessage(namespace, message, context)); break; case 'warning': this.logger.warn(message); @@ -47,23 +66,4 @@ export class MikroOrmLogger extends DefaultLogger { } } } - - private createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext) { - const labels: LokiLabels = { - service: 'ORM', - }; - - let message: string; - if (context?.label) { - message = `[${namespace}] (${context?.label}) ${messageArg}`; - } else { - message = `[${namespace}] ${messageArg}`; - } - - return { - message: message, - labels: labels, - context: context, - }; - } } diff --git a/backend/src/logging/responseTimeLogger.ts b/backend/src/logging/responseTimeLogger.ts index c1bb1e33..7fcc6c93 100644 --- a/backend/src/logging/responseTimeLogger.ts +++ b/backend/src/logging/responseTimeLogger.ts @@ -1,7 +1,7 @@ import { getLogger, Logger } from './initalize.js'; import { Request, Response } from 'express'; -export function responseTimeLogger(req: Request, res: Response, time: number) { +export function responseTimeLogger(req: Request, res: Response, time: number): void { const logger: Logger = getLogger(); const method = req.method; diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts index 5ff5a53c..a91932ea 100644 --- a/backend/src/middleware/auth/auth.ts +++ b/backend/src/middleware/auth/auth.ts @@ -1,12 +1,13 @@ -import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import { envVars, getEnvVar } from '../../util/envVars.js'; import { expressjwt } from 'express-jwt'; +import * as jwt from 'jsonwebtoken'; import { JwtPayload } from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; import * as express from 'express'; -import * as jwt from 'jsonwebtoken'; import { AuthenticatedRequest } from './authenticated-request.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_RATE_LIMIT = true; @@ -32,12 +33,12 @@ function createJwksClient(uri: string): jwksClient.JwksClient { const idpConfigs = { student: { - issuer: getEnvVar(EnvVars.IdpStudentUrl), - jwksClient: createJwksClient(getEnvVar(EnvVars.IdpStudentJwksEndpoint)), + issuer: getEnvVar(envVars.IdpStudentUrl), + jwksClient: createJwksClient(getEnvVar(envVars.IdpStudentJwksEndpoint)), }, teacher: { - issuer: getEnvVar(EnvVars.IdpTeacherUrl), - jwksClient: createJwksClient(getEnvVar(EnvVars.IdpTeacherJwksEndpoint)), + issuer: getEnvVar(envVars.IdpTeacherUrl), + jwksClient: createJwksClient(getEnvVar(envVars.IdpTeacherJwksEndpoint)), }, }; @@ -63,7 +64,7 @@ const verifyJwtToken = expressjwt({ } return signingKey.getPublicKey(); }, - audience: getEnvVar(EnvVars.IdpAudience), + audience: getEnvVar(envVars.IdpAudience), algorithms: [JWT_ALGORITHM], credentialsRequired: false, requestProperty: REQUEST_PROPERTY_FOR_JWT_PAYLOAD, @@ -74,7 +75,7 @@ const verifyJwtToken = expressjwt({ */ function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined { if (!req.jwtPayload) { - return; + return undefined; } const issuer = req.jwtPayload.iss; let accountType: 'student' | 'teacher'; @@ -84,8 +85,9 @@ function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | } else if (issuer === idpConfigs.teacher.issuer) { accountType = 'teacher'; } else { - return; + return undefined; } + return { accountType: accountType, username: req.jwtPayload[JWT_PROPERTY_NAMES.username]!, @@ -100,10 +102,10 @@ function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | * Add the AuthenticationInfo object with the information about the current authentication to the request in order * to avoid that the routers have to deal with the JWT token. */ -const addAuthenticationInfo = (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => { +function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void { req.auth = getAuthenticationInfo(req); next(); -}; +} export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; @@ -113,9 +115,8 @@ export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates * to true. */ -export const authorize = - (accessCondition: (auth: AuthenticationInfo) => boolean) => - (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction): void => { +export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) { + return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => { if (!req.auth) { throw new UnauthorizedException(); } else if (!accessCondition(req.auth)) { @@ -124,6 +125,7 @@ export const authorize = next(); } }; +} /** * Middleware which rejects all unauthenticated users, but accepts all authenticated users. diff --git a/backend/src/middleware/auth/authentication-info.d.ts b/backend/src/middleware/auth/authentication-info.d.ts index 4b060dfa..e8f0d48c 100644 --- a/backend/src/middleware/auth/authentication-info.d.ts +++ b/backend/src/middleware/auth/authentication-info.d.ts @@ -1,11 +1,11 @@ /** * Object with information about the user who is currently logged in. */ -export type AuthenticationInfo = { +export interface AuthenticationInfo { accountType: 'student' | 'teacher'; username: string; name?: string; firstName?: string; lastName?: string; email?: string; -}; +} diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts index 3d2c9be0..48e0704d 100644 --- a/backend/src/middleware/cors.ts +++ b/backend/src/middleware/cors.ts @@ -1,7 +1,7 @@ import cors from 'cors'; -import { EnvVars, getEnvVar } from '../util/envvars.js'; +import { envVars, getEnvVar } from '../util/envVars.js'; export default cors({ - origin: getEnvVar(EnvVars.CorsAllowedOrigins).split(','), - allowedHeaders: getEnvVar(EnvVars.CorsAllowedHeaders).split(','), + origin: getEnvVar(envVars.CorsAllowedOrigins).split(','), + allowedHeaders: getEnvVar(envVars.CorsAllowedHeaders).split(','), }); diff --git a/backend/src/middleware/error-handling/error-handler.ts b/backend/src/middleware/error-handling/error-handler.ts new file mode 100644 index 00000000..d7315603 --- /dev/null +++ b/backend/src/middleware/error-handling/error-handler.ts @@ -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); + } +} diff --git a/backend/src/mikro-orm.config.ts b/backend/src/mikro-orm.config.ts index c9cf6ed9..eb0e5f7a 100644 --- a/backend/src/mikro-orm.config.ts +++ b/backend/src/mikro-orm.config.ts @@ -1,9 +1,8 @@ import { LoggerOptions, Options } from '@mikro-orm/core'; 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 { MikroOrmLogger } from './logging/mikroOrmLogger.js'; -import { LOG_LEVEL } from './config.js'; // Import alle entity-bestanden handmatig import { User } from './entities/users/user.entity.js'; @@ -43,33 +42,35 @@ const entities = [ Question, ]; -function config(testingMode: boolean = false): Options { +function config(testingMode = false): Options { if (testingMode) { return { driver: SqliteDriver, - dbName: getEnvVar(EnvVars.DbName), + dbName: getEnvVar(envVars.DbName), subscribers: [new SqliteAutoincrementSubscriber()], entities: entities, + persistOnCreate: false, // Do not implicitly save entities when they are created via `create`. // EntitiesTs: entitiesTs, // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION) // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint) - dynamicImportProvider: (id) => import(id), + dynamicImportProvider: async (id) => import(id), }; } return { driver: PostgreSqlDriver, - host: getEnvVar(EnvVars.DbHost), - port: getNumericEnvVar(EnvVars.DbPort), - dbName: getEnvVar(EnvVars.DbName), - user: getEnvVar(EnvVars.DbUsername), - password: getEnvVar(EnvVars.DbPassword), + host: getEnvVar(envVars.DbHost), + port: getNumericEnvVar(envVars.DbPort), + dbName: getEnvVar(envVars.DbName), + user: getEnvVar(envVars.DbUsername), + password: getEnvVar(envVars.DbPassword), entities: entities, + persistOnCreate: false, // Do not implicitly save entities when they are created via `create`. // EntitiesTs: entitiesTs, // Logging - debug: LOG_LEVEL === 'debug', + debug: getEnvVar(envVars.LogLevel) === 'debug', loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options), }; } diff --git a/backend/src/orm.ts b/backend/src/orm.ts index 93feea7a..3e6e26c8 100644 --- a/backend/src/orm.ts +++ b/backend/src/orm.ts @@ -1,10 +1,10 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; import config from './mikro-orm.config.js'; -import { EnvVars, getEnvVar } from './util/envvars.js'; +import { envVars, getEnvVar } from './util/envVars.js'; import { getLogger, Logger } from './logging/initalize.js'; let orm: MikroORM | undefined; -export async function initORM(testingMode: boolean = false) { +export async function initORM(testingMode = false): Promise { const logger: Logger = getLogger(); logger.info('Initializing ORM'); @@ -12,7 +12,7 @@ export async function initORM(testingMode: boolean = false) { orm = await MikroORM.init(config(testingMode)); // Update the database scheme if necessary and enabled. - if (getEnvVar(EnvVars.DbUpdate)) { + if (getEnvVar(envVars.DbUpdate)) { await orm.schema.updateSchema(); } else { const diff = await orm.schema.getUpdateSchemaSQL(); diff --git a/backend/src/routes/assignment.ts b/backend/src/routes/assignment.ts deleted file mode 100644 index 4ae5756d..00000000 --- a/backend/src/routes/assignment.ts +++ /dev/null @@ -1,45 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - assignments: ['0', '1'], - }); -}); - -// Information about an assignment with id 'id' -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - title: 'Dit is een test assignment', - description: 'Een korte beschrijving', - groups: ['0'], - learningPath: '0', - class: '0', - links: { - self: `${req.baseUrl}/${req.params.id}`, - submissions: `${req.baseUrl}/${req.params.id}`, - }, - }); -}); - -router.get('/:id/submissions', (req, res) => { - res.json({ - submissions: ['0'], - }); -}); - -router.get('/:id/groups', (req, res) => { - res.json({ - groups: ['0'], - }); -}); - -router.get('/:id/questions', (req, res) => { - res.json({ - questions: ['0'], - }); -}); - -export default router; diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts new file mode 100644 index 00000000..3652dcc6 --- /dev/null +++ b/backend/src/routes/assignments.ts @@ -0,0 +1,30 @@ +import express from 'express'; +import { + createAssignmentHandler, + getAllAssignmentsHandler, + getAssignmentHandler, + getAssignmentsSubmissionsHandler, +} from '../controllers/assignments.js'; +import groupRouter from './groups.js'; + +const router = express.Router({ mergeParams: true }); + +// Root endpoint used to search objects +router.get('/', getAllAssignmentsHandler); + +router.post('/', createAssignmentHandler); + +// Information about an assignment with id 'id' +router.get('/:id', getAssignmentHandler); + +router.get('/:id/submissions', getAssignmentsSubmissionsHandler); + +router.get('/:id/questions', (_req, res) => { + res.json({ + questions: ['0'], + }); +}); + +router.use('/:assignmentid/groups', groupRouter); + +export default router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 942a997a..4a1f27d2 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,19 +4,22 @@ import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/aut const router = express.Router(); // Returns auth configuration for frontend -router.get('/config', (req, res) => { +router.get('/config', (_req, res) => { res.json(getFrontendAuthConfig()); }); -router.get('/testAuthenticatedOnly', authenticatedOnly, (req, res) => { +router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { + /* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ res.json({ message: 'If you see this, you should be authenticated!' }); }); -router.get('/testStudentsOnly', studentsOnly, (req, res) => { +router.get('/testStudentsOnly', studentsOnly, (_req, res) => { + /* #swagger.security = [{ "student": [ ] }] */ res.json({ message: 'If you see this, you should be a student!' }); }); -router.get('/testTeachersOnly', teachersOnly, (req, res) => { +router.get('/testTeachersOnly', teachersOnly, (_req, res) => { + /* #swagger.security = [{ "teacher": [ ] }] */ res.json({ message: 'If you see this, you should be a teacher!' }); }); diff --git a/backend/src/routes/class.ts b/backend/src/routes/class.ts deleted file mode 100644 index 6f8f324e..00000000 --- a/backend/src/routes/class.ts +++ /dev/null @@ -1,46 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - classes: ['0', '1'], - }); -}); - -// Information about an class with id 'id' -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - displayName: 'Klas 4B', - teachers: ['0'], - students: ['0'], - joinRequests: ['0'], - links: { - self: `${req.baseUrl}/${req.params.id}`, - classes: `${req.baseUrl}/${req.params.id}/invitations`, - questions: `${req.baseUrl}/${req.params.id}/assignments`, - students: `${req.baseUrl}/${req.params.id}/students`, - }, - }); -}); - -router.get('/:id/invitations', (req, res) => { - res.json({ - invitations: ['0'], - }); -}); - -router.get('/:id/assignments', (req, res) => { - res.json({ - assignments: ['0'], - }); -}); - -router.get('/:id/students', (req, res) => { - res.json({ - students: ['0'], - }); -}); - -export default router; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts new file mode 100644 index 00000000..e0972988 --- /dev/null +++ b/backend/src/routes/classes.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import { + createClassHandler, + getAllClassesHandler, + getClassHandler, + getClassStudentsHandler, + getTeacherInvitationsHandler, +} from '../controllers/classes.js'; +import assignmentRouter from './assignments.js'; + +const router = express.Router(); + +// Root endpoint used to search objects +router.get('/', getAllClassesHandler); + +router.post('/', createClassHandler); + +// Information about an class with id 'id' +router.get('/:id', getClassHandler); + +router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); + +router.get('/:id/students', getClassStudentsHandler); + +router.use('/:classid/assignments', assignmentRouter); + +export default router; diff --git a/backend/src/routes/group.ts b/backend/src/routes/group.ts deleted file mode 100644 index 303f5215..00000000 --- a/backend/src/routes/group.ts +++ /dev/null @@ -1,31 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - groups: ['0', '1'], - }); -}); - -// Information about a group (members, ... [TODO DOC]) -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - assignment: '0', - students: ['0'], - submissions: ['0'], - // Reference to other endpoint - // Should be less hardcoded - questions: `/group/${req.params.id}/question`, - }); -}); - -// The list of questions a group has made -router.get('/:id/question', (req, res) => { - res.json({ - questions: ['0'], - }); -}); - -export default router; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts new file mode 100644 index 00000000..dc8917bd --- /dev/null +++ b/backend/src/routes/groups.ts @@ -0,0 +1,23 @@ +import express from 'express'; +import { createGroupHandler, getAllGroupsHandler, getGroupHandler, getGroupSubmissionsHandler } from '../controllers/groups.js'; + +const router = express.Router({ mergeParams: true }); + +// Root endpoint used to search objects +router.get('/', getAllGroupsHandler); + +router.post('/', createGroupHandler); + +// Information about a group (members, ... [TODO DOC]) +router.get('/:groupid', getGroupHandler); + +router.get('/:groupid', getGroupSubmissionsHandler); + +// The list of questions a group has made +router.get('/:id/questions', (_req, res) => { + res.json({ + questions: ['0'], + }); +}); + +export default router; diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts index b731fe69..7532765b 100644 --- a/backend/src/routes/learning-objects.ts +++ b/backend/src/routes/learning-objects.ts @@ -1,6 +1,9 @@ import express from 'express'; import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; +import submissionRoutes from './submissions.js'; +import questionRoutes from './questions.js'; + const router = express.Router(); // DWENGO learning objects @@ -21,6 +24,10 @@ router.get('/', getAllLearningObjects); // Example: http://localhost:3000/learningObject/un_ai7 router.get('/:hruid', getLearningObject); +router.use('/:hruid/submissions', submissionRoutes); + +router.use('/:hruid/:version/questions', questionRoutes); + // Parameter: hruid of learning object // Query: language, version (optional) // Route to fetch the HTML rendering of one learning object based on its hruid. diff --git a/backend/src/routes/question.ts b/backend/src/routes/question.ts deleted file mode 100644 index 2e5db624..00000000 --- a/backend/src/routes/question.ts +++ /dev/null @@ -1,33 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - questions: ['0', '1'], - }); -}); - -// Information about an question with id 'id' -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - student: '0', - group: '0', - time: new Date(2025, 1, 1), - content: 'Zijn alle gehele getallen groter dan 2 gelijk aan de som van 2 priemgetallen????', - learningObject: '0', - links: { - self: `${req.baseUrl}/${req.params.id}`, - answers: `${req.baseUrl}/${req.params.id}/answers`, - }, - }); -}); - -router.get('/:id/answers', (req, res) => { - res.json({ - answers: ['0'], - }); -}); - -export default router; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts new file mode 100644 index 00000000..31a71f3b --- /dev/null +++ b/backend/src/routes/questions.ts @@ -0,0 +1,25 @@ +import express from 'express'; +import { + createQuestionHandler, + deleteQuestionHandler, + getAllQuestionsHandler, + getQuestionAnswersHandler, + getQuestionHandler, +} from '../controllers/questions.js'; +const router = express.Router({ mergeParams: true }); + +// Query language + +// Root endpoint used to search objects +router.get('/', getAllQuestionsHandler); + +router.post('/', createQuestionHandler); + +router.delete('/:seq', deleteQuestionHandler); + +// Information about a question with id +router.get('/:seq', getQuestionHandler); + +router.get('/answers/:seq', getQuestionAnswersHandler); + +export default router; diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts new file mode 100644 index 00000000..99d4312c --- /dev/null +++ b/backend/src/routes/router.ts @@ -0,0 +1,29 @@ +import { Response, Router } from 'express'; +import studentRouter from './students.js'; +import teacherRouter from './teachers.js'; +import classRouter from './classes.js'; +import authRouter from './auth.js'; +import themeRoutes from './themes.js'; +import learningPathRoutes from './learning-paths.js'; +import learningObjectRoutes from './learning-objects.js'; +import { getLogger, Logger } from '../logging/initalize.js'; + +const router = Router(); +const logger: Logger = getLogger(); + +router.get('/', (_, res: Response) => { + logger.debug('GET /'); + res.json({ + message: 'Hello Dwengo!🚀', + }); +}); + +router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); +router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); +router.use('/class', classRouter /* #swagger.tags = ['Class'] */); +router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); +router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); +router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); +router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); + +export default router; diff --git a/backend/src/routes/student.ts b/backend/src/routes/student.ts deleted file mode 100644 index 9cb0cdee..00000000 --- a/backend/src/routes/student.ts +++ /dev/null @@ -1,55 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - students: ['0', '1'], - }); -}); - -// Information about a student's profile -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - firstName: 'Jimmy', - lastName: 'Faster', - username: 'JimmyFaster2', - endpoints: { - classes: `/student/${req.params.id}/classes`, - questions: `/student/${req.params.id}/submissions`, - invitations: `/student/${req.params.id}/assignments`, - groups: `/student/${req.params.id}/groups`, - }, - }); -}); - -// The list of classes a student is in -router.get('/:id/classes', (req, res) => { - res.json({ - classes: ['0'], - }); -}); - -// The list of submissions a student has made -router.get('/:id/submissions', (req, res) => { - res.json({ - submissions: ['0'], - }); -}); - -// The list of assignments a student has -router.get('/:id/assignments', (req, res) => { - res.json({ - assignments: ['0'], - }); -}); - -// The list of groups a student is in -router.get('/:id/groups', (req, res) => { - res.json({ - groups: ['0'], - }); -}); - -export default router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts new file mode 100644 index 00000000..d58c2adc --- /dev/null +++ b/backend/src/routes/students.ts @@ -0,0 +1,46 @@ +import express from 'express'; +import { + createStudentHandler, + deleteStudentHandler, + getAllStudentsHandler, + getStudentAssignmentsHandler, + getStudentClassesHandler, + getStudentGroupsHandler, + getStudentHandler, + getStudentSubmissionsHandler, +} from '../controllers/students.js'; + +const router = express.Router(); + +// Root endpoint used to search objects +router.get('/', getAllStudentsHandler); + +router.post('/', createStudentHandler); + +router.delete('/', deleteStudentHandler); + +router.delete('/:username', deleteStudentHandler); + +// Information about a student's profile +router.get('/:username', getStudentHandler); + +// The list of classes a student is in +router.get('/:id/classes', getStudentClassesHandler); + +// The list of submissions a student has made +router.get('/:id/submissions', getStudentSubmissionsHandler); + +// The list of assignments a student has +router.get('/:id/assignments', getStudentAssignmentsHandler); + +// The list of groups a student is in +router.get('/:id/groups', getStudentGroupsHandler); + +// A list of questions a user has created +router.get('/:id/questions', (_req, res) => { + res.json({ + questions: ['0'], + }); +}); + +export default router; diff --git a/backend/src/routes/submission.ts b/backend/src/routes/submission.ts deleted file mode 100644 index cb4d3e85..00000000 --- a/backend/src/routes/submission.ts +++ /dev/null @@ -1,23 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - submissions: ['0', '1'], - }); -}); - -// Information about an submission with id 'id' -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - student: '0', - group: '0', - time: new Date(2025, 1, 1), - content: 'Wortel 2 is rationeel', - learningObject: '0', - }); -}); - -export default router; diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts new file mode 100644 index 00000000..8e9831b9 --- /dev/null +++ b/backend/src/routes/submissions.ts @@ -0,0 +1,19 @@ +import express from 'express'; +import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler } from '../controllers/submissions.js'; +const router = express.Router({ mergeParams: true }); + +// Root endpoint used to search objects +router.get('/', (_req, res) => { + res.json({ + submissions: ['0', '1'], + }); +}); + +router.post('/:id', createSubmissionHandler); + +// Information about an submission with id 'id' +router.get('/:id', getSubmissionHandler); + +router.delete('/:id', deleteSubmissionHandler); + +export default router; diff --git a/backend/src/routes/teacher.ts b/backend/src/routes/teacher.ts deleted file mode 100644 index a7c60bc9..00000000 --- a/backend/src/routes/teacher.ts +++ /dev/null @@ -1,48 +0,0 @@ -import express from 'express'; -const router = express.Router(); - -// Root endpoint used to search objects -router.get('/', (req, res) => { - res.json({ - teachers: ['0', '1'], - }); -}); - -// Information about a teacher -router.get('/:id', (req, res) => { - res.json({ - id: req.params.id, - firstName: 'John', - lastName: 'Doe', - username: 'JohnDoe1', - links: { - self: `${req.baseUrl}/${req.params.id}`, - classes: `${req.baseUrl}/${req.params.id}/classes`, - questions: `${req.baseUrl}/${req.params.id}/questions`, - invitations: `${req.baseUrl}/${req.params.id}/invitations`, - }, - }); -}); - -// The questions students asked a teacher -router.get('/:id/questions', (req, res) => { - res.json({ - questions: ['0'], - }); -}); - -// Invitations to other classes a teacher received -router.get('/:id/invitations', (req, res) => { - res.json({ - invitations: ['0'], - }); -}); - -// A list with ids of classes a teacher is in -router.get('/:id/classes', (req, res) => { - res.json({ - classes: ['0'], - }); -}); - -export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts new file mode 100644 index 00000000..3782a6ca --- /dev/null +++ b/backend/src/routes/teachers.ts @@ -0,0 +1,37 @@ +import express from 'express'; +import { + createTeacherHandler, + deleteTeacherHandler, + getAllTeachersHandler, + getTeacherClassHandler, + getTeacherHandler, + getTeacherQuestionHandler, + getTeacherStudentHandler, +} from '../controllers/teachers.js'; +const router = express.Router(); + +// Root endpoint used to search objects +router.get('/', getAllTeachersHandler); + +router.post('/', createTeacherHandler); + +router.delete('/', deleteTeacherHandler); + +router.get('/:username', getTeacherHandler); + +router.delete('/:username', deleteTeacherHandler); + +router.get('/:username/classes', getTeacherClassHandler); + +router.get('/:username/students', getTeacherStudentHandler); + +router.get('/:username/questions', getTeacherQuestionHandler); + +// Invitations to other classes a teacher received +router.get('/:id/invitations', (_req, res) => { + res.json({ + invitations: ['0'], + }); +}); + +export default router; diff --git a/backend/src/routes/themes.ts b/backend/src/routes/themes.ts index 388b3e38..b135d44f 100644 --- a/backend/src/routes/themes.ts +++ b/backend/src/routes/themes.ts @@ -1,14 +1,14 @@ import express from 'express'; -import { getThemes, getThemeByTitle } from '../controllers/themes.js'; +import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; const router = express.Router(); // Query: language // Route to fetch list of {key, title, description, image} themes in their respective language -router.get('/', getThemes); +router.get('/', getThemesHandler); // Arg: theme (key) // Route to fetch list of hruids based on theme -router.get('/:theme', getThemeByTitle); +router.get('/:theme', getHruidsByThemeHandler); export default router; diff --git a/backend/src/services/assignments.ts b/backend/src/services/assignments.ts new file mode 100644 index 00000000..22c5ce9e --- /dev/null +++ b/backend/src/services/assignments.ts @@ -0,0 +1,94 @@ +import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; +import { AssignmentDTO, mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; +import { mapToSubmissionDTO, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } from '../interfaces/submission.js'; +import { getLogger } from '../logging/initalize.js'; + +export async function getAllAssignments(classid: string, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignments = await assignmentRepository.findAllAssignmentsInClass(cls); + + if (full) { + return assignments.map(mapToAssignmentDTO); + } + + return assignments.map(mapToAssignmentDTOId); +} + +export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return null; + } + + const assignment = mapToAssignment(assignmentData, cls); + const assignmentRepository = getAssignmentRepository(); + + try { + const newAssignment = assignmentRepository.create(assignment); + await assignmentRepository.save(newAssignment); + + return mapToAssignmentDTO(newAssignment); + } catch (e) { + getLogger().error(e); + return null; + } +} + +export async function getAssignment(classid: string, id: number): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return null; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, id); + + if (!assignment) { + return null; + } + + return mapToAssignmentDTO(assignment); +} + +export async function getAssignmentsSubmissions( + classid: string, + assignmentNumber: number, + full: boolean +): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return []; + } + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); + + const submissionRepository = getSubmissionRepository(); + const submissions = (await Promise.all(groups.map(async (group) => submissionRepository.findAllSubmissionsForGroup(group)))).flat(); + + if (full) { + return submissions.map(mapToSubmissionDTO); + } + + return submissions.map(mapToSubmissionDTOId); +} diff --git a/backend/src/services/classes.ts b/backend/src/services/classes.ts new file mode 100644 index 00000000..dc3ac8a0 --- /dev/null +++ b/backend/src/services/classes.ts @@ -0,0 +1,100 @@ +import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js'; +import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; +import { mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; +import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds, TeacherInvitationDTO } from '../interfaces/teacher-invitation.js'; +import { getLogger } from '../logging/initalize.js'; + +const logger = getLogger(); + +export async function getAllClasses(full: boolean): Promise { + const classRepository = getClassRepository(); + const classes = await classRepository.find({}, { populate: ['students', 'teachers'] }); + + if (!classes) { + return []; + } + + if (full) { + return classes.map(mapToClassDTO); + } + return classes.map((cls) => cls.classId!); +} + +export async function createClass(classData: ClassDTO): Promise { + const teacherRepository = getTeacherRepository(); + const teacherUsernames = classData.teachers || []; + const teachers = (await Promise.all(teacherUsernames.map(async (id) => teacherRepository.findByUsername(id)))).filter( + (teacher) => teacher !== null + ); + + const studentRepository = getStudentRepository(); + const studentUsernames = classData.students || []; + const students = (await Promise.all(studentUsernames.map(async (id) => studentRepository.findByUsername(id)))).filter( + (student) => student !== null + ); + + const classRepository = getClassRepository(); + + try { + const newClass = classRepository.create({ + displayName: classData.displayName, + teachers: teachers, + students: students, + }); + await classRepository.save(newClass); + + return mapToClassDTO(newClass); + } catch (e) { + logger.error(e); + return null; + } +} + +export async function getClass(classId: string): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return null; + } + + return mapToClassDTO(cls); +} + +async function fetchClassStudents(classId: string): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + return cls.students.map(mapToStudentDTO); +} + +export async function getClassStudents(classId: string): Promise { + return await fetchClassStudents(classId); +} + +export async function getClassStudentsIds(classId: string): Promise { + const students: StudentDTO[] = await fetchClassStudents(classId); + return students.map((student) => student.username); +} + +export async function getClassTeacherInvitations(classId: string, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + const teacherInvitationRepository = getTeacherInvitationRepository(); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(cls); + + if (full) { + return invitations.map(mapToTeacherInvitationDTO); + } + + return invitations.map(mapToTeacherInvitationDTOIds); +} diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts new file mode 100644 index 00000000..16895e0a --- /dev/null +++ b/backend/src/services/groups.ts @@ -0,0 +1,142 @@ +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getStudentRepository, + getSubmissionRepository, +} from '../data/repositories.js'; +import { Group } from '../entities/assignments/group.entity.js'; +import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToSubmissionDTO, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } from '../interfaces/submission.js'; +import { getLogger } from '../logging/initalize.js'; + +export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return null; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return null; + } + + const groupRepository = getGroupRepository(); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); + + if (!group) { + return null; + } + + if (full) { + return mapToGroupDTO(group); + } + + return mapToGroupDTOId(group); +} + +export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { + const studentRepository = getStudentRepository(); + + const memberUsernames = (groupData.members as string[]) || []; // TODO check if groupdata.members is a list + const members = (await Promise.all([...memberUsernames].map(async (id) => studentRepository.findByUsername(id)))).filter( + (student) => student !== null + ); + + getLogger().debug(members); + + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return null; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return null; + } + + const groupRepository = getGroupRepository(); + try { + const newGroup = groupRepository.create({ + assignment: assignment, + members: members, + }); + await groupRepository.save(newGroup); + + return newGroup; + } catch (e) { + getLogger().error(e); + return null; + } +} + +export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return []; + } + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); + + if (full) { + getLogger().debug({ full: full, groups: groups }); + return groups.map(mapToGroupDTO); + } + + return groups.map(mapToGroupDTOId); +} + +export async function getGroupSubmissions( + classId: string, + assignmentNumber: number, + groupNumber: number, + full: boolean +): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return []; + } + + const groupRepository = getGroupRepository(); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); + + if (!group) { + return []; + } + + const submissionRepository = getSubmissionRepository(); + const submissions = await submissionRepository.findAllSubmissionsForGroup(group); + + if (full) { + return submissions.map(mapToSubmissionDTO); + } + + return submissions.map(mapToSubmissionDTOId); +} diff --git a/backend/src/services/learning-objects.ts b/backend/src/services/learning-objects.ts new file mode 100644 index 00000000..43af1aca --- /dev/null +++ b/backend/src/services/learning-objects.ts @@ -0,0 +1,95 @@ +import { DWENGO_API_BASE } from '../config.js'; +import { fetchWithLogging } from '../util/api-helper.js'; +import { FilteredLearningObject, LearningObjectMetadata, LearningObjectNode, LearningPathResponse } from '../interfaces/learning-content.js'; +import { getLogger } from '../logging/initalize.js'; + +function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { + return { + key: data.hruid, // Hruid learningObject (not path) + _id: data._id, + uuid: data.uuid, + version: data.version, + title: data.title, + htmlUrl, // Url to fetch html content + language: data.language, + difficulty: data.difficulty, + estimatedTime: data.estimated_time, + available: data.available, + teacherExclusive: data.teacher_exclusive, + educationalGoals: data.educational_goals, // List with learningObjects + keywords: data.keywords, // For search + description: data.description, // For search (not an actual description) + targetAges: data.target_ages, + contentType: data.content_type, // Markdown, image, audio, etc. + contentLocation: data.content_location, // If content type extern + skosConcepts: data.skos_concepts, + returnValue: data.return_value, // Callback response information + }; +} + +/** + * Fetches a single learning object by its HRUID + */ +export async function getLearningObjectById(hruid: string, language: string): Promise { + const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; + const metadata = await fetchWithLogging( + metadataUrl, + `Metadata for Learning Object HRUID "${hruid}" (language ${language})` + ); + + if (!metadata) { + getLogger().error(`⚠️ WARNING: Learning object "${hruid}" not found.`); + return null; + } + + const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`; + return filterData(metadata, htmlUrl); +} + +/** + * Generic function to fetch learning paths + */ +function fetchLearningPaths(_arg0: string[], _language: string, _arg2: string): LearningPathResponse | PromiseLike { + throw new Error('Function not implemented.'); +} + +/** + * Generic function to fetch learning objects (full data or just HRUIDs) + */ +async function fetchLearningObjects(hruid: string, full: boolean, language: string): Promise { + try { + const learningPathResponse: LearningPathResponse = await fetchLearningPaths([hruid], language, `Learning path for HRUID "${hruid}"`); + + if (!learningPathResponse.success || !learningPathResponse.data?.length) { + getLogger().error(`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`); + return []; + } + + const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; + + if (!full) { + return nodes.map((node) => node.learningobject_hruid); + } + + return await Promise.all(nodes.map(async (node) => getLearningObjectById(node.learningobject_hruid, language))).then((objects) => + objects.filter((obj): obj is FilteredLearningObject => obj !== null) + ); + } catch (error) { + getLogger().error('❌ Error fetching learning objects:', error); + return []; + } +} + +/** + * Fetch full learning object data (metadata) + */ +export async function getLearningObjectsFromPath(hruid: string, language: string): Promise { + return (await fetchLearningObjects(hruid, true, language)) as FilteredLearningObject[]; +} + +/** + * Fetch only learning object HRUIDs + */ +export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise { + return (await fetchLearningObjects(hruid, false, language)) as string[]; +} diff --git a/backend/src/services/learning-objects/attachment-service.ts b/backend/src/services/learning-objects/attachment-service.ts index aacc7187..4ff4ec47 100644 --- a/backend/src/services/learning-objects/attachment-service.ts +++ b/backend/src/services/learning-objects/attachment-service.ts @@ -3,7 +3,7 @@ import { Attachment } from '../../entities/content/attachment.entity.js'; import { LearningObjectIdentifier } from '../../interfaces/learning-content.js'; const attachmentService = { - getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise { + async getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise { const attachmentRepo = getAttachmentRepository(); if (learningObjectId.version) { diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index bab0b9b1..a8055f2c 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -1,7 +1,6 @@ import { LearningObjectProvider } from './learning-object-provider.js'; import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js'; -import { Language } from '../../entities/content/language.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { getUrlStringForLearningObject } from '../../util/links.js'; import processingService from './processing/processing-service.js'; @@ -41,10 +40,10 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL }; } -function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise { +async function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise { const learningObjectRepo = getLearningObjectRepository(); - return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); + return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language); } /** @@ -65,11 +64,11 @@ const databaseLearningObjectProvider: LearningObjectProvider = { async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { const learningObjectRepo = getLearningObjectRepository(); - const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); + const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language); if (!learningObject) { return null; } - return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id)); + return await processingService.render(learningObject, async (id) => findLearningObjectEntityById(id)); }, /** @@ -96,7 +95,7 @@ const databaseLearningObjectProvider: LearningObjectProvider = { throw new NotFoundError('The learning path with the given ID could not be found.'); } const learningObjects = await Promise.all( - learningPath.nodes.map((it) => { + learningPath.nodes.map(async (it) => { const learningObject = learningObjectService.getLearningObjectById({ hruid: it.learningObjectHruid, language: it.language, diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts index 37e68c07..dfee329d 100644 --- a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -1,5 +1,5 @@ import { DWENGO_API_BASE } from '../../config.js'; -import { fetchWithLogging } from '../../util/apiHelper.js'; +import { fetchWithLogging } from '../../util/api-helper.js'; import { FilteredLearningObject, LearningObjectIdentifier, diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 8289660b..59ffb643 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -1,11 +1,11 @@ import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js'; import { LearningObjectProvider } from './learning-object-provider.js'; -import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import { envVars, getEnvVar } from '../../util/envVars.js'; import databaseLearningObjectProvider from './database-learning-object-provider.js'; function getProvider(id: LearningObjectIdentifier): LearningObjectProvider { - if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) { + if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) { return databaseLearningObjectProvider; } return dwengoApiLearningObjectProvider; @@ -18,28 +18,28 @@ const learningObjectService = { /** * Fetches a single learning object by its HRUID */ - getLearningObjectById(id: LearningObjectIdentifier): Promise { + async getLearningObjectById(id: LearningObjectIdentifier): Promise { return getProvider(id).getLearningObjectById(id); }, /** * Fetch full learning object data (metadata) */ - getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { return getProvider(id).getLearningObjectsFromPath(id); }, /** * Fetch only learning object HRUIDs */ - getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { return getProvider(id).getLearningObjectIdsFromPath(id); }, /** * Obtain a HTML-rendering of the learning object with the given identifier (as a string). */ - getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { return getProvider(id).getLearningObjectHTML(id); }, }; diff --git a/backend/src/services/learning-objects/processing/audio/audio-processor.ts b/backend/src/services/learning-objects/processing/audio/audio-processor.ts index 592669d5..227eae13 100644 --- a/backend/src/services/learning-objects/processing/audio/audio-processor.ts +++ b/backend/src/services/learning-objects/processing/audio/audio-processor.ts @@ -14,7 +14,7 @@ class AudioProcessor extends StringProcessor { super(DwengoContentType.AUDIO_MPEG); } - protected renderFn(audioUrl: string): string { + override renderFn(audioUrl: string): string { return DOMPurify.sanitize(`