Boost Azure Demo

This commit is contained in:
MangoPig
2026-05-25 17:05:06 +01:00
parent 675285e99d
commit 4f79137d89
230 changed files with 43275 additions and 2644 deletions

View File

@@ -0,0 +1,562 @@
// Path: Backend/internal/handlers/api/answers/handler.go
package answers
import (
"boostai-backend/internal/aireview"
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/http/params"
"boostai-backend/internal/http/respond"
authmw "boostai-backend/internal/middleware"
"boostai-backend/internal/sqlc"
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type Handler struct {
queries *sqlc.Queries
aiReview *aireview.Service
}
type StudentAnswerResponse struct {
ID int64 `json:"id"`
AssignmentID int64 `json:"assignment_id"`
QuestionID int64 `json:"question_id"`
StudentID int64 `json:"student_id"`
AnswerText *string `json:"answer_text,omitempty"`
IsCorrect *bool `json:"is_correct,omitempty"`
SolveMode string `json:"solve_mode"`
WorkingSteps *string `json:"working_steps,omitempty"`
AiFeedback *string `json:"ai_feedback,omitempty"`
TeacherFeedback *string `json:"teacher_feedback,omitempty"`
Status string `json:"status"`
ReviewNeedsAttention bool `json:"review_needs_attention"`
ReviewIssueReason *string `json:"review_issue_reason,omitempty"`
ReviewCorrectnessScore *float64 `json:"review_correctness_score,omitempty"`
ReviewUnderstandingScore *float64 `json:"review_understanding_score,omitempty"`
ReviewQuestionScore *float64 `json:"review_question_score,omitempty"`
ReviewConfidence *float64 `json:"review_confidence,omitempty"`
ReviewTags []string `json:"review_tags"`
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type upsertStudentAnswerRequest struct {
AssignmentID int64 `json:"assignment_id"`
QuestionID int64 `json:"question_id"`
StudentID int64 `json:"student_id"`
AnswerText *string `json:"answer_text"`
SolveMode string `json:"solve_mode"`
WorkingSteps *string `json:"working_steps"`
AiFeedback *string `json:"ai_feedback"`
TeacherFeedback *string `json:"teacher_feedback"`
Status string `json:"status"`
SubmittedAt *time.Time `json:"submitted_at"`
ReviewedAt *time.Time `json:"reviewed_at"`
}
type updateAnswerReviewRequest struct {
Status string `json:"status"`
ReviewNeedsAttention *bool `json:"review_needs_attention"`
ReviewIssueReason *string `json:"review_issue_reason"`
ReviewCorrectnessScore *float64 `json:"review_correctness_score"`
ReviewUnderstandingScore *float64 `json:"review_understanding_score"`
ReviewQuestionScore *float64 `json:"review_question_score"`
ReviewConfidence *float64 `json:"review_confidence"`
ReviewTags []string `json:"review_tags"`
}
func NewHandler(queries *sqlc.Queries, aiReview *aireview.Service) *Handler {
return &Handler{queries: queries, aiReview: aiReview}
}
func (h *Handler) ListAnswersForAssignment(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
answers, err := h.queries.ListAnswersForAssignment(ctx, assignmentID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]StudentAnswerResponse, 0, len(answers))
for _, answer := range answers {
items = append(items, mapStudentAnswer(answer))
}
return c.JSON(shared.ListResponse[StudentAnswerResponse]{Data: items})
}
func (h *Handler) ListAnswersForStudent(c *fiber.Ctx) error {
studentID, err := params.Int64PathParam(c, "studentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
answers, err := h.queries.ListAnswersForStudent(ctx, studentID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]StudentAnswerResponse, 0, len(answers))
for _, answer := range answers {
items = append(items, mapStudentAnswer(answer))
}
return c.JSON(shared.ListResponse[StudentAnswerResponse]{Data: items})
}
func (h *Handler) UpsertStudentAnswer(c *fiber.Ctx) error {
var req upsertStudentAnswerRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
studentID := req.StudentID
if authmw.CurrentUserRole(c) == sqlc.UserRoleStudent {
studentID = authmw.CurrentUserID(c)
}
if req.AssignmentID == 0 || req.QuestionID == 0 || studentID == 0 || strings.TrimSpace(req.Status) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "assignment_id, question_id, student identity, and status are required")
}
solveMode := strings.TrimSpace(req.SolveMode)
if solveMode == "" {
solveMode = "just_answer"
}
if !isValidSolveMode(solveMode) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "A valid solve_mode is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
question, err := h.queries.GetQuestionByID(ctx, req.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)
}
isCorrect := compareAnswer(question.CorrectAnswer, req.AnswerText)
answer, err := h.queries.UpsertStudentAnswer(ctx, sqlc.UpsertStudentAnswerParams{
AssignmentID: req.AssignmentID,
QuestionID: req.QuestionID,
StudentID: studentID,
AnswerText: shared.NullableText(req.AnswerText),
SolveMode: solveMode,
WorkingSteps: shared.NullableText(req.WorkingSteps),
AiFeedback: shared.NullableText(req.AiFeedback),
TeacherFeedback: shared.NullableText(req.TeacherFeedback),
Status: sqlc.AnswerStatus(strings.TrimSpace(req.Status)),
SubmittedAt: shared.NullableTime(req.SubmittedAt),
ReviewedAt: shared.NullableTime(req.ReviewedAt),
IsCorrect: shared.NullableBool(isCorrect),
})
if err != nil {
return respond.DatabaseError(c, err)
}
if strings.TrimSpace(req.Status) == string(sqlc.AnswerStatusSubmitted) {
updatedAnswer, aiErr := h.runAISubmissionReview(context.Background(), req.AssignmentID, studentID, answer)
if aiErr != nil {
log.Printf("AI review failed for assignment %d student %d: %v", req.AssignmentID, studentID, aiErr)
} else {
answer = updatedAnswer
}
}
return c.Status(fiber.StatusCreated).JSON(mapStudentAnswer(answer))
}
func (h *Handler) runAISubmissionReview(parentCtx context.Context, assignmentID, studentID int64, currentAnswer sqlc.StudentAnswer) (sqlc.StudentAnswer, error) {
if h.aiReview == nil || !h.aiReview.Enabled() {
return currentAnswer, nil
}
dbCtx, cancel := shared.WithTimeout()
assignment, err := h.queries.GetAssignmentByID(dbCtx, assignmentID)
cancel()
if err != nil {
return currentAnswer, fmt.Errorf("load assignment for AI review: %w", err)
}
detailCtx, cancel := shared.WithTimeout()
questions, err := h.queries.ListQuestionDetailsForAssignmentStudent(detailCtx, sqlc.ListQuestionDetailsForAssignmentStudentParams{
AssignmentID: assignmentID,
StudentID: studentID,
})
cancel()
if err != nil {
return currentAnswer, fmt.Errorf("load assignment question details for AI review: %w", err)
}
input := buildAssignmentReviewInput(assignment, studentID, questions)
if len(input.Questions) == 0 {
return currentAnswer, nil
}
var result *aireview.AssignmentReviewResult
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
attemptCtx, attemptCancel := context.WithTimeout(parentCtx, 45*time.Second)
result, lastErr = h.aiReview.ReviewSubmission(attemptCtx, input)
attemptCancel()
if lastErr == nil {
break
}
if attempt < 3 {
time.Sleep(time.Duration(attempt) * time.Second)
}
}
if lastErr != nil {
fallbackMessage := fmt.Sprintf("AI review could not be completed automatically after 3 attempts. Please review manually. Last error: %v", lastErr)
updateCtx, updateCancel := shared.WithTimeout()
_, updateErr := h.queries.UpdateAssignmentAIReview(updateCtx, sqlc.UpdateAssignmentAIReviewParams{
AssignmentID: assignmentID,
StudentID: studentID,
AiFeedback: shared.NullableText(&fallbackMessage),
Column4: "",
})
updateCancel()
if updateErr != nil {
return currentAnswer, fmt.Errorf("AI review failed (%v) and fallback update failed: %w", lastErr, updateErr)
}
return currentAnswer, lastErr
}
questionByID := make(map[int64]sqlc.ListQuestionDetailsForAssignmentStudentRow, len(questions))
for _, question := range questions {
if question.AnswerID.Valid {
questionByID[question.QuestionID] = question
}
}
updatedAnswer := currentAnswer
for _, review := range result.Questions {
question, ok := questionByID[review.QuestionID]
if !ok || !question.AnswerID.Valid {
continue
}
aiFeedback := review.AiFeedback
issueReason := review.IssueReason
correctnessScore := 1.0
questionScore := 1.0
understandingScore := review.UnderstandingScore
confidence := review.Confidence
updateCtx, updateCancel := shared.WithTimeout()
answer, updateErr := h.queries.UpdateAnswerAIReview(updateCtx, sqlc.UpdateAnswerAIReviewParams{
ID: question.AnswerID.Int64,
AiFeedback: shared.NullableText(&aiFeedback),
ReviewNeedsAttention: review.NeedsAttention,
ReviewIssueReason: shared.NullableText(&issueReason),
ReviewCorrectnessScore: mustNumeric(correctnessScore),
ReviewUnderstandingScore: mustNumeric(understandingScore),
ReviewQuestionScore: mustNumeric(questionScore),
ReviewConfidence: mustNumeric(confidence),
})
updateCancel()
if updateErr != nil {
return currentAnswer, fmt.Errorf("persist AI answer review for answer %d: %w", question.AnswerID.Int64, updateErr)
}
if answer.ID == currentAnswer.ID {
updatedAnswer = answer
}
}
for _, question := range questions {
if !question.AnswerID.Valid {
continue
}
answerText := strings.TrimSpace(shared.TextValue(question.AnswerText))
workingSteps := strings.TrimSpace(shared.TextValue(question.WorkingSteps))
if answerText != "" || workingSteps != "" {
continue
}
updateCtx, updateCancel := shared.WithTimeout()
answer, updateErr := h.queries.UpdateAnswerAIReview(updateCtx, sqlc.UpdateAnswerAIReviewParams{
ID: question.AnswerID.Int64,
AiFeedback: shared.NullableText(pointerToString("No answer was submitted for this question.")),
ReviewNeedsAttention: true,
ReviewIssueReason: shared.NullableText(pointerToString("No answer submitted.")),
ReviewCorrectnessScore: mustNumeric(1.0),
ReviewUnderstandingScore: mustNumeric(0.0),
ReviewQuestionScore: mustNumeric(1.0),
ReviewConfidence: mustNumeric(1.0),
})
updateCancel()
if updateErr != nil {
return currentAnswer, fmt.Errorf("persist blank-answer AI review for answer %d: %w", question.AnswerID.Int64, updateErr)
}
if answer.ID == currentAnswer.ID {
updatedAnswer = answer
}
}
assignmentSummary := strings.TrimSpace(result.AssignmentSummary)
nextStepOutcome := sqlc.NullAssignmentNextStepOutcome{}
if result.RecommendedNextStep != "" {
nextStepOutcome = sqlc.NullAssignmentNextStepOutcome{
AssignmentNextStepOutcome: sqlc.AssignmentNextStepOutcome(result.RecommendedNextStep),
Valid: true,
}
}
updateCtx, updateCancel := shared.WithTimeout()
_, err = h.queries.UpdateAssignmentAIReview(updateCtx, sqlc.UpdateAssignmentAIReviewParams{
AssignmentID: assignmentID,
StudentID: studentID,
AiFeedback: shared.NullableText(&assignmentSummary),
Column4: nextStepOutcomeString(nextStepOutcome),
})
updateCancel()
if err != nil {
return currentAnswer, fmt.Errorf("persist assignment AI review: %w", err)
}
return updatedAnswer, nil
}
func buildAssignmentReviewInput(assignment sqlc.Assignment, studentID int64, questions []sqlc.ListQuestionDetailsForAssignmentStudentRow) aireview.AssignmentReviewInput {
passThreshold := 6.0
if value := shared.NumericPointer(assignment.PassThreshold); value != nil {
passThreshold = *value
}
input := aireview.AssignmentReviewInput{
AssignmentID: assignment.ID,
StudentID: studentID,
AssignmentTitle: assignment.Title,
Instructions: strings.TrimSpace(shared.TextValue(assignment.Instructions)),
PassThreshold: passThreshold,
Questions: make([]aireview.AssignmentQuestionInput, 0, len(questions)),
}
for _, question := range questions {
answerText := strings.TrimSpace(shared.TextValue(question.AnswerText))
workingSteps := strings.TrimSpace(shared.TextValue(question.WorkingSteps))
answerStatus := ""
if question.AnswerStatus.Valid {
answerStatus = string(question.AnswerStatus.AnswerStatus)
}
input.Questions = append(input.Questions, aireview.AssignmentQuestionInput{
QuestionID: question.QuestionID,
Position: question.Position,
Title: question.Title,
Prompt: question.Prompt,
Subject: strings.TrimSpace(shared.TextValue(question.Subject)),
Source: strings.TrimSpace(shared.TextValue(question.Source)),
CorrectAnswer: strings.TrimSpace(shared.TextValue(question.CorrectAnswer)),
QuestionTags: question.QuestionTags,
SolveMode: strings.TrimSpace(shared.TextValue(question.SolveMode)),
AnswerText: answerText,
WorkingSteps: workingSteps,
IsCorrect: shared.BoolPointer(question.IsCorrect),
AnswerStatus: answerStatus,
})
}
return input
}
func mustNumeric(value float64) pgtype.Numeric {
numeric, err := shared.NullableFloat64AsNumeric(&value)
if err != nil {
panic(err)
}
return numeric
}
func nextStepOutcomeString(value sqlc.NullAssignmentNextStepOutcome) string {
if !value.Valid {
return ""
}
return string(value.AssignmentNextStepOutcome)
}
func pointerToString(value string) *string {
return &value
}
func (h *Handler) UpdateAnswerReview(c *fiber.Ctx) error {
answerID, err := params.Int64PathParam(c, "answerId")
if err != nil {
return err
}
var req updateAnswerReviewRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
status := strings.TrimSpace(req.Status)
if status == "" || !shared.IsValidAnswerStatus(status) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "A valid answer status is required")
}
for _, score := range []struct {
name string
value *float64
}{
{name: "review_correctness_score", value: req.ReviewCorrectnessScore},
{name: "review_understanding_score", value: req.ReviewUnderstandingScore},
{name: "review_question_score", value: req.ReviewQuestionScore},
{name: "review_confidence", value: req.ReviewConfidence},
} {
if score.value != nil && (*score.value < 0 || *score.value > 1) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", score.name+" must be between 0 and 1")
}
}
reviewCorrectnessScore, err := shared.NullableFloat64AsNumeric(req.ReviewCorrectnessScore)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_correctness_score must be a valid number")
}
reviewUnderstandingScore, err := shared.NullableFloat64AsNumeric(req.ReviewUnderstandingScore)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_understanding_score must be a valid number")
}
reviewQuestionScore, err := shared.NullableFloat64AsNumeric(req.ReviewQuestionScore)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_question_score must be a valid number")
}
reviewConfidence, err := shared.NullableFloat64AsNumeric(req.ReviewConfidence)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_confidence must be a valid number")
}
reviewNeedsAttention := false
if req.ReviewNeedsAttention != nil {
reviewNeedsAttention = *req.ReviewNeedsAttention
}
reviewTags := req.ReviewTags
if reviewTags == nil {
reviewTags = []string{}
}
ctx, cancel := shared.WithTimeout()
defer cancel()
answer, err := h.queries.UpdateAnswerReview(ctx, sqlc.UpdateAnswerReviewParams{
ID: answerID,
Status: sqlc.AnswerStatus(status),
ReviewNeedsAttention: reviewNeedsAttention,
ReviewIssueReason: shared.NullableText(req.ReviewIssueReason),
ReviewCorrectnessScore: reviewCorrectnessScore,
ReviewUnderstandingScore: reviewUnderstandingScore,
ReviewQuestionScore: reviewQuestionScore,
ReviewConfidence: reviewConfidence,
ReviewTags: reviewTags,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Answer not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(mapStudentAnswer(answer))
}
func mapStudentAnswer(answer sqlc.StudentAnswer) StudentAnswerResponse {
return StudentAnswerResponse{
ID: answer.ID,
AssignmentID: answer.AssignmentID,
QuestionID: answer.QuestionID,
StudentID: answer.StudentID,
AnswerText: shared.TextPointer(answer.AnswerText),
IsCorrect: shared.BoolPointer(answer.IsCorrect),
SolveMode: answer.SolveMode,
WorkingSteps: shared.TextPointer(answer.WorkingSteps),
AiFeedback: shared.TextPointer(answer.AiFeedback),
TeacherFeedback: shared.TextPointer(answer.TeacherFeedback),
Status: string(answer.Status),
ReviewNeedsAttention: answer.ReviewNeedsAttention,
ReviewIssueReason: shared.TextPointer(answer.ReviewIssueReason),
ReviewCorrectnessScore: shared.NumericPointer(answer.ReviewCorrectnessScore),
ReviewUnderstandingScore: shared.NumericPointer(answer.ReviewUnderstandingScore),
ReviewQuestionScore: shared.NumericPointer(answer.ReviewQuestionScore),
ReviewConfidence: shared.NumericPointer(answer.ReviewConfidence),
ReviewTags: answer.ReviewTags,
SubmittedAt: shared.TimePointer(answer.SubmittedAt),
ReviewedAt: shared.TimePointer(answer.ReviewedAt),
CreatedAt: shared.TimePointer(answer.CreatedAt),
UpdatedAt: shared.TimePointer(answer.UpdatedAt),
}
}
func isValidSolveMode(value string) bool {
switch value {
case "just_answer", "step_by_step", "solve_together", "handwritten":
return true
default:
return false
}
}
func compareAnswer(correctAnswer pgtype.Text, studentAnswer *string) *bool {
if !correctAnswer.Valid {
return nil
}
canonical := normalizeComparableAnswer(correctAnswer.String)
if canonical == "" {
return nil
}
if studentAnswer == nil {
return nil
}
student := normalizeComparableAnswer(*studentAnswer)
if student == "" {
return nil
}
result := student == canonical
return &result
}
func normalizeComparableAnswer(value string) string {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return ""
}
return strings.Join(strings.Fields(trimmed), " ")
}

View File

@@ -0,0 +1,16 @@
// Path: Backend/internal/handlers/api/answers/routes.go
package answers
import (
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
app.Get("/assignments/:assignmentId/answers", auth.RequireTeacher(), h.ListAnswersForAssignment)
app.Get("/students/:studentId/answers", auth.RequireStudentSelfOrTeacher("studentId"), h.ListAnswersForStudent)
app.Post("/answers", h.UpsertStudentAnswer)
app.Patch("/answers/:answerId/review", auth.RequireTeacher(), h.UpdateAnswerReview)
}

View File

@@ -0,0 +1,660 @@
package assignments
import (
"boostai-backend/internal/aireview"
"boostai-backend/internal/assignmentgen"
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/http/params"
"boostai-backend/internal/http/respond"
authmw "boostai-backend/internal/middleware"
"boostai-backend/internal/sqlc"
"errors"
"fmt"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type Handler struct {
queries *sqlc.Queries
aiReview *aireview.Service
assignmentGenerator *assignmentgen.Service
}
const fixedPassThreshold = 6.0
func NewHandler(queries *sqlc.Queries, aiReview *aireview.Service, assignmentGenerator *assignmentgen.Service) *Handler {
return &Handler{queries: queries, aiReview: aiReview, assignmentGenerator: assignmentGenerator}
}
func (h *Handler) ListAssignmentsByTeacher(c *fiber.Ctx) error {
teacherID, err := params.Int64PathParam(c, "teacherId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignments, err := h.queries.ListAssignmentsByTeacher(ctx, teacherID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]AssignmentResponse, 0, len(assignments))
for _, assignment := range assignments {
items = append(items, mapAssignment(assignment))
}
return c.JSON(shared.ListResponse[AssignmentResponse]{Data: items})
}
func (h *Handler) ListAssignmentsForStudent(c *fiber.Ctx) error {
studentID, err := params.Int64PathParam(c, "studentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignments, err := h.queries.ListAssignmentsForStudent(ctx, studentID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]AssignmentResponse, 0, len(assignments))
for _, assignment := range assignments {
items = append(items, mapAssignment(assignment))
}
return c.JSON(shared.ListResponse[AssignmentResponse]{Data: items})
}
func (h *Handler) GetAssignmentByID(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(mapAssignment(assignment))
}
func (h *Handler) ListQuestionsForAssignment(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
questions, err := h.queries.ListQuestionsForAssignment(ctx, assignmentID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]AssignmentQuestionResponse, 0, len(questions))
for _, question := range questions {
items = append(items, mapAssignmentQuestion(question))
}
return c.JSON(shared.ListResponse[AssignmentQuestionResponse]{Data: items})
}
func (h *Handler) ListQuestionDetailsForAssignmentStudent(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
studentID, err := params.Int64PathParam(c, "studentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
rows, err := h.queries.ListQuestionDetailsForAssignmentStudent(ctx, sqlc.ListQuestionDetailsForAssignmentStudentParams{
AssignmentID: assignmentID,
StudentID: studentID,
})
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]AssignmentStudentQuestionDetailResponse, 0, len(rows))
for _, row := range rows {
items = append(items, mapAssignmentStudentQuestionDetail(row, studentID))
}
return c.JSON(shared.ListResponse[AssignmentStudentQuestionDetailResponse]{Data: items})
}
func (h *Handler) GetAssignmentReviewSummary(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
summary, err := h.queries.GetAssignmentReviewSummary(ctx, assignmentID)
if err != nil {
return respond.DatabaseError(c, err)
}
return c.JSON(mapAssignmentReviewSummary(summary))
}
func (h *Handler) GetAssignmentRedoPlan(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
studentID, err := params.Int64PathParam(c, "studentId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
row, err := h.queries.GetAssignmentRedoPlan(ctx, sqlc.GetAssignmentRedoPlanParams{
AssignmentID: assignmentID,
StudentID: studentID,
})
cancel()
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment student record not found")
}
return respond.DatabaseError(c, err)
}
summary, err := h.buildStudentWeaknessSummary(studentID)
if err != nil {
return respond.DatabaseError(c, err)
}
response := AssignmentRedoPlanResponse{
AssignmentID: assignmentID,
StudentID: studentID,
RedoPlanGeneratedAt: shared.TimePointer(row.RedoPlanGeneratedAt),
WeaknessSummary: mapWeaknessSummary(studentID, summary),
}
if row.RedoPlan.Valid {
stored, err := parseStoredRedoPlan(row.RedoPlan.String)
if err != nil {
response.Error = fmt.Sprintf("stored redo plan could not be parsed: %v", err)
} else {
response.TeacherFeedback = emptyStringPointer(stored.TeacherFeedback)
response.Error = stored.Error
response.Plan = stored.Plan
if len(stored.WeaknessSummary.TopicScores) > 0 || len(stored.WeaknessSummary.WeakTags) > 0 || len(stored.WeaknessSummary.RecentIssues) > 0 {
response.WeaknessSummary = mapWeaknessSummary(studentID, stored.WeaknessSummary)
}
}
}
return c.JSON(response)
}
func (h *Handler) UpdateAssignmentDraft(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
var req updateAssignmentDraftRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
teacherID := authmw.CurrentUserID(c)
title := strings.TrimSpace(req.Title)
if req.ClassroomID == 0 || teacherID == 0 || title == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "classroom_id, teacher authentication, and title are required")
}
passThreshold, err := shared.NullableFloat64AsNumeric(pointerToFloat64(fixedPassThreshold))
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_threshold must be a valid number")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
}
return respond.DatabaseError(c, err)
}
if assignment.TeacherID != teacherID {
return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only edit your own draft assignments")
}
if assignment.Status != sqlc.AssignmentStatusDraft {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Only draft assignments can be edited here")
}
classrooms, err := h.queries.ListClassroomsByTeacher(ctx, teacherID)
if err != nil {
return respond.DatabaseError(c, err)
}
classroomAllowed := false
for _, classroom := range classrooms {
if classroom.ID == req.ClassroomID {
classroomAllowed = true
break
}
}
if !classroomAllowed {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Choose one of your classrooms for this draft")
}
updatedAssignment, err := h.queries.UpdateAssignmentDraft(ctx, sqlc.UpdateAssignmentDraftParams{
ID: assignmentID,
ClassroomID: req.ClassroomID,
Title: title,
Instructions: shared.NullableText(req.Instructions),
PassThreshold: passThreshold,
DueAt: shared.NullableTime(req.DueAt),
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.JSON(mapAssignment(updatedAssignment))
}
func (h *Handler) UpdateAssignmentTeacherFeedback(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
studentID, err := params.Int64PathParam(c, "studentId")
if err != nil {
return err
}
var req updateAssignmentTeacherFeedbackRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
passStatusOverrideValue := ""
if req.PassStatusOverride != nil {
passStatusOverrideValue = strings.TrimSpace(*req.PassStatusOverride)
if passStatusOverrideValue != "" && !isValidAssignmentPassStatus(passStatusOverrideValue) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_status_override must be pending, pass, no_pass, or empty")
}
}
nextStepOutcomeValue := ""
if req.NextStepOutcome != nil {
nextStepOutcomeValue = strings.TrimSpace(*req.NextStepOutcome)
if nextStepOutcomeValue != "" && !isValidAssignmentNextStepOutcome(nextStepOutcomeValue) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "next_step_outcome must be redo, accept, support, or empty")
}
}
ctx, cancel := shared.WithTimeout()
defer cancel()
row, err := h.queries.UpdateAssignmentTeacherFeedback(ctx, sqlc.UpdateAssignmentTeacherFeedbackParams{
AssignmentID: assignmentID,
StudentID: studentID,
TeacherFeedback: shared.NullableText(req.TeacherFeedback),
Column4: passStatusOverrideValue,
Column5: nextStepOutcomeValue,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment student record not found")
}
return respond.DatabaseError(c, err)
}
if nextStepOutcomeValue == string(sqlc.AssignmentNextStepOutcomeRedo) {
if err := h.generateAndStoreRedoPlan(assignmentID, studentID, strings.TrimSpace(shared.TextValue(row.TeacherFeedback))); err != nil {
fmt.Printf("redo plan generation failed for assignment %d student %d: %v\n", assignmentID, studentID, err)
}
} else {
clearCtx, clearCancel := shared.WithTimeout()
_, clearErr := h.queries.UpdateAssignmentRedoPlan(clearCtx, sqlc.UpdateAssignmentRedoPlanParams{
AssignmentID: assignmentID,
StudentID: studentID,
Column3: "",
})
clearCancel()
if clearErr != nil && !errors.Is(clearErr, pgx.ErrNoRows) {
fmt.Printf("redo plan clear failed for assignment %d student %d: %v\n", assignmentID, studentID, clearErr)
}
}
var passStatusOverride *string
if row.PassStatusOverride.Valid {
status := string(row.PassStatusOverride.AssignmentPassStatus)
passStatusOverride = &status
}
var nextStepOutcome *string
if row.NextStepOutcome.Valid {
outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome)
nextStepOutcome = &outcome
}
return c.JSON(fiber.Map{
"assignment_id": assignmentID,
"student_id": studentID,
"ai_feedback": shared.TextPointer(row.AiFeedback),
"teacher_feedback": shared.TextPointer(row.TeacherFeedback),
"overall_score": shared.NumericPointer(row.OverallScore),
"pass_threshold": shared.NumericPointer(row.PassThreshold),
"next_step_outcome": nextStepOutcome,
"pass_status_override": passStatusOverride,
"pass_status": string(row.PassStatus),
})
}
func (h *Handler) CloseAssignment(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
teacherID := authmw.CurrentUserID(c)
if teacherID == 0 {
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
}
return respond.DatabaseError(c, err)
}
if assignment.TeacherID != teacherID {
return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only close your own assignments")
}
if assignment.Status == sqlc.AssignmentStatusDraft {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Draft assignments cannot be closed")
}
if assignment.Status == sqlc.AssignmentStatusClosed {
return c.JSON(mapAssignment(assignment))
}
queue, err := h.queries.ListAssignmentReviewQueue(ctx, sqlc.ListAssignmentReviewQueueParams{
AssignmentID: assignmentID,
Column2: "",
})
if err != nil {
return respond.DatabaseError(c, err)
}
readiness := buildAssignmentCloseReadiness(queue)
if !readiness.CanClose {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "assignment_not_ready_to_close",
"message": "This assignment still has open review blockers.",
"blockers": readiness.Blockers,
})
}
closedAssignment, err := h.queries.CloseAssignment(ctx, assignmentID)
if err != nil {
return respond.DatabaseError(c, err)
}
return c.JSON(mapAssignment(closedAssignment))
}
func (h *Handler) ListAssignmentReviewQueue(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
statusFilter := strings.TrimSpace(c.Query("status"))
if statusFilter != "" && !shared.IsValidAnswerStatus(statusFilter) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid review status filter")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
rows, err := h.queries.ListAssignmentReviewQueue(ctx, sqlc.ListAssignmentReviewQueueParams{
AssignmentID: assignmentID,
Column2: statusFilter,
})
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]AssignmentReviewQueueItemResponse, 0, len(rows))
for _, row := range rows {
items = append(items, mapAssignmentReviewQueueItem(row))
}
return c.JSON(shared.ListResponse[AssignmentReviewQueueItemResponse]{Data: items})
}
func (h *Handler) CreateAssignment(c *fiber.Ctx) error {
var req createAssignmentRequest
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 req.ClassroomID == 0 || teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Status) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "classroom_id, teacher authentication, title, and status are required")
}
passThreshold, err := shared.NullableFloat64AsNumeric(pointerToFloat64(fixedPassThreshold))
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_threshold must be a valid number")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignment, err := h.queries.CreateAssignment(ctx, sqlc.CreateAssignmentParams{
ClassroomID: req.ClassroomID,
TeacherID: teacherID,
Title: strings.TrimSpace(req.Title),
Instructions: shared.NullableText(req.Instructions),
PassThreshold: passThreshold,
Status: sqlc.AssignmentStatus(strings.TrimSpace(req.Status)),
DueAt: shared.NullableTime(req.DueAt),
PublishedAt: shared.NullableTime(req.PublishedAt),
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(mapAssignment(assignment))
}
func (h *Handler) AssignStudentToAssignment(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
var req assignStudentToAssignmentRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
if req.StudentID == 0 {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "student_id is required")
}
teacherID := authmw.CurrentUserID(c)
if teacherID == 0 {
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
}
return respond.DatabaseError(c, err)
}
if assignment.TeacherID != teacherID {
return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only assign students to your own assignments")
}
err = h.queries.AssignStudentToAssignment(ctx, sqlc.AssignStudentToAssignmentParams{
AssignmentID: assignmentID,
StudentID: req.StudentID,
})
if err != nil {
return respond.DatabaseError(c, err)
}
response := fiber.Map{
"status": "ok",
"assignment_id": assignmentID,
"student_id": req.StudentID,
}
if req.MixedGeneration != nil {
if h.assignmentGenerator == nil {
cleanupErr := h.queries.DeleteAssignmentAssignee(ctx, sqlc.DeleteAssignmentAssigneeParams{
AssignmentID: assignmentID,
StudentID: req.StudentID,
})
if cleanupErr != nil {
return respond.DatabaseError(c, cleanupErr)
}
return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Assignment generator is not configured")
}
generated, generationErr := h.generateMixedStudentQuestionsForAssignmentStudent(ctx, assignmentID, req.StudentID, teacherID, req.MixedGeneration)
if generationErr != nil {
cleanupErr := h.queries.DeleteAssignmentAssignee(ctx, sqlc.DeleteAssignmentAssigneeParams{
AssignmentID: assignmentID,
StudentID: req.StudentID,
})
if cleanupErr != nil {
return respond.DatabaseError(c, cleanupErr)
}
if apiErr, ok := generationErr.(*assignmentAPIError); ok {
return respond.Error(c, apiErr.status, apiErr.code, apiErr.message)
}
return respond.DatabaseError(c, generationErr)
}
response["mixed_generation"] = generated
}
return c.Status(fiber.StatusCreated).JSON(response)
}
func (h *Handler) AddQuestionToAssignment(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
var req addQuestionToAssignmentRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
if req.QuestionID == 0 || req.Position <= 0 {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "question_id and positive position are required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
err = h.queries.AddQuestionToAssignment(ctx, sqlc.AddQuestionToAssignmentParams{
AssignmentID: assignmentID,
QuestionID: req.QuestionID,
Position: req.Position,
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"status": "ok",
"assignment_id": assignmentID,
"question_id": req.QuestionID,
"position": req.Position,
})
}
func (h *Handler) GenerateMixedStudentQuestions(c *fiber.Ctx) error {
assignmentID, err := params.Int64PathParam(c, "assignmentId")
if err != nil {
return err
}
studentID, err := params.Int64PathParam(c, "studentId")
if err != nil {
return err
}
var req generateMixedStudentQuestionsRequest
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 h.assignmentGenerator == nil {
return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Assignment generator is not configured")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
response, generationErr := h.generateMixedStudentQuestionsForAssignmentStudent(ctx, assignmentID, studentID, teacherID, &req)
if generationErr != nil {
if apiErr, ok := generationErr.(*assignmentAPIError); ok {
return respond.Error(c, apiErr.status, apiErr.code, apiErr.message)
}
return respond.DatabaseError(c, generationErr)
}
return c.Status(fiber.StatusCreated).JSON(response)
}

View File

@@ -0,0 +1,321 @@
package assignments
import (
"boostai-backend/internal/aireview"
"boostai-backend/internal/assignmentgen"
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/sqlc"
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
func (h *Handler) generateMixedStudentQuestionsForAssignmentStudent(
ctx context.Context,
assignmentID int64,
studentID int64,
teacherID int64,
req *generateMixedStudentQuestionsRequest,
) (generateMixedStudentQuestionsResponse, error) {
if h.assignmentGenerator == nil {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusServiceUnavailable, code: "generator_unavailable", message: "Assignment generator is not configured"}
}
primaryTopic, err := parseQuestionTopicValue(req.PrimaryTopic)
if err != nil {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
}
primaryDifficulty, err := parseQuestionDifficultyValue(req.PrimaryDifficulty)
if err != nil {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
}
if req.TotalQuestions <= 0 {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: "total_questions must be greater than 0"}
}
personalizedDifficulty := primaryDifficulty
if req.PersonalizedDifficulty != nil && strings.TrimSpace(*req.PersonalizedDifficulty) != "" {
personalizedDifficulty, err = parseQuestionDifficultyValue(*req.PersonalizedDifficulty)
if err != nil {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
}
}
questionStatus := sqlc.QuestionStatusDraft
if req.QuestionStatus != nil && strings.TrimSpace(*req.QuestionStatus) != "" {
questionStatus, err = parseQuestionStatusValue(*req.QuestionStatus)
if err != nil {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
}
}
personalizedRatio := 0.0
if req.PersonalizedRatio != nil {
personalizedRatio = *req.PersonalizedRatio
}
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusNotFound, code: "not_found", message: "Assignment not found"}
}
return generateMixedStudentQuestionsResponse{}, err
}
if assignment.TeacherID != teacherID {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusForbidden, code: "forbidden", message: "You can only generate questions for your own assignments"}
}
_, err = h.queries.GetAssignmentAssignee(ctx, sqlc.GetAssignmentAssigneeParams{
AssignmentID: assignmentID,
StudentID: studentID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusNotFound, code: "not_found", message: "Student is not assigned to this assignment"}
}
return generateMixedStudentQuestionsResponse{}, err
}
result, err := h.assignmentGenerator.GenerateAndStoreMixedStudentQuestions(ctx, assignmentgen.GenerateMixedStudentQuestionSetParams{
AssignmentID: assignmentID,
StudentID: studentID,
TeacherID: teacherID,
Subject: trimmedPointerValue(req.Subject),
QuestionStatus: questionStatus,
QuestionSource: trimmedPointerValue(req.QuestionSource),
PrimaryTopic: primaryTopic,
PrimaryDifficulty: primaryDifficulty,
TotalQuestions: req.TotalQuestions,
PersonalizedRatio: personalizedRatio,
Seed: int64Value(req.Seed),
PersonalizedDifficulty: personalizedDifficulty,
})
if err != nil {
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "generation_failed", message: err.Error()}
}
questions := make([]mixedPlanQuestionResponse, 0, len(result.StoredQuestions))
for _, item := range result.StoredQuestions {
questions = append(questions, mixedPlanQuestionResponse{
MappingID: item.Mapping.ID,
QuestionID: item.Question.ID,
Position: item.Mapping.Position,
SourceBucket: item.Mapping.SourceBucket,
SourceTopic: questionTopicPointer(item.Mapping.SourceTopic),
SourceDifficulty: questionDifficultyPointer(item.Mapping.SourceDifficulty),
GeneratorSeed: int64Pointer(item.UsedSeed),
Title: item.Question.Title,
Prompt: item.Question.Prompt,
Subject: shared.TextPointer(item.Question.Subject),
QuestionStatus: string(item.Question.Status),
QuestionSource: shared.TextPointer(item.Question.Source),
CorrectAnswer: shared.TextPointer(item.Question.CorrectAnswer),
Tags: item.Tags,
QuestionCreatedAt: shared.TimePointer(item.Question.CreatedAt),
QuestionUpdatedAt: shared.TimePointer(item.Question.UpdatedAt),
})
}
response := generateMixedStudentQuestionsResponse{
AssignmentID: assignmentID,
StudentID: studentID,
PrimaryTopic: string(primaryTopic),
PrimaryDifficulty: string(primaryDifficulty),
TotalQuestions: req.TotalQuestions,
CoreCount: result.MixedPlan.CoreCount,
PersonalizedCount: result.MixedPlan.PersonalizedCount,
PersonalizedApplied: result.MixedPlan.PersonalizedApplied,
PersonalizedRatio: personalizedRatioValue(req.PersonalizedRatio),
BaseSeed: result.MixedPlan.BaseSeed,
WeaknessSummary: mapAssignmentGenerationWeaknessSummary(result.MixedPlan.WeaknessSummary),
Questions: questions,
}
if result.MixedPlan.PersonalizedApplied {
response.PersonalizedTopic = stringPointer(string(result.MixedPlan.PersonalizedTopic))
}
return response, nil
}
func (h *Handler) generateAndStoreRedoPlan(assignmentID, studentID int64, teacherFeedback string) error {
summary, err := h.buildStudentWeaknessSummary(studentID)
if err != nil {
return fmt.Errorf("build weakness summary: %w", err)
}
stored := storedRedoPlan{
TeacherFeedback: teacherFeedback,
WeaknessSummary: summary,
}
if h.aiReview != nil && h.aiReview.Enabled() {
assignmentCtx, assignmentCancel := shared.WithTimeout()
assignment, err := h.queries.GetAssignmentByID(assignmentCtx, assignmentID)
assignmentCancel()
if err != nil {
return fmt.Errorf("load assignment for redo plan: %w", err)
}
passThreshold := fixedPassThreshold
if value := shared.NumericPointer(assignment.PassThreshold); value != nil {
passThreshold = *value
}
planCtx, planCancel := context.WithTimeout(context.Background(), 45*time.Second)
defer planCancel()
plan, planErr := h.aiReview.PlanRedoAssignment(planCtx, aireview.RedoPlanInput{
AssignmentID: assignmentID,
StudentID: studentID,
AssignmentTitle: assignment.Title,
Instructions: strings.TrimSpace(shared.TextValue(assignment.Instructions)),
TeacherFeedback: teacherFeedback,
PassThreshold: passThreshold,
TopicScores: summary.TopicScores,
WeakTags: summary.WeakTags,
RecentIssues: summary.RecentIssues,
AllowedTopics: allowedQuestionTopics(),
AllowedDifficulties: []string{"easy", "medium", "hard"},
})
if planErr != nil {
stored.Error = fmt.Sprintf("AI redo plan could not be generated automatically: %v", planErr)
} else {
stored.Plan = plan
}
}
payload, err := json.Marshal(stored)
if err != nil {
return fmt.Errorf("marshal redo plan payload: %w", err)
}
updateCtx, updateCancel := shared.WithTimeout()
defer updateCancel()
_, err = h.queries.UpdateAssignmentRedoPlan(updateCtx, sqlc.UpdateAssignmentRedoPlanParams{
AssignmentID: assignmentID,
StudentID: studentID,
Column3: string(payload),
})
if err != nil {
return fmt.Errorf("persist redo plan: %w", err)
}
return nil
}
func (h *Handler) buildStudentWeaknessSummary(studentID int64) (weaknessSummary, error) {
ctx, cancel := shared.WithTimeout()
rows, err := h.queries.ListStudentPlanningPerformance(ctx, studentID)
cancel()
if err != nil {
return weaknessSummary{}, err
}
topicTotals := map[string]struct {
sum float64
count int
}{}
tagTotals := map[string]struct {
sum float64
count int
flagged int
}{}
recentIssues := make([]string, 0, 5)
seenIssues := map[string]struct{}{}
for _, row := range rows {
score := planningScore(row.IsCorrect, row.ReviewUnderstandingScore)
if row.Topic.Valid {
key := string(row.Topic.QuestionTopic)
total := topicTotals[key]
total.sum += score
total.count++
topicTotals[key] = total
}
for _, tag := range row.QuestionTags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
total := tagTotals[tag]
total.sum += score
total.count++
if row.ReviewNeedsAttention {
total.flagged++
}
tagTotals[tag] = total
}
issue := strings.TrimSpace(shared.TextValue(row.ReviewIssueReason))
if issue != "" {
if _, exists := seenIssues[issue]; !exists {
seenIssues[issue] = struct{}{}
recentIssues = append(recentIssues, issue)
if len(recentIssues) >= 5 {
// keep collecting scores, but no need for more issue strings
}
}
}
}
topicScores := make(map[string]float64, len(topicTotals))
for topic, total := range topicTotals {
if total.count == 0 {
continue
}
topicScores[topic] = roundToOneDecimal((total.sum / float64(total.count)) * 100)
}
type weakTagCandidate struct {
tag string
score float64
flagged int
}
candidates := make([]weakTagCandidate, 0, len(tagTotals))
for tag, total := range tagTotals {
if total.count == 0 {
continue
}
avg := (total.sum / float64(total.count)) * 100
if avg < 70 || total.flagged > 0 {
candidates = append(candidates, weakTagCandidate{tag: tag, score: avg, flagged: total.flagged})
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
if candidates[i].flagged == candidates[j].flagged {
return candidates[i].tag < candidates[j].tag
}
return candidates[i].flagged > candidates[j].flagged
}
return candidates[i].score < candidates[j].score
})
weakTags := make([]string, 0, minInt(len(candidates), 6))
for _, candidate := range candidates {
weakTags = append(weakTags, candidate.tag)
if len(weakTags) >= 6 {
break
}
}
if len(recentIssues) > 5 {
recentIssues = recentIssues[:5]
}
return weaknessSummary{
TopicScores: topicScores,
WeakTags: weakTags,
RecentIssues: recentIssues,
}, nil
}

View File

@@ -0,0 +1,375 @@
package assignments
import (
"boostai-backend/internal/assignmentgen"
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/sqlc"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/jackc/pgx/v5/pgtype"
)
func mapAssignment(assignment sqlc.Assignment) AssignmentResponse {
return AssignmentResponse{
ID: assignment.ID,
ClassroomID: assignment.ClassroomID,
TeacherID: assignment.TeacherID,
Title: assignment.Title,
Instructions: shared.TextPointer(assignment.Instructions),
PassThreshold: shared.NumericPointer(assignment.PassThreshold),
Status: string(assignment.Status),
DueAt: shared.TimePointer(assignment.DueAt),
PublishedAt: shared.TimePointer(assignment.PublishedAt),
CreatedAt: shared.TimePointer(assignment.CreatedAt),
UpdatedAt: shared.TimePointer(assignment.UpdatedAt),
}
}
func parseQuestionTopicValue(value string) (sqlc.QuestionTopic, error) {
switch strings.TrimSpace(strings.ToLower(value)) {
case string(sqlc.QuestionTopicPlaceValue):
return sqlc.QuestionTopicPlaceValue, nil
case string(sqlc.QuestionTopicArithmetic):
return sqlc.QuestionTopicArithmetic, nil
case string(sqlc.QuestionTopicNegativeNumbers):
return sqlc.QuestionTopicNegativeNumbers, nil
case string(sqlc.QuestionTopicBidmas):
return sqlc.QuestionTopicBidmas, nil
case string(sqlc.QuestionTopicFractions):
return sqlc.QuestionTopicFractions, nil
case string(sqlc.QuestionTopicAlgebra):
return sqlc.QuestionTopicAlgebra, nil
case string(sqlc.QuestionTopicGeometry):
return sqlc.QuestionTopicGeometry, nil
case string(sqlc.QuestionTopicData):
return sqlc.QuestionTopicData, nil
default:
return "", fmt.Errorf("primary_topic must be one of place_value, arithmetic, negative_numbers, bidmas, fractions, algebra, geometry, or data")
}
}
func parseQuestionDifficultyValue(value string) (sqlc.QuestionDifficulty, error) {
switch strings.TrimSpace(strings.ToLower(value)) {
case string(sqlc.QuestionDifficultyEasy):
return sqlc.QuestionDifficultyEasy, nil
case string(sqlc.QuestionDifficultyMedium):
return sqlc.QuestionDifficultyMedium, nil
case string(sqlc.QuestionDifficultyHard):
return sqlc.QuestionDifficultyHard, nil
default:
return "", fmt.Errorf("difficulty must be one of easy, medium, or hard")
}
}
func parseQuestionStatusValue(value string) (sqlc.QuestionStatus, error) {
switch strings.TrimSpace(strings.ToLower(value)) {
case string(sqlc.QuestionStatusDraft):
return sqlc.QuestionStatusDraft, nil
case string(sqlc.QuestionStatusPublished):
return sqlc.QuestionStatusPublished, nil
case string(sqlc.QuestionStatusArchived):
return sqlc.QuestionStatusArchived, nil
default:
return "", fmt.Errorf("question_status must be one of draft, published, or archived")
}
}
func mapAssignmentGenerationWeaknessSummary(summary assignmentgen.WeaknessSummary) mixedPlanWeaknessSummaryResponse {
topicScores := make(map[string]float64, len(summary.TopicScores))
for topic, score := range summary.TopicScores {
topicScores[string(topic)] = score
}
return mixedPlanWeaknessSummaryResponse{
TopicScores: topicScores,
WeakTags: append([]string(nil), summary.WeakTags...),
RecentIssues: append([]string(nil), summary.RecentIssues...),
}
}
func questionTopicPointer(topic sqlc.NullQuestionTopic) *string {
if !topic.Valid {
return nil
}
value := string(topic.QuestionTopic)
return &value
}
func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string {
if !difficulty.Valid {
return nil
}
value := string(difficulty.QuestionDifficulty)
return &value
}
func personalizedRatioValue(value *float64) float64 {
if value == nil || *value == 0 {
return 0.30
}
return *value
}
func int64Value(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func int64Pointer(value int64) *int64 {
return &value
}
func stringPointer(value string) *string {
return &value
}
func trimmedPointerValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func mapAssignmentQuestion(question sqlc.ListQuestionsForAssignmentRow) AssignmentQuestionResponse {
return AssignmentQuestionResponse{
AssignmentID: question.AssignmentID,
QuestionID: question.QuestionID,
Position: question.Position,
AuthorTeacherID: question.AuthorTeacherID,
Title: question.Title,
Prompt: question.Prompt,
Subject: shared.TextPointer(question.Subject),
Source: shared.TextPointer(question.Source),
QuestionStatus: string(question.Status),
QuestionCreatedAt: shared.TimePointer(question.CreatedAt),
QuestionUpdatedAt: shared.TimePointer(question.UpdatedAt),
}
}
func mapAssignmentStudentQuestionDetail(row sqlc.ListQuestionDetailsForAssignmentStudentRow, studentID int64) AssignmentStudentQuestionDetailResponse {
var answerStatus *string
if row.AnswerStatus.Valid {
status := string(row.AnswerStatus.AnswerStatus)
answerStatus = &status
}
var passStatus *string
if row.PassStatus.Valid {
status := string(row.PassStatus.AssignmentPassStatus)
passStatus = &status
}
var passStatusOverride *string
if row.PassStatusOverride.Valid {
status := string(row.PassStatusOverride.AssignmentPassStatus)
passStatusOverride = &status
}
var nextStepOutcome *string
if row.NextStepOutcome.Valid {
outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome)
nextStepOutcome = &outcome
}
var reviewNeedsAttention *bool
if row.AnswerID.Valid {
reviewNeedsAttention = shared.BoolPointer(row.ReviewNeedsAttention)
}
return AssignmentStudentQuestionDetailResponse{
AssignmentID: row.AssignmentID,
StudentID: studentID,
QuestionID: row.QuestionID,
Position: row.Position,
Title: row.Title,
Prompt: row.Prompt,
Subject: shared.TextPointer(row.Subject),
Source: shared.TextPointer(row.Source),
QuestionTags: row.QuestionTags,
QuestionStatus: string(row.QuestionStatus),
CorrectAnswer: shared.TextPointer(row.CorrectAnswer),
AssignmentAiFeedback: shared.TextPointer(row.AssignmentAiFeedback),
AssignmentTeacherFeedback: shared.TextPointer(row.AssignmentTeacherFeedback),
OverallScore: shared.NumericPointer(row.OverallScore),
PassThreshold: shared.NumericPointer(row.PassThreshold),
NextStepOutcome: nextStepOutcome,
PassStatusOverride: passStatusOverride,
PassStatus: passStatus,
AnswerID: shared.Int64Pointer(row.AnswerID),
AnswerText: shared.TextPointer(row.AnswerText),
SolveMode: shared.TextPointer(row.SolveMode),
WorkingSteps: shared.TextPointer(row.WorkingSteps),
IsCorrect: shared.BoolPointer(row.IsCorrect),
AiFeedback: shared.TextPointer(row.AiFeedback),
TeacherFeedback: shared.TextPointer(row.TeacherFeedback),
AnswerStatus: answerStatus,
ReviewNeedsAttention: reviewNeedsAttention,
ReviewIssueReason: shared.TextPointer(row.ReviewIssueReason),
ReviewCorrectnessScore: shared.NumericPointer(row.ReviewCorrectnessScore),
ReviewUnderstandingScore: shared.NumericPointer(row.ReviewUnderstandingScore),
ReviewQuestionScore: shared.NumericPointer(row.ReviewQuestionScore),
ReviewConfidence: shared.NumericPointer(row.ReviewConfidence),
ReviewTags: row.ReviewTags,
SubmittedAt: shared.TimePointer(row.SubmittedAt),
ReviewedAt: shared.TimePointer(row.ReviewedAt),
AnswerCreatedAt: shared.TimePointer(row.AnswerCreatedAt),
AnswerUpdatedAt: shared.TimePointer(row.AnswerUpdatedAt),
}
}
func mapAssignmentReviewSummary(summary sqlc.GetAssignmentReviewSummaryRow) AssignmentReviewSummaryResponse {
return AssignmentReviewSummaryResponse{
AssignmentID: summary.AssignmentID,
TotalQuestions: summary.TotalQuestions,
TotalAssigned: summary.TotalAssigned,
NotStarted: summary.NotStarted,
InProgress: summary.InProgress,
Submitted: summary.Submitted,
Reviewed: summary.Reviewed,
}
}
func mapAssignmentReviewQueueItem(row sqlc.ListAssignmentReviewQueueRow) AssignmentReviewQueueItemResponse {
var nextStepOutcome *string
if row.NextStepOutcome.Valid {
outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome)
nextStepOutcome = &outcome
}
return AssignmentReviewQueueItemResponse{
AssignmentID: row.AssignmentID,
StudentID: row.StudentID,
NextStepOutcome: nextStepOutcome,
StudentName: row.StudentName,
StudentEmail: row.StudentEmail,
TotalQuestions: row.TotalQuestions,
AnsweredQuestions: row.AnsweredQuestions,
ReviewedQuestions: row.ReviewedQuestions,
SubmittedQuestions: row.SubmittedQuestions,
InProgressQuestions: row.InProgressQuestions,
ReviewStatus: string(row.ReviewStatus),
LatestSubmittedAt: shared.TimePointer(row.LatestSubmittedAt),
LatestReviewedAt: shared.TimePointer(row.LatestReviewedAt),
}
}
func buildAssignmentCloseReadiness(queue []sqlc.ListAssignmentReviewQueueRow) assignmentCloseReadiness {
blockers := make([]string, 0)
if len(queue) == 0 {
return assignmentCloseReadiness{
CanClose: false,
Blockers: []string{"No students have been assigned yet."},
}
}
for _, item := range queue {
name := strings.TrimSpace(item.StudentName)
if name == "" {
name = fmt.Sprintf("Student %d", item.StudentID)
}
switch {
case item.SubmittedQuestions > 0 || item.ReviewStatus == sqlc.AnswerStatusSubmitted:
blockers = append(blockers, fmt.Sprintf("%s still has submitted work waiting for review.", name))
case item.InProgressQuestions > 0 || item.ReviewStatus == sqlc.AnswerStatusInProgress:
blockers = append(blockers, fmt.Sprintf("%s still has work in progress.", name))
case item.AnsweredQuestions == 0 || item.ReviewStatus == sqlc.AnswerStatusNotStarted:
blockers = append(blockers, fmt.Sprintf("%s has not started this assignment yet.", name))
case !item.NextStepOutcome.Valid:
blockers = append(blockers, fmt.Sprintf("%s still needs a next-step decision.", name))
}
}
return assignmentCloseReadiness{
CanClose: len(blockers) == 0,
Blockers: blockers,
}
}
func parseStoredRedoPlan(value string) (storedRedoPlan, error) {
var payload storedRedoPlan
if err := json.Unmarshal([]byte(value), &payload); err != nil {
return storedRedoPlan{}, err
}
return payload, nil
}
func mapWeaknessSummary(studentID int64, summary weaknessSummary) StudentWeaknessSummaryResponse {
return StudentWeaknessSummaryResponse{
StudentID: studentID,
TopicScores: summary.TopicScores,
WeakTags: summary.WeakTags,
RecentIssues: summary.RecentIssues,
}
}
func planningScore(isCorrect pgtype.Bool, understanding pgtype.Numeric) float64 {
understandingValue := 0.0
if value := shared.NumericPointer(understanding); value != nil {
understandingValue = *value
}
correctnessValue := 0.0
if isCorrect.Valid && isCorrect.Bool {
correctnessValue = 1.0
}
return (correctnessValue + understandingValue) / 2
}
func roundToOneDecimal(value float64) float64 {
return math.Round(value*10) / 10
}
func allowedQuestionTopics() []string {
return []string{
string(sqlc.QuestionTopicPlaceValue),
string(sqlc.QuestionTopicArithmetic),
string(sqlc.QuestionTopicNegativeNumbers),
string(sqlc.QuestionTopicBidmas),
string(sqlc.QuestionTopicFractions),
string(sqlc.QuestionTopicAlgebra),
string(sqlc.QuestionTopicGeometry),
string(sqlc.QuestionTopicData),
}
}
func emptyStringPointer(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func isValidAssignmentPassStatus(value string) bool {
switch value {
case string(sqlc.AssignmentPassStatusPending), string(sqlc.AssignmentPassStatusPass), string(sqlc.AssignmentPassStatusNoPass):
return true
default:
return false
}
}
func isValidAssignmentNextStepOutcome(value string) bool {
switch value {
case "redo", "accept", "support":
return true
default:
return false
}
}
func pointerToFloat64(value float64) *float64 {
return &value
}

View File

@@ -0,0 +1,236 @@
package assignments
import (
"boostai-backend/internal/aireview"
"time"
)
type AssignmentResponse struct {
ID int64 `json:"id"`
ClassroomID int64 `json:"classroom_id"`
TeacherID int64 `json:"teacher_id"`
Title string `json:"title"`
Instructions *string `json:"instructions,omitempty"`
PassThreshold *float64 `json:"pass_threshold,omitempty"`
Status string `json:"status"`
DueAt *time.Time `json:"due_at,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type AssignmentQuestionResponse struct {
AssignmentID int64 `json:"assignment_id"`
QuestionID int64 `json:"question_id"`
Position int32 `json:"position"`
AuthorTeacherID int64 `json:"author_teacher_id"`
Title string `json:"title"`
Prompt string `json:"prompt"`
Subject *string `json:"subject,omitempty"`
Source *string `json:"source,omitempty"`
QuestionStatus string `json:"question_status"`
QuestionCreatedAt *time.Time `json:"question_created_at,omitempty"`
QuestionUpdatedAt *time.Time `json:"question_updated_at,omitempty"`
}
type AssignmentStudentQuestionDetailResponse struct {
AssignmentID int64 `json:"assignment_id"`
StudentID int64 `json:"student_id"`
QuestionID int64 `json:"question_id"`
Position int32 `json:"position"`
Title string `json:"title"`
Prompt string `json:"prompt"`
Subject *string `json:"subject,omitempty"`
Source *string `json:"source,omitempty"`
QuestionTags []string `json:"question_tags,omitempty"`
QuestionStatus string `json:"question_status"`
CorrectAnswer *string `json:"correct_answer,omitempty"`
AssignmentAiFeedback *string `json:"assignment_ai_feedback,omitempty"`
AssignmentTeacherFeedback *string `json:"assignment_teacher_feedback,omitempty"`
OverallScore *float64 `json:"overall_score,omitempty"`
PassThreshold *float64 `json:"pass_threshold,omitempty"`
NextStepOutcome *string `json:"next_step_outcome,omitempty"`
PassStatusOverride *string `json:"pass_status_override,omitempty"`
PassStatus *string `json:"pass_status,omitempty"`
AnswerID *int64 `json:"answer_id,omitempty"`
AnswerText *string `json:"answer_text,omitempty"`
SolveMode *string `json:"solve_mode,omitempty"`
WorkingSteps *string `json:"working_steps,omitempty"`
IsCorrect *bool `json:"is_correct,omitempty"`
AiFeedback *string `json:"ai_feedback,omitempty"`
TeacherFeedback *string `json:"teacher_feedback,omitempty"`
AnswerStatus *string `json:"answer_status,omitempty"`
ReviewNeedsAttention *bool `json:"review_needs_attention,omitempty"`
ReviewIssueReason *string `json:"review_issue_reason,omitempty"`
ReviewCorrectnessScore *float64 `json:"review_correctness_score,omitempty"`
ReviewUnderstandingScore *float64 `json:"review_understanding_score,omitempty"`
ReviewQuestionScore *float64 `json:"review_question_score,omitempty"`
ReviewConfidence *float64 `json:"review_confidence,omitempty"`
ReviewTags []string `json:"review_tags,omitempty"`
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
AnswerCreatedAt *time.Time `json:"answer_created_at,omitempty"`
AnswerUpdatedAt *time.Time `json:"answer_updated_at,omitempty"`
}
type updateAssignmentTeacherFeedbackRequest struct {
TeacherFeedback *string `json:"teacher_feedback"`
PassStatusOverride *string `json:"pass_status_override"`
NextStepOutcome *string `json:"next_step_outcome"`
}
type updateAssignmentDraftRequest struct {
ClassroomID int64 `json:"classroom_id"`
Title string `json:"title"`
Instructions *string `json:"instructions"`
PassThreshold *float64 `json:"pass_threshold"`
DueAt *time.Time `json:"due_at"`
}
type AssignmentReviewSummaryResponse struct {
AssignmentID int64 `json:"assignment_id"`
TotalQuestions int64 `json:"total_questions"`
TotalAssigned int64 `json:"total_assigned"`
NotStarted int64 `json:"not_started"`
InProgress int64 `json:"in_progress"`
Submitted int64 `json:"submitted"`
Reviewed int64 `json:"reviewed"`
}
type AssignmentReviewQueueItemResponse struct {
AssignmentID int64 `json:"assignment_id"`
StudentID int64 `json:"student_id"`
NextStepOutcome *string `json:"next_step_outcome,omitempty"`
StudentName string `json:"student_name"`
StudentEmail string `json:"student_email"`
TotalQuestions int64 `json:"total_questions"`
AnsweredQuestions int64 `json:"answered_questions"`
ReviewedQuestions int64 `json:"reviewed_questions"`
SubmittedQuestions int64 `json:"submitted_questions"`
InProgressQuestions int64 `json:"in_progress_questions"`
ReviewStatus string `json:"review_status"`
LatestSubmittedAt *time.Time `json:"latest_submitted_at,omitempty"`
LatestReviewedAt *time.Time `json:"latest_reviewed_at,omitempty"`
}
type assignmentCloseReadiness struct {
CanClose bool
Blockers []string
}
type StudentWeaknessSummaryResponse struct {
StudentID int64 `json:"student_id"`
TopicScores map[string]float64 `json:"topic_scores"`
WeakTags []string `json:"weak_tags"`
RecentIssues []string `json:"recent_issues"`
}
type AssignmentRedoPlanResponse struct {
AssignmentID int64 `json:"assignment_id"`
StudentID int64 `json:"student_id"`
RedoPlanGeneratedAt *time.Time `json:"redo_plan_generated_at,omitempty"`
TeacherFeedback *string `json:"teacher_feedback,omitempty"`
WeaknessSummary StudentWeaknessSummaryResponse `json:"weakness_summary"`
Plan *aireview.RedoPlanResult `json:"plan,omitempty"`
Error string `json:"error,omitempty"`
}
type createAssignmentRequest struct {
ClassroomID int64 `json:"classroom_id"`
TeacherID int64 `json:"teacher_id"`
Title string `json:"title"`
Instructions *string `json:"instructions"`
PassThreshold *float64 `json:"pass_threshold"`
Status string `json:"status"`
DueAt *time.Time `json:"due_at"`
PublishedAt *time.Time `json:"published_at"`
}
type assignStudentToAssignmentRequest struct {
StudentID int64 `json:"student_id"`
MixedGeneration *generateMixedStudentQuestionsRequest `json:"mixed_generation"`
}
type addQuestionToAssignmentRequest struct {
QuestionID int64 `json:"question_id"`
Position int32 `json:"position"`
}
type generateMixedStudentQuestionsRequest struct {
PrimaryTopic string `json:"primary_topic"`
PrimaryDifficulty string `json:"primary_difficulty"`
TotalQuestions int `json:"total_questions"`
PersonalizedRatio *float64 `json:"personalized_ratio"`
Seed *int64 `json:"seed"`
PersonalizedDifficulty *string `json:"personalized_difficulty"`
Subject *string `json:"subject"`
QuestionStatus *string `json:"question_status"`
QuestionSource *string `json:"question_source"`
}
type mixedPlanWeaknessSummaryResponse struct {
TopicScores map[string]float64 `json:"topic_scores"`
WeakTags []string `json:"weak_tags"`
RecentIssues []string `json:"recent_issues"`
}
type mixedPlanQuestionResponse struct {
MappingID int64 `json:"mapping_id"`
QuestionID int64 `json:"question_id"`
Position int32 `json:"position"`
SourceBucket string `json:"source_bucket"`
SourceTopic *string `json:"source_topic,omitempty"`
SourceDifficulty *string `json:"source_difficulty,omitempty"`
GeneratorSeed *int64 `json:"generator_seed,omitempty"`
Title string `json:"title"`
Prompt string `json:"prompt"`
Subject *string `json:"subject,omitempty"`
QuestionStatus string `json:"question_status"`
QuestionSource *string `json:"question_source,omitempty"`
CorrectAnswer *string `json:"correct_answer,omitempty"`
Tags []string `json:"tags,omitempty"`
QuestionCreatedAt *time.Time `json:"question_created_at,omitempty"`
QuestionUpdatedAt *time.Time `json:"question_updated_at,omitempty"`
}
type generateMixedStudentQuestionsResponse struct {
AssignmentID int64 `json:"assignment_id"`
StudentID int64 `json:"student_id"`
PrimaryTopic string `json:"primary_topic"`
PrimaryDifficulty string `json:"primary_difficulty"`
TotalQuestions int `json:"total_questions"`
CoreCount int `json:"core_count"`
PersonalizedCount int `json:"personalized_count"`
PersonalizedApplied bool `json:"personalized_applied"`
PersonalizedTopic *string `json:"personalized_topic,omitempty"`
PersonalizedRatio float64 `json:"personalized_ratio"`
BaseSeed int64 `json:"base_seed"`
WeaknessSummary mixedPlanWeaknessSummaryResponse `json:"weakness_summary"`
Questions []mixedPlanQuestionResponse `json:"questions"`
}
type assignmentAPIError struct {
status int
code string
message string
}
func (e *assignmentAPIError) Error() string {
if e == nil {
return ""
}
return e.message
}
type weaknessSummary struct {
TopicScores map[string]float64 `json:"topicScores"`
WeakTags []string `json:"weakTags"`
RecentIssues []string `json:"recentIssues"`
}
type storedRedoPlan struct {
TeacherFeedback string `json:"teacherFeedback,omitempty"`
WeaknessSummary weaknessSummary `json:"weaknessSummary"`
Plan *aireview.RedoPlanResult `json:"plan,omitempty"`
Error string `json:"error,omitempty"`
}

View File

@@ -0,0 +1,25 @@
package assignments
import (
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
app.Get("/teachers/:teacherId/assignments", auth.RequireTeacherSelf("teacherId"), h.ListAssignmentsByTeacher)
app.Get("/students/:studentId/assignments", auth.RequireStudentSelfOrTeacher("studentId"), h.ListAssignmentsForStudent)
app.Get("/assignments/:assignmentId", h.GetAssignmentByID)
app.Get("/assignments/:assignmentId/questions", h.ListQuestionsForAssignment)
app.Get("/assignments/:assignmentId/students/:studentId/questions", auth.RequireStudentSelfOrTeacher("studentId"), h.ListQuestionDetailsForAssignmentStudent)
app.Get("/assignments/:assignmentId/students/:studentId/redo-plan", auth.RequireTeacher(), h.GetAssignmentRedoPlan)
app.Post("/assignments/:assignmentId/students/:studentId/generate-mixed-questions", auth.RequireTeacher(), h.GenerateMixedStudentQuestions)
app.Patch("/assignments/:assignmentId", auth.RequireTeacher(), h.UpdateAssignmentDraft)
app.Post("/assignments/:assignmentId/close", auth.RequireTeacher(), h.CloseAssignment)
app.Patch("/assignments/:assignmentId/students/:studentId/feedback", auth.RequireTeacher(), h.UpdateAssignmentTeacherFeedback)
app.Get("/assignments/:assignmentId/review-summary", auth.RequireTeacher(), h.GetAssignmentReviewSummary)
app.Get("/assignments/:assignmentId/review", auth.RequireTeacher(), h.ListAssignmentReviewQueue)
app.Post("/assignments", auth.RequireTeacher(), h.CreateAssignment)
app.Post("/assignments/:assignmentId/students", auth.RequireTeacher(), h.AssignStudentToAssignment)
app.Post("/assignments/:assignmentId/questions", auth.RequireTeacher(), h.AddQuestionToAssignment)
}

View File

@@ -0,0 +1,180 @@
package classrooms
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/sqlc"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
type Handler struct {
queries *sqlc.Queries
}
type ClassroomResponse struct {
ID int64 `json:"id"`
TeacherID int64 `json:"teacher_id"`
Name string `json:"name"`
Code *string `json:"code,omitempty"`
Description *string `json:"description,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type StudentResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
FullName string `json:"full_name"`
IsActive bool `json:"is_active"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type createClassroomRequest struct {
TeacherID int64 `json:"teacher_id"`
Name string `json:"name"`
Code *string `json:"code"`
Description *string `json:"description"`
}
type addStudentToClassroomRequest struct {
StudentID int64 `json:"student_id"`
}
func NewHandler(queries *sqlc.Queries) *Handler {
return &Handler{queries: queries}
}
func (h *Handler) ListClassroomsByTeacher(c *fiber.Ctx) error {
teacherID, err := params.Int64PathParam(c, "teacherId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
classrooms, err := h.queries.ListClassroomsByTeacher(ctx, teacherID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]ClassroomResponse, 0, len(classrooms))
for _, classroom := range classrooms {
items = append(items, mapClassroom(classroom))
}
return c.JSON(shared.ListResponse[ClassroomResponse]{Data: items})
}
func (h *Handler) ListStudentsForClassroom(c *fiber.Ctx) error {
classroomID, err := params.Int64PathParam(c, "classroomId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
students, err := h.queries.ListStudentsForClassroom(ctx, classroomID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]StudentResponse, 0, len(students))
for _, student := range students {
items = append(items, mapStudent(student))
}
return c.JSON(shared.ListResponse[StudentResponse]{Data: items})
}
func (h *Handler) CreateClassroom(c *fiber.Ctx) error {
var req createClassroomRequest
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.Name) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "teacher authentication and name are required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
classroom, err := h.queries.CreateClassroom(ctx, sqlc.CreateClassroomParams{
TeacherID: teacherID,
Name: strings.TrimSpace(req.Name),
Code: shared.NullableText(req.Code),
Description: shared.NullableText(req.Description),
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(mapClassroom(classroom))
}
func (h *Handler) AddStudentToClassroom(c *fiber.Ctx) error {
classroomID, err := params.Int64PathParam(c, "classroomId")
if err != nil {
return err
}
var req addStudentToClassroomRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
if req.StudentID == 0 {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "student_id is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
err = h.queries.AddStudentToClassroom(ctx, sqlc.AddStudentToClassroomParams{
ClassroomID: classroomID,
StudentID: req.StudentID,
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"status": "ok",
"classroom_id": classroomID,
"student_id": req.StudentID,
})
}
func mapClassroom(classroom sqlc.Classroom) ClassroomResponse {
return ClassroomResponse{
ID: classroom.ID,
TeacherID: classroom.TeacherID,
Name: classroom.Name,
Code: shared.TextPointer(classroom.Code),
Description: shared.TextPointer(classroom.Description),
CreatedAt: shared.TimePointer(classroom.CreatedAt),
UpdatedAt: shared.TimePointer(classroom.UpdatedAt),
}
}
func mapStudent(user sqlc.User) StudentResponse {
return StudentResponse{
ID: user.ID,
Email: user.Email,
Role: string(user.Role),
FullName: user.FullName,
IsActive: user.IsActive,
CreatedAt: shared.TimePointer(user.CreatedAt),
UpdatedAt: shared.TimePointer(user.UpdatedAt),
}
}

View File

@@ -0,0 +1,14 @@
package classrooms
import (
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
app.Get("/teachers/:teacherId/classrooms", h.ListClassroomsByTeacher)
app.Get("/classrooms/:classroomId/students", h.ListStudentsForClassroom)
app.Post("/classrooms", auth.RequireTeacher(), h.CreateClassroom)
app.Post("/classrooms/:classroomId/students", auth.RequireTeacher(), h.AddStudentToClassroom)
}

View File

@@ -0,0 +1,41 @@
package api
import (
"boostai-backend/internal/aireview"
"boostai-backend/internal/assignmentgen"
"boostai-backend/internal/config"
"boostai-backend/internal/database"
answershandler "boostai-backend/internal/handlers/api/answers"
assignmentshandler "boostai-backend/internal/handlers/api/assignments"
classroomshandler "boostai-backend/internal/handlers/api/classrooms"
messageshandler "boostai-backend/internal/handlers/api/messages"
questionshandler "boostai-backend/internal/handlers/api/questions"
usershandler "boostai-backend/internal/handlers/api/users"
"boostai-backend/internal/questiongen"
"boostai-backend/internal/sqlc"
)
type Handler struct {
users *usershandler.Handler
classrooms *classroomshandler.Handler
messages *messageshandler.Handler
questions *questionshandler.Handler
assignments *assignmentshandler.Handler
answers *answershandler.Handler
}
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
queries := sqlc.New(db.Pool)
aiReviewService := aireview.NewService(cfg.AIReviewEndpoint, cfg.AIReviewAPIKey, cfg.AIReviewModel)
questionGenerator := questiongen.NewService()
assignmentGenerator := assignmentgen.NewService(db, questionGenerator)
return &Handler{
users: usershandler.NewHandler(queries),
classrooms: classroomshandler.NewHandler(queries),
messages: messageshandler.NewHandler(db),
questions: questionshandler.NewHandler(queries, questionGenerator),
assignments: assignmentshandler.NewHandler(queries, aiReviewService, assignmentGenerator),
answers: answershandler.NewHandler(queries, aiReviewService),
}
}

View File

@@ -0,0 +1,708 @@
package messages
import (
"boostai-backend/internal/database"
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/http/params"
"boostai-backend/internal/http/respond"
authmw "boostai-backend/internal/middleware"
"boostai-backend/internal/sqlc"
"errors"
"sort"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type Handler struct {
db *database.DB
queries *sqlc.Queries
}
type recipientResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
FullName string `json:"full_name"`
PreferredName *string `json:"preferred_name"`
ProfileIconURL *string `json:"profile_icon_url"`
Headline *string `json:"headline"`
}
type threadParticipantResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
FullName string `json:"full_name"`
PreferredName *string `json:"preferred_name"`
ProfileIconURL *string `json:"profile_icon_url"`
Headline *string `json:"headline"`
JoinedAt *time.Time `json:"joined_at,omitempty"`
LastReadAt *time.Time `json:"last_read_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
type messageSenderResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
FullName string `json:"full_name"`
PreferredName *string `json:"preferred_name"`
ProfileIconURL *string `json:"profile_icon_url"`
Headline *string `json:"headline"`
}
type messageResponse struct {
ID int64 `json:"id"`
ThreadID int64 `json:"thread_id"`
Body string `json:"body"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Mine bool `json:"mine"`
Sender messageSenderResponse `json:"sender"`
}
type messageThreadSummaryResponse struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
CreatedByUserID int64 `json:"created_by_user_id"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UnreadCount int64 `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
LastMessageBody *string `json:"last_message_body"`
LastMessageCreatedAt *time.Time `json:"last_message_created_at,omitempty"`
LastMessageSender *messageSenderResponse `json:"last_message_sender,omitempty"`
Participants []threadParticipantResponse `json:"participants"`
}
type messageThreadDetailResponse struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
CreatedByUserID int64 `json:"created_by_user_id"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UnreadCount int64 `json:"unread_count"`
LastReadAt *time.Time `json:"last_read_at,omitempty"`
Participants []threadParticipantResponse `json:"participants"`
Messages []messageResponse `json:"messages"`
}
type createThreadRequest struct {
Subject string `json:"subject"`
RecipientIDs []int64 `json:"recipient_ids"`
Body string `json:"body"`
}
type createThreadResponse struct {
ThreadID int64 `json:"thread_id"`
}
type createThreadMessageRequest struct {
Body string `json:"body"`
}
type updateThreadRequest struct {
Subject string `json:"subject"`
}
type updateThreadMessageRequest struct {
Body string `json:"body"`
}
func NewHandler(db *database.DB) *Handler {
return &Handler{db: db, queries: sqlc.New(db.Pool)}
}
func (h *Handler) ListRecipients(c *fiber.Ctx) error {
currentUserID := authmw.CurrentUserID(c)
ctx, cancel := shared.WithTimeout()
defer cancel()
recipients, err := h.queries.ListMessageRecipientsForUser(ctx, currentUserID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]recipientResponse, 0, len(recipients))
for _, recipient := range recipients {
items = append(items, mapRecipient(recipient))
}
return c.JSON(shared.ListResponse[recipientResponse]{Data: items})
}
func (h *Handler) ListThreads(c *fiber.Ctx) error {
currentUserID := authmw.CurrentUserID(c)
ctx, cancel := shared.WithTimeout()
defer cancel()
threads, err := h.queries.ListMessageThreadsForUser(ctx, currentUserID)
if err != nil {
return respond.DatabaseError(c, err)
}
participants, err := h.queries.ListMessageThreadParticipantsForUser(ctx, currentUserID)
if err != nil {
return respond.DatabaseError(c, err)
}
participantsByThread := make(map[int64][]threadParticipantResponse)
for _, participant := range participants {
participantsByThread[participant.ThreadID] = append(participantsByThread[participant.ThreadID], mapThreadParticipant(participant))
}
items := make([]messageThreadSummaryResponse, 0, len(threads))
for _, thread := range threads {
items = append(items, mapThreadSummary(thread, participantsByThread[thread.ThreadID]))
}
return c.JSON(shared.ListResponse[messageThreadSummaryResponse]{Data: items})
}
func (h *Handler) GetThread(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
thread, err := h.loadThread(threadID, authmw.CurrentUserID(c))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(thread)
}
func (h *Handler) CreateThread(c *fiber.Ctx) error {
currentUserID := authmw.CurrentUserID(c)
var req createThreadRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
subject := strings.TrimSpace(req.Subject)
body := strings.TrimSpace(req.Body)
if subject == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "subject is required")
}
recipientIDs := normalizeRecipientIDs(currentUserID, req.RecipientIDs)
if len(recipientIDs) == 0 {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "At least one valid recipient is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
tx, err := h.db.Pool.Begin(ctx)
if err != nil {
return respond.DatabaseError(c, err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
queries := h.queries.WithTx(tx)
for _, recipientID := range recipientIDs {
if _, err := queries.GetMessageRecipientByIDForUser(ctx, sqlc.GetMessageRecipientByIDForUserParams{ID: currentUserID, ID_2: recipientID}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "One or more recipients are not available for messaging")
}
return respond.DatabaseError(c, err)
}
}
thread, err := queries.CreateMessageThread(ctx, sqlc.CreateMessageThreadParams{
CreatedByUserID: currentUserID,
Subject: subject,
})
if err != nil {
return respond.DatabaseError(c, err)
}
creatorReadAt := pgtype.Timestamptz{}
if body != "" {
message, err := queries.CreateThreadMessage(ctx, sqlc.CreateThreadMessageParams{
ThreadID: thread.ID,
SenderUserID: currentUserID,
Body: body,
})
if err != nil {
return respond.DatabaseError(c, err)
}
if message.CreatedAt.Valid {
creatorReadAt = pgtype.Timestamptz{Time: message.CreatedAt.Time.UTC(), Valid: true}
}
}
if err := queries.AddMessageThreadParticipant(ctx, sqlc.AddMessageThreadParticipantParams{
ThreadID: thread.ID,
UserID: currentUserID,
LastReadAt: creatorReadAt,
}); err != nil {
return respond.DatabaseError(c, err)
}
for _, recipientID := range recipientIDs {
if err := queries.AddMessageThreadParticipant(ctx, sqlc.AddMessageThreadParticipantParams{
ThreadID: thread.ID,
UserID: recipientID,
}); err != nil {
return respond.DatabaseError(c, err)
}
}
if err := queries.TouchMessageThread(ctx, thread.ID); err != nil {
return respond.DatabaseError(c, err)
}
if err := tx.Commit(ctx); err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(createThreadResponse{ThreadID: thread.ID})
}
func (h *Handler) CreateThreadMessage(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
currentUserID := authmw.CurrentUserID(c)
var req createThreadMessageRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
body := strings.TrimSpace(req.Body)
if body == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "body is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
tx, err := h.db.Pool.Begin(ctx)
if err != nil {
return respond.DatabaseError(c, err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
queries := h.queries.WithTx(tx)
if _, err := queries.GetMessageThreadForUser(ctx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
if _, err := queries.CreateThreadMessage(ctx, sqlc.CreateThreadMessageParams{
ThreadID: threadID,
SenderUserID: currentUserID,
Body: body,
}); err != nil {
return respond.DatabaseError(c, err)
}
if err := queries.TouchMessageThread(ctx, threadID); err != nil {
return respond.DatabaseError(c, err)
}
if _, err := queries.MarkMessageThreadRead(ctx, sqlc.MarkMessageThreadReadParams{ThreadID: threadID, UserID: currentUserID}); err != nil {
return respond.DatabaseError(c, err)
}
if err := tx.Commit(ctx); err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "ok"})
}
func (h *Handler) UpdateThread(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
currentUserID := authmw.CurrentUserID(c)
var req updateThreadRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
subject := strings.TrimSpace(req.Subject)
if subject == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "subject is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
thread, err := h.queries.GetMessageThreadForUser(ctx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
if thread.CreatedByUserID != currentUserID {
return respond.Error(c, fiber.StatusForbidden, "forbidden", "Only the conversation starter can edit the thread title")
}
if _, err := h.queries.UpdateMessageThreadSubject(ctx, sqlc.UpdateMessageThreadSubjectParams{
ThreadID: threadID,
Subject: subject,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *Handler) DeleteThread(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
currentUserID := authmw.CurrentUserID(c)
ctx, cancel := shared.WithTimeout()
defer cancel()
thread, err := h.queries.GetMessageThreadForUser(ctx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
if thread.CreatedByUserID != currentUserID {
return respond.Error(c, fiber.StatusForbidden, "forbidden", "Only the conversation starter can delete this conversation")
}
if _, err := h.queries.DeleteMessageThread(ctx, threadID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *Handler) UpdateThreadMessage(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
messageID, err := params.Int64PathParam(c, "messageId")
if err != nil {
return err
}
currentUserID := authmw.CurrentUserID(c)
var req updateThreadMessageRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
body := strings.TrimSpace(req.Body)
if body == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "body is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
tx, err := h.db.Pool.Begin(ctx)
if err != nil {
return respond.DatabaseError(c, err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
queries := h.queries.WithTx(tx)
if _, err := queries.UpdateThreadMessageBody(ctx, sqlc.UpdateThreadMessageBodyParams{
Body: body,
MessageID: messageID,
ThreadID: threadID,
UserID: currentUserID,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message not found")
}
return respond.DatabaseError(c, err)
}
if err := queries.TouchMessageThread(ctx, threadID); err != nil {
return respond.DatabaseError(c, err)
}
if err := tx.Commit(ctx); err != nil {
return respond.DatabaseError(c, err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *Handler) DeleteThreadMessage(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
messageID, err := params.Int64PathParam(c, "messageId")
if err != nil {
return err
}
currentUserID := authmw.CurrentUserID(c)
ctx, cancel := shared.WithTimeout()
defer cancel()
tx, err := h.db.Pool.Begin(ctx)
if err != nil {
return respond.DatabaseError(c, err)
}
defer func() {
_ = tx.Rollback(ctx)
}()
queries := h.queries.WithTx(tx)
if _, err := queries.DeleteThreadMessage(ctx, sqlc.DeleteThreadMessageParams{
MessageID: messageID,
ThreadID: threadID,
UserID: currentUserID,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message not found")
}
return respond.DatabaseError(c, err)
}
if err := queries.TouchMessageThread(ctx, threadID); err != nil {
return respond.DatabaseError(c, err)
}
if err := tx.Commit(ctx); err != nil {
return respond.DatabaseError(c, err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *Handler) MarkThreadRead(c *fiber.Ctx) error {
threadID, err := params.Int64PathParam(c, "threadId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
if _, err := h.queries.MarkMessageThreadRead(ctx, sqlc.MarkMessageThreadReadParams{ThreadID: threadID, UserID: authmw.CurrentUserID(c)}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Message thread not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *Handler) loadThread(threadID, currentUserID int64) (messageThreadDetailResponse, error) {
queryCtx, cancel := shared.WithTimeout()
defer cancel()
thread, err := h.queries.GetMessageThreadForUser(queryCtx, sqlc.GetMessageThreadForUserParams{ID: threadID, SenderUserID: currentUserID})
if err != nil {
return messageThreadDetailResponse{}, err
}
participants, err := h.queries.ListParticipantsForThreadForUser(queryCtx, sqlc.ListParticipantsForThreadForUserParams{ThreadID: threadID, UserID: currentUserID})
if err != nil {
return messageThreadDetailResponse{}, err
}
messages, err := h.queries.ListMessagesForThreadForUser(queryCtx, sqlc.ListMessagesForThreadForUserParams{ThreadID: threadID, UserID: currentUserID})
if err != nil {
return messageThreadDetailResponse{}, err
}
participantItems := make([]threadParticipantResponse, 0, len(participants))
for _, participant := range participants {
participantItems = append(participantItems, mapThreadParticipantByThread(participant))
}
messageItems := make([]messageResponse, 0, len(messages))
for _, message := range messages {
messageItems = append(messageItems, mapThreadMessage(message, currentUserID))
}
return messageThreadDetailResponse{
ID: thread.ID,
Subject: thread.Subject,
CreatedByUserID: thread.CreatedByUserID,
CreatedAt: shared.TimePointer(thread.CreatedAt),
UpdatedAt: shared.TimePointer(thread.UpdatedAt),
UnreadCount: thread.UnreadCount,
LastReadAt: shared.TimePointer(thread.LastReadAt),
Participants: participantItems,
Messages: messageItems,
}, nil
}
func mapRecipient(row sqlc.ListMessageRecipientsForUserRow) recipientResponse {
return recipientResponse{
ID: row.UserID,
Email: row.UserEmail,
Role: string(row.UserRole),
FullName: row.UserFullName,
PreferredName: shared.TextPointer(row.PreferredName),
ProfileIconURL: shared.TextPointer(row.ProfileIconUrl),
Headline: shared.TextPointer(row.Headline),
}
}
func mapRecipientByID(row sqlc.GetMessageRecipientByIDForUserRow) recipientResponse {
return recipientResponse{
ID: row.UserID,
Email: row.UserEmail,
Role: string(row.UserRole),
FullName: row.UserFullName,
PreferredName: shared.TextPointer(row.PreferredName),
ProfileIconURL: shared.TextPointer(row.ProfileIconUrl),
Headline: shared.TextPointer(row.Headline),
}
}
func mapThreadParticipant(row sqlc.ListMessageThreadParticipantsForUserRow) threadParticipantResponse {
return threadParticipantResponse{
ID: row.UserID,
Email: row.UserEmail,
Role: string(row.UserRole),
FullName: row.UserFullName,
PreferredName: shared.TextPointer(row.PreferredName),
ProfileIconURL: shared.TextPointer(row.ProfileIconUrl),
Headline: shared.TextPointer(row.Headline),
JoinedAt: shared.TimePointer(row.JoinedAt),
LastReadAt: shared.TimePointer(row.LastReadAt),
ArchivedAt: shared.TimePointer(row.ArchivedAt),
}
}
func mapThreadParticipantByThread(row sqlc.ListParticipantsForThreadForUserRow) threadParticipantResponse {
return threadParticipantResponse{
ID: row.UserID,
Email: row.UserEmail,
Role: string(row.UserRole),
FullName: row.UserFullName,
PreferredName: shared.TextPointer(row.PreferredName),
ProfileIconURL: shared.TextPointer(row.ProfileIconUrl),
Headline: shared.TextPointer(row.Headline),
JoinedAt: shared.TimePointer(row.JoinedAt),
LastReadAt: shared.TimePointer(row.LastReadAt),
ArchivedAt: shared.TimePointer(row.ArchivedAt),
}
}
func mapThreadSummary(row sqlc.ListMessageThreadsForUserRow, participants []threadParticipantResponse) messageThreadSummaryResponse {
response := messageThreadSummaryResponse{
ID: row.ThreadID,
Subject: row.Subject,
CreatedByUserID: row.CreatedByUserID,
CreatedAt: shared.TimePointer(row.ThreadCreatedAt),
UpdatedAt: shared.TimePointer(row.ThreadUpdatedAt),
UnreadCount: row.UnreadCount,
LastMessageID: row.LastMessageID,
LastMessageBody: stringPointerOrNil(row.LastMessageBody),
LastMessageCreatedAt: shared.TimePointer(row.LastMessageCreatedAt),
Participants: participants,
}
if row.LastMessageID > 0 {
response.LastMessageSender = &messageSenderResponse{
ID: row.LastMessageSenderUserID,
Email: "",
Role: "",
FullName: valueOrEmpty(row.LastMessageSenderFullName),
PreferredName: shared.TextPointer(row.LastMessageSenderPreferredName),
ProfileIconURL: shared.TextPointer(row.LastMessageSenderProfileIconUrl),
}
}
return response
}
func mapThreadMessage(row sqlc.ListMessagesForThreadForUserRow, currentUserID int64) messageResponse {
return messageResponse{
ID: row.ID,
ThreadID: row.ThreadID,
Body: row.Body,
CreatedAt: shared.TimePointer(row.CreatedAt),
UpdatedAt: shared.TimePointer(row.UpdatedAt),
Mine: row.SenderUserID == currentUserID,
Sender: messageSenderResponse{
ID: row.SenderUserID,
Email: row.SenderEmail,
Role: string(row.SenderRole),
FullName: row.SenderFullName,
PreferredName: shared.TextPointer(row.SenderPreferredName),
ProfileIconURL: shared.TextPointer(row.SenderProfileIconUrl),
Headline: shared.TextPointer(row.SenderHeadline),
},
}
}
func normalizeRecipientIDs(currentUserID int64, values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
normalized := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 || value == currentUserID {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] })
return normalized
}
func valueOrEmpty(value pgtype.Text) string {
if !value.Valid {
return ""
}
return value.String
}
func stringPointerOrNil(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
copy := value
return &copy
}

View File

@@ -0,0 +1,20 @@
package messages
import (
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
app.Get("/messages/recipients", h.ListRecipients)
app.Get("/messages/threads", h.ListThreads)
app.Get("/messages/threads/:threadId", h.GetThread)
app.Post("/messages/threads", h.CreateThread)
app.Patch("/messages/threads/:threadId", h.UpdateThread)
app.Delete("/messages/threads/:threadId", h.DeleteThread)
app.Post("/messages/threads/:threadId/messages", h.CreateThreadMessage)
app.Patch("/messages/threads/:threadId/messages/:messageId", h.UpdateThreadMessage)
app.Delete("/messages/threads/:threadId/messages/:messageId", h.DeleteThreadMessage)
app.Patch("/messages/threads/:threadId/read", h.MarkThreadRead)
}

View File

@@ -0,0 +1,506 @@
// 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
}

View File

@@ -0,0 +1,140 @@
package questions
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"boostai-backend/internal/http/respond"
"boostai-backend/internal/questiongen"
"boostai-backend/internal/sqlc"
"github.com/gofiber/fiber/v2"
)
func TestGenerateQuestionsReturnsGeneratorUnavailable(t *testing.T) {
t.Parallel()
handler := NewHandler(nil, nil)
status, body := performGenerateRequest(t, handler, map[string]any{
"topic": "fractions",
"difficulty": "easy",
"count": 1,
}, true)
if status != fiber.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", fiber.StatusServiceUnavailable, status)
}
if body.Error != "generator_unavailable" {
t.Fatalf("expected generator_unavailable error, got %#v", body)
}
}
func TestGenerateQuestionsRequiresTeacherAuthentication(t *testing.T) {
t.Parallel()
handler := NewHandler(nil, questiongen.NewService())
status, body := performGenerateRequest(t, handler, map[string]any{
"topic": "fractions",
"difficulty": "easy",
"count": 1,
}, false)
if status != fiber.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", fiber.StatusUnauthorized, status)
}
if body.Error != "unauthorized" {
t.Fatalf("expected unauthorized error, got %#v", body)
}
}
func TestGenerateQuestionsRejectsZeroCount(t *testing.T) {
t.Parallel()
handler := NewHandler(nil, questiongen.NewService())
status, body := performGenerateRequest(t, handler, map[string]any{
"topic": "fractions",
"difficulty": "easy",
"count": 0,
}, true)
if status != fiber.StatusBadRequest {
t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status)
}
if body.Message != "count must be between 1 and 25" {
t.Fatalf("expected count validation message, got %#v", body)
}
}
func TestGenerateQuestionsRejectsInvalidStatus(t *testing.T) {
t.Parallel()
handler := NewHandler(nil, questiongen.NewService())
status, body := performGenerateRequest(t, handler, map[string]any{
"topic": "fractions",
"difficulty": "easy",
"count": 1,
"status": "invalid",
}, true)
if status != fiber.StatusBadRequest {
t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status)
}
if body.Message != "status must be draft, published, or archived" {
t.Fatalf("expected invalid status message, got %#v", body)
}
}
func TestGenerateQuestionsRejectsInvalidTopic(t *testing.T) {
t.Parallel()
handler := NewHandler(nil, questiongen.NewService())
status, body := performGenerateRequest(t, handler, map[string]any{
"topic": "not_a_topic",
"difficulty": "easy",
"count": 1,
}, true)
if status != fiber.StatusBadRequest {
t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status)
}
if body.Error != "invalid_request" {
t.Fatalf("expected invalid_request error, got %#v", body)
}
}
func performGenerateRequest(t *testing.T, handler *Handler, payload map[string]any, authenticated bool) (int, respond.ErrorBody) {
t.Helper()
app := fiber.New()
app.Post("/questions/generate", func(c *fiber.Ctx) error {
if authenticated {
c.Locals("auth.user_id", int64(42))
c.Locals("auth.role", sqlc.UserRoleTeacher)
}
return handler.GenerateQuestions(c)
})
bodyBytes, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/questions/generate", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test returned error: %v", err)
}
defer resp.Body.Close()
var errorBody respond.ErrorBody
if err := json.NewDecoder(resp.Body).Decode(&errorBody); err != nil {
t.Fatalf("decode error response: %v", err)
}
return resp.StatusCode, errorBody
}

View File

@@ -0,0 +1,17 @@
package questions
import (
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
app.Get("/teachers/:teacherId/questions", auth.RequireTeacherSelf("teacherId"), h.ListQuestionsByTeacher)
app.Get("/questions/:questionId", h.GetQuestionByID)
app.Get("/tags", h.ListTags)
app.Post("/questions", auth.RequireTeacher(), h.CreateQuestion)
app.Post("/questions/generate", auth.RequireTeacher(), h.GenerateQuestions)
app.Post("/tags", auth.RequireTeacher(), h.CreateTag)
app.Post("/questions/:questionId/tags", auth.RequireTeacher(), h.AttachTagToQuestion)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"boostai-backend/internal/handlers/api/answers"
"boostai-backend/internal/handlers/api/assignments"
"boostai-backend/internal/handlers/api/classrooms"
"boostai-backend/internal/handlers/api/messages"
"boostai-backend/internal/handlers/api/questions"
"boostai-backend/internal/handlers/api/users"
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func (h *Handler) Register(app fiber.Router, auth *authmw.AuthMiddleware) {
users.RegisterRoutes(app, auth, h.users)
classrooms.RegisterRoutes(app, auth, h.classrooms)
messages.RegisterRoutes(app, auth, h.messages)
questions.RegisterRoutes(app, auth, h.questions)
assignments.RegisterRoutes(app, auth, h.assignments)
answers.RegisterRoutes(app, auth, h.answers)
}

View File

@@ -0,0 +1,159 @@
// Path: Backend/internal/handlers/api/shared/shared.go
package shared
import (
"boostai-backend/internal/sqlc"
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt"
)
const QueryTimeout = 5 * time.Second
type ListResponse[T any] struct {
Data []T `json:"data"`
}
func WithTimeout() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), QueryTimeout)
}
func IsValidAnswerStatus(status string) bool {
switch sqlc.AnswerStatus(strings.TrimSpace(status)) {
case sqlc.AnswerStatusNotStarted,
sqlc.AnswerStatusInProgress,
sqlc.AnswerStatusSubmitted,
sqlc.AnswerStatusReviewed:
return true
default:
return false
}
}
func NullableText(value *string) pgtype.Text {
if value == nil {
return pgtype.Text{}
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return pgtype.Text{}
}
return pgtype.Text{String: trimmed, Valid: true}
}
func MaybeHashPassword(value *string) (pgtype.Text, error) {
if value == nil {
return pgtype.Text{}, nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return pgtype.Text{}, nil
}
if len(trimmed) < 8 {
return pgtype.Text{}, errors.New("password must be at least 8 characters")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(trimmed), bcrypt.DefaultCost)
if err != nil {
return pgtype.Text{}, err
}
return pgtype.Text{String: string(hashedPassword), Valid: true}, nil
}
func NullableTime(value *time.Time) pgtype.Timestamptz {
if value == nil {
return pgtype.Timestamptz{}
}
return pgtype.Timestamptz{Time: value.UTC(), Valid: true}
}
func NullableBool(value *bool) pgtype.Bool {
if value == nil {
return pgtype.Bool{}
}
return pgtype.Bool{Bool: *value, Valid: true}
}
func TextPointer(value pgtype.Text) *string {
if !value.Valid {
return nil
}
text := value.String
return &text
}
func TextValue(value pgtype.Text) string {
if !value.Valid {
return ""
}
return value.String
}
func TimePointer(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil
}
timestamp := value.Time.UTC()
return &timestamp
}
func Int64Pointer(value pgtype.Int8) *int64 {
if !value.Valid {
return nil
}
v := value.Int64
return &v
}
func BoolPointer(value pgtype.Bool) *bool {
if !value.Valid {
return nil
}
v := value.Bool
return &v
}
func NullableFloat64AsNumeric(value *float64) (pgtype.Numeric, error) {
if value == nil {
return pgtype.Numeric{}, nil
}
numeric := pgtype.Numeric{}
if err := numeric.ScanScientific(fmt.Sprintf("%f", *value)); err != nil {
return pgtype.Numeric{}, err
}
return numeric, nil
}
func NumericPointer(value pgtype.Numeric) *float64 {
if !value.Valid {
return nil
}
floatValue, err := value.Float64Value()
if err != nil || !floatValue.Valid {
return nil
}
v := floatValue.Float64
return &v
}

View File

@@ -0,0 +1,133 @@
// Path: Backend/internal/handlers/api/users/handler.go
package users
import (
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/http/params"
"boostai-backend/internal/http/respond"
"boostai-backend/internal/sqlc"
"errors"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type Handler struct {
queries *sqlc.Queries
}
type UserResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
FullName string `json:"full_name"`
IsActive bool `json:"is_active"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
PasswordHash *string `json:"password_hash,omitempty"`
}
type createUserRequest struct {
Email string `json:"email"`
Password *string `json:"password"`
Role string `json:"role"`
FullName string `json:"full_name"`
}
func NewHandler(queries *sqlc.Queries) *Handler {
return &Handler{queries: queries}
}
func (h *Handler) ListUsersByRole(c *fiber.Ctx) error {
role := strings.TrimSpace(c.Query("role"))
if role == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Query parameter 'role' is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
users, err := h.queries.ListUsersByRole(ctx, sqlc.UserRole(role))
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]UserResponse, 0, len(users))
for _, user := range users {
items = append(items, mapUser(user, false))
}
return c.JSON(shared.ListResponse[UserResponse]{Data: items})
}
func (h *Handler) GetUserByID(c *fiber.Ctx) error {
id, err := params.Int64PathParam(c, "id")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
user, err := h.queries.GetUserByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "User not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(mapUser(user, false))
}
func (h *Handler) CreateUser(c *fiber.Ctx) error {
var req createUserRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
if strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.FullName) == "" || strings.TrimSpace(req.Role) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "email, full_name, and role are required")
}
passwordHash, err := shared.MaybeHashPassword(req.Password)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
}
ctx, cancel := shared.WithTimeout()
defer cancel()
user, err := h.queries.CreateUser(ctx, sqlc.CreateUserParams{
Email: strings.TrimSpace(req.Email),
PasswordHash: passwordHash,
Role: sqlc.UserRole(strings.TrimSpace(req.Role)),
FullName: strings.TrimSpace(req.FullName),
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(mapUser(user, false))
}
func mapUser(user sqlc.User, includePasswordHash bool) UserResponse {
response := UserResponse{
ID: user.ID,
Email: user.Email,
Role: string(user.Role),
FullName: user.FullName,
IsActive: user.IsActive,
CreatedAt: shared.TimePointer(user.CreatedAt),
UpdatedAt: shared.TimePointer(user.UpdatedAt),
}
if includePasswordHash {
response.PasswordHash = shared.TextPointer(user.PasswordHash)
}
return response
}

View File

@@ -0,0 +1,13 @@
package users
import (
authmw "boostai-backend/internal/middleware"
"github.com/gofiber/fiber/v2"
)
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
app.Get("/users", auth.RequireTeacher(), h.ListUsersByRole)
app.Get("/users/:id", h.GetUserByID)
app.Post("/users", auth.RequireTeacher(), h.CreateUser)
}