Merge pull request #293 from SELab-2/release/1.0.0
Some checks failed
Backend Testing / Run backend unit tests (push) Has been cancelled
Deployment / Deploy with docker (push) Has been cancelled
Frontend Testing / Run frontend unit tests (push) Has been cancelled
Lint / Run linters (push) Has been cancelled

release 1.0.0: Milestone 3

Bedankt allemaal!
This commit is contained in:
Tibo De Peuter 2025-05-20 22:25:10 +02:00 committed by GitHub
commit e7a90c650f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
232 changed files with 11879 additions and 2971 deletions

View file

@ -13,7 +13,7 @@
#DWENGO_LEARNING_CONTENT_REPO_API_BASE_URL=https://dwengo.org/backend/api
# The default fallback language.
#DWENGO_FALLBACK_LANGUAGE=nl
# Whether running in production mode or not. Possible values are "prod" or "dev".
# Whether running in production mode or not. Possible values are "prod", "staging", "test" or "dev".
#DWENGO_RUN_MODE=dev
# ! Change this! The hostname or IP address of the database
@ -66,3 +66,12 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://hostname/realms/teacher/protocol/openid
# The address of the Loki instance, a log aggregation system.
# If running your stack in docker, this should use the docker service name.
#DWENGO_LOGGING_LOKI_HOST=http://localhost:3102
# The hostname or IP address of the caching server, e.g. Memcached.
# If running your stack in docker, this should use the docker service name.
#DWENGO_CACHE_HOST=localhost
#DWENGO_CACHE_PORT=11211
# The time-to-live (TTL) for cached items in seconds.
#DWENGO_CACHE_TTL=3600
# If your cache server benefits from a prefix, you can set it here.
#DWENGO_CACHE_KEY_PREFIX=dwengo

View file

@ -35,3 +35,7 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/op
DWENGO_LOGGING_LEVEL=info
DWENGO_LOGGING_LOKI_HOST=http://logging:3102
DWENGO_CACHE_HOST=caching
#DWENGO_CACHE_PORT=11211
DWENGO_CACHE_TTL=604800

View file

@ -1,4 +1,6 @@
PORT=3000
DWENGO_RUN_MODE=staging
DWENGO_DB_HOST=db
DWENGO_DB_PORT=5432
DWENGO_DB_USERNAME=postgres
@ -18,4 +20,8 @@ DWENGO_CORS_ALLOWED_ORIGINS=http://localhost/*,http://idp:7080,https://idp:7080
# Logging and monitoring
LOKI_HOST=http://logging:3102
DWENGO_LOGGING_LEVEL=debug
DWENGO_LOGGING_LOKI_HOST=http://logging:3102
DWENGO_CACHE_HOST=caching
DWENGO_CACHE_TTL=86400

View file

@ -4,10 +4,13 @@
# Should not need to be modified.
# See .env.example for more information.
#
### Advanced configuration ###
### Dwengo ###
DWENGO_PORT=3000
DWENGO_RUN_MODE=test
DWENGO_DB_NAME=":memory:"
DWENGO_DB_UPDATE=true
@ -20,3 +23,7 @@ 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,*
### Advanced configuration ###
DWENGO_LOGGING_LEVEL=debug

View file

@ -1,3 +1,5 @@
#syntax=docker/dockerfile:1.7-labs
FROM node:22 AS build-stage
WORKDIR /app/dwengo
@ -17,7 +19,7 @@ RUN npm install --silent
# Root tsconfig.json
COPY tsconfig.json tsconfig.build.json ./
COPY backend ./backend
COPY --exclude=backend/tests/ backend ./backend
COPY common ./common
COPY docs ./docs
@ -27,6 +29,13 @@ FROM node:22 AS production-stage
WORKDIR /app/dwengo
COPY package*.json ./
COPY backend/package.json ./backend/
# Backend depends on common
COPY common/package.json ./common/
RUN npm install --silent --only=production
# Copy static files
COPY ./backend/i18n ./i18n
@ -37,15 +46,6 @@ 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 ./backend/i18n ./backend/i18n
EXPOSE 3000
CMD ["node", "--env-file=/app/dwengo/backend/.env", "/app/dwengo/backend/dist/app.js"]

View file

@ -1,6 +1,6 @@
{
"name": "@dwengo-1/backend",
"version": "0.2.0",
"version": "1.0.0",
"description": "Backend for Dwengo-1",
"private": true,
"type": "module",
@ -29,6 +29,7 @@
"cross-env": "^7.0.3",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"express-fileupload": "^1.5.1",
"express-jwt": "^8.5.1",
"gift-pegjs": "^1.0.2",
"isomorphic-dompurify": "^2.22.0",
@ -37,8 +38,12 @@
"jwks-rsa": "^3.1.0",
"loki-logger-ts": "^1.0.2",
"marked": "^15.0.7",
"memjs": "^1.3.2",
"mime-types": "^3.0.1",
"nanoid": "^5.1.5",
"response-time": "^2.3.3",
"swagger-ui-express": "^5.0.1",
"unzipper": "^0.12.3",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-loki": "^6.1.3"
@ -47,10 +52,14 @@
"@mikro-orm/cli": "6.4.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-fileupload": "^1.5.1",
"@types/js-yaml": "^4.0.9",
"@types/memjs": "^1.3.3",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4",
"@types/response-time": "^2.3.8",
"@types/swagger-ui-express": "^4.1.8",
"@types/unzipper": "^0.10.11",
"globals": "^15.15.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.3",

44
backend/src/caching.ts Normal file
View file

@ -0,0 +1,44 @@
import { getLogger } from './logging/initalize.js';
import { envVars, getEnvVar } from './util/envVars.js';
import { Client } from 'memjs';
export type CacheClient = Client;
let cacheClient: CacheClient;
async function initializeClient(): Promise<CacheClient> {
if (cacheClient !== undefined) {
return cacheClient;
}
const cachingHost = getEnvVar(envVars.CacheHost);
const cachingPort = getEnvVar(envVars.CachePort);
const cachingUrl = `${cachingHost}:${cachingPort}`;
if (cachingHost === '') {
return cacheClient;
}
cacheClient = Client.create(cachingUrl);
getLogger().info(`Memcached client initialized at ${cachingUrl}`);
return cacheClient;
}
export async function getCacheClient(): Promise<CacheClient> {
cacheClient ||= await initializeClient();
return cacheClient;
}
export async function checkCachingHealth(): Promise<boolean> {
try {
const client = await getCacheClient();
await client.set('health', Buffer.from('ok'), { expires: 60 });
const reply = await cacheClient.get('health');
return reply?.value?.toString() === 'ok';
} catch (error) {
getLogger().error('Caching Health Check Failed:', error);
return false;
}
}

View file

@ -11,8 +11,7 @@ import {
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';
import { FALLBACK_LANG } from '../config.js';
function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } {
const classid = req.params.classid;
@ -38,14 +37,19 @@ export async function getAllAssignmentsHandler(req: Request, res: Response): Pro
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 description = req.body.description || '';
const language = req.body.language || FALLBACK_LANG;
const learningPath = req.body.learningPath || '';
const title = req.body.title;
requireFields({ description, language, learningPath, title });
requireFields({ title });
const assignmentData = req.body as AssignmentDTO;
const assignmentData = {
description: description,
language: language,
learningPath: learningPath,
title: title,
} as AssignmentDTO;
const assignment = await createAssignment(classid, assignmentData);
res.json({ assignment });
@ -62,7 +66,7 @@ export async function getAssignmentHandler(req: Request, res: Response): Promise
export async function putAssignmentHandler(req: Request, res: Response): Promise<void> {
const { classid, assignmentNumber } = getAssignmentParams(req);
const assignmentData = req.body as Partial<EntityDTO<Assignment>>;
const assignmentData = req.body as Partial<AssignmentDTO>;
const assignment = await putAssignment(classid, assignmentNumber, assignmentData);
res.json({ assignment });

View file

@ -1,10 +1,11 @@
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';
import { createOrUpdateStudent } from '../services/students.js';
import { Request, Response } from 'express';
import { createOrUpdateTeacher } from '../services/teachers.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
interface FrontendIdpConfig {
authority: string;
@ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig {
};
}
export function handleGetFrontendAuthConfig(_req: Request, res: Response): void {
res.json(getFrontendAuthConfig());
}
export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> {
const auth = req.auth;
if (!auth) {
@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response)
firstName: auth.firstName ?? '',
lastName: auth.lastName ?? '',
};
if (auth.accountType === 'student') {
if (auth.accountType === AccountType.Student) {
await createOrUpdateStudent(userData);
logger.debug(`Synchronized student ${userData.username} with IDP`);
} else {

View file

@ -7,6 +7,9 @@ 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';
import { UploadedFile } from 'express-fileupload';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request';
import { requireFields } from './error-helper.js';
function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifierDTO {
if (!req.params.hruid) {
@ -20,27 +23,35 @@ function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIde
}
function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier {
if (!req.query.hruid) {
throw new BadRequestException('HRUID is required.');
}
const { hruid, language } = req.params;
requireFields({ hruid });
return {
hruid: req.params.hruid,
language: (req.query.language as Language) || FALLBACK_LANG,
hruid,
language: (language as Language) || FALLBACK_LANG,
};
}
export async function getAllLearningObjects(req: Request, res: Response): Promise<void> {
const learningPathId = getLearningPathIdentifierFromRequest(req);
const full = req.query.full;
if (req.query.admin) {
// If the admin query parameter is present, the user wants to have all learning objects with this admin.
const learningObjects = await learningObjectService.getLearningObjectsAdministratedBy(req.query.admin as string);
let learningObjects: FilteredLearningObject[] | string[];
if (full) {
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
res.json(learningObjects);
} else {
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId);
}
// Else he/she wants all learning objects on the path specified by the request parameters.
const learningPathId = getLearningPathIdentifierFromRequest(req);
const full = req.query.full;
res.json({ learningObjects: learningObjects });
let learningObjects: FilteredLearningObject[] | string[];
if (full) {
learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId);
} else {
learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId);
}
res.json({ learningObjects: learningObjects });
}
}
export async function getLearningObject(req: Request, res: Response): Promise<void> {
@ -72,3 +83,32 @@ export async function getAttachment(req: Request, res: Response): Promise<void>
}
res.setHeader('Content-Type', attachment.mimeType).send(attachment.content);
}
export async function handlePostLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> {
if (!req.files || !req.files.learningObject) {
throw new BadRequestException('No file uploaded');
}
const learningObject = await learningObjectService.storeLearningObject((req.files.learningObject as UploadedFile).tempFilePath, [
req.auth!.username,
]);
res.json(learningObject);
}
export async function handleDeleteLearningObject(req: AuthenticatedRequest, res: Response): Promise<void> {
const learningObjectId = getLearningObjectIdentifierFromRequest(req);
if (!learningObjectId.version) {
throw new BadRequestException('When deleting a learning object, a version must be specified.');
}
const deletedLearningObject = await learningObjectService.deleteLearningObject({
hruid: learningObjectId.hruid,
version: learningObjectId.version,
language: learningObjectId.language,
});
if (deletedLearningObject) {
res.json(deletedLearningObject);
} else {
throw new NotFoundException('Learning object not found');
}
}

View file

@ -1,4 +1,4 @@
import { Request, Response } from 'express';
import { 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';
@ -7,51 +7,116 @@ 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';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js';
import { LearningPath, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import { getTeacher } from '../services/teachers.js';
import { requireFields } from './error-helper.js';
/**
* Fetch learning paths based on query parameters.
*/
export async function getLearningPaths(req: Request, res: Response): Promise<void> {
const hruids = req.query.hruid;
const themeKey = req.query.theme as string;
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
const forGroupNo = req.query.forGroup as string;
const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string;
let forGroup: Group | undefined;
if (forGroupNo) {
if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
}
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
if (assignment) {
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
}
}
let hruidList;
if (hruids) {
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
} else if (themeKey) {
const theme = themes.find((t) => t.title === themeKey);
if (theme) {
hruidList = theme.hruids;
} else {
throw new NotFoundException(`Theme "${themeKey}" not found.`);
}
} else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
res.json(searchResults);
return;
export async function getLearningPaths(req: AuthenticatedRequest, res: Response): Promise<void> {
const admin = req.query.admin;
if (admin) {
const paths = await learningPathService.getLearningPathsAdministratedBy(admin as string);
res.json(paths);
} else {
hruidList = themes.flatMap((theme) => theme.hruids);
}
const hruids = req.query.hruid;
const themeKey = req.query.theme as string;
const searchQuery = req.query.search as string;
const language = (req.query.language as string) || FALLBACK_LANG;
const learningPaths = await learningPathService.fetchLearningPaths(hruidList, language as Language, `HRUIDs: ${hruidList.join(', ')}`, forGroup);
res.json(learningPaths.data);
const forGroupNo = req.query.forGroup as string;
const assignmentNo = req.query.assignmentNo as string;
const classId = req.query.classId as string;
let forGroup: Group | undefined;
if (forGroupNo) {
if (!assignmentNo || !classId) {
throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.');
}
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, parseInt(assignmentNo));
if (assignment) {
forGroup = (await getGroupRepository().findByAssignmentAndGroupNumber(assignment, parseInt(forGroupNo))) ?? undefined;
}
}
let hruidList;
if (hruids) {
hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)];
} else if (themeKey) {
const theme = themes.find((t) => t.title === themeKey);
if (theme) {
hruidList = theme.hruids;
} else {
throw new NotFoundException(`Theme "${themeKey}" not found.`);
}
} else if (searchQuery) {
const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, forGroup);
res.json(searchResults);
return;
} else {
hruidList = themes.flatMap((theme) => theme.hruids);
const apiLearningPathResponse = await learningPathService.fetchLearningPaths(hruidList, language as Language, 'All themes', forGroup);
const apiLearningPaths: LearningPath[] = apiLearningPathResponse.data || [];
let allLearningPaths: LearningPath[] = apiLearningPaths;
if (req.auth) {
const adminUsername = req.auth.username;
const userLearningPaths = (await learningPathService.getLearningPathsAdministratedBy(adminUsername)) || [];
allLearningPaths = apiLearningPaths.concat(userLearningPaths);
}
res.json(allLearningPaths);
return;
}
const learningPaths = await learningPathService.fetchLearningPaths(
hruidList,
language as Language,
`HRUIDs: ${hruidList.join(', ')}`,
forGroup
);
res.json(learningPaths.data);
}
}
function postOrPutLearningPath(isPut: boolean): (req: AuthenticatedRequest, res: Response) => Promise<void> {
return async (req, res) => {
const path = req.body as LearningPath;
const { hruid: hruidParam, language: languageParam } = req.params;
if (isPut) {
requireFields({ hruidParam, languageParam, path });
}
const teacher = await getTeacher(req.auth!.username);
if (isPut) {
if (req.params.hruid !== path.hruid || req.params.language !== path.language) {
throw new BadRequestException('id_not_matching_query_params');
}
await learningPathService.deleteLearningPath({ hruid: path.hruid, language: path.language as Language });
}
res.json(await learningPathService.createNewLearningPath(path, [teacher]));
};
}
export const postLearningPath = postOrPutLearningPath(false);
export const putLearningPath = postOrPutLearningPath(true);
export async function deleteLearningPath(req: AuthenticatedRequest, res: Response): Promise<void> {
const { hruid, language } = req.params;
requireFields({ hruid, language });
const id: LearningPathIdentifier = { hruid, language: language as Language };
const deletedPath = await learningPathService.deleteLearningPath(id);
if (deletedPath) {
res.json(deletedPath);
} else {
throw new NotFoundException('The learning path could not be found.');
}
}

View file

@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response):
const classId = req.body.classId;
requireFields({ username, classId });
const request = await createClassJoinRequest(username, classId);
const request = await createClassJoinRequest(username, classId.toUpperCase());
res.json({ request });
}

View file

@ -62,6 +62,11 @@ export async function getAllSubmissionsHandler(req: Request, res: Response): Pro
// 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 submitter = req.body.submitter;
const usernameSubmitter = req.body.submitter.username;
const group = req.body.group;
requireFields({ group, submitter, usernameSubmitter });
const submissionDTO = req.body as SubmissionDTO;
const submission = await createSubmission(submissionDTO);

View file

@ -2,6 +2,7 @@ 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';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom
const classId = req.body.class;
requireFields({ sender, receiver, classId });
if (sender === receiver) {
throw new ConflictException('Cannot send an invitation to yourself');
}
const data = req.body as TeacherInvitationData;
const invitation = await createInvitation(data);

View file

@ -7,7 +7,7 @@ import {
getJoinRequestsByClass,
getStudentsByTeacher,
getTeacher,
getTeacherQuestions,
getTeacherAssignments,
updateClassJoinRequestStatus,
} from '../services/teachers.js';
import { requireFields } from './error-helper.js';
@ -60,6 +60,16 @@ export async function getTeacherClassHandler(req: Request, res: Response): Promi
res.json({ classes });
}
export async function getTeacherAssignmentsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';
requireFields({ username });
const assignments = await getTeacherAssignments(username, full);
res.json({ assignments });
}
export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';
@ -70,16 +80,6 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro
res.json({ students });
}
export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';
requireFields({ username });
const questions = await getTeacherQuestions(username, full);
res.json({ questions });
}
export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> {
const classId = req.params.classId;
requireFields({ classId });

View file

@ -7,7 +7,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] });
}
public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> {
return this.findOne({ within: { classId: withinClass }, id: id });
return this.findOne({ within: { classId: withinClass }, id: id }, { populate: ['groups', 'groups.members'] });
}
public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> {
return this.findAll({
@ -20,6 +20,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
},
},
},
populate: ['groups', 'groups.members'],
});
}
public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {

View file

@ -28,4 +28,9 @@ export class GroupRepository extends DwengoEntityRepository<Group> {
groupNumber: groupNumber,
});
}
public async deleteAllByAssignment(assignment: Assignment): Promise<void> {
return this.deleteAllWhere({
assignment: assignment,
});
}
}

View file

@ -2,7 +2,6 @@ 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 '@dwengo-1/common/util/language';
import { Teacher } from '../../entities/users/teacher.entity.js';
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
@ -13,7 +12,7 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
version: identifier.version,
},
{
populate: ['keywords'],
populate: ['keywords', 'admins'],
}
);
}
@ -33,10 +32,22 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
);
}
public async findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> {
public async findAllByAdmin(adminUsername: string): Promise<LearningObject[]> {
return this.find(
{ admins: teacher },
{
admins: {
username: adminUsername,
},
},
{ populate: ['admins'] } // Make sure to load admin relations
);
}
public async removeByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
const learningObject = await this.findByIdentifier(identifier);
if (learningObject) {
await this.em.removeAndFlush(learningObject);
}
return learningObject;
}
}

View file

@ -4,18 +4,23 @@ 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 async findByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] });
return this.findOne(
{
hruid: hruid,
language: language,
},
{ populate: ['nodes', 'nodes.transitions', 'admins'] }
);
}
/**
* Returns all learning paths which have the given language and whose title OR description contains the
* query string.
*
* @param query The query string we want to seach for in the title or description.
* @param query The query string we want to search for in the title or description.
* @param language The language of the learning paths we want to find.
*/
public async findByQueryStringAndLanguage(query: string, language: Language): Promise<LearningPath[]> {
@ -24,7 +29,21 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
language: language,
$or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }],
},
populate: ['nodes', 'nodes.transitions'],
populate: ['nodes', 'nodes.transitions', 'admins'],
});
}
/**
* Returns all learning paths which have the user with the given username as an administrator.
*/
public async findAllByAdminUsername(adminUsername: string): Promise<LearningPath[]> {
return this.findAll({
where: {
admins: {
username: adminUsername,
},
},
populate: ['nodes', 'nodes.transitions', 'admins'],
});
}
@ -36,18 +55,15 @@ export class LearningPathRepository extends DwengoEntityRepository<LearningPath>
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.');
/**
* Deletes the learning path with the given hruid and language.
* @returns the deleted learning path or null if it was not found.
*/
public async deleteByHruidAndLanguage(hruid: string, language: Language): Promise<LearningPath | null> {
const path = await this.findByHruidAndLanguage(hruid, language);
if (path) {
await this.em.removeAndFlush(path);
}
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)));
return path;
}
}

View file

@ -1,12 +1,25 @@
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, FilterQuery, SyntaxErrorException } from '@mikro-orm/core';
import { EntityAlreadyExistsException } from '../exceptions/entity-already-exists-exception.js';
import { getLogger } from '../logging/initalize.js';
export abstract class DwengoEntityRepository<T extends object> extends EntityRepository<T> {
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);
try {
await this.getEntityManager().persistAndFlush(entity);
} catch (e: unknown) {
// Workaround for MikroORM bug: Sometimes, queries are generated with random syntax errors.
// The faulty query is then retried everytime something is persisted. By clearing the entity
// Manager in that case, we make sure that future queries will work.
if (e instanceof SyntaxErrorException) {
getLogger().error('SyntaxErrorException caught => entity manager cleared.');
this.em.clear();
} else {
throw e;
}
}
}
public async deleteWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.findOne(query);
@ -16,4 +29,13 @@ export abstract class DwengoEntityRepository<T extends object> extends EntityRep
await em.flush();
}
}
public async deleteAllWhere(query: FilterQuery<T>): Promise<void> {
const toDelete = await this.find(query);
const em = this.getEntityManager();
if (toDelete) {
em.remove(toDelete);
await em.flush();
}
}
}

View file

@ -3,9 +3,9 @@ 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 { Group } from '../../entities/assignments/group.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 async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> {
@ -18,13 +18,9 @@ export class QuestionRepository extends DwengoEntityRepository<Question> {
content: question.content,
timestamp: new Date(),
});
questionEntity.learningObjectHruid = question.loId.hruid;
questionEntity.learningObjectLanguage = question.loId.language;
questionEntity.learningObjectVersion = question.loId.version;
questionEntity.author = question.author;
questionEntity.inGroup = question.inGroup;
questionEntity.content = question.content;
return await this.insert(questionEntity);
// Don't check for overwrite since this is impossible anyway due to autoincrement.
await this.save(questionEntity, { preventOverwrite: false });
return questionEntity;
}
public async findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise<Question[]> {
return this.findAll({

View file

@ -26,6 +26,9 @@ export class Assignment {
@Property({ type: 'string' })
learningPathHruid!: string;
@Property({ type: 'datetime', nullable: true })
deadline?: Date;
@Enum({
items: () => Language,
})

View file

@ -1,15 +1,17 @@
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;

View file

@ -9,6 +9,7 @@ export class Attachment {
@ManyToOne({
entity: () => LearningObject,
primary: true,
deleteRule: 'cascade',
})
learningObject!: LearningObject;

View file

@ -1,4 +1,4 @@
import { ArrayType, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { ArrayType, Collection, 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';
@ -28,7 +28,7 @@ export class LearningObject {
@ManyToMany({
entity: () => Teacher,
})
admins!: Teacher[];
admins: Collection<Teacher> = new Collection<Teacher>(this);
@Property({ type: 'string' })
title!: string;
@ -84,7 +84,7 @@ export class LearningObject {
entity: () => Attachment,
mappedBy: 'learningObject',
})
attachments: Attachment[] = [];
attachments: Collection<Attachment> = new Collection<Attachment>(this);
@Property({ type: 'blob' })
content!: Buffer;

View file

@ -1,4 +1,4 @@
import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core';
import { Cascade, 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';
@ -26,7 +26,7 @@ export class LearningPathNode {
@Property({ type: 'bool' })
startNode!: boolean;
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' })
@OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node', cascade: [Cascade.ALL] })
transitions!: Collection<LearningPathTransition>;
@Property({ length: 3 })

View file

@ -1,4 +1,4 @@
import { Collection, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { Cascade, 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';
@ -24,6 +24,6 @@ export class LearningPath {
@Property({ type: 'blob', nullable: true })
image: Buffer | null = null;
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' })
@OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath', cascade: [Cascade.ALL] })
nodes: Collection<LearningPathNode> = new Collection<LearningPathNode>(this);
}

View file

@ -6,6 +6,9 @@ import { Group } from '../assignments/group.entity.js';
@Entity({ repository: () => QuestionRepository })
export class Question {
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@PrimaryKey({ type: 'string' })
learningObjectHruid!: string;
@ -18,9 +21,6 @@ export class Question {
@PrimaryKey({ type: 'number' })
learningObjectVersion = 1;
@PrimaryKey({ type: 'integer', autoincrement: true })
sequenceNumber?: number;
@ManyToOne({ entity: () => Group })
inGroup!: Group;

View file

@ -20,6 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO {
description: assignment.description,
learningPath: assignment.learningPathHruid,
language: assignment.learningPathLanguage,
deadline: assignment.deadline ?? null,
groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)),
};
}
@ -31,6 +32,7 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi
description: assignmentData.description,
learningPathHruid: assignmentData.learningPath,
learningPathLanguage: languageMap[assignmentData.language],
deadline: assignmentData.deadline,
groups: [],
});
}

View file

@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO {
};
}
export function mapToUsername(user: { username: string }): string {
return user.username;
}
export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T {
userInstance.username = userData.username;
userInstance.firstName = userData.firstName;

View file

@ -29,7 +29,8 @@ function initializeLogger(): Logger {
format: format.combine(format.cli(), format.simple()),
});
if (getEnvVar(envVars.RunMode) === 'dev') {
const runMode = getEnvVar(envVars.RunMode);
if (runMode === 'dev' || runMode.includes('test')) {
logger = createLogger({
transports: [consoleTransport],
});

View file

@ -7,7 +7,6 @@ import * as express from 'express';
import { AuthenticatedRequest } from './authenticated-request.js';
import { AuthenticationInfo } from './authentication-info.js';
import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../exceptions/forbidden-exception.js';
const JWKS_CACHE = true;
const JWKS_RATE_LIMIT = true;
@ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response
}
export const authenticateUser = [verifyJwtToken, addAuthenticationInfo];
/**
* Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill
* the given access condition.
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
* to true.
*/
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)) {
throw new ForbiddenException();
} else {
next();
}
};
}
/**
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
*/
export const authenticatedOnly = authorize((_) => true);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't students.
*/
export const studentsOnly = authorize((auth) => auth.accountType === 'student');
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === 'teacher');

View file

@ -1,8 +1,15 @@
import { Request } from 'express';
import { JwtPayload } from 'jsonwebtoken';
import { AuthenticationInfo } from './authentication-info.js';
import * as core from 'express-serve-static-core';
export interface AuthenticatedRequest extends Request {
export interface AuthenticatedRequest<
P = core.ParamsDictionary,
ResBody = unknown,
ReqBody = unknown,
ReqQuery = core.Query,
Locals extends Record<string, unknown> = Record<string, unknown>,
> extends Request<P, ResBody, ReqBody, ReqQuery, Locals> {
// Properties are optional since the user is not necessarily authenticated.
jwtPayload?: JwtPayload;
auth?: AuthenticationInfo;

View file

@ -0,0 +1,21 @@
import { authorize } from './auth-checks.js';
import { fetchClass } from '../../../services/classes.js';
import { fetchAllGroups } from '../../../services/groups.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment).
* Only allows requests from users who are
* - either teachers of the class the assignment was posted in,
* - or students in a group of the assignment.
*/
export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => {
const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number };
if (auth.accountType === AccountType.Teacher) {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
const groups = await fetchAllGroups(classId, assignmentId);
return groups.some((group) => group.members.map((member) => member.username).includes(auth.username));
});

View file

@ -0,0 +1,61 @@
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import * as express from 'express';
import { RequestHandler } from 'express';
import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../../exceptions/forbidden-exception.js';
import { envVars, getEnvVar } from '../../../util/envVars.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill
* the given access condition.
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
* to true.
*/
export function authorize<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>(
accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean>
): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> {
// Bypass authentication during testing
if (getEnvVar(envVars.RunMode) === 'test') {
return async (
_req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
next();
};
}
return async (
req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
if (!req.auth) {
throw new UnauthorizedException();
} else if (!(await accessCondition(req.auth, req))) {
throw new ForbiddenException();
} else {
next();
}
};
}
/**
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
*/
export const authenticatedOnly = authorize((_) => true);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't students.
*/
export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher);
/**
* Middleware which is to be used on requests no normal user should be able to execute.
* Since there is no concept of administrator accounts yet, currently, those requests will always be blocked.
*/
export const adminOnly = authorize(() => false);

View file

@ -0,0 +1,70 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { fetchClass } from '../../../services/classes.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { getAllInvitations } from '../../../services/teacher-invitations.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
async function teaches(teacherUsername: string, classId: string): Promise<boolean> {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(teacherUsername);
}
/**
* To be used on a request with path parameters username and classId.
* Only allows requests whose username parameter is equal to the username of the user who is logged in and requests
* whose classId parameter references a class the logged-in user is a teacher of.
*/
export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
if (req.params.username === auth.username) {
return true;
} else if (auth.accountType === AccountType.Teacher) {
return teaches(auth.username, req.params.classId);
}
return false;
});
/**
* Only let the request pass through if its path parameter "username" is the username of the currently logged-in
* teacher and the path parameter "classId" refers to a class the teacher teaches.
*/
export const onlyAllowTeacherOfClass = authorize(
async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId)
);
/**
* Only let the request pass through if the class id in it refers to a class the current user is in (as a student
* or teacher)
*/
export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const classId = req.params.classId ?? req.params.classid ?? req.params.id;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});
export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const classId = req.params.classId ?? req.params.classid ?? req.params.id;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
const invitations = await getAllInvitations(auth.username, false);
return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});
/**
* Only allows the request to pass if the 'class' property in its body is a class the current user is a member of.
*/
export const onlyAllowOwnClassInBody = authorize(async (auth, req) => {
const classId = (req.body as { class: string })?.class;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,26 @@
import { authorize } from './auth-checks.js';
import { fetchClass } from '../../../services/classes.js';
import { fetchGroup } from '../../../services/groups.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'.
* Only allows requests from users who are
* - either teachers of the class the assignment for the group was posted in,
* - or students in the group
*/
export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => {
const {
classid: classId,
assignmentid: assignmentId,
groupid: groupId,
} = req.params as { classid: string; assignmentid: number; groupid: number };
if (auth.accountType === AccountType.Teacher) {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(auth.username);
} // User is student
const group = await fetchGroup(classId, assignmentId, groupId);
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,21 @@
import { authorize } from './auth-checks';
import { AuthenticationInfo } from '../authentication-info';
import { AuthenticatedRequest } from '../authenticated-request';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId')
* are
* - either not set
* - or set to a group the user is in,
* - or set to anything if the user is a teacher.
*/
export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { forGroup, assignmentNo, classId } = req.params;
if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) {
// TODO: groupNumber?
// Const group = await fetchGroup(Number(classId), Number(assignmentNo), )
return false;
}
return true;
});

View file

@ -0,0 +1,16 @@
import { Language } from '@dwengo-1/common/util/language';
import learningObjectService from '../../../services/learning-objects/learning-object-service.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
export const onlyAdminsForLearningObject = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid } = req.params;
const { version, language } = req.query;
const admins = await learningObjectService.getAdmins({
hruid,
language: language as Language,
version: parseInt(version as string),
});
return admins.includes(auth.username);
});

View file

@ -0,0 +1,13 @@
import { Language } from '@dwengo-1/common/util/language';
import learningPathService from '../../../services/learning-paths/learning-path-service.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const adminsForLearningPath = await learningPathService.getAdmins({
hruid: req.params.hruid,
language: req.params.language as Language,
});
return adminsForLearningPath && adminsForLearningPath.includes(auth.username);
});

View file

@ -0,0 +1,66 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { requireFields } from '../../../controllers/error-helper.js';
import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js';
import { fetchQuestion } from '../../../services/questions.js';
import { FALLBACK_SEQ_NUM } from '../../../config.js';
import { fetchAnswer } from '../../../services/answers.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
export const onlyAllowAuthor = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username
);
export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
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 question = await fetchQuestion(questionId);
return question.author.username === auth.username;
});
export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
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 fetchAnswer(questionId, sequenceNumber);
return answer.author.username === auth.username;
});
export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
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 question = await fetchQuestion(questionId);
const group = question.inGroup;
if (auth.accountType === AccountType.Teacher) {
const cls = group.assignment.within; // TODO check if contains full objects
return cls.teachers.map(mapToUsername).includes(auth.username);
} // User is student
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,48 @@
import { languageMap } from '@dwengo-1/common/util/language';
import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js';
import { fetchSubmission } from '../../../services/submissions.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
import { FALLBACK_LANG } from '../../../config.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
import { fetchClass } from '../../../services/classes.js';
import { fetchGroup } from '../../../services/groups.js';
import { requireFields } from '../../../controllers/error-helper.js';
import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission';
export const onlyAllowSubmitter = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const submittedFor = (req.body as SubmissionDTO).submitter.username;
const submittedBy = auth.username;
return submittedFor === submittedBy;
});
export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid: lohruid, id: submissionNumber } = req.params;
const { language: lang, version: version } = req.query;
const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version));
const submission = await fetchSubmission(loId, Number(submissionNumber));
if (auth.accountType === AccountType.Teacher) {
// Dit kan niet werken om dat al deze objecten niet gepopulate zijn.
return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username);
}
return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username);
});
export const onlyAllowIfHasAccessToSubmissionFromParams = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { classId, assignmentId, forGroup } = req.query;
requireFields({ classId, assignmentId, forGroup });
if (auth.accountType === AccountType.Teacher) {
const cls = await fetchClass(classId as string);
return cls.teachers.map(mapToUsername).includes(auth.username);
}
const group = await fetchGroup(classId as string, Number(assignmentId as string), Number(forGroup as string));
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,17 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
export const onlyAllowSenderOrReceiver = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username
);
export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username);
export const onlyAllowSenderBody = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username
);
export const onlyAllowReceiverBody = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username
);

View file

@ -0,0 +1,8 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
/**
* Only allow the user whose username is in the path parameter "username" to access the endpoint.
*/
export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username);

View file

@ -7,12 +7,17 @@ let orm: MikroORM | undefined;
export async function initORM(testingMode = false): Promise<MikroORM<IDatabaseDriver, EntityManager>> {
const logger: Logger = getLogger();
logger.info('Initializing ORM');
logger.debug('MikroORM config is', config);
const options = config(testingMode);
logger.info('MikroORM config is', options);
logger.info('Initializing ORM');
orm = await MikroORM.init(options);
logger.info('MikroORM initialized');
orm = await MikroORM.init(config(testingMode));
// Update the database scheme if necessary and enabled.
if (getEnvVar(envVars.DbUpdate)) {
logger.info('MikroORM: Updating database schema');
await orm.schema.updateSchema();
} else {
const diff = await orm.schema.getUpdateSchemaSQL();

View file

@ -1,16 +1,18 @@
import express from 'express';
import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js';
const router = express.Router({ mergeParams: true });
router.get('/', getAllAnswersHandler);
router.get('/', authenticatedOnly, getAllAnswersHandler);
router.post('/', createAnswerHandler);
router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler);
router.get('/:seqAnswer', getAnswerHandler);
router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler);
router.delete('/:seqAnswer', deleteAnswerHandler);
router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler);
router.put('/:seqAnswer', updateAnswerHandler);
router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler);
export default router;

View file

@ -9,22 +9,25 @@ import {
putAssignmentHandler,
} from '../controllers/assignments.js';
import groupRouter from './groups.js';
import { teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js';
import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js';
const router = express.Router({ mergeParams: true });
router.get('/', getAllAssignmentsHandler);
router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler);
router.post('/', createAssignmentHandler);
router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler);
router.get('/:id', getAssignmentHandler);
router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler);
router.put('/:id', putAssignmentHandler);
router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler);
router.delete('/:id', deleteAssignmentHandler);
router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler);
router.get('/:id/submissions', getAssignmentsSubmissionsHandler);
router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler);
router.get('/:id/questions', getAssignmentQuestionsHandler);
router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler);
router.use('/:assignmentid/groups', groupRouter);

View file

@ -1,28 +1,35 @@
import express from 'express';
import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js';
import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router();
// Returns auth configuration for frontend
router.get('/config', (_req, res) => {
res.json(getFrontendAuthConfig());
});
router.get('/config', handleGetFrontendAuthConfig);
router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => {
/* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */
/* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
res.json({ message: 'If you see this, you should be authenticated!' });
});
router.get('/testStudentsOnly', studentsOnly, (_req, res) => {
/* #swagger.security = [{ "student": [ ] }] */
/* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */
res.json({ message: 'If you see this, you should be a student!' });
});
router.get('/testTeachersOnly', teachersOnly, (_req, res) => {
/* #swagger.security = [{ "teacher": [ ] }] */
/* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */
res.json({ message: 'If you see this, you should be a teacher!' });
});
router.post('/hello', authenticatedOnly, postHelloHandler);
// This endpoint is called by the client when the user has just logged in.
// It creates or updates the user entity based on the authentication data the endpoint was called with.
router.post(
'/hello',
authenticatedOnly,
/*
#swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }]
*/ postHelloHandler
);
export default router;

View file

@ -14,33 +14,35 @@ import {
putClassHandler,
} from '../controllers/classes.js';
import assignmentRouter from './assignments.js';
import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js';
const router = express.Router();
// Root endpoint used to search objects
router.get('/', getAllClassesHandler);
router.get('/', adminOnly, getAllClassesHandler);
router.post('/', createClassHandler);
router.post('/', teachersOnly, createClassHandler);
router.get('/:id', getClassHandler);
router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler);
router.put('/:id', putClassHandler);
router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler);
router.delete('/:id', deleteClassHandler);
router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler);
router.get('/:id/teacher-invitations', getTeacherInvitationsHandler);
router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler);
router.get('/:id/students', getClassStudentsHandler);
router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler);
router.post('/:id/students', addClassStudentHandler);
router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler);
router.delete('/:id/students/:username', deleteClassStudentHandler);
router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler);
router.get('/:id/teachers', getClassTeachersHandler);
router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler);
router.post('/:id/teachers', addClassTeacherHandler);
// De combinatie van deze POST en DELETE endpoints kan lethal zijn
router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler);
router.delete('/:id/teachers/:username', deleteClassTeacherHandler);
router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler);
router.use('/:classid/assignments', assignmentRouter);

View file

@ -8,22 +8,24 @@ import {
getGroupSubmissionsHandler,
putGroupHandler,
} from '../controllers/groups.js';
import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js';
import { teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js';
const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects
router.get('/', getAllGroupsHandler);
router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler);
router.post('/', createGroupHandler);
router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler);
router.get('/:groupid', getGroupHandler);
router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler);
router.put('/:groupid', putGroupHandler);
router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler);
router.delete('/:groupid', deleteGroupHandler);
router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler);
router.get('/:groupid/submissions', getGroupSubmissionsHandler);
router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler);
router.get('/:groupid/questions', getGroupQuestionsHandler);
router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler);
export default router;

View file

@ -1,8 +1,17 @@
import express from 'express';
import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js';
import {
getAllLearningObjects,
getAttachment,
getLearningObject,
getLearningObjectHTML,
handleDeleteLearningObject,
handlePostLearningObject,
} from '../controllers/learning-objects.js';
import submissionRoutes from './submissions.js';
import questionRoutes from './questions.js';
import fileUpload from 'express-fileupload';
import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router();
@ -16,13 +25,21 @@ const router = express.Router();
// Route 2: list of object data
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
router.get('/', getAllLearningObjects);
router.get('/', authenticatedOnly, getAllLearningObjects);
router.post('/', teachersOnly, fileUpload({ useTempFiles: true }), handlePostLearningObject);
// Parameter: hruid of learning object
// Query: language
// Route to fetch data of one learning object based on its hruid
// Example: http://localhost:3000/learningObject/un_ai7
router.get('/:hruid', getLearningObject);
router.get('/:hruid', authenticatedOnly, getLearningObject);
// Parameter: hruid of learning object
// Query: language
// Route to delete a learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7?language=nl&version=1
router.delete('/:hruid', onlyAdminsForLearningObject, handleDeleteLearningObject);
router.use('/:hruid/submissions', submissionRoutes);
@ -32,12 +49,12 @@ router.use('/:hruid/:version/questions', questionRoutes);
// Query: language, version (optional)
// Route to fetch the HTML rendering of one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7/html
router.get('/:hruid/html', getLearningObjectHTML);
router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML);
// Parameter: hruid of learning object, name of attachment.
// Query: language, version (optional).
// Route to get the raw data of the attachment for one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
router.get('/:hruid/html/:attachmentName', getAttachment);
router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment);
export default router;

View file

@ -1,5 +1,7 @@
import express from 'express';
import { getLearningPaths } from '../controllers/learning-paths.js';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js';
import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js';
const router = express.Router();
@ -22,6 +24,10 @@ const router = express.Router();
// Route to fetch learning paths based on a theme
// Example: http://localhost:3000/learningPath?theme=kiks
router.get('/', getLearningPaths);
router.get('/', authenticatedOnly, getLearningPaths);
router.post('/', teachersOnly, postLearningPath);
router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath);
router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath);
export default router;

View file

@ -1,20 +1,25 @@
import express from 'express';
import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js';
import answerRoutes from './answers.js';
import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
import { updateAnswerHandler } from '../controllers/answers.js';
import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js';
const router = express.Router({ mergeParams: true });
// Query language
// Root endpoint used to search objects
router.get('/', getAllQuestionsHandler);
router.get('/', authenticatedOnly, getAllQuestionsHandler);
router.post('/', createQuestionHandler);
router.delete('/:seq', deleteQuestionHandler);
router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler);
// Information about a question with id
router.get('/:seq', getQuestionHandler);
router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler);
router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler);
router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler);
router.use('/:seq/answers', answerRoutes);

View file

@ -18,12 +18,30 @@ router.get('/', (_, res: Response) => {
});
});
router.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */);
router.use('/class', classRouter /* #swagger.tags = ['Class'] */);
router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */);
router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */);
router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */);
router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */);
router.use(
'/class',
classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/learningObject',
learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/learningPath',
learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/student',
studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/teacher',
teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/theme',
themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
export default router;

View file

@ -5,15 +5,19 @@ import {
getStudentRequestHandler,
getStudentRequestsHandler,
} from '../controllers/students.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js';
// Under /:username/joinRequests/
const router = express.Router({ mergeParams: true });
router.get('/', getStudentRequestsHandler);
router.get('/', preventImpersonation, getStudentRequestsHandler);
router.post('/', createStudentRequestHandler);
router.post('/', preventImpersonation, createStudentRequestHandler);
router.get('/:classId', getStudentRequestHandler);
router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler);
router.delete('/:classId', deleteClassJoinRequestHandler);
router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler);
export default router;

View file

@ -11,33 +11,37 @@ import {
getStudentSubmissionsHandler,
} from '../controllers/students.js';
import joinRequestRouter from './student-join-requests.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { adminOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router();
// Root endpoint used to search objects
router.get('/', getAllStudentsHandler);
router.get('/', adminOnly, getAllStudentsHandler);
router.post('/', createStudentHandler);
// Users will be created automatically when some resource is created for them. Therefore, this endpoint
// Can only be used by an administrator.
router.post('/', adminOnly, createStudentHandler);
router.delete('/:username', deleteStudentHandler);
router.delete('/:username', preventImpersonation, deleteStudentHandler);
// Information about a student's profile
router.get('/:username', getStudentHandler);
router.get('/:username', preventImpersonation, getStudentHandler);
// The list of classes a student is in
router.get('/:username/classes', getStudentClassesHandler);
router.get('/:username/classes', preventImpersonation, getStudentClassesHandler);
// The list of submissions a student has made
router.get('/:username/submissions', getStudentSubmissionsHandler);
router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler);
// The list of assignments a student has
router.get('/:username/assignments', getStudentAssignmentsHandler);
router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler);
// The list of groups a student is in
router.get('/:username/groups', getStudentGroupsHandler);
router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler);
// A list of questions a user has created
router.get('/:username/questions', getStudentQuestionsHandler);
router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler);
router.use('/:username/joinRequests', joinRequestRouter);

View file

@ -1,15 +1,19 @@
import express from 'express';
import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js';
import {
onlyAllowIfHasAccessToSubmission,
onlyAllowIfHasAccessToSubmissionFromParams,
onlyAllowSubmitter,
} from '../middleware/auth/checks/submission-checks.js';
import { studentsOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects
router.get('/', getSubmissionsHandler);
router.get('/', onlyAllowIfHasAccessToSubmissionFromParams, getSubmissionsHandler);
router.post('/', createSubmissionHandler);
router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);
// Information about an submission with id 'id'
router.get('/:id', getSubmissionHandler);
router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler);
router.delete('/:id', deleteSubmissionHandler);
router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler);
export default router;

View file

@ -6,17 +6,24 @@ import {
getInvitationHandler,
updateInvitationHandler,
} from '../controllers/teacher-invitations.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import {
onlyAllowReceiverBody,
onlyAllowSender,
onlyAllowSenderBody,
onlyAllowSenderOrReceiver,
} from '../middleware/auth/checks/teacher-invitation-checks.js';
const router = express.Router({ mergeParams: true });
router.get('/:username', getAllInvitationsHandler);
router.get('/:username', preventImpersonation, getAllInvitationsHandler);
router.get('/:sender/:receiver/:classId', getInvitationHandler);
router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler);
router.post('/', createInvitationHandler);
router.post('/', onlyAllowSenderBody, createInvitationHandler);
router.put('/', updateInvitationHandler);
router.put('/', onlyAllowReceiverBody, updateInvitationHandler);
router.delete('/:sender/:receiver/:classId', deleteInvitationHandler);
router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler);
export default router;

View file

@ -4,34 +4,36 @@ import {
deleteTeacherHandler,
getAllTeachersHandler,
getStudentJoinRequestHandler,
getTeacherAssignmentsHandler,
getTeacherClassHandler,
getTeacherHandler,
getTeacherQuestionHandler,
getTeacherStudentHandler,
updateStudentJoinRequestHandler,
} from '../controllers/teachers.js';
import invitationRouter from './teacher-invitations.js';
import { adminOnly } from '../middleware/auth/checks/auth-checks.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js';
const router = express.Router();
// Root endpoint used to search objects
router.get('/', getAllTeachersHandler);
router.get('/', adminOnly, getAllTeachersHandler);
router.post('/', createTeacherHandler);
router.post('/', adminOnly, createTeacherHandler);
router.get('/:username', getTeacherHandler);
router.get('/:username', preventImpersonation, getTeacherHandler);
router.delete('/:username', deleteTeacherHandler);
router.delete('/:username', preventImpersonation, deleteTeacherHandler);
router.get('/:username/classes', getTeacherClassHandler);
router.get('/:username/classes', preventImpersonation, getTeacherClassHandler);
router.get('/:username/students', getTeacherStudentHandler);
router.get('/:username/students', preventImpersonation, getTeacherStudentHandler);
router.get('/:username/questions', getTeacherQuestionHandler);
router.get(`/:username/assignments`, getTeacherAssignmentsHandler);
router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler);
router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler);
router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler);
router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler);
// Invitations to other classes a teacher received
router.use('/invitations', invitationRouter);

View file

@ -1,14 +1,15 @@
import express from 'express';
import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js';
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router();
// Query: language
// Route to fetch list of {key, title, description, image} themes in their respective language
router.get('/', getThemesHandler);
router.get('/', authenticatedOnly, getThemesHandler);
// Arg: theme (key)
// Route to fetch list of hruids based on theme
router.get('/:theme', getHruidsByThemeHandler);
router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler);
export default router;

View file

@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat
return mapToAnswerDTO(answer);
}
async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> {
export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> {
const answerRepository = getAnswerRepository();
const question = await fetchQuestion(questionId);
const answer = await answerRepository.findAnswer(question, sequenceNumber);

View file

@ -14,10 +14,13 @@ import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submissi
import { fetchClass } from './classes.js';
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission';
import { EntityDTO } from '@mikro-orm/core';
import { EntityDTO, ForeignKeyConstraintViolationException } from '@mikro-orm/core';
import { putObject } from './service-helper.js';
import { fetchStudents } from './students.js';
import { ServerErrorException } from '../exceptions/server-error-exception.js';
import { BadRequestException } from '../exceptions/bad-request-exception.js';
import { ConflictException } from '../exceptions/conflict-exception.js';
import { PostgreSqlExceptionConverter } from '@mikro-orm/postgresql';
export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> {
const classRepository = getClassRepository();
@ -59,7 +62,7 @@ export async function createAssignment(classid: string, assignmentData: Assignme
if (assignmentData.groups) {
/*
For some reason when trying to add groups, it does not work when using the original assignment variable.
For some reason when trying to add groups, it does not work when using the original assignment variable.
The assignment needs to be refetched in order for it to work.
*/
@ -93,10 +96,45 @@ export async function getAssignment(classid: string, id: number): Promise<Assign
return mapToAssignmentDTO(assignment);
}
export async function putAssignment(classid: string, id: number, assignmentData: Partial<EntityDTO<Assignment>>): Promise<AssignmentDTO> {
function hasDuplicates(arr: string[]): boolean {
return new Set(arr).size !== arr.length;
}
export async function putAssignment(classid: string, id: number, assignmentData: Partial<AssignmentDTO>): Promise<AssignmentDTO> {
const assignment = await fetchAssignment(classid, id);
await putObject<Assignment>(assignment, assignmentData, getAssignmentRepository());
if (assignmentData.groups) {
if (hasDuplicates(assignmentData.groups.flat() as string[])) {
throw new BadRequestException('Student can only be in one group');
}
const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group)));
try {
const groupRepository = getGroupRepository();
await groupRepository.deleteAllByAssignment(assignment);
await Promise.all(
studentLists.map(async (students) => {
const newGroup = groupRepository.create({
assignment: assignment,
members: students,
});
await groupRepository.save(newGroup);
})
);
} catch (e: unknown) {
if (e instanceof ForeignKeyConstraintViolationException || e instanceof PostgreSqlExceptionConverter) {
throw new ConflictException('Cannot update assigment with questions or submissions');
} else {
throw e;
}
}
delete assignmentData.groups;
}
await putObject<Assignment>(assignment, assignmentData as Partial<EntityDTO<Assignment>>, getAssignmentRepository());
return mapToAssignmentDTO(assignment);
}
@ -106,7 +144,16 @@ export async function deleteAssignment(classid: string, id: number): Promise<Ass
const cls = await fetchClass(classid);
const assignmentRepository = getAssignmentRepository();
await assignmentRepository.deleteByClassAndId(cls, id);
try {
await assignmentRepository.deleteByClassAndId(cls, id);
} catch (e: unknown) {
if (e instanceof ForeignKeyConstraintViolationException || e instanceof PostgreSqlExceptionConverter) {
throw new ConflictException('Cannot delete assigment with questions or submissions');
} else {
throw e;
}
}
return mapToAssignmentDTO(assignment);
}

View file

@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou
return group;
}
export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise<Group[]> {
const assignment = await fetchAssignment(classId, assignmentNumber);
const groupRepository = getGroupRepository();
const groups = await groupRepository.findAllGroupsForAssignment(assignment);
return groups;
}
export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> {
const group = await fetchGroup(classId, assignmentNumber, groupNumber);
return mapToGroupDTO(group, group.assignment.within);

View file

@ -1,5 +1,5 @@
import { DWENGO_API_BASE } from '../config.js';
import { fetchWithLogging } from '../util/api-helper.js';
import { fetchRemote } from '../util/api-helper.js';
import {
FilteredLearningObject,
@ -39,10 +39,7 @@ function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLear
*/
export async function getLearningObjectById(hruid: string, language: string): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${hruid}" (language ${language})`
);
const metadata = await fetchRemote<LearningObjectMetadata>(metadataUrl, `Metadata for Learning Object HRUID "${hruid}" (language ${language})`);
if (!metadata) {
getLogger().error(`⚠️ WARNING: Learning object "${hruid}" not found.`);

View file

@ -109,6 +109,15 @@ const databaseLearningObjectProvider: LearningObjectProvider = {
);
return learningObjects.filter((it) => it !== null);
},
/**
* Returns all learning objects containing the given username as an admin.
*/
async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> {
const learningObjectRepo = getLearningObjectRepository();
const learningObjects = await learningObjectRepo.findAllByAdmin(adminUsername);
return learningObjects.map((it) => convertLearningObject(it)).filter((it) => it !== null);
},
};
export default databaseLearningObjectProvider;

View file

@ -1,5 +1,5 @@
import { DWENGO_API_BASE } from '../../config.js';
import { fetchWithLogging } from '../../util/api-helper.js';
import { fetchRemote } from '../../util/api-helper.js';
import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js';
import { LearningObjectProvider } from './learning-object-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
@ -88,7 +88,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
*/
async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
const metadata = await fetchRemote<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`,
{
@ -124,7 +124,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
*/
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
const html = await fetchRemote<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
params: { ...id },
});
@ -135,6 +135,13 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
return html;
},
/**
* Obtain all learning objects who have the user with the given username as an admin.
*/
async getLearningObjectsAdministratedBy(_adminUsername: string): Promise<FilteredLearningObject[]> {
return []; // The dwengo database does not contain any learning objects administrated by users.
},
};
export default dwengoApiLearningObjectProvider;

View file

@ -20,4 +20,9 @@ export interface LearningObjectProvider {
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null>;
/**
* Obtain all learning object who have the user with the given username as an admin.
*/
getLearningObjectsAdministratedBy(username: string): Promise<FilteredLearningObject[]>;
}

View file

@ -3,6 +3,11 @@ import { LearningObjectProvider } from './learning-object-provider.js';
import { envVars, getEnvVar } from '../../util/envVars.js';
import databaseLearningObjectProvider from './database-learning-object-provider.js';
import { FilteredLearningObject, LearningObjectIdentifierDTO, LearningPathIdentifier } from '@dwengo-1/common/interfaces/learning-content';
import { getLearningObjectRepository, getTeacherRepository } from '../../data/repositories.js';
import { processLearningObjectZip } from './learning-object-zip-processing-service.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { NotFoundException } from '../../exceptions/not-found-exception.js';
function getProvider(id: LearningObjectIdentifierDTO): LearningObjectProvider {
if (id.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
@ -42,6 +47,66 @@ const learningObjectService = {
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
return getProvider(id).getLearningObjectHTML(id);
},
/**
* Obtain all learning objects administrated by the user with the given username.
*/
async getLearningObjectsAdministratedBy(adminUsername: string): Promise<FilteredLearningObject[]> {
return databaseLearningObjectProvider.getLearningObjectsAdministratedBy(adminUsername);
},
/**
* Store the learning object in the given zip file in the database.
* @param learningObjectPath The path where the uploaded learning object resides.
* @param admins The usernames of the users which should be administrators of the learning object.
*/
async storeLearningObject(learningObjectPath: string, admins: string[]): Promise<LearningObject> {
const learningObjectRepository = getLearningObjectRepository();
const learningObject = await processLearningObjectZip(learningObjectPath);
if (!learningObject.hruid.startsWith(getEnvVar(envVars.UserContentPrefix))) {
learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid;
}
// Lookup the admin teachers based on their usernames and add them to the admins of the learning object.
const teacherRepo = getTeacherRepository();
const adminTeachers = await Promise.all(admins.map(async (it) => teacherRepo.findByUsername(it)));
adminTeachers.forEach((it) => {
if (it !== null) {
learningObject.admins.add(it);
}
});
try {
await learningObjectRepository.save(learningObject, { preventOverwrite: true });
} catch (e: unknown) {
learningObjectRepository.getEntityManager().clear();
throw e;
}
return learningObject;
},
/**
* Deletes the learning object with the given identifier.
*/
async deleteLearningObject(id: LearningObjectIdentifier): Promise<LearningObject | null> {
const learningObjectRepository = getLearningObjectRepository();
return await learningObjectRepository.removeByIdentifier(id);
},
/**
* Returns a list of the usernames of the administrators of the learning object with the given identifier.
* @throws NotFoundException if the specified learning object was not found in the database.
*/
async getAdmins(id: LearningObjectIdentifier): Promise<string[]> {
const learningObjectRepo = getLearningObjectRepository();
const learningObject = await learningObjectRepo.findByIdentifier(id);
if (!learningObject) {
throw new NotFoundException('learningObjectNotFound');
}
return learningObject.admins.map((admin) => admin.username);
},
};
export default learningObjectService;

View file

@ -0,0 +1,119 @@
import unzipper from 'unzipper';
import mime from 'mime-types';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { getAttachmentRepository, getLearningObjectRepository } from '../../data/repositories.js';
import { BadRequestException } from '../../exceptions/bad-request-exception.js';
import { LearningObjectMetadata } from '@dwengo-1/common/interfaces/learning-content';
import { DwengoContentType } from './processing/content-type.js';
import { v4 } from 'uuid';
const METADATA_PATH_REGEX = /.*[/^]metadata\.json$/;
const CONTENT_PATH_REGEX = /.*[/^]content\.[a-zA-Z]*$/;
/**
* Process an uploaded zip file and construct a LearningObject from its contents.
* @param filePath Path of the zip file to process.
*/
export async function processLearningObjectZip(filePath: string): Promise<LearningObject> {
let zip: unzipper.CentralDirectory;
try {
zip = await unzipper.Open.file(filePath);
} catch (_: unknown) {
throw new BadRequestException('invalidZip');
}
let metadata: LearningObjectMetadata | undefined = undefined;
const attachments: { name: string; content: Buffer }[] = [];
let content: Buffer | undefined = undefined;
if (zip.files.length === 0) {
throw new BadRequestException('emptyZip');
}
await Promise.all(
zip.files.map(async (file) => {
if (file.type !== 'Directory') {
if (METADATA_PATH_REGEX.test(file.path)) {
metadata = await processMetadataJson(file);
} else if (CONTENT_PATH_REGEX.test(file.path)) {
content = await processFile(file);
} else {
attachments.push({
name: file.path,
content: await processFile(file),
});
}
}
})
);
if (!metadata) {
throw new BadRequestException('missingMetadata');
}
if (!content) {
throw new BadRequestException('missingIndex');
}
const learningObject = createLearningObject(metadata, content, attachments);
return learningObject;
}
function createLearningObject(metadata: LearningObjectMetadata, content: Buffer, attachments: { name: string; content: Buffer }[]): LearningObject {
const learningObjectRepo = getLearningObjectRepository();
const attachmentRepo = getAttachmentRepository();
const returnValue = {
callbackUrl: metadata.return_value?.callback_url ?? '',
callbackSchema: metadata.return_value?.callback_schema ? JSON.stringify(metadata.return_value.callback_schema) : '',
};
if (!metadata.target_ages || metadata.target_ages.length === 0) {
throw new BadRequestException('targetAgesMandatory');
}
const learningObject = learningObjectRepo.create({
admins: [],
available: metadata.available ?? true,
content: content,
contentType: metadata.content_type as DwengoContentType,
copyright: metadata.copyright ?? '',
description: metadata.description ?? '',
educationalGoals: metadata.educational_goals ?? [],
hruid: metadata.hruid,
keywords: metadata.keywords,
language: metadata.language,
license: metadata.license ?? '',
returnValue,
skosConcepts: metadata.skos_concepts ?? [],
teacherExclusive: metadata.teacher_exclusive,
title: metadata.title,
version: metadata.version,
estimatedTime: metadata.estimated_time ?? 1,
targetAges: metadata.target_ages ?? [],
difficulty: metadata.difficulty ?? 1,
uuid: v4(),
});
const attachmentEntities = attachments.map((it) =>
attachmentRepo.create({
name: it.name,
content: it.content,
mimeType: mime.lookup(it.name) || 'text/plain',
learningObject,
})
);
attachmentEntities.forEach((it) => {
learningObject.attachments.add(it);
});
return learningObject;
}
async function processMetadataJson(file: unzipper.File): Promise<LearningObjectMetadata> {
const buf = await file.buffer();
const content = buf.toString();
return JSON.parse(content);
}
async function processFile(file: unzipper.File): Promise<Buffer> {
return await file.buffer();
}

View file

@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js';
import learningObjectService from '../learning-objects/learning-object-service.js';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js';
import { getLastSubmissionForGroup, idFromLearningPathNode, isTransitionPossible } from './learning-path-personalization-util.js';
import {
FilteredLearningObject,
LearningObjectNode,
@ -16,6 +16,9 @@ import { Language } from '@dwengo-1/common/util/language';
import { Group } from '../../entities/assignments/group.entity';
import { Collection } from '@mikro-orm/core';
import { v4 } from 'uuid';
import { getLogger } from '../../logging/initalize.js';
const logger = getLogger();
/**
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
@ -38,8 +41,13 @@ async function getLearningObjectsForNodes(nodes: Collection<LearningPathNode>):
)
)
);
if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) {
throw new Error('At least one of the learning objects on this path could not be found.');
// Ignore all learning objects that cannot be found such that the rest of the learning path keeps working.
for (const [key, value] of nullableNodesToLearningObjects) {
if (value === null) {
logger.warn(`Learning object ${key.learningObjectHruid}/${key.language}/${key.version} not found!`);
nullableNodesToLearningObjects.delete(key);
}
}
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
}
@ -62,6 +70,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
// Convert the learning object notes as retrieved from the database into the expected response format-
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
const nodesActuallyOnPath = traverseLearningPath(convertedNodes);
return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order,
@ -71,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb
image: image,
title: learningPath.title,
nodes: convertedNodes,
num_nodes: learningPath.nodes.length,
num_nodes_left: convertedNodes.filter((it) => !it.done).length,
num_nodes: nodesActuallyOnPath.length,
num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length,
keywords: keywords.join(' '),
target_ages: targetAges,
max_age: Math.max(...targetAges),
@ -95,14 +105,22 @@ async function convertNode(
personalizedFor: Group | undefined,
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): Promise<LearningObjectNode> {
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null;
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningPathNode(node), personalizedFor) : null;
const transitions = node.transitions
.filter(
(trans) =>
!personalizedFor || // If we do not want a personalized learning path, keep all transitions
isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // Otherwise remove all transitions that aren't possible.
)
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects));
.map((trans, i) => {
try {
return convertTransition(trans, i, nodesToLearningObjects);
} catch (_: unknown) {
logger.error(`Transition could not be resolved: ${JSON.stringify(trans)}`);
return undefined; // Do not crash on invalid transitions, just ignore them so the rest of the learning path keeps working.
}
})
.filter((it) => it !== undefined);
return {
_id: learningObject.uuid,
language: learningObject.language,
@ -174,6 +192,29 @@ function convertTransition(
}
}
/**
* Start from the start node and then always take the first transition until there are no transitions anymore.
* Returns the traversed nodes as an array. (This effectively filters outs nodes that cannot be reached.)
*/
function traverseLearningPath(nodes: LearningObjectNode[]): LearningObjectNode[] {
const traversedNodes: LearningObjectNode[] = [];
let currentNode = nodes.find((it) => it.start_node);
while (currentNode) {
traversedNodes.push(currentNode);
const next = currentNode.transitions[0]?.next;
if (next) {
currentNode = nodes.find((it) => it.learningobject_hruid === next.hruid && it.language === next.language && it.version === next.version);
} else {
currentNode = undefined;
}
}
return traversedNodes;
}
/**
* Service providing access to data about learning paths from the database.
*/
@ -198,6 +239,15 @@ const databaseLearningPathProvider: LearningPathProvider = {
};
},
/**
* Returns all the learning paths which have the user with the given username as an administrator.
*/
async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> {
const repo = getLearningPathRepository();
const paths = await repo.findAllByAdminUsername(adminUsername);
return await Promise.all(paths.map(async (result, index) => convertLearningPath(result, index)));
},
/**
* Search learning paths in the database using the given search string.
*/

View file

@ -1,13 +1,35 @@
import { fetchWithLogging } from '../../util/api-helper.js';
import { fetchRemote } from '../../util/api-helper.js';
import { DWENGO_API_BASE } from '../../config.js';
import { LearningPathProvider } from './learning-path-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { Group } from '../../entities/assignments/group.entity.js';
import { getLastSubmissionForGroup, idFromLearningObjectNode } from './learning-path-personalization-util.js';
const logger: Logger = getLogger();
/**
* Adds progress information to the learning path. Modifies the learning path in-place.
* @param learningPath The learning path to add progress to.
* @param personalizedFor The group whose progress should be shown.
* @returns the modified learning path.
*/
async function addProgressToLearningPath(learningPath: LearningPath, personalizedFor: Group): Promise<LearningPath> {
await Promise.all(
learningPath.nodes.map(async (node) => {
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningObjectNode(node), personalizedFor) : null;
node.done = Boolean(lastSubmission);
})
);
learningPath.num_nodes = learningPath.nodes.length;
learningPath.num_nodes_left = learningPath.nodes.filter((it) => !it.done).length;
return learningPath;
}
const dwengoApiLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
async fetchLearningPaths(hruids: string[], language: string, source: string, personalizedFor: Group): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
@ -20,7 +42,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
const learningPaths = await fetchRemote<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
if (!learningPaths || learningPaths.length === 0) {
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
@ -32,19 +54,31 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
};
}
await Promise.all(learningPaths?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
return {
success: true,
source,
data: learningPaths,
};
},
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
const searchResults = await fetchRemote<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
if (searchResults) {
await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
}
return searchResults ?? [];
},
async getLearningPathsAdministratedBy(_adminUsername: string) {
// Dwengo API does not have the concept of admins, so we cannot filter by them.
return [];
},
};
export default dwengoApiLearningPathProvider;

View file

@ -5,18 +5,36 @@ import { getSubmissionRepository } from '../../data/repositories.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { JSONPath } from 'jsonpath-plus';
import { LearningObjectNode } from '@dwengo-1/common/interfaces/learning-content';
/**
* Returns the last submission for the learning object associated with the given node and for the group
*/
export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> {
export async function getLastSubmissionForGroup(learningObjectId: LearningObjectIdentifier, pathFor: Group): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = {
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
}
/**
* Creates a LearningObjectIdentifier describing the specified node.
*/
export function idFromLearningObjectNode(node: LearningObjectNode): LearningObjectIdentifier {
return {
hruid: node.learningobject_hruid,
language: node.language,
version: node.version,
};
}
/**
* Creates a LearningObjectIdentifier describing the specified node.
*/
export function idFromLearningPathNode(node: LearningPathNode): LearningObjectIdentifier {
return {
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
};
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
}
/**

View file

@ -15,4 +15,9 @@ export interface LearningPathProvider {
* Search learning paths in the data source using the given search string.
*/
searchLearningPaths(query: string, language: Language, personalizedFor?: Group): Promise<LearningPath[]>;
/**
* Get all learning paths which have the teacher with the given user as an administrator.
*/
getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]>;
}

View file

@ -1,7 +1,7 @@
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
import databaseLearningPathProvider from './database-learning-path-provider.js';
import { envVars, getEnvVar } from '../../util/envVars.js';
import { LearningObjectNode, LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { LearningObjectNode, LearningPath, LearningPathIdentifier, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { Language } from '@dwengo-1/common/util/language';
import { Group } from '../../entities/assignments/group.entity.js';
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
@ -12,6 +12,9 @@ import { base64ToArrayBuffer } from '../../util/base64-buffer-conversion.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { mapToTeacher } from '../../interfaces/teacher.js';
import { Collection } from '@mikro-orm/core';
import { NotFoundException } from '../../exceptions/not-found-exception.js';
import { BadRequestException } from '../../exceptions/bad-request-exception.js';
import learningObjectService from '../learning-objects/learning-object-service.js';
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
@ -43,27 +46,24 @@ export function mapToLearningPath(dto: LearningPath, adminsDto: TeacherDTO[]): L
const fromNode = nodes.find(
(it) => it.learningObjectHruid === nodeDto.learningobject_hruid && it.language === nodeDto.language && it.version === nodeDto.version
)!;
const transitions = nodeDto.transitions
.map((transDto, i) => {
const toNode = nodes.find(
(it) =>
it.learningObjectHruid === transDto.next.hruid &&
it.language === transDto.next.language &&
it.version === transDto.next.version
);
const transitions = nodeDto.transitions.map((transDto, i) => {
const toNode = nodes.find(
(it) =>
it.learningObjectHruid === transDto.next.hruid && it.language === transDto.next.language && it.version === transDto.next.version
);
if (toNode) {
return repo.createTransition({
transitionNumber: i,
node: fromNode,
next: toNode,
condition: transDto.condition ?? 'true',
});
}
return undefined;
})
.filter((it) => it)
.map((it) => it!);
if (toNode) {
return repo.createTransition({
transitionNumber: i,
node: fromNode,
next: toNode,
condition: transDto.condition ?? 'true',
});
}
throw new BadRequestException(
`Invalid transition destination: ${JSON.stringify(transDto.next)}: This learning object does not exist in this learning path.`
);
});
fromNode.transitions = new Collection<LearningPathTransition>(fromNode, transitions);
});
@ -105,6 +105,14 @@ const learningPathService = {
};
},
/**
* Fetch the learning paths administrated by the teacher with the given username.
*/
async getLearningPathsAdministratedBy(adminUsername: string): Promise<LearningPath[]> {
const providerResponses = await Promise.all(allProviders.map(async (provider) => provider.getLearningPathsAdministratedBy(adminUsername)));
return providerResponses.flat();
},
/**
* Search learning paths in the data source using the given search string.
*/
@ -119,11 +127,67 @@ const learningPathService = {
* Add a new learning path to the database.
* @param dto Learning path DTO from which the learning path will be created.
* @param admins Teachers who should become an admin of the learning path.
* @returns the created learning path.
*/
async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<void> {
async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise<LearningPathEntity> {
const repo = getLearningPathRepository();
const userContentPrefix = getEnvVar(envVars.UserContentPrefix);
if (!dto.hruid.startsWith(userContentPrefix)) {
dto.hruid = userContentPrefix + dto.hruid;
}
const path = mapToLearningPath(dto, admins);
await repo.save(path, { preventOverwrite: true });
// Verify that all specified learning objects actually exist.
const learningObjectsOnPath = await Promise.all(
path.nodes.map(async (node) =>
learningObjectService.getLearningObjectById({
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
})
)
);
if (learningObjectsOnPath.some((it) => !it)) {
throw new BadRequestException('pathContainsNonExistingLearningObjects');
}
try {
await repo.save(path, { preventOverwrite: true });
} catch (e: unknown) {
repo.getEntityManager().clear();
throw e;
}
return path;
},
/**
* Deletes the learning path with the given identifier from the database.
* @param id Identifier of the learning path to delete.
* @returns the deleted learning path.
*/
async deleteLearningPath(id: LearningPathIdentifier): Promise<LearningPathEntity> {
const repo = getLearningPathRepository();
const deletedPath = await repo.deleteByHruidAndLanguage(id.hruid, id.language);
if (deletedPath) {
return deletedPath;
}
throw new NotFoundException('No learning path with the given identifier found.');
},
/**
* Returns a list of the usernames of the administrators of the learning path with the given identifier.
* @param id The identifier of the learning path whose admins should be fetched.
*/
async getAdmins(id: LearningPathIdentifier): Promise<string[]> {
const repo = getLearningPathRepository();
const path = await repo.findByHruidAndLanguage(id.hruid, id.language);
if (!path) {
throw new NotFoundException('No learning path with the given identifier found.');
}
return path.admins.map((admin) => admin.username);
},
};

View file

@ -13,6 +13,7 @@ import { fetchStudent } from './students.js';
import { NotFoundException } from '../exceptions/not-found-exception.js';
import { FALLBACK_VERSION_NUM } from '../config.js';
import { fetchAssignment } from './assignments.js';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getQuestionsAboutLearningObjectInAssignment(
loId: LearningObjectIdentifier,
@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat
const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber);
if (!inGroup) {
throw new NotFoundException('Group with id and assignment not found');
}
if (!inGroup.members.contains(author)) {
throw new ConflictException('Author is not part of this group');
}
const question = await questionRepository.createQuestion({
loId,
author,
inGroup: inGroup!,
inGroup: inGroup,
content,
});

View file

@ -10,7 +10,7 @@ import { mapToClassDTO } from '../interfaces/class.js';
import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js';
import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js';
import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js';
import { getAllAssignments } from './assignments.js';
import { fetchAssignment } from './assignments.js';
import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js';
import { Student } from '../entities/users/student.entity.js';
@ -24,7 +24,9 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js';
import { Submission } from '../entities/assignments/submission.entity';
import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToUsername } from '../interfaces/user.js';
import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository();
@ -34,7 +36,7 @@ export async function getAllStudents(full: boolean): Promise<StudentDTO[] | stri
return users.map(mapToStudentDTO);
}
return users.map((user) => user.username);
return users.map(mapToUsername);
}
export async function fetchStudent(username: string): Promise<Student> {
@ -42,15 +44,14 @@ export async function fetchStudent(username: string): Promise<Student> {
const user = await studentRepository.findByUsername(username);
if (!user) {
throw new NotFoundException('Student with username not found');
throw new NotFoundException(`Student with username ${username} not found`);
}
return user;
}
export async function fetchStudents(usernames: string[]): Promise<Student[]> {
const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username)));
return members;
return await Promise.all(usernames.map(async (username) => await fetchStudent(username)));
}
export async function getStudent(username: string): Promise<StudentDTO> {
@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> {
const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: true });
return userData;
return mapToStudentDTO(newStudent);
}
export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> {
@ -101,10 +102,14 @@ export async function getStudentClasses(username: string, full: boolean): Promis
export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> {
const student = await fetchStudent(username);
const classRepository = getClassRepository();
const classes = await classRepository.findByStudent(student);
const groupRepository = getGroupRepository();
const groups = await groupRepository.findAllGroupsWithStudent(student);
const assignments = await Promise.all(groups.map(async (group) => await fetchAssignment(group.assignment.within.classId!, group.assignment.id!)));
return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat();
if (full) {
return assignments.map(mapToAssignmentDTO);
}
return assignments.map(mapToAssignmentDTOId);
}
export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> {

View file

@ -1,12 +1,13 @@
import { getAssignmentRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js';
import { getSubmissionRepository } from '../data/repositories.js';
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
import { NotFoundException } from '../exceptions/not-found-exception.js';
import { mapToSubmission, mapToSubmissionDTO } from '../interfaces/submission.js';
import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission';
import { fetchStudent } from './students.js';
import { getExistingGroupFromGroupDTO } from './groups.js';
import { fetchGroup, getExistingGroupFromGroupDTO } from './groups.js';
import { Submission } from '../entities/assignments/submission.entity.js';
import { Language } from '@dwengo-1/common/util/language';
import { fetchAssignment } from './assignments.js';
export async function fetchSubmission(loId: LearningObjectIdentifier, submissionNumber: number): Promise<Submission> {
const submissionRepository = getSubmissionRepository();
@ -64,15 +65,18 @@ export async function getSubmissionsForLearningObjectAndAssignment(
groupId?: number
): Promise<SubmissionDTO[]> {
const loId = new LearningObjectIdentifier(learningObjectHruid, language, version);
const assignment = await getAssignmentRepository().findByClassIdAndAssignmentId(classId, assignmentId);
let submissions: Submission[];
if (groupId !== undefined) {
const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, groupId);
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndGroup(loId, group!);
} else {
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!);
try {
let submissions: Submission[];
if (groupId !== undefined) {
const group = await fetchGroup(classId, assignmentId, groupId);
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndGroup(loId, group);
} else {
const assignment = await fetchAssignment(classId, assignmentId);
submissions = await getSubmissionRepository().findAllSubmissionsForLearningObjectAndAssignment(loId, assignment);
}
return submissions.map((s) => mapToSubmissionDTO(s));
} catch (_) {
return [];
}
return submissions.map((s) => mapToSubmissionDTO(s));
}

View file

@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise<Tea
throw new ConflictException('The teacher sending the invite is not part of the class');
}
if (cls.teachers.contains(receiver)) {
throw new ConflictException('The teacher receiving the invite is already part of the class');
}
const newInvitation = mapToInvitation(sender, receiver, cls);
await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true });

View file

@ -1,12 +1,5 @@
import {
getClassJoinRequestRepository,
getClassRepository,
getLearningObjectRepository,
getQuestionRepository,
getTeacherRepository,
} from '../data/repositories.js';
import { getAssignmentRepository, getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js';
import { mapToClassDTO } from '../interfaces/class.js';
import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js';
import { Teacher } from '../entities/users/teacher.entity.js';
import { fetchStudent } from './students.js';
@ -15,10 +8,6 @@ import { mapToStudentRequestDTO } from '../interfaces/student-request.js';
import { TeacherRepository } from '../data/users/teacher-repository.js';
import { ClassRepository } from '../data/classes/class-repository.js';
import { Class } from '../entities/classes/class.entity.js';
import { LearningObjectRepository } from '../data/content/learning-object-repository.js';
import { LearningObject } from '../entities/content/learning-object.entity.js';
import { QuestionRepository } from '../data/questions/question-repository.js';
import { Question } from '../entities/questions/question.entity.js';
import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js';
import { Student } from '../entities/users/student.entity.js';
import { NotFoundException } from '../exceptions/not-found-exception.js';
@ -26,10 +15,12 @@ import { addClassStudent, fetchClass, getClassStudentsDTO } from './classes.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { ClassDTO } from '@dwengo-1/common/interfaces/class';
import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js';
import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment';
import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> {
const teacherRepository: TeacherRepository = getTeacherRepository();
@ -38,7 +29,7 @@ export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | stri
if (full) {
return users.map(mapToTeacherDTO);
}
return users.map((user) => user.username);
return users.map(mapToUsername);
}
export async function fetchTeacher(username: string): Promise<Teacher> {
@ -57,7 +48,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> {
return mapToTeacherDTO(user);
}
export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> {
// TODO update parameter
export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise<TeacherDTO> {
const teacherRepository: TeacherRepository = getTeacherRepository();
const newTeacher = mapToTeacher(userData);
@ -101,6 +93,17 @@ export async function getClassesByTeacher(username: string, full: boolean): Prom
return classes.map((cls) => cls.id);
}
export async function getTeacherAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> {
const assignmentRepository = getAssignmentRepository();
const assignments = await assignmentRepository.findAllByResponsibleTeacher(username);
if (full) {
return assignments.map(mapToAssignmentDTO);
}
return assignments.map(mapToAssignmentDTOId);
}
export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[]> {
const classes: ClassDTO[] = await fetchClassesByTeacher(username);
@ -110,7 +113,9 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro
const classIds: string[] = classes.map((cls) => cls.id);
const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat();
const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId))))
.flat()
.filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index);
if (full) {
return students;
@ -119,28 +124,6 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro
return students.map((student) => student.username);
}
export async function getTeacherQuestions(username: string, full: boolean): Promise<QuestionDTO[] | QuestionId[]> {
const teacher: Teacher = await fetchTeacher(username);
// Find all learning objects that this teacher manages
const learningObjectRepository: LearningObjectRepository = getLearningObjectRepository();
const learningObjects: LearningObject[] = await learningObjectRepository.findAllByTeacher(teacher);
if (!learningObjects || learningObjects.length === 0) {
return [];
}
// Fetch all questions related to these learning objects
const questionRepository: QuestionRepository = getQuestionRepository();
const questions: Question[] = await questionRepository.findAllByLearningObjects(learningObjects);
if (full) {
return questions.map(mapToQuestionDTO);
}
return questions.map(mapToQuestionDTOId);
}
export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> {
const classRepository: ClassRepository = getClassRepository();
const cls: Class | null = await classRepository.findById(classId);

View file

@ -1,28 +1,66 @@
import axios, { AxiosRequestConfig } from 'axios';
import { getLogger, Logger } from '../logging/initalize.js';
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
import { getCacheClient } from '../caching.js';
import { envVars, getEnvVar, getNumericEnvVar } from './envVars.js';
import { createHash } from 'crypto';
const cacheClient = await getCacheClient();
const logger: Logger = getLogger();
const runMode: string = getEnvVar(envVars.RunMode);
const prefix: string = getEnvVar(envVars.CacheKeyPrefix);
interface Options {
params?: Record<string, unknown> | LearningObjectIdentifier;
query?: Record<string, unknown>;
responseType?: 'json' | 'text';
}
/**
* Utility function to fetch data from an API endpoint with error handling.
* Utility function to fetch data from an API endpoint with error handling and caching.
* Logs errors but does NOT throw exceptions to keep the system running.
*
* @param url The API endpoint to fetch from.
* @param description A short description of what is being fetched (for logging).
* @param options Contains further options such as params (the query params) and responseType (whether the response
* should be parsed as JSON ("json") or whether it should be returned as plain text ("text")
* @param cacheTTL Time-to-live for the cache in seconds (default: 60 seconds).
* @returns The response data if successful, or null if an error occurs.
*/
export async function fetchWithLogging<T>(
url: string,
description: string,
options?: {
params?: Record<string, unknown> | LearningObjectIdentifier;
query?: Record<string, unknown>;
responseType?: 'json' | 'text';
export async function fetchRemote<T>(url: string, description: string, options?: Options, cacheTTL?: number): Promise<T | null> {
if (runMode !== 'dev' && !runMode.includes('test') && cacheClient !== undefined) {
return fetchWithCache<T>(url, description, options, cacheTTL);
}
): Promise<T | null> {
getLogger().debug(`🔄 INFO: Bypassing cache for ${description} at "${url}".`);
return fetchWithLogging(url, description, options);
}
async function fetchWithCache<T>(url: string, description: string, options?: Options, cacheTTL?: number): Promise<T | null> {
// Combine the URL and parameters to create a unique cache key.
// NOTE Using a hash function to keep the key short, since Memcached has a limit on key size
const urlWithParams = `${url}${options?.params ? JSON.stringify(options.params) : ''}`;
const hashedUrl = createHash('sha256').update(urlWithParams).digest('hex');
const key = `${prefix}:${hashedUrl}`;
const cachedData = await cacheClient.get(key);
if (cachedData?.value) {
logger.debug(`✅ INFO: Cache hit for ${description} at "${url}" (key: "${key}")`);
return JSON.parse(cachedData.value.toString()) as T;
}
logger.debug(`🔄 INFO: Cache miss for ${description} at "${url}". Fetching data...`);
const response = await fetchWithLogging<T>(url, description, options);
const ttl = cacheTTL || getNumericEnvVar(envVars.CacheTTL);
await cacheClient.set(key, JSON.stringify(response), { expires: ttl });
logger.debug(`✅ INFO: Cached response for ${description} at "${url}" for ${ttl} seconds. (key: "${key}")`);
return response;
}
async function fetchWithLogging<T>(url: string, description: string, options?: Options): Promise<T | null> {
try {
const config: AxiosRequestConfig = options || {};
const response = await axios.get<T>(url, config);

View file

@ -5,6 +5,7 @@ const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_';
const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_';
const CORS_PREFIX = PREFIX + 'CORS_';
const LOGGING_PREFIX = PREFIX + 'LOGGING_';
const CACHE_PREFIX = PREFIX + 'CACHE_';
interface EnvVar {
key: string;
@ -39,6 +40,11 @@ export const envVars: Record<string, EnvVar> = {
LogLevel: { key: LOGGING_PREFIX + 'LEVEL', defaultValue: 'info' },
LokiHost: { key: LOGGING_PREFIX + 'LOKI_HOST', defaultValue: 'http://localhost:3102' },
CacheHost: { key: CACHE_PREFIX + 'HOST' },
CachePort: { key: CACHE_PREFIX + 'PORT', defaultValue: 11211 },
CacheTTL: { key: CACHE_PREFIX + 'TTL', defaultValue: 60 * 60 * 24 }, // 24 hours
CacheKeyPrefix: { key: CACHE_PREFIX + 'KEY_PREFIX', defaultValue: 'dwengo' },
} as const;
/**
@ -56,7 +62,7 @@ export function getEnvVar(envVar: EnvVar): string {
} else if (envVar.required) {
throw new Error(`Missing environment variable: ${envVar.key}`);
} else {
return String(envVar.defaultValue) || '';
return envVar.defaultValue !== undefined ? String(envVar.defaultValue) || '' : '';
}
}

View file

@ -5,6 +5,7 @@ import { Language } from '@dwengo-1/common/util/language';
import { getAllAnswersHandler, getAnswerHandler, updateAnswerHandler } from '../../src/controllers/answers';
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getAnswer02 } from '../test_assets/questions/answers.testdata';
describe('Questions controllers', () => {
let req: Partial<Request>;
@ -24,9 +25,14 @@ describe('Questions controllers', () => {
});
it('Get answers list', async () => {
const a = getAnswer02();
req = {
params: { hruid: 'id05', version: '1', seq: '2' },
query: { lang: Language.English, full: 'true' },
params: {
hruid: a.toQuestion.learningObjectHruid,
version: a.toQuestion.learningObjectVersion.toString(),
seq: a.toQuestion.sequenceNumber!.toString(),
},
query: { lang: a.toQuestion.learningObjectLanguage, full: 'true' },
};
await getAllAnswersHandler(req as Request, res as Response);
@ -38,9 +44,15 @@ describe('Questions controllers', () => {
});
it('Get answer', async () => {
const a = getAnswer02();
req = {
params: { hruid: 'id05', version: '1', seq: '2', seqAnswer: '2' },
query: { lang: Language.English, full: 'true' },
params: {
hruid: a.toQuestion.learningObjectHruid,
version: a.toQuestion.learningObjectVersion.toString(),
seq: a.toQuestion.sequenceNumber!.toString(),
seqAnswer: a.sequenceNumber!.toString(),
},
query: { lang: a.toQuestion.learningObjectLanguage, full: 'true' },
};
await getAnswerHandler(req as Request, res as Response);
@ -68,11 +80,19 @@ describe('Questions controllers', () => {
await expect(async () => getAnswerHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException);
});
it('Update question', async () => {
const newContent = 'updated question';
it('Update answer', async () => {
const a = getAnswer02();
const q = a.toQuestion;
const newContent = 'updated answer';
req = {
params: { hruid: 'id05', version: '1', seq: '2', seqAnswer: '2' },
query: { lang: Language.English },
params: {
hruid: q.learningObjectHruid,
version: q.learningObjectVersion.toString(),
seq: q.sequenceNumber!.toString(),
seqAnswer: a.sequenceNumber!.toString(),
},
query: { lang: q.learningObjectLanguage },
body: { content: newContent },
};

View file

@ -0,0 +1,76 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { Request, Response } from 'express';
import { getAssignmentHandler, getAllAssignmentsHandler, getAssignmentsSubmissionsHandler } from '../../src/controllers/assignments.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
import { getAssignment01 } from '../test_assets/assignments/assignments.testdata';
function createRequestObject(
classid: string,
assignmentid: string
): {
query: { full: string };
params: { classid: string; id: string };
} {
return {
params: {
classid: classid,
id: assignmentid,
},
query: {
full: 'true',
},
};
}
describe('Assignment controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('return error non-existing assignment', async () => {
req = createRequestObject('doesnotexist', '43000'); // Should not exist
await expect(async () => getAssignmentHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return an assignment', async () => {
const assignment = getAssignment01();
req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString());
await getAssignmentHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignment: expect.anything() }));
});
it('should return a list of assignments', async () => {
req = createRequestObject(getClass01().classId as string, 'irrelevant');
await getAllAssignmentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() }));
});
it('should return a list of submissions for an assignment', async () => {
const assignment = getAssignment01();
req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString());
await getAssignmentsSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
});

View file

@ -0,0 +1,123 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import {
createClassHandler,
deleteClassHandler,
getAllClassesHandler,
getClassHandler,
getClassStudentsHandler,
getTeacherInvitationsHandler,
} from '../../src/controllers/classes.js';
import { Request, Response } from 'express';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
describe('Class controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('create and delete class', async () => {
req = {
body: { displayName: 'coole_nieuwe_klas' },
};
await createClassHandler(req as Request, res as Response);
const result = jsonMock.mock.lastCall?.[0];
// Console.log('class', result.class);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() }));
req = {
params: { id: result.class.id },
};
await deleteClassHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() }));
});
it('Error class not found', async () => {
req = {
params: { id: 'doesnotexist' },
};
await expect(async () => getClassHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('Error create a class without name', async () => {
req = {
body: {},
};
await expect(async () => createClassHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException);
});
it('return list of students', async () => {
req = {
params: { id: getClass01().classId as string },
query: {},
};
await getClassStudentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ students: expect.anything() }));
});
it('Error students on a non-existent class', async () => {
req = {
params: { id: 'doesnotexist' },
query: {},
};
await expect(async () => getClassStudentsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 200 and a list of teacher-invitations', async () => {
const classId = getClass01().classId as string;
req = {
params: { id: classId },
query: {},
};
await getTeacherInvitationsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() }));
});
it('Error teacher-invitations on a non-existent class', async () => {
req = {
params: { id: 'doesnotexist' },
query: {},
};
await expect(async () => getTeacherInvitationsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return a list of classes', async () => {
req = {
query: {},
};
await getAllClassesHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ classes: expect.anything() }));
});
});

View file

@ -0,0 +1,140 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { Request, Response } from 'express';
import {
createGroupHandler,
deleteGroupHandler,
getAllGroupsHandler,
getGroupHandler,
getGroupSubmissionsHandler,
} from '../../src/controllers/groups.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
import { getAssignment01, getAssignment02 } from '../test_assets/assignments/assignments.testdata';
import { getTestGroup01 } from '../test_assets/assignments/groups.testdata';
function createRequestObject(
classid: string,
assignmentid: string,
groupNumber: string
): {
query: { full: string };
params: { classid: string; groupid: string; assignmentid: string };
} {
return {
params: {
classid: classid,
assignmentid: assignmentid,
groupid: groupNumber,
},
query: {
full: 'true',
},
};
}
describe('Group controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('Error not found on a non-existing group', async () => {
req = {
params: {
classid: 'id01',
assignmentid: '1',
groupid: '154981', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 404 not found on a non-existing assignment', async () => {
req = {
params: {
classid: 'id01',
assignmentid: '1000', // Should not exist
groupid: '42000', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 404 not found ont a non-existing class', async () => {
req = {
params: {
classid: 'doesnotexist', // Should not exist
assignmentid: '1000', // Should not exist
groupid: '42000', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return an existing group', async () => {
const group = getTestGroup01();
const classId = getClass01().classId as string;
req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString());
await getGroupHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() }));
});
it('Create and delete', async () => {
const assignment = getAssignment02();
const classId = assignment.within.classId as string;
req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1');
req.body = {
members: ['Noordkaap', 'DireStraits'],
};
await createGroupHandler(req as Request, res as Response);
await deleteGroupHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() }));
});
it('should return the submissions for a group', async () => {
const group = getTestGroup01();
const classId = getClass01().classId as string;
req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString());
await getGroupSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
it('should return a list of groups for an assignment', async () => {
const assignment = getAssignment01();
const classId = assignment.within.classId as string;
req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1');
await getAllGroupsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ groups: expect.anything() }));
});
});

View file

@ -5,6 +5,7 @@ import { getAllQuestionsHandler, getQuestionHandler, updateQuestionHandler } fro
import { Language } from '@dwengo-1/common/util/language';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { getQuestion01 } from '../test_assets/questions/questions.testdata';
describe('Questions controllers', () => {
let req: Partial<Request>;
@ -24,9 +25,10 @@ describe('Questions controllers', () => {
});
it('Get question list', async () => {
const q = getQuestion01();
req = {
params: { hruid: 'id05', version: '1' },
query: { lang: Language.English, full: 'true' },
params: { hruid: q.learningObjectHruid, version: q.learningObjectVersion.toString() },
query: { lang: q.learningObjectLanguage, full: 'true' },
};
await getAllQuestionsHandler(req as Request, res as Response);
@ -38,9 +40,10 @@ describe('Questions controllers', () => {
});
it('Get question', async () => {
const q = getQuestion01();
req = {
params: { hruid: 'id05', version: '1', seq: '1' },
query: { lang: Language.English, full: 'true' },
params: { hruid: q.learningObjectHruid, version: q.learningObjectVersion.toString(), seq: q.sequenceNumber!.toString() },
query: { lang: q.learningObjectLanguage, full: 'true' },
};
await getQuestionHandler(req as Request, res as Response);
@ -51,8 +54,9 @@ describe('Questions controllers', () => {
});
it('Get question with fallback sequence number and version', async () => {
const q = getQuestion01();
req = {
params: { hruid: 'id05' },
params: { hruid: q.learningObjectHruid },
query: { lang: Language.English, full: 'true' },
};
@ -99,10 +103,11 @@ describe('Questions controllers', () => {
*/
it('Update question', async () => {
const q = getQuestion01();
const newContent = 'updated question';
req = {
params: { hruid: 'id05', version: '1', seq: '1' },
query: { lang: Language.English },
params: { hruid: q.learningObjectHruid, version: q.learningObjectVersion.toString(), seq: q.sequenceNumber!.toString() },
query: { lang: q.learningObjectLanguage },
body: { content: newContent },
};

View file

@ -14,13 +14,19 @@ import {
getStudentRequestsHandler,
deleteClassJoinRequestHandler,
getStudentRequestHandler,
getStudentAssignmentsHandler,
} from '../../src/controllers/students.js';
import { TEST_STUDENTS } from '../test_assets/users/students.testdata.js';
import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception.js';
import { BadRequestException } from '../../src/exceptions/bad-request-exception.js';
import { ConflictException } from '../../src/exceptions/conflict-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { getClass02 } from '../test_assets/classes/classes.testdata.js';
import { getClassJoinRequest02 } from '../test_assets/classes/class-join-requests.testdata.js';
import { getTestGroup01 } from '../test_assets/assignments/groups.testdata.js';
import { getSubmission01 } from '../test_assets/assignments/submission.testdata.js';
import { getQuestion01 } from '../test_assets/questions/questions.testdata.js';
describe('Student controllers', () => {
let req: Partial<Request>;
@ -40,7 +46,8 @@ describe('Student controllers', () => {
});
it('Get student', async () => {
req = { params: { username: 'DireStraits' } };
const student = getDireStraits();
req = { params: { username: student.username } };
await getStudentHandler(req as Request, res as Response);
@ -82,11 +89,12 @@ describe('Student controllers', () => {
});
it('Create duplicate student', async () => {
const student = getDireStraits();
req = {
body: {
username: 'DireStraits',
firstName: 'dupe',
lastName: 'dupe',
username: student.username,
firstName: student.firstName,
lastName: student.lastName,
},
};
@ -110,14 +118,17 @@ describe('Student controllers', () => {
// Check is DireStraits is part of the student list
const studentUsernames = result.students.map((s: StudentDTO) => s.username);
expect(studentUsernames).toContain('DireStraits');
expect(studentUsernames).toContain(TEST_STUDENTS[0].username);
// Check length, +1 because of create
expect(result.students).toHaveLength(TEST_STUDENTS.length);
});
it('Student classes', async () => {
req = { params: { username: 'DireStraits' }, query: {} };
const class_ = getClass02();
const member = class_.students[0];
req = { params: { username: member.username }, query: {} };
await getStudentClassesHandler(req as Request, res as Response);
@ -128,7 +139,9 @@ describe('Student controllers', () => {
});
it('Student groups', async () => {
req = { params: { username: 'DireStraits' }, query: {} };
const group = getTestGroup01();
const member = group.members[0];
req = { params: { username: member.username }, query: {} };
await getStudentGroupsHandler(req as Request, res as Response);
@ -138,8 +151,22 @@ describe('Student controllers', () => {
expect(result.groups).to.have.length.greaterThan(0);
});
it('Student assignments', async () => {
const group = getTestGroup01();
const member = group.members[0];
req = { params: { username: member.username }, query: {} };
await getStudentAssignmentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() }));
const result = jsonMock.mock.lastCall?.[0];
expect(result.assignments).to.have.length.greaterThan(0);
});
it('Student submissions', async () => {
req = { params: { username: 'DireStraits' }, query: { full: 'true' } };
const submission = getSubmission01();
req = { params: { username: submission.submitter.username }, query: { full: 'true' } };
await getStudentSubmissionsHandler(req as Request, res as Response);
@ -150,7 +177,8 @@ describe('Student controllers', () => {
});
it('Student questions', async () => {
req = { params: { username: 'DireStraits' }, query: { full: 'true' } };
const question = getQuestion01();
req = { params: { username: question.author.username }, query: { full: 'true' } };
await getStudentQuestionsHandler(req as Request, res as Response);
@ -167,8 +195,9 @@ describe('Student controllers', () => {
});
it('Get join requests by student', async () => {
const jr = getClassJoinRequest02();
req = {
params: { username: 'PinkFloyd' },
params: { username: jr.requester.username },
};
await getStudentRequestsHandler(req as Request, res as Response);
@ -185,8 +214,9 @@ describe('Student controllers', () => {
});
it('Get join request by student and class', async () => {
const jr = getClassJoinRequest02();
req = {
params: { username: 'PinkFloyd', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { username: jr.requester.username, classId: jr.class.classId! },
};
await getStudentRequestHandler(req as Request, res as Response);
@ -199,9 +229,11 @@ describe('Student controllers', () => {
});
it('Create and delete join request', async () => {
const student = getTheDoors();
const class_ = getClass02();
req = {
params: { username: 'TheDoors' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { username: student.username },
body: { classId: class_.classId! },
};
await createStudentRequestHandler(req as Request, res as Response);
@ -209,7 +241,7 @@ describe('Student controllers', () => {
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() }));
req = {
params: { username: 'TheDoors', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { username: student.username, classId: class_.classId! },
};
await deleteClassJoinRequestHandler(req as Request, res as Response);
@ -220,18 +252,21 @@ describe('Student controllers', () => {
});
it('Create join request student already in class error', async () => {
const student = getNoordkaap();
const class_ = getClass02();
req = {
params: { username: 'Noordkaap' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { username: student.username },
body: { classId: class_.classId! },
};
await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException);
});
it('Create join request duplicate', async () => {
const jr = getClassJoinRequest02();
req = {
params: { username: 'Tool' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { username: jr.requester.username },
body: { classId: jr.class.classId! },
};
await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException);

View file

@ -0,0 +1,61 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { getSubmissionHandler, getAllSubmissionsHandler } from '../../src/controllers/submissions.js';
import { Request, Response } from 'express';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass02 } from '../test_assets/classes/classes.testdata';
function createRequestObject(
hruid: string,
submissionNumber: string
): {
query: { language: string; version: string };
params: { hruid: string; id: string };
} {
return {
params: {
hruid: hruid,
id: submissionNumber,
},
query: {
language: 'en',
version: '1',
},
};
}
describe('Submission controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('error submission is not found', async () => {
req = createRequestObject('id01', '1000000');
await expect(async () => getSubmissionHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return a list of submissions for a learning object', async () => {
req = createRequestObject(getClass02().classId as string, 'irrelevant');
await getAllSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
});

View file

@ -12,6 +12,9 @@ import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invit
import { getClassHandler } from '../../src/controllers/classes';
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { getTeacherInvitation01 } from '../test_assets/classes/teacher-invitations.testdata.js';
import { getLimpBizkit, getTestleerkracht1 } from '../test_assets/users/teachers.testdata.js';
import { getClass02 } from '../test_assets/classes/classes.testdata.js';
describe('Teacher controllers', () => {
let req: Partial<Request>;
@ -31,7 +34,8 @@ describe('Teacher controllers', () => {
});
it('Get teacher invitations by', async () => {
req = { params: { username: 'LimpBizkit' }, query: { sent: 'true' } };
const ti = getTeacherInvitation01();
req = { params: { username: ti.sender.username }, query: { sent: 'true' } };
await getAllInvitationsHandler(req as Request, res as Response);
@ -43,7 +47,8 @@ describe('Teacher controllers', () => {
});
it('Get teacher invitations for', async () => {
req = { params: { username: 'FooFighters' }, query: { by: 'false' } };
const ti = getTeacherInvitation01();
req = { params: { username: ti.receiver.username }, query: { by: 'false' } };
await getAllInvitationsHandler(req as Request, res as Response);
@ -54,10 +59,13 @@ describe('Teacher controllers', () => {
});
it('Create and delete invitation', async () => {
const sender = getLimpBizkit();
const receiver = getTestleerkracht1();
const class_ = getClass02();
const body = {
sender: 'LimpBizkit',
receiver: 'testleerkracht1',
class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89',
sender: sender.username,
receiver: receiver.username,
class: class_.classId,
} as TeacherInvitationData;
req = { body };
@ -65,9 +73,9 @@ describe('Teacher controllers', () => {
req = {
params: {
sender: 'LimpBizkit',
receiver: 'testleerkracht1',
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89',
sender: sender.username,
receiver: receiver.username,
classId: class_.classId!,
},
body: { accepted: 'false' },
};
@ -76,11 +84,12 @@ describe('Teacher controllers', () => {
});
it('Get invitation', async () => {
const ti = getTeacherInvitation01();
req = {
params: {
sender: 'LimpBizkit',
receiver: 'FooFighters',
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89',
sender: ti.sender.username,
receiver: ti.receiver.username,
classId: ti.class.classId!,
},
};
await getInvitationHandler(req as Request, res as Response);
@ -97,10 +106,11 @@ describe('Teacher controllers', () => {
});
it('Accept invitation', async () => {
const ti = getTeacherInvitation01();
const body = {
sender: 'LimpBizkit',
receiver: 'FooFighters',
class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89',
sender: ti.sender.username,
receiver: ti.receiver.username,
class: ti.class.classId,
} as TeacherInvitationData;
req = { body };
@ -111,13 +121,13 @@ describe('Teacher controllers', () => {
req = {
params: {
id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89',
id: ti.class.classId!,
},
};
await getClassHandler(req as Request, res as Response);
const result = jsonMock.mock.lastCall?.[0];
expect(result.class.teachers).toContain('FooFighters');
expect(result.class.teachers).toContain(ti.receiver.username);
});
});

View file

@ -15,8 +15,10 @@ import {
import { BadRequestException } from '../../src/exceptions/bad-request-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { getStudentRequestsHandler } from '../../src/controllers/students.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { getClassHandler } from '../../src/controllers/classes';
import { getFooFighters, getTestleerkracht1 } from '../test_assets/users/teachers.testdata.js';
import { getClass02 } from '../test_assets/classes/classes.testdata.js';
import { getClassJoinRequest01 } from '../test_assets/classes/class-join-requests.testdata.js';
describe('Teacher controllers', () => {
let req: Partial<Request>;
@ -36,7 +38,8 @@ describe('Teacher controllers', () => {
});
it('Get teacher', async () => {
req = { params: { username: 'FooFighters' } };
const teacher = getFooFighters();
req = { params: { username: teacher.username } };
await getTeacherHandler(req as Request, res as Response);
@ -77,12 +80,13 @@ describe('Teacher controllers', () => {
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ teacher: expect.objectContaining(teacher) }));
});
it('Create duplicate student', async () => {
it('Create duplicate teacher', async () => {
const teacher = getFooFighters();
req = {
body: {
username: 'FooFighters',
firstName: 'Dave',
lastName: 'Grohl',
username: teacher.username,
firstName: teacher.firstName,
lastName: teacher.lastName,
},
};
@ -96,7 +100,7 @@ describe('Teacher controllers', () => {
});
it('Teacher list', async () => {
req = { query: { full: 'true' } };
req = { query: { full: 'false' } };
await getAllTeachersHandler(req as Request, res as Response);
@ -104,21 +108,22 @@ describe('Teacher controllers', () => {
const result = jsonMock.mock.lastCall?.[0];
const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username);
expect(teacherUsernames).toContain('testleerkracht1');
const teacher = getTestleerkracht1();
expect(result.teachers).toContain(teacher.username);
expect(result.teachers).toHaveLength(5);
expect(result.teachers.length).toBeGreaterThan(0);
});
it('Deleting non-existent student', async () => {
it('Deleting non-existent teacher', async () => {
req = { params: { username: 'doesnotexist' } };
await expect(async () => deleteTeacherHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('Get teacher classes', async () => {
const class_ = getClass02();
req = {
params: { username: 'testleerkracht1' },
params: { username: class_.teachers[0].username },
query: { full: 'true' },
};
@ -131,9 +136,10 @@ describe('Teacher controllers', () => {
expect(result.classes.length).toBeGreaterThan(0);
});
it('Get teacher students', async () => {
it('Get teacher teachers', async () => {
const teacher = getTestleerkracht1();
req = {
params: { username: 'testleerkracht1' },
params: { username: teacher.username },
query: { full: 'true' },
};
@ -146,30 +152,10 @@ describe('Teacher controllers', () => {
expect(result.students.length).toBeGreaterThan(0);
});
/*
It('Get teacher questions', async () => {
req = {
params: { username: 'FooFighters' },
query: { full: 'true' },
};
await getTeacherQuestionHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ questions: expect.anything() }));
const result = jsonMock.mock.lastCall?.[0];
// console.log('[TEACHER QUESTIONS]', result.questions);
expect(result.questions.length).toBeGreaterThan(0);
// TODO fix
});
*/
it('Get join requests by class', async () => {
const jr = getClassJoinRequest01();
req = {
params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { classId: jr.class.classId! },
};
await getStudentJoinRequestHandler(req as Request, res as Response);
@ -182,8 +168,9 @@ describe('Teacher controllers', () => {
});
it('Update join request status', async () => {
const jr = getClassJoinRequest01();
req = {
params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', studentUsername: 'PinkFloyd' },
params: { classId: jr.class.classId!, studentUsername: jr.requester.username },
body: { accepted: 'true' },
};
@ -192,7 +179,7 @@ describe('Teacher controllers', () => {
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() }));
req = {
params: { username: 'PinkFloyd' },
params: { username: jr.requester.username },
};
await getStudentRequestsHandler(req as Request, res as Response);
@ -201,11 +188,11 @@ describe('Teacher controllers', () => {
expect(status).toBeTruthy();
req = {
params: { id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' },
params: { id: jr.class.classId! },
};
await getClassHandler(req as Request, res as Response);
const students: string[] = jsonMock.mock.lastCall?.[0].class.students;
expect(students).contains('PinkFloyd');
expect(students).contains(jr.requester.username);
});
});

View file

@ -1,48 +1,60 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getAssignmentRepository } from '../../../src/data/repositories';
import { getClass02 } from '../../test_assets/classes/classes.testdata';
import { getAssignment02, getAssignment03 } from '../../test_assets/assignments/assignments.testdata';
import { getTestleerkracht1 } from '../../test_assets/users/teachers.testdata';
describe('AssignmentRepository', () => {
let assignmentRepository: AssignmentRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
assignmentRepository = getAssignmentRepository();
classRepository = getClassRepository();
});
it('should return the requested assignment', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89');
const assignment = await assignmentRepository.findByClassAndId(class_!, 21001);
const class_ = getClass02();
const usedAssignment = getAssignment02();
const assignment = await assignmentRepository.findByClassAndId(class_, 21001);
expect(assignment).toBeTruthy();
expect(assignment!.title).toBe('tool');
expect(assignment!.description).toBe(usedAssignment.description);
expect(assignment!.id).toBe(usedAssignment.id);
expect(assignment!.learningPathHruid).toBe(usedAssignment.learningPathHruid);
expect(assignment!.within.classId).toBe(usedAssignment.within.classId);
expect(assignment!.title).toBe(usedAssignment.title);
});
it('should return all assignments for a class', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89');
const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!);
const class_ = getClass02();
const usedAssignment = getAssignment02();
const assignments = await assignmentRepository.findAllAssignmentsInClass(class_);
expect(assignments).toBeTruthy();
expect(assignments).toHaveLength(1);
expect(assignments[0].title).toBe('tool');
const assignment = assignments[0];
expect(assignment.description).toBe(usedAssignment.description);
expect(assignment.id).toBe(usedAssignment.id);
expect(assignment.learningPathHruid).toBe(usedAssignment.learningPathHruid);
expect(assignment.within.classId).toBe(usedAssignment.within.classId);
expect(assignment.title).toBe(usedAssignment.title);
});
it('should find all by username of the responsible teacher', async () => {
const result = await assignmentRepository.findAllByResponsibleTeacher('testleerkracht1');
const teacher = getTestleerkracht1();
const result = await assignmentRepository.findAllByResponsibleTeacher(teacher.username);
const resultIds = result.map((it) => it.id).sort((a, b) => (a ?? 0) - (b ?? 0));
expect(resultIds).toEqual([21000, 21002, 21003, 21004]);
});
it('should not find removed assignment', async () => {
const class_ = await classRepository.findById('id01');
await assignmentRepository.deleteByClassAndId(class_!, 3);
const deleted = getAssignment03();
await assignmentRepository.deleteByClassAndId(deleted.within, deleted.id!);
const assignment = await assignmentRepository.findByClassAndId(class_!, 3);
const assignment = await assignmentRepository.findByClassAndId(deleted.within, deleted.id!);
expect(assignment).toBeNull();
});

View file

@ -1,48 +1,56 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { GroupRepository } from '../../../src/data/assignments/group-repository';
import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getGroupRepository } from '../../../src/data/repositories';
import { getAssignment01, getAssignment02 } from '../../test_assets/assignments/assignments.testdata';
import { getTestGroup01, getTestGroup02, getTestGroup03 } from '../../test_assets/assignments/groups.testdata';
import { getDireStraits, getNoordkaap } from '../../test_assets/users/students.testdata';
describe('GroupRepository', () => {
let groupRepository: GroupRepository;
let assignmentRepository: AssignmentRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
groupRepository = getGroupRepository();
assignmentRepository = getAssignmentRepository();
classRepository = getClassRepository();
});
it('should return the requested group', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const assignment = getAssignment01();
const usedGroup = getTestGroup01();
const member1 = getNoordkaap();
const member2 = getDireStraits();
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, usedGroup.groupNumber!);
expect(group).toBeTruthy();
expect(group?.groupNumber).toBe(usedGroup.groupNumber);
expect(group!.members[0].username).toBeOneOf([member1.username, member2.username]);
expect(group!.members[1].username).toBeOneOf([member1.username, member2.username]);
expect(group!.assignment.id).toBe(usedGroup.assignment.id);
});
it('should return all groups for assignment', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const assignment = getAssignment01();
const gr1 = getTestGroup01();
const gr2 = getTestGroup02();
const gr3 = getTestGroup03();
const groups = await groupRepository.findAllGroupsForAssignment(assignment!);
const groups = await groupRepository.findAllGroupsForAssignment(assignment);
expect(groups).toBeTruthy();
expect(groups).toHaveLength(3);
expect(groups[0].groupNumber).toBeOneOf([gr1.groupNumber, gr2.groupNumber, gr3.groupNumber]);
expect(groups[1].groupNumber).toBeOneOf([gr1.groupNumber, gr2.groupNumber, gr3.groupNumber]);
expect(groups[2].groupNumber).toBeOneOf([gr1.groupNumber, gr2.groupNumber, gr3.groupNumber]);
});
it('should not find removed group', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89');
const assignment = await assignmentRepository.findByClassAndId(class_!, 21001);
const assignment = getAssignment02();
const deleted = getTestGroup01();
await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001);
await groupRepository.deleteByAssignmentAndGroupNumber(assignment, deleted.groupNumber!);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, deleted.groupNumber!);
expect(group).toBeNull();
});

View file

@ -1,80 +1,75 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { SubmissionRepository } from '../../../src/data/assignments/submission-repository';
import {
getAssignmentRepository,
getClassRepository,
getGroupRepository,
getStudentRepository,
getSubmissionRepository,
} from '../../../src/data/repositories';
import { getSubmissionRepository } from '../../../src/data/repositories';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '@dwengo-1/common/util/language';
import { StudentRepository } from '../../../src/data/users/student-repository';
import { GroupRepository } from '../../../src/data/assignments/group-repository';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { Submission } from '../../../src/entities/assignments/submission.entity';
import { Class } from '../../../src/entities/classes/class.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata';
import { getSubmission01, getSubmission02, getSubmission07, getSubmission08 } from '../../test_assets/assignments/submission.testdata';
import { getAssignment01 } from '../../test_assets/assignments/assignments.testdata';
import { getTestGroup02 } from '../../test_assets/assignments/groups.testdata';
describe('SubmissionRepository', () => {
let submissionRepository: SubmissionRepository;
let studentRepository: StudentRepository;
let groupRepository: GroupRepository;
let assignmentRepository: AssignmentRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
submissionRepository = getSubmissionRepository();
studentRepository = getStudentRepository();
groupRepository = getGroupRepository();
assignmentRepository = getAssignmentRepository();
classRepository = getClassRepository();
});
it('should find the requested submission', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1);
const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1);
const usedSubmission = getSubmission01();
const id = new LearningObjectIdentifier(
usedSubmission.learningObjectHruid,
usedSubmission.learningObjectLanguage,
usedSubmission.learningObjectVersion
);
const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, usedSubmission.submissionNumber!);
expect(submission).toBeTruthy();
expect(submission?.content).toBe('sub1');
expect(submission?.content).toBe(usedSubmission.content);
expect(submission?.submissionNumber).toBe(usedSubmission.submissionNumber);
expect(submission?.submitter.username).toBe(usedSubmission.submitter.username);
});
it('should find the most recent submission for a student', async () => {
const id = new LearningObjectIdentifier('id02', Language.English, 1);
const student = await studentRepository.findByUsername('Noordkaap');
const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, student!);
const usedSubmission = getSubmission02();
const id = new LearningObjectIdentifier(
usedSubmission.learningObjectHruid,
usedSubmission.learningObjectLanguage,
usedSubmission.learningObjectVersion
);
const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, usedSubmission.submitter);
expect(submission).toBeTruthy();
expect(submission?.submissionTime.getDate()).toBe(25);
expect(submission?.submissionTime).toStrictEqual(usedSubmission.submissionTime);
});
it('should find the most recent submission for a group', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1);
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001);
const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!);
const usedSubmission = getSubmission02();
const id = new LearningObjectIdentifier(
usedSubmission.learningObjectHruid,
usedSubmission.learningObjectLanguage,
usedSubmission.learningObjectVersion
);
const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, usedSubmission.onBehalfOf);
expect(submission).toBeTruthy();
expect(submission?.submissionTime.getDate()).toBe(25);
expect(submission?.submissionTime).toStrictEqual(usedSubmission.submissionTime);
});
let clazz: Class | null;
let assignment: Assignment | null;
let loId: LearningObjectIdentifier;
it('should find all submissions for a certain learning object and assignment', async () => {
clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
assignment = await assignmentRepository.findByClassAndId(clazz!, 21000);
loId = {
hruid: 'id02',
language: Language.English,
version: 1,
const usedSubmission = getSubmission08();
const assignment = getAssignment01();
const loId = {
hruid: usedSubmission.learningObjectHruid,
language: usedSubmission.learningObjectLanguage,
version: usedSubmission.learningObjectVersion,
};
const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment!);
const result = await submissionRepository.findAllSubmissionsForLearningObjectAndAssignment(loId, assignment);
sortSubmissions(result);
expect(result).toHaveLength(3);
@ -93,8 +88,15 @@ describe('SubmissionRepository', () => {
});
it('should find only the submissions for a certain learning object and assignment made for the given group', async () => {
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21002);
const result = await submissionRepository.findAllSubmissionsForLearningObjectAndGroup(loId, group!);
const group = getTestGroup02();
const usedSubmission = getSubmission08();
const loId = {
hruid: usedSubmission.learningObjectHruid,
language: usedSubmission.learningObjectLanguage,
version: usedSubmission.learningObjectVersion,
};
const result = await submissionRepository.findAllSubmissionsForLearningObjectAndGroup(loId, group);
expect(result).toHaveLength(1);
@ -107,10 +109,11 @@ describe('SubmissionRepository', () => {
});
it('should not find a deleted submission', async () => {
const usedSubmission = getSubmission07();
const id = new LearningObjectIdentifier(testLearningObject01.hruid, testLearningObject01.language, testLearningObject01.version);
await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1);
await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, usedSubmission.submissionNumber!);
const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1);
const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, usedSubmission.submissionNumber!);
expect(submission).toBeNull();
});

View file

@ -1,44 +1,49 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join-request-repository';
import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories';
import { StudentRepository } from '../../../src/data/users/student-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClassJoinRequestRepository } from '../../../src/data/repositories';
import { getPinkFloyd, getSmashingPumpkins } from '../../test_assets/users/students.testdata';
import { getClass02, getClass03 } from '../../test_assets/classes/classes.testdata';
import { getClassJoinRequest01, getClassJoinRequest02, getClassJoinRequest03 } from '../../test_assets/classes/class-join-requests.testdata';
describe('ClassJoinRequestRepository', () => {
let classJoinRequestRepository: ClassJoinRequestRepository;
let studentRepository: StudentRepository;
let cassRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
classJoinRequestRepository = getClassJoinRequestRepository();
studentRepository = getStudentRepository();
cassRepository = getClassRepository();
});
it('should list all requests from student to join classes', async () => {
const student = await studentRepository.findByUsername('PinkFloyd');
const requests = await classJoinRequestRepository.findAllRequestsBy(student!);
const studentUsed = getPinkFloyd();
const jr1 = getClassJoinRequest01();
const jr2 = getClassJoinRequest03();
const requests = await classJoinRequestRepository.findAllRequestsBy(studentUsed);
expect(requests).toBeTruthy();
expect(requests).toHaveLength(2);
expect(requests[0].class.classId!).toBeOneOf([jr1.class.classId!, jr2.class.classId!]);
expect(requests[1].class.classId!).toBeOneOf([jr1.class.classId!, jr2.class.classId!]);
});
it('should list all requests to a single class', async () => {
const class_ = await cassRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89');
const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!);
const class_ = getClass02();
const jr1 = getClassJoinRequest01();
const jr2 = getClassJoinRequest02();
const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_);
expect(requests).toBeTruthy();
expect(requests).toHaveLength(2);
expect(requests[0].class.classId).toBeOneOf([jr1.class.classId, jr2.class.classId]);
expect(requests[1].class.classId).toBeOneOf([jr1.class.classId, jr2.class.classId]);
});
it('should not find a removed request', async () => {
const student = await studentRepository.findByUsername('SmashingPumpkins');
const class_ = await cassRepository.findById('80dcc3e0-1811-4091-9361-42c0eee91cfa');
await classJoinRequestRepository.deleteBy(student!, class_!);
const studentUsed = getSmashingPumpkins();
const class_ = getClass03();
await classJoinRequestRepository.deleteBy(studentUsed, class_);
const request = await classJoinRequestRepository.findAllRequestsBy(student!);
const request = await classJoinRequestRepository.findAllRequestsBy(studentUsed);
expect(request).toHaveLength(0);
});

View file

@ -2,6 +2,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { setupTestApp } from '../../setup-tests';
import { getClassRepository } from '../../../src/data/repositories';
import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata';
import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata';
describe('ClassRepository', () => {
let classRepository: ClassRepository;
@ -18,16 +20,18 @@ describe('ClassRepository', () => {
});
it('should return requested class', async () => {
const classVar = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
const expected = getClass01();
const classVar = await classRepository.findById(expected.classId!);
expect(classVar).toBeTruthy();
expect(classVar?.displayName).toBe('class01');
expect(classVar?.displayName).toBe(expected.displayName);
});
it('class should be gone after deletion', async () => {
await classRepository.deleteById('33d03536-83b8-4880-9982-9bbf2f908ddf');
const deleted = getClass04();
await classRepository.deleteById(deleted.classId!);
const classVar = await classRepository.findById('33d03536-83b8-4880-9982-9bbf2f908ddf');
const classVar = await classRepository.findById(deleted.classId!);
expect(classVar).toBeNull();
});

View file

@ -1,53 +1,62 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { getClassRepository, getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories';
import { getTeacherInvitationRepository } from '../../../src/data/repositories';
import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository';
import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getFooFighters, getLimpBizkit } from '../../test_assets/users/teachers.testdata';
import { getTeacherInvitation01, getTeacherInvitation02, getTeacherInvitation03 } from '../../test_assets/classes/teacher-invitations.testdata';
import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata';
describe('ClassRepository', () => {
let teacherInvitationRepository: TeacherInvitationRepository;
let teacherRepository: TeacherRepository;
let classRepository: ClassRepository;
beforeAll(async () => {
await setupTestApp();
teacherInvitationRepository = getTeacherInvitationRepository();
teacherRepository = getTeacherRepository();
classRepository = getClassRepository();
});
it('should return all invitations from a teacher', async () => {
const teacher = await teacherRepository.findByUsername('LimpBizkit');
const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!);
const teacher = getLimpBizkit();
const ti1 = getTeacherInvitation01();
const ti2 = getTeacherInvitation02();
const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher);
expect(invitations).toBeTruthy();
expect(invitations).toHaveLength(2);
expect(invitations[0].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]);
expect(invitations[1].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]);
});
it('should return all invitations for a teacher', async () => {
const teacher = await teacherRepository.findByUsername('FooFighters');
const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!);
const teacher = getFooFighters();
const ti1 = getTeacherInvitation01();
const ti2 = getTeacherInvitation03();
const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher);
expect(invitations).toBeTruthy();
expect(invitations).toHaveLength(2);
expect(invitations[0].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]);
expect(invitations[1].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]);
});
it('should return all invitations for a class', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89');
const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!);
const class_ = getClass02();
const ti1 = getTeacherInvitation01();
const ti2 = getTeacherInvitation02();
const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_);
expect(invitations).toBeTruthy();
expect(invitations).toHaveLength(2);
expect(invitations[0].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]);
expect(invitations[1].class.classId).toBeOneOf([ti1.class.classId, ti2.class.classId]);
});
it('should not find a removed invitation', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
const sender = await teacherRepository.findByUsername('FooFighters');
const receiver = await teacherRepository.findByUsername('LimpBizkit');
await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!);
const class_ = getClass01();
const sender = getFooFighters();
const receiver = getLimpBizkit();
await teacherInvitationRepository.deleteBy(class_, sender, receiver);
const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!);
const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender);
expect(invitation).toHaveLength(0);
});

View file

@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests.js';
import { getAttachmentRepository } from '../../../src/data/repositories.js';
import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js';
import { testLearningObject02 } from '../../test_assets/content/learning-objects.testdata';
import { getAttachment01 } from '../../test_assets/content/attachments.testdata.js';
describe('AttachmentRepository', () => {
let attachmentRepository: AttachmentRepository;
@ -13,10 +13,11 @@ describe('AttachmentRepository', () => {
});
it('should return the requested attachment', async () => {
const usedAttachment = getAttachment01();
const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName(
testLearningObject02.hruid,
testLearningObject02.language,
'attachment01'
usedAttachment.learningObject.hruid,
usedAttachment.learningObject.language,
usedAttachment.name
);
expect(attachment).toBeTruthy();

View file

@ -37,7 +37,8 @@ describe('LearningObjectRepository', () => {
let newerExample: LearningObject;
it('should allow a learning object with the same id except a different version to be added', async () => {
const testLearningObject01Newer = structuredClone(testLearningObject01);
// StructeredClone failed on teacher, this copies all fields to a json object
const testLearningObject01Newer = { ...testLearningObject01 };
testLearningObject01Newer.version = 10;
testLearningObject01Newer.title += ' (nieuw)';
testLearningObject01Newer.uuid = v4();

View file

@ -1,48 +1,39 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { AnswerRepository } from '../../../src/data/questions/answer-repository';
import { getAnswerRepository, getQuestionRepository, getTeacherRepository } from '../../../src/data/repositories';
import { QuestionRepository } from '../../../src/data/questions/question-repository';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '@dwengo-1/common/util/language';
import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { getAnswerRepository } from '../../../src/data/repositories';
import { getQuestion01, getQuestion02 } from '../../test_assets/questions/questions.testdata';
import { getAnswer01, getAnswer02, getAnswer03 } from '../../test_assets/questions/answers.testdata';
import { getFooFighters } from '../../test_assets/users/teachers.testdata';
describe('AnswerRepository', () => {
let answerRepository: AnswerRepository;
let questionRepository: QuestionRepository;
let teacherRepository: TeacherRepository;
beforeAll(async () => {
await setupTestApp();
answerRepository = getAnswerRepository();
questionRepository = getQuestionRepository();
teacherRepository = getTeacherRepository();
});
it('should find all answers to a question', async () => {
const id = new LearningObjectIdentifier('id05', Language.English, 1);
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
const question = getQuestion02();
const a1 = getAnswer01();
const a2 = getAnswer02();
const question = questions.find((it) => it.sequenceNumber === 2);
const answers = await answerRepository.findAllAnswersToQuestion(question!);
const answers = await answerRepository.findAllAnswersToQuestion(question);
expect(answers).toBeTruthy();
expect(answers).toHaveLength(2);
expect(answers[0].content).toBeOneOf(['answer', 'answer2']);
expect(answers[1].content).toBeOneOf(['answer', 'answer2']);
expect(answers[0].content).toBeOneOf([a1.content, a2.content]);
expect(answers[1].content).toBeOneOf([a1.content, a2.content]);
});
it('should create an answer to a question', async () => {
const teacher = await teacherRepository.findByUsername('FooFighters');
const id = new LearningObjectIdentifier('id05', Language.English, 1);
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
const question = questions[0];
const teacher = getFooFighters();
const question = getQuestion01();
await answerRepository.createAnswer({
toQuestion: question,
author: teacher!,
author: teacher,
content: 'created answer',
});
@ -54,12 +45,11 @@ describe('AnswerRepository', () => {
});
it('should not find a removed answer', async () => {
const id = new LearningObjectIdentifier('id04', Language.English, 1);
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
const deleted = getAnswer03();
await answerRepository.removeAnswerByQuestionAndSequenceNumber(questions[0], 1);
await answerRepository.removeAnswerByQuestionAndSequenceNumber(deleted.toQuestion, deleted.sequenceNumber!);
const emptyList = await answerRepository.findAllAnswersToQuestion(questions[0]);
const emptyList = await answerRepository.findAllAnswersToQuestion(deleted.toQuestion);
expect(emptyList).toHaveLength(0);
});

View file

@ -1,69 +1,72 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { setupTestApp } from '../../setup-tests';
import { QuestionRepository } from '../../../src/data/questions/question-repository';
import {
getAssignmentRepository,
getClassRepository,
getGroupRepository,
getQuestionRepository,
getStudentRepository,
} from '../../../src/data/repositories';
import { StudentRepository } from '../../../src/data/users/student-repository';
import { getQuestionRepository } from '../../../src/data/repositories';
import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier';
import { Language } from '@dwengo-1/common/util/language';
import { Question } from '../../../src/entities/questions/question.entity';
import { Class } from '../../../src/entities/classes/class.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { testLearningObject03, testLearningObject05 } from '../../test_assets/content/learning-objects.testdata';
import { getQuestion01, getQuestion02, getQuestion03, getQuestion05, getQuestion06 } from '../../test_assets/questions/questions.testdata';
import { getNoordkaap, getTool } from '../../test_assets/users/students.testdata';
import { getAssignment01 } from '../../test_assets/assignments/assignments.testdata';
import { getTestGroup01 } from '../../test_assets/assignments/groups.testdata';
describe('QuestionRepository', () => {
let questionRepository: QuestionRepository;
let studentRepository: StudentRepository;
beforeAll(async () => {
await setupTestApp();
questionRepository = getQuestionRepository();
studentRepository = getStudentRepository();
});
it('should return all questions part of the given learning object', async () => {
const id = new LearningObjectIdentifier('id05', Language.English, 1);
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
const q1 = getQuestion01();
const q2 = getQuestion02();
const q3 = getQuestion05();
const q4 = getQuestion06();
const loid = {
hruid: q1.learningObjectHruid,
language: q1.learningObjectLanguage,
version: q1.learningObjectVersion,
} as LearningObjectIdentifier;
const questions = await questionRepository.findAllQuestionsAboutLearningObject(loid);
expect(questions).toBeTruthy();
expect(questions).toHaveLength(4);
expect(questions[0].sequenceNumber!).toBeOneOf([q1.sequenceNumber, q2.sequenceNumber, q3.sequenceNumber, q4.sequenceNumber]);
expect(questions[1].sequenceNumber!).toBeOneOf([q1.sequenceNumber, q2.sequenceNumber, q3.sequenceNumber, q4.sequenceNumber]);
expect(questions[2].sequenceNumber!).toBeOneOf([q1.sequenceNumber, q2.sequenceNumber, q3.sequenceNumber, q4.sequenceNumber]);
expect(questions[3].sequenceNumber!).toBeOneOf([q1.sequenceNumber, q2.sequenceNumber, q3.sequenceNumber, q4.sequenceNumber]);
});
it('should create new question', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1);
const student = await studentRepository.findByUsername('Noordkaap');
const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000);
const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001);
const id = {
hruid: testLearningObject03.hruid,
language: testLearningObject03.language,
version: testLearningObject03.version,
} as LearningObjectIdentifier;
const student = getNoordkaap();
const group = getTestGroup01();
await questionRepository.createQuestion({
loId: id,
inGroup: group!,
author: student!,
inGroup: group,
author: student,
content: 'question?',
});
const question = await questionRepository.findAllQuestionsAboutLearningObject(id);
expect(question).toBeTruthy();
expect(question).toHaveLength(1);
expect(question[0].content).toBe('question?');
});
let clazz: Class | null;
let assignment: Assignment | null;
let loId: LearningObjectIdentifier;
it('should find all questions for a certain learning object and assignment', async () => {
clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9');
assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000);
loId = {
hruid: 'id05',
language: Language.English,
version: 1,
const assignment = getAssignment01();
const loId = {
hruid: testLearningObject05.hruid,
language: testLearningObject05.language,
version: testLearningObject05.version,
};
const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!);
const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment);
sortQuestions(result);
expect(result).toHaveLength(3);
@ -84,7 +87,14 @@ describe('QuestionRepository', () => {
});
it("should find only the questions for a certain learning object and assignment asked by the user's group", async () => {
const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment!, 'Tool');
const loId = {
hruid: testLearningObject05.hruid,
language: testLearningObject05.language,
version: testLearningObject05.version,
};
const assignment = getAssignment01();
const result = await questionRepository.findAllQuestionsAboutLearningObjectInAssignment(loId, assignment, getTool().username);
// (student Tool is in group #2)
expect(result).toHaveLength(1);
@ -99,12 +109,17 @@ describe('QuestionRepository', () => {
});
it('should not find removed question', async () => {
const id = new LearningObjectIdentifier('id04', Language.English, 1);
await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1);
const usedQuestion = getQuestion03();
const id = {
hruid: usedQuestion.learningObjectHruid,
language: usedQuestion.learningObjectLanguage,
version: usedQuestion.learningObjectVersion,
} as LearningObjectIdentifier;
await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, usedQuestion.sequenceNumber!);
const question = await questionRepository.findAllQuestionsAboutLearningObject(id);
const questions = await questionRepository.findAllQuestionsAboutLearningObject(id);
expect(question).toHaveLength(0);
expect(questions).toHaveLength(0);
});
});

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