diff --git a/backend/middlewares/validators/courseValidators.ts b/backend/middlewares/validators/courseValidators.ts index 1c1d5d3..ee4039d 100644 --- a/backend/middlewares/validators/courseValidators.ts +++ b/backend/middlewares/validators/courseValidators.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { getApiValidationError, validatePrimitive } from "./util"; import CourseUnitService from "../../services/implementations/courseUnitService"; +import CourseModuleService from "../../services/implementations/courseModuleService"; /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ export const createCourseUnitDtoValidator = async ( @@ -36,6 +37,31 @@ export const updateCourseUnitDtoValidator = async ( return next(); }; +export const coursePageDtoValidator = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + if (!validatePrimitive(req.body.title, "string")) { + return res.status(400).send(getApiValidationError("title", "string")); + } + if (!validatePrimitive(req.body.type, "string")) { + return res.status(400).send(getApiValidationError("type", "string")); + } + if (req.body.type === "Lesson") { + if (!validatePrimitive(req.body.source, "string")) { + return res.status(400).send(getApiValidationError("source", "string")); + } + } else if (req.body.type === "Activity") { + if (!req.body.layout) { + return res.status(400).send("Layout field missing for Activity Page"); + } + } else { + return res.status(400).send(`Invalid page type "${req.body.type}"`); + } + return next(); +}; + export const moduleBelongsToUnitValidator = async ( req: Request, res: Response, @@ -56,3 +82,25 @@ export const moduleBelongsToUnitValidator = async ( } return next(); }; + +export const pageBelongsToModuleValidator = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + const { moduleId, pageId } = req.params; + + const courseModuleService: CourseModuleService = new CourseModuleService(); + + const courseModule = await courseModuleService.getCourseModule(moduleId); + + if (!courseModule.pages.includes(pageId)) { + return res + .status(404) + .send( + `Page with ID ${pageId} is not found in module with ID ${moduleId}`, + ); + } + + return next(); +}; diff --git a/backend/models/coursemodule.mgmodel.ts b/backend/models/coursemodule.mgmodel.ts index 8c4feac..bd0b623 100644 --- a/backend/models/coursemodule.mgmodel.ts +++ b/backend/models/coursemodule.mgmodel.ts @@ -28,6 +28,7 @@ export const CourseModuleSchema: Schema = new Schema({ CourseModuleSchema.set("toObject", { virtuals: true, versionKey: false, + flattenObjectIds: true, transform: (_doc: Document, ret: Record) => { // eslint-disable-next-line no-underscore-dangle delete ret._id; diff --git a/backend/models/coursepage.mgmodel.ts b/backend/models/coursepage.mgmodel.ts index c1973ba..4eaca23 100644 --- a/backend/models/coursepage.mgmodel.ts +++ b/backend/models/coursepage.mgmodel.ts @@ -1,13 +1,6 @@ import mongoose, { Schema, Document } from "mongoose"; - -export type ElementSkeleton = { - id: string; - x: number; - y: number; - w: number; - h: number; - content: string; -}; +import { ObjectId } from "mongodb"; +import { ElementSkeleton, PageType } from "../types/courseTypes"; const ElementSkeletonSchema: Schema = new Schema({ x: { @@ -27,17 +20,15 @@ const ElementSkeletonSchema: Schema = new Schema({ required: true, }, content: { - type: String, + type: ObjectId, required: true, + ref: "CourseElement", }, }); -export type PageType = "Lesson" | "Activity"; - export interface CoursePage extends Document { id: string; title: string; - displayIndex: number; type: PageType; } @@ -59,10 +50,6 @@ export const CoursePageSchema: Schema = new Schema( type: String, required: true, }, - displayIndex: { - type: Number, - required: true, - }, type: { type: String, required: true, diff --git a/backend/models/courseunit.mgmodel.ts b/backend/models/courseunit.mgmodel.ts index 68c2238..8e44f08 100644 --- a/backend/models/courseunit.mgmodel.ts +++ b/backend/models/courseunit.mgmodel.ts @@ -28,6 +28,7 @@ const CourseUnitSchema: Schema = new Schema({ CourseUnitSchema.set("toObject", { virtuals: true, versionKey: false, + flattenObjectIds: true, transform: (_doc: Document, ret: Record) => { // eslint-disable-next-line no-underscore-dangle delete ret._id; diff --git a/backend/rest/courseRoutes.ts b/backend/rest/courseRoutes.ts index 5e6b7e6..424ea3b 100644 --- a/backend/rest/courseRoutes.ts +++ b/backend/rest/courseRoutes.ts @@ -2,16 +2,20 @@ import { Router } from "express"; import CourseUnitService from "../services/implementations/courseUnitService"; import { getErrorMessage } from "../utilities/errorUtils"; import { + coursePageDtoValidator, createCourseUnitDtoValidator, moduleBelongsToUnitValidator, + pageBelongsToModuleValidator, updateCourseUnitDtoValidator, } from "../middlewares/validators/courseValidators"; import { isAuthorizedByRole } from "../middlewares/auth"; import CourseModuleService from "../services/implementations/courseModuleService"; +import CoursePageService from "../services/implementations/coursePageService"; const courseRouter: Router = Router(); const courseUnitService: CourseUnitService = new CourseUnitService(); const courseModuleService: CourseModuleService = new CourseModuleService(); +const coursePageService: CoursePageService = new CoursePageService(); courseRouter.get( "/", @@ -148,4 +152,72 @@ courseRouter.delete( }, ); +courseRouter.get( + "/:unitId/:moduleId", + isAuthorizedByRole(new Set(["Administrator", "Facilitator", "Learner"])), + moduleBelongsToUnitValidator, + async (req, res) => { + try { + const coursePages = await coursePageService.getCoursePages( + req.params.moduleId, + ); + res.status(200).json(coursePages); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + +courseRouter.post( + "/:unitId/:moduleId", + isAuthorizedByRole(new Set(["Administrator"])), + moduleBelongsToUnitValidator, + coursePageDtoValidator, + async (req, res) => { + try { + const newCoursePage = await coursePageService.createCoursePage( + req.params.moduleId, + req.body, + ); + res.status(200).json(newCoursePage); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + +courseRouter.put( + "/:unitId/:moduleId/:pageId", + isAuthorizedByRole(new Set(["Administrator"])), + moduleBelongsToUnitValidator, + pageBelongsToModuleValidator, + coursePageDtoValidator, + async (req, res) => { + try { + const updatedCoursePage = await coursePageService.updateCoursePage( + req.params.pageId, + req.body, + ); + res.status(200).json(updatedCoursePage); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + +courseRouter.delete( + "/:unitId/:moduleId/:pageId", + isAuthorizedByRole(new Set(["Administrator"])), + async (req, res) => { + try { + const deletedCoursePageId = await coursePageService.deleteCoursePage( + req.params.pageId, + ); + res.status(200).json({ id: deletedCoursePageId }); + } catch (e: unknown) { + res.status(500).send(getErrorMessage(e)); + } + }, +); + export default courseRouter; diff --git a/backend/services/implementations/courseModuleService.ts b/backend/services/implementations/courseModuleService.ts index 07a1662..c9b49b1 100644 --- a/backend/services/implementations/courseModuleService.ts +++ b/backend/services/implementations/courseModuleService.ts @@ -32,7 +32,7 @@ class CourseModuleService implements ICourseModuleService { _id: { $in: courseUnit.modules }, }); - return courseModules; + return courseModules.map((courseModule) => courseModule.toObject()); } catch (error) { Logger.error( `Failed to get course modules for course unit with id: ${courseUnitId}. Reason = ${getErrorMessage( @@ -43,6 +43,27 @@ class CourseModuleService implements ICourseModuleService { } } + async getCourseModule(courseModuleId: string): Promise { + try { + const courseModule: CourseModule | null = await MgCourseModule.findById( + courseModuleId, + ); + + if (!courseModule) { + throw new Error(`id ${courseModuleId} not found.`); + } + + return courseModule.toObject(); + } catch (error) { + Logger.error( + `Failed to get course module with id: ${courseModuleId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + async createCourseModule( courseUnitId: string, courseModuleDTO: CreateCourseModuleDTO, diff --git a/backend/services/implementations/coursePageService.ts b/backend/services/implementations/coursePageService.ts new file mode 100644 index 0000000..708dabc --- /dev/null +++ b/backend/services/implementations/coursePageService.ts @@ -0,0 +1,146 @@ +/* eslint-disable class-methods-use-this */ +import { startSession } from "mongoose"; +import { + CoursePageDTO, + CreateCoursePageDTO, + UpdateCoursePageDTO, +} from "../../types/courseTypes"; +import logger from "../../utilities/logger"; +import ICoursePageService from "../interfaces/coursePageService"; +import MgCourseModule, { + CourseModule, +} from "../../models/coursemodule.mgmodel"; +import MgCoursePage from "../../models/coursepage.mgmodel"; +import { getErrorMessage } from "../../utilities/errorUtils"; + +const Logger = logger(__filename); + +class CoursePageService implements ICoursePageService { + async getCoursePages(courseModuleId: string): Promise> { + try { + const courseModule: CourseModule | null = await MgCourseModule.findById( + courseModuleId, + ); + + if (!courseModule) { + throw new Error(`Course module with id ${courseModuleId} not found.`); + } + + const coursePages = await MgCoursePage.find({ + _id: { $in: courseModule.pages }, + }); + + const coursePageDtos = await Promise.all( + coursePages.map(async (coursePage) => coursePage.toObject()), + ); + + return coursePageDtos; + } catch (error) { + Logger.error( + `Failed to get course pages in course module with id: ${courseModuleId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + + async getCoursePage(coursePageId: string): Promise { + try { + const coursePage = await MgCoursePage.findById(coursePageId); + + if (!coursePage) { + throw new Error(`id ${coursePageId} not found.`); + } + + return coursePage; + } catch (error) { + Logger.error( + `Failed to get course page with id: ${coursePageId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + + async createCoursePage( + courseModuleId: string, + coursePageDTO: CreateCoursePageDTO, + ): Promise { + const session = await startSession(); + session.startTransaction(); + try { + const newCoursePage = await MgCoursePage.create({ + ...coursePageDTO, + session, + }); + + await MgCourseModule.findByIdAndUpdate(courseModuleId, { + $push: { pages: newCoursePage.id }, + }).session(session); + + await session.commitTransaction(); + + return newCoursePage.toObject(); + } catch (error) { + Logger.error( + `Failed to create new course page for module with id ${courseModuleId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } finally { + session.endSession(); + } + } + + async updateCoursePage( + coursePageId: string, + coursePageDTO: UpdateCoursePageDTO, + ): Promise { + try { + const updatedCoursePage = await MgCoursePage.findByIdAndUpdate( + coursePageId, + coursePageDTO, + { runValidators: true, new: true }, + ); + + if (!updatedCoursePage) { + throw new Error(`Course page with id ${coursePageId} not found.`); + } + + return updatedCoursePage.toObject(); + } catch (error) { + Logger.error( + `Failed to update course page with id ${coursePageId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } + + async deleteCoursePage(coursePageId: string): Promise { + try { + const deletedCoursePage = await MgCoursePage.findByIdAndDelete( + coursePageId, + ); + + if (!deletedCoursePage) { + throw new Error(`Course page with id ${coursePageId} not found.`); + } + + return deletedCoursePage.id; + } catch (error) { + Logger.error( + `Failed to delete course page with id ${coursePageId}. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } +} + +export default CoursePageService; diff --git a/backend/services/implementations/courseUnitService.ts b/backend/services/implementations/courseUnitService.ts index 0761290..87673eb 100644 --- a/backend/services/implementations/courseUnitService.ts +++ b/backend/services/implementations/courseUnitService.ts @@ -17,20 +17,14 @@ class CourseUnitService implements ICourseUnitService { const courseUnits: Array = await MgCourseUnit.find().sort( "displayIndex", ); - return courseUnits.map((courseUnit) => ({ - id: courseUnit.id, - displayIndex: courseUnit.displayIndex, - title: courseUnit.title, - })); + return courseUnits.map((courseUnit) => courseUnit.toObject()); } catch (error) { Logger.error(`Failed to get courses. Reason = ${getErrorMessage(error)}`); throw error; } } - async getCourseUnit( - unitId: string, - ): Promise { + async getCourseUnit(unitId: string): Promise { try { const courseUnit: CourseUnit | null = await MgCourseUnit.findById(unitId); @@ -38,11 +32,7 @@ class CourseUnitService implements ICourseUnitService { throw new Error(`Course unit with id ${unitId} not found.`); } - const courseModuleIds = courseUnit.modules.map((id) => { - return id.toString(); - }); - - return { ...(courseUnit as CourseUnitDTO), modules: courseModuleIds }; + return courseUnit.toObject(); } catch (error) { Logger.error( `Failed to get course with id ${unitId}. Reason = ${getErrorMessage( @@ -69,41 +59,33 @@ class CourseUnitService implements ICourseUnitService { ); throw error; } - return { - id: newCourseUnit.id, - displayIndex: newCourseUnit.displayIndex, - title: newCourseUnit.title, - }; + return newCourseUnit.toObject(); } async updateCourseUnit( id: string, courseUnit: UpdateCourseUnitDTO, ): Promise { - let oldCourse: CourseUnit | null; try { - oldCourse = await MgCourseUnit.findByIdAndUpdate( - id, - { - title: courseUnit.title, - }, - { runValidators: true }, - ); + const updatedCourseUnit: CourseUnit | null = + await MgCourseUnit.findByIdAndUpdate( + id, + { + title: courseUnit.title, + }, + { runValidators: true, new: true }, + ); - if (!oldCourse) { + if (!updatedCourseUnit) { throw new Error(`Course unit with id ${id} not found.`); } + return updatedCourseUnit.toObject(); } catch (error) { Logger.error( `Failed to update course unit. Reason = ${getErrorMessage(error)}`, ); throw error; } - return { - id, - title: courseUnit.title, - displayIndex: oldCourse.displayIndex, - }; } async deleteCourseUnit(id: string): Promise { diff --git a/backend/services/implementations/helpRequestService.ts b/backend/services/implementations/helpRequestService.ts index e04d7e8..2f25302 100644 --- a/backend/services/implementations/helpRequestService.ts +++ b/backend/services/implementations/helpRequestService.ts @@ -44,7 +44,7 @@ export class HelpRequestService implements IHelpRequestService { .populate("learner", "firstName lastName") .populate("unit", "title displayIndex") .populate("module", "title displayIndex") - .populate("page", "title displayIndex") + .populate("page", "title") .sort({ createdAt: -1, }); @@ -83,7 +83,7 @@ export class HelpRequestService implements IHelpRequestService { .populate("learner", "firstName lastName") .populate("unit", "title displayIndex") .populate("module", "title displayIndex") - .populate("page", "title displayIndex"); + .populate("page", "title"); if (!oldHelpRequest) { throw new Error(`Help Request with id ${requestId} not found.`); diff --git a/backend/services/interfaces/courseModuleService.ts b/backend/services/interfaces/courseModuleService.ts index 7fae257..a904121 100644 --- a/backend/services/interfaces/courseModuleService.ts +++ b/backend/services/interfaces/courseModuleService.ts @@ -12,6 +12,12 @@ interface ICourseModuleService { */ getCourseModules(courseUnitId: string): Promise>; + /** + * Returns course module based on module id + * @throwsError if course module was not successfully fetched or not found + */ + getCourseModule(courseModuleId: string): Promise; + /** * Creates a course module * @param courseUnitId the id of the unit that we want to create the new module under diff --git a/backend/services/interfaces/coursePageService.ts b/backend/services/interfaces/coursePageService.ts new file mode 100644 index 0000000..ef76472 --- /dev/null +++ b/backend/services/interfaces/coursePageService.ts @@ -0,0 +1,52 @@ +import { + CoursePageDTO, + CreateCoursePageDTO, + UpdateCoursePageDTO, +} from "../../types/courseTypes"; + +interface ICoursePageService { + /** + * Returns all course pages belonging to a module + * @param courseModuleId the id of the module we want to fetch the pages of + * @throws Error if course pages were not successfully fetched + */ + getCoursePages(courseModuleId: string): Promise>; + + /** + * Returns 1 course page + * @param coursePageId the id of the page we want to fetch + * @throwsError if course page was not successfully fetched or not found + */ + getCoursePage(coursePageId: string): Promise; + + /** + * Creates a course page, appended as the last page in the module (for now) + * @param courseModuleId the id of the module that we want to create the new page under + * @param coursePageDTO the info about the course page that we want to create + * @throws Error if course page was not successfully created + */ + createCoursePage( + courseModuleId: string, + coursePageDTO: CreateCoursePageDTO, + ): Promise; + + /** + * Updates 1 specific course page + * @param coursePageId the id of the course page we want to update + * @param coursePageDTO the info about course page we want to update + * @throws Error if the course page failed to update + */ + updateCoursePage( + coursePageId: string, + coursePageDTO: UpdateCoursePageDTO, + ): Promise; + + /** + * Deletes 1 course page + * @param coursePageId the id of the course page we want to delete + * @throws Error if the page id does't exist in the database + */ + deleteCoursePage(coursePageId: string): Promise; +} + +export default ICoursePageService; diff --git a/backend/types/courseTypes.ts b/backend/types/courseTypes.ts index a82e71c..48e1050 100644 --- a/backend/types/courseTypes.ts +++ b/backend/types/courseTypes.ts @@ -2,21 +2,54 @@ export type CourseUnitDTO = { id: string; displayIndex: number; title: string; + modules: string[]; }; export type CreateCourseUnitDTO = Pick; - export type UpdateCourseUnitDTO = Pick; export type CourseModuleDTO = { id: string; displayIndex: number; title: string; + pages: string[]; }; export type CreateCourseModuleDTO = Pick; export type UpdateCourseModuleDTO = Pick; +export type PageType = "Lesson" | "Activity"; +export type CoursePageDTO = { + id: string; + title: string; + type: PageType; +}; + +export type LessonPageDTO = CoursePageDTO & { + source: string; +}; + +export type ElementSkeleton = { + id: string; + x: number; + y: number; + w: number; + h: number; + content: string; +}; +export type ActivityPageDTO = CoursePageDTO & { + layout: [ElementSkeleton]; +}; + +export type CreateCoursePageDTO = + | Pick + | Pick + | Pick; +export type UpdateCoursePageDTO = + | Pick + | Pick + | Pick; + export enum InteractiveElementType { TextInput = "TextInput", NumberInput = "NumberInput", diff --git a/frontend/src/types/HelpRequestType.ts b/frontend/src/types/HelpRequestType.ts index 38f081b..616e94e 100644 --- a/frontend/src/types/HelpRequestType.ts +++ b/frontend/src/types/HelpRequestType.ts @@ -17,7 +17,6 @@ export interface HelpRequest { }; page: { id: string; - displayIndex: number; title: string; }; completed: boolean;