Yimaru-BackEnd/internal/web_server/handlers/questions.go

1273 lines
42 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"context"
"encoding/json"
"fmt"
"strconv"
"github.com/gofiber/fiber/v2"
)
// Request/Response types for Questions
type optionInput struct {
OptionText string `json:"option_text" validate:"required"`
OptionOrder *int32 `json:"option_order"`
IsCorrect bool `json:"is_correct"`
}
type shortAnswerInput struct {
AcceptableAnswer string `json:"acceptable_answer" validate:"required"`
MatchType *string `json:"match_type"`
}
type createQuestionReq struct {
QuestionText string `json:"question_text" validate:"required"`
QuestionType string `json:"question_type" validate:"required,oneof=MCQ TRUE_FALSE SHORT_ANSWER"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
}
type optionRes struct {
ID int64 `json:"id"`
OptionText string `json:"option_text"`
OptionOrder int32 `json:"option_order"`
IsCorrect bool `json:"is_correct"`
}
type shortAnswerRes struct {
ID int64 `json:"id"`
AcceptableAnswer string `json:"acceptable_answer"`
MatchType string `json:"match_type"`
}
type questionRes struct {
ID int64 `json:"id"`
QuestionText string `json:"question_text"`
QuestionType string `json:"question_type"`
DifficultyLevel *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Options []optionRes `json:"options,omitempty"`
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
}
type listQuestionsRes struct {
Questions []questionRes `json:"questions"`
TotalCount int64 `json:"total_count"`
}
// CreateQuestion godoc
// @Summary Create a new question
// @Description Creates a new question with options (for MCQ/TRUE_FALSE) or short answers (for SHORT_ANSWER)
// @Tags questions
// @Accept json
// @Produce json
// @Param body body createQuestionReq true "Create question payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions [post]
func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
var req createQuestionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
// Build options input
var options []domain.CreateQuestionOptionInput
for _, opt := range req.Options {
options = append(options, domain.CreateQuestionOptionInput{
OptionText: opt.OptionText,
OptionOrder: opt.OptionOrder,
IsCorrect: opt.IsCorrect,
})
}
// Build short answers input
var shortAnswers []domain.CreateShortAnswerInput
for _, sa := range req.ShortAnswers {
shortAnswers = append(shortAnswers, domain.CreateShortAnswerInput{
AcceptableAnswer: sa.AcceptableAnswer,
MatchType: sa.MatchType,
})
}
input := domain.CreateQuestionInput{
QuestionText: req.QuestionText,
QuestionType: req.QuestionType,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
}
question, err := h.questionsSvc.CreateQuestion(c.Context(), input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create question",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"question_type": question.QuestionType})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionCreated, domain.ResourceQuestion, &question.ID, "Created question: "+question.QuestionText, meta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question created successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
},
})
}
// GetQuestionByID godoc
// @Summary Get question by ID
// @Description Returns a question with its options/short answers
// @Tags questions
// @Produce json
// @Param id path int true "Question ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/questions/{id} [get]
func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question ID",
Error: err.Error(),
})
}
question, err := h.questionsSvc.GetQuestionWithDetails(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question not found",
Error: err.Error(),
})
}
var options []optionRes
for _, opt := range question.Options {
options = append(options, optionRes{
ID: opt.ID,
OptionText: opt.OptionText,
OptionOrder: opt.OptionOrder,
IsCorrect: opt.IsCorrect,
})
}
var shortAnswers []shortAnswerRes
for _, sa := range question.ShortAnswers {
shortAnswers = append(shortAnswers, shortAnswerRes{
ID: sa.ID,
AcceptableAnswer: sa.AcceptableAnswer,
MatchType: sa.MatchType,
})
}
return c.JSON(domain.Response{
Message: "Question retrieved successfully",
Data: questionRes{
ID: question.ID,
QuestionText: question.QuestionText,
QuestionType: question.QuestionType,
DifficultyLevel: question.DifficultyLevel,
Points: question.Points,
Explanation: question.Explanation,
Tips: question.Tips,
VoicePrompt: question.VoicePrompt,
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
Status: question.Status,
CreatedAt: question.CreatedAt.String(),
Options: options,
ShortAnswers: shortAnswers,
},
})
}
// ListQuestions godoc
// @Summary List questions
// @Description Returns a paginated list of questions with optional filters
// @Tags questions
// @Produce json
// @Param question_type query string false "Question type filter (MCQ, TRUE_FALSE, SHORT_ANSWER)"
// @Param difficulty query string false "Difficulty level filter (EASY, MEDIUM, HARD)"
// @Param status query string false "Status filter (DRAFT, PUBLISHED, INACTIVE)"
// @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 500 {object} domain.ErrorResponse
// @Router /api/v1/questions [get]
func (h *Handler) ListQuestions(c *fiber.Ctx) error {
questionType := c.Query("question_type")
difficulty := c.Query("difficulty")
status := c.Query("status")
limitStr := c.Query("limit", "10")
offsetStr := c.Query("offset", "0")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
var qTypePtr, diffPtr, statusPtr *string
if questionType != "" {
qTypePtr = &questionType
}
if difficulty != "" {
diffPtr = &difficulty
}
if status != "" {
statusPtr = &status
}
questions, totalCount, err := h.questionsSvc.ListQuestions(c.Context(), qTypePtr, diffPtr, statusPtr, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list questions",
Error: err.Error(),
})
}
var questionResponses []questionRes
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Explanation: q.Explanation,
Tips: q.Tips,
VoicePrompt: q.VoicePrompt,
Status: q.Status,
CreatedAt: q.CreatedAt.String(),
})
}
return c.JSON(domain.Response{
Message: "Questions retrieved successfully",
Data: listQuestionsRes{
Questions: questionResponses,
TotalCount: totalCount,
},
})
}
// SearchQuestions godoc
// @Summary Search questions
// @Description Search questions by text
// @Tags questions
// @Produce json
// @Param q query string true "Search query"
// @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 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/search [get]
func (h *Handler) SearchQuestions(c *fiber.Ctx) error {
query := c.Query("q")
if query == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Search query is required",
})
}
limitStr := c.Query("limit", "10")
offsetStr := c.Query("offset", "0")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
questions, totalCount, err := h.questionsSvc.SearchQuestions(c.Context(), query, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to search questions",
Error: err.Error(),
})
}
var questionResponses []questionRes
for _, q := range questions {
questionResponses = append(questionResponses, questionRes{
ID: q.ID,
QuestionText: q.QuestionText,
QuestionType: q.QuestionType,
DifficultyLevel: q.DifficultyLevel,
Points: q.Points,
Status: q.Status,
CreatedAt: q.CreatedAt.String(),
})
}
return c.JSON(domain.Response{
Message: "Questions retrieved successfully",
Data: listQuestionsRes{
Questions: questionResponses,
TotalCount: totalCount,
},
})
}
type updateQuestionReq struct {
QuestionText *string `json:"question_text"`
QuestionType *string `json:"question_type"`
DifficultyLevel *string `json:"difficulty_level"`
Points *int32 `json:"points"`
Explanation *string `json:"explanation"`
Tips *string `json:"tips"`
VoicePrompt *string `json:"voice_prompt"`
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
Status *string `json:"status"`
Options []optionInput `json:"options"`
ShortAnswers []shortAnswerInput `json:"short_answers"`
}
// UpdateQuestion godoc
// @Summary Update a question
// @Description Updates a question and optionally replaces its options/short answers
// @Tags questions
// @Accept json
// @Produce json
// @Param id path int true "Question ID"
// @Param body body updateQuestionReq true "Update question payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/{id} [put]
func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question ID",
Error: err.Error(),
})
}
var req updateQuestionReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
var options []domain.CreateQuestionOptionInput
for _, opt := range req.Options {
options = append(options, domain.CreateQuestionOptionInput{
OptionText: opt.OptionText,
OptionOrder: opt.OptionOrder,
IsCorrect: opt.IsCorrect,
})
}
var shortAnswers []domain.CreateShortAnswerInput
for _, sa := range req.ShortAnswers {
shortAnswers = append(shortAnswers, domain.CreateShortAnswerInput{
AcceptableAnswer: sa.AcceptableAnswer,
MatchType: sa.MatchType,
})
}
questionText := ""
if req.QuestionText != nil {
questionText = *req.QuestionText
}
questionType := ""
if req.QuestionType != nil {
questionType = *req.QuestionType
}
input := domain.CreateQuestionInput{
QuestionText: questionText,
QuestionType: questionType,
DifficultyLevel: req.DifficultyLevel,
Points: req.Points,
Explanation: req.Explanation,
Tips: req.Tips,
VoicePrompt: req.VoicePrompt,
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
Status: req.Status,
Options: options,
ShortAnswers: shortAnswers,
}
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update question",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"question_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionUpdated, domain.ResourceQuestion, &id, fmt.Sprintf("Updated question ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question updated successfully",
})
}
// DeleteQuestion godoc
// @Summary Delete a question
// @Description Archives a question (soft delete)
// @Tags questions
// @Produce json
// @Param id path int true "Question ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/{id} [delete]
func (h *Handler) DeleteQuestion(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question ID",
Error: err.Error(),
})
}
err = h.questionsSvc.ArchiveQuestion(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete question",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"question_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionDeleted, domain.ResourceQuestion, &id, fmt.Sprintf("Deleted question ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question deleted successfully",
})
}
// Question Set types
type createQuestionSetReq struct {
Title string `json:"title" validate:"required"`
Description *string `json:"description"`
SetType string `json:"set_type" validate:"required,oneof=PRACTICE INITIAL_ASSESSMENT QUIZ EXAM SURVEY"`
OwnerType *string `json:"owner_type"`
OwnerID *int64 `json:"owner_id"`
BannerImage *string `json:"banner_image"`
Persona *string `json:"persona"`
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
}
type questionSetRes struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
SetType string `json:"set_type"`
OwnerType *string `json:"owner_type,omitempty"`
OwnerID *int64 `json:"owner_id,omitempty"`
BannerImage *string `json:"banner_image,omitempty"`
Persona *string `json:"persona,omitempty"`
TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"`
PassingScore *int32 `json:"passing_score,omitempty"`
ShuffleQuestions bool `json:"shuffle_questions"`
Status string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id,omitempty"`
CreatedAt string `json:"created_at"`
QuestionCount *int64 `json:"question_count,omitempty"`
}
type listQuestionSetsRes struct {
QuestionSets []questionSetRes `json:"question_sets"`
TotalCount int64 `json:"total_count"`
}
// CreateQuestionSet godoc
// @Summary Create a new question set
// @Description Creates a new question set (practice, assessment, quiz, exam, or survey)
// @Tags question-sets
// @Accept json
// @Produce json
// @Param body body createQuestionSetReq true "Create question set payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets [post]
func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
var req createQuestionSetReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
input := domain.CreateQuestionSetInput{
Title: req.Title,
Description: req.Description,
SetType: req.SetType,
OwnerType: req.OwnerType,
OwnerID: req.OwnerID,
BannerImage: req.BannerImage,
Persona: req.Persona,
TimeLimitMinutes: req.TimeLimitMinutes,
PassingScore: req.PassingScore,
ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status,
SubCourseVideoID: req.SubCourseVideoID,
}
set, err := h.questionsSvc.CreateQuestionSet(c.Context(), input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create question set",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
qsMeta, _ := json.Marshal(map[string]interface{}{"title": set.Title, "set_type": set.SetType})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetCreated, domain.ResourceQuestionSet, &set.ID, "Created question set: "+set.Title, qsMeta, &ip, &ua)
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question set created successfully",
Data: questionSetRes{
ID: set.ID,
Title: set.Title,
Description: set.Description,
SetType: set.SetType,
OwnerType: set.OwnerType,
OwnerID: set.OwnerID,
BannerImage: set.BannerImage,
Persona: set.Persona,
TimeLimitMinutes: set.TimeLimitMinutes,
PassingScore: set.PassingScore,
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
CreatedAt: set.CreatedAt.String(),
},
})
}
// GetQuestionSetByID godoc
// @Summary Get question set by ID
// @Description Returns a question set with question count
// @Tags question-sets
// @Produce json
// @Param id path int true "Question Set ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{id} [get]
func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question set ID",
Error: err.Error(),
})
}
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question set not found",
Error: err.Error(),
})
}
count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), id)
return c.JSON(domain.Response{
Message: "Question set retrieved successfully",
Data: questionSetRes{
ID: set.ID,
Title: set.Title,
Description: set.Description,
SetType: set.SetType,
OwnerType: set.OwnerType,
OwnerID: set.OwnerID,
BannerImage: set.BannerImage,
Persona: set.Persona,
TimeLimitMinutes: set.TimeLimitMinutes,
PassingScore: set.PassingScore,
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
CreatedAt: set.CreatedAt.String(),
QuestionCount: &count,
},
})
}
// GetQuestionSetsByType godoc
// @Summary Get question sets by type
// @Description Returns a paginated list of question sets filtered by type
// @Tags question-sets
// @Produce json
// @Param set_type query string true "Set type (PRACTICE, INITIAL_ASSESSMENT, QUIZ, EXAM, SURVEY)"
// @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 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets [get]
func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error {
setType := c.Query("set_type")
if setType == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "set_type query parameter is required",
})
}
limitStr := c.Query("limit", "10")
offsetStr := c.Query("offset", "0")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
sets, totalCount, err := h.questionsSvc.GetQuestionSetsByType(c.Context(), setType, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get question sets",
Error: err.Error(),
})
}
var setResponses []questionSetRes
for _, s := range sets {
setResponses = append(setResponses, questionSetRes{
ID: s.ID,
Title: s.Title,
Description: s.Description,
SetType: s.SetType,
OwnerType: s.OwnerType,
OwnerID: s.OwnerID,
BannerImage: s.BannerImage,
Persona: s.Persona,
TimeLimitMinutes: s.TimeLimitMinutes,
PassingScore: s.PassingScore,
ShuffleQuestions: s.ShuffleQuestions,
Status: s.Status,
SubCourseVideoID: s.SubCourseVideoID,
CreatedAt: s.CreatedAt.String(),
})
}
return c.JSON(domain.Response{
Message: "Question sets retrieved successfully",
Data: listQuestionSetsRes{
QuestionSets: setResponses,
TotalCount: totalCount,
},
})
}
// GetQuestionSetsByOwner godoc
// @Summary Get question sets by owner
// @Description Returns question sets for a specific owner (e.g., sub-course)
// @Tags question-sets
// @Produce json
// @Param owner_type query string true "Owner type (SUB_COURSE, COURSE, CATEGORY, STANDALONE)"
// @Param owner_id query int true "Owner ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/by-owner [get]
func (h *Handler) GetQuestionSetsByOwner(c *fiber.Ctx) error {
ownerType := c.Query("owner_type")
ownerIDStr := c.Query("owner_id")
if ownerType == "" || ownerIDStr == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "owner_type and owner_id query parameters are required",
})
}
ownerID, err := strconv.ParseInt(ownerIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid owner_id",
Error: err.Error(),
})
}
sets, err := h.questionsSvc.GetQuestionSetsByOwner(c.Context(), ownerType, ownerID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get question sets",
Error: err.Error(),
})
}
var setResponses []questionSetRes
for _, s := range sets {
setResponses = append(setResponses, questionSetRes{
ID: s.ID,
Title: s.Title,
Description: s.Description,
SetType: s.SetType,
OwnerType: s.OwnerType,
OwnerID: s.OwnerID,
BannerImage: s.BannerImage,
Persona: s.Persona,
TimeLimitMinutes: s.TimeLimitMinutes,
PassingScore: s.PassingScore,
ShuffleQuestions: s.ShuffleQuestions,
Status: s.Status,
SubCourseVideoID: s.SubCourseVideoID,
CreatedAt: s.CreatedAt.String(),
})
}
return c.JSON(domain.Response{
Message: "Question sets retrieved successfully",
Data: setResponses,
})
}
type updateQuestionSetReq struct {
Title *string `json:"title"`
Description *string `json:"description"`
BannerImage *string `json:"banner_image"`
Persona *string `json:"persona"`
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
}
// UpdateQuestionSet godoc
// @Summary Update a question set
// @Description Updates a question set's properties
// @Tags question-sets
// @Accept json
// @Produce json
// @Param id path int true "Question Set ID"
// @Param body body updateQuestionSetReq true "Update question set payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{id} [put]
func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question set ID",
Error: err.Error(),
})
}
var req updateQuestionSetReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
title := ""
if req.Title != nil {
title = *req.Title
}
input := domain.CreateQuestionSetInput{
Title: title,
Description: req.Description,
BannerImage: req.BannerImage,
Persona: req.Persona,
TimeLimitMinutes: req.TimeLimitMinutes,
PassingScore: req.PassingScore,
ShuffleQuestions: req.ShuffleQuestions,
Status: req.Status,
SubCourseVideoID: req.SubCourseVideoID,
}
err = h.questionsSvc.UpdateQuestionSet(c.Context(), id, input)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update question set",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"question_set_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, &id, fmt.Sprintf("Updated question set ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question set updated successfully",
})
}
// DeleteQuestionSet godoc
// @Summary Delete a question set
// @Description Archives a question set (soft delete)
// @Tags question-sets
// @Produce json
// @Param id path int true "Question Set ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{id} [delete]
func (h *Handler) DeleteQuestionSet(c *fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question set ID",
Error: err.Error(),
})
}
err = h.questionsSvc.ArchiveQuestionSet(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to delete question set",
Error: err.Error(),
})
}
actorID := c.Locals("user_id").(int64)
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"question_set_id": id})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetDeleted, domain.ResourceQuestionSet, &id, fmt.Sprintf("Deleted question set ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
Message: "Question set deleted successfully",
})
}
// Question Set Items
type addQuestionToSetReq struct {
QuestionID int64 `json:"question_id" validate:"required"`
DisplayOrder *int32 `json:"display_order"`
}
type questionSetItemRes struct {
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 *string `json:"difficulty_level,omitempty"`
Points int32 `json:"points"`
Explanation *string `json:"explanation,omitempty"`
Tips *string `json:"tips,omitempty"`
VoicePrompt *string `json:"voice_prompt,omitempty"`
QuestionStatus string `json:"question_status"`
}
// AddQuestionToSet godoc
// @Summary Add question to set
// @Description Links a question to a question set
// @Tags question-set-items
// @Accept json
// @Produce json
// @Param setId path int true "Question Set ID"
// @Param body body addQuestionToSetReq true "Add question to set payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/questions [post]
func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
var req addQuestionToSetReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
item, err := h.questionsSvc.AddQuestionToSet(c.Context(), setID, req.QuestionID, req.DisplayOrder)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to add question to set",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Question added to set successfully",
Data: map[string]interface{}{
"id": item.ID,
"set_id": item.SetID,
"question_id": item.QuestionID,
"display_order": item.DisplayOrder,
},
})
}
// GetQuestionSetItems godoc
// @Summary Get questions in set
// @Description Returns all questions in a question set with details
// @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 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/questions [get]
func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), setID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get questions in set",
Error: err.Error(),
})
}
var itemResponses []questionSetItemRes
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,
QuestionStatus: item.QuestionStatus,
})
}
return c.JSON(domain.Response{
Message: "Questions retrieved successfully",
Data: itemResponses,
})
}
// RemoveQuestionFromSet godoc
// @Summary Remove question from set
// @Description Unlinks a question from a question set
// @Tags question-set-items
// @Produce json
// @Param setId path int true "Question Set ID"
// @Param questionId path int true "Question ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/questions/{questionId} [delete]
func (h *Handler) RemoveQuestionFromSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
questionIDStr := c.Params("questionId")
questionID, err := strconv.ParseInt(questionIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question ID",
Error: err.Error(),
})
}
err = h.questionsSvc.RemoveQuestionFromSet(c.Context(), setID, questionID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove question from set",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question removed from set successfully",
})
}
type updateQuestionOrderReq struct {
DisplayOrder int32 `json:"display_order" validate:"required"`
}
// UpdateQuestionOrder godoc
// @Summary Update question order in set
// @Description Updates the display order of a question in a set
// @Tags question-set-items
// @Accept json
// @Produce json
// @Param setId path int true "Question Set ID"
// @Param questionId path int true "Question ID"
// @Param body body updateQuestionOrderReq true "Update order payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/questions/{questionId}/order [put]
func (h *Handler) UpdateQuestionOrderInSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
questionIDStr := c.Params("questionId")
questionID, err := strconv.ParseInt(questionIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid question ID",
Error: err.Error(),
})
}
var req updateQuestionOrderReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
err = h.questionsSvc.UpdateQuestionOrder(c.Context(), setID, questionID, req.DisplayOrder)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update question order",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Question order updated successfully",
})
}
// User Persona types for question sets
type userPersonaRes struct {
ID int64 `json:"id"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
NickName *string `json:"nick_name,omitempty"`
ProfilePictureURL *string `json:"profile_picture_url,omitempty"`
Role string `json:"role"`
DisplayOrder int32 `json:"display_order"`
}
type addUserPersonaReq struct {
UserID int64 `json:"user_id" validate:"required"`
DisplayOrder int32 `json:"display_order"`
}
// GetUserPersonasByQuestionSet godoc
// @Summary Get user personas for a question set
// @Description Returns all users assigned as personas to a question set (practice)
// @Tags question-sets
// @Produce json
// @Param setId path int true "Question Set ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/personas [get]
func (h *Handler) GetUserPersonasByQuestionSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
personas, err := h.questionsSvc.GetUserPersonasByQuestionSetID(c.Context(), setID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get personas",
Error: err.Error(),
})
}
result := make([]userPersonaRes, len(personas))
for i, p := range personas {
result[i] = userPersonaRes{
ID: p.ID,
FirstName: p.FirstName,
LastName: p.LastName,
NickName: p.NickName,
ProfilePictureURL: p.ProfilePictureURL,
Role: p.Role,
DisplayOrder: p.DisplayOrder,
}
}
return c.JSON(domain.Response{
Message: "Personas retrieved successfully",
Data: result,
})
}
// AddUserPersonaToQuestionSet godoc
// @Summary Add a user as persona to a question set
// @Description Links a user as a persona to a question set (practice)
// @Tags question-sets
// @Accept json
// @Produce json
// @Param setId path int true "Question Set ID"
// @Param body body addUserPersonaReq true "Add user persona payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/personas [post]
func (h *Handler) AddUserPersonaToQuestionSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
var req addUserPersonaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
err = h.questionsSvc.AddUserPersonaToQuestionSet(c.Context(), setID, req.UserID, req.DisplayOrder)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to add persona to question set",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Persona added to question set successfully",
})
}
// RemoveUserPersonaFromQuestionSet godoc
// @Summary Remove a user persona from a question set
// @Description Unlinks a user as persona from a question set (practice)
// @Tags question-sets
// @Produce json
// @Param setId path int true "Question Set ID"
// @Param userId path int true "User ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/{setId}/personas/{userId} [delete]
func (h *Handler) RemoveUserPersonaFromQuestionSet(c *fiber.Ctx) error {
setIDStr := c.Params("setId")
setID, err := strconv.ParseInt(setIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid set ID",
Error: err.Error(),
})
}
userIDStr := c.Params("userId")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.Error(),
})
}
err = h.questionsSvc.RemoveUserPersonaFromQuestionSet(c.Context(), setID, userID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove persona from question set",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Persona removed from question set successfully",
})
}