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 {
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)

View File

@ -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,
})

View File

@ -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)