Files
BoostAI/Backend/internal/handlers/api/questions/handler.go
2026-05-25 17:05:06 +01:00

507 lines
15 KiB
Go

// 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
}