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 index 865f4524..f29e69e8 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -13,7 +13,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - + name: Copy environment variables to correct file + run: cp /home/dev/.backend.env backend/.env && cp /home/dev/.idp.env config/idp/.env - name: Start docker - run: docker compose -f compose.yml -f compose.prod.yml up --build -d - \ No newline at end of file + run: docker compose -f compose.yml -f compose.production.yml up --build -d + 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 32823417..f7e8d11e 100644 --- a/.github/workflows/lint-action.yml +++ b/.github/workflows/lint-action.yml @@ -4,13 +4,11 @@ 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"] @@ -43,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}' \ No newline at end of file + 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 3526b53d..0499b037 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,8 @@ Om de applicatie lokaal te draaien als kant-en-klare Docker-containers: ```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 -f compose.staging.yml up --build +# Gebruikt backend/.env.staging ``` ### Handmatige installatie en ontwikkeling 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 index b8a81003..fb94aa09 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -1,3 +1,22 @@ -PORT=3000 -DWENGO_DB_UPDATE=true +# +# Test environment configuration +# +# Should not need to be modified. +# See .env.example for more information. +# + +### Dwengo ### + +DWENGO_PORT=3000 + DWENGO_DB_NAME=":memory:" +DWENGO_DB_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 535628cd..00000000 --- a/backend/.env.test.example +++ /dev/null @@ -1,13 +0,0 @@ -# -# Test environment configuration -# -# Should not need to be modified. -# See .env.example for more information. -# - -### Dwengo ### - -DWENGO_PORT=3000 - -DWENGO_DB_NAME=":memory:" -DWENGO_DB_UPDATE=true diff --git a/backend/Dockerfile b/backend/Dockerfile index 7c63c4b8..1d82a484 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,38 +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 ./backend/i18n /app/i18n -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 8a78ed14..ded42bd8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -34,7 +34,9 @@ npm run test:unit ```shell # Omgevingsvariabelen -cp .env.development.example .env +cp .env.example .env +# Configureer de .env file met de juiste waarden! +nano .env npm run build npm run start 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/package.json b/backend/package.json index 4e3b890d..1d97dfc0 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", @@ -33,6 +37,7 @@ "jwks-rsa": "^3.1.0", "loki-logger-ts": "^1.0.2", "marked": "^15.0.7", + "nanoid": "^5.1.5", "response-time": "^2.3.3", "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", @@ -40,7 +45,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 b9974a3b..9b209ada 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,7 +1,8 @@ -import { EnvVars, getEnvVar } from './util/envvars.js'; +import { envVars, getEnvVar } from './util/envVars.js'; // API -export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); -export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); +export const DWENGO_API_BASE = getEnvVar(envVars.LearningContentRepoApiBaseUrl); +export const FALLBACK_LANG = getEnvVar(envVars.FallbackLanguage); 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 48b25a82..2ca6d2fc 100644 --- a/backend/src/controllers/assignments.ts +++ b/backend/src/controllers/assignments.ts @@ -1,72 +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 -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'; + requireFields({ assignmentNumber, classid }); + + if (isNaN(assignmentNumber)) { + throw new BadRequestException('Assignment id should be a number'); + } + + 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); + const assignments = await getAllAssignments(classId, full); - res.json({ - assignments: assignments, - }); + 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; - - 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); + res.json({ assignment }); } export async function getAssignmentHandler(req: Request, res: Response): Promise { - const id = +req.params.id; - const classid = req.params.classid; + const { classid, assignmentNumber } = getAssignmentParams(req); - if (isNaN(id)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; - } + const assignment = await getAssignment(classid, assignmentNumber); - const assignment = await getAssignment(classid, id); + res.json({ assignment }); +} - if (!assignment) { - res.status(404).json({ error: 'Assignment not found' }); - return; - } +export async function putAssignmentHandler(req: Request, res: Response): Promise { + const { classid, assignmentNumber } = getAssignmentParams(req); - res.json(assignment); + 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 = req.params.classid; - const assignmentNumber = +req.params.id; - const full = req.query.full === 'true'; - - if (isNaN(assignmentNumber)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; - } + const { classid, assignmentNumber, full } = getAssignmentParams(req); const submissions = await getAssignmentsSubmissions(classid, assignmentNumber, full); - res.json({ - submissions: submissions, - }); + 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 a57d5cee..6f253547 100644 --- a/backend/src/controllers/classes.ts +++ b/backend/src/controllers/classes.ts @@ -1,76 +1,132 @@ import { Request, Response } from 'express'; -import { createClass, getAllClasses, getClass, getClassStudents, 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(cls); + res.json({ class: cls }); } export async function getClassHandler(req: Request, res: Response): Promise { const classId = req.params.id; + requireFields({ classId }); + const cls = await getClass(classId); - if (!cls) { - res.status(404).json({ error: 'Class not found' }); - return; - } + res.json({ class: cls }); +} - res.json(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 = await getClassStudents(classId, full); - if (!students) { - res.status(404).json({ error: 'Class not found' }); - return; - } + res.json({ students }); +} - res.json({ - students: 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'; + requireFields({ classId }); const invitations = await getClassTeacherInvitations(classId, full); - if (!invitations) { - res.status(404).json({ error: 'Class not found' }); - return; - } - - 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 18bfcac5..f17aada5 100644 --- a/backend/src/controllers/groups.ts +++ b/backend/src/controllers/groups.ts @@ -1,93 +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'; + +function checkGroupFields(classId: string, assignmentId: number, groupId: number): void { + requireFields({ classId, assignmentId, groupId }); + + if (isNaN(assignmentId)) { + throw new BadRequestException('Assignment id must be a number'); + } + + if (isNaN(groupId)) { + throw new BadRequestException('Group id must be a number'); + } +} 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; + const assignmentId = parseInt(req.params.assignmentid); + const groupId = parseInt(req.params.groupid); + checkGroupFields(classId, assignmentId, groupId); - if (isNaN(assignmentId)) { - res.status(400).json({ error: 'Assignment id must be a number' }); - return; - } + const group = await getGroup(classId, assignmentId, groupId); - const groupId = +req.params.groupid!; // Can't be undefined + res.json({ group }); +} - if (isNaN(groupId)) { - res.status(400).json({ error: 'Group id must be a number' }); - return; - } +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); - const group = await getGroup(classId, assignmentId, groupId, full); + // Only members field can be changed + const members = req.body.members; + requireFields({ members }); - if (!group) { - res.status(404).json({ error: 'Group not found' }); - return; - } + const group = await putGroup(classId, assignmentId, groupId, { members } as Partial); - res.json(group); + 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); + 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 assignmentId = +req.params.assignmentid; - - 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 { classId, assignmentId, groupId, full } = getGroupParams(req); const submissions = await getGroupSubmissions(classId, assignmentId, groupId, full); - 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 e9d0d727..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, }; } @@ -47,6 +47,11 @@ export async function getLearningObject(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 217994f6..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,85 +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 }); + 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 = await getAnswersByQuestion(questionId, full); - - if (!answers) { - res.status(404).json({ error: `Questions not found` }); - } else { - res.json({ answers: 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 create 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) { - res.json(404).json({ error: 'Question not found' }); - return; - } + const learningObjectId = getLearningObjectId(hruid, version, language); + const questionId = getQuestionId(learningObjectId, seq); const question = await deleteQuestion(questionId); - if (!question) { - res.status(404).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 5190c1d6..229cff7e 100644 --- a/backend/src/controllers/students.ts +++ b/backend/src/controllers/students.ts @@ -1,101 +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 { StudentDTO } from '../interfaces/student.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 students = await getAllStudents(full); + const students: StudentDTO[] | string[] = await getAllStudents(full); - if (!students) { - res.status(404).json({ error: `Student not found.` }); - return; - } - - res.json({ students: 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.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); - - if (!newUser) { - res.status(500).json({ - error: 'Something went wrong while creating student' - }); - return; - } - - 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 { const full = req.query.full === 'true'; - const username = req.params.id; + const username = req.params.username; + requireFields({ username }); const classes = await getStudentClasses(username, full); - res.json({ - classes: classes, - }); + res.json({ classes }); } // TODO @@ -104,33 +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, 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 b4f0bf8e..a117d7bf 100644 --- a/backend/src/controllers/submissions.ts +++ b/backend/src/controllers/submissions.ts @@ -1,68 +1,86 @@ import { Request, Response } from 'express'; -import { createSubmission, deleteSubmission, getAllSubmissions, 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'; + +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 { const lohruid = req.params.hruid; - const submissionNumber = +req.params.id; - - if (isNaN(submissionNumber)) { - res.status(400).json({ error: 'Submission number is not a number' }); - return; - } - - const lang = languageMap[req.query.language as string] || Language.Dutch; const version = (req.query.version || 1) as number; + const submissionNumber = Number(req.params.id); + requireFields({ lohruid, submissionNumber }); - const submission = await getSubmission(lohruid, lang, version, submissionNumber); - - if (!submission) { - res.status(404).json({ error: 'Submission not found' }); - return; + if (isNaN(submissionNumber)) { + throw new BadRequestException('Submission number must be a number'); } - res.json(submission); + const loId = new LearningObjectIdentifier(lohruid, lang, version); + const submission = await getSubmission(loId, submissionNumber); + + res.json({ submission }); } 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 submissions = await getAllSubmissions(lohruid, lang, version); + const loId = new LearningObjectIdentifier(lohruid, lang, version); + const submissions = await getAllSubmissions(loId); - res.json({ submissions: submissions }); + res.json({ submissions }); } -export async function createSubmissionHandler(req: Request, res: Response) { +// 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(400).json({ error: 'Failed to create submission' }); - return; - } - - 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' }); - return; + if (isNaN(submissionNumber)) { + throw new BadRequestException('Submission number must be a number'); } - res.json(submission); + 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 7376abed..c8063f80 100644 --- a/backend/src/controllers/teachers.ts +++ b/backend/src/controllers/teachers.ts @@ -4,137 +4,96 @@ import { deleteTeacher, getAllTeachers, getClassesByTeacher, - getQuestionsByTeacher, + getJoinRequestsByClass, getStudentsByTeacher, getTeacher, + getTeacherQuestions, + updateClassJoinRequestStatus, } from '../services/teachers.js'; -import { TeacherDTO } from '../interfaces/teacher.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 teachers = await getAllTeachers(full); + const teachers: TeacherDTO[] | string[] = await getAllTeachers(full); - if (!teachers) { - res.status(404).json({ error: `Teacher not found.` }); - return; - } - - res.json({ teachers: 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: `Teacher '${username}' not found.`, - }); - return; - } - - res.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); - - if (!newUser) { - res.status(400).json({ error: 'Failed to create teacher' }); - return; - } - - 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 '${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 { - const username = req.params.username as string; + const username = req.params.username; const full = req.query.full === 'true'; - - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + requireFields({ username }); const classes = await getClassesByTeacher(username, full); - if (!classes) { - res.status(404).json({ error: 'Teacher not found' }); - return; - } - - res.json({ classes: classes }); + res.json({ classes }); } export async function getTeacherStudentHandler(req: Request, res: Response): Promise { - const username = req.params.username as string; + const username = req.params.username; const full = req.query.full === 'true'; - - if (!username) { - res.status(400).json({ error: 'Missing required field: username' }); - return; - } + requireFields({ username }); const students = await getStudentsByTeacher(username, full); - if (!students) { - res.status(404).json({ error: 'Teacher not found' }); - return; - } - - res.json({ students: students }); + res.json({ students }); } export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { - const username = req.params.username as string; + 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 = await getQuestionsByTeacher(username, full); - - if (!questions) { - res.status(404).json({ error: 'Teacher not found' }); - return; - } - - res.json({ questions: questions }); + 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 8406cf5f..b34d0c80 100644 --- a/backend/src/controllers/themes.ts +++ b/backend/src/controllers/themes.ts @@ -3,25 +3,23 @@ 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 getThemesHandler(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 getHruidsByThemeHandler(req: Request, res: Response) { +export function getHruidsByThemeHandler(req: Request, res: Response): void { const themeKey = req.params.theme; if (!themeKey) { 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 96f196f6..e9889bcf 100644 --- a/backend/src/data/assignments/submission-repository.ts +++ b/backend/src/data/assignments/submission-repository.ts @@ -3,29 +3,30 @@ 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 { - return this.findOne( - { - learningObjectHruid: loId.hruid, - learningObjectLanguage: loId.language, - learningObjectVersion: loId.version, - submissionNumber: submissionNumber, - }, - { populate: ['submitter', 'onBehalfOf'] }, - ); + public async findSubmissionByLearningObjectAndSubmissionNumber( + loId: LearningObjectIdentifier, + submissionNumber: number + ): Promise { + return this.findOne({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + submissionNumber: submissionNumber, + }); } - public findSubmissionsByLearningObject(loId: LearningObjectIdentifier): Promise { + public async findByLearningObject(loId: LearningObjectIdentifier): Promise { return this.find({ learningObjectHruid: loId.hruid, learningObjectLanguage: loId.language, learningObjectVersion: loId.version, - }) + }); } - public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { + public async findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { return this.findOne( { learningObjectHruid: loId.hruid, @@ -37,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, @@ -49,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 cdeb50c1..f09c3c75 100644 --- a/backend/src/data/repositories.ts +++ b/backend/src/data/repositories.ts @@ -34,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 1419ace9..2efca048 100644 --- a/backend/src/data/users/student-repository.ts +++ b/backend/src/data/users/student-repository.ts @@ -1,18 +1,11 @@ -import { Class } from '../../entities/classes/class.entity.js'; import { Student } from '../../entities/users/student.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 findByClass(cls: Class): Promise { - return this.find({ classes: cls }); - } - 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 825b4d18..aa915627 100644 --- a/backend/src/data/users/teacher-repository.ts +++ b/backend/src/data/users/teacher-repository.ts @@ -2,10 +2,10 @@ import { Teacher } from '../../entities/users/teacher.entity.js'; import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; export class TeacherRepository extends DwengoEntityRepository { - public findByUsername(username: string): Promise { + public async findByUsername(username: string): Promise { return this.findOne({ username: username }); } - public deleteByUsername(username: string): Promise { + public async deleteByUsername(username: string): Promise { return this.deleteWhere({ username: username }); } } diff --git a/backend/src/data/users/user-repository.ts b/backend/src/data/users/user-repository.ts index 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 daa71ed6..a12ffbac 100644 --- a/backend/src/entities/assignments/assignment.entity.ts +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -1,7 +1,7 @@ -import { 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 cfe21f7f..62d5fee9 100644 --- a/backend/src/entities/assignments/group.entity.ts +++ b/backend/src/entities/assignments/group.entity.ts @@ -1,4 +1,4 @@ -import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; +import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; import { Assignment } from './assignment.entity.js'; import { Student } from '../users/student.entity.js'; import { GroupRepository } from '../../data/assignments/group-repository.js'; @@ -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 fbaa2791..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 64a597bb..548968a6 100644 --- a/backend/src/entities/classes/class-join-request.entity.ts +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -2,12 +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'; - -export enum ClassJoinRequestStatus { - Open = 'open', - Accepted = 'accepted', - Declined = 'declined', -} +import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; @Entity({ repository: () => ClassJoinRequestRepository, @@ -25,6 +20,6 @@ export class ClassJoinRequest { }) class!: Class; - @Enum(() => ClassJoinRequestStatus) - status!: ClassJoinRequestStatus; -} \ No newline at end of file + @Enum(() => ClassStatus) + status!: ClassStatus; +} diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts index 63315304..5bedf560 100644 --- a/backend/src/entities/classes/class.entity.ts +++ b/backend/src/entities/classes/class.entity.ts @@ -1,22 +1,24 @@ import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; -import { v4 } from 'uuid'; import { Teacher } from '../users/teacher.entity.js'; import { Student } from '../users/student.entity.js'; import { ClassRepository } from '../../data/classes/class-repository.js'; +import { customAlphabet } from 'nanoid'; + +const generateClassId = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6); @Entity({ repository: () => ClassRepository, }) export class Class { @PrimaryKey() - classId? = v4(); + classId? = generateClassId(); @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 eefa8c96..7c5a0909 100644 --- a/backend/src/interfaces/assignment.ts +++ b/backend/src/interfaces/assignment.ts @@ -1,50 +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 } 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; - - 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 ea1d4901..76fa5fd5 100644 --- a/backend/src/interfaces/class.ts +++ b/backend/src/interfaces/class.ts @@ -2,14 +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[]; -} +import { ClassDTO } from '@dwengo-1/common/interfaces/class'; export function mapToClassDTO(cls: Class): ClassDTO { return { @@ -17,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 0da87eb7..98e6f33c 100644 --- a/backend/src/interfaces/question.ts +++ b/backend/src/interfaces/question.ts @@ -1,42 +1,47 @@ import { Question } from '../entities/questions/question.entity.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 { 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 98cc4f22..e3b60311 100644 --- a/backend/src/interfaces/submission.ts +++ b/backend/src/interfaces/submission.ts @@ -1,26 +1,10 @@ import { Submission } from '../entities/assignments/submission.entity.js'; -import { Language } from '../entities/content/language.js'; -import { GroupDTO, mapToGroupDTO } from './group.js'; -import { mapToStudent, mapToStudentDTO, StudentDTO } from './student.js'; -import { LearningObjectIdentifier } from './learning-content.js'; - -export interface SubmissionDTO { - learningObjectIdentifier: LearningObjectIdentifier; - - submissionNumber?: number; - submitter: StudentDTO; - time?: Date; - group?: GroupDTO; - content: string; -} - -export interface SubmissionDTOId { - learningObjectHruid: string; - learningObjectLanguage: Language; - learningObjectVersion: number; - - submissionNumber?: number; -} +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 { @@ -29,11 +13,10 @@ export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { language: submission.learningObjectLanguage, version: submission.learningObjectVersion, }, - submissionNumber: submission.submissionNumber, submitter: mapToStudentDTO(submission.submitter), time: submission.submissionTime, - group: submission.onBehalfOf ? mapToGroupDTO(submission.onBehalfOf) : undefined, + group: submission.onBehalfOf ? mapToGroupDTOId(submission.onBehalfOf, submission.onBehalfOf.assignment.within) : undefined, content: submission.content, }; } @@ -48,17 +31,14 @@ export function mapToSubmissionDTOId(submission: Submission): SubmissionDTOId { }; } -export function mapToSubmission(submissionDTO: SubmissionDTO): Submission { - const submission = new Submission(); - submission.learningObjectHruid = submissionDTO.learningObjectIdentifier.hruid; - submission.learningObjectLanguage = submissionDTO.learningObjectIdentifier.language; - submission.learningObjectVersion = submissionDTO.learningObjectIdentifier.version!; - // Submission.submissionNumber = submissionDTO.submissionNumber; - submission.submitter = mapToStudent(submissionDTO.submitter); - // Submission.submissionTime = submissionDTO.time; - // Submission.onBehalfOf = submissionDTO.group!; - // TODO fix group - submission.content = submissionDTO.content; - - return submission; +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 4d14e8ab..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 { EnvVars, getEnvVar } from '../util/envvars.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,30 @@ function initializeLogger(): Logger { return logger; } - const logLevel = getEnvVar(EnvVars.LogLevel); + const logLevel = getEnvVar(envVars.LogLevel); const consoleTransport = new transports.Console({ - level: getEnvVar(EnvVars.LogLevel), - format: format.combine(format.cli(), format.colorize()), + level: getEnvVar(envVars.LogLevel), + format: format.combine(format.cli(), format.simple()), }); - if (getEnvVar(EnvVars.RunMode) === 'dev') { - return createLogger({ + 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 lokiHost = getEnvVar(envVars.LokiHost); const lokiTransport: LokiTransport = new LokiTransport({ host: lokiHost, - labels: Labels, + 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}`); }, 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 f2fb2bae..eb0e5f7a 100644 --- a/backend/src/mikro-orm.config.ts +++ b/backend/src/mikro-orm.config.ts @@ -1,6 +1,6 @@ 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'; @@ -42,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: getEnvVar(EnvVars.LogLevel) === '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/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 6efbab39..0f5d5349 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -7,8 +7,11 @@ import { getStudentClassesHandler, getStudentGroupsHandler, getStudentHandler, + getStudentQuestionsHandler, getStudentSubmissionsHandler, } from '../controllers/students.js'; +import joinRequestRouter from './student-join-requests.js'; + const router = express.Router(); // Root endpoint used to search objects @@ -16,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/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 a21a96fa..2379ecfb 100644 --- a/backend/src/services/assignments.ts +++ b/backend/src/services/assignments.ts @@ -1,15 +1,45 @@ -import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; -import { AssignmentDTO, mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; -import { mapToSubmissionDTO, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } from '../interfaces/submission.js'; +import { 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 { 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); @@ -20,42 +50,63 @@ 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 mapToAssignmentDTO(newAssignment); - } catch (e) { - console.error(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); + + await putObject(assignment, assignmentData, getAssignmentRepository()); + + return mapToAssignmentDTO(assignment); +} + +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, id); - - if (!assignment) { - return null; - } + await assignmentRepository.deleteByClassAndId(cls, id); return mapToAssignmentDTO(assignment); } @@ -65,25 +116,13 @@ export async function getAssignmentsSubmissions( assignmentNumber: number, full: boolean ): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classid); - - if (!cls) { - return []; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return []; - } + const 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(); if (full) { return submissions.map(mapToSubmissionDTO); @@ -91,3 +130,16 @@ export async function getAssignmentsSubmissions( 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/classes.ts b/backend/src/services/classes.ts index 6c535c80..1f197b2a 100644 --- a/backend/src/services/classes.ts +++ b/backend/src/services/classes.ts @@ -1,19 +1,33 @@ -import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js'; -import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; -import { mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; -import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds, TeacherInvitationDTO } from '../interfaces/teacher-invitation.js'; -import { getLogger } from '../logging/initalize.js'; -import { getStudent } from './students.js'; +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'; -const logger = getLogger(); +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.find({}, { populate: ['students', 'teachers'] }); - - if (!classes) { - return []; - } + const classes = await classRepository.findAll({ populate: ['students', 'teachers'] }); if (full) { return classes.map(mapToClassDTO); @@ -21,68 +35,71 @@ export async function getAllClasses(full: boolean): Promise 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 classRepository = getClassRepository(); - - try { - const newClass = classRepository.create({ - displayName: classData.displayName, - teachers: teachers, - students: students, - }); - await classRepository.save(newClass); - - return mapToClassDTO(newClass); - } catch (e) { - logger.error(e); - return null; - } +export async function getClass(classId: string): Promise { + const cls = await fetchClass(classId); + return mapToClassDTO(cls); } -export async function getClass(classId: string): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); +export async function createClass(classData: ClassDTO): Promise { + const teacherUsernames = classData.teachers || []; + const teachers = await Promise.all(teacherUsernames.map(async (id) => fetchTeacher(id))); - if (!cls) { - return null; - } + 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 getClassStudents(classId: string, full: boolean): Promise { +export async function deleteClass(classId: string): Promise { + const cls = await fetchClass(classId); + const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); + await classRepository.deleteById(classId); - if (!cls) { - return null; - } - - const studentRepository = getStudentRepository(); - const students = await studentRepository.findByClass(cls); - - if (full) { - return cls.students.map(mapToStudentDTO); - } - - return students.map((student) => student.username); + return mapToClassDTO(cls); } -export async function getClassTeacherInvitations(classId: string, full: boolean): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); +export async function getClassStudents(classId: string, full: boolean): Promise { + const cls = await fetchClass(classId); - if (!cls) { - return null; + 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); @@ -93,3 +110,41 @@ export async function getClassTeacherInvitations(classId: string, full: boolean) 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 4a1cbbf0..b75fe82f 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -1,105 +1,109 @@ -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, mapToSubmissionDTOId, SubmissionDTO, SubmissionDTOId } 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( @@ -108,26 +112,7 @@ export async function getGroupSubmissions( groupNumber: number, full: boolean ): Promise { - const classRepository = getClassRepository(); - const cls = await classRepository.findById(classId); - - if (!cls) { - return []; - } - - const assignmentRepository = getAssignmentRepository(); - const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); - - if (!assignment) { - return []; - } - - const groupRepository = getGroupRepository(); - const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); - - if (!group) { - return []; - } + const group = await fetchGroup(classId, assignmentNumber, groupNumber); const submissionRepository = getSubmissionRepository(); const submissions = await submissionRepository.findAllSubmissionsForGroup(group); @@ -138,3 +123,21 @@ export async function getGroupSubmissions( 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 85141b1d..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; } @@ -48,7 +56,7 @@ export async function getLearningObjectById(hruid: string, language: string): Pr /** * Generic function to fetch learning paths */ -function fetchLearningPaths(arg0: string[], language: string, arg2: string): LearningPathResponse | PromiseLike { +function fetchLearningPaths(_arg0: string[], _language: string, _arg2: string): LearningPathResponse | PromiseLike { throw new Error('Function not implemented.'); } @@ -60,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 []; } @@ -74,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 []; } } @@ -92,4 +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[]; } - 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(`