Merge remote-tracking branch 'origin/dev' into feat/endpoints-finaliseren-tests-backend-adriaan

# Conflicts:
#	backend/src/controllers/assignments.ts
#	backend/src/controllers/classes.ts
#	backend/src/controllers/groups.ts
#	backend/src/controllers/questions.ts
#	backend/src/controllers/submissions.ts
#	backend/src/data/assignments/submission-repository.ts
#	backend/src/data/users/student-repository.ts
#	backend/src/services/classes.ts
#	backend/src/services/submissions.ts
#	backend/tests/controllers/classes.test.ts
This commit is contained in:
Gabriellvl 2025-05-10 16:44:06 +02:00
commit ded1a5908e
375 changed files with 23425 additions and 7033 deletions

63
.github/workflows/backend-testing.yml vendored Normal file
View file

@ -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

View file

@ -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
run: docker compose -f compose.yml -f compose.production.yml up --build -d

72
.github/workflows/frontend-testing.yml vendored Normal file
View file

@ -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

View file

@ -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}'
commit_message: 'style: fix linting issues met ${linter}'

4
.gitignore vendored
View file

@ -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

View file

@ -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

21
backend/.env.staging Normal file
View file

@ -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

View file

@ -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,*

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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;

View file

@ -8,14 +8,4 @@ export default [
globals: globals.node,
},
},
{
files: ['tests/**/*.ts'],
languageOptions: {
globals: globals.node,
},
rules: {
'no-console': 'off',
},
},
];

View file

@ -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",

View file

@ -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<void> {
await initORM();
app.listen(port, () => {

View file

@ -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;

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const { classid, assignmentNumber } = getAssignmentParams(req);
res.json(assignment);
const assignmentData = req.body as Partial<EntityDTO<Assignment>>;
const assignment = await putAssignment(classid, assignmentNumber, assignmentData);
res.json({ assignment });
}
export async function deleteAssignmentHandler(req: Request, res: Response): Promise<void> {
const { classid, assignmentNumber } = getAssignmentParams(req);
const assignment = await deleteAssignment(classid, assignmentNumber);
res.json({ assignment });
}
export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise<void> {
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<void> {
const { classid, assignmentNumber, full } = getAssignmentParams(req);
const questions = await getAssignmentsQuestions(classid, assignmentNumber, full);
res.json({ questions });
}

View file

@ -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<void> {
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!' });
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
const classId = req.params.id;
requireFields({ classId });
const newData = req.body as Partial<EntityDTO<Class>>;
const cls = await putClass(classId, newData);
res.json({ class: cls });
}
export async function deleteClassHandler(req: Request, res: Response): Promise<void> {
const classId = req.params.id;
const cls = await deleteClass(classId);
res.json({ class: cls });
}
export async function getClassStudentsHandler(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const classId = req.params.id;
const username = req.body.username;
requireFields({ classId, username });
const cls = await addClassTeacher(classId, username);
res.json({ class: cls });
}

View file

@ -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<string, unknown>): 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);
}
}

View file

@ -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<void> {
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<void> {
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<GroupDTO>);
res.json(group);
res.json({ group });
}
export async function deleteGroupHandler(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const { classId, assignmentId, groupId, full } = getGroupParams(req);
const questions = await getGroupQuestions(classId, assignmentId, groupId, full);
res.json({ questions });
}

View file

@ -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<vo
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
const learningObject = await learningObjectService.getLearningObjectById(learningObjectId);
if (!learningObject) {
throw new NotFoundException('Learning object not found');
}
res.json(learningObject);
}
@ -63,7 +68,7 @@ export async function getAttachment(req: Request, res: Response): Promise<void>
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);
}

View file

@ -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<voi
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
const forStudent = req.query.forStudent as string;
const forGroupNo = req.query.forGroup as string;
const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string;
let personalizationTarget: PersonalizationTarget | undefined;
let forGroup: Group | undefined;
if (forStudent) {
personalizationTarget = await personalizedForStudent(forStudent);
} else if (forGroupNo) {
if (forGroupNo) {
if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
}
personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo));
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
if (assignment) {
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
}
}
let hruidList;
@ -47,18 +45,13 @@ export async function getLearningPaths(req: Request, res: Response): Promise<voi
throw new NotFoundException(`Theme "${themeKey}" not found.`);
}
} else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget);
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
res.json(searchResults);
return;
} else {
hruidList = themes.flatMap((theme) => 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);
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const username = req.params.username;
requireFields({ username });
const requests = await getJoinRequestsByStudent(username);
res.json({ requests });
}
export async function getStudentRequestHandler(req: Request, res: Response): Promise<void> {
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<void> {
const username = req.params.username;
const classId = req.params.classId;
requireFields({ username, classId });
const request = await deleteClassJoinRequest(username, classId);
res.json({ request });
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const classId = req.params.classId;
requireFields({ classId });
const joinRequests = await getJoinRequestsByClass(classId);
res.json({ joinRequests });
}
export async function updateStudentJoinRequestHandler(req: Request, res: Response): Promise<void> {
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 });
}

View file

@ -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<string, { title: string; description?: string }>;
}
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<Translations>(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) {

View file

@ -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<Assignment> {
public findByClassAndId(within: Class, id: number): Promise<Assignment | null> {
return this.findOne({ within: within, id: id });
public async findByClassAndId(within: Class, id: number): Promise<Assignment | null> {
return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] });
}
public findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {
return this.findAll({ where: { within: within } });
public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> {
return this.findOne({ within: { classId: withinClass }, id: id });
}
public deleteByClassAndId(within: Class, id: number): Promise<void> {
public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> {
return this.findAll({
where: {
within: {
teachers: {
$some: {
username: teacherUsername,
},
},
},
},
});
}
public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {
return this.findAll({ where: { within: within }, populate: ['groups', 'groups.members'] });
}
public async deleteByClassAndId(within: Class, id: number): Promise<void> {
return this.deleteWhere({ within: within, id: id });
}
}

View file

@ -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<Group> {
public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise<Group | null> {
public async findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise<Group | null> {
return this.findOne(
{
assignment: assignment,
@ -13,16 +13,16 @@ export class GroupRepository extends DwengoEntityRepository<Group> {
{ populate: ['members'] }
);
}
public findAllGroupsForAssignment(assignment: Assignment): Promise<Group[]> {
public async findAllGroupsForAssignment(assignment: Assignment): Promise<Group[]> {
return this.findAll({
where: { assignment: assignment },
populate: ['members'],
});
}
public findAllGroupsWithStudent(student: Student): Promise<Group[]> {
public async findAllGroupsWithStudent(student: Student): Promise<Group[]> {
return this.find({ members: student }, { populate: ['members'] });
}
public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) {
public async deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise<void> {
return this.deleteWhere({
assignment: assignment,
groupNumber: groupNumber,

View file

@ -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<Submission> {
public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<Submission | null> {
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<Submission | null> {
return this.findOne({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
submissionNumber: submissionNumber,
});
}
public findSubmissionsByLearningObject(loId: LearningObjectIdentifier): Promise<Submission[]> {
public async findByLearningObject(loId: LearningObjectIdentifier): Promise<Submission[]> {
return this.find({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
})
});
}
public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise<Submission | null> {
public async findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise<Submission | null> {
return this.findOne(
{
learningObjectHruid: loId.hruid,
@ -37,7 +38,7 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
);
}
public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission | null> {
public async findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission | null> {
return this.findOne(
{
learningObjectHruid: loId.hruid,
@ -49,15 +50,60 @@ export class SubmissionRepository extends DwengoEntityRepository<Submission> {
);
}
public findAllSubmissionsForGroup(group: Group): Promise<Submission[]> {
return this.find({ onBehalfOf: group });
public async findAllSubmissionsForGroup(group: Group): Promise<Submission[]> {
return this.find(
{ onBehalfOf: group },
{
populate: ['onBehalfOf.members'],
}
);
}
public findAllSubmissionsForStudent(student: Student): Promise<Submission[]> {
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<Submission[]> {
return this.findAll({
where: {
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
onBehalfOf: {
assignment,
},
},
});
}
public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise<void> {
/**
* Looks up all submissions for the given learning object which were submitted by the given group
*/
public async findAllSubmissionsForLearningObjectAndGroup(loId: LearningObjectIdentifier, group: Group): Promise<Submission[]> {
return this.findAll({
where: {
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
onBehalfOf: group,
},
});
}
public async findAllSubmissionsForStudent(student: Student): Promise<Submission[]> {
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<void> {
return this.deleteWhere({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,

View file

@ -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<ClassJoinRequest> {
public findAllRequestsBy(requester: Student): Promise<ClassJoinRequest[]> {
public async findAllRequestsBy(requester: Student): Promise<ClassJoinRequest[]> {
return this.findAll({ where: { requester: requester } });
}
public findAllOpenRequestsTo(clazz: Class): Promise<ClassJoinRequest[]> {
return this.findAll({ where: { class: clazz } });
public async findAllOpenRequestsTo(clazz: Class): Promise<ClassJoinRequest[]> {
return this.findAll({ where: { class: clazz, status: ClassStatus.Open } }); // TODO check if works like this
}
public deleteBy(requester: Student, clazz: Class): Promise<void> {
public async findByStudentAndClass(requester: Student, clazz: Class): Promise<ClassJoinRequest | null> {
return this.findOne({ requester, class: clazz });
}
public async deleteBy(requester: Student, clazz: Class): Promise<void> {
return this.deleteWhere({ requester: requester, class: clazz });
}
}

View file

@ -4,20 +4,20 @@ import { Student } from '../../entities/users/student.entity.js';
import { Teacher } from '../../entities/users/teacher.entity';
export class ClassRepository extends DwengoEntityRepository<Class> {
public findById(id: string): Promise<Class | null> {
public async findById(id: string): Promise<Class | null> {
return this.findOne({ classId: id }, { populate: ['students', 'teachers'] });
}
public deleteById(id: string): Promise<void> {
public async deleteById(id: string): Promise<void> {
return this.deleteWhere({ classId: id });
}
public findByStudent(student: Student): Promise<Class[]> {
public async findByStudent(student: Student): Promise<Class[]> {
return this.find(
{ students: student },
{ populate: ['students', 'teachers'] } // Voegt student en teacher objecten toe
);
}
public findByTeacher(teacher: Teacher): Promise<Class[]> {
public async findByTeacher(teacher: Teacher): Promise<Class[]> {
return this.find({ teachers: teacher }, { populate: ['students', 'teachers'] });
}
}

View file

@ -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<TeacherInvitation> {
public findAllInvitationsForClass(clazz: Class): Promise<TeacherInvitation[]> {
public async findAllInvitationsForClass(clazz: Class): Promise<TeacherInvitation[]> {
return this.findAll({ where: { class: clazz } });
}
public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> {
public async findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> {
return this.findAll({ where: { sender: sender } });
}
public findAllInvitationsFor(receiver: Teacher): Promise<TeacherInvitation[]> {
return this.findAll({ where: { receiver: receiver } });
public async findAllInvitationsFor(receiver: Teacher): Promise<TeacherInvitation[]> {
return this.findAll({ where: { receiver: receiver, status: ClassStatus.Open } });
}
public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise<void> {
public async deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise<void> {
return this.deleteWhere({
sender: sender,
receiver: receiver,
class: clazz,
});
}
public async findBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise<TeacherInvitation | null> {
return this.findOne({
sender: sender,
receiver: receiver,
class: clazz,
});
}
}

View file

@ -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<Attachment> {
public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> {
public async findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise<Attachment | null> {
return this.findOne({
learningObject: {
hruid: learningObjectId.hruid,
@ -15,7 +15,11 @@ export class AttachmentRepository extends DwengoEntityRepository<Attachment> {
});
}
public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise<Attachment | null> {
public async findByMostRecentVersionOfLearningObjectAndName(
hruid: string,
language: Language,
attachmentName: string
): Promise<Attachment | null> {
return this.findOne(
{
learningObject: {

View file

@ -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<LearningObject> {
public findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
return this.findOne(
{
hruid: identifier.hruid,
@ -18,7 +18,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
);
}
public findLatestByHruidAndLanguage(hruid: string, language: Language) {
public async findLatestByHruidAndLanguage(hruid: string, language: Language): Promise<LearningObject | null> {
return this.findOne(
{
hruid: hruid,
@ -33,7 +33,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
);
}
public findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> {
public async findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> {
return this.find(
{ admins: teacher },
{ populate: ['admins'] } // Make sure to load admin relations

View file

@ -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<LearningPath> {
public findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
public async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] });
}
@ -23,4 +27,27 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
populate: ['nodes', 'nodes.transitions'],
});
}
public createNode(nodeData: RequiredEntityData<LearningPathNode>): LearningPathNode {
return this.em.create(LearningPathNode, nodeData);
}
public createTransition(transitionData: RequiredEntityData<LearningPathTransition>): LearningPathTransition {
return this.em.create(LearningPathTransition, transitionData);
}
public async saveLearningPathNodesAndTransitions(
path: LearningPath,
nodes: LearningPathNode[],
transitions: LearningPathTransition[],
options?: { preventOverwrite?: boolean }
): Promise<void> {
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)));
}
}

View file

@ -1,12 +1,14 @@
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception.js';
export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> {
public async save(entity: T) {
const em = this.getEntityManager();
em.persist(entity);
await em.flush();
public async save(entity: T, options?: { preventOverwrite?: boolean }): Promise<void> {
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<T>) {
public async deleteWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.findOne(query);
const em = this.getEntityManager();
if (toDelete) {

View file

@ -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<Answer> {
public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> {
public async createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise<Answer> {
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<Answer[]> {
public async findAllAnswersToQuestion(question: Question): Promise<Answer[]> {
return this.findAll({
where: { toQuestion: question },
orderBy: { sequenceNumber: 'ASC' },
});
}
public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise<void> {
public async findAnswer(question: Question, sequenceNumber: number): Promise<Loaded<Answer> | null> {
return this.findOne({
toQuestion: question,
sequenceNumber,
});
}
public async removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise<void> {
return this.deleteWhere({
toQuestion: question,
sequenceNumber: sequenceNumber,
});
}
public async updateContent(answer: Answer, newContent: string): Promise<Answer> {
answer.content = newContent;
await this.save(answer);
return answer;
}
}

View file

@ -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<Question> {
public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise<Question> {
public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> {
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<Question> {
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<Question[]> {
public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {
return this.findAll({
where: {
learningObjectHruid: loId.hruid,
@ -33,7 +38,7 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
},
});
}
public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise<void> {
public async removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise<void> {
return this.deleteWhere({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
@ -54,4 +59,73 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
orderBy: { timestamp: 'ASC' },
});
}
public async findAllByAssignment(assignment: Assignment): Promise<Question[]> {
return this.find({
inGroup: assignment.groups.getItems(),
learningObjectHruid: assignment.learningPathHruid,
learningObjectLanguage: assignment.learningPathLanguage,
});
}
public async findAllByAuthor(author: Student): Promise<Question[]> {
return this.findAll({
where: { author },
orderBy: { timestamp: 'DESC' }, // New to old
});
}
public async findAllByGroup(inGroup: Group): Promise<Question[]> {
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<Question[]> {
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<Loaded<Question> | null> {
return this.findOne({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
sequenceNumber,
});
}
public async updateContent(question: Question, newContent: string): Promise<Question> {
question.content = newContent;
await this.save(question);
return question;
}
}

View file

@ -34,8 +34,8 @@ let entityManager: EntityManager | undefined;
/**
* Execute all the database operations within the function f in a single transaction.
*/
export function transactional<T>(f: () => Promise<T>) {
entityManager?.transactional(f);
export async function transactional<T>(f: () => Promise<T>): Promise<void> {
await entityManager?.transactional(f);
}
function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(entity: EntityName<T>): () => R {

View file

@ -1,7 +1,4 @@
export interface Theme {
title: string;
hruids: string[];
}
import { Theme } from '@dwengo-1/common/interfaces/theme';
export const themes: Theme[] = [
{

View file

@ -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<Student> {}
export class StudentRepository extends DwengoEntityRepository<Student> {
public findByUsername(username: string): Promise<Student | null> {
public async findByUsername(username: string): Promise<Student | null> {
return this.findOne({ username: username });
}
public findByClass(cls: Class): Promise<Student[]> {
return this.find({ classes: cls });
}
public deleteByUsername(username: string): Promise<void> {
public async deleteByUsername(username: string): Promise<void> {
return this.deleteWhere({ username: username });
}
}

View file

@ -2,10 +2,10 @@ import { Teacher } from '../../entities/users/teacher.entity.js';
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
export class TeacherRepository extends DwengoEntityRepository<Teacher> {
public findByUsername(username: string): Promise<Teacher | null> {
public async findByUsername(username: string): Promise<Teacher | null> {
return this.findOne({ username: username });
}
public deleteByUsername(username: string): Promise<void> {
public async deleteByUsername(username: string): Promise<void> {
return this.deleteWhere({ username: username });
}
}

View file

@ -2,10 +2,10 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { User } from '../../entities/users/user.entity.js';
export class UserRepository<T extends User> extends DwengoEntityRepository<T> {
public findByUsername(username: string): Promise<T | null> {
public async findByUsername(username: string): Promise<T | null> {
return this.findOne({ username } as Partial<T>);
}
public deleteByUsername(username: string): Promise<void> {
public async deleteByUsername(username: string): Promise<void> {
return this.deleteWhere({ username } as Partial<T>);
}
}

View file

@ -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<Group> = new Collection<Group>(this);
}

View file

@ -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<Student> = new Collection<Student>(this);
}

View file

@ -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;
}

View file

@ -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;
}
@Enum(() => ClassStatus)
status!: ClassStatus;
}

View file

@ -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<Teacher>;
@ManyToMany(() => Student)
@ManyToMany({ entity: () => Student, owner: true, inversedBy: 'classes' })
students!: Collection<Student>;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
}
}

View file

@ -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;

View file

@ -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<LearningPath>;
@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<LearningPathTransition>;
@Property({ length: 3 })
createdAt: Date = new Date();

View file

@ -3,12 +3,12 @@ import { LearningPathNode } from './learning-path-node.entity.js';
@Entity()
export class LearningPathTransition {
@ManyToOne({ entity: () => LearningPathNode, primary: true })
node!: Rel<LearningPathNode>;
@PrimaryKey({ type: 'numeric' })
transitionNumber!: number;
@ManyToOne({ entity: () => LearningPathNode, primary: true })
node!: Rel<LearningPathNode>;
@Property({ type: 'string' })
condition!: string;

View file

@ -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<LearningPathNode> = new Collection<LearningPathNode>(this);
}

View file

@ -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;
}

View file

@ -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,
})

View file

@ -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<Class>;
@ManyToMany(() => Group)
groups!: Collection<Group>;
constructor(
public username: string,
public firstName: string,
public lastName: string
) {
super();
}
@ManyToMany({ entity: () => Group, mappedBy: 'members' })
groups: Collection<Group> = new Collection<Group>(this);
}

View file

@ -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<Class>;
constructor(
public username: string,
public firstName: string,
public lastName: string
) {
super();
}
}

View file

@ -6,8 +6,8 @@ export abstract class User {
username!: string;
@Property()
firstName: string = '';
firstName = '';
@Property()
lastName: string = '';
lastName = '';
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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);
}
}

View file

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

View file

@ -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';
}

View file

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

View file

@ -0,0 +1,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);
}
}

View file

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

View file

@ -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!,
};
}

View file

@ -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: [],
});
}

View file

@ -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
};
}

View file

@ -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),

View file

@ -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!,
};
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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 {

View file

@ -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}`);
},

View file

@ -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,
};
}
}

View file

@ -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;

View file

@ -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.

View file

@ -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;
};
}

View file

@ -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(','),
});

View file

@ -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);
}
}

View file

@ -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),
};
}

View file

@ -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<MikroORM<IDatabaseDriver, EntityManager>> {
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) {

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

Some files were not shown because too many files have changed in this diff Show more