diff --git a/internal/services/practicecontent/service.go b/internal/services/practicecontent/service.go index bd554fa..20c54e8 100644 --- a/internal/services/practicecontent/service.go +++ b/internal/services/practicecontent/service.go @@ -131,23 +131,86 @@ func (s *Service) applySharedUpdates( } if synced == nil && in.Questions == nil { - items, err := s.questions.GetQuestionSetItems(ctx, questionSetID) + _, synced, err = s.loadQuestionsInSet(ctx, questionSetID) if err != nil { return domain.QuestionSet{}, nil, err } - synced = make([]domain.QuestionWithDetails, 0, len(items)) - for _, item := range items { - q, err := s.questions.GetQuestionWithDetails(ctx, item.QuestionID) - if err != nil { - return domain.QuestionSet{}, nil, err - } - synced = append(synced, q) - } } return set, synced, nil } +func (s *Service) loadQuestionsInSet(ctx context.Context, questionSetID int64) ([]domain.QuestionSetItemWithQuestion, []domain.QuestionWithDetails, error) { + items, err := s.questions.GetQuestionSetItems(ctx, questionSetID) + if err != nil { + return nil, nil, err + } + questions := make([]domain.QuestionWithDetails, 0, len(items)) + for _, item := range items { + q, err := s.questions.GetQuestionWithDetails(ctx, item.QuestionID) + if err != nil { + return nil, nil, err + } + questions = append(questions, q) + } + return items, questions, nil +} + +func (s *Service) loadPracticeFullContent(ctx context.Context, questionSetID int64) (domain.QuestionSet, []domain.QuestionWithDetails, error) { + set, err := s.questions.GetQuestionSetByID(ctx, questionSetID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.QuestionSet{}, nil, ErrQuestionSetNotFound + } + return domain.QuestionSet{}, nil, err + } + _, questions, err := s.loadQuestionsInSet(ctx, questionSetID) + if err != nil { + return domain.QuestionSet{}, nil, err + } + return set, questions, nil +} + +// GetLmsPracticeFull returns an LMS practice shell, its linked question set, and all questions in the set. +func (s *Service) GetLmsPracticeFull(ctx context.Context, practiceID int64) (domain.FullUpdatePracticeResult, error) { + practice, err := s.lms.GetLmsPracticeByID(ctx, practiceID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound + } + return domain.FullUpdatePracticeResult{}, err + } + set, questions, err := s.loadPracticeFullContent(ctx, practice.QuestionSetID) + if err != nil { + return domain.FullUpdatePracticeResult{}, err + } + return domain.FullUpdatePracticeResult{ + Practice: practice, + QuestionSet: set, + Questions: questions, + }, nil +} + +// GetExamPrepPracticeFull returns an exam-prep practice shell, its linked question set, and all questions in the set. +func (s *Service) GetExamPrepPracticeFull(ctx context.Context, practiceID int64) (domain.FullUpdatePracticeResult, error) { + practice, err := s.examPrep.GetExamPrepLessonPracticeByID(ctx, practiceID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound + } + return domain.FullUpdatePracticeResult{}, err + } + set, questions, err := s.loadPracticeFullContent(ctx, practice.QuestionSetID) + if err != nil { + return domain.FullUpdatePracticeResult{}, err + } + return domain.FullUpdatePracticeResult{ + Practice: practice, + QuestionSet: set, + Questions: questions, + }, nil +} + // UpdateLmsPracticeFull updates an LMS practice shell, linked question set, and questions. func (s *Service) UpdateLmsPracticeFull(ctx context.Context, practiceID int64, in domain.FullUpdatePracticeInput) (domain.FullUpdatePracticeResult, error) { practice, err := s.lms.GetLmsPracticeByID(ctx, practiceID) diff --git a/internal/web_server/handlers/practice_full_update_handler.go b/internal/web_server/handlers/practice_full_update_handler.go index 1a85830..739e2fa 100644 --- a/internal/web_server/handlers/practice_full_update_handler.go +++ b/internal/web_server/handlers/practice_full_update_handler.go @@ -3,26 +3,52 @@ package handlers import ( "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/services/practicecontent" + "context" "errors" "strconv" "github.com/gofiber/fiber/v2" ) -func fullUpdatePracticeQuestionResponses(questions []domain.QuestionWithDetails, catalog []domain.QuestionTypeDefinition) []questionRes { +func fullPracticeQuestionResponses( + questions []domain.QuestionWithDetails, + displayOrderByQuestionID map[int64]int32, + catalog []domain.QuestionTypeDefinition, +) []questionRes { out := make([]questionRes, 0, len(questions)) for _, question := range questions { - out = append(out, buildQuestionRes(question, catalog)) + res := buildQuestionRes(question, catalog) + if displayOrderByQuestionID != nil { + if order, ok := displayOrderByQuestionID[question.ID]; ok { + res.DisplayOrder = &order + } + } + out = append(out, res) } return out } -func fullUpdatePracticeResponse(result domain.FullUpdatePracticeResult, catalog []domain.QuestionTypeDefinition) fiber.Map { +func (h *Handler) buildFullPracticeResponse( + ctx context.Context, + result domain.FullUpdatePracticeResult, + catalog []domain.QuestionTypeDefinition, +) (fiber.Map, error) { + items, err := h.questionsSvc.GetQuestionSetItems(ctx, result.QuestionSet.ID) + if err != nil { + return nil, err + } + displayOrderByQuestionID := make(map[int64]int32, len(items)) + for _, item := range items { + displayOrderByQuestionID[item.QuestionID] = item.DisplayOrder + } + questionCount := int64(len(items)) + qsRes := questionSetResFromDomain(result.QuestionSet) + qsRes.QuestionCount = &questionCount return fiber.Map{ "practice": result.Practice, - "question_set": questionSetResFromDomain(result.QuestionSet), - "questions": fullUpdatePracticeQuestionResponses(result.Questions, catalog), - } + "question_set": qsRes, + "questions": fullPracticeQuestionResponses(result.Questions, displayOrderByQuestionID, catalog), + }, nil } func questionSetResFromDomain(set domain.QuestionSet) questionSetRes { @@ -44,7 +70,7 @@ func questionSetResFromDomain(set domain.QuestionSet) questionSetRes { } } -func mapFullUpdatePracticeError(err error) (int, string) { +func mapPracticeFullError(err error, action string) (int, string) { switch { case errors.Is(err, practicecontent.ErrPracticeNotFound): return fiber.StatusNotFound, "Practice not found" @@ -57,10 +83,144 @@ func mapFullUpdatePracticeError(err error) (int, string) { case errors.Is(err, practicecontent.ErrInvalidQuestionItem): return fiber.StatusBadRequest, "Invalid question item" default: + if action == "load" { + return fiber.StatusInternalServerError, "Failed to load practice" + } return fiber.StatusBadRequest, "Failed to update practice" } } +// GetLmsPracticeFull godoc +// @Summary Get LMS practice with question set and questions +// @Description Returns practice metadata, linked question set settings, and all questions in the set (same shape as PUT /practices/{id}/full). +// @Tags practices +// @Produce json +// @Param id path int true "Practice ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/practices/{id}/full [get] +func (h *Handler) GetLmsPracticeFull(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice id", + Error: err.Error(), + }) + } + + result, err := h.practiceContentSvc.GetLmsPracticeFull(c.Context(), id) + if err != nil { + status, msg := mapPracticeFullError(err, "load") + return c.Status(status).JSON(domain.ErrorResponse{ + Message: msg, + Error: err.Error(), + }) + } + + practice, ok := result.Practice.(domain.Practice) + if !ok { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load practice", + }) + } + if !practice.VisibleToLearners() && !h.canManageLMSPractices(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) + } + if err := h.applyPracticeAccess(c.Context(), c, []domain.Practice{practice}); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to build practice", + Error: err.Error(), + }) + } + result.Practice = practice + + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), + }) + } + + data, err := h.buildFullPracticeResponse(c.Context(), result, catalog) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load practice questions", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Practice retrieved successfully", + Data: data, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetExamPrepPracticeFull godoc +// @Summary Get exam-prep practice with question set and questions +// @Description Returns exam-prep practice metadata, linked question set settings, and all questions in the set. +// @Tags exam-prep +// @Produce json +// @Param id path int true "Exam prep practice ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/exam-prep/practices/{id}/full [get] +func (h *Handler) GetExamPrepPracticeFull(c *fiber.Ctx) error { + id, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice id", + Error: err.Error(), + }) + } + + result, err := h.practiceContentSvc.GetExamPrepPracticeFull(c.Context(), id) + if err != nil { + status, msg := mapPracticeFullError(err, "load") + return c.Status(status).JSON(domain.ErrorResponse{ + Message: msg, + Error: err.Error(), + }) + } + + practice, ok := result.Practice.(domain.ExamPrepPractice) + if !ok { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load practice", + }) + } + if !practice.VisibleToLearners() && !h.canManageExamPrepPractices(c) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Practice not found"}) + } + + catalog, err := h.activeQuestionTypeDefinitionCatalog(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load question type catalog", + Error: err.Error(), + }) + } + + data, err := h.buildFullPracticeResponse(c.Context(), result, catalog) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load practice questions", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Practice retrieved successfully", + Data: data, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + // UpdateLmsPracticeFull godoc // @Summary Full update LMS practice with question set and questions // @Description Updates practice metadata, linked question set settings, and syncs questions in one request. Questions omitted from the questions array are removed from the set (not deleted from the bank). @@ -92,7 +252,7 @@ func (h *Handler) UpdateLmsPracticeFull(c *fiber.Ctx) error { result, err := h.practiceContentSvc.UpdateLmsPracticeFull(c.Context(), id, req) if err != nil { - status, msg := mapFullUpdatePracticeError(err) + status, msg := mapPracticeFullError(err, "update") return c.Status(status).JSON(domain.ErrorResponse{ Message: msg, Error: err.Error(), @@ -107,9 +267,17 @@ func (h *Handler) UpdateLmsPracticeFull(c *fiber.Ctx) error { }) } + data, err := h.buildFullPracticeResponse(c.Context(), result, catalog) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load practice questions", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ Message: "Practice updated successfully", - Data: fullUpdatePracticeResponse(result, catalog), + Data: data, Success: true, StatusCode: fiber.StatusOK, }) @@ -146,7 +314,7 @@ func (h *Handler) UpdateExamPrepPracticeFull(c *fiber.Ctx) error { result, err := h.practiceContentSvc.UpdateExamPrepPracticeFull(c.Context(), id, req) if err != nil { - status, msg := mapFullUpdatePracticeError(err) + status, msg := mapPracticeFullError(err, "update") return c.Status(status).JSON(domain.ErrorResponse{ Message: msg, Error: err.Error(), @@ -161,9 +329,17 @@ func (h *Handler) UpdateExamPrepPracticeFull(c *fiber.Ctx) error { }) } + data, err := h.buildFullPracticeResponse(c.Context(), result, catalog) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load practice questions", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ Message: "Practice updated successfully", - Data: fullUpdatePracticeResponse(result, catalog), + Data: data, Success: true, StatusCode: fiber.StatusOK, }) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e6d3e26..4e459e1 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -115,6 +115,7 @@ func (a *App) initAppRoutes() { examPrep.Post("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.create"), h.CreateExamPrepPractice) examPrep.Get("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.list_by_lesson"), h.ListExamPrepPracticesByLesson) examPrep.Get("/practices/:id", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeByID) + examPrep.Get("/practices/:id/full", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeFull) examPrep.Put("/practices/:id", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice) examPrep.Put("/practices/:id/full", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPracticeFull) examPrep.Delete("/practices/:id", a.RequirePermission("exam_prep.practices.delete"), h.DeleteExamPrepPractice) @@ -160,6 +161,7 @@ func (a *App) initAppRoutes() { groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice) groupV1.Get("/practices/:id", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("practices.get"), h.GetPractice) + groupV1.Get("/practices/:id/full", a.authMiddleware, a.RequireLMSSubscriptionUnlessFree(), a.RequirePermission("practices.get"), h.GetLmsPracticeFull) groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice) groupV1.Put("/practices/:id/full", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdateLmsPracticeFull) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)