feat: add GET /practices/:id/full for practice with question set and questions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-12 02:15:37 -07:00
parent c9f4a9c428
commit 0e31a08903
3 changed files with 261 additions and 20 deletions

View File

@ -131,23 +131,86 @@ func (s *Service) applySharedUpdates(
} }
if synced == nil && in.Questions == nil { if synced == nil && in.Questions == nil {
items, err := s.questions.GetQuestionSetItems(ctx, questionSetID) _, synced, err = s.loadQuestionsInSet(ctx, questionSetID)
if err != nil { if err != nil {
return domain.QuestionSet{}, nil, err 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 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. // 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) { func (s *Service) UpdateLmsPracticeFull(ctx context.Context, practiceID int64, in domain.FullUpdatePracticeInput) (domain.FullUpdatePracticeResult, error) {
practice, err := s.lms.GetLmsPracticeByID(ctx, practiceID) practice, err := s.lms.GetLmsPracticeByID(ctx, practiceID)

View File

@ -3,26 +3,52 @@ package handlers
import ( import (
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/services/practicecontent" "Yimaru-Backend/internal/services/practicecontent"
"context"
"errors" "errors"
"strconv" "strconv"
"github.com/gofiber/fiber/v2" "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)) out := make([]questionRes, 0, len(questions))
for _, question := range 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 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{ return fiber.Map{
"practice": result.Practice, "practice": result.Practice,
"question_set": questionSetResFromDomain(result.QuestionSet), "question_set": qsRes,
"questions": fullUpdatePracticeQuestionResponses(result.Questions, catalog), "questions": fullPracticeQuestionResponses(result.Questions, displayOrderByQuestionID, catalog),
} }, nil
} }
func questionSetResFromDomain(set domain.QuestionSet) questionSetRes { 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 { switch {
case errors.Is(err, practicecontent.ErrPracticeNotFound): case errors.Is(err, practicecontent.ErrPracticeNotFound):
return fiber.StatusNotFound, "Practice not found" return fiber.StatusNotFound, "Practice not found"
@ -57,10 +83,144 @@ func mapFullUpdatePracticeError(err error) (int, string) {
case errors.Is(err, practicecontent.ErrInvalidQuestionItem): case errors.Is(err, practicecontent.ErrInvalidQuestionItem):
return fiber.StatusBadRequest, "Invalid question item" return fiber.StatusBadRequest, "Invalid question item"
default: default:
if action == "load" {
return fiber.StatusInternalServerError, "Failed to load practice"
}
return fiber.StatusBadRequest, "Failed to update 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 // UpdateLmsPracticeFull godoc
// @Summary Full update LMS practice with question set and questions // @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). // @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) result, err := h.practiceContentSvc.UpdateLmsPracticeFull(c.Context(), id, req)
if err != nil { if err != nil {
status, msg := mapFullUpdatePracticeError(err) status, msg := mapPracticeFullError(err, "update")
return c.Status(status).JSON(domain.ErrorResponse{ return c.Status(status).JSON(domain.ErrorResponse{
Message: msg, Message: msg,
Error: err.Error(), 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{ return c.JSON(domain.Response{
Message: "Practice updated successfully", Message: "Practice updated successfully",
Data: fullUpdatePracticeResponse(result, catalog), Data: data,
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
@ -146,7 +314,7 @@ func (h *Handler) UpdateExamPrepPracticeFull(c *fiber.Ctx) error {
result, err := h.practiceContentSvc.UpdateExamPrepPracticeFull(c.Context(), id, req) result, err := h.practiceContentSvc.UpdateExamPrepPracticeFull(c.Context(), id, req)
if err != nil { if err != nil {
status, msg := mapFullUpdatePracticeError(err) status, msg := mapPracticeFullError(err, "update")
return c.Status(status).JSON(domain.ErrorResponse{ return c.Status(status).JSON(domain.ErrorResponse{
Message: msg, Message: msg,
Error: err.Error(), 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{ return c.JSON(domain.Response{
Message: "Practice updated successfully", Message: "Practice updated successfully",
Data: fullUpdatePracticeResponse(result, catalog), Data: data,
Success: true, Success: true,
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })

View File

@ -115,6 +115,7 @@ func (a *App) initAppRoutes() {
examPrep.Post("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.create"), h.CreateExamPrepPractice) 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("/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", 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", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice)
examPrep.Put("/practices/:id/full", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPracticeFull) 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) 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.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", 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", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
groupV1.Put("/practices/:id/full", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdateLmsPracticeFull) 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) groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)