Merge pull request #15 from SELab-2/chore/database-setup

chore(backend): Database setup
This commit is contained in:
Gerald Schmittinger 2025-02-28 10:46:18 +01:00 committed by GitHub
commit 44a433e8c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2634 additions and 46 deletions

6
.gitignore vendored
View file

@ -43,6 +43,9 @@ build/Release
node_modules/
jspm_packages/
# package-lock.json
backend/package-lock.json
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
@ -641,7 +644,8 @@ FodyWeavers.xsd
.LSOverride
# Icon must end with two \r
Icon
Icon
# Thumbnails
._*

View file

@ -0,0 +1,6 @@
DWENGO_PORT=3000
DWENGO_DB_HOST=localhost
DWENGO_DB_PORT=5431
DWENGO_DB_USERNAME=postgres
DWENGO_DB_PASSWORD=postgres
DWENGO_DB_UPDATE=true

3
backend/.env.test Normal file
View file

@ -0,0 +1,3 @@
PORT=3000
DWENGO_DB_UPDATE=true
DWENGO_DB_NAME=":memory:"

View file

@ -1,2 +0,0 @@
-- Create the database
CREATE DATABASE dwengo;

View file

@ -11,18 +11,19 @@
"format": "prettier --write src/",
"format-check": "prettier --check src/",
"lint": "eslint . --fix",
"test:unit": "vitest --run"
"test:unit": "vitest"
},
"dependencies": {
"@mikro-orm/core": "^6.4.6",
"@mikro-orm/postgresql": "^6.4.6",
"@mikro-orm/reflection": "^6.4.6",
"@types/js-yaml": "^4.0.9",
"@mikro-orm/sqlite": "^6.4.6",
"@mikro-orm/reflection": "^6.4.6",
"@mikro-orm/core": "6.4.6",
"@mikro-orm/postgresql": "6.4.6",
"@mikro-orm/sqlite": "6.4.6",
"@mikro-orm/reflection": "6.4.6",
"dotenv": "^16.4.7",
"express": "^5.0.1",
"js-yaml": "^4.1.0"
"uuid": "^11.1.0",
"express": "^5.0.1",
"js-yaml": "^4.1.0",
"@types/js-yaml": "^4.0.9",
},
"devDependencies": {
"@mikro-orm/cli": "^6.4.6",

View file

@ -1,5 +1,7 @@
import express, { Express, Response } from 'express';
import initORM from './orm.js';
import { initORM } from './orm.js';
import { EnvVars, getNumericEnvVar } from './util/envvars.js';
import themeRoutes from './routes/themes.js';
import studentRouter from './routes/student';
@ -11,7 +13,7 @@ import questionRouter from './routes/question';
import loginRouter from './routes/login';
const app: Express = express();
const port: string | number = process.env.PORT || 3000;
const port: string | number = getNumericEnvVar(EnvVars.Port);
// TODO Replace with Express routes
@ -39,4 +41,4 @@ async function startServer() {
});
}
startServer();
await startServer();

View file

@ -0,0 +1,18 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Assignment } from '../../entities/assignments/assignment.entity.js';
import { Class } from '../../entities/classes/class.entity.js';
export class AssignmentRepository extends DwengoEntityRepository<Assignment> {
public findByClassAndId(
within: Class,
id: number
): Promise<Assignment | null> {
return this.findOne({ within: within, id: id });
}
public findAllAssignmentsInClass(within: Class): Promise<Assignment[]> {
return this.findAll({ where: { within: within } });
}
public deleteByClassAndId(within: Class, id: number): Promise<void> {
return this.deleteWhere({ within: within, id: id });
}
}

View file

@ -0,0 +1,29 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Group } from '../../entities/assignments/group.entity.js';
import { Assignment } from '../../entities/assignments/assignment.entity.js';
export class GroupRepository extends DwengoEntityRepository<Group> {
public findByAssignmentAndGroupNumber(
assignment: Assignment,
groupNumber: number
): Promise<Group | null> {
return this.findOne({
assignment: assignment,
groupNumber: groupNumber,
});
}
public findAllGroupsForAssignment(
assignment: Assignment
): Promise<Group[]> {
return this.findAll({ where: { assignment: assignment } });
}
public deleteByAssignmentAndGroupNumber(
assignment: Assignment,
groupNumber: number
) {
return this.deleteWhere({
assignment: assignment,
groupNumber: groupNumber,
});
}
}

View file

@ -0,0 +1,61 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Group } from '../../entities/assignments/group.entity.js';
import { Submission } from '../../entities/assignments/submission.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Student } from '../../entities/users/student.entity.js';
export class SubmissionRepository extends DwengoEntityRepository<Submission> {
public findSubmissionByLearningObjectAndSubmissionNumber(
loId: LearningObjectIdentifier,
submissionNumber: number
): Promise<Submission | null> {
return this.findOne({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
submissionNumber: submissionNumber,
});
}
public findMostRecentSubmissionForStudent(
loId: LearningObjectIdentifier,
submitter: Student
): Promise<Submission | null> {
return this.findOne(
{
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
submitter: submitter,
},
{ orderBy: { submissionNumber: 'DESC' } }
);
}
public findMostRecentSubmissionForGroup(
loId: LearningObjectIdentifier,
group: Group
): Promise<Submission | null> {
return this.findOne(
{
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
onBehalfOf: group,
},
{ orderBy: { submissionNumber: 'DESC' } }
);
}
public deleteSubmissionByLearningObjectAndSubmissionNumber(
loId: LearningObjectIdentifier,
submissionNumber: number
): Promise<void> {
return this.deleteWhere({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
submissionNumber: submissionNumber,
});
}
}

View file

@ -0,0 +1,16 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Class } from '../../entities/classes/class.entity.js';
import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js';
import { Student } from '../../entities/users/student.entity.js';
export class ClassJoinRequestRepository extends DwengoEntityRepository<ClassJoinRequest> {
public findAllRequestsBy(requester: Student): Promise<ClassJoinRequest[]> {
return this.findAll({ where: { requester: requester } });
}
public findAllOpenRequestsTo(clazz: Class): Promise<ClassJoinRequest[]> {
return this.findAll({ where: { class: clazz } });
}
public deleteBy(requester: Student, clazz: Class): Promise<void> {
return this.deleteWhere({ requester: requester, class: clazz });
}
}

View file

@ -0,0 +1,11 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Class } from '../../entities/classes/class.entity.js';
export class ClassRepository extends DwengoEntityRepository<Class> {
public findById(id: string): Promise<Class | null> {
return this.findOne({ classId: id });
}
public deleteById(id: string): Promise<void> {
return this.deleteWhere({ classId: id });
}
}

View file

@ -0,0 +1,31 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Class } from '../../entities/classes/class.entity.js';
import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js';
import { Teacher } from '../../entities/users/teacher.entity.js';
export class TeacherInvitationRepository extends DwengoEntityRepository<TeacherInvitation> {
public findAllInvitationsForClass(
clazz: Class
): Promise<TeacherInvitation[]> {
return this.findAll({ where: { class: clazz } });
}
public findAllInvitationsBy(sender: Teacher): Promise<TeacherInvitation[]> {
return this.findAll({ where: { sender: sender } });
}
public findAllInvitationsFor(
receiver: Teacher
): Promise<TeacherInvitation[]> {
return this.findAll({ where: { receiver: receiver } });
}
public deleteBy(
clazz: Class,
sender: Teacher,
receiver: Teacher
): Promise<void> {
return this.deleteWhere({
sender: sender,
receiver: receiver,
class: clazz,
});
}
}

View file

@ -0,0 +1,16 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Attachment } from '../../entities/content/attachment.entity.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
export class AttachmentRepository extends DwengoEntityRepository<Attachment> {
public findByLearningObjectAndNumber(
learningObject: LearningObject,
sequenceNumber: number
) {
return this.findOne({
learningObject: learningObject,
sequenceNumber: sequenceNumber,
});
}
// This repository is read-only for now since creating own learning object is an extension feature.
}

View file

@ -0,0 +1,16 @@
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';
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
public findByIdentifier(
identifier: LearningObjectIdentifier
): Promise<LearningObject | null> {
return this.findOne({
hruid: identifier.hruid,
language: identifier.language,
version: identifier.version,
});
}
// This repository is read-only for now since creating own learning object is an extension feature.
}

View file

@ -0,0 +1,13 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { LearningPath } from '../../entities/content/learning-path.entity.js';
import { Language } from '../../entities/content/language.js';
export class LearningPathRepository extends DwengoEntityRepository<LearningPath> {
public findByHruidAndLanguage(
hruid: string,
language: Language
): Promise<LearningPath | null> {
return this.findOne({ hruid: hruid, language: language });
}
// This repository is read-only for now since creating own learning object is an extension feature.
}

View file

@ -0,0 +1,19 @@
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
export abstract class DwengoEntityRepository<
T extends object,
> extends EntityRepository<T> {
public async save(entity: T) {
let em = this.getEntityManager();
em.persist(entity);
await em.flush();
}
public async deleteWhere(query: FilterQuery<T>) {
let toDelete = await this.findOne(query);
let em = this.getEntityManager();
if (toDelete) {
em.remove(toDelete);
await em.flush();
}
}
}

View file

@ -0,0 +1,33 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Answer } from '../../entities/questions/answer.entity.js';
import { Question } from '../../entities/questions/question.entity.js';
import { Teacher } from '../../entities/users/teacher.entity.js';
export class AnswerRepository extends DwengoEntityRepository<Answer> {
public createAnswer(answer: {
toQuestion: Question;
author: Teacher;
content: string;
}): Promise<Answer> {
let answerEntity = new Answer();
answerEntity.toQuestion = answer.toQuestion;
answerEntity.author = answer.author;
answerEntity.content = answer.content;
return this.insert(answerEntity);
}
public findAllAnswersToQuestion(question: Question): Promise<Answer[]> {
return this.findAll({
where: { toQuestion: question },
orderBy: { sequenceNumber: 'ASC' },
});
}
public removeAnswerByQuestionAndSequenceNumber(
question: Question,
sequenceNumber: number
): Promise<void> {
return this.deleteWhere({
toQuestion: question,
sequenceNumber: sequenceNumber,
});
}
}

View file

@ -0,0 +1,45 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Question } from '../../entities/questions/question.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Student } from '../../entities/users/student.entity.js';
export class QuestionRepository extends DwengoEntityRepository<Question> {
public createQuestion(question: {
loId: LearningObjectIdentifier;
author: Student;
content: string;
}): Promise<Question> {
let questionEntity = new Question();
questionEntity.learningObjectHruid = question.loId.hruid;
questionEntity.learningObjectLanguage = question.loId.language;
questionEntity.learningObjectVersion = question.loId.version;
questionEntity.author = question.author;
questionEntity.content = question.content;
return this.insert(questionEntity);
}
public findAllQuestionsAboutLearningObject(
loId: LearningObjectIdentifier
): Promise<Question[]> {
return this.findAll({
where: {
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
},
orderBy: {
sequenceNumber: 'ASC',
},
});
}
public removeQuestionByLearningObjectAndSequenceNumber(
loId: LearningObjectIdentifier,
sequenceNumber: number
): Promise<void> {
return this.deleteWhere({
learningObjectHruid: loId.hruid,
learningObjectLanguage: loId.language,
learningObjectVersion: loId.version,
sequenceNumber: sequenceNumber,
});
}
}

View file

@ -0,0 +1,119 @@
import {
AnyEntity,
EntityManager,
EntityName,
EntityRepository,
} from '@mikro-orm/core';
import { forkEntityManager } from '../orm.js';
import { StudentRepository } from './users/student-repository.js';
import { Student } from '../entities/users/student.entity.js';
import { User } from '../entities/users/user.entity.js';
import { UserRepository } from './users/user-repository.js';
import { Teacher } from '../entities/users/teacher.entity.js';
import { TeacherRepository } from './users/teacher-repository.js';
import { Class } from '../entities/classes/class.entity.js';
import { ClassRepository } from './classes/class-repository.js';
import { ClassJoinRequest } from '../entities/classes/class-join-request.entity.js';
import { ClassJoinRequestRepository } from './classes/class-join-request-repository.js';
import { TeacherInvitationRepository } from './classes/teacher-invitation-repository.js';
import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js';
import { Assignment } from '../entities/assignments/assignment.entity.js';
import { AssignmentRepository } from './assignments/assignment-repository.js';
import { GroupRepository } from './assignments/group-repository.js';
import { Group } from '../entities/assignments/group.entity.js';
import { Submission } from '../entities/assignments/submission.entity.js';
import { SubmissionRepository } from './assignments/submission-repository.js';
import { Question } from '../entities/questions/question.entity.js';
import { QuestionRepository } from './questions/question-repository.js';
import { Answer } from '../entities/questions/answer.entity.js';
import { AnswerRepository } from './questions/answer-repository.js';
import { LearningObject } from '../entities/content/learning-object.entity.js';
import { LearningObjectRepository } from './content/learning-object-repository.js';
import { LearningPath } from '../entities/content/learning-path.entity.js';
import { LearningPathRepository } from './content/learning-path-repository.js';
import { AttachmentRepository } from './content/attachment-repository.js';
import { Attachment } from '../entities/content/attachment.entity.js';
let entityManager: EntityManager | undefined;
/**
* Execute all the database operations within the function f in a single transaction.
*/
export function transactional<T>(f: () => Promise<T>) {
entityManager?.transactional(f);
}
function repositoryGetter<T extends AnyEntity, R extends EntityRepository<T>>(
entity: EntityName<T>
): () => R {
let cachedRepo: R | undefined;
return (): R => {
if (!cachedRepo) {
if (!entityManager) {
entityManager = forkEntityManager();
}
cachedRepo = entityManager.getRepository(entity) as R;
}
return cachedRepo;
};
}
/* Users */
export const getUserRepository = repositoryGetter<User, UserRepository>(User);
export const getStudentRepository = repositoryGetter<
Student,
StudentRepository
>(Student);
export const getTeacherRepository = repositoryGetter<
Teacher,
TeacherRepository
>(Teacher);
/* Classes */
export const getClassRepository = repositoryGetter<Class, ClassRepository>(
Class
);
export const getClassJoinRequestRepository = repositoryGetter<
ClassJoinRequest,
ClassJoinRequestRepository
>(ClassJoinRequest);
export const getTeacherInvitationRepository = repositoryGetter<
TeacherInvitation,
TeacherInvitationRepository
>(TeacherInvitationRepository);
/* Assignments */
export const getAssignmentRepository = repositoryGetter<
Assignment,
AssignmentRepository
>(Assignment);
export const getGroupRepository = repositoryGetter<Group, GroupRepository>(
Group
);
export const getSubmissionRepository = repositoryGetter<
Submission,
SubmissionRepository
>(Submission);
/* Questions and answers */
export const getQuestionRepository = repositoryGetter<
Question,
QuestionRepository
>(Question);
export const getAnswerRepository = repositoryGetter<Answer, AnswerRepository>(
Answer
);
/* Learning content */
export const getLearningObjectRepository = repositoryGetter<
LearningObject,
LearningObjectRepository
>(LearningObject);
export const getLearningPathRepository = repositoryGetter<
LearningPath,
LearningPathRepository
>(LearningPath);
export const getAttachmentRepository = repositoryGetter<
Attachment,
AttachmentRepository
>(Assignment);

View file

@ -0,0 +1,11 @@
import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { Student } from '../../entities/users/student.entity.js';
export class StudentRepository extends DwengoEntityRepository<Student> {
public findByUsername(username: string): Promise<Student | null> {
return this.findOne({ username: username });
}
public deleteByUsername(username: string): Promise<void> {
return this.deleteWhere({ username: username });
}
}

View file

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

View file

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

View file

@ -0,0 +1,35 @@
import {
Entity,
Enum,
ManyToOne,
OneToMany,
PrimaryKey,
Property,
} from '@mikro-orm/core';
import { Class } from '../classes/class.entity.js';
import { Group } from './group.entity.js';
import { Language } from '../content/language.js';
@Entity()
export class Assignment {
@ManyToOne({ entity: () => Class, primary: true })
within!: Class;
@PrimaryKey({ type: 'number' })
id!: number;
@Property({ type: 'string' })
title!: string;
@Property({ type: 'text' })
description!: string;
@Property({ type: 'string' })
learningPathHruid!: string;
@Enum({ items: () => Language })
learningPathLanguage!: Language;
@OneToMany({ entity: () => Group, mappedBy: 'assignment' })
groups!: Group[];
}

View file

@ -0,0 +1,15 @@
import { Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core';
import { Assignment } from './assignment.entity.js';
import { Student } from '../users/student.entity.js';
@Entity()
export class Group {
@ManyToOne({ entity: () => Assignment, primary: true })
assignment!: Assignment;
@PrimaryKey({ type: 'integer' })
groupNumber!: number;
@ManyToMany({ entity: () => Student })
members!: Student[];
}

View file

@ -0,0 +1,31 @@
import { Student } from '../users/student.entity.js';
import { Group } from './group.entity.js';
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Language } from '../content/language.js';
@Entity()
export class Submission {
@PrimaryKey({ type: 'string' })
learningObjectHruid!: string;
@Enum({ items: () => Language, primary: true })
learningObjectLanguage!: Language;
@PrimaryKey({ type: 'string' })
learningObjectVersion: string = '1';
@PrimaryKey({ type: 'integer' })
submissionNumber!: number;
@ManyToOne({ entity: () => Student })
submitter!: Student;
@Property({ type: 'datetime' })
submissionTime!: Date;
@ManyToOne({ entity: () => Group, nullable: true })
onBehalfOf?: Group;
@Property({ type: 'json' })
content!: string;
}

View file

@ -0,0 +1,21 @@
import { Entity, Enum, ManyToOne } from '@mikro-orm/core';
import { Student } from '../users/student.entity';
import { Class } from './class.entity';
@Entity()
export class ClassJoinRequest {
@ManyToOne({ entity: () => Student, primary: true })
requester!: Student;
@ManyToOne({ entity: () => Class, primary: true })
class!: Class;
@Enum(() => ClassJoinRequestStatus)
status!: ClassJoinRequestStatus;
}
export enum ClassJoinRequestStatus {
Open = 'open',
Accepted = 'accepted',
Declined = 'declined',
}

View file

@ -0,0 +1,25 @@
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';
@Entity()
export class Class {
@PrimaryKey()
classId = v4();
@Property({ type: 'string' })
displayName!: string;
@ManyToMany(() => Teacher)
teachers!: Collection<Teacher>;
@ManyToMany(() => Student)
students!: Collection<Student>;
}

View file

@ -0,0 +1,18 @@
import { Entity, ManyToOne } from '@mikro-orm/core';
import { Teacher } from '../users/teacher.entity.js';
import { Class } from './class.entity.js';
/**
* Invitation of a teacher into a class (in order to teach it).
*/
@Entity()
export class TeacherInvitation {
@ManyToOne({ entity: () => Teacher, primary: true })
sender!: Teacher;
@ManyToOne({ entity: () => Teacher, primary: true })
receiver!: Teacher;
@ManyToOne({ entity: () => Class, primary: true })
class!: Class;
}

View file

@ -0,0 +1,17 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { LearningObject } from './learning-object.entity.js';
@Entity()
export class Attachment {
@ManyToOne({ entity: () => LearningObject, primary: true })
learningObject!: LearningObject;
@PrimaryKey({ type: 'integer' })
sequenceNumber!: number;
@Property({ type: 'string' })
mimeType!: string;
@Property({ type: 'blob' })
content!: Buffer;
}

View file

@ -0,0 +1,6 @@
export enum Language {
Dutch = 'nl',
French = 'fr',
English = 'en',
Germany = 'de',
}

View file

@ -0,0 +1,9 @@
import { Language } from './language.js';
export class LearningObjectIdentifier {
constructor(
public hruid: string,
public language: Language,
public version: string
) {}
}

View file

@ -0,0 +1,106 @@
import {
Embeddable,
Embedded,
Entity,
Enum,
ManyToMany,
OneToMany,
PrimaryKey,
Property,
} from '@mikro-orm/core';
import { Language } from './language.js';
import { Attachment } from './attachment.entity.js';
import { Teacher } from '../users/teacher.entity.js';
@Entity()
export class LearningObject {
@PrimaryKey({ type: 'string' })
hruid!: string;
@Enum({ items: () => Language, primary: true })
language!: Language;
@PrimaryKey({ type: 'string' })
version: string = '1';
@ManyToMany({ entity: () => Teacher })
admins!: Teacher[];
@Property({ type: 'string' })
title!: string;
@Property({ type: 'text' })
description!: string;
@Property({ type: 'string' })
contentType!: string;
@Property({ type: 'array' })
keywords: string[] = [];
@Property({ type: 'array', nullable: true })
targetAges?: number[];
@Property({ type: 'bool' })
teacherExclusive: boolean = false;
@Property({ type: 'array' })
skosConcepts!: string[];
@Embedded({ entity: () => EducationalGoal, array: true })
educationalGoals: EducationalGoal[] = [];
@Property({ type: 'string' })
copyright: string = '';
@Property({ type: 'string' })
license: string = '';
@Property({ type: 'smallint', nullable: true })
difficulty?: number;
@Property({ type: 'integer' })
estimatedTime!: number;
@Embedded({ entity: () => ReturnValue })
returnValue!: ReturnValue;
@Property({ type: 'bool' })
available: boolean = true;
@Property({ type: 'string', nullable: true })
contentLocation?: string;
@OneToMany({ entity: () => Attachment, mappedBy: 'learningObject' })
attachments: Attachment[] = [];
@Property({ type: 'blob' })
content!: Buffer;
}
@Embeddable()
export class EducationalGoal {
@Property({ type: 'string' })
source!: string;
@Property({ type: 'string' })
id!: string;
}
@Embeddable()
export class ReturnValue {
@Property({ type: 'string' })
callbackUrl!: string;
@Property({ type: 'json' })
callbackSchema!: string;
}
export enum ContentType {
Markdown = 'text/markdown',
Image = 'image/image',
Mpeg = 'audio/mpeg',
Pdf = 'application/pdf',
Extern = 'extern',
Blockly = 'Blockly',
}

View file

@ -0,0 +1,66 @@
import {
Embeddable,
Embedded,
Entity,
Enum,
ManyToMany,
OneToOne,
PrimaryKey,
Property,
} from '@mikro-orm/core';
import { Language } from './language.js';
import { Teacher } from '../users/teacher.entity.js';
@Entity()
export class LearningPath {
@PrimaryKey({ type: 'string' })
hruid!: string;
@Enum({ items: () => Language, primary: true })
language!: Language;
@ManyToMany({ entity: () => Teacher })
admins!: Teacher[];
@Property({ type: 'string' })
title!: string;
@Property({ type: 'text' })
description!: string;
@Property({ type: 'blob' })
image!: string;
@Embedded({ entity: () => LearningPathNode, array: true })
nodes: LearningPathNode[] = [];
}
@Embeddable()
export class LearningPathNode {
@Property({ type: 'string' })
learningObjectHruid!: string;
@Enum({ items: () => Language })
language!: Language;
@Property({ type: 'string' })
version!: string;
@Property({ type: 'longtext' })
instruction!: string;
@Property({ type: 'bool' })
startNode!: boolean;
@Embedded({ entity: () => LearningPathTransition, array: true })
transitions!: LearningPathTransition[];
}
@Embeddable()
export class LearningPathTransition {
@Property({ type: 'string' })
condition!: string;
@OneToOne({ entity: () => LearningPathNode })
next!: LearningPathNode;
}

View file

@ -0,0 +1,21 @@
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Question } from './question.entity';
import { Teacher } from '../users/teacher.entity';
@Entity()
export class Answer {
@ManyToOne({ entity: () => Teacher, primary: true })
author!: Teacher;
@ManyToOne({ entity: () => Question, primary: true })
toQuestion!: Question;
@PrimaryKey({ type: 'integer' })
sequenceNumber!: number;
@Property({ type: 'datetime' })
timestamp: Date = new Date();
@Property({ type: 'text' })
content!: string;
}

View file

@ -0,0 +1,27 @@
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
import { Language } from '../content/language.js';
import { Student } from '../users/student.entity.js';
@Entity()
export class Question {
@PrimaryKey({ type: 'string' })
learningObjectHruid!: string;
@Enum({ items: () => Language, primary: true })
learningObjectLanguage!: Language;
@PrimaryKey({ type: 'string' })
learningObjectVersion: string = '1';
@PrimaryKey({ type: 'integer' })
sequenceNumber!: number;
@ManyToOne({ entity: () => Student })
author!: Student;
@Property({ type: 'datetime' })
timestamp: Date = new Date();
@Property({ type: 'text' })
content!: string;
}

View file

@ -0,0 +1,22 @@
import { User } from './user.entity.js';
import { Collection, Entity, ManyToMany } from '@mikro-orm/core';
import { Class } from '../classes/class.entity.js';
import { Group } from '../assignments/group.entity.js';
import { StudentRepository } from '../../data/users/student-repository.js';
@Entity({ repository: () => StudentRepository })
export class Student extends User {
@ManyToMany(() => Class)
classes!: Collection<Class>;
@ManyToMany(() => Group)
groups!: Collection<Group>;
constructor(
public username: string,
public firstName: string,
public lastName: string
) {
super();
}
}

View file

@ -0,0 +1,9 @@
import { Collection, Entity, ManyToMany } from '@mikro-orm/core';
import { User } from './user.entity.js';
import { Class } from '../classes/class.entity.js';
@Entity()
export class Teacher extends User {
@ManyToMany(() => Class)
classes!: Collection<Class>;
}

View file

@ -1,9 +1,9 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
@Entity()
export class User {
@PrimaryKey({ type: 'number' })
id!: number;
@Entity({ abstract: true })
export abstract class User {
@PrimaryKey({ type: 'string' })
username!: string;
@Property()
firstName: string = '';

View file

@ -1,12 +1,35 @@
import { Options } from '@mikro-orm/core';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
import { EnvVars, getEnvVar, getNumericEnvVar } from './util/envvars.js';
import { SqliteDriver } from '@mikro-orm/sqlite';
const config: Options = {
driver: PostgreSqlDriver,
dbName: 'dwengo',
entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
debug: true,
};
const entities = ['dist/**/*.entity.js'];
const entitiesTs = ['src/**/*.entity.ts'];
function config(testingMode: boolean = false): Options {
if (testingMode) {
return {
driver: SqliteDriver,
dbName: getEnvVar(EnvVars.DbName),
entities: entities,
entitiesTs: entitiesTs,
// Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION)
// (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint)
dynamicImportProvider: (id) => import(id),
};
} else {
return {
driver: PostgreSqlDriver,
host: getEnvVar(EnvVars.DbHost),
port: getNumericEnvVar(EnvVars.DbPort),
dbName: getEnvVar(EnvVars.DbName),
user: getEnvVar(EnvVars.DbUsername),
password: getEnvVar(EnvVars.DbPassword),
entities: entities,
entitiesTs: entitiesTs,
debug: true,
};
}
}
export default config;

View file

@ -1,6 +1,30 @@
import { MikroORM } from '@mikro-orm/core';
import { EntityManager, MikroORM } from '@mikro-orm/core';
import config from './mikro-orm.config.js';
import { EnvVars, getEnvVar } from './util/envvars.js';
export default async function initORM() {
await MikroORM.init(config);
let orm: MikroORM | undefined;
export async function initORM(testingMode: boolean = false) {
orm = await MikroORM.init(config(testingMode));
// Update the database scheme if necessary and enabled.
if (getEnvVar(EnvVars.DbUpdate)) {
await orm.schema.updateSchema();
} else {
const diff = await orm.schema.getUpdateSchemaSQL();
if (diff) {
throw Error(
'The database structure needs to be updated in order to fit the new database structure ' +
'of the app. In order to do so automatically, set the environment variable DWENGO_DB_UPDATE to true. ' +
'The following queries will then be executed:\n' +
diff
);
}
}
}
export function forkEntityManager(): EntityManager {
if (!orm) {
throw Error(
'Accessing the Entity Manager before the ORM is fully initialized.'
);
}
return orm.em.fork();
}

View file

@ -0,0 +1,45 @@
const PREFIX = 'DWENGO_';
const DB_PREFIX = PREFIX + 'DB_';
type EnvVar = { key: string; required?: boolean; defaultValue?: any };
export const EnvVars: { [key: string]: EnvVar } = {
Port: { key: PREFIX + 'PORT', defaultValue: 3000 },
DbHost: { key: DB_PREFIX + 'HOST', required: true },
DbPort: { key: DB_PREFIX + 'PORT', defaultValue: 5432 },
DbName: { key: DB_PREFIX + 'NAME', defaultValue: 'dwengo' },
DbUsername: { key: DB_PREFIX + 'USERNAME', required: true },
DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true },
DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false },
} as const;
/**
* Returns the value of the given environment variable if it is set.
* Otherwise,
* - throw an error if the environment variable was required,
* - return the default value if there is one and it was not required,
* - return an empty string if the environment variable is not required and there is also no default.
* @param envVar The properties of the environment variable (from the EnvVar object).
*/
export function getEnvVar(envVar: EnvVar): string {
const value: string | undefined = process.env[envVar.key];
if (value) {
return value;
} else if (envVar.required) {
throw new Error(`Missing environment variable: ${envVar.key}`);
} else {
return envVar.defaultValue || '';
}
}
export function getNumericEnvVar(envVar: EnvVar): number {
const valueString = getEnvVar(envVar);
const value = parseInt(valueString);
if (isNaN(value)) {
throw new Error(
`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`
);
} else {
return value;
}
}

View file

@ -0,0 +1,33 @@
import {setupTestApp} from "../setup-tests.js"
import {Student} from "../../src/entities/users/student.entity.js";
import {describe, it, expect, beforeAll} from "vitest";
import {StudentRepository} from "../../src/data/users/student-repository.js";
import {getStudentRepository} from "../../src/data/repositories.js";
const username = "teststudent";
const firstName = "John";
const lastName = "Doe";
describe("StudentRepository", () => {
let studentRepository: StudentRepository;
beforeAll(async () => {
await setupTestApp();
studentRepository = getStudentRepository();
});
it("should return the queried student after he was added", async () => {
await studentRepository.insert(new Student(username, firstName, lastName));
let retrievedStudent = await studentRepository.findByUsername(username);
expect(retrievedStudent).toBeTruthy();
expect(retrievedStudent?.firstName).toBe(firstName);
expect(retrievedStudent?.lastName).toBe(lastName);
});
it("should no longer return the queried student after he was removed again", async () => {
await studentRepository.deleteByUsername(username);
let retrievedStudent = await studentRepository.findByUsername(username);
expect(retrievedStudent).toBeNull();
});
});

View file

@ -4,6 +4,6 @@ describe("Sample test", () => {
it("should sum to 2", () => {
const expected = 2;
const result = 1 + 1;
expect(result).toBe(expected);
expect(result).equals(expected);
});
})

View file

@ -0,0 +1,7 @@
import {initORM} from "../src/orm.js";
import dotenv from "dotenv";
export async function setupTestApp() {
dotenv.config({path: ".env.test"});
await initORM(true);
}

8
backend/vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: true
}
});

View file

@ -6,11 +6,9 @@ services:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
network_mode: "host"
- "5431:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/config/db/init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
postgres_data:

1576
package-lock.json generated

File diff suppressed because it is too large Load diff