From a75700ffaa4faf1cf677cffdd22bf113c48168ce Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 2 Jun 2026 06:35:46 -0700 Subject: [PATCH] Add GET question-sets question-types endpoint for practice sets. Returns distinct question_type values with per-type counts so clients can resolve types from question_set_id without loading full questions. Co-authored-by: Cursor --- db/query/question_set_items.sql | 11 ++++++ gen/db/question_set_items.sql.go | 37 +++++++++++++++++++ internal/domain/questions.go | 13 +++++++ internal/ports/questions.go | 1 + internal/repository/questions.go | 15 ++++++++ internal/services/questions/service.go | 19 ++++++++++ internal/web_server/handlers/questions.go | 43 +++++++++++++++++++++++ internal/web_server/routes.go | 1 + 8 files changed, 140 insertions(+) diff --git a/db/query/question_set_items.sql b/db/query/question_set_items.sql index 5efa846..0e217cc 100644 --- a/db/query/question_set_items.sql +++ b/db/query/question_set_items.sql @@ -102,6 +102,17 @@ JOIN questions q ON q.id = qsi.question_id WHERE qsi.set_id = $1 AND q.status != 'ARCHIVED'; +-- name: GetQuestionTypeCountsInSet :many +SELECT + q.question_type, + COUNT(*)::bigint AS question_count +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +GROUP BY q.question_type +ORDER BY q.question_type; + -- name: GetQuestionSetsContainingQuestion :many SELECT qs.* FROM question_sets qs diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index 08e75e9..d71aa8d 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -362,6 +362,43 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio return items, nil } +const GetQuestionTypeCountsInSet = `-- name: GetQuestionTypeCountsInSet :many +SELECT + q.question_type, + COUNT(*)::bigint AS question_count +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +GROUP BY q.question_type +ORDER BY q.question_type +` + +type GetQuestionTypeCountsInSetRow struct { + QuestionType string `json:"question_type"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]GetQuestionTypeCountsInSetRow, error) { + rows, err := q.db.Query(ctx, GetQuestionTypeCountsInSet, setID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetQuestionTypeCountsInSetRow + for rows.Next() { + var i GetQuestionTypeCountsInSetRow + if err := rows.Scan(&i.QuestionType, &i.QuestionCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const RemoveQuestionFromSet = `-- name: RemoveQuestionFromSet :exec DELETE FROM question_set_items WHERE set_id = $1 AND question_id = $2 diff --git a/internal/domain/questions.go b/internal/domain/questions.go index cec5944..b14db94 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -120,6 +120,19 @@ type QuestionSetItem struct { CreatedAt time.Time } +// QuestionSetQuestionTypeCount is one question_type present in a set with how many questions use it. +type QuestionSetQuestionTypeCount struct { + QuestionType string `json:"question_type"` + Count int64 `json:"count"` +} + +// QuestionSetQuestionTypesSummary summarizes distinct question types in a question set (e.g. linked practice). +type QuestionSetQuestionTypesSummary struct { + QuestionSetID int64 `json:"question_set_id"` + QuestionTypes []QuestionSetQuestionTypeCount `json:"question_types"` + TotalQuestions int64 `json:"total_questions"` +} + type QuestionSetItemWithQuestion struct { QuestionSetItem QuestionText string diff --git a/internal/ports/questions.go b/internal/ports/questions.go index db56086..76b0ce3 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -58,6 +58,7 @@ type QuestionStore interface { RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) + GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeCount, error) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) // User Personas in Question Sets diff --git a/internal/repository/questions.go b/internal/repository/questions.go index 814cade..8097a2b 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -1226,6 +1226,21 @@ func (s *Store) CountQuestionsInSet(ctx context.Context, setID int64) (int64, er return s.queries.CountQuestionsInSet(ctx, setID) } +func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeCount, error) { + rows, err := s.queries.GetQuestionTypeCountsInSet(ctx, setID) + if err != nil { + return nil, err + } + result := make([]domain.QuestionSetQuestionTypeCount, len(rows)) + for i, r := range rows { + result[i] = domain.QuestionSetQuestionTypeCount{ + QuestionType: r.QuestionType, + Count: r.QuestionCount, + } + } + return result, nil +} + func (s *Store) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) { sets, err := s.queries.GetQuestionSetsContainingQuestion(ctx, questionID) if err != nil { diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index 71a9b23..a56356f 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -192,6 +192,25 @@ func (s *Service) CountQuestionsInSet(ctx context.Context, setID int64) (int64, return s.questionStore.CountQuestionsInSet(ctx, setID) } +func (s *Service) GetQuestionTypesInSet(ctx context.Context, setID int64) (domain.QuestionSetQuestionTypesSummary, error) { + counts, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID) + if err != nil { + return domain.QuestionSetQuestionTypesSummary{}, err + } + var total int64 + for _, c := range counts { + total += c.Count + } + if counts == nil { + counts = []domain.QuestionSetQuestionTypeCount{} + } + return domain.QuestionSetQuestionTypesSummary{ + QuestionSetID: setID, + QuestionTypes: counts, + TotalQuestions: total, + }, nil +} + func (s *Service) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) { return s.questionStore.GetQuestionSetsContainingQuestion(ctx, questionID) } diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index ac25bc5..4f0cd50 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -1322,6 +1322,49 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error { }) } +// GetQuestionTypesInSet godoc +// @Summary List question types in a question set +// @Description Returns distinct question_type values (with counts) for non-archived questions linked to the set. Use the question_set_id from a practice to see which types were used to build it. +// @Tags question-set-items +// @Produce json +// @Param setId path int true "Question Set ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/question-sets/{setId}/question-types [get] +func (h *Handler) GetQuestionTypesInSet(c *fiber.Ctx) error { + setID, err := strconv.ParseInt(c.Params("setId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid set ID", + Error: err.Error(), + }) + } + + if _, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID); err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Question set not found", + Error: err.Error(), + }) + } + + summary, err := h.questionsSvc.GetQuestionTypesInSet(c.Context(), setID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get question types in set", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Question types retrieved successfully", + Success: true, + StatusCode: fiber.StatusOK, + Data: summary, + }) +} + // GetQuestionSetItems godoc // @Summary Get questions in set // @Description Returns all questions in a question set with details diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index f690fe8..00d5517 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -243,6 +243,7 @@ func (a *App) initAppRoutes() { // Question Set Items groupV1.Post("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.add"), h.AddQuestionToSet) + groupV1.Get("/question-sets/:setId/question-types", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionTypesInSet) groupV1.Get("/question-sets/:setId/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet) groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("question_set_items.list"), h.GetQuestionsByPractice) groupV1.Delete("/question-sets/:setId/questions/:questionId", a.authMiddleware, a.RequirePermission("question_set_items.remove"), h.RemoveQuestionFromSet)