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