diff --git a/.github/workflows/backend-testing.yml b/.github/workflows/backend-testing.yml new file mode 100644 index 00000000..5a7074d9 --- /dev/null +++ b/.github/workflows/backend-testing.yml @@ -0,0 +1,63 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, run backend tests across different versions of node (here 22.x) +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Backend Testing + +# Workflow runs when: +# - a backend js/ts file on "dev" changes +# - a non-draft PR to "dev" with backend js/ts files is opened, is reopened, or changes +# - a draft PR to "dev" with backend js/ts files is marked as ready for review +on: + push: + branches: [ "dev", "main" ] + paths: + - 'backend/src/**.[jt]s' + - 'backend/tests/**.[jt]s' + - 'backend/vitest.config.ts' + pull_request: + branches: [ "dev", "main" ] + types: ["synchronize", "ready_for_review", "opened", "reopened"] + paths: + - 'backend/src/**.[jt]s' + - 'backend/tests/**.[jt]s' + - 'backend/vitest.config.ts' + + +jobs: + test: + name: Run backend unit tests + if: '! github.event.pull_request.draft' + runs-on: [self-hosted, Linux, X64] + + permissions: + # Required to checkout the code + contents: read + # Required to put a comment into the pull-request + pull-requests: write + + strategy: + matrix: + node-version: [22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build + - run: npm run test:coverage -w backend + - name: 'Report Backend Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: 'Backend' + json-summary-path: './backend/coverage/coverage-summary.json' + json-final-path: './backend/coverage/coverage-final.json' + vite-config-path: './backend/vitest.config.ts' + file-coverage-mode: all diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 00000000..2eb42efa --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,22 @@ +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: Copy environment variables to correct file + run: cp /home/dev/.backend.env backend/.env + - + name: Start docker + run: docker compose -f compose.production.yml up --build -d + \ No newline at end of file diff --git a/.github/workflows/frontend-testing.yml b/.github/workflows/frontend-testing.yml new file mode 100644 index 00000000..5554fcff --- /dev/null +++ b/.github/workflows/frontend-testing.yml @@ -0,0 +1,72 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, run frontend tests across different versions of node (here 22.x) +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Frontend Testing + +# Workflow runs when: +# - a frontend js/ts/vue/css file on "dev" changes +# - a non-draft PR to "dev" with frontend js/ts/vue/css files is opened, is reopened, or changes +# - a draft PR to "dev" with frontend js/ts/vue/css files is marked as ready for review +on: + push: + branches: [ "dev", "main" ] + paths: + - 'frontend/src/**.[jt]s' + - 'frontend/src/**.vue' + - 'frontend/src/**.css' + - 'frontend/tests/**.[jt]s' + - 'frontend/tests/**.vue' + - 'frontend/tests/**.css' + - 'frontend/vitest.config.ts' + - 'frontend/playwright.config.ts' + pull_request: + branches: [ "dev", "main" ] + types: ["synchronize", "ready_for_review", "opened", "reopened"] + paths: + - 'frontend/src/**.[jt]s' + - 'frontend/src/**.vue' + - 'frontend/src/**.css' + - 'frontend/tests/**.[jt]s' + - 'frontend/tests/**.vue' + - 'frontend/tests/**.css' + - 'frontend/vitest.config.ts' + - 'frontend/playwright.config.ts' + +jobs: + test: + name: Run frontend unit tests + if: '! github.event.pull_request.draft' + runs-on: [self-hosted, Linux, X64] + + permissions: + # Required to checkout the code + contents: read + # Required to put a comment into the pull-request + pull-requests: write + + strategy: + matrix: + node-version: [22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm ci + - run: npm run build + - run: npm run test:coverage -w frontend + - name: 'Report Frontend Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + name: 'Frontend' + json-summary-path: './frontend/coverage/coverage-summary.json' + json-final-path: './frontend/coverage/coverage-final.json' + vite-config-path: './frontend/vitest.config.ts' + file-coverage-mode: all diff --git a/.github/workflows/lint-action.yml b/.github/workflows/lint-action.yml index e0f24ba9..f7e8d11e 100644 --- a/.github/workflows/lint-action.yml +++ b/.github/workflows/lint-action.yml @@ -4,13 +4,13 @@ on: # Trigger the workflow on push or pull request, # but only for the main branch push: - branches: - - dev + branches: [ "dev", "main" ] # Replace pull_request with pull_request_target if you # plan to use this action with forks, see the Limitations section pull_request: - branches: - - dev + branches: [ "dev", "main" ] + types: ["synchronize", "ready_for_review", "opened", "reopened"] + # Down scope as necessary via https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token permissions: @@ -20,6 +20,7 @@ permissions: jobs: run-linters: name: Run linters + if: '! github.event.pull_request.draft' runs-on: [self-hosted, Linux, X64] steps: @@ -40,6 +41,6 @@ jobs: with: auto_fix: true eslint: true - eslint_args: '--config eslint.config.ts' + eslint_args: "--config eslint.config.ts --ignore-pattern '**/prettier.config.js'" prettier: true commit_message: 'style: fix linting issues met ${linter}' diff --git a/.gitignore b/.gitignore index d28e7d73..e10668cd 100644 --- a/.gitignore +++ b/.gitignore @@ -737,4 +737,6 @@ flycheck_*.el # network security /network-security.data - +docs/.venv +idp_data/h2/keycloakdb.mv.db +idp_data/h2/keycloakdb.trace.db diff --git a/README.md b/README.md index dc09bbfc..0499b037 100644 --- a/README.md +++ b/README.md @@ -21,31 +21,28 @@ 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 git clone https://github.com/SELab-2/Dwengo-1.git -cd Dwengo-1/backend -cp .env.example .env -# Pas .env aan -nano .env -cd .. -docker compose up -# Configureer de applicatie +docker compose -f compose.staging.yml up --build +# Gebruikt backend/.env.staging ``` -### 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 466e1b7b..d03a6744 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -1,15 +1,24 @@ # -# Basic configuration +# Development environment configuration +# +# You probably don't need to change these values, as this configuration takes +# the docker services and their default ports into account. # -DWENGO_PORT=3000 # The port the backend will listen on +### Dwengo ### + +#DWENGO_PORT=3000 +#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api +#DWENGO_FALLBACK_LANGUAGE=nl +#DWENGO_RUN_MODE=dev + DWENGO_DB_HOST=localhost DWENGO_DB_PORT=5431 +#DWENGO_DB_NAME=dwengo DWENGO_DB_USERNAME=postgres DWENGO_DB_PASSWORD=postgres DWENGO_DB_UPDATE=true - -# Auth +#DWENGO_DB_CONTENT_PREFIX=u_ DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo @@ -17,12 +26,12 @@ DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/ DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher DWENGO_AUTH_TEACHER_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 -# +### Advanced configuration ### -# LOKI_HOST=http://localhost:9001 # The address of the Loki instance, used for logging +DWENGO_LOGGING_LEVEL=debug +#DWENGO_LOGGING_LOKI_HOST=http://localhost:3102 diff --git a/backend/.env.example b/backend/.env.example index 68cef35d..8873515c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,27 +1,68 @@ # # Basic configuration # +# Change the values of the variables below to match your environment! +# Default values are commented out. +# -DWENGO_PORT=3000 # The port the backend will listen on +### Dwengo ### + +# Port the backend will listen on +#DWENGO_PORT=3000 +# The hostname or IP address of the remote learning content API. +#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api +# The default fallback language. +#DWENGO_FALLBACK_LANGUAGE=nl +# Whether running in production mode or not. Possible values are "prod" or "dev". +#DWENGO_RUN_MODE=dev + +# ! Change this! The hostname or IP address of the database +# If running your stack in docker, this should use the docker service name. DWENGO_DB_HOST=domain-or-ip-of-database -DWENGO_DB_PORT=5431 - -# 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 -# The address of the Lokiinstance, used for logging -# LOKI_HOST=http://localhost:3102 +# 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 index 390409d1..4f36cf53 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -1,28 +1,37 @@ -DWENGO_PORT=3000 # The port the backend will listen on -DWENGO_DB_HOST=db # Name of the database container -DWENGO_DB_PORT=5431 +# +# Production environment configuration +# +# Change the values of the variables below to match your production environment! +# See .env.example for more information. +# -# Change this to the actual credentials of the user Dwengo should use in the backend +### Dwengo ### + +DWENGO_PORT=3000 +#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api +#DWENGO_FALLBACK_LANGUAGE=nl +DWENGO_RUN_MODE=prod + +DWENGO_DB_HOST=db +DWENGO_DB_PORT=5432 DWENGO_DB_NAME=postgres DWENGO_DB_USERNAME=postgres DWENGO_DB_PASSWORD=postgres - -# Set this to true when the database scheme needs to be updated. In that case, take a backup first. DWENGO_DB_UPDATE=false +#DWENGO_DB_CONTENT_PREFIX=u_ -# Data for the identity provider via which the students authenticate. DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container -# Data for the identity provider via which the teachers authenticate. DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher DWENGO_AUTH_TEACHER_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 -# -# Advanced configuration -# +#DWENGO_CORS_ALLOWED_ORIGINS= +#DWENGO_CORS_ALLOWED_HEADERS=Authorization,Content-Type -# Logging and monitoring +### Advanced configuration ### -# LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging +DWENGO_LOGGING_LEVEL=info +DWENGO_LOGGING_LOKI_HOST=http://logging:3102 diff --git a/backend/.env.staging b/backend/.env.staging new file mode 100644 index 00000000..bedfb0b7 --- /dev/null +++ b/backend/.env.staging @@ -0,0 +1,21 @@ +PORT=3000 +DWENGO_DB_HOST=db +DWENGO_DB_PORT=5432 +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres +DWENGO_DB_UPDATE=false + +DWENGO_AUTH_STUDENT_URL=http://localhost/idp/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost/idp/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs + +# 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/,127.0.0.1:80,http://127.0.0.1,http://localhost:80,http://127.0.0.1:80,localhost +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080 + +# Logging and monitoring + +LOKI_HOST=http://logging:3102 diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 00000000..fb94aa09 --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,22 @@ +# +# 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 + +DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs +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_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:9876,* diff --git a/backend/.env.test.example b/backend/.env.test.example deleted file mode 100644 index b8a81003..00000000 --- a/backend/.env.test.example +++ /dev/null @@ -1,3 +0,0 @@ -PORT=3000 -DWENGO_DB_UPDATE=true -DWENGO_DB_NAME=":memory:" diff --git a/backend/Dockerfile b/backend/Dockerfile index bd7db2ff..1d82a484 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,37 +1,51 @@ FROM node:22 AS build-stage -WORKDIR /app +WORKDIR /app/dwengo # Install dependencies COPY package*.json ./ COPY backend/package.json ./backend/ +# Backend depends on common and docs +COPY common/package.json ./common/ +COPY docs/package.json ./docs/ RUN npm install --silent # Build the backend # Root tsconfig.json -COPY tsconfig.json ./ +COPY tsconfig.json tsconfig.build.json ./ -WORKDIR /app/backend - -COPY backend ./ -COPY docs /app/docs +COPY backend ./backend +COPY common ./common +COPY docs ./docs RUN npm run build FROM node:22 AS production-stage -WORKDIR /app +WORKDIR /app/dwengo -COPY package-lock.json backend/package.json ./ +# Copy static files + +COPY ./backend/i18n ./i18n + +# Copy built files + +COPY --from=build-stage /app/dwengo/common/dist ./common/dist +COPY --from=build-stage /app/dwengo/backend/dist ./backend/dist +COPY --from=build-stage /app/dwengo/docs/api/swagger.json ./docs/api/swagger.json + +COPY package*.json ./ +COPY backend/package.json ./backend/ +# Backend depends on common +COPY common/package.json ./common/ RUN npm install --silent --only=production -COPY ./docs /docs -COPY --from=build-stage /app/backend/dist ./dist/ +COPY ./backend/i18n ./backend/i18n EXPOSE 3000 -CMD ["node", "--env-file=.env", "dist/app.js"] +CMD ["node", "--env-file=/app/dwengo/backend/.env", "/app/dwengo/backend/dist/app.js"] diff --git a/backend/README.md b/backend/README.md index 442cea82..ded42bd8 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,20 @@ Voer volgend commando uit om de unit tests uit te voeren: npm run test:unit ``` +### Productie + +```shell +# Omgevingsvariabelen +cp .env.example .env +# Configureer de .env file met de juiste waarden! +nano .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 deleted file mode 100644 index be42027c..00000000 --- a/backend/config.js +++ /dev/null @@ -1,7 +0,0 @@ -// 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 4e3b890d..7943d61d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,24 +1,28 @@ { - "name": "dwengo-1-backend", - "version": "0.1.1", + "name": "@dwengo-1/backend", + "version": "0.2.0", "description": "Backend for Dwengo-1", "private": true, "type": "module", + "main": "dist/app.js", "scripts": { - "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", - "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", + "build": "cross-env NODE_ENV=production tsc --build", + "predev": "tsc --build ../common/tsconfig.json", + "dev": "cross-env NODE_ENV=development tsx tool/seed.ts && tsx watch --env-file=.env.development.local src/app.ts", "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", "format": "prettier --write src/", "format-check": "prettier --check src/", "lint": "eslint . --fix", - "test:unit": "vitest" + "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", + "test:unit": "vitest --run", + "test:coverage": "vitest --run --coverage.enabled true" }, "dependencies": { - "@mikro-orm/core": "6.4.9", - "@mikro-orm/knex": "6.4.9", - "@mikro-orm/postgresql": "6.4.9", - "@mikro-orm/reflection": "6.4.9", - "@mikro-orm/sqlite": "6.4.9", + "@mikro-orm/core": "6.4.12", + "@mikro-orm/knex": "6.4.12", + "@mikro-orm/postgresql": "6.4.12", + "@mikro-orm/reflection": "6.4.12", + "@mikro-orm/sqlite": "6.4.12", "axios": "^1.8.2", "cors": "^2.8.5", "cross": "^1.0.0", @@ -40,7 +44,7 @@ "winston-loki": "^6.1.3" }, "devDependencies": { - "@mikro-orm/cli": "6.4.9", + "@mikro-orm/cli": "6.4.12", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/js-yaml": "^4.0.9", diff --git a/backend/src/app.ts b/backend/src/app.ts index 0c5e8892..cf10a6df 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -5,15 +5,16 @@ 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(express.json()); app.use(cors); @@ -26,7 +27,9 @@ app.use('/api', apiRouter); // Swagger app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); -async function startServer() { +app.use(errorHandler); + +async function startServer(): Promise { await initORM(); app.listen(port, () => { diff --git a/backend/src/config.ts b/backend/src/config.ts index 69af5d74..9b209ada 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,12 +1,8 @@ -import { EnvVars, getEnvVar } from './util/envvars.js'; -import { Language } from './entities/content/language.js'; +import { envVars, getEnvVar } from './util/envVars.js'; // API -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 DWENGO_API_BASE = getEnvVar(envVars.LearningContentRepoApiBaseUrl); +export const FALLBACK_LANG = getEnvVar(envVars.FallbackLanguage); export const FALLBACK_SEQ_NUM = 1; +export const FALLBACK_VERSION_NUM = 1; diff --git a/backend/src/controllers/answers.ts b/backend/src/controllers/answers.ts new file mode 100644 index 00000000..38cebe84 --- /dev/null +++ b/backend/src/controllers/answers.ts @@ -0,0 +1,99 @@ +import { Request, Response } from 'express'; +import { requireFields } from './error-helper.js'; +import { getLearningObjectId, getQuestionId } from './questions.js'; +import { createAnswer, deleteAnswer, getAnswer, getAnswersByQuestion, updateAnswer } from '../services/answers.js'; +import { FALLBACK_SEQ_NUM } from '../config.js'; +import { AnswerData } from '@dwengo-1/common/interfaces/answer'; + +export async function getAllAnswersHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + const full = req.query.full === 'true'; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const answers = await getAnswersByQuestion(questionId, full); + + res.json({ answers }); +} + +export async function getAnswerHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + const seqAnswer = req.params.seqAnswer; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM; + const answer = await getAnswer(questionId, sequenceNumber); + + res.json({ answer }); +} + +export async function createAnswerHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const author = req.body.author as string; + const content = req.body.content as string; + requireFields({ author, content }); + + const answerData = req.body as AnswerData; + + const answer = await createAnswer(questionId, answerData); + + res.json({ answer }); +} + +export async function deleteAnswerHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + const seqAnswer = req.params.seqAnswer; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM; + const answer = await deleteAnswer(questionId, sequenceNumber); + + res.json({ answer }); +} + +export async function updateAnswerHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + const seqAnswer = req.params.seqAnswer; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const content = req.body.content as string; + requireFields({ content }); + + const answerData = req.body as AnswerData; + + const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM; + const answer = await updateAnswer(questionId, sequenceNumber, answerData); + + res.json({ answer }); +} diff --git a/backend/src/controllers/assignments.ts b/backend/src/controllers/assignments.ts index 03332469..2ca6d2fc 100644 --- a/backend/src/controllers/assignments.ts +++ b/backend/src/controllers/assignments.ts @@ -1,76 +1,93 @@ import { Request, Response } from 'express'; -import { createAssignment, getAllAssignments, getAssignment, getAssignmentsSubmissions } from '../services/assignments.js'; -import { AssignmentDTO } from '../interfaces/assignment.js'; +import { + createAssignment, + deleteAssignment, + getAllAssignments, + getAssignment, + getAssignmentsQuestions, + getAssignmentsSubmissions, + putAssignment, +} from '../services/assignments.js'; +import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { requireFields } from './error-helper.js'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { EntityDTO } from '@mikro-orm/core'; -// Typescript is annoy with with parameter forwarding from class.ts -interface AssignmentParams { - classid: string; - id: string; -} - -export async function getAllAssignmentsHandler(req: Request, res: Response): Promise { +function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } { const classid = req.params.classid; + const assignmentNumber = Number(req.params.id); 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: assignment }); -} - -export async function getAssignmentHandler(req: Request, res: Response): Promise { - const id = +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 = +req.params.id; + requireFields({ assignmentNumber, classid }); if (isNaN(assignmentNumber)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id should be a number'); } - const submissions = await getAssignmentsSubmissions(classid, assignmentNumber); - - res.json({ - submissions: submissions, - }); + return { classid, assignmentNumber, full }; +} + +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 }); +} + +export async function createAssignmentHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const description = req.body.description; + const language = req.body.language; + const learningPath = req.body.learningPath; + const title = req.body.title; + + requireFields({ description, language, learningPath, title }); + + const assignmentData = req.body as AssignmentDTO; + const assignment = await createAssignment(classid, assignmentData); + + res.json({ assignment }); +} + +export async function getAssignmentHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber } = getAssignmentParams(req); + + const assignment = await getAssignment(classid, assignmentNumber); + + res.json({ assignment }); +} + +export async function putAssignmentHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber } = getAssignmentParams(req); + + const assignmentData = req.body as Partial>; + const assignment = await putAssignment(classid, assignmentNumber, assignmentData); + + res.json({ assignment }); +} + +export async function deleteAssignmentHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber } = getAssignmentParams(req); + + const assignment = await deleteAssignment(classid, assignmentNumber); + + res.json({ assignment }); +} + +export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber, full } = getAssignmentParams(req); + + const submissions = await getAssignmentsSubmissions(classid, assignmentNumber, full); + + res.json({ submissions }); +} + +export async function getAssignmentQuestionsHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber, full } = getAssignmentParams(req); + + const questions = await getAssignmentsQuestions(classid, assignmentNumber, full); + + res.json({ questions }); } diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts index 409ead0c..49e2159b 100644 --- a/backend/src/controllers/auth.ts +++ b/backend/src/controllers/auth.ts @@ -1,33 +1,62 @@ -import { EnvVars, getEnvVar } from '../util/envvars.js'; +import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; +import { getLogger } from '../logging/initalize.js'; +import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; +import { createOrUpdateStudent } from '../services/students.js'; +import { createOrUpdateTeacher } from '../services/teachers.js'; +import { envVars, getEnvVar } from '../util/envVars.js'; +import { Response } from 'express'; -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'; +const logger = getLogger(); + 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, }, }; } + +export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise { + const auth = req.auth; + if (!auth) { + throw new UnauthorizedException('Cannot say hello when not authenticated.'); + } + const userData = { + id: auth.username, + username: auth.username, + firstName: auth.firstName ?? '', + lastName: auth.lastName ?? '', + }; + if (auth.accountType === 'student') { + await createOrUpdateStudent(userData); + logger.debug(`Synchronized student ${userData.username} with IDP`); + } else { + await createOrUpdateTeacher(userData); + logger.debug(`Synchronized teacher ${userData.username} with IDP`); + } + res.status(200).send({ message: 'Welcome!' }); +} diff --git a/backend/src/controllers/classes.ts b/backend/src/controllers/classes.ts index ca2f5698..6f253547 100644 --- a/backend/src/controllers/classes.ts +++ b/backend/src/controllers/classes.ts @@ -1,77 +1,132 @@ import { Request, Response } from 'express'; -import { createClass, getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/class.js'; -import { ClassDTO } from '../interfaces/class.js'; +import { + addClassStudent, + addClassTeacher, + createClass, + deleteClass, + deleteClassStudent, + deleteClassTeacher, + getAllClasses, + getClass, + getClassStudents, + getClassTeacherInvitations, + getClassTeachers, + putClass, +} from '../services/classes.js'; +import { ClassDTO } from '@dwengo-1/common/interfaces/class'; +import { requireFields } from './error-helper.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { Class } from '../entities/classes/class.entity.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, - }); + res.json({ classes }); } export async function createClassHandler(req: Request, res: Response): Promise { + const displayName = req.body.displayName; + requireFields({ displayName }); + 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({ class: cls }); + res.json({ class: cls }); } export async function getClassHandler(req: Request, res: Response): Promise { - try { - const classId = req.params.id; - const cls = await getClass(classId); + const classId = req.params.id; + requireFields({ classId }); - if (!cls) { - res.status(404).json({ error: 'Class not found' }); - return; - } - cls.endpoints = { - self: `${req.baseUrl}/${req.params.id}`, - invitations: `${req.baseUrl}/${req.params.id}/invitations`, - assignments: `${req.baseUrl}/${req.params.id}/assignments`, - students: `${req.baseUrl}/${req.params.id}/students`, - }; + const cls = await getClass(classId); - res.json(cls); - } catch (error) { - console.error('Error fetching learning objects:', error); - res.status(500).json({ error: 'Internal server error' }); - } + res.json({ class: cls }); +} + +export async function putClassHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + requireFields({ classId }); + + const newData = req.body as Partial>; + const cls = await putClass(classId, newData); + + res.json({ class: cls }); +} + +export async function deleteClassHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const cls = await deleteClass(classId); + + res.json({ class: cls }); } export async function getClassStudentsHandler(req: Request, res: Response): Promise { const classId = req.params.id; const full = req.query.full === 'true'; + requireFields({ classId }); - const students = full ? await getClassStudents(classId) : await getClassStudentsIds(classId); + const students = await getClassStudents(classId, full); - res.json({ - students: students, - }); + res.json({ students }); +} + +export async function getClassTeachersHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const full = req.query.full === 'true'; + requireFields({ classId }); + + const teachers = await getClassTeachers(classId, full); + + res.json({ teachers }); } export async function getTeacherInvitationsHandler(req: Request, res: Response): Promise { const classId = req.params.id; - const full = req.query.full === 'true'; // TODO: not implemented yet + const full = req.query.full === 'true'; + requireFields({ classId }); const invitations = await getClassTeacherInvitations(classId, full); - res.json({ - invitations: invitations, - }); + res.json({ invitations }); +} + +export async function deleteClassStudentHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.params.username; + requireFields({ classId, username }); + + const cls = await deleteClassStudent(classId, username); + + res.json({ class: cls }); +} + +export async function deleteClassTeacherHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.params.username; + requireFields({ classId, username }); + + const cls = await deleteClassTeacher(classId, username); + + res.json({ class: cls }); +} + +export async function addClassStudentHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.body.username; + requireFields({ classId, username }); + + const cls = await addClassStudent(classId, username); + + res.json({ class: cls }); +} + +export async function addClassTeacherHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const username = req.body.username; + requireFields({ classId, username }); + + const cls = await addClassTeacher(classId, username); + + res.json({ class: cls }); } diff --git a/backend/src/controllers/error-helper.ts b/backend/src/controllers/error-helper.ts new file mode 100644 index 00000000..a902560f --- /dev/null +++ b/backend/src/controllers/error-helper.ts @@ -0,0 +1,18 @@ +import { BadRequestException } from '../exceptions/bad-request-exception.js'; + +/** + * Checks for the presence of required fields and throws a BadRequestException + * if any are missing. + * + * @param fields - An object with key-value pairs to validate. + */ +export function requireFields(fields: Record): void { + const missing = Object.entries(fields) + .filter(([_, value]) => value === undefined || value === null || value === '') + .map(([key]) => key); + + if (missing.length > 0) { + const message = `Missing required field${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`; + throw new BadRequestException(message); + } +} diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts index b7bfd212..f17aada5 100644 --- a/backend/src/controllers/groups.ts +++ b/backend/src/controllers/groups.ts @@ -1,95 +1,120 @@ import { Request, Response } from 'express'; -import { createGroup, getAllGroups, getGroup, getGroupSubmissions } from '../services/groups.js'; -import { GroupDTO } from '../interfaces/group.js'; +import { createGroup, deleteGroup, getAllGroups, getGroup, getGroupQuestions, getGroupSubmissions, putGroup } from '../services/groups.js'; +import { GroupDTO } from '@dwengo-1/common/interfaces/group'; +import { requireFields } from './error-helper.js'; +import { BadRequestException } from '../exceptions/bad-request-exception.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 = +req.params.assignmentid; +function checkGroupFields(classId: string, assignmentId: number, groupId: number): void { + requireFields({ classId, assignmentId, groupId }); if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } - const groupId = +req.params.groupid!; // Can't be undefined - if (isNaN(groupId)) { - res.status(400).json({ error: 'Group id must be a number' }); - return; + throw new BadRequestException('Group id must be a number'); } +} - const group = await getGroup(classId, assignmentId, groupId, full); +export async function getGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); - res.json(group); + const group = await getGroup(classId, assignmentId, groupId); + + res.json({ group }); +} + +export async function putGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); + + // Only members field can be changed + const members = req.body.members; + requireFields({ members }); + + const group = await putGroup(classId, assignmentId, groupId, { members } as Partial); + + res.json({ group }); +} + +export async function deleteGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); + + const group = await deleteGroup(classId, assignmentId, groupId); + + res.json({ group }); } export async function getAllGroupsHandler(req: Request, res: Response): Promise { const classId = req.params.classid; + const assignmentId = Number(req.params.assignmentid); const full = req.query.full === 'true'; - - const assignmentId = +req.params.assignmentid; + requireFields({ classId, assignmentId }); if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } const groups = await getAllGroups(classId, assignmentId, full); - res.json({ - groups: groups, - }); + res.json({ groups }); } export async function createGroupHandler(req: Request, res: Response): Promise { const classid = req.params.classid; - const assignmentId = +req.params.assignmentid; + const assignmentId = Number(req.params.assignmentid); + const members = req.body.members; + requireFields({ classid, assignmentId, members }); if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; + throw new BadRequestException('Assignment id must be a number'); } 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 }); +} + +function getGroupParams(req: Request): { classId: string; assignmentId: number; groupId: number; full: boolean } { + const classId = req.params.classid; + const assignmentId = Number(req.params.assignmentid); + const groupId = Number(req.params.groupid); + const full = req.query.full === 'true'; + + requireFields({ classId, assignmentId, groupId }); + + if (isNaN(assignmentId)) { + throw new BadRequestException('Assignment id must be a number'); } - res.status(201).json({ group: group }); + if (isNaN(groupId)) { + throw new BadRequestException('Group id must be a number'); + } + + return { classId, assignmentId, groupId, full }; } export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise { - const classId = req.params.classid; - // Const full = req.query.full === 'true'; + const { classId, assignmentId, groupId, full } = getGroupParams(req); - const assignmentId = +req.params.assignmentid; + const submissions = await getGroupSubmissions(classId, assignmentId, groupId, full); - if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; - } - - const groupId = +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); - - res.json({ - submissions: submissions, - }); + res.json({ submissions }); +} + +export async function getGroupQuestionsHandler(req: Request, res: Response): Promise { + const { classId, assignmentId, groupId, full } = getGroupParams(req); + + const questions = await getGroupQuestions(classId, assignmentId, groupId, full); + + res.json({ questions }); } diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts index 455a4006..83aa33f9 100644 --- a/backend/src/controllers/learning-objects.ts +++ b/backend/src/controllers/learning-objects.ts @@ -1,20 +1,20 @@ 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 { Language } from '../entities/content/language.js'; -import { BadRequestException } from '../exceptions.js'; +import { Language } from '@dwengo-1/common/util/language'; import attachmentService from '../services/learning-objects/attachment-service.js'; -import { NotFoundError } from '@mikro-orm/core'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { envVars, getEnvVar } from '../util/envVars.js'; +import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; -function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { +function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO { 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,13 +40,18 @@ 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 { const learningObjectId = getLearningObjectIdentifierFromRequest(req); const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); + + if (!learningObject) { + throw new NotFoundException('Learning object not found'); + } + res.json(learningObject); } @@ -63,7 +68,7 @@ export async function getAttachment(req: Request, res: Response): Promise const attachment = await attachmentService.getAttachment(learningObjectId, name); if (!attachment) { - throw new NotFoundError(`Attachment ${name} not found`); + throw new NotFoundException(`Attachment ${name} not found`); } res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); } diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 37f92d91..1bd3f2b1 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -2,13 +2,11 @@ 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 { Language } from '@dwengo-1/common/util/language'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { Group } from '../entities/assignments/group.entity.js'; +import { getAssignmentRepository, getGroupRepository } from '../data/repositories.js'; /** * Fetch learning paths based on query parameters. @@ -19,20 +17,20 @@ export async function getLearningPaths(req: Request, res: Response): Promise theme.hruids); } - const learningPaths = await learningPathService.fetchLearningPaths( - hruidList, - language as Language, - `HRUIDs: ${hruidList.join(', ')}`, - personalizationTarget - ); + const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup); res.json(learningPaths.data); } diff --git a/backend/src/controllers/questions.ts b/backend/src/controllers/questions.ts index 917b48ae..f467f907 100644 --- a/backend/src/controllers/questions.ts +++ b/backend/src/controllers/questions.ts @@ -1,34 +1,27 @@ 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 { + createQuestion, + deleteQuestion, + getAllQuestions, + getQuestion, + getQuestionsAboutLearningObjectInAssignment, + updateQuestion, +} from '../services/questions.js'; +import { FALLBACK_LANG, FALLBACK_SEQ_NUM, FALLBACK_VERSION_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; - } +import { QuestionData, QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { Language } from '@dwengo-1/common/util/language'; +import { requireFields } from './error-helper.js'; +export function getLearningObjectId(hruid: string, version: string, lang: string): LearningObjectIdentifier { return { hruid, - language: (lang as Language) || FALLBACK_LANG, - version: +version, + language: (lang || FALLBACK_LANG) as Language, + version: Number(version) || FALLBACK_VERSION_NUM, }; } -function getQuestionId(req: Request, res: Response): QuestionId | null { - const seq = req.params.seq; - const learningObjectIdentifier = getObjectId(req, res); - - if (!learningObjectIdentifier) { - return null; - } - +export function getQuestionId(learningObjectIdentifier: LearningObjectIdentifier, seq: string): QuestionId { return { learningObjectIdentifier, sequenceNumber: seq ? Number(seq) : FALLBACK_SEQ_NUM, @@ -36,84 +29,96 @@ function getQuestionId(req: Request, res: Response): QuestionId | null { } export async function getAllQuestionsHandler(req: Request, res: Response): Promise { - const objectId = getObjectId(req, res); + const hruid = req.params.hruid; + const version = req.params.version; + const language = (req.query.lang ? req.query.lang : FALLBACK_LANG) as string; const full = req.query.full === 'true'; + requireFields({ hruid }); - if (!objectId) { - return; - } + const learningObjectId = getLearningObjectId(hruid, version, language); - const questions = await getAllQuestions(objectId, full); - - if (!questions) { - res.status(404).json({ error: `Questions not found.` }); + let questions: QuestionDTO[] | QuestionId[]; + if (req.query.classId && req.query.assignmentId) { + questions = await getQuestionsAboutLearningObjectInAssignment( + learningObjectId, + req.query.classId as string, + parseInt(req.query.assignmentId as string), + full ?? false, + req.query.forStudent as string | undefined + ); } else { - res.json(questions); + questions = await getAllQuestions(learningObjectId, full ?? false); } + + res.json({ questions }); } export async function getQuestionHandler(req: Request, res: Response): Promise { - const questionId = getQuestionId(req, res); + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); - if (!questionId) { - return; - } + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); 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 = getAnswersByQuestion(questionId, full); - - if (!answers) { - res.status(404).json({ error: `Questions not found.` }); - } else { - res.json(answers); - } + res.json({ question }); } export async function createQuestionHandler(req: Request, res: Response): Promise { - const questionDTO = req.body as QuestionDTO; + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + requireFields({ hruid }); - if (!questionDTO.learningObjectIdentifier || !questionDTO.author || !questionDTO.content) { - res.status(400).json({ error: 'Missing required fields: identifier and content' }); - return; - } + const loId = getLearningObjectId(hruid, version, language); - const question = await createQuestion(questionDTO); + const author = req.body.author as string; + const content = req.body.content as string; + const inGroup = req.body.inGroup; + requireFields({ author, content, inGroup }); - if (!question) { - res.status(400).json({ error: 'Could not add question' }); - } else { - res.json(question); - } + const questionData = req.body as QuestionData; + + const question = await createQuestion(loId, questionData); + + res.json({ question }); } export async function deleteQuestionHandler(req: Request, res: Response): Promise { - const questionId = getQuestionId(req, res); + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); - if (!questionId) { - return; - } + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); const question = await deleteQuestion(questionId); - if (!question) { - res.status(400).json({ error: 'Could not find nor delete question' }); - } else { - res.json(question); - } + res.json({ question }); +} + +export async function updateQuestionHandler(req: Request, res: Response): Promise { + const hruid = req.params.hruid; + const version = req.params.version; + const language = req.query.lang as string; + const seq = req.params.seq; + requireFields({ hruid }); + + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); + + const content = req.body.content as string; + requireFields({ content }); + + const questionData = req.body as QuestionData; + + const question = await updateQuestion(questionId, questionData); + + res.json({ question }); } diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts index 6c253cff..229cff7e 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -1,112 +1,67 @@ import { Request, Response } from 'express'; import { + createClassJoinRequest, createStudent, + deleteClassJoinRequest, deleteStudent, getAllStudents, + getJoinRequestByStudentClass, + getJoinRequestsByStudent, getStudent, getStudentAssignments, getStudentClasses, getStudentGroups, + getStudentQuestions, getStudentSubmissions, } from '../services/students.js'; -import { ClassDTO } from '../interfaces/class.js'; -import { getAllAssignments } from '../services/assignments.js'; -import { getUserHandler } from './users.js'; -import { Student } from '../entities/users/student.entity.js'; -import { StudentDTO } from '../interfaces/student.js'; -import { getStudentRepository } from '../data/repositories.js'; -import { UserDTO } from '../interfaces/user.js'; +import { requireFields } from './error-helper.js'; +import { StudentDTO } from '@dwengo-1/common/interfaces/student'; -// TODO: accept arguments (full, ...) -// TODO: endpoints export async function getAllStudentsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const studentRepository = getStudentRepository(); + const students: StudentDTO[] | string[] = await getAllStudents(full); - const students: StudentDTO[] | string[] = full ? await getAllStudents() : await getAllStudents(); - - if (!students) { - res.status(404).json({ error: `Student not found.` }); - return; - } - - res.status(201).json(students); + res.json({ students }); } export async function getStudentHandler(req: Request, res: Response): Promise { const username = req.params.username; + requireFields({ username }); - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + const student = await getStudent(username); - const user = await getStudent(username); - - if (!user) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); - return; - } - - res.status(201).json(user); + res.json({ student }); } -export async function createStudentHandler(req: Request, res: Response) { +export async function createStudentHandler(req: Request, res: Response): Promise { + const username = req.body.username; + const firstName = req.body.firstName; + const lastName = req.body.lastName; + requireFields({ username, firstName, lastName }); + 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); - res.status(201).json(newUser); + const student = await createStudent(userData); + res.json({ student }); } -export async function deleteStudentHandler(req: Request, res: Response) { +export async function deleteStudentHandler(req: Request, res: Response): Promise { const username = req.params.username; + requireFields({ 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); + const student = await deleteStudent(username); + res.json({ student }); } export async function getStudentClassesHandler(req: Request, res: Response): Promise { - try { - const full = req.query.full === 'true'; - const username = req.params.id; + const full = req.query.full === 'true'; + const username = req.params.username; + requireFields({ username }); - const classes = await getStudentClasses(username, full); + const classes = await getStudentClasses(username, full); - res.json({ - classes: classes, - endpoints: { - self: `${req.baseUrl}/${req.params.id}`, - classes: `${req.baseUrl}/${req.params.id}/invitations`, - questions: `${req.baseUrl}/${req.params.id}/assignments`, - students: `${req.baseUrl}/${req.params.id}/students`, - }, - }); - } catch (error) { - console.error('Error fetching learning objects:', error); - res.status(500).json({ error: 'Internal server error' }); - } + res.json({ classes }); } // TODO @@ -115,32 +70,75 @@ export async function getStudentClassesHandler(req: Request, res: Response): Pro // Have this assignment. export async function getStudentAssignmentsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const username = req.params.id; + const username = req.params.username; + requireFields({ username }); - const assignments = getStudentAssignments(username, full); + const assignments = await getStudentAssignments(username, full); - res.json({ - assignments: assignments, - }); + res.json({ assignments }); } export async function getStudentGroupsHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const username = req.params.id; + const username = req.params.username; + requireFields({ username }); const groups = await getStudentGroups(username, full); - res.json({ - groups: groups, - }); + res.json({ groups }); } export async function getStudentSubmissionsHandler(req: Request, res: Response): Promise { - const username = req.params.id; + const username = req.params.username; + const full = req.query.full === 'true'; + requireFields({ username }); - const submissions = await getStudentSubmissions(username); + const submissions = await getStudentSubmissions(username, full); - res.json({ - submissions: submissions, - }); + res.json({ submissions }); +} + +export async function getStudentQuestionsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const username = req.params.username; + requireFields({ username }); + + const questions = await getStudentQuestions(username, full); + + res.json({ questions }); +} + +export async function createStudentRequestHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const classId = req.body.classId; + requireFields({ username, classId }); + + const request = await createClassJoinRequest(username, classId); + res.json({ request }); +} + +export async function getStudentRequestsHandler(req: Request, res: Response): Promise { + const username = req.params.username; + requireFields({ username }); + + const requests = await getJoinRequestsByStudent(username); + res.json({ requests }); +} + +export async function getStudentRequestHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const classId = req.params.classId; + requireFields({ username, classId }); + + const request = await getJoinRequestByStudentClass(username, classId); + res.json({ request }); +} + +export async function deleteClassJoinRequestHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const classId = req.params.classId; + requireFields({ username, classId }); + + const request = await deleteClassJoinRequest(username, classId); + res.json({ request }); } diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts index 1e66dbe9..a117d7bf 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -1,59 +1,86 @@ 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'; +import { + createSubmission, + deleteSubmission, + getAllSubmissions, + getSubmission, + getSubmissionsForLearningObjectAndAssignment, +} from '../services/submissions.js'; +import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; +import { Language, languageMap } from '@dwengo-1/common/util/language'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { requireFields } from './error-helper.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -interface SubmissionParams { - hruid: string; - id: number; +export async function getSubmissionsHandler(req: Request, res: Response): Promise { + const loHruid = req.params.hruid; + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = parseInt(req.query.version as string) ?? 1; + + const forGroup = req.query.forGroup as string | undefined; + + const submissions: SubmissionDTO[] = await getSubmissionsForLearningObjectAndAssignment( + loHruid, + lang, + version, + req.query.classId as string, + parseInt(req.query.assignmentId as string), + forGroup ? parseInt(forGroup) : undefined + ); + + res.json({ submissions }); } -export async function getSubmissionHandler(req: Request, res: Response): Promise { +export async function getSubmissionHandler(req: Request, res: Response): Promise { const lohruid = req.params.hruid; - const submissionNumber = +req.params.id; + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + const submissionNumber = Number(req.params.id); + requireFields({ lohruid, submissionNumber }); if (isNaN(submissionNumber)) { - res.status(400).json({ error: 'Submission number is not a number' }); - return; + throw new BadRequestException('Submission number must be a number'); } - const lang = languageMap[req.query.language as string] || Language.Dutch; - const version = (req.query.version || 1) as number; + const loId = new LearningObjectIdentifier(lohruid, lang, version); + const submission = await getSubmission(loId, submissionNumber); - const submission = await getSubmission(lohruid, lang, version, submissionNumber); - - if (!submission) { - res.status(404).json({ error: 'Submission not found' }); - return; - } - - res.json(submission); + res.json({ submission }); } -export async function createSubmissionHandler(req: Request, res: Response) { - const submissionDTO = req.body as SubmissionDTO; +export async function getAllSubmissionsHandler(req: Request, res: Response): Promise { + const lohruid = req.params.hruid; + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + requireFields({ lohruid }); + const loId = new LearningObjectIdentifier(lohruid, lang, version); + const submissions = await getAllSubmissions(loId); + + res.json({ submissions }); +} + +// TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden +export async function createSubmissionHandler(req: Request, res: Response): Promise { + const submissionDTO = req.body as SubmissionDTO; const submission = await createSubmission(submissionDTO); - if (!submission) { - res.status(404).json({ error: 'Submission not added' }); - } else { - res.json(submission); - } + res.json({ submission }); } -export async function deleteSubmissionHandler(req: Request, res: Response) { +export async function deleteSubmissionHandler(req: Request, res: Response): Promise { const hruid = req.params.hruid; - const submissionNumber = +req.params.id; - const lang = languageMap[req.query.language as string] || Language.Dutch; const version = (req.query.version || 1) as number; + const submissionNumber = Number(req.params.id); + requireFields({ hruid, submissionNumber }); - const submission = await deleteSubmission(hruid, lang, version, submissionNumber); - - if (!submission) { - res.status(404).json({ error: 'Submission not found' }); - } else { - res.json(submission); + if (isNaN(submissionNumber)) { + throw new BadRequestException('Submission number must be a number'); } + + const loId = new LearningObjectIdentifier(hruid, lang, version); + const submission = await deleteSubmission(loId, submissionNumber); + + res.json({ submission }); } diff --git a/backend/src/controllers/teacher-invitations.ts b/backend/src/controllers/teacher-invitations.ts new file mode 100644 index 00000000..932bb1af --- /dev/null +++ b/backend/src/controllers/teacher-invitations.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import { requireFields } from './error-helper.js'; +import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; +import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; + +export async function getAllInvitationsHandler(req: Request, res: Response): Promise { + const username = req.params.username; + const by = req.query.sent === 'true'; + requireFields({ username }); + + const invitations = await getAllInvitations(username, by); + + res.json({ invitations }); +} + +export async function getInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const invitation = await getInvitation(sender, receiver, classId); + + res.json({ invitation }); +} + +export async function createInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await createInvitation(data); + + res.json({ invitation }); +} + +export async function updateInvitationHandler(req: Request, res: Response): Promise { + const sender = req.body.sender; + const receiver = req.body.receiver; + const classId = req.body.class; + req.body.accepted = req.body.accepted !== false; + requireFields({ sender, receiver, classId }); + + const data = req.body as TeacherInvitationData; + const invitation = await updateInvitation(data); + + res.json({ invitation }); +} + +export async function deleteInvitationHandler(req: Request, res: Response): Promise { + const sender = req.params.sender; + const receiver = req.params.receiver; + const classId = req.params.classId; + requireFields({ sender, receiver, classId }); + + const data: TeacherInvitationData = { + sender, + receiver, + class: classId, + }; + const invitation = await deleteInvitation(data); + + res.json({ invitation }); +} diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts index 52e5e713..c8063f80 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -4,141 +4,96 @@ import { deleteTeacher, getAllTeachers, getClassesByTeacher, - getClassIdsByTeacher, - getQuestionIdsByTeacher, - getQuestionsByTeacher, - getStudentIdsByTeacher, + getJoinRequestsByClass, getStudentsByTeacher, getTeacher, + getTeacherQuestions, + updateClassJoinRequestStatus, } from '../services/teachers.js'; -import { ClassDTO } from '../interfaces/class.js'; -import { StudentDTO } from '../interfaces/student.js'; -import { QuestionDTO, QuestionId } from '../interfaces/question.js'; -import { Teacher } from '../entities/users/teacher.entity.js'; -import { TeacherDTO } from '../interfaces/teacher.js'; -import { getTeacherRepository } from '../data/repositories.js'; +import { requireFields } from './error-helper.js'; +import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; export async function getAllTeachersHandler(req: Request, res: Response): Promise { const full = req.query.full === 'true'; - const teacherRepository = getTeacherRepository(); + const teachers: TeacherDTO[] | string[] = await getAllTeachers(full); - const teachers: TeacherDTO[] | string[] = full ? await getAllTeachers() : await getAllTeachers(); - - if (!teachers) { - res.status(404).json({ error: `Teacher not found.` }); - return; - } - - res.status(201).json(teachers); + res.json({ teachers }); } export async function getTeacherHandler(req: Request, res: Response): Promise { const username = req.params.username; + requireFields({ username }); - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + const teacher = await getTeacher(username); - const user = await getTeacher(username); - - if (!user) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); - return; - } - - res.status(201).json(user); + res.json({ teacher }); } -export async function createTeacherHandler(req: Request, res: Response) { +export async function createTeacherHandler(req: Request, res: Response): Promise { + const username = req.body.username; + const firstName = req.body.firstName; + const lastName = req.body.lastName; + requireFields({ username, firstName, lastName }); + 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); - res.status(201).json(newUser); + const teacher = await createTeacher(userData); + res.json({ teacher }); } -export async function deleteTeacherHandler(req: Request, res: Response) { +export async function deleteTeacherHandler(req: Request, res: Response): Promise { const username = req.params.username; + requireFields({ 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 with username '${username}' not found.`, - }); - return; - } - - res.status(200).json(deletedUser); + const teacher = await deleteTeacher(username); + res.json({ teacher }); } export async function getTeacherClassHandler(req: Request, res: Response): Promise { - try { - const username = req.params.username as string; - const full = req.query.full === 'true'; + const username = req.params.username; + const full = req.query.full === 'true'; + requireFields({ username }); - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + const classes = await getClassesByTeacher(username, full); - const classes: ClassDTO[] | string[] = full ? await getClassesByTeacher(username) : await getClassIdsByTeacher(username); - - res.status(201).json(classes); - } catch (error) { - console.error('Error fetching classes by teacher:', error); - res.status(500).json({ error: 'Internal server error' }); - } + res.json({ classes }); } export async function getTeacherStudentHandler(req: Request, res: Response): Promise { - try { - const username = req.params.username as string; - const full = req.query.full === 'true'; + const username = req.params.username; + const full = req.query.full === 'true'; + requireFields({ username }); - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + const students = await getStudentsByTeacher(username, full); - const students: StudentDTO[] | string[] = full ? await getStudentsByTeacher(username) : await getStudentIdsByTeacher(username); - - res.status(201).json(students); - } catch (error) { - console.error('Error fetching students by teacher:', error); - res.status(500).json({ error: 'Internal server error' }); - } + res.json({ students }); } export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { - try { - const username = req.params.username as string; - const full = req.query.full === 'true'; + const username = req.params.username; + const full = req.query.full === 'true'; + requireFields({ username }); - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + const questions = await getTeacherQuestions(username, full); - const questions: QuestionDTO[] | QuestionId[] = full ? await getQuestionsByTeacher(username) : await getQuestionIdsByTeacher(username); - - res.status(201).json(questions); - } catch (error) { - console.error('Error fetching questions by teacher:', error); - res.status(500).json({ error: 'Internal server error' }); - } + res.json({ questions }); +} + +export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise { + const classId = req.params.classId; + requireFields({ classId }); + + const joinRequests = await getJoinRequestsByClass(classId); + res.json({ joinRequests }); +} + +export async function updateStudentJoinRequestHandler(req: Request, res: Response): Promise { + const studentUsername = req.params.studentUsername; + const classId = req.params.classId; + const accepted = req.body.accepted !== 'false'; // Default = true + requireFields({ studentUsername, classId }); + + const request = await updateClassJoinRequestStatus(studentUsername, classId, accepted); + res.json({ request }); } diff --git a/backend/src/controllers/themes.ts b/backend/src/controllers/themes.ts index 61a1a834..b34d0c80 100644 --- a/backend/src/controllers/themes.ts +++ b/backend/src/controllers/themes.ts @@ -3,26 +3,30 @@ import { themes } from '../data/themes.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/controllers/users.ts b/backend/src/controllers/users.ts deleted file mode 100644 index 850c6549..00000000 --- a/backend/src/controllers/users.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Request, Response } from 'express'; -import { UserService } from '../services/users.js'; -import { UserDTO } from '../interfaces/user.js'; -import { User } from '../entities/users/user.entity.js'; - -export async function getAllUsersHandler(req: Request, res: Response, service: UserService): Promise { - try { - const full = req.query.full === 'true'; - - const users: UserDTO[] | string[] = full ? await service.getAllUsers() : await service.getAllUserIds(); - - if (!users) { - res.status(404).json({ error: `Users not found.` }); - return; - } - - res.status(201).json(users); - } catch (error) { - console.error('❌ Error fetching users:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function getUserHandler(req: Request, res: Response, service: UserService): Promise { - try { - const username = req.params.username as string; - - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const user = await service.getUserByUsername(username); - - if (!user) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); - return; - } - - res.status(201).json(user); - } catch (error) { - console.error('❌ Error fetching users:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function createUserHandler(req: Request, res: Response, service: UserService, UserClass: new () => T) { - try { - console.log('req', req); - const userData = req.body as UserDTO; - - if (!userData.username || !userData.firstName || !userData.lastName) { - res.status(400).json({ - error: 'Missing required fields: username, firstName, lastName', - }); - return; - } - - const newUser = await service.createUser(userData, UserClass); - res.status(201).json(newUser); - } catch (error) { - console.error('❌ Error creating user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -export async function deleteUserHandler(req: Request, res: Response, service: UserService) { - try { - const username = req.params.username; - - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } - - const deletedUser = await service.deleteUser(username); - if (!deletedUser) { - res.status(404).json({ - error: `User with username '${username}' not found.`, - }); - return; - } - - res.status(200).json(deletedUser); - } catch (error) { - console.error('❌ Error deleting user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts index c3c457d3..1c8bb504 100644 --- a/backend/src/data/assignments/assignment-repository.ts +++ b/backend/src/data/assignments/assignment-repository.ts @@ -3,13 +3,29 @@ 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 { - return this.findOne({ within: within, id: id }); + public async findByClassAndId(within: Class, id: number): Promise { + return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] }); } - public findAllAssignmentsInClass(within: Class): Promise { - return this.findAll({ where: { within: within } }); + public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise { + return this.findOne({ within: { classId: withinClass }, id: id }); } - public deleteByClassAndId(within: Class, id: number): Promise { + public async findAllByResponsibleTeacher(teacherUsername: string): Promise { + return this.findAll({ + where: { + within: { + teachers: { + $some: { + username: teacherUsername, + }, + }, + }, + }, + }); + } + public async findAllAssignmentsInClass(within: Class): Promise { + return this.findAll({ where: { within: within }, populate: ['groups', 'groups.members'] }); + } + 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 eb1b09e2..f06080f7 100644 --- a/backend/src/data/assignments/group-repository.ts +++ b/backend/src/data/assignments/group-repository.ts @@ -4,7 +4,7 @@ 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 { + public async findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise { return this.findOne( { assignment: assignment, @@ -13,16 +13,16 @@ export class GroupRepository extends DwengoEntityRepository { { populate: ['members'] } ); } - public findAllGroupsForAssignment(assignment: Assignment): Promise { + public async findAllGroupsForAssignment(assignment: Assignment): Promise { return this.findAll({ where: { assignment: assignment }, populate: ['members'], }); } - public findAllGroupsWithStudent(student: Student): Promise { + 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 251823fa..e9889bcf 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -3,9 +3,13 @@ import { Group } from '../../entities/assignments/group.entity.js'; import { Submission } from '../../entities/assignments/submission.entity.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { Student } from '../../entities/users/student.entity.js'; +import { Assignment } from '../../entities/assignments/assignment.entity'; 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 +18,15 @@ export class SubmissionRepository extends DwengoEntityRepository { }); } - public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { + public async findByLearningObject(loId: LearningObjectIdentifier): Promise { + return this.find({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + }); + } + + public async findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { return this.findOne( { learningObjectHruid: loId.hruid, @@ -26,7 +38,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,15 +50,60 @@ export class SubmissionRepository extends DwengoEntityRepository { ); } - public findAllSubmissionsForGroup(group: Group): Promise { - return this.find({ onBehalfOf: group }); + public async findAllSubmissionsForGroup(group: Group): Promise { + return this.find( + { onBehalfOf: group }, + { + populate: ['onBehalfOf.members'], + } + ); } - public findAllSubmissionsForStudent(student: Student): Promise { - return this.find({ submitter: student }); + /** + * Looks up all submissions for the given learning object which were submitted as part of the given assignment. + */ + public async findAllSubmissionsForLearningObjectAndAssignment(loId: LearningObjectIdentifier, assignment: Assignment): Promise { + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + onBehalfOf: { + assignment, + }, + }, + }); } - public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + /** + * Looks up all submissions for the given learning object which were submitted by the given group + */ + public async findAllSubmissionsForLearningObjectAndGroup(loId: LearningObjectIdentifier, group: Group): Promise { + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + onBehalfOf: group, + }, + }); + } + + public async findAllSubmissionsForStudent(student: Student): Promise { + const result = await this.find( + { submitter: student }, + { + populate: ['onBehalfOf.members'], + } + ); + + // Workaround: For some reason, without this MikroORM generates an UPDATE query with a syntax error in some tests + this.em.clear(); + + return result; + } + + 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..8bd0f81e 100644 --- a/backend/src/data/classes/class-join-request-repository.ts +++ b/backend/src/data/classes/class-join-request-repository.ts @@ -2,15 +2,19 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js'; import { Student } from '../../entities/users/student.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; 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 { - return this.findAll({ where: { class: clazz } }); + public async findAllOpenRequestsTo(clazz: Class): Promise { + return this.findAll({ where: { class: clazz, status: ClassStatus.Open } }); // TODO check if works like this } - public deleteBy(requester: Student, clazz: Class): Promise { + public async findByStudentAndClass(requester: Student, clazz: Class): Promise { + return this.findOne({ requester, class: clazz }); + } + 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 0ceed98e..f4e0723f 100644 --- a/backend/src/data/classes/class-repository.ts +++ b/backend/src/data/classes/class-repository.ts @@ -4,20 +4,20 @@ 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 { + 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 findByStudent(student: Student): Promise { + public async findByStudent(student: Student): Promise { return this.find( { students: student }, { populate: ['students', 'teachers'] } // Voegt student en teacher objecten toe ); } - public findByTeacher(teacher: Teacher): Promise { + 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..c9442e29 100644 --- a/backend/src/data/classes/teacher-invitation-repository.ts +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -2,22 +2,30 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Class } from '../../entities/classes/class.entity.js'; import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; 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 { - return this.findAll({ where: { receiver: receiver } }); + public async findAllInvitationsFor(receiver: Teacher): Promise { + return this.findAll({ where: { receiver: receiver, status: ClassStatus.Open } }); } - 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, class: clazz, }); } + public async findBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { + return this.findOne({ + sender: sender, + receiver: receiver, + class: clazz, + }); + } } diff --git a/backend/src/data/content/attachment-repository.ts b/backend/src/data/content/attachment-repository.ts index 95c5ab1c..69178b1c 100644 --- a/backend/src/data/content/attachment-repository.ts +++ b/backend/src/data/content/attachment-repository.ts @@ -1,10 +1,10 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Attachment } from '../../entities/content/attachment.entity.js'; -import { Language } from '../../entities/content/language'; +import { Language } from '@dwengo-1/common/util/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 49b4c536..889a1594 100644 --- a/backend/src/data/content/learning-object-repository.ts +++ b/backend/src/data/content/learning-object-repository.ts @@ -1,11 +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.js'; +import { Language } from '@dwengo-1/common/util/language'; 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, @@ -18,7 +18,7 @@ export class LearningObjectRepository extends DwengoEntityRepository { return this.findOne( { hruid: hruid, @@ -33,7 +33,7 @@ export class LearningObjectRepository extends DwengoEntityRepository { + public async findAllByTeacher(teacher: Teacher): Promise { 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..67f08a03 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -1,9 +1,13 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { LearningPath } from '../../entities/content/learning-path.entity.js'; -import { Language } from '../../entities/content/language.js'; +import { Language } from '@dwengo-1/common/util/language'; +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { EntityAlreadyExistsException } from '../../exceptions/entity-already-exists-exception.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'] }); } @@ -23,4 +27,27 @@ export class LearningPathRepository extends DwengoEntityRepository populate: ['nodes', 'nodes.transitions'], }); } + + public createNode(nodeData: RequiredEntityData): LearningPathNode { + return this.em.create(LearningPathNode, nodeData); + } + + public createTransition(transitionData: RequiredEntityData): LearningPathTransition { + return this.em.create(LearningPathTransition, transitionData); + } + + public async saveLearningPathNodesAndTransitions( + path: LearningPath, + nodes: LearningPathNode[], + transitions: LearningPathTransition[], + options?: { preventOverwrite?: boolean } + ): Promise { + if (options?.preventOverwrite && (await this.findOne(path))) { + throw new EntityAlreadyExistsException('A learning path with this hruid/language combination already exists.'); + } + const em = this.getEntityManager(); + await em.persistAndFlush(path); + await Promise.all(nodes.map(async (it) => em.persistAndFlush(it))); + await Promise.all(transitions.map(async (it) => em.persistAndFlush(it))); + } } 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..4ef30bbe 100644 --- a/backend/src/data/questions/answer-repository.ts +++ b/backend/src/data/questions/answer-repository.ts @@ -2,27 +2,43 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; import { Answer } from '../../entities/questions/answer.entity.js'; import { Question } from '../../entities/questions/question.entity.js'; import { Teacher } from '../../entities/users/teacher.entity.js'; +import { Loaded } from '@mikro-orm/core'; 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, content: answer.content, timestamp: new Date(), }); - return this.insert(answerEntity); + await this.insert(answerEntity); + answerEntity.toQuestion = answer.toQuestion; + answerEntity.author = answer.author; + answerEntity.content = answer.content; + return 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 findAnswer(question: Question, sequenceNumber: number): Promise | null> { + return this.findOne({ + toQuestion: question, + sequenceNumber, + }); + } + public async removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise { return this.deleteWhere({ toQuestion: question, sequenceNumber: sequenceNumber, }); } + public async updateContent(answer: Answer, newContent: string): Promise { + answer.content = newContent; + await this.save(answer); + return answer; + } } diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts index 9207e1dd..b9935b16 100644 --- a/backend/src/data/questions/question-repository.ts +++ b/backend/src/data/questions/question-repository.ts @@ -3,14 +3,18 @@ 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'; +import { Assignment } from '../../entities/assignments/assignment.entity.js'; +import { Loaded } from '@mikro-orm/core'; +import { Group } from '../../entities/assignments/group.entity'; export class QuestionRepository extends DwengoEntityRepository { - public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { + public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise { const questionEntity = this.create({ learningObjectHruid: question.loId.hruid, learningObjectLanguage: question.loId.language, learningObjectVersion: question.loId.version, author: question.author, + inGroup: question.inGroup, content: question.content, timestamp: new Date(), }); @@ -18,10 +22,11 @@ export class QuestionRepository extends DwengoEntityRepository { questionEntity.learningObjectLanguage = question.loId.language; questionEntity.learningObjectVersion = question.loId.version; questionEntity.author = question.author; + questionEntity.inGroup = question.inGroup; questionEntity.content = question.content; - return this.insert(questionEntity); + return await this.insert(questionEntity); } - public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { + public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { return this.findAll({ where: { learningObjectHruid: loId.hruid, @@ -33,7 +38,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, @@ -54,4 +59,73 @@ export class QuestionRepository extends DwengoEntityRepository { orderBy: { timestamp: 'ASC' }, }); } + + public async findAllByAssignment(assignment: Assignment): Promise { + return this.find({ + inGroup: assignment.groups.getItems(), + learningObjectHruid: assignment.learningPathHruid, + learningObjectLanguage: assignment.learningPathLanguage, + }); + } + + public async findAllByAuthor(author: Student): Promise { + return this.findAll({ + where: { author }, + orderBy: { timestamp: 'DESC' }, // New to old + }); + } + + public async findAllByGroup(inGroup: Group): Promise { + return this.findAll({ + where: { inGroup }, + orderBy: { timestamp: 'DESC' }, + }); + } + + /** + * Looks up all questions for the given learning object which were asked as part of the given assignment. + * When forStudentUsername is set, only the questions within the given user's group are shown. + */ + public async findAllQuestionsAboutLearningObjectInAssignment( + loId: LearningObjectIdentifier, + assignment: Assignment, + forStudentUsername?: string + ): Promise { + const inGroup = forStudentUsername + ? { + assignment, + members: { + $some: { + username: forStudentUsername, + }, + }, + } + : { + assignment, + }; + + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + inGroup, + }, + }); + } + + public async findByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise | null> { + return this.findOne({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + sequenceNumber, + }); + } + + public async updateContent(question: Question, newContent: string): Promise { + question.content = newContent; + await this.save(question); + return question; + } } diff --git a/backend/src/data/repositories.ts b/backend/src/data/repositories.ts index 02385109..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 { diff --git a/backend/src/data/themes.ts b/backend/src/data/themes.ts index b0fc930c..0a2272e6 100644 --- a/backend/src/data/themes.ts +++ b/backend/src/data/themes.ts @@ -1,7 +1,4 @@ -export interface Theme { - title: string; - hruids: string[]; -} +import { Theme } from '@dwengo-1/common/interfaces/theme'; export const themes: Theme[] = [ { diff --git a/backend/src/data/users/student-repository.ts b/backend/src/data/users/student-repository.ts index 0792678d..2efca048 100644 --- a/backend/src/data/users/student-repository.ts +++ b/backend/src/data/users/student-repository.ts @@ -1,15 +1,11 @@ import { Student } from '../../entities/users/student.entity.js'; -import { User } from '../../entities/users/user.entity.js'; import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; -// Import { UserRepository } from './user-repository.js'; - -// Export class StudentRepository extends UserRepository {} 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 2b2bee75..aa915627 100644 --- a/backend/src/data/users/teacher-repository.ts +++ b/backend/src/data/users/teacher-repository.ts @@ -1,12 +1,11 @@ import { Teacher } from '../../entities/users/teacher.entity.js'; import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; -import { UserRepository } from './user-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 21497b79..44eb0bc7 100644 --- a/backend/src/data/users/user-repository.ts +++ b/backend/src/data/users/user-repository.ts @@ -2,10 +2,10 @@ 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 { + public async findByUsername(username: string): Promise { return this.findOne({ username } as Partial); } - public deleteByUsername(username: string): Promise { + 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 692e2112..a12ffbac 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -1,7 +1,7 @@ -import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Cascade, Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Class } from '../classes/class.entity.js'; import { Group } from './group.entity.js'; -import { Language } from '../content/language.js'; +import { Language } from '@dwengo-1/common/util/language'; import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; @Entity({ @@ -14,7 +14,7 @@ export class Assignment { }) within!: Class; - @PrimaryKey({ type: 'number', autoincrement: true }) + @PrimaryKey({ type: 'integer', autoincrement: true }) id?: number; @Property({ type: 'string' }) @@ -34,6 +34,7 @@ export class Assignment { @OneToMany({ entity: () => Group, mappedBy: 'assignment', + cascade: [Cascade.ALL], }) - groups!: Group[]; + groups: Collection = new Collection(this); } diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts index 213e0f38..62d5fee9 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -7,17 +7,23 @@ import { GroupRepository } from '../../data/assignments/group-repository.js'; repository: () => GroupRepository, }) export class Group { + /* + WARNING: Don't move the definition of groupNumber! If it does not come before the definition of assignment, + creating groups fails because of a MikroORM bug! + */ + @PrimaryKey({ type: 'integer', autoincrement: true }) + groupNumber?: number; + @ManyToOne({ entity: () => Assignment, primary: true, }) assignment!: Assignment; - @PrimaryKey({ type: 'integer', autoincrement: true }) - groupNumber?: number; - @ManyToMany({ entity: () => Student, + owner: true, + inversedBy: 'groups', }) - members!: Student[]; + members: Collection = new Collection(this); } diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts index f008c8c2..b19a99eb 100644 --- a/backend/src/entities/assignments/submission.entity.ts +++ b/backend/src/entities/assignments/submission.entity.ts @@ -1,11 +1,14 @@ import { Student } from '../users/student.entity.js'; import { Group } from './group.entity.js'; -import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; -import { Language } from '../content/language.js'; +import { Entity, Enum, ManyToOne, PrimaryKey, Property, Cascade } from '@mikro-orm/core'; import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; +import { Language } from '@dwengo-1/common/util/language'; @Entity({ repository: () => SubmissionRepository }) export class Submission { + @PrimaryKey({ type: 'integer', autoincrement: true }) + submissionNumber?: number; + @PrimaryKey({ type: 'string' }) learningObjectHruid!: string; @@ -15,11 +18,13 @@ export class Submission { }) learningObjectLanguage!: Language; - @PrimaryKey({ type: 'numeric' }) - learningObjectVersion: number = 1; + @PrimaryKey({ type: 'numeric', autoincrement: false }) + learningObjectVersion = 1; - @PrimaryKey({ type: 'integer', autoincrement: true }) - submissionNumber!: number; + @ManyToOne(() => Group, { + cascade: [Cascade.REMOVE], + }) + onBehalfOf!: Group; @ManyToOne({ entity: () => Student, @@ -29,12 +34,6 @@ export class Submission { @Property({ type: 'datetime' }) submissionTime!: Date; - @ManyToOne({ - entity: () => Group, - nullable: true, - }) - onBehalfOf?: Group; - @Property({ type: 'json' }) content!: string; } diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts index bdef1f52..548968a6 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -2,6 +2,7 @@ import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Student } from '../users/student.entity.js'; import { Class } from './class.entity.js'; import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; @Entity({ repository: () => ClassJoinRequestRepository, @@ -19,12 +20,6 @@ export class ClassJoinRequest { }) class!: Class; - @Enum(() => ClassJoinRequestStatus) - status!: ClassJoinRequestStatus; -} - -export enum ClassJoinRequestStatus { - Open = 'open', - Accepted = 'accepted', - Declined = 'declined', + @Enum(() => ClassStatus) + status!: ClassStatus; } diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index 63315304..b2c59ade 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -14,9 +14,9 @@ export class Class { @Property({ type: 'string' }) displayName!: string; - @ManyToMany(() => Teacher) + @ManyToMany({ entity: () => Teacher, owner: true, inversedBy: 'classes' }) teachers!: Collection; - @ManyToMany(() => Student) + @ManyToMany({ entity: () => Student, owner: true, inversedBy: 'classes' }) students!: Collection; } diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts index 668a0a1c..6059f155 100644 --- a/backend/src/entities/classes/teacher-invitation.entity.ts +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -1,7 +1,8 @@ -import { Entity, ManyToOne } from '@mikro-orm/core'; +import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { Class } from './class.entity.js'; import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; /** * Invitation of a teacher into a class (in order to teach it). @@ -25,4 +26,7 @@ export class TeacherInvitation { primary: true, }) class!: Class; + + @Enum(() => ClassStatus) + status!: ClassStatus; } 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/learning-object-identifier.ts b/backend/src/entities/content/learning-object-identifier.ts index 3c020bd7..09a9c057 100644 --- a/backend/src/entities/content/learning-object-identifier.ts +++ b/backend/src/entities/content/learning-object-identifier.ts @@ -1,9 +1,11 @@ -import { Language } from './language.js'; +import { Language } from '@dwengo-1/common/util/language'; export class LearningObjectIdentifier { constructor( 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..e0ae09d6 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 { Language } from './language.js'; +import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; 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'; +import { Language } from '@dwengo-1/common/util/language'; @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(); @@ -58,11 +42,11 @@ export class LearningObject { @Property({ type: 'array' }) keywords: string[] = []; - @Property({ type: 'array', nullable: true }) + @Property({ type: new ArrayType((i) => Number(i)), nullable: true }) 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/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts index 03499270..fd870dcd 100644 --- a/backend/src/entities/content/learning-path-node.entity.ts +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -1,16 +1,16 @@ -import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; -import { Language } from './language.js'; +import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; import { LearningPath } from './learning-path.entity.js'; import { LearningPathTransition } from './learning-path-transition.entity.js'; +import { Language } from '@dwengo-1/common/util/language'; @Entity() export class LearningPathNode { + @PrimaryKey({ type: 'integer', autoincrement: true }) + nodeNumber?: number; + @ManyToOne({ entity: () => LearningPath, primary: true }) learningPath!: Rel; - @PrimaryKey({ type: 'integer', autoincrement: true }) - nodeNumber!: number; - @Property({ type: 'string' }) learningObjectHruid!: string; @@ -27,7 +27,7 @@ export class LearningPathNode { startNode!: boolean; @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) - transitions: LearningPathTransition[] = []; + transitions!: Collection; @Property({ length: 3 }) createdAt: Date = new Date(); diff --git a/backend/src/entities/content/learning-path-transition.entity.ts b/backend/src/entities/content/learning-path-transition.entity.ts index 7d6601a3..0f466fdd 100644 --- a/backend/src/entities/content/learning-path-transition.entity.ts +++ b/backend/src/entities/content/learning-path-transition.entity.ts @@ -3,12 +3,12 @@ import { LearningPathNode } from './learning-path-node.entity.js'; @Entity() export class LearningPathTransition { - @ManyToOne({ entity: () => LearningPathNode, primary: true }) - node!: Rel; - @PrimaryKey({ type: 'numeric' }) transitionNumber!: number; + @ManyToOne({ entity: () => LearningPathNode, primary: true }) + node!: Rel; + @Property({ type: 'string' }) condition!: string; diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 888cc0cf..1b96d8ea 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -1,8 +1,8 @@ -import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; -import { Language } from './language.js'; +import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Teacher } from '../users/teacher.entity.js'; import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; import { LearningPathNode } from './learning-path-node.entity.js'; +import { Language } from '@dwengo-1/common/util/language'; @Entity({ repository: () => LearningPathRepository }) export class LearningPath { @@ -25,5 +25,5 @@ export class LearningPath { image: Buffer | null = null; @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) - nodes: LearningPathNode[] = []; + nodes: Collection = new Collection(this); } 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..44ccfbd3 100644 --- a/backend/src/entities/questions/question.entity.ts +++ b/backend/src/entities/questions/question.entity.ts @@ -1,7 +1,8 @@ import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; -import { Language } from '../content/language.js'; import { Student } from '../users/student.entity.js'; import { QuestionRepository } from '../../data/questions/question-repository.js'; +import { Language } from '@dwengo-1/common/util/language'; +import { Group } from '../assignments/group.entity.js'; @Entity({ repository: () => QuestionRepository }) export class Question { @@ -15,11 +16,14 @@ export class Question { learningObjectLanguage!: Language; @PrimaryKey({ type: 'number' }) - learningObjectVersion: number = 1; + learningObjectVersion = 1; @PrimaryKey({ type: 'integer', autoincrement: true }) sequenceNumber?: number; + @ManyToOne({ entity: () => Group }) + inGroup!: Group; + @ManyToOne({ entity: () => Student, }) diff --git a/backend/src/entities/users/student.entity.ts b/backend/src/entities/users/student.entity.ts index da5b4367..9f294d3c 100644 --- a/backend/src/entities/users/student.entity.ts +++ b/backend/src/entities/users/student.entity.ts @@ -8,17 +8,9 @@ import { StudentRepository } from '../../data/users/student-repository.js'; repository: () => StudentRepository, }) export class Student extends User { - @ManyToMany(() => Class) + @ManyToMany({ entity: () => Class, mappedBy: 'students' }) classes!: Collection; - @ManyToMany(() => Group) - groups!: Collection; - - constructor( - public username: string, - public firstName: string, - public lastName: string - ) { - super(); - } + @ManyToMany({ entity: () => Group, mappedBy: 'members' }) + groups: Collection = new Collection(this); } diff --git a/backend/src/entities/users/teacher.entity.ts b/backend/src/entities/users/teacher.entity.ts index 8e22d1de..8fbe5e51 100644 --- a/backend/src/entities/users/teacher.entity.ts +++ b/backend/src/entities/users/teacher.entity.ts @@ -5,14 +5,6 @@ import { TeacherRepository } from '../../data/users/teacher-repository.js'; @Entity({ repository: () => TeacherRepository }) export class Teacher extends User { - @ManyToMany(() => Class) + @ManyToMany({ entity: () => Class, mappedBy: 'teachers' }) 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..5f12e25d --- /dev/null +++ b/backend/src/exceptions/exception-with-http-state.ts @@ -0,0 +1,13 @@ +import { HasStatusCode } from './has-status-code'; + +/** + * Exceptions which are associated with a HTTP error code. + */ +export abstract class ExceptionWithHttpState extends Error implements HasStatusCode { + 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/has-status-code.ts b/backend/src/exceptions/has-status-code.ts new file mode 100644 index 00000000..46b8e491 --- /dev/null +++ b/backend/src/exceptions/has-status-code.ts @@ -0,0 +1,6 @@ +export interface HasStatusCode { + status: number; +} +export function hasStatusCode(err: unknown): err is HasStatusCode { + return typeof err === 'object' && err !== null && 'status' in err && typeof (err as HasStatusCode)?.status === 'number'; +} 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/server-error-exception.ts b/backend/src/exceptions/server-error-exception.ts new file mode 100644 index 00000000..49251bdf --- /dev/null +++ b/backend/src/exceptions/server-error-exception.ts @@ -0,0 +1,12 @@ +import { ExceptionWithHttpState } from './exception-with-http-state.js'; + +/** + * Exception for HTTP 500 Internal Server Error + */ +export class ServerErrorException extends ExceptionWithHttpState { + status = 500; + + constructor(message = 'Internal server error, something went wrong') { + super(500, message); + } +} 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 index 493fd3c0..513fc63e 100644 --- a/backend/src/interfaces/answer.ts +++ b/backend/src/interfaces/answer.ts @@ -1,21 +1,14 @@ -import { mapToUserDTO, UserDTO } from './user.js'; -import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from './question.js'; +import { mapToQuestionDTO, mapToQuestionDTOId } from './question.js'; import { Answer } from '../entities/questions/answer.entity.js'; - -export interface AnswerDTO { - author: UserDTO; - toQuestion: QuestionDTO; - sequenceNumber: number; - timestamp: string; - content: string; -} +import { AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer'; +import { mapToTeacherDTO } from './teacher.js'; /** * Convert a Question entity to a DTO format. */ export function mapToAnswerDTO(answer: Answer): AnswerDTO { return { - author: mapToUserDTO(answer.author), + author: mapToTeacherDTO(answer.author), toQuestion: mapToQuestionDTO(answer.toQuestion), sequenceNumber: answer.sequenceNumber!, timestamp: answer.timestamp.toISOString(), @@ -23,16 +16,10 @@ export function mapToAnswerDTO(answer: Answer): AnswerDTO { }; } -export interface AnswerId { - author: string; - toQuestion: QuestionId; - sequenceNumber: number; -} - -export function mapToAnswerId(answer: AnswerDTO): AnswerId { +export function mapToAnswerDTOId(answer: Answer): AnswerId { return { author: answer.author.username, - toQuestion: mapToQuestionId(answer.toQuestion), - sequenceNumber: answer.sequenceNumber, + toQuestion: mapToQuestionDTOId(answer.toQuestion), + sequenceNumber: answer.sequenceNumber!, }; } diff --git a/backend/src/interfaces/assignment.ts b/backend/src/interfaces/assignment.ts index 8f6120b6..7c5a0909 100644 --- a/backend/src/interfaces/assignment.ts +++ b/backend/src/interfaces/assignment.ts @@ -1,52 +1,36 @@ -import { FALLBACK_LANG } from '../config.js'; +import { languageMap } from '@dwengo-1/common/util/language'; 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, mapToGroupDTO } from './group.js'; +import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; +import { mapToGroupDTO } from './group.js'; +import { getAssignmentRepository } from '../data/repositories.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 { +export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTOId { 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), + within: assignment.within.classId!, }; } export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { return { id: assignment.id!, - class: assignment.within.classId!, + within: assignment.within.classId!, title: assignment.title, description: assignment.description, learningPath: assignment.learningPathHruid, language: assignment.learningPathLanguage, - // Groups: assignment.groups.map(mapToGroupDTO), + groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), }; } 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; - - console.log(assignment); - - return assignment; + return getAssignmentRepository().create({ + within: cls, + title: assignmentData.title, + description: assignmentData.description, + learningPathHruid: assignmentData.learningPath, + learningPathLanguage: languageMap[assignmentData.language], + groups: [], + }); } diff --git a/backend/src/interfaces/class.ts b/backend/src/interfaces/class.ts index 371e3cae..76fa5fd5 100644 --- a/backend/src/interfaces/class.ts +++ b/backend/src/interfaces/class.ts @@ -2,20 +2,7 @@ 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[]; - endpoints?: { - self: string; - invitations: string; - assignments: string; - students: string; - }; -} +import { ClassDTO } from '@dwengo-1/common/interfaces/class'; export function mapToClassDTO(cls: Class): ClassDTO { return { @@ -23,7 +10,6 @@ export function mapToClassDTO(cls: Class): ClassDTO { displayName: cls.displayName, teachers: cls.teachers.map((teacher) => teacher.username), students: cls.students.map((student) => student.username), - joinRequests: [], // TODO }; } diff --git a/backend/src/interfaces/group.ts b/backend/src/interfaces/group.ts index a25c5b8e..3cebb9eb 100644 --- a/backend/src/interfaces/group.ts +++ b/backend/src/interfaces/group.ts @@ -1,23 +1,46 @@ import { Group } from '../entities/assignments/group.entity.js'; -import { AssignmentDTO, mapToAssignmentDTO } from './assignment.js'; -import { mapToStudentDTO, StudentDTO } from './student.js'; +import { mapToAssignment } from './assignment.js'; +import { mapToStudent } from './student.js'; +import { mapToStudentDTO } from './student.js'; +import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; +import { getGroupRepository } from '../data/repositories.js'; +import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; +import { Class } from '../entities/classes/class.entity.js'; +import { StudentDTO } from '@dwengo-1/common/interfaces/student'; -export interface GroupDTO { - assignment: number | AssignmentDTO; - groupNumber: number; - members: string[] | StudentDTO[]; +export function mapToGroup(groupDto: GroupDTO, clazz: Class): Group { + const assignmentDto = groupDto.assignment as AssignmentDTO; + + return getGroupRepository().create({ + groupNumber: groupDto.groupNumber, + assignment: mapToAssignment(assignmentDto, clazz), + members: groupDto.members!.map((studentDto) => mapToStudent(studentDto as StudentDTO)), + }); } -export function mapToGroupDTO(group: Group): GroupDTO { +export function mapToGroupDTO(group: Group, cls: Class): GroupDTO { return { - assignment: mapToAssignmentDTO(group.assignment), // ERROR: , group.assignment.within), + class: cls.classId!, + assignment: group.assignment.id!, groupNumber: group.groupNumber!, members: group.members.map(mapToStudentDTO), }; } -export function mapToGroupDTOId(group: Group): GroupDTO { +export function mapToGroupDTOId(group: Group, cls: Class): GroupDTOId { return { + class: cls.classId!, + assignment: group.assignment.id!, + groupNumber: group.groupNumber!, + }; +} + +/** + * Map to group DTO where other objects are only referenced by their id. + */ +export function mapToShallowGroupDTO(group: Group): GroupDTO { + return { + class: group.assignment.within.classId!, assignment: group.assignment.id!, groupNumber: group.groupNumber!, members: group.members.map((member) => member.username), diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts index 8cca42f6..98e6f33c 100644 --- a/backend/src/interfaces/question.ts +++ b/backend/src/interfaces/question.ts @@ -1,44 +1,47 @@ import { Question } from '../entities/questions/question.entity.js'; -import { UserDTO } from './user.js'; +import { mapToStudentDTO } from './student.js'; +import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content'; import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; -import { mapToStudentDTO, StudentDTO } from './student.js'; -import { TeacherDTO } from './teacher.js'; +import { mapToGroupDTOId } from './group.js'; -export interface QuestionDTO { - learningObjectIdentifier: LearningObjectIdentifier; - sequenceNumber?: number; - author: StudentDTO; - timestamp?: string; - content: string; +function getLearningObjectIdentifier(question: Question): LearningObjectIdentifierDTO { + return { + hruid: question.learningObjectHruid, + language: question.learningObjectLanguage, + version: question.learningObjectVersion, + }; +} + +export function mapToLearningObjectID(loID: LearningObjectIdentifierDTO): LearningObjectIdentifier { + return { + hruid: loID.hruid, + language: loID.language, + version: loID.version ?? 1, + }; } /** * 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, - }; + const learningObjectIdentifier = getLearningObjectIdentifier(question); return { learningObjectIdentifier, sequenceNumber: question.sequenceNumber!, author: mapToStudentDTO(question.author), + inGroup: mapToGroupDTOId(question.inGroup, question.inGroup.assignment?.within), timestamp: question.timestamp.toISOString(), content: question.content, }; } -export interface QuestionId { - learningObjectIdentifier: LearningObjectIdentifier; - sequenceNumber: number; -} +export function mapToQuestionDTOId(question: Question): QuestionId { + const learningObjectIdentifier = getLearningObjectIdentifier(question); -export function mapToQuestionId(question: QuestionDTO): QuestionId { return { - learningObjectIdentifier: question.learningObjectIdentifier, + learningObjectIdentifier, sequenceNumber: question.sequenceNumber!, }; } diff --git a/backend/src/interfaces/student-request.ts b/backend/src/interfaces/student-request.ts new file mode 100644 index 00000000..a4d3b31b --- /dev/null +++ b/backend/src/interfaces/student-request.ts @@ -0,0 +1,23 @@ +import { mapToStudentDTO } from './student.js'; +import { ClassJoinRequest } from '../entities/classes/class-join-request.entity.js'; +import { getClassJoinRequestRepository } from '../data/repositories.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; + +export function mapToStudentRequestDTO(request: ClassJoinRequest): ClassJoinRequestDTO { + return { + requester: mapToStudentDTO(request.requester), + class: request.class.classId!, + status: request.status, + }; +} + +export function mapToStudentRequest(student: Student, cls: Class): ClassJoinRequest { + return getClassJoinRequestRepository().create({ + requester: student, + class: cls, + status: ClassStatus.Open, + }); +} diff --git a/backend/src/interfaces/student.ts b/backend/src/interfaces/student.ts index 079b355b..06e173a1 100644 --- a/backend/src/interfaces/student.ts +++ b/backend/src/interfaces/student.ts @@ -1,17 +1,6 @@ import { Student } from '../entities/users/student.entity.js'; - -export interface StudentDTO { - id: string; - username: string; - firstName: string; - lastName: string; - endpoints?: { - classes: string; - questions: string; - invitations: string; - groups: string; - }; -} +import { getStudentRepository } from '../data/repositories.js'; +import { StudentDTO } from '@dwengo-1/common/interfaces/student'; export function mapToStudentDTO(student: Student): StudentDTO { return { @@ -23,7 +12,9 @@ export function mapToStudentDTO(student: Student): StudentDTO { } export function mapToStudent(studentData: StudentDTO): Student { - const student = new Student(studentData.username, studentData.firstName, studentData.lastName); - - return 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 index fbaf520d..e3b60311 100644 --- a/backend/src/interfaces/submission.ts +++ b/backend/src/interfaces/submission.ts @@ -1,47 +1,44 @@ 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 { mapToUser } from './user'; -import { Student } from '../entities/users/student.entity'; - -export interface SubmissionDTO { - learningObjectHruid: string; - learningObjectLanguage: Language; - learningObjectVersion: number; - - submissionNumber?: number; - submitter: StudentDTO; - time?: Date; - group?: GroupDTO; - content: string; -} +import { mapToGroupDTOId } from './group.js'; +import { mapToStudentDTO } from './student.js'; +import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; +import { getSubmissionRepository } from '../data/repositories.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Group } from '../entities/assignments/group.entity.js'; 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 ? mapToGroupDTOId(submission.onBehalfOf, submission.onBehalfOf.assignment.within) : undefined, + content: submission.content, + }; +} + +export function mapToSubmissionDTOId(submission: Submission): SubmissionDTOId { return { learningObjectHruid: submission.learningObjectHruid, learningObjectLanguage: submission.learningObjectLanguage, learningObjectVersion: submission.learningObjectVersion, submissionNumber: submission.submissionNumber, - submitter: mapToStudentDTO(submission.submitter), - time: submission.submissionTime, - group: submission.onBehalfOf ? mapToGroupDTO(submission.onBehalfOf) : undefined, - content: submission.content, }; } -export function mapToSubmission(submissionDTO: SubmissionDTO): Submission { - const submission = new Submission(); - submission.learningObjectHruid = submissionDTO.learningObjectHruid; - submission.learningObjectLanguage = submissionDTO.learningObjectLanguage; - submission.learningObjectVersion = submissionDTO.learningObjectVersion; - // 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; +export function mapToSubmission(submissionDTO: SubmissionDTO, submitter: Student, onBehalfOf: Group): Submission { + return getSubmissionRepository().create({ + learningObjectHruid: submissionDTO.learningObjectIdentifier.hruid, + learningObjectLanguage: submissionDTO.learningObjectIdentifier.language, + learningObjectVersion: submissionDTO.learningObjectIdentifier.version || 1, + submitter: submitter, + submissionTime: new Date(), + content: submissionDTO.content, + onBehalfOf: onBehalfOf, + }); } diff --git a/backend/src/interfaces/teacher-invitation.ts b/backend/src/interfaces/teacher-invitation.ts index cddef566..88b66f7a 100644 --- a/backend/src/interfaces/teacher-invitation.ts +++ b/backend/src/interfaces/teacher-invitation.ts @@ -1,18 +1,17 @@ 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; -} +import { mapToUserDTO } from './user.js'; +import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { getTeacherInvitationRepository } from '../data/repositories.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO { return { sender: mapToUserDTO(invitation.sender), receiver: mapToUserDTO(invitation.receiver), - class: mapToClassDTO(invitation.class), + classId: invitation.class.classId!, + status: invitation.status, }; } @@ -20,6 +19,16 @@ export function mapToTeacherInvitationDTOIds(invitation: TeacherInvitation): Tea return { sender: invitation.sender.username, receiver: invitation.receiver.username, - class: invitation.class.classId!, + classId: invitation.class.classId!, + status: invitation.status, }; } + +export function mapToInvitation(sender: Teacher, receiver: Teacher, cls: Class): TeacherInvitation { + return getTeacherInvitationRepository().create({ + sender, + receiver, + class: cls, + status: ClassStatus.Open, + }); +} diff --git a/backend/src/interfaces/teacher.ts b/backend/src/interfaces/teacher.ts index 4dd6adb4..f7e1745f 100644 --- a/backend/src/interfaces/teacher.ts +++ b/backend/src/interfaces/teacher.ts @@ -1,17 +1,6 @@ import { Teacher } from '../entities/users/teacher.entity.js'; - -export interface TeacherDTO { - id: string; - username: string; - firstName: string; - lastName: string; - endpoints?: { - classes: string; - questions: string; - invitations: string; - groups: string; - }; -} +import { getTeacherRepository } from '../data/repositories.js'; +import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; export function mapToTeacherDTO(teacher: Teacher): TeacherDTO { return { @@ -22,8 +11,10 @@ export function mapToTeacherDTO(teacher: Teacher): TeacherDTO { }; } -export function mapToTeacher(TeacherData: TeacherDTO): Teacher { - const teacher = new Teacher(TeacherData.username, TeacherData.firstName, TeacherData.lastName); - - return teacher; +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 index 58f0dd5a..f4413b5e 100644 --- a/backend/src/interfaces/user.ts +++ b/backend/src/interfaces/user.ts @@ -1,17 +1,5 @@ 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; - }; -} +import { UserDTO } from '@dwengo-1/common/interfaces/user'; export function mapToUserDTO(user: User): UserDTO { return { diff --git a/backend/src/logging/initalize.ts b/backend/src/logging/initalize.ts index 1ff761c9..f89518c4 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,40 @@ 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.simple()), + }); + + if (getEnvVar(envVars.RunMode) === 'dev') { + logger = createLogger({ + transports: [consoleTransport], + }); + logger.debug(`Logger initialized with level ${logLevel} to console`); + return logger; + } + + 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..0fc18b87 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 !== undefined && context.labels !== undefined) { + 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..73a65b9a 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)), }, }; @@ -47,14 +48,14 @@ const idpConfigs = { const verifyJwtToken = expressjwt({ secret: async (_: express.Request, token: jwt.Jwt | undefined) => { if (!token?.payload || !(token.payload as JwtPayload).iss) { - throw new Error('Invalid token'); + throw new UnauthorizedException('Invalid token.'); } const issuer = (token.payload as JwtPayload).iss; const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); if (!idpConfig) { - throw new Error('Issuer not accepted.'); + throw new UnauthorizedException('Issuer not accepted.'); } const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); @@ -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..f2cddf43 --- /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 { hasStatusCode } from '../../exceptions/has-status-code.js'; + +const logger: Logger = getLogger(); + +export function errorHandler(err: unknown, _req: Request, res: Response, _: NextFunction): void { + if (hasStatusCode(err)) { + 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: ${(err as { stack: string })?.stack ?? 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..76cd0ee9 100644 --- a/backend/src/orm.ts +++ b/backend/src/orm.ts @@ -1,10 +1,10 @@ -import { EntityManager, MikroORM } from '@mikro-orm/core'; +import { EntityManager, IDatabaseDriver, 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(); @@ -25,6 +25,8 @@ export async function initORM(testingMode: boolean = false) { ); } } + + return orm; } export function forkEntityManager(): EntityManager { if (!orm) { diff --git a/backend/src/routes/answers.ts b/backend/src/routes/answers.ts new file mode 100644 index 00000000..b74f76a0 --- /dev/null +++ b/backend/src/routes/answers.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; + +const router = express.Router({ mergeParams: true }); + +router.get('/', getAllAnswersHandler); + +router.post('/', createAnswerHandler); + +router.get('/:seqAnswer', getAnswerHandler); + +router.delete('/:seqAnswer', deleteAnswerHandler); + +router.put('/:seqAnswer', updateAnswerHandler); + +export default router; diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts index a733d093..4503414d 100644 --- a/backend/src/routes/assignments.ts +++ b/backend/src/routes/assignments.ts @@ -1,29 +1,30 @@ import express from 'express'; import { createAssignmentHandler, + deleteAssignmentHandler, getAllAssignmentsHandler, getAssignmentHandler, + getAssignmentQuestionsHandler, getAssignmentsSubmissionsHandler, + putAssignmentHandler, } 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.put('/:id', putAssignmentHandler); + +router.delete('/:id', deleteAssignmentHandler); + router.get('/:id/submissions', getAssignmentsSubmissionsHandler); -router.get('/:id/questions', (req, res) => { - res.json({ - questions: ['0'], - }); -}); +router.get('/:id/questions', getAssignmentQuestionsHandler); router.use('/:assignmentid/groups', groupRouter); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 778e51fd..6f153836 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,26 +1,28 @@ import express from 'express'; -import { getFrontendAuthConfig } from '../controllers/auth.js'; +import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; 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!' }); }); +router.post('/hello', authenticatedOnly, postHelloHandler); + export default router; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts index e0972988..cef6fd72 100644 --- a/backend/src/routes/classes.ts +++ b/backend/src/routes/classes.ts @@ -1,10 +1,17 @@ import express from 'express'; import { + addClassStudentHandler, + addClassTeacherHandler, createClassHandler, + deleteClassHandler, + deleteClassStudentHandler, + deleteClassTeacherHandler, getAllClassesHandler, getClassHandler, getClassStudentsHandler, + getClassTeachersHandler, getTeacherInvitationsHandler, + putClassHandler, } from '../controllers/classes.js'; import assignmentRouter from './assignments.js'; @@ -15,13 +22,26 @@ router.get('/', getAllClassesHandler); router.post('/', createClassHandler); -// Information about an class with id 'id' router.get('/:id', getClassHandler); +router.put('/:id', putClassHandler); + +router.delete('/:id', deleteClassHandler); + router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); router.get('/:id/students', getClassStudentsHandler); +router.post('/:id/students', addClassStudentHandler); + +router.delete('/:id/students/:username', deleteClassStudentHandler); + +router.get('/:id/teachers', getClassTeachersHandler); + +router.post('/:id/teachers', addClassTeacherHandler); + +router.delete('/:id/teachers/:username', deleteClassTeacherHandler); + router.use('/:classid/assignments', assignmentRouter); export default router; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index 0c9692b0..3043c23b 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -1,5 +1,13 @@ import express from 'express'; -import { createGroupHandler, getAllGroupsHandler, getGroupHandler, getGroupSubmissionsHandler } from '../controllers/groups.js'; +import { + createGroupHandler, + deleteGroupHandler, + getAllGroupsHandler, + getGroupHandler, + getGroupQuestionsHandler, + getGroupSubmissionsHandler, + putGroupHandler, +} from '../controllers/groups.js'; const router = express.Router({ mergeParams: true }); @@ -8,16 +16,14 @@ router.get('/', getAllGroupsHandler); router.post('/', createGroupHandler); -// Information about a group (members, ... [TODO DOC]) router.get('/:groupid', getGroupHandler); -router.get('/:groupid', getGroupSubmissionsHandler); +router.put('/:groupid', putGroupHandler); -// The list of questions a group has made -router.get('/:id/questions', (req, res) => { - res.json({ - questions: ['0'], - }); -}); +router.delete('/:groupid', deleteGroupHandler); + +router.get('/:groupid/submissions', getGroupSubmissionsHandler); + +router.get('/:groupid/questions', getGroupQuestionsHandler); export default router; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts index 31a71f3b..5135c197 100644 --- a/backend/src/routes/questions.ts +++ b/backend/src/routes/questions.ts @@ -1,11 +1,7 @@ import express from 'express'; -import { - createQuestionHandler, - deleteQuestionHandler, - getAllQuestionsHandler, - getQuestionAnswersHandler, - getQuestionHandler, -} from '../controllers/questions.js'; +import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; +import answerRoutes from './answers.js'; + const router = express.Router({ mergeParams: true }); // Query language @@ -20,6 +16,6 @@ router.delete('/:seq', deleteQuestionHandler); // Information about a question with id router.get('/:seq', getQuestionHandler); -router.get('/answers/:seq', getQuestionAnswersHandler); +router.use('/:seq/answers', answerRoutes); export default router; diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index 639857a7..99d4312c 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -1,10 +1,7 @@ import { Response, Router } from 'express'; import studentRouter from './students.js'; -import groupRouter from './groups.js'; -import assignmentRouter from './assignments.js'; -import submissionRouter from './submissions.js'; +import teacherRouter from './teachers.js'; import classRouter from './classes.js'; -import questionRouter from './questions.js'; import authRouter from './auth.js'; import themeRoutes from './themes.js'; import learningPathRoutes from './learning-paths.js'; @@ -22,11 +19,8 @@ router.get('/', (_, res: Response) => { }); router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); -router.use('/group', groupRouter /* #swagger.tags = ['Group'] */); -router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); -router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); +router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */); router.use('/class', classRouter /* #swagger.tags = ['Class'] */); -router.use('/question', questionRouter /* #swagger.tags = ['Question'] */); router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); diff --git a/backend/src/routes/student-join-requests.ts b/backend/src/routes/student-join-requests.ts new file mode 100644 index 00000000..daf79f09 --- /dev/null +++ b/backend/src/routes/student-join-requests.ts @@ -0,0 +1,19 @@ +import express from 'express'; +import { + createStudentRequestHandler, + deleteClassJoinRequestHandler, + getStudentRequestHandler, + getStudentRequestsHandler, +} from '../controllers/students.js'; + +const router = express.Router({ mergeParams: true }); + +router.get('/', getStudentRequestsHandler); + +router.post('/', createStudentRequestHandler); + +router.get('/:classId', getStudentRequestHandler); + +router.delete('/:classId', deleteClassJoinRequestHandler); + +export default router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 7ed7a666..0f5d5349 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -7,9 +7,11 @@ import { getStudentClassesHandler, getStudentGroupsHandler, getStudentHandler, + getStudentQuestionsHandler, getStudentSubmissionsHandler, } from '../controllers/students.js'; -import { getStudentGroups } from '../services/students.js'; +import joinRequestRouter from './student-join-requests.js'; + const router = express.Router(); // Root endpoint used to search objects @@ -17,30 +19,26 @@ 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); +router.get('/:username/classes', getStudentClassesHandler); // The list of submissions a student has made -router.get('/:id/submissions', getStudentSubmissionsHandler); +router.get('/:username/submissions', getStudentSubmissionsHandler); // The list of assignments a student has -router.get('/:id/assignments', getStudentAssignmentsHandler); +router.get('/:username/assignments', getStudentAssignmentsHandler); // The list of groups a student is in -router.get('/:id/groups', getStudentGroupsHandler); +router.get('/:username/groups', getStudentGroupsHandler); // A list of questions a user has created -router.get('/:id/questions', (req, res) => { - res.json({ - questions: ['0'], - }); -}); +router.get('/:username/questions', getStudentQuestionsHandler); + +router.use('/:username/joinRequests', joinRequestRouter); export default router; diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts index 4db93027..fc0aa7c6 100644 --- a/backend/src/routes/submissions.ts +++ b/backend/src/routes/submissions.ts @@ -1,15 +1,11 @@ import express from 'express'; -import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler } from '../controllers/submissions.js'; +import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } 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.get('/', getSubmissionsHandler); -router.post('/:id', createSubmissionHandler); +router.post('/', createSubmissionHandler); // Information about an submission with id 'id' router.get('/:id', getSubmissionHandler); diff --git a/backend/src/routes/teacher-invitations.ts b/backend/src/routes/teacher-invitations.ts new file mode 100644 index 00000000..23b943d0 --- /dev/null +++ b/backend/src/routes/teacher-invitations.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import { + createInvitationHandler, + deleteInvitationHandler, + getAllInvitationsHandler, + getInvitationHandler, + updateInvitationHandler, +} from '../controllers/teacher-invitations.js'; + +const router = express.Router({ mergeParams: true }); + +router.get('/:username', getAllInvitationsHandler); + +router.get('/:sender/:receiver/:classId', getInvitationHandler); + +router.post('/', createInvitationHandler); + +router.put('/', updateInvitationHandler); + +router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); + +export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts index c04e1575..44d3064b 100644 --- a/backend/src/routes/teachers.ts +++ b/backend/src/routes/teachers.ts @@ -3,11 +3,15 @@ import { createTeacherHandler, deleteTeacherHandler, getAllTeachersHandler, + getStudentJoinRequestHandler, getTeacherClassHandler, getTeacherHandler, getTeacherQuestionHandler, getTeacherStudentHandler, + updateStudentJoinRequestHandler, } from '../controllers/teachers.js'; +import invitationRouter from './teacher-invitations.js'; + const router = express.Router(); // Root endpoint used to search objects @@ -15,8 +19,6 @@ router.get('/', getAllTeachersHandler); router.post('/', createTeacherHandler); -router.delete('/', deleteTeacherHandler); - router.get('/:username', getTeacherHandler); router.delete('/:username', deleteTeacherHandler); @@ -27,11 +29,11 @@ router.get('/:username/students', getTeacherStudentHandler); router.get('/:username/questions', getTeacherQuestionHandler); +router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); + +router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler); + // Invitations to other classes a teacher received -router.get('/:id/invitations', (req, res) => { - res.json({ - invitations: ['0'], - }); -}); +router.use('/invitations', invitationRouter); 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/answers.ts b/backend/src/services/answers.ts new file mode 100644 index 00000000..ab603883 --- /dev/null +++ b/backend/src/services/answers.ts @@ -0,0 +1,70 @@ +import { getAnswerRepository } from '../data/repositories.js'; +import { Answer } from '../entities/questions/answer.entity.js'; +import { mapToAnswerDTO, mapToAnswerDTOId } from '../interfaces/answer.js'; +import { fetchTeacher } from './teachers.js'; +import { fetchQuestion } from './questions.js'; +import { QuestionId } from '@dwengo-1/common/interfaces/question'; +import { AnswerData, AnswerDTO, AnswerId } from '@dwengo-1/common/interfaces/answer'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; + +export async function getAnswersByQuestion(questionId: QuestionId, full: boolean): Promise { + const answerRepository = getAnswerRepository(); + const question = await fetchQuestion(questionId); + + const answers: Answer[] = await answerRepository.findAllAnswersToQuestion(question); + + if (full) { + return answers.map(mapToAnswerDTO); + } + + return answers.map(mapToAnswerDTOId); +} + +export async function createAnswer(questionId: QuestionId, answerData: AnswerData): Promise { + const answerRepository = getAnswerRepository(); + const toQuestion = await fetchQuestion(questionId); + const author = await fetchTeacher(answerData.author); + const content = answerData.content; + + const answer = await answerRepository.createAnswer({ + toQuestion, + author, + content, + }); + return mapToAnswerDTO(answer); +} + +async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + const answerRepository = getAnswerRepository(); + const question = await fetchQuestion(questionId); + const answer = await answerRepository.findAnswer(question, sequenceNumber); + + if (!answer) { + throw new NotFoundException('Answer with questionID and sequence number not found'); + } + + return answer; +} + +export async function getAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + const answer = await fetchAnswer(questionId, sequenceNumber); + return mapToAnswerDTO(answer); +} + +export async function deleteAnswer(questionId: QuestionId, sequenceNumber: number): Promise { + const answerRepository = getAnswerRepository(); + + const question = await fetchQuestion(questionId); + const answer = await fetchAnswer(questionId, sequenceNumber); + + await answerRepository.removeAnswerByQuestionAndSequenceNumber(question, sequenceNumber); + return mapToAnswerDTO(answer); +} + +export async function updateAnswer(questionId: QuestionId, sequenceNumber: number, answerData: AnswerData): Promise { + const answerRepository = getAnswerRepository(); + const answer = await fetchAnswer(questionId, sequenceNumber); + + const newAnswer = await answerRepository.updateContent(answer, answerData.content); + return mapToAnswerDTO(newAnswer); +} diff --git a/backend/src/services/assignments.ts b/backend/src/services/assignments.ts index be121810..2379ecfb 100644 --- a/backend/src/services/assignments.ts +++ b/backend/src/services/assignments.ts @@ -1,16 +1,45 @@ -import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; +import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getQuestionRepository, + getSubmissionRepository, +} from '../data/repositories.js'; import { Assignment } from '../entities/assignments/assignment.entity.js'; -import { AssignmentDTO, mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; -import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; +import { mapToQuestionDTO } from '../interfaces/question.js'; +import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; +import { fetchClass } from './classes.js'; +import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; +import { EntityDTO } from '@mikro-orm/core'; +import { putObject } from './service-helper.js'; +import { fetchStudents } from './students.js'; +import { ServerErrorException } from '../exceptions/server-error-exception.js'; -export async function getAllAssignments(classid: string, full: boolean): Promise { +export async function fetchAssignment(classid: string, assignmentNumber: number): Promise { const classRepository = getClassRepository(); const cls = await classRepository.findById(classid); if (!cls) { - return []; + throw new NotFoundException("Could not find assignment's class"); } + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + throw new NotFoundException('Could not find assignment'); + } + + return assignment; +} + +export async function getAllAssignments(classid: string, full: boolean): Promise { + const cls = await fetchClass(classid); + const assignmentRepository = getAssignmentRepository(); const assignments = await assignmentRepository.findAllAssignmentsInClass(cls); @@ -21,65 +50,96 @@ export async function getAllAssignments(classid: string, full: boolean): Promise return assignments.map(mapToAssignmentDTOId); } -export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); +export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { + const cls = await fetchClass(classid); - if (!cls) { - return null; - } - - const assignment = mapToAssignment(assignmentData, cls); const assignmentRepository = getAssignmentRepository(); + const assignment = mapToAssignment(assignmentData, cls); + await assignmentRepository.save(assignment); - try { - const newAssignment = assignmentRepository.create(assignment); - await assignmentRepository.save(newAssignment); + if (assignmentData.groups) { + /* + For some reason when trying to add groups, it does not work when using the original assignment variable. + The assignment needs to be refetched in order for it to work. + */ - return newAssignment; - } catch (e) { - return null; + const assignmentCopy = await assignmentRepository.findByClassAndId(cls, assignment.id!); + + if (assignmentCopy === null) { + throw new ServerErrorException('Something has gone horribly wrong. Could not find newly added assignment which is needed to add groups.'); + } + + const groupRepository = getGroupRepository(); + + (assignmentData.groups as string[][]).forEach(async (memberUsernames) => { + const members = await fetchStudents(memberUsernames); + + const newGroup = groupRepository.create({ + assignment: assignmentCopy, + members: members, + }); + await groupRepository.save(newGroup); + }); } + + /* Need to refetch the assignment here again such that the groups are added. */ + const assignmentWithGroups = await fetchAssignment(classid, assignment.id!); + + return mapToAssignmentDTO(assignmentWithGroups); } -export async function getAssignment(classid: string, id: number): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); +export async function getAssignment(classid: string, id: number): Promise { + const assignment = await fetchAssignment(classid, id); + return mapToAssignmentDTO(assignment); +} - if (!cls) { - return null; - } +export async function putAssignment(classid: string, id: number, assignmentData: Partial>): Promise { + const assignment = await fetchAssignment(classid, id); - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, id); - - if (!assignment) { - return null; - } + await putObject(assignment, assignmentData, getAssignmentRepository()); return mapToAssignmentDTO(assignment); } -export async function getAssignmentsSubmissions(classid: string, assignmentNumber: number): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); - - if (!cls) { - return []; - } +export async function deleteAssignment(classid: string, id: number): Promise { + const assignment = await fetchAssignment(classid, id); + const cls = await fetchClass(classid); const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + await assignmentRepository.deleteByClassAndId(cls, id); - if (!assignment) { - return []; - } + return mapToAssignmentDTO(assignment); +} + +export async function getAssignmentsSubmissions( + classid: string, + assignmentNumber: number, + full: boolean +): Promise { + const assignment = await fetchAssignment(classid, assignmentNumber); const groupRepository = getGroupRepository(); const groups = await groupRepository.findAllGroupsForAssignment(assignment); const submissionRepository = getSubmissionRepository(); - const submissions = (await Promise.all(groups.map((group) => submissionRepository.findAllSubmissionsForGroup(group)))).flat(); + const submissions = (await Promise.all(groups.map(async (group) => submissionRepository.findAllSubmissionsForGroup(group)))).flat(); - return submissions.map(mapToSubmissionDTO); + if (full) { + return submissions.map(mapToSubmissionDTO); + } + + return submissions.map(mapToSubmissionDTOId); +} + +export async function getAssignmentsQuestions(classid: string, assignmentNumber: number, full: boolean): Promise { + const assignment = await fetchAssignment(classid, assignmentNumber); + + const questionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllByAssignment(assignment); + + if (full) { + return questions.map(mapToQuestionDTO); + } + + return questions.map(mapToQuestionDTO); } diff --git a/backend/src/services/class.ts b/backend/src/services/class.ts deleted file mode 100644 index 9f6e1efe..00000000 --- a/backend/src/services/class.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js'; -import { Class } from '../entities/classes/class.entity.js'; -import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; -import { 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((id) => teacherRepository.findByUsername(id)))).filter((teacher) => teacher != null); - - const studentRepository = getStudentRepository(); - const studentUsernames = classData.students || []; - const students = (await Promise.all(studentUsernames.map((id) => studentRepository.findByUsername(id)))).filter((student) => student != null); - - //Const cls = mapToClass(classData, teachers, students); - - const classRepository = getClassRepository(); - - try { - const newClass = classRepository.create({ - displayName: classData.displayName, - teachers: teachers, - students: students, - }); - await classRepository.save(newClass); - - return 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/classes.ts b/backend/src/services/classes.ts new file mode 100644 index 00000000..1f197b2a --- /dev/null +++ b/backend/src/services/classes.ts @@ -0,0 +1,150 @@ +import { getClassRepository, getTeacherInvitationRepository } from '../data/repositories.js'; +import { mapToClassDTO } from '../interfaces/class.js'; +import { mapToStudentDTO } from '../interfaces/student.js'; +import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds } from '../interfaces/teacher-invitation.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassDTO } from '@dwengo-1/common/interfaces/class'; +import { TeacherInvitationDTO } from '@dwengo-1/common/interfaces/teacher-invitation'; +import { StudentDTO } from '@dwengo-1/common/interfaces/student'; +import { fetchTeacher } from './teachers.js'; +import { fetchStudent } from './students.js'; +import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; +import { mapToTeacherDTO } from '../interfaces/teacher.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { putObject } from './service-helper.js'; + +export async function fetchClass(classid: string): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + throw new NotFoundException('Class not found'); + } + + return cls; +} + +export async function getAllClasses(full: boolean): Promise { + const classRepository = getClassRepository(); + const classes = await classRepository.findAll({ populate: ['students', 'teachers'] }); + + if (full) { + return classes.map(mapToClassDTO); + } + return classes.map((cls) => cls.classId!); +} + +export async function getClass(classId: string): Promise { + const cls = await fetchClass(classId); + return mapToClassDTO(cls); +} + +export async function createClass(classData: ClassDTO): Promise { + const teacherUsernames = classData.teachers || []; + const teachers = await Promise.all(teacherUsernames.map(async (id) => fetchTeacher(id))); + + const studentUsernames = classData.students || []; + const students = await Promise.all(studentUsernames.map(async (id) => fetchStudent(id))); + + const classRepository = getClassRepository(); + const newClass = classRepository.create({ + displayName: classData.displayName, + teachers: teachers, + students: students, + }); + await classRepository.save(newClass, { preventOverwrite: true }); + + return mapToClassDTO(newClass); +} + +export async function putClass(classId: string, classData: Partial>): Promise { + const cls = await fetchClass(classId); + + await putObject(cls, classData, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function deleteClass(classId: string): Promise { + const cls = await fetchClass(classId); + + const classRepository = getClassRepository(); + await classRepository.deleteById(classId); + + return mapToClassDTO(cls); +} + +export async function getClassStudents(classId: string, full: boolean): Promise { + const cls = await fetchClass(classId); + + if (full) { + return cls.students.map(mapToStudentDTO); + } + return cls.students.map((student) => student.username); +} + +export async function getClassStudentsDTO(classId: string): Promise { + const cls = await fetchClass(classId); + return cls.students.map(mapToStudentDTO); +} + +export async function getClassTeachers(classId: string, full: boolean): Promise { + const cls = await fetchClass(classId); + + if (full) { + return cls.teachers.map(mapToTeacherDTO); + } + return cls.teachers.map((student) => student.username); +} + +export async function getClassTeacherInvitations(classId: string, full: boolean): Promise { + const cls = await fetchClass(classId); + + const teacherInvitationRepository = getTeacherInvitationRepository(); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(cls); + + if (full) { + return invitations.map(mapToTeacherInvitationDTO); + } + + return invitations.map(mapToTeacherInvitationDTOIds); +} + +export async function deleteClassStudent(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + + const newStudents = { students: cls.students.filter((student) => student.username !== username) }; + await putObject(cls, newStudents, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function deleteClassTeacher(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + + const newTeachers = { teachers: cls.teachers.filter((teacher) => teacher.username !== username) }; + await putObject(cls, newTeachers, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function addClassStudent(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + const newStudent = await fetchStudent(username); + + const newStudents = { students: [...cls.students, newStudent] }; + await putObject(cls, newStudents, getClassRepository()); + + return mapToClassDTO(cls); +} + +export async function addClassTeacher(classId: string, username: string): Promise { + const cls = await fetchClass(classId); + const newTeacher = await fetchTeacher(username); + + const newTeachers = { teachers: [...cls.teachers, newTeacher] }; + await putObject(cls, newTeachers, getClassRepository()); + + return mapToClassDTO(cls); +} diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index 91091703..b75fe82f 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -1,132 +1,143 @@ -import { GroupRepository } from '../data/assignments/group-repository.js'; -import { - getAssignmentRepository, - getClassRepository, - getGroupRepository, - getStudentRepository, - getSubmissionRepository, -} from '../data/repositories.js'; +import { EntityDTO } from '@mikro-orm/core'; +import { getGroupRepository, getQuestionRepository, getSubmissionRepository } from '../data/repositories.js'; import { Group } from '../entities/assignments/group.entity.js'; -import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; -import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; +import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; +import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; +import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; +import { fetchAssignment } from './assignments.js'; +import { NotFoundException } from '../exceptions/not-found-exception.js'; +import { fetchStudents } from './students.js'; +import { fetchClass } from './classes.js'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; +import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.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; +async function assertMembersInClass(members: Student[], cls: Class): Promise { + if (!members.every((student) => cls.students.contains(student))) { + throw new BadRequestException('Student does not belong to class'); } +} - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return null; - } +export async function fetchGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const assignment = await fetchAssignment(classId, assignmentNumber); const groupRepository = getGroupRepository(); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); if (!group) { - return null; + throw new NotFoundException('Could not find group'); } - if (full) { - return mapToGroupDTO(group); - } - - return mapToGroupDTOId(group); + return group; } -export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { - const studentRepository = getStudentRepository(); +export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + return mapToGroupDTO(group, group.assignment.within); +} - const memberUsernames = (groupData.members as string[]) || []; // TODO check if groupdata.members is a list - const members = (await Promise.all([...memberUsernames].map((id) => studentRepository.findByUsername(id)))).filter((student) => student != null); +export async function putGroup(classId: string, assignmentNumber: number, groupNumber: number, groupData: Partial): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); - console.log(members); + const memberUsernames = groupData.members as string[]; + const members = await fetchStudents(memberUsernames); - 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 cls = await fetchClass(classId); + await assertMembersInClass(members, cls); const groupRepository = getGroupRepository(); - try { - const newGroup = groupRepository.create({ - assignment: assignment, - members: members, - }); - await groupRepository.save(newGroup); + groupRepository.assign(group, { members } as Partial>); + await groupRepository.getEntityManager().persistAndFlush(group); - return newGroup; - } catch (e) { - console.log(e); - return null; - } + return mapToGroupDTO(group, group.assignment.within); } -export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); +export async function deleteGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + const assignment = await fetchAssignment(classId, assignmentNumber); - if (!cls) { - return []; - } + const groupRepository = getGroupRepository(); + await groupRepository.deleteByAssignmentAndGroupNumber(assignment, groupNumber); - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + return mapToGroupDTO(group, assignment.within); +} - if (!assignment) { - return []; - } +export async function getExistingGroupFromGroupDTO(groupData: GroupDTO): Promise { + const classId = typeof groupData.class === 'string' ? groupData.class : groupData.class.id; + const assignmentNumber = typeof groupData.assignment === 'number' ? groupData.assignment : groupData.assignment.id; + const groupNumber = groupData.groupNumber; + + return await fetchGroup(classId, assignmentNumber, groupNumber); +} + +export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { + const memberUsernames = (groupData.members as string[]) || []; + const members = await fetchStudents(memberUsernames); + + const cls = await fetchClass(classid); + await assertMembersInClass(members, cls); + + const assignment = await fetchAssignment(classid, assignmentNumber); + + const groupRepository = getGroupRepository(); + const newGroup = groupRepository.create({ + assignment: assignment, + members: members, + }); + + await groupRepository.save(newGroup); + + return mapToGroupDTO(newGroup, newGroup.assignment.within); +} + +export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { + const assignment = await fetchAssignment(classId, assignmentNumber); const groupRepository = getGroupRepository(); const groups = await groupRepository.findAllGroupsForAssignment(assignment); if (full) { - console.log('full'); - console.log(groups); - return groups.map(mapToGroupDTO); + return groups.map((group) => mapToGroupDTO(group, assignment.within)); } - return groups.map(mapToGroupDTOId); + return groups.map((group) => mapToGroupDTOId(group, assignment.within)); } -export async function getGroupSubmissions(classId: string, assignmentNumber: number, groupNumber: number): 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 []; - } +export async function getGroupSubmissions( + classId: string, + assignmentNumber: number, + groupNumber: number, + full: boolean +): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); const submissionRepository = getSubmissionRepository(); const submissions = await submissionRepository.findAllSubmissionsForGroup(group); - return submissions.map(mapToSubmissionDTO); + if (full) { + return submissions.map(mapToSubmissionDTO); + } + + return submissions.map(mapToSubmissionDTOId); +} + +export async function getGroupQuestions( + classId: string, + assignmentNumber: number, + groupNumber: number, + full: boolean +): Promise { + const group = await fetchGroup(classId, assignmentNumber, groupNumber); + + const questionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllByGroup(group); + + if (full) { + return questions.map(mapToQuestionDTO); + } + + return questions.map(mapToQuestionDTOId); } diff --git a/backend/src/services/learning-objects.ts b/backend/src/services/learning-objects.ts index fb579471..089fd25a 100644 --- a/backend/src/services/learning-objects.ts +++ b/backend/src/services/learning-objects.ts @@ -1,12 +1,20 @@ 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 { + FilteredLearningObject, + LearningObjectMetadata, + LearningObjectNode, + LearningPathResponse, +} from '@dwengo-1/common/interfaces/learning-content'; +import { getLogger } from '../logging/initalize.js'; +import { v4 } from 'uuid'; function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { return { key: data.hruid, // Hruid learningObject (not path) _id: data._id, - uuid: data.uuid, + uuid: data.uuid || v4(), version: data.version, title: data.title, htmlUrl, // Url to fetch html content @@ -37,7 +45,7 @@ export async function getLearningObjectById(hruid: string, language: string): Pr ); if (!metadata) { - console.error(`⚠️ WARNING: Learning object "${hruid}" not found.`); + getLogger().error(`⚠️ WARNING: Learning object "${hruid}" not found.`); return null; } @@ -45,6 +53,13 @@ export async function getLearningObjectById(hruid: string, language: string): Pr 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) */ @@ -53,7 +68,7 @@ async function fetchLearningObjects(hruid: string, full: boolean, language: stri const learningPathResponse: LearningPathResponse = await fetchLearningPaths([hruid], language, `Learning path for HRUID "${hruid}"`); if (!learningPathResponse.success || !learningPathResponse.data?.length) { - console.error(`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`); + getLogger().error(`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`); return []; } @@ -67,7 +82,7 @@ async function fetchLearningObjects(hruid: string, full: boolean, language: stri objects.filter((obj): obj is FilteredLearningObject => obj !== null) ); } catch (error) { - console.error('❌ Error fetching learning objects:', error); + getLogger().error('❌ Error fetching learning objects:', error); return []; } } @@ -85,6 +100,3 @@ export async function getLearningObjectsFromPath(hruid: string, language: string export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise { return (await fetchLearningObjects(hruid, false, language)) as string[]; } -function fetchLearningPaths(arg0: string[], language: string, arg2: string): LearningPathResponse | PromiseLike { - throw new Error('Function not implemented.'); -} diff --git a/backend/src/services/learning-objects/attachment-service.ts b/backend/src/services/learning-objects/attachment-service.ts index aacc7187..2a6298c1 100644 --- a/backend/src/services/learning-objects/attachment-service.ts +++ b/backend/src/services/learning-objects/attachment-service.ts @@ -1,9 +1,10 @@ import { getAttachmentRepository } from '../../data/repositories.js'; import { Attachment } from '../../entities/content/attachment.entity.js'; -import { LearningObjectIdentifier } from '../../interfaces/learning-content.js'; + +import { LearningObjectIdentifierDTO } from '@dwengo-1/common/interfaces/learning-content'; const attachmentService = { - getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise { + async getAttachment(learningObjectId: LearningObjectIdentifierDTO, 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..0b805a56 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -1,13 +1,12 @@ 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'; import { NotFoundError } from '@mikro-orm/core'; import learningObjectService from './learning-object-service.js'; import { getLogger, Logger } from '../../logging/initalize.js'; +import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; const logger: Logger = getLogger(); @@ -33,7 +32,7 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL educationalGoals: learningObject.educationalGoals, returnValue: { callback_url: learningObject.returnValue.callbackUrl, - callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), + callback_schema: learningObject.returnValue.callbackSchema === '' ? '' : JSON.parse(learningObject.returnValue.callbackSchema), }, skosConcepts: learningObject.skosConcepts, targetAges: learningObject.targetAges || [], @@ -41,10 +40,10 @@ function convertLearningObject(learningObject: LearningObject | null): FilteredL }; } -function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise { +async function findLearningObjectEntityById(id: LearningObjectIdentifierDTO): Promise { const learningObjectRepo = getLearningObjectRepository(); - return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); + return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language); } /** @@ -54,7 +53,7 @@ const databaseLearningObjectProvider: LearningObjectProvider = { /** * Fetches a single learning object by its HRUID */ - async getLearningObjectById(id: LearningObjectIdentifier): Promise { + async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise { const learningObject = await findLearningObjectEntityById(id); return convertLearningObject(learningObject); }, @@ -62,14 +61,14 @@ const databaseLearningObjectProvider: LearningObjectProvider = { /** * Obtain a HTML-rendering of the learning object with the given identifier (as a string). */ - async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + async getLearningObjectHTML(id: LearningObjectIdentifierDTO): 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 dfee329d..4a4bdc54 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,16 +1,17 @@ import { DWENGO_API_BASE } from '../../config.js'; import { fetchWithLogging } from '../../util/api-helper.js'; +import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; import { FilteredLearningObject, - LearningObjectIdentifier, + LearningObjectIdentifierDTO, LearningObjectMetadata, LearningObjectNode, LearningPathIdentifier, LearningPathResponse, -} from '../../interfaces/learning-content.js'; -import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; -import { LearningObjectProvider } from './learning-object-provider.js'; -import { getLogger, Logger } from '../../logging/initalize.js'; +} from '@dwengo-1/common/interfaces/learning-content'; +import { v4 } from 'uuid'; const logger: Logger = getLogger(); @@ -23,7 +24,7 @@ function filterData(data: LearningObjectMetadata): FilteredLearningObject { return { key: data.hruid, // Hruid learningObject (not path) _id: data._id, - uuid: data.uuid, + uuid: data.uuid ?? v4(), version: data.version, title: data.title, htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content @@ -66,12 +67,13 @@ async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full } const objects = await Promise.all( - nodes.map(async (node) => - dwengoApiLearningObjectProvider.getLearningObjectById({ + nodes.map(async (node) => { + const learningObjectId: LearningObjectIdentifierDTO = { hruid: node.learningobject_hruid, language: learningPathId.language, - }) - ) + }; + return dwengoApiLearningObjectProvider.getLearningObjectById(learningObjectId); + }) ); return objects.filter((obj): obj is FilteredLearningObject => obj !== null); } catch (error) { @@ -84,13 +86,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { /** * Fetches a single learning object by its HRUID */ - async getLearningObjectById(id: LearningObjectIdentifier): Promise { + async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise { const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; const metadata = await fetchWithLogging( metadataUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { - params: id, + params: { ...id }, } ); @@ -120,10 +122,10 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { * Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects * from the Dwengo API, this means passing through the HTML rendering from there. */ - async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise { const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; const html = await fetchWithLogging(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { - params: id, + params: { ...id }, }); if (!html) { diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts index 81b4d228..14848bc0 100644 --- a/backend/src/services/learning-objects/learning-object-provider.ts +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -1,10 +1,10 @@ -import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; export interface LearningObjectProvider { /** * Fetches a single learning object by its HRUID */ - getLearningObjectById(id: LearningObjectIdentifier): Promise; + getLearningObjectById(id: LearningObjectIdentifierDTO): Promise; /** * Fetch full learning object data (metadata) @@ -19,5 +19,5 @@ export interface LearningObjectProvider { /** * Obtain a HTML-rendering of the learning object with the given identifier (as a string). */ - getLearningObjectHTML(id: LearningObjectIdentifier): Promise; + getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise; } diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index 8289660b..7b4f47fc 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'; +import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content'; -function getProvider(id: LearningObjectIdentifier): LearningObjectProvider { - if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) { +function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider { + 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: LearningObjectIdentifierDTO): 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: LearningObjectIdentifierDTO): 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(`