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

661 lines
20 KiB
Go

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