diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts index 9a03e681..5c1fb8e1 100644 --- a/backend/src/controllers/learning-paths.ts +++ b/backend/src/controllers/learning-paths.ts @@ -68,13 +68,14 @@ export async function getLearningPaths(req: Request, res: Response): Promise Promise { return async (req, res) => { const path: LearningPath = req.body; + 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}); } - const teacher = await getTeacher(req.auth!.username); - res.json(await learningPathService.createNewLearningPath(path, [teacher], isPut)); + res.json(await learningPathService.createNewLearningPath(path, [teacher])); } } diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts index 503a6287..81b0df99 100644 --- a/backend/src/data/content/learning-path-repository.ts +++ b/backend/src/data/content/learning-path-repository.ts @@ -8,7 +8,10 @@ import { EntityAlreadyExistsException } from '../../exceptions/entity-already-ex export class LearningPathRepository extends DwengoEntityRepository { public async findByHruidAndLanguage(hruid: string, language: Language): Promise { - return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); + return this.findOne( + { hruid: hruid, language: language }, + { populate: ['nodes', 'nodes.transitions', 'admins'] } + ); } /** @@ -24,7 +27,7 @@ export class LearningPathRepository extends DwengoEntityRepository language: language, $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], }, - populate: ['nodes', 'nodes.transitions'], + populate: ['nodes', 'nodes.transitions', 'admins'], }); } @@ -37,7 +40,8 @@ export class LearningPathRepository extends DwengoEntityRepository admins: { username: adminUsername } - } + }, + populate: ['nodes', 'nodes.transitions', 'admins'] }); } diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts index fd870dcd..08543d22 100644 --- a/backend/src/entities/content/learning-path-node.entity.ts +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -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; @Property({ length: 3 }) diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index 1b96d8ea..36e13766 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -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 = new Collection(this); } diff --git a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts index 64e51416..95a94d44 100644 --- a/backend/src/middleware/auth/checks/learning-path-auth-checks.ts +++ b/backend/src/middleware/auth/checks/learning-path-auth-checks.ts @@ -1,3 +1,4 @@ +import { Language } from "@dwengo-1/common/util/language"; import learningPathService from "../../../services/learning-paths/learning-path-service"; import { authorize } from "../auth"; import { AuthenticatedRequest } from "../authenticated-request"; @@ -5,8 +6,8 @@ import { AuthenticationInfo } from "../authentication-info"; export const onlyAdminsForLearningPath = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { const adminsForLearningPath = await learningPathService.getAdmins({ - hruid: req.body.hruid, - language: req.body.language + hruid: req.params.hruid, + language: req.params.language as Language }); return adminsForLearningPath && adminsForLearningPath.includes(auth.username); }); diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts index b2e67d57..525ec34f 100644 --- a/backend/src/routes/learning-paths.ts +++ b/backend/src/routes/learning-paths.ts @@ -1,7 +1,7 @@ import express from 'express'; import { deleteLearningPath, getLearningPaths, postLearningPath, putLearningPath } from '../controllers/learning-paths.js'; import { teachersOnly } from '../middleware/auth/auth.js'; -import { onlyAdminsForLearningObject } from '../middleware/auth/checks/learning-object-auth-checks.js'; +import { onlyAdminsForLearningPath } from '../middleware/auth/checks/learning-path-auth-checks.js'; const router = express.Router(); @@ -27,7 +27,7 @@ const router = express.Router(); router.get('/', getLearningPaths); router.post('/', teachersOnly, postLearningPath) -router.put('/:hruid/:language', onlyAdminsForLearningObject, putLearningPath); -router.delete('/:hruid/:language', onlyAdminsForLearningObject, deleteLearningPath); +router.put('/:hruid/:language', onlyAdminsForLearningPath, putLearningPath); +router.delete('/:hruid/:language', onlyAdminsForLearningPath, deleteLearningPath); export default router; diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts index f70b88b2..dca719f8 100644 --- a/backend/src/services/learning-objects/learning-object-service.ts +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -72,6 +72,8 @@ const learningObjectService = { learningObject.hruid = getEnvVar(envVars.UserContentPrefix) + learningObject.hruid; } + await learningObjectRepository.getEntityManager().flush(); + // 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( diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts index ac525831..0bcd0922 100644 --- a/backend/src/services/learning-paths/database-learning-path-provider.ts +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -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): ) ) ); - 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; } @@ -102,7 +110,14 @@ async function convertNode( !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); return { _id: learningObject.uuid, language: learningObject.language, diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts index 53c084fd..7ee60ac3 100644 --- a/backend/src/services/learning-paths/learning-path-service.ts +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -130,13 +130,12 @@ 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. - * @param allowReplace If this is set to true and there is already a learning path with the same identifier, it is replaced. * @returns the created learning path. */ - async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[], allowReplace = false): Promise { + async createNewLearningPath(dto: LearningPath, admins: TeacherDTO[]): Promise { const repo = getLearningPathRepository(); const path = mapToLearningPath(dto, admins); - await repo.save(path, { preventOverwrite: allowReplace }); + await repo.save(path, { preventOverwrite: true }); return path; }, diff --git a/common/src/interfaces/learning-content.ts b/common/src/interfaces/learning-content.ts index 582e0086..a3e5dacb 100644 --- a/common/src/interfaces/learning-content.ts +++ b/common/src/interfaces/learning-content.ts @@ -25,8 +25,8 @@ export interface LearningObjectNode { language: Language; start_node?: boolean; transitions: Transition[]; - created_at: string; - updatedAt: string; + created_at?: string; + updatedAt?: string; done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. } diff --git a/frontend/src/controllers/learning-paths.ts b/frontend/src/controllers/learning-paths.ts index f8b82bef..4a8a1824 100644 --- a/frontend/src/controllers/learning-paths.ts +++ b/frontend/src/controllers/learning-paths.ts @@ -1,8 +1,8 @@ import { BaseController } from "@/controllers/base-controller.ts"; -import { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { Language } from "@/data-objects/language.ts"; +import { LearningPath } from "@/data-objects/learning-paths/learning-path"; import { single } from "@/utils/response-assertions.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathController extends BaseController { constructor() { diff --git a/frontend/src/data-objects/learning-paths/learning-path-node.ts b/frontend/src/data-objects/learning-paths/learning-path-node.ts index 99bac8db..de977233 100644 --- a/frontend/src/data-objects/learning-paths/learning-path-node.ts +++ b/frontend/src/data-objects/learning-paths/learning-path-node.ts @@ -1,5 +1,5 @@ import type { Language } from "@/data-objects/language.ts"; -import type { LearningPathNodeDTO } from "@/data-objects/learning-paths/learning-path.ts"; +import type { LearningObjectNode as LearningPathNodeDTO } from "@dwengo-1/common/interfaces/learning-content"; export class LearningPathNode { public readonly learningobjectHruid: string; @@ -14,7 +14,7 @@ export class LearningPathNode { learningobjectHruid: string; version: number; language: Language; - transitions: { next: LearningPathNode; default: boolean }[]; + transitions: { next: LearningPathNode; default?: boolean }[]; createdAt: Date; updatedAt: Date; done?: boolean; @@ -22,7 +22,7 @@ export class LearningPathNode { this.learningobjectHruid = options.learningobjectHruid; this.version = options.version; this.language = options.language; - this.transitions = options.transitions; + this.transitions = options.transitions.map(it => ({ next: it.next, default: it.default ?? false })); this.createdAt = options.createdAt; this.updatedAt = options.updatedAt; this.done = options.done || false; @@ -50,8 +50,8 @@ export class LearningPathNode { return undefined; }) .filter((it) => it !== undefined), - createdAt: new Date(dto.created_at), - updatedAt: new Date(dto.updatedAt), + createdAt: dto.created_at ? new Date(dto.created_at) : new Date(), + updatedAt: dto.updatedAt ? new Date(dto.updatedAt) : new Date(), done: dto.done, }); } diff --git a/frontend/src/data-objects/learning-paths/learning-path.ts b/frontend/src/data-objects/learning-paths/learning-path.ts index d764d123..22ccdf0d 100644 --- a/frontend/src/data-objects/learning-paths/learning-path.ts +++ b/frontend/src/data-objects/learning-paths/learning-path.ts @@ -1,6 +1,6 @@ import type { Language } from "@/data-objects/language.ts"; import { LearningPathNode } from "@/data-objects/learning-paths/learning-path-node.ts"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto.ts"; +import type { LearningObjectNode, LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; export interface LearningPathNodeDTO { _id: string; @@ -77,16 +77,19 @@ export class LearningPath { hruid: dto.hruid, title: dto.title, description: dto.description, - amountOfNodes: dto.num_nodes, - amountOfNodesLeft: dto.num_nodes_left, + amountOfNodes: dto.num_nodes ?? dto.nodes.length, + amountOfNodesLeft: dto.num_nodes_left ?? dto.nodes.length, keywords: dto.keywords.split(" "), - targetAges: { min: dto.min_age, max: dto.max_age }, + targetAges: { + min: dto.min_age ?? NaN, + max: dto.max_age ?? NaN + }, startNode: LearningPathNode.fromDTOAndOtherNodes(LearningPath.getStartNode(dto), dto.nodes), image: dto.image, }); } - static getStartNode(dto: LearningPathDTO): LearningPathNodeDTO { + static getStartNode(dto: LearningPathDTO): LearningObjectNode { const startNodeDtos = dto.nodes.filter((it) => it.start_node === true); if (startNodeDtos.length < 1) { // The learning path has no starting node -> use the first node. diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json index 47acd255..d014cf39 100644 --- a/frontend/src/i18n/locale/en.json +++ b/frontend/src/i18n/locale/en.json @@ -134,5 +134,9 @@ "invalidZip": "This is not a valid zip file.", "emptyZip": "This zip file is empty", "missingMetadata": "This learning object is missing a metadata.json file.", - "missingContent": "This learning object is missing a content.* file." + "missingContent": "This learning object is missing a content.* file.", + "open": "open", + "editLearningPath": "Edit learning path", + "newLearningPath": "Create a new learning path", + "saveChanges": "Save changes" } diff --git a/frontend/src/queries/learning-paths.ts b/frontend/src/queries/learning-paths.ts index d24b6fc5..7f43b5a9 100644 --- a/frontend/src/queries/learning-paths.ts +++ b/frontend/src/queries/learning-paths.ts @@ -2,9 +2,9 @@ import { type MaybeRefOrGetter, toValue } from "vue"; import type { Language } from "@/data-objects/language.ts"; import { useMutation, useQuery, useQueryClient, type UseMutationReturnType, type UseQueryReturnType } from "@tanstack/vue-query"; import { getLearningPathController } from "@/controllers/controllers"; -import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { AxiosError } from "axios"; -import type { LearningPathDTO } from "@/data-objects/learning-paths/learning-path-dto"; +import type { LearningPath as LearningPathDTO } from "@dwengo-1/common/interfaces/learning-content"; +import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; export const LEARNING_PATH_KEY = "learningPath"; const learningPathController = getLearningPathController(); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index c8a1ebc4..274e8c5d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -106,6 +106,12 @@ const router = createRouter({ component: SingleDiscussion, meta: { requiresAuth: true }, }, + { + path: "/my-content", + name: "OwnLearningContentPage", + component: OwnLearningContentPage, + meta: { requiresAuth: true } + }, { path: "/learningPath", children: [ @@ -115,18 +121,12 @@ const router = createRouter({ component: LearningPathSearchPage, meta: { requiresAuth: true }, }, - { - path: "my", - name: "OwnLearningContentPage", - component: OwnLearningContentPage, - meta: { requiresAuth: true } - }, { path: ":hruid/:language/:learningObjectHruid", name: "LearningPath", component: LearningPathPage, props: true, - meta: { requiresAuth: true }, + meta: { requiresAuth: true } }, ], }, diff --git a/frontend/src/views/learning-paths/LearningPathPage.vue b/frontend/src/views/learning-paths/LearningPathPage.vue index dc444156..c3cf64a9 100644 --- a/frontend/src/views/learning-paths/LearningPathPage.vue +++ b/frontend/src/views/learning-paths/LearningPathPage.vue @@ -247,7 +247,7 @@ -
+
- + diff --git a/frontend/src/views/own-learning-content/learning-objects/OwnLearningObjectsView.vue b/frontend/src/views/own-learning-content/learning-objects/OwnLearningObjectsView.vue index e353cff7..5603e4fb 100644 --- a/frontend/src/views/own-learning-content/learning-objects/OwnLearningObjectsView.vue +++ b/frontend/src/views/own-learning-content/learning-objects/OwnLearningObjectsView.vue @@ -2,7 +2,7 @@ import type { LearningObject } from '@/data-objects/learning-objects/learning-object'; import LearningObjectUploadButton from '@/views/own-learning-content/learning-objects/LearningObjectUploadButton.vue' import LearningObjectPreviewCard from './LearningObjectPreviewCard.vue'; - import { computed, ref, type Ref } from 'vue'; + import { computed, ref, watch, type Ref } from 'vue'; import { useI18n } from 'vue-i18n'; const { t } = useI18n(); @@ -19,6 +19,8 @@ const selectedLearningObjects: Ref = ref([]); + watch(() => props.learningObjects, () => selectedLearningObjects.value = []); + const selectedLearningObject = computed(() => selectedLearningObjects.value ? selectedLearningObjects.value[0] : undefined ); @@ -27,16 +29,20 @@