package handlers import ( "Yimaru-Backend/internal/domain" "context" "encoding/json" "fmt" "strconv" "strings" "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 AUDIO"` 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"` ImageURL *string `json:"image_url"` Status *string `json:"status"` Options []optionInput `json:"options"` ShortAnswers []shortAnswerInput `json:"short_answers"` AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` } 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"` ImageURL *string `json:"image_url,omitempty"` Status string `json:"status"` CreatedAt string `json:"created_at"` Options []optionRes `json:"options,omitempty"` ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"` AudioCorrectAnswerText *string `json:"audio_correct_answer_text,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, ImageURL: req.ImageURL, Status: req.Status, Options: options, ShortAnswers: shortAnswers, AudioCorrectAnswerText: req.AudioCorrectAnswerText, } 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, ImageURL: question.ImageURL, 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, }) } var audioCorrectAnswerText *string if question.AudioAnswer != nil { audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText } 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, ImageURL: question.ImageURL, Status: question.Status, CreatedAt: question.CreatedAt.String(), Options: options, ShortAnswers: shortAnswers, AudioCorrectAnswerText: audioCorrectAnswerText, }, }) } // 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"` ImageURL *string `json:"image_url"` Status *string `json:"status"` Options []optionInput `json:"options"` ShortAnswers []shortAnswerInput `json:"short_answers"` AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` } // 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, ImageURL: req.ImageURL, Status: req.Status, Options: options, ShortAnswers: shortAnswers, AudioCorrectAnswerText: req.AudioCorrectAnswerText, } 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"` } func isSubCoursePractice(set domain.QuestionSet) bool { return strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) && set.OwnerType != nil && strings.EqualFold(*set.OwnerType, "SUB_COURSE") } func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error { role := c.Locals("role").(domain.Role) if role != domain.RoleStudent || !isSubCoursePractice(set) { return nil } if !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { return fiber.NewError(fiber.StatusNotFound, "Practice not found") } userID := c.Locals("user_id").(int64) blockedBy, err := h.questionsSvc.GetFirstIncompletePreviousPractice(c.Context(), userID, set.ID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate practice access") } if blockedBy != nil { return fiber.NewError( fiber.StatusForbidden, fmt.Sprintf( "Complete practice '%s' (display_order=%d) before accessing this one", blockedBy.Title, blockedBy.DisplayOrder, ), ) } return nil } // 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(), }) } if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) { if req.OwnerType == nil || !strings.EqualFold(*req.OwnerType, "SUB_COURSE") || req.OwnerID == nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid initial assessment ownership", Error: "INITIAL_ASSESSMENT question sets must include owner_type=SUB_COURSE and owner_id", }) } } 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(), }, }) } // GetSubCourseEntryAssessmentSet godoc // @Summary Get entry assessment set for a sub-course // @Description Returns the published INITIAL_ASSESSMENT question set for the given sub-course // @Tags question-sets // @Produce json // @Param subCourseId path int true "Sub-course 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/sub-courses/{subCourseId}/entry-assessment [get] func (h *Handler) GetSubCourseEntryAssessmentSet(c *fiber.Ctx) error { subCourseIDStr := c.Params("subCourseId") subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } set, err := h.questionsSvc.GetSubCourseInitialAssessmentSet(c.Context(), subCourseID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Entry assessment set not found for sub-course", Error: err.Error(), }) } count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), set.ID) return c.JSON(domain.Response{ Message: "Entry assessment 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, }, }) } // 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(), }) } 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(), }) } 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(), }) } set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Question set not found", Error: err.Error(), }) } 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(), }) } 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, }) } // CompletePractice godoc // @Summary Mark practice as completed // @Description Marks a practice question set as completed for the authenticated learner // @Tags progression // @Produce json // @Param id path int true "Practice Question Set ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 403 {object} domain.ErrorResponse // @Failure 404 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/progress/practices/{id}/complete [post] func (h *Handler) CompletePractice(c *fiber.Ctx) error { role := c.Locals("role").(domain.Role) if role != domain.RoleStudent { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "Only learners can complete practices", }) } userID := c.Locals("user_id").(int64) setID, 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(), }) } set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Practice not found", Error: err.Error(), }) } if !isSubCoursePractice(set) || !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Practice not found", }) } if err := h.enforcePracticeSequenceForStudent(c, set); err != nil { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "You must complete previous practices first", Error: err.Error(), }) } if err := h.questionsSvc.MarkPracticeCompleted(c.Context(), userID, set.ID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to complete practice", Error: err.Error(), }) } if set.OwnerID == nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course progress", Error: "practice owner is missing", }) } if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, *set.OwnerID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course progress", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Practice completed", }) } // 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", }) }