diff --git a/db/query/question_set_items.sql b/db/query/question_set_items.sql index 718c022..448dd2a 100644 --- a/db/query/question_set_items.sql +++ b/db/query/question_set_items.sql @@ -29,6 +29,33 @@ WHERE qsi.set_id = $1 AND q.status != 'ARCHIVED' ORDER BY qsi.display_order; +-- name: GetQuestionSetItemsPaginated :many +SELECT + COUNT(*) OVER () AS total_count, + qsi.id, + qsi.set_id, + qsi.question_id, + qsi.display_order, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt, + q.sample_answer_voice_prompt, + q.image_url, + q.status AS question_status, + qaa.correct_answer_text AS audio_correct_answer_text +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +ORDER BY qsi.display_order +LIMIT sqlc.narg('limit')::INT +OFFSET sqlc.narg('offset')::INT; + -- name: GetPublishedQuestionsInSet :many SELECT qsi.id, diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index a78a515..71d08da 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -197,6 +197,96 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu return items, nil } +const GetQuestionSetItemsPaginated = `-- name: GetQuestionSetItemsPaginated :many +SELECT + COUNT(*) OVER () AS total_count, + qsi.id, + qsi.set_id, + qsi.question_id, + qsi.display_order, + q.question_text, + q.question_type, + q.difficulty_level, + q.points, + q.explanation, + q.tips, + q.voice_prompt, + q.sample_answer_voice_prompt, + q.image_url, + q.status AS question_status, + qaa.correct_answer_text AS audio_correct_answer_text +FROM question_set_items qsi +JOIN questions q ON q.id = qsi.question_id +LEFT JOIN question_audio_answers qaa ON qaa.question_id = q.id +WHERE qsi.set_id = $1 + AND q.status != 'ARCHIVED' +ORDER BY qsi.display_order +LIMIT $3::INT +OFFSET $2::INT +` + +type GetQuestionSetItemsPaginatedParams struct { + SetID int64 `json:"set_id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +type GetQuestionSetItemsPaginatedRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + SetID int64 `json:"set_id"` + QuestionID int64 `json:"question_id"` + DisplayOrder int32 `json:"display_order"` + QuestionText string `json:"question_text"` + QuestionType string `json:"question_type"` + DifficultyLevel pgtype.Text `json:"difficulty_level"` + Points int32 `json:"points"` + Explanation pgtype.Text `json:"explanation"` + Tips pgtype.Text `json:"tips"` + VoicePrompt pgtype.Text `json:"voice_prompt"` + SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + ImageUrl pgtype.Text `json:"image_url"` + QuestionStatus string `json:"question_status"` + AudioCorrectAnswerText pgtype.Text `json:"audio_correct_answer_text"` +} + +func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuestionSetItemsPaginatedParams) ([]GetQuestionSetItemsPaginatedRow, error) { + rows, err := q.db.Query(ctx, GetQuestionSetItemsPaginated, arg.SetID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetQuestionSetItemsPaginatedRow + for rows.Next() { + var i GetQuestionSetItemsPaginatedRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.SetID, + &i.QuestionID, + &i.DisplayOrder, + &i.QuestionText, + &i.QuestionType, + &i.DifficultyLevel, + &i.Points, + &i.Explanation, + &i.Tips, + &i.VoicePrompt, + &i.SampleAnswerVoicePrompt, + &i.ImageUrl, + &i.QuestionStatus, + &i.AudioCorrectAnswerText, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url FROM question_sets qs diff --git a/internal/domain/questions.go b/internal/domain/questions.go index d5c9a20..3e4670a 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -127,7 +127,9 @@ type QuestionSetItemWithQuestion struct { Explanation *string Tips *string VoicePrompt *string + SampleAnswerVoicePrompt *string ImageURL *string + AudioCorrectAnswerText *string QuestionStatus string } diff --git a/internal/ports/questions.go b/internal/ports/questions.go index 753868f..6c7f1ee 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -47,6 +47,7 @@ type QuestionStore interface { // Question Set Items AddQuestionToSet(ctx context.Context, setID, questionID int64, displayOrder *int32) (domain.QuestionSetItem, error) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) + GetQuestionSetItemsPaginated(ctx context.Context, setID int64, limit, offset int32) ([]domain.QuestionSetItemWithQuestion, int64, error) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error diff --git a/internal/repository/questions.go b/internal/repository/questions.go index 92e2fc9..d5b118b 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -743,13 +743,54 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain. Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), + SampleAnswerVoicePrompt: nil, ImageURL: fromPgText(r.ImageUrl), + AudioCorrectAnswerText: nil, QuestionStatus: r.QuestionStatus, } } return result, nil } +func (s *Store) GetQuestionSetItemsPaginated(ctx context.Context, setID int64, limit, offset int32) ([]domain.QuestionSetItemWithQuestion, int64, error) { + rows, err := s.queries.GetQuestionSetItemsPaginated(ctx, dbgen.GetQuestionSetItemsPaginatedParams{ + SetID: setID, + Offset: pgtype.Int4{Int32: offset, Valid: true}, + Limit: pgtype.Int4{Int32: limit, Valid: true}, + }) + if err != nil { + return nil, 0, err + } + + var totalCount int64 + result := make([]domain.QuestionSetItemWithQuestion, len(rows)) + for i, r := range rows { + if i == 0 { + totalCount = r.TotalCount + } + result[i] = domain.QuestionSetItemWithQuestion{ + QuestionSetItem: domain.QuestionSetItem{ + ID: r.ID, + SetID: r.SetID, + QuestionID: r.QuestionID, + DisplayOrder: r.DisplayOrder, + }, + QuestionText: r.QuestionText, + QuestionType: r.QuestionType, + DifficultyLevel: fromPgText(r.DifficultyLevel), + Points: r.Points, + Explanation: fromPgText(r.Explanation), + Tips: fromPgText(r.Tips), + VoicePrompt: fromPgText(r.VoicePrompt), + SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + ImageURL: fromPgText(r.ImageUrl), + AudioCorrectAnswerText: fromPgText(r.AudioCorrectAnswerText), + QuestionStatus: r.QuestionStatus, + } + } + return result, totalCount, nil +} + func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { rows, err := s.queries.GetPublishedQuestionsInSet(ctx, setID) if err != nil { diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index a544dfc..c813d41 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -154,6 +154,10 @@ func (s *Service) GetQuestionSetItems(ctx context.Context, setID int64) ([]domai return s.questionStore.GetQuestionSetItems(ctx, setID) } +func (s *Service) GetQuestionSetItemsPaginated(ctx context.Context, setID int64, limit, offset int32) ([]domain.QuestionSetItemWithQuestion, int64, error) { + return s.questionStore.GetQuestionSetItemsPaginated(ctx, setID, limit, offset) +} + func (s *Service) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetItemWithQuestion, error) { return s.questionStore.GetPublishedQuestionsInSet(ctx, setID) } diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 1b5b694..f1da2ed 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -1053,9 +1053,19 @@ type questionSetItemRes struct { Explanation *string `json:"explanation,omitempty"` Tips *string `json:"tips,omitempty"` VoicePrompt *string `json:"voice_prompt,omitempty"` + SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` QuestionStatus string `json:"question_status"` } +type paginatedQuestionSetItemsRes struct { + Questions []questionSetItemRes `json:"questions"` + TotalCount int64 `json:"total_count"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + // AddQuestionToSet godoc // @Summary Add question to set // @Description Links a question to a question set @@ -1166,6 +1176,9 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { Explanation: item.Explanation, Tips: item.Tips, VoicePrompt: item.VoicePrompt, + SampleAnswerVoicePrompt: item.SampleAnswerVoicePrompt, + ImageURL: item.ImageURL, + AudioCorrectAnswerText: item.AudioCorrectAnswerText, QuestionStatus: item.QuestionStatus, }) } @@ -1176,6 +1189,106 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { }) } +// GetQuestionsByPractice godoc +// @Summary Get questions by practice +// @Description Returns paginated questions for a practice(question-set), including AUDIO fields +// @Tags question-set-items +// @Produce json +// @Param practiceId path int true "Practice(question-set) ID" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/practices/{practiceId}/questions [get] +func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error { + practiceIDStr := c.Params("practiceId") + practiceID, err := strconv.ParseInt(practiceIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice ID", + Error: err.Error(), + }) + } + + set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), practiceID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Practice not found", + Error: err.Error(), + }) + } + + if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Question set is not a practice", + }) + } + + if err := h.enforcePracticeSequenceForStudent(c, set); err != nil { + status := fiber.StatusForbidden + if ferr, ok := err.(*fiber.Error); ok { + status = ferr.Code + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Practice is locked", + Error: err.Error(), + }) + } + + limit, _ := strconv.Atoi(c.Query("limit", "10")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + if limit <= 0 { + limit = 10 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + + items, totalCount, err := h.questionsSvc.GetQuestionSetItemsPaginated(c.Context(), practiceID, int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get practice questions", + Error: err.Error(), + }) + } + + itemResponses := make([]questionSetItemRes, 0, len(items)) + for _, item := range items { + itemResponses = append(itemResponses, questionSetItemRes{ + ID: item.ID, + SetID: item.SetID, + QuestionID: item.QuestionID, + DisplayOrder: item.DisplayOrder, + QuestionText: item.QuestionText, + QuestionType: item.QuestionType, + DifficultyLevel: item.DifficultyLevel, + Points: item.Points, + Explanation: item.Explanation, + Tips: item.Tips, + VoicePrompt: item.VoicePrompt, + SampleAnswerVoicePrompt: item.SampleAnswerVoicePrompt, + ImageURL: item.ImageURL, + AudioCorrectAnswerText: item.AudioCorrectAnswerText, + QuestionStatus: item.QuestionStatus, + }) + } + + return c.JSON(domain.Response{ + Message: "Practice questions retrieved successfully", + Data: paginatedQuestionSetItemsRes{ + Questions: itemResponses, + TotalCount: totalCount, + Limit: int32(limit), + Offset: int32(offset), + }, + }) +} + // CompletePractice godoc // @Summary Mark practice as completed // @Description Marks a practice question set as completed for the authenticated learner diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 76491c8..0b4ec02 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -149,6 +149,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/questions", a.authMiddleware, a.RequirePermission("question_set_items.list"), h.GetQuestionsInSet) + groupV1.Get("/practices/:practiceId/questions", a.authMiddleware, 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) groupV1.Put("/question-sets/:setId/questions/:questionId/order", a.authMiddleware, a.RequirePermission("question_set_items.update_order"), h.UpdateQuestionOrderInSet)