From 5858aeb744a94ea93986a2c7bd5a320b42e784f1 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 04:06:49 -0700 Subject: [PATCH] course CRUD fix --- .../web_server/handlers/hierarchy_handler.go | 198 ++++++++++++++++++ internal/web_server/routes.go | 4 + 2 files changed, 202 insertions(+) diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go index 647ad5b..4c493d6 100644 --- a/internal/web_server/handlers/hierarchy_handler.go +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -23,6 +23,27 @@ type createCourseCategoryReq struct { IsActive *bool `json:"is_active"` } +type createCourseReq struct { + CategoryID int64 `json:"category_id"` + Title string `json:"title"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + IntroVideoURL *string `json:"intro_video_url"` + IsActive *bool `json:"is_active"` +} + +type updateCourseReq struct { + Title *string `json:"title"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + IntroVideoURL *string `json:"intro_video_url"` + IsActive *bool `json:"is_active"` +} + +type updateCourseThumbnailReq struct { + ThumbnailURL string `json:"thumbnail_url"` +} + type createLevelReq struct { CourseID int64 `json:"course_id"` CEFRLevel string `json:"cefr_level"` @@ -178,6 +199,183 @@ func (h *Handler) CreateCourseCategory(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course category created", Data: created}) } +// CreateCourse godoc +// @Summary Create course +// @Description Legacy-compatible endpoint for creating a course +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createCourseReq true "Create course payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses [post] +func (h *Handler) CreateCourse(c *fiber.Ctx) error { + var req createCourseReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.CategoryID <= 0 || strings.TrimSpace(req.Title) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and title are required"}) + } + + created, err := h.analyticsDB.CreateCourse(c.Context(), dbgen.CreateCourseParams{ + CategoryID: req.CategoryID, + Title: req.Title, + Description: toText(req.Description), + Thumbnail: toText(req.Thumbnail), + IntroVideoUrl: toText(req.IntroVideoURL), + Column6: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create course", Error: err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course created", Data: created}) +} + +// UpdateCourse godoc +// @Summary Update course +// @Description Legacy-compatible endpoint for updating a course +// @Tags course-management +// @Accept json +// @Produce json +// @Param courseId path int true "Course ID" +// @Param body body updateCourseReq true "Update course payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId} [put] +func (h *Handler) UpdateCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil || courseID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) + } + + var req updateCourseReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + + existing, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()}) + } + + title := existing.Title + if req.Title != nil { + title = *req.Title + } + description := existing.Description + if req.Description != nil { + description = toText(req.Description) + } + thumbnail := existing.Thumbnail + if req.Thumbnail != nil { + thumbnail = toText(req.Thumbnail) + } + introVideo := existing.IntroVideoUrl + if req.IntroVideoURL != nil { + introVideo = toText(req.IntroVideoURL) + } + isActive := existing.IsActive + if req.IsActive != nil { + isActive = *req.IsActive + } + + if err := h.analyticsDB.UpdateCourse(c.Context(), dbgen.UpdateCourseParams{ + Title: title, + Description: description, + Thumbnail: thumbnail, + IntroVideoUrl: introVideo, + IsActive: isActive, + ID: courseID, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update course", Error: err.Error()}) + } + + updated, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Course updated but failed to fetch latest record", Error: err.Error()}) + } + + return c.JSON(domain.Response{Message: "Course updated", Data: updated}) +} + +// DeleteCourse godoc +// @Summary Delete course +// @Description Legacy-compatible endpoint for deleting a course +// @Tags course-management +// @Produce json +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId} [delete] +func (h *Handler) DeleteCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil || courseID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) + } + + if err := h.analyticsDB.DeleteCourse(c.Context(), courseID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete course", Error: err.Error()}) + } + + return c.JSON(domain.Response{Message: "Course deleted"}) +} + +// UpdateCourseThumbnail godoc +// @Summary Update course thumbnail +// @Description Legacy-compatible endpoint for updating course thumbnail +// @Tags course-management +// @Accept json +// @Produce json +// @Param courseId path int true "Course ID" +// @Param body body updateCourseThumbnailReq true "Update course thumbnail payload" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId}/thumbnail [post] +func (h *Handler) UpdateCourseThumbnail(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil || courseID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) + } + + var req updateCourseThumbnailReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + + existing, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()}) + } + thumb := req.ThumbnailURL + if strings.TrimSpace(thumb) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "thumbnail_url is required"}) + } + + if err := h.analyticsDB.UpdateCourse(c.Context(), dbgen.UpdateCourseParams{ + Title: existing.Title, + Description: existing.Description, + Thumbnail: pgtype.Text{String: thumb, Valid: true}, + IntroVideoUrl: existing.IntroVideoUrl, + IsActive: existing.IsActive, + ID: courseID, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update course thumbnail", Error: err.Error()}) + } + + updated, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Thumbnail updated but failed to fetch latest record", Error: err.Error()}) + } + + return c.JSON(domain.Response{Message: "Course thumbnail updated", Data: updated}) +} + // UnifiedHierarchy godoc // @Summary Get unified course hierarchy // @Description Returns full hierarchy: category -> sub-category -> course diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 567e536..63c360c 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -81,6 +81,10 @@ func (a *App) initAppRoutes() { // Unified Course Management (single hierarchy model) groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseCategories) groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory) + groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) + groupV1.Put("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) + groupV1.Delete("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) + groupV1.Post("/course-management/courses/:courseId/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UpdateCourseThumbnail) groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy) groupV1.Get("/course-management/courses/:courseId/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchyByCourse) groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory)