package lessons import ( "errors" "sort" "strings" "git.ex.umbach.dev/LMS/libcore/models" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "gorm.io/gorm" "lms.de/backend/modules/database" "lms.de/backend/modules/structs" "lms.de/backend/modules/utils" "lms.de/backend/socketclients" ) func GetLessons(c *fiber.Ctx) error { // swagger:operation GET /v1/lessons lessons GetLessons // --- // summary: Get lessons. // consumes: // - application/json // produces: // - application/json // responses: // '200': // description: Lessons retrieved successfully. // schema: // type: array // items: // "$ref": "#/definitions/LessonResponse" // '500': // description: Failed to retrieve lessons. var lessons []structs.LessonResponse if err := database.DB.Model(&models.Lesson{}). Where("organization_id = ?", c.Locals("organizationId")). Find(&lessons).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } return c.JSON(lessons) } func CreateLesson(c *fiber.Ctx) error { // swagger:operation POST /v1/lessons lessons CreateLesson // --- // summary: Create lesson. // consumes: // - application/json // produces: // - application/json // responses: // '200': // description: Lesson created successfully. // schema: // "$ref": "#/definitions/CreateLessonResponse" // '500': // description: Failed to create lesson. lesson := models.Lesson{ Id: uuid.New().String(), OrganizationId: c.Locals("organizationId").(string), State: structs.LessonStateDraft, Title: "Test", CreatorUserId: c.Locals("userId").(string), } if err := database.DB.Create(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessons, c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonCreated, Body: lesson, }, ) return c.JSON( structs.CreateLessonResponse{ Id: lesson.Id, }, ) } func GetLessonContents(c *fiber.Ctx) error { // swagger:operation GET /v1/lessons/{lessonId}/contents lessons GetLessonContents // --- // summary: Get lesson contents. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // responses: // '200': // description: Lesson contents retrieved successfully. // schema: // type: array // items: // "$ref": "#/definitions/LessonContent" // '500': // description: Failed to retrieve lesson contents. var params structs.LessonIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // check if lesson belongs to organization var lesson models.Lesson if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusNotFound) } // get lesson contents var lessonContents []structs.GetLessonContentsResponse if err := database.DB.Model(&models.LessonContent{}). Where("lesson_id = ?", params.LessonId). Find(&lessonContents).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } // sort contents by position sort.SliceStable(lessonContents, func(i, j int) bool { return lessonContents[i].Position < lessonContents[j].Position }) return c.JSON(lessonContents) } func GetLessonSettings(c *fiber.Ctx) error { // swagger:operation GET /v1/lessons/{lessonId}/settings lessons GetLessonSettings // --- // summary: Get lesson settings. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // responses: // '200': // description: Lesson settings retrieved successfully. // schema: // "$ref": "#/definitions/LessonSettings" // '500': // description: Failed to retrieve lesson settings. var params structs.LessonIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } var lessonSettings structs.LessonSettings if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lessonSettings).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } return c.JSON(lessonSettings) } func UpdateLessonPreviewTitle(c *fiber.Ctx) error { // swagger:operation PATCH /v1/lessons/{lessonId}/preview/title lessons UpdateLessonPreviewTitle // --- // summary: Update lesson preview title. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: body // in: body // schema: // "$ref": "#/definitions/UpdateLessonPreviewRequest" // responses: // '200': // description: Lesson preview updated successfully. // schema: // "$ref": "#/definitions/UpdateLessonPreviewResponse" // '500': // description: Failed to update lesson preview. var params structs.LessonIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } var body structs.UpdateLessonPreviewTitleRequest if err := c.BodyParser(&body); err != nil { return c.SendStatus(fiber.StatusBadRequest) } if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). Update("title", body.Title).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicsExceptBrowserTabSession( c.Locals("organizationId").(string), []string{utils.SubscribedTopicLessons, utils.SubscribedTopicLessonsEditorId(params.LessonId)}, c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonPreviewTitleUpdated, Body: struct { LessonId string Title string }{ LessonId: params.LessonId, Title: body.Title, }, }, ) return c.JSON( fiber.Map{ "message": "Lesson preview updated successfully", }, ) } func UpdateLessonPreviewThumbnail(c *fiber.Ctx) error { // swagger:operation POST /v1/lessons/{lessonId}/preview/thumbnail lessons UpdateLessonPreviewThumbnail // --- // summary: Update lesson preview thumbnail. // consumes: // - multipart/form-data // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: body // in: body // schema: // "$ref": "#/definitions/UpdateLessonPreviewThumbnailRequest" // responses: // '200': // description: Lesson preview updated successfully. // '500': // description: Failed to update lesson preview. var params structs.LessonIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } fileHeader, err := c.FormFile("file") if err != nil { return c.SendStatus(fiber.StatusBadRequest) } if fileHeader.Size > utils.MaxImageSize { return c.SendStatus(fiber.StatusBadRequest) } if !utils.IsFileTypeAllowed(fileHeader.Header.Get("Content-Type"), utils.AcceptedImageFileTypes) { return c.SendStatus(fiber.StatusBadRequest) } // get current thumbnail lesson := models.Lesson{} if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } // delete current thumbnail if lesson.ThumbnailUrl != "" { utils.DeleteFile(lesson.ThumbnailUrl) } fileName := uuid.New().String() + "." + strings.Split(fileHeader.Header["Content-Type"][0], "/")[1] databasePath, publicPath := utils.GetFullImagePath(c.Locals("organizationId").(string), params.LessonId) utils.CreateFolderStructureIfNotExists(publicPath) thumbnailUrl := databasePath + fileName if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). Update("thumbnail_url", thumbnailUrl).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } if err := c.SaveFile(fileHeader, publicPath+fileName); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Failed to save file", }) } socketclients.BroadcastMessageToTopics( c.Locals("organizationId").(string), []string{utils.SubscribedTopicLessons, utils.SubscribedTopicLessonsEditorId(params.LessonId)}, structs.SendSocketMessage{ Cmd: utils.SendCmdLessonPreviewThumbnailUpdated, Body: struct { LessonId string ThumbnailUrl string }{ LessonId: params.LessonId, ThumbnailUrl: thumbnailUrl, }, }, ) return c.JSON(fiber.Map{ "status": "success", }) } func UpdateLessonState(c *fiber.Ctx) error { // swagger:operation PATCH /v1/lessons/{lessonId}/state lessons UpdateLessonState // --- // summary: Update lesson state. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: body // in: body // schema: // "$ref": "#/definitions/UpdateLessonStateRequest" // responses: // '200': // description: Lesson state updated successfully. // '500': // description: Failed to update lesson state. var params structs.LessonIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } var body structs.UpdateLessonStateRequest if err := c.BodyParser(&body); err != nil { return c.SendStatus(fiber.StatusBadRequest) } if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). Update("state", body.State).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicStartsWithExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessonsId(params.LessonId), c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonStateUpdated, Body: struct { LessonId string State uint8 }{ LessonId: params.LessonId, State: body.State, }, }, ) return c.JSON( fiber.Map{ "message": "Lesson state updated successfully", }, ) } func AddLessonContent(c *fiber.Ctx) error { // swagger:operation POST /v1/lessons/{lessonId}/contents lessons AddLessonContent // --- // summary: Add lesson content. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: body // in: body // schema: // "$ref": "#/definitions/AddLessonContentRequest" // responses: // '200': // description: Lesson content added successfully. // schema: // "$ref": "#/definitions/AddLessonContentResponse" // '500': // description: Failed to add lesson content. var params structs.LessonIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } var body structs.AddLessonContentRequest if err := c.BodyParser(&body); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // check if lesson belongs to organization var lesson models.Lesson if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusNotFound) } // get last position var lastContent models.LessonContent if err := database.DB.Select("position"). Model(&models.LessonContent{}). Where("lesson_id = ?", params.LessonId). Order("position desc"). First(&lastContent).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return c.SendStatus(fiber.StatusNotFound) } // no content found, set position to 1 lastContent.Position = 1 } // create new content content := models.LessonContent{ Id: uuid.New().String(), LessonId: params.LessonId, Page: 1, Position: lastContent.Position + 1, Type: body.Type, Data: body.Data, } if err := database.DB.Create(&content).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicStartsWithExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessonsId(params.LessonId), c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonAddedContent, Body: content, }, ) return c.JSON( structs.AddLessonContentResponse{ Id: content.Id, }, ) } func UploadLessonContentFile(c *fiber.Ctx) error { // swagger:operation POST /v1/lessons/{lessonId}/contents/{contentId}/file/{type} lessons UploadLessonContentFile // --- // summary: Upload lesson content file. // consumes: // - multipart/form-data // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: contentId // in: path // required: true // type: string // - name: file // in: formData // required: true // type: file // responses: // '200': // description: Lesson content file uploaded successfully. // '500': // description: Failed to upload lesson content file. var params structs.UploadLessonContentFileParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } fileHeader, err := c.FormFile("file") if err != nil { return c.SendStatus(fiber.StatusBadRequest) } if params.Type == "image" { if fileHeader.Size > utils.MaxImageSize { return c.SendStatus(fiber.StatusBadRequest) } if !utils.IsFileTypeAllowed(fileHeader.Header.Get("Content-Type"), utils.AcceptedImageFileTypes) { return c.SendStatus(fiber.StatusBadRequest) } } else if params.Type == "video" { if fileHeader.Size > utils.MaxVideoSize { return c.SendStatus(fiber.StatusBadRequest) } if !utils.IsFileTypeAllowed(fileHeader.Header.Get("Content-Type"), utils.AcceptedVideoFileTypes) { return c.SendStatus(fiber.StatusBadRequest) } } else { return c.SendStatus(fiber.StatusBadRequest) } // check if lesson belongs to organization var lesson models.Lesson if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusNotFound) } // get current file content := models.LessonContent{} if err := database.DB.Select("type", "data"). Model(&models.LessonContent{}). Where("id = ? AND lesson_id = ?", params.ContentId, params.LessonId). First(&content).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } // delete current image if content.Type == utils.LessonContentTypeImage && content.Data != "" { utils.DeleteFile(content.Data) } fileName := uuid.New().String() + "." + strings.Split(fileHeader.Header["Content-Type"][0], "/")[1] databasePath, publicPath := utils.GetFullImagePath(c.Locals("organizationId").(string), params.LessonId) utils.CreateFolderStructureIfNotExists(publicPath) if err := database.DB.Model(&models.LessonContent{}). Where("id = ? AND lesson_id = ?", params.ContentId, params.LessonId). Update("data", databasePath+fileName).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } if err := c.SaveFile(fileHeader, publicPath+fileName); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Failed to save file", }) } socketclients.BroadcastMessageToTopicStartsWithExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessonsId(params.LessonId), c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonContentFileUpdated, Body: struct { ContentId string LessonId string Data string }{ ContentId: params.ContentId, LessonId: params.LessonId, Data: databasePath + fileName, }, }, ) return c.JSON(fiber.Map{ "Data": databasePath + fileName, }) } func UpdateLessonContent(c *fiber.Ctx) error { // swagger:operation PATCH /v1/lessons/{lessonId}/contents/{contentId} lessons UpdateLessonContent // --- // summary: Update lesson content. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: contentId // in: path // required: true // type: string // - name: body // in: body // schema: // "$ref": "#/definitions/UpdateLessonContentRequest" // responses: // '200': // description: Lesson content updated successfully. // '500': // description: Failed to update lesson content. var params structs.LessonIdAndContentIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } var body structs.UpdateLessonContentRequest if err := c.BodyParser(&body); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // check if lesson belongs to organization var lesson models.Lesson if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusNotFound) } // update content if err := database.DB.Model(&models.LessonContent{}). Where("id = ? AND lesson_id = ?", params.ContentId, params.LessonId). Update("data", body.Data).Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicStartsWithExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessonsId(params.LessonId), c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonContentUpdated, Body: struct { ContentId string LessonId string Data string }{ ContentId: params.ContentId, LessonId: params.LessonId, Data: body.Data, }, }, ) return c.JSON( fiber.Map{ "message": "Lesson content updated successfully", }, ) } func UpdateLessonContentPosition(c *fiber.Ctx) error { // swagger:operation PATCH /v1/lessons/{lessonId}/contents/{contentId}/position lessons UpdateLessonContentPosition // --- // summary: Update lesson content position. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: contentId // in: path // required: true // type: string // - name: body // in: body // schema: // "$ref": "#/definitions/UpdateLessonContentPositionRequest" // responses: // '200': // description: Lesson content position updated successfully. // '500': // description: Failed to update lesson content position. var params structs.LessonIdAndContentIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } var body structs.UpdateLessonContentPositionRequest if err := c.BodyParser(&body); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // check if lesson belongs to organization var lesson models.Lesson if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusNotFound) } // update position newPosition := body.Position // Begin a transaction to ensure consistency tx := database.DB.Begin() if tx.Error != nil { return c.SendStatus(fiber.StatusInternalServerError) } // Fetch the current position of the content being moved var currentContent models.LessonContent if err := tx.Model(&models.LessonContent{}). Where("id = ? AND lesson_id = ?", params.ContentId, params.LessonId). First(¤tContent).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusNotFound) } oldPosition := currentContent.Position if oldPosition == newPosition { // No need to update if the position hasn't changed return c.JSON(fiber.Map{ "message": "Lesson content position updated successfully", }) } if oldPosition < newPosition { // Items between oldPosition and newPosition need to be shifted down if err := tx.Model(&models.LessonContent{}). Where("lesson_id = ?", params.LessonId). Where("position > ? AND position <= ?", oldPosition, newPosition). Update("position", gorm.Expr("position - 1")).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusInternalServerError) } } else { // Items between newPosition and oldPosition need to be shifted up if err := tx.Model(&models.LessonContent{}). Where("lesson_id = ?", params.LessonId). Where("position >= ? AND position < ?", newPosition, oldPosition). Update("position", gorm.Expr("position + 1")).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusInternalServerError) } } // Update the position of the moved content if err := tx.Model(&models.LessonContent{}). Where("id = ? AND lesson_id = ?", params.ContentId, params.LessonId). Update("position", newPosition).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusInternalServerError) } // Commit the transaction if err := tx.Commit().Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicStartsWithExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessonsId(params.LessonId), c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonContentUpdatedPosition, Body: struct { ContentId string LessonId string Position uint16 }{ ContentId: params.ContentId, LessonId: params.LessonId, Position: body.Position, }, }, ) return c.JSON( fiber.Map{ "message": "Lesson content position updated successfully", }, ) } func DeleteLessonContent(c *fiber.Ctx) error { // swagger:operation DELETE /v1/lessons/{lessonId}/contents/{contentId} lessons DeleteLessonContent // --- // summary: Delete lesson content. // consumes: // - application/json // produces: // - application/json // parameters: // - name: lessonId // in: path // required: true // type: string // - name: contentId // in: path // required: true // type: string // responses: // '200': // description: Lesson content deleted successfully. // '500': // description: Failed to delete lesson content. var params structs.LessonIdAndContentIdParam if err := c.ParamsParser(¶ms); err != nil { return c.SendStatus(fiber.StatusBadRequest) } // Begin a transaction to ensure consistency tx := database.DB.Begin() if tx.Error != nil { return c.SendStatus(fiber.StatusInternalServerError) } // check if lesson belongs to organization var lesson models.Lesson if err := database.DB.Model(&models.Lesson{}). Where("id = ? AND organization_id = ?", params.LessonId, c.Locals("organizationId").(string)). First(&lesson).Error; err != nil { return c.SendStatus(fiber.StatusNotFound) } // Fetch the current position of the content being deleted var content models.LessonContent if err := tx.Model(&models.LessonContent{}). Where("id = ? AND lesson_id = ?", params.ContentId, params.LessonId). First(&content).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusNotFound) } // Delete the content if err := tx.Delete(&content).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusInternalServerError) } // Shift down the positions of all contents with a higher position if err := tx.Model(&models.LessonContent{}). Where("lesson_id = ? AND position > ?", params.LessonId, content.Position). Update("position", gorm.Expr("position - 1")).Error; err != nil { tx.Rollback() return c.SendStatus(fiber.StatusInternalServerError) } // Commit the transaction if err := tx.Commit().Error; err != nil { return c.SendStatus(fiber.StatusInternalServerError) } socketclients.BroadcastMessageToTopicStartsWithExceptBrowserTabSession( c.Locals("organizationId").(string), utils.SubscribedTopicLessonsId(params.LessonId), c.Locals("browserTabSession").(string), structs.SendSocketMessage{ Cmd: utils.SendCmdLessonDeletedContent, Body: struct { ContentId string LessonId string }{ ContentId: params.ContentId, LessonId: params.LessonId, }, }, ) return c.JSON( fiber.Map{ "message": "Lesson content deleted successfully", }, ) }