// Path: Backend/internal/handlers/api/questions/handler.go package questions import ( "boostai-backend/internal/handlers/api/shared" "boostai-backend/internal/http/params" "boostai-backend/internal/http/respond" authmw "boostai-backend/internal/middleware" "boostai-backend/internal/questiongen" "boostai-backend/internal/sqlc" "errors" "fmt" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" ) type Handler struct { queries *sqlc.Queries generator *questiongen.Service } type QuestionResponse struct { ID int64 `json:"id"` AuthorTeacherID int64 `json:"author_teacher_id"` Title string `json:"title"` Prompt string `json:"prompt"` Topic *string `json:"topic,omitempty"` Subject *string `json:"subject,omitempty"` Difficulty *string `json:"difficulty,omitempty"` Source *string `json:"source,omitempty"` CorrectAnswer *string `json:"correct_answer,omitempty"` Status string `json:"status"` CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } type TagResponse struct { ID int64 `json:"id"` Name string `json:"name"` CreatedAt *time.Time `json:"created_at,omitempty"` } type createQuestionRequest struct { AuthorTeacherID int64 `json:"author_teacher_id"` Title string `json:"title"` Prompt string `json:"prompt"` Topic *string `json:"topic"` Subject *string `json:"subject"` Difficulty *string `json:"difficulty"` Source *string `json:"source"` CorrectAnswer *string `json:"correct_answer"` Status string `json:"status"` } type createTagRequest struct { Name string `json:"name"` } type attachTagToQuestionRequest struct { TagID int64 `json:"tag_id"` } type generateQuestionsRequest struct { Topic string `json:"topic"` Difficulty string `json:"difficulty"` Count int `json:"count"` Seed *int64 `json:"seed"` Status *string `json:"status"` Source *string `json:"source"` } type GeneratedQuestionResponse struct { Question QuestionResponse `json:"question"` Tags []string `json:"tags"` WorkedSolution []string `json:"worked_solution"` } type GenerateQuestionsResponse struct { Seed int64 `json:"seed"` Data []GeneratedQuestionResponse `json:"data"` Count int `json:"count"` } func NewHandler(queries *sqlc.Queries, generator *questiongen.Service) *Handler { return &Handler{queries: queries, generator: generator} } func (h *Handler) ListQuestionsByTeacher(c *fiber.Ctx) error { teacherID, err := params.Int64PathParam(c, "teacherId") if err != nil { return err } ctx, cancel := shared.WithTimeout() defer cancel() questions, err := h.queries.ListQuestionsByTeacher(ctx, teacherID) if err != nil { return respond.DatabaseError(c, err) } items := make([]QuestionResponse, 0, len(questions)) for _, question := range questions { items = append(items, mapQuestion(question)) } return c.JSON(shared.ListResponse[QuestionResponse]{Data: items}) } func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { questionID, err := params.Int64PathParam(c, "questionId") if err != nil { return err } ctx, cancel := shared.WithTimeout() defer cancel() question, err := h.queries.GetQuestionByID(ctx, questionID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return respond.Error(c, fiber.StatusNotFound, "not_found", "Question not found") } return respond.DatabaseError(c, err) } return c.JSON(mapQuestion(question)) } func (h *Handler) ListTags(c *fiber.Ctx) error { ctx, cancel := shared.WithTimeout() defer cancel() tags, err := h.queries.ListTags(ctx) if err != nil { return respond.DatabaseError(c, err) } items := make([]TagResponse, 0, len(tags)) for _, tag := range tags { items = append(items, mapTag(tag)) } return c.JSON(shared.ListResponse[TagResponse]{Data: items}) } func (h *Handler) CreateQuestion(c *fiber.Ctx) error { var req createQuestionRequest if err := c.BodyParser(&req); err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") } teacherID := authmw.CurrentUserID(c) if teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Prompt) == "" || strings.TrimSpace(req.Status) == "" { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "teacher authentication, title, prompt, and status are required") } ctx, cancel := shared.WithTimeout() defer cancel() topic, subject, err := parseQuestionTopic(req.Topic, req.Subject) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) } difficulty, err := parseQuestionDifficulty(req.Difficulty) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) } question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{ AuthorTeacherID: teacherID, Title: strings.TrimSpace(req.Title), Prompt: strings.TrimSpace(req.Prompt), Topic: topic, Subject: shared.NullableText(subject), Difficulty: difficulty, Source: shared.NullableText(req.Source), CorrectAnswer: shared.NullableText(req.CorrectAnswer), Status: sqlc.QuestionStatus(strings.TrimSpace(req.Status)), }) if err != nil { return respond.DatabaseError(c, err) } return c.Status(fiber.StatusCreated).JSON(mapQuestion(question)) } func (h *Handler) GenerateQuestions(c *fiber.Ctx) error { if h.generator == nil { return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Question generator is not available") } var req generateQuestionsRequest if err := c.BodyParser(&req); err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") } teacherID := authmw.CurrentUserID(c) if teacherID == 0 { return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "teacher authentication is required") } if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Difficulty) == "" { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic and difficulty are required") } if req.Count < 1 || req.Count > 25 { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "count must be between 1 and 25") } topic, subject, err := parseQuestionTopic(&req.Topic, nil) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) } if !topic.Valid { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic is required") } difficulty, err := parseQuestionDifficulty(&req.Difficulty) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) } if !difficulty.Valid { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "difficulty is required") } status := sqlc.QuestionStatusDraft if req.Status != nil && strings.TrimSpace(*req.Status) != "" { normalizedStatus := sqlc.QuestionStatus(strings.ToLower(strings.TrimSpace(*req.Status))) switch normalizedStatus { case sqlc.QuestionStatusDraft, sqlc.QuestionStatusPublished, sqlc.QuestionStatusArchived: status = normalizedStatus default: return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "status must be draft, published, or archived") } } seed := int64(0) if req.Seed != nil { seed = *req.Seed } generated, usedSeed, err := h.generator.Generate(questiongen.GenerateParams{ Topic: topic.QuestionTopic, Difficulty: difficulty.QuestionDifficulty, Count: req.Count, Seed: seed, }) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "generation_failed", err.Error()) } ctx, cancel := shared.WithTimeout() defer cancel() source := shared.NullableText(req.Source) if !source.Valid { defaultSource := "rng_generated" source = shared.NullableText(&defaultSource) } responses := make([]GeneratedQuestionResponse, 0, len(generated)) for index, item := range generated { title := strings.TrimSpace(item.Title) if title == "" { title = fmt.Sprintf("%s %s %d", questionTopicLabel(topic.QuestionTopic), strings.Title(string(difficulty.QuestionDifficulty)), index+1) } question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{ AuthorTeacherID: teacherID, Title: title, Prompt: strings.TrimSpace(item.Prompt), Topic: topic, Subject: shared.NullableText(subject), Difficulty: difficulty, Source: source, CorrectAnswer: shared.NullableText(stringPointer(item.CorrectAnswer)), Status: status, }) if err != nil { return respond.DatabaseError(c, err) } for _, tagName := range item.Tags { tag, err := h.queries.CreateTag(ctx, tagName) if err != nil { return respond.DatabaseError(c, err) } if err := h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{QuestionID: question.ID, TagID: tag.ID}); err != nil { return respond.DatabaseError(c, err) } } responses = append(responses, GeneratedQuestionResponse{ Question: mapQuestion(question), Tags: item.Tags, WorkedSolution: item.WorkedSolution, }) } return c.Status(fiber.StatusCreated).JSON(GenerateQuestionsResponse{ Seed: usedSeed, Data: responses, Count: len(responses), }) } func (h *Handler) CreateTag(c *fiber.Ctx) error { var req createTagRequest if err := c.BodyParser(&req); err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") } if strings.TrimSpace(req.Name) == "" { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "name is required") } ctx, cancel := shared.WithTimeout() defer cancel() tag, err := h.queries.CreateTag(ctx, strings.TrimSpace(req.Name)) if err != nil { return respond.DatabaseError(c, err) } return c.Status(fiber.StatusCreated).JSON(mapTag(tag)) } func (h *Handler) AttachTagToQuestion(c *fiber.Ctx) error { questionID, err := params.Int64PathParam(c, "questionId") if err != nil { return err } var req attachTagToQuestionRequest if err := c.BodyParser(&req); err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") } if req.TagID == 0 { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "tag_id is required") } ctx, cancel := shared.WithTimeout() defer cancel() err = h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{ QuestionID: questionID, TagID: req.TagID, }) if err != nil { return respond.DatabaseError(c, err) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "status": "ok", "question_id": questionID, "tag_id": req.TagID, }) } func mapQuestion(question sqlc.Question) QuestionResponse { return QuestionResponse{ ID: question.ID, AuthorTeacherID: question.AuthorTeacherID, Title: question.Title, Prompt: question.Prompt, Topic: questionTopicPointer(question.Topic), Subject: shared.TextPointer(question.Subject), Difficulty: questionDifficultyPointer(question.Difficulty), Source: shared.TextPointer(question.Source), CorrectAnswer: shared.TextPointer(question.CorrectAnswer), Status: string(question.Status), CreatedAt: shared.TimePointer(question.CreatedAt), UpdatedAt: shared.TimePointer(question.UpdatedAt), } } func mapTag(tag sqlc.Tag) TagResponse { return TagResponse{ ID: tag.ID, Name: tag.Name, CreatedAt: shared.TimePointer(tag.CreatedAt), } } func parseQuestionTopic(rawTopic, rawSubject *string) (sqlc.NullQuestionTopic, *string, error) { topicValue := strings.TrimSpace(firstNonEmpty(rawTopic, rawSubject)) if topicValue == "" { return sqlc.NullQuestionTopic{}, rawSubject, nil } normalizedTopic, ok := normalizeQuestionTopic(topicValue) if !ok { return sqlc.NullQuestionTopic{}, nil, errors.New("topic must match the supported seeded subjects") } subjectLabel := questionTopicLabel(sqlc.QuestionTopic(normalizedTopic)) return sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopic(normalizedTopic), Valid: true}, &subjectLabel, nil } func parseQuestionDifficulty(rawDifficulty *string) (sqlc.NullQuestionDifficulty, error) { value := strings.TrimSpace(firstNonEmpty(rawDifficulty)) if value == "" { return sqlc.NullQuestionDifficulty{}, nil } switch strings.ToLower(value) { case "easy": return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyEasy, Valid: true}, nil case "medium": return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyMedium, Valid: true}, nil case "hard": return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyHard, Valid: true}, nil default: return sqlc.NullQuestionDifficulty{}, errors.New("difficulty must be easy, medium, or hard") } } func normalizeQuestionTopic(value string) (string, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "place value", "place_value": return string(sqlc.QuestionTopicPlaceValue), true case "arithmetic": return string(sqlc.QuestionTopicArithmetic), true case "negative numbers", "negative_numbers": return string(sqlc.QuestionTopicNegativeNumbers), true case "bidmas": return string(sqlc.QuestionTopicBidmas), true case "fractions": return string(sqlc.QuestionTopicFractions), true case "algebra": return string(sqlc.QuestionTopicAlgebra), true case "geometry": return string(sqlc.QuestionTopicGeometry), true case "data": return string(sqlc.QuestionTopicData), true default: return "", false } } func questionTopicLabel(topic sqlc.QuestionTopic) string { switch topic { case sqlc.QuestionTopicPlaceValue: return "Place Value" case sqlc.QuestionTopicArithmetic: return "Arithmetic" case sqlc.QuestionTopicNegativeNumbers: return "Negative Numbers" case sqlc.QuestionTopicBidmas: return "BIDMAS" case sqlc.QuestionTopicFractions: return "Fractions" case sqlc.QuestionTopicAlgebra: return "Algebra" case sqlc.QuestionTopicGeometry: return "Geometry" case sqlc.QuestionTopicData: return "Data" default: return "" } } func questionTopicPointer(topic sqlc.NullQuestionTopic) *string { if !topic.Valid { return nil } label := string(topic.QuestionTopic) return &label } func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string { if !difficulty.Valid { return nil } value := string(difficulty.QuestionDifficulty) return &value } func firstNonEmpty(values ...*string) string { for _, value := range values { if value == nil { continue } trimmed := strings.TrimSpace(*value) if trimmed != "" { return trimmed } } return "" } func stringPointer(value string) *string { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } return &trimmed }