507 lines
15 KiB
Go
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
|
|
}
|