diff --git a/.envsitter/pepper b/.envsitter/pepper new file mode 100644 index 0000000..8de9c6e --- /dev/null +++ b/.envsitter/pepper @@ -0,0 +1 @@ +RgIlJyE1N29vsJg2hyEPwkyf4Fkf7vWFNZggxti97pI= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7bdd2d7..1ba8a39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ node_modules .output .nitro -dist \ No newline at end of file +dist +tmp +seed +.env +.env.* +!.env.example diff --git a/Backend/.air.toml b/Backend/.air.toml new file mode 100644 index 0000000..9057af7 --- /dev/null +++ b/Backend/.air.toml @@ -0,0 +1,30 @@ +root = "." +tmp_dir = "tmp" + +[build] +bin = "./tmp/main" +cmd = "go build -o ./tmp/main ./cmd/server" +delay = 1000 +exclude_dir = ["tmp", "vendor"] +exclude_regex = ["_test.go"] +follow_symlink = false +include_ext = ["go"] +kill_delay = "0s" +log = "build-errors.log" +poll = false +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = false + +[log] +main_only = false +silent = false +time = false + +[misc] +clean_on_exit = true + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/Backend/Earthfile b/Backend/Earthfile new file mode 100644 index 0000000..54fe54b --- /dev/null +++ b/Backend/Earthfile @@ -0,0 +1,67 @@ +VERSION 0.8 + +go-base: + FROM golang:1.24.11-bookworm + RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/* + RUN curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin + RUN go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 + RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 + WORKDIR /app + COPY go.mod go.sum ./ + +deps: + FROM +go-base + RUN go mod download + +dev-image: + ARG IMAGE_NAME="boost-ai/demo-backend-dev" + ARG TAG="latest" + + FROM +deps + COPY . . + + ENV GO_ENV=development + ENV BACKEND_INTERNAL_PORT=8081 + ENV DATABASE_URL=postgres://boostai:boostai_dev_password@postgres-dev:5432/boostai?sslmode=disable + EXPOSE 8081 + + SAVE IMAGE $IMAGE_NAME:$TAG + +dev-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-backend-dev" + ARG TAG="latest" + + FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG + +prod-bin: + FROM +deps + COPY . . + RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/boostai-server ./cmd/server + SAVE ARTIFACT /out/boostai-server AS LOCAL ./tmp/prod-a/boostai-server + +prod-image: + ARG IMAGE_NAME="boost-ai/demo-backend-prod-a" + ARG TAG="latest" + + FROM alpine:3.22 + RUN apk add --no-cache ca-certificates + WORKDIR /app + COPY +prod-bin/boostai-server /app/boostai-server + + ENV GO_ENV=production + ENV BACKEND_INTERNAL_PORT=8081 + EXPOSE 8081 + + ENTRYPOINT ["/app/boostai-server"] + + SAVE IMAGE $IMAGE_NAME:$TAG + +prod-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-backend-prod-a" + ARG TAG="latest" + + FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG diff --git a/Backend/cmd/backfill_historical_reviews/main.go b/Backend/cmd/backfill_historical_reviews/main.go new file mode 100644 index 0000000..8bc21f8 --- /dev/null +++ b/Backend/cmd/backfill_historical_reviews/main.go @@ -0,0 +1,560 @@ +package main + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +var historicalAssignmentIDs = map[int64]bool{ + 3001: true, + 3002: true, + 3003: true, + 3004: true, + 3005: true, +} + +type assignmentRecord struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + MaximumMarks int `json:"maximum_marks"` +} + +type assigneeRecord struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + Status string `json:"status"` + OverallScore *float64 `json:"overall_score"` + AiFeedback *string `json:"ai_feedback"` + NextStepOutcome *string `json:"next_step_outcome"` +} + +type assignmentQuestionRecord struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + QuestionBankID int64 `json:"question_bank_id"` +} + +type studentAnswerRecord struct { + AssigneeID int64 `json:"assignee_id"` + AssignmentQuestionID int64 `json:"assignment_question_id"` + AnswerLatex string `json:"answer_latex"` + AiReasoning string `json:"ai_reasoning"` + AiFeedback *string `json:"ai_feedback"` + SolveMode *string `json:"solve_mode"` + UnderSolveMode *string `json:"_solve_mode"` + IsCorrect *bool `json:"is_correct"` + UnderIsCorrect *bool `json:"_is_correct"` + MisconceptionTag *string `json:"_misconception_tag"` + QuestionTopic *string `json:"_question_topic"` + ReviewNeedsAttention *bool `json:"review_needs_attention"` + ReviewIssueReason *string `json:"review_issue_reason"` + ReviewUnderstandingScore *float64 `json:"review_understanding_score"` + ReviewCorrectnessScore *float64 `json:"review_correctness_score"` + ReviewQuestionScore *float64 `json:"review_question_score"` + ReviewConfidence *float64 `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` + CreatedAt int64 `json:"created_at"` + AnsweredAt *int64 `json:"_answered_at"` +} + +type questionReviewUpdate struct { + AssignmentID int64 + StudentID int64 + QuestionID int64 + IsCorrect bool + AIFeedback string + NeedsAttention bool + IssueReason string + CorrectnessScore float64 + UnderstandingScore float64 + QuestionScore float64 + Confidence float64 + ReviewTags []string + Topic string + QuestionContribution float64 + AnsweredAt time.Time + HasAnsweredAt bool +} + +type assigneeSummary struct { + AssignmentID int64 + StudentID int64 + AssignmentName string + OverallScore *float64 + AIFeedback string + NextStepOutcome string + QuestionUpdates []questionReviewUpdate + CorrectCount int + NeedsAttentionCnt int +} + +func main() { + cfg := config.Load() + db, err := database.NewPostgres(cfg.DatabaseURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer db.Close() + + mockDataDir, err := resolveMockDataDir() + if err != nil { + log.Fatalf("failed to resolve mock data directory: %v", err) + } + + assignments, err := readJSON[[]assignmentRecord](filepath.Join(mockDataDir, "assignments.json")) + if err != nil { + log.Fatalf("failed to read assignments.json: %v", err) + } + assignees, err := readJSON[[]assigneeRecord](filepath.Join(mockDataDir, "assignment_assignees.json")) + if err != nil { + log.Fatalf("failed to read assignment_assignees.json: %v", err) + } + assignmentQuestions, err := readJSON[[]assignmentQuestionRecord](filepath.Join(mockDataDir, "assignment_questions.json")) + if err != nil { + log.Fatalf("failed to read assignment_questions.json: %v", err) + } + studentAnswers, err := readJSON[[]studentAnswerRecord](filepath.Join(mockDataDir, "student_answers.json")) + if err != nil { + log.Fatalf("failed to read student_answers.json: %v", err) + } + + assignmentByID := map[int64]assignmentRecord{} + for _, item := range assignments { + assignmentByID[item.ID] = item + } + + assigneeByID := map[int64]assigneeRecord{} + for _, item := range assignees { + assigneeByID[item.ID] = item + } + + questionIDByAssignmentQuestionID := map[int64]int64{} + for _, item := range assignmentQuestions { + questionIDByAssignmentQuestionID[item.ID] = item.QuestionBankID + } + + summaries := map[string]*assigneeSummary{} + for _, row := range studentAnswers { + assignee, ok := assigneeByID[row.AssigneeID] + if !ok || !historicalAssignmentIDs[assignee.AssignmentID] { + continue + } + questionID, ok := questionIDByAssignmentQuestionID[row.AssignmentQuestionID] + if !ok { + continue + } + assignment := assignmentByID[assignee.AssignmentID] + key := fmt.Sprintf("%d:%d", assignee.AssignmentID, assignee.StudentID) + summary := summaries[key] + if summary == nil { + summary = &assigneeSummary{ + AssignmentID: assignee.AssignmentID, + StudentID: assignee.StudentID, + AssignmentName: assignment.Name, + } + summaries[key] = summary + } + + update := buildQuestionReviewUpdate(assignee, questionID, row) + summary.QuestionUpdates = append(summary.QuestionUpdates, update) + if update.IsCorrect { + summary.CorrectCount++ + } + if update.NeedsAttention { + summary.NeedsAttentionCnt++ + } + } + + for _, summary := range summaries { + finalizeAssigneeSummary(summary) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + tx, err := db.Pool.Begin(ctx) + if err != nil { + log.Fatalf("failed to begin transaction: %v", err) + } + defer tx.Rollback(ctx) + + updatedAnswers := 0 + updatedAssignees := 0 + for _, summary := range summaries { + for _, update := range summary.QuestionUpdates { + _, err := tx.Exec(ctx, ` + UPDATE student_answers + SET is_correct = $4, + ai_feedback = $5, + review_needs_attention = $6, + review_issue_reason = $7, + review_correctness_score = $8, + review_understanding_score = $9, + review_question_score = $10, + review_confidence = $11, + review_tags = $12, + updated_at = NOW() + WHERE assignment_id = $1 + AND student_id = $2 + AND question_id = $3 + `, + update.AssignmentID, + update.StudentID, + update.QuestionID, + update.IsCorrect, + nullableString(update.AIFeedback), + update.NeedsAttention, + nullableString(update.IssueReason), + update.CorrectnessScore, + update.UnderstandingScore, + update.QuestionScore, + update.Confidence, + update.ReviewTags, + ) + if err != nil { + log.Fatalf("failed to update student answer (%d/%d/%d): %v", update.AssignmentID, update.StudentID, update.QuestionID, err) + } + updatedAnswers++ + } + + var overall any + var passStatus string + if summary.OverallScore == nil { + passStatus = "pending" + overall = nil + } else { + overall = *summary.OverallScore + if *summary.OverallScore >= 6.0 { + passStatus = "pass" + } else { + passStatus = "no_pass" + } + } + + _, err := tx.Exec(ctx, ` + UPDATE assignment_assignees + SET overall_score = $3, + ai_feedback = $4, + pass_status = $5, + pass_status_override = NULL + WHERE assignment_id = $1 + AND student_id = $2 + `, summary.AssignmentID, summary.StudentID, overall, nullableString(summary.AIFeedback), passStatus) + if err != nil { + log.Fatalf("failed to update assignment assignee (%d/%d): %v", summary.AssignmentID, summary.StudentID, err) + } + updatedAssignees++ + } + + if err := tx.Commit(ctx); err != nil { + log.Fatalf("failed to commit transaction: %v", err) + } + + log.Printf("historical review backfill complete: %d answers updated, %d assignees updated", updatedAnswers, updatedAssignees) +} + +func buildQuestionReviewUpdate(assignee assigneeRecord, questionID int64, row studentAnswerRecord) questionReviewUpdate { + isCorrect := false + if row.IsCorrect != nil { + isCorrect = *row.IsCorrect + } else if row.UnderIsCorrect != nil { + isCorrect = *row.UnderIsCorrect + } else { + isCorrect = !strings.HasPrefix(strings.ToLower(strings.TrimSpace(row.AiReasoning)), "incorrect") + } + + solveMode := firstNonEmptyString(row.SolveMode, row.UnderSolveMode) + understanding := valueOrElse(row.ReviewUnderstandingScore, deriveUnderstandingScore(isCorrect, solveMode)) + confidence := valueOrElse(row.ReviewConfidence, deriveConfidenceScore(isCorrect, solveMode)) + needsAttention := boolOrElse(row.ReviewNeedsAttention, !isCorrect || understanding < 0.72) + issueReason := stringOrElse(row.ReviewIssueReason, deriveIssueReason(row.AiReasoning, row.MisconceptionTag)) + aiFeedback := stringOrElse(row.AiFeedback, row.AiReasoning) + reviewTags := sanitizeTags(row.ReviewTags, row.MisconceptionTag) + correctness := valueOrElse(row.ReviewCorrectnessScore, 1.0) + questionScore := valueOrElse(row.ReviewQuestionScore, 1.0) + questionContribution := ((boolToFloat(isCorrect)) + understanding) / 2 + + var answeredAt time.Time + var hasAnsweredAt bool + if row.AnsweredAt != nil && *row.AnsweredAt > 0 { + answeredAt = time.UnixMilli(*row.AnsweredAt).UTC() + hasAnsweredAt = true + } else if row.CreatedAt > 0 { + answeredAt = time.UnixMilli(row.CreatedAt).UTC() + hasAnsweredAt = true + } + + return questionReviewUpdate{ + AssignmentID: assignee.AssignmentID, + StudentID: assignee.StudentID, + QuestionID: questionID, + IsCorrect: isCorrect, + AIFeedback: aiFeedback, + NeedsAttention: needsAttention, + IssueReason: issueReason, + CorrectnessScore: roundToThree(correctness), + UnderstandingScore: roundToThree(understanding), + QuestionScore: roundToThree(questionScore), + Confidence: roundToThree(confidence), + ReviewTags: reviewTags, + Topic: stringOrElse(row.QuestionTopic, "general"), + QuestionContribution: questionContribution, + AnsweredAt: answeredAt, + HasAnsweredAt: hasAnsweredAt, + } +} + +func finalizeAssigneeSummary(summary *assigneeSummary) { + if len(summary.QuestionUpdates) == 0 { + return + } + var total float64 + topicScores := map[string][]float64{} + for _, item := range summary.QuestionUpdates { + total += item.QuestionContribution + topicScores[item.Topic] = append(topicScores[item.Topic], item.QuestionContribution) + } + overall := roundToTwo(total / float64(len(summary.QuestionUpdates)) * 10) + summary.OverallScore = &overall + + type topicAvg struct { + name string + avg float64 + } + var weakest []topicAvg + for topic, scores := range topicScores { + var subtotal float64 + for _, score := range scores { + subtotal += score + } + weakest = append(weakest, topicAvg{name: topic, avg: subtotal / float64(len(scores))}) + } + if len(weakest) > 1 { + sort.Slice(weakest, func(i, j int) bool { + if weakest[i].avg == weakest[j].avg { + return weakest[i].name < weakest[j].name + } + return weakest[i].avg < weakest[j].avg + }) + } + weakestTopics := []string{} + for i, item := range weakest { + if i >= 2 { + break + } + weakestTopics = append(weakestTopics, displayTopic(item.name)) + } + weakestTopicText := "general fluency" + if len(weakestTopics) > 0 { + weakestTopicText = strings.Join(weakestTopics, ", ") + } + summary.NextStepOutcome = "accept" + if overall < 4.5 { + summary.NextStepOutcome = "redo" + } else if overall < 6.0 { + summary.NextStepOutcome = "support" + } + summary.AIFeedback = fmt.Sprintf( + "Student completed %s with %d/%d correct responses. Overall score is %.2f/10. The weakest areas were %s. %d question(s) need extra attention.", + summary.AssignmentName, + summary.CorrectCount, + len(summary.QuestionUpdates), + overall, + weakestTopicText, + summary.NeedsAttentionCnt, + ) +} + +func deriveUnderstandingScore(isCorrect bool, solveMode string) float64 { + if isCorrect { + return map[string]float64{ + "step_by_step": 0.95, + "handwritten": 0.85, + "just_answer": 0.75, + "solve_together": 0.65, + }[defaultSolveMode(solveMode)] + } + return map[string]float64{ + "step_by_step": 0.40, + "handwritten": 0.32, + "just_answer": 0.20, + "solve_together": 0.28, + }[defaultSolveMode(solveMode)] +} + +func deriveConfidenceScore(isCorrect bool, solveMode string) float64 { + if isCorrect { + return map[string]float64{ + "step_by_step": 0.82, + "handwritten": 0.78, + "just_answer": 0.90, + "solve_together": 0.62, + }[defaultSolveMode(solveMode)] + } + return map[string]float64{ + "step_by_step": 0.55, + "handwritten": 0.60, + "just_answer": 0.72, + "solve_together": 0.50, + }[defaultSolveMode(solveMode)] +} + +func deriveIssueReason(aiReasoning string, misconception *string) string { + if misconception != nil && strings.TrimSpace(*misconception) != "" { + switch strings.TrimSpace(*misconception) { + case "add_tops_add_bottoms": + return "The student added the numerator and denominator directly instead of finding a common denominator." + case "fraction_op_confusion": + return "The student confused the fraction operation and did not apply the correct method." + case "fraction_general_uncertainty": + return "The student shows insecure understanding of equivalent or comparable fractions." + case "place_value_misalignment": + return "The student misread place value, causing digits to be aligned incorrectly." + case "arithmetic_slip": + return "The final answer is wrong, suggesting a careless arithmetic slip rather than a secure method." + case "scaffolding_dependence": + return "The student appears dependent on scaffolding and does not show secure independent understanding." + case "word_problem_interpretation": + return "The student did not translate the word problem into the correct calculation." + default: + return strings.TrimSpace(*misconception) + } + } + text := strings.TrimSpace(aiReasoning) + if text == "" { + return "The answer shows incomplete understanding of the method." + } + return text +} + +func defaultSolveMode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "just_answer" + } + return value +} + +func displayTopic(value string) string { + value = strings.ReplaceAll(strings.TrimSpace(value), "_", " ") + parts := strings.Fields(value) + for i, part := range parts { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, " ") +} + +func boolToFloat(value bool) float64 { + if value { + return 1 + } + return 0 +} + +func roundToTwo(value float64) float64 { + return math.Round(value*100) / 100 +} + +func roundToThree(value float64) float64 { + return math.Round(value*1000) / 1000 +} + +func readJSON[T any](path string) (T, error) { + var zero T + data, err := os.ReadFile(path) + if err != nil { + return zero, err + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return zero, err + } + return value, nil +} + +func resolveMockDataDir() (string, error) { + if value := strings.TrimSpace(os.Getenv("MOCK_DATA_DIR")); value != "" { + return value, nil + } + candidates := []string{ + filepath.Join("..", "Mock-Data"), + filepath.Join(".", "Mock-Data"), + filepath.Join("..", "..", "Mock-Data"), + } + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate, nil + } + } + return "", errors.New("Mock-Data directory not found; set MOCK_DATA_DIR") +} + +func valueOrElse(value *float64, fallback float64) float64 { + if value != nil { + return *value + } + return fallback +} + +func boolOrElse(value *bool, fallback bool) bool { + if value != nil { + return *value + } + return fallback +} + +func stringOrElse(value *string, fallback string) string { + if value != nil && strings.TrimSpace(*value) != "" { + return strings.TrimSpace(*value) + } + return strings.TrimSpace(fallback) +} + +func firstNonEmptyString(values ...*string) string { + for _, value := range values { + if value != nil && strings.TrimSpace(*value) != "" { + return strings.TrimSpace(*value) + } + } + return "" +} + +func sanitizeTags(tags []string, misconception *string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(tags)+1) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" || seen[tag] { + continue + } + seen[tag] = true + result = append(result, tag) + } + if misconception != nil { + tag := strings.TrimSpace(*misconception) + if tag != "" && !seen[tag] { + result = append(result, tag) + } + } + return result +} + +func nullableString(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return strings.TrimSpace(value) +} diff --git a/Backend/cmd/server/main.go b/Backend/cmd/server/main.go new file mode 100644 index 0000000..9ea573d --- /dev/null +++ b/Backend/cmd/server/main.go @@ -0,0 +1,81 @@ +// Path: Backend/cmd/server/main.go + +package main + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "boostai-backend/internal/router" + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +func main() { + cfg := config.Load() + + db, err := database.NewPostgres(cfg.DatabaseURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer db.Close() + log.Println("Connected to database") + + if err := db.Migrate(); err != nil { + log.Fatalf("failed to run migrations: %v", err) + } + log.Println("Database migrations complete") + + app := fiber.New(fiber.Config{ + AppName: "BoostAI Backend", + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + }) + + app.Use(recover.New()) + app.Use(logger.New(logger.Config{ + Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n", + TimeFormat: "2006-01-02 15:04:05", + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: cfg.AllowedOrigins, + AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept,Authorization", + AllowCredentials: true, + MaxAge: 86400, + })) + + router.Setup(app, cfg, db) + + go func() { + if err := app.Listen(":" + cfg.Port); err != nil { + log.Fatalf("failed to start server: %v", err) + } + }() + + log.Printf("BoostAI backend listening on port %s", cfg.Port) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down backend server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := app.ShutdownWithContext(ctx); err != nil { + log.Fatalf("server forced to shutdown: %v", err) + } + + log.Println("Backend server exited") +} diff --git a/Backend/db/embed.go b/Backend/db/embed.go new file mode 100644 index 0000000..e0a4e0b --- /dev/null +++ b/Backend/db/embed.go @@ -0,0 +1,6 @@ +package db + +import "embed" + +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/Backend/db/migrations/001_init.sql b/Backend/db/migrations/001_init.sql new file mode 100644 index 0000000..37c0c60 --- /dev/null +++ b/Backend/db/migrations/001_init.sql @@ -0,0 +1,160 @@ +-- +goose Up +CREATE TYPE user_role AS ENUM ('student', 'teacher'); +CREATE TYPE question_status AS ENUM ('draft', 'published', 'archived'); +CREATE TYPE assignment_status AS ENUM ('draft', 'assigned', 'closed'); +CREATE TYPE answer_status AS ENUM ('not_started', 'in_progress', 'submitted', 'reviewed'); + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT, + role user_role NOT NULL, + full_name VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE classrooms ( + id BIGSERIAL PRIMARY KEY, + teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + code VARCHAR(50) UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE classroom_students ( + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (classroom_id, student_id) +); + +CREATE TABLE questions ( + id BIGSERIAL PRIMARY KEY, + author_teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title VARCHAR(255) NOT NULL, + prompt TEXT NOT NULL, + subject VARCHAR(100), + source TEXT, + status question_status NOT NULL DEFAULT 'draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE tags ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE question_tags ( + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (question_id, tag_id) +); + +CREATE TABLE assignments ( + id BIGSERIAL PRIMARY KEY, + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + title VARCHAR(255) NOT NULL, + instructions TEXT, + status assignment_status NOT NULL DEFAULT 'draft', + due_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE assignment_assignees ( + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (assignment_id, student_id) +); + +CREATE TABLE assignment_questions ( + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT, + position INTEGER NOT NULL, + PRIMARY KEY (assignment_id, question_id), + CONSTRAINT assignment_questions_assignment_position_key UNIQUE (assignment_id, position) +); + +CREATE TABLE student_answers ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT, + student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + answer_text TEXT, + ai_feedback TEXT, + teacher_feedback TEXT, + status answer_status NOT NULL DEFAULT 'not_started', + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT student_answers_assignment_question_student_key UNIQUE (assignment_id, question_id, student_id) +); + +CREATE INDEX idx_users_role ON users(role); +CREATE INDEX idx_classrooms_teacher_id ON classrooms(teacher_id); +CREATE INDEX idx_questions_author_teacher_id ON questions(author_teacher_id); +CREATE INDEX idx_questions_status ON questions(status); +CREATE INDEX idx_assignments_classroom_id ON assignments(classroom_id); +CREATE INDEX idx_assignments_teacher_id ON assignments(teacher_id); +CREATE INDEX idx_assignment_assignees_student_id ON assignment_assignees(student_id); +CREATE INDEX idx_student_answers_student_id ON student_answers(student_id); +CREATE INDEX idx_student_answers_assignment_id ON student_answers(assignment_id); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER classrooms_updated_at BEFORE UPDATE ON classrooms + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER questions_updated_at BEFORE UPDATE ON questions + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER assignments_updated_at BEFORE UPDATE ON assignments + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER student_answers_updated_at BEFORE UPDATE ON student_answers + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- +goose Down +DROP TRIGGER IF EXISTS student_answers_updated_at ON student_answers; +DROP TRIGGER IF EXISTS assignments_updated_at ON assignments; +DROP TRIGGER IF EXISTS questions_updated_at ON questions; +DROP TRIGGER IF EXISTS classrooms_updated_at ON classrooms; +DROP TRIGGER IF EXISTS users_updated_at ON users; +DROP FUNCTION IF EXISTS update_updated_at(); + +DROP TABLE IF EXISTS student_answers; +DROP TABLE IF EXISTS assignment_questions; +DROP TABLE IF EXISTS assignment_assignees; +DROP TABLE IF EXISTS assignments; +DROP TABLE IF EXISTS question_tags; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS questions; +DROP TABLE IF EXISTS classroom_students; +DROP TABLE IF EXISTS classrooms; +DROP TABLE IF EXISTS users; + +DROP TYPE IF EXISTS answer_status; +DROP TYPE IF EXISTS assignment_status; +DROP TYPE IF EXISTS question_status; +DROP TYPE IF EXISTS user_role; diff --git a/Backend/db/migrations/002_profiles.sql b/Backend/db/migrations/002_profiles.sql new file mode 100644 index 0000000..e99403f --- /dev/null +++ b/Backend/db/migrations/002_profiles.sql @@ -0,0 +1,44 @@ +-- +goose Up +CREATE TABLE profiles ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferred_name VARCHAR(100), + profile_icon_url TEXT, + headline VARCHAR(255), + bio TEXT, + timezone VARCHAR(100), + locale VARCHAR(20), + grade_level VARCHAR(100), + learning_goal TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO profiles (user_id) +SELECT id +FROM users +ON CONFLICT (user_id) DO NOTHING; + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION ensure_profile_for_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO profiles (user_id) + VALUES (NEW.id) + ON CONFLICT (user_id) DO NOTHING; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER users_ensure_profile_after_insert AFTER INSERT ON users + FOR EACH ROW EXECUTE FUNCTION ensure_profile_for_user(); + +-- +goose Down +DROP TRIGGER IF EXISTS users_ensure_profile_after_insert ON users; +DROP TRIGGER IF EXISTS profiles_updated_at ON profiles; +DROP FUNCTION IF EXISTS ensure_profile_for_user(); +DROP TABLE IF EXISTS profiles; diff --git a/Backend/db/migrations/003_messages.sql b/Backend/db/migrations/003_messages.sql new file mode 100644 index 0000000..123eadc --- /dev/null +++ b/Backend/db/migrations/003_messages.sql @@ -0,0 +1,48 @@ +-- +goose Up +CREATE TABLE message_threads ( + id BIGSERIAL PRIMARY KEY, + created_by_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT message_threads_subject_not_blank CHECK (length(btrim(subject)) > 0) +); + +CREATE TABLE message_thread_participants ( + thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_read_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + PRIMARY KEY (thread_id, user_id) +); + +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE, + sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT messages_body_not_blank CHECK (length(btrim(body)) > 0) +); + +CREATE INDEX idx_message_threads_created_by_user_id ON message_threads(created_by_user_id); +CREATE INDEX idx_message_threads_updated_at ON message_threads(updated_at DESC); +CREATE INDEX idx_message_thread_participants_user_id ON message_thread_participants(user_id); +CREATE INDEX idx_messages_thread_id_created_at ON messages(thread_id, created_at DESC); +CREATE INDEX idx_messages_sender_user_id ON messages(sender_user_id); + +CREATE TRIGGER message_threads_updated_at BEFORE UPDATE ON message_threads + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER messages_updated_at BEFORE UPDATE ON messages + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- +goose Down +DROP TRIGGER IF EXISTS messages_updated_at ON messages; +DROP TRIGGER IF EXISTS message_threads_updated_at ON message_threads; + +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS message_thread_participants; +DROP TABLE IF EXISTS message_threads; diff --git a/Backend/db/migrations/004_student_answer_workspace.sql b/Backend/db/migrations/004_student_answer_workspace.sql new file mode 100644 index 0000000..82928b4 --- /dev/null +++ b/Backend/db/migrations/004_student_answer_workspace.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE student_answers + ADD COLUMN solve_mode TEXT NOT NULL DEFAULT 'just_answer', + ADD COLUMN working_steps TEXT, + ADD CONSTRAINT student_answers_solve_mode_check + CHECK (solve_mode IN ('just_answer', 'step_by_step', 'solve_together', 'handwritten')); + +-- +goose Down +ALTER TABLE student_answers + DROP CONSTRAINT IF EXISTS student_answers_solve_mode_check, + DROP COLUMN IF EXISTS working_steps, + DROP COLUMN IF EXISTS solve_mode; diff --git a/Backend/db/migrations/005_question_answers_and_correctness.sql b/Backend/db/migrations/005_question_answers_and_correctness.sql new file mode 100644 index 0000000..d660f24 --- /dev/null +++ b/Backend/db/migrations/005_question_answers_and_correctness.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE questions +ADD COLUMN correct_answer TEXT; + +ALTER TABLE student_answers +ADD COLUMN is_correct BOOLEAN; + +-- +goose Down +ALTER TABLE student_answers +DROP COLUMN IF EXISTS is_correct; + +ALTER TABLE questions +DROP COLUMN IF EXISTS correct_answer; diff --git a/Backend/db/migrations/006_assignment_level_feedback.sql b/Backend/db/migrations/006_assignment_level_feedback.sql new file mode 100644 index 0000000..c2534ce --- /dev/null +++ b/Backend/db/migrations/006_assignment_level_feedback.sql @@ -0,0 +1,53 @@ +-- +goose Up + +ALTER TABLE assignment_assignees + ADD COLUMN ai_feedback TEXT, + ADD COLUMN teacher_feedback TEXT; + +UPDATE assignment_assignees aa +SET ai_feedback = aggregated.ai_feedback +FROM ( + SELECT + sa.assignment_id, + sa.student_id, + string_agg( + format('Question %s: %s', aq.position, btrim(sa.ai_feedback)), + E'\n\n' + ORDER BY aq.position ASC + ) AS ai_feedback + FROM student_answers sa + JOIN assignment_questions aq + ON aq.assignment_id = sa.assignment_id + AND aq.question_id = sa.question_id + WHERE NULLIF(btrim(sa.ai_feedback), '') IS NOT NULL + GROUP BY sa.assignment_id, sa.student_id +) AS aggregated +WHERE aa.assignment_id = aggregated.assignment_id + AND aa.student_id = aggregated.student_id; + +UPDATE assignment_assignees aa +SET teacher_feedback = aggregated.teacher_feedback +FROM ( + SELECT + sa.assignment_id, + sa.student_id, + string_agg( + format('Question %s: %s', aq.position, btrim(sa.teacher_feedback)), + E'\n\n' + ORDER BY aq.position ASC + ) AS teacher_feedback + FROM student_answers sa + JOIN assignment_questions aq + ON aq.assignment_id = sa.assignment_id + AND aq.question_id = sa.question_id + WHERE NULLIF(btrim(sa.teacher_feedback), '') IS NOT NULL + GROUP BY sa.assignment_id, sa.student_id +) AS aggregated +WHERE aa.assignment_id = aggregated.assignment_id + AND aa.student_id = aggregated.student_id; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS teacher_feedback, + DROP COLUMN IF EXISTS ai_feedback; diff --git a/Backend/db/migrations/007_review_contract.sql b/Backend/db/migrations/007_review_contract.sql new file mode 100644 index 0000000..341e0e5 --- /dev/null +++ b/Backend/db/migrations/007_review_contract.sql @@ -0,0 +1,66 @@ +-- +goose Up + +CREATE TYPE assignment_pass_status AS ENUM ('pending', 'pass', 'no_pass'); + +ALTER TABLE student_answers + ADD COLUMN review_needs_attention BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN review_issue_reason TEXT, + ADD COLUMN review_correctness_score NUMERIC(4,3), + ADD COLUMN review_understanding_score NUMERIC(4,3), + ADD COLUMN review_question_score NUMERIC(4,3), + ADD COLUMN review_confidence NUMERIC(4,3), + ADD COLUMN review_tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD CONSTRAINT student_answers_review_correctness_score_range_check + CHECK (review_correctness_score IS NULL OR (review_correctness_score >= 0 AND review_correctness_score <= 1)), + ADD CONSTRAINT student_answers_review_understanding_score_range_check + CHECK (review_understanding_score IS NULL OR (review_understanding_score >= 0 AND review_understanding_score <= 1)), + ADD CONSTRAINT student_answers_review_question_score_range_check + CHECK (review_question_score IS NULL OR (review_question_score >= 0 AND review_question_score <= 1)), + ADD CONSTRAINT student_answers_review_confidence_range_check + CHECK (review_confidence IS NULL OR (review_confidence >= 0 AND review_confidence <= 1)); + +UPDATE student_answers +SET review_correctness_score = CASE + WHEN is_correct IS TRUE THEN 1.000 + WHEN is_correct IS FALSE THEN 0.000 + ELSE NULL + END, + review_question_score = CASE + WHEN is_correct IS TRUE THEN 1.000 + WHEN is_correct IS FALSE THEN 0.000 + ELSE NULL + END +WHERE is_correct IS NOT NULL; + +ALTER TABLE assignment_assignees + ADD COLUMN overall_score NUMERIC(5,2), + ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00, + ADD COLUMN pass_status assignment_pass_status NOT NULL DEFAULT 'pending', + ADD CONSTRAINT assignment_assignees_overall_score_range_check + CHECK (overall_score IS NULL OR (overall_score >= 0 AND overall_score <= 10)), + ADD CONSTRAINT assignment_assignees_pass_threshold_range_check + CHECK (pass_threshold >= 0 AND pass_threshold <= 10); + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP CONSTRAINT IF EXISTS assignment_assignees_pass_threshold_range_check, + DROP CONSTRAINT IF EXISTS assignment_assignees_overall_score_range_check, + DROP COLUMN IF EXISTS pass_status, + DROP COLUMN IF EXISTS pass_threshold, + DROP COLUMN IF EXISTS overall_score; + +ALTER TABLE student_answers + DROP CONSTRAINT IF EXISTS student_answers_review_confidence_range_check, + DROP CONSTRAINT IF EXISTS student_answers_review_question_score_range_check, + DROP CONSTRAINT IF EXISTS student_answers_review_understanding_score_range_check, + DROP CONSTRAINT IF EXISTS student_answers_review_correctness_score_range_check, + DROP COLUMN IF EXISTS review_tags, + DROP COLUMN IF EXISTS review_confidence, + DROP COLUMN IF EXISTS review_question_score, + DROP COLUMN IF EXISTS review_understanding_score, + DROP COLUMN IF EXISTS review_correctness_score, + DROP COLUMN IF EXISTS review_issue_reason, + DROP COLUMN IF EXISTS review_needs_attention; + +DROP TYPE IF EXISTS assignment_pass_status; diff --git a/Backend/db/migrations/008_assignment_pass_threshold.sql b/Backend/db/migrations/008_assignment_pass_threshold.sql new file mode 100644 index 0000000..143ec56 --- /dev/null +++ b/Backend/db/migrations/008_assignment_pass_threshold.sql @@ -0,0 +1,22 @@ +-- +goose Up + +ALTER TABLE assignments + ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00, + ADD CONSTRAINT assignments_pass_threshold_range_check + CHECK (pass_threshold >= 0 AND pass_threshold <= 10); + +UPDATE assignments a +SET pass_threshold = COALESCE( + ( + SELECT MAX(aa.pass_threshold) + FROM assignment_assignees aa + WHERE aa.assignment_id = a.id + ), + 8.00 +); + +-- +goose Down + +ALTER TABLE assignments + DROP CONSTRAINT IF EXISTS assignments_pass_threshold_range_check, + DROP COLUMN IF EXISTS pass_threshold; diff --git a/Backend/db/migrations/009_assignment_pass_status_override.sql b/Backend/db/migrations/009_assignment_pass_status_override.sql new file mode 100644 index 0000000..8045bd7 --- /dev/null +++ b/Backend/db/migrations/009_assignment_pass_status_override.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE assignment_assignees + ADD COLUMN pass_status_override assignment_pass_status; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS pass_status_override; diff --git a/Backend/db/migrations/010_assignment_next_step_outcome.sql b/Backend/db/migrations/010_assignment_next_step_outcome.sql new file mode 100644 index 0000000..863877a --- /dev/null +++ b/Backend/db/migrations/010_assignment_next_step_outcome.sql @@ -0,0 +1,13 @@ +-- +goose Up + +CREATE TYPE assignment_next_step_outcome AS ENUM ('redo', 'accept', 'support'); + +ALTER TABLE assignment_assignees + ADD COLUMN next_step_outcome assignment_next_step_outcome; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS next_step_outcome; + +DROP TYPE IF EXISTS assignment_next_step_outcome; diff --git a/Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql b/Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql new file mode 100644 index 0000000..c69238d --- /dev/null +++ b/Backend/db/migrations/011_backfill_seeded_question_correct_answers.sql @@ -0,0 +1,80 @@ +-- +goose Up + +UPDATE questions AS q +SET correct_answer = seeded.correct_answer +FROM ( + VALUES + (1001, '700'), + (1002, '25000'), + (1003, '7/100'), + (1004, '0.37'), + (1101, '383'), + (1102, '456'), + (1103, '2627'), + (1104, '196'), + (1105, '24'), + (1106, '3744'), + (1201, '3'), + (1202, '-5'), + (1203, '-4'), + (1301, '11'), + (1302, '22'), + (1303, '19'), + (1401, '1/2'), + (1402, '2/3'), + (1403, '5/8'), + (1411, '5/6'), + (1412, '3/4'), + (1413, '11/15'), + (1414, '11/12'), + (1415, '1/2'), + (1416, '29/24'), + (1421, '1/6'), + (1422, '1/2'), + (1423, '28'), + (1501, '5'), + (1502, '7'), + (1503, '6'), + (1511, '14'), + (1512, '31'), + (1513, '3n+2'), + (1601, '28'), + (1602, '40'), + (1603, '27'), + (1611, '70'), + (1612, '75'), + (1613, '720'), + (1701, '8'), + (1702, '5'), + (1703, '7'), + (1711, '3/8'), + (1712, '1/3'), + (1801, '15'), + (1802, '3/8'), + (1803, '51'), + (1804, '23'), + (1805, '96') +) AS seeded(id, correct_answer) +WHERE q.id = seeded.id + AND (q.correct_answer IS NULL OR BTRIM(q.correct_answer) = ''); + +-- +goose Down + +UPDATE questions +SET correct_answer = NULL +WHERE id IN ( + 1001, 1002, 1003, 1004, + 1101, 1102, 1103, 1104, 1105, 1106, + 1201, 1202, 1203, + 1301, 1302, 1303, + 1401, 1402, 1403, + 1411, 1412, 1413, 1414, 1415, 1416, + 1421, 1422, 1423, + 1501, 1502, 1503, + 1511, 1512, 1513, + 1601, 1602, 1603, + 1611, 1612, 1613, + 1701, 1702, 1703, + 1711, 1712, + 1801, 1802, 1803, 1804, 1805 +); diff --git a/Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql b/Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql new file mode 100644 index 0000000..59533bc --- /dev/null +++ b/Backend/db/migrations/012_phase_a_topics_difficulty_and_threshold.sql @@ -0,0 +1,123 @@ +-- +goose Up + +CREATE TYPE question_topic AS ENUM ( + 'place_value', + 'arithmetic', + 'negative_numbers', + 'bidmas', + 'fractions', + 'algebra', + 'geometry', + 'data' +); + +CREATE TYPE question_difficulty AS ENUM ('easy', 'medium', 'hard'); + +ALTER TABLE questions + ADD COLUMN topic question_topic, + ADD COLUMN difficulty question_difficulty; + +UPDATE questions +SET topic = CASE BTRIM(COALESCE(subject, '')) + WHEN 'Place Value' THEN 'place_value'::question_topic + WHEN 'Arithmetic' THEN 'arithmetic'::question_topic + WHEN 'Negative Numbers' THEN 'negative_numbers'::question_topic + WHEN 'BIDMAS' THEN 'bidmas'::question_topic + WHEN 'Fractions' THEN 'fractions'::question_topic + WHEN 'Algebra' THEN 'algebra'::question_topic + WHEN 'Geometry' THEN 'geometry'::question_topic + WHEN 'Data' THEN 'data'::question_topic + ELSE NULL +END +WHERE topic IS NULL; + +UPDATE questions AS q +SET difficulty = seeded.difficulty::question_difficulty +FROM ( + VALUES + (1001, 'easy'), + (1002, 'medium'), + (1003, 'medium'), + (1004, 'hard'), + (1101, 'easy'), + (1102, 'medium'), + (1103, 'hard'), + (1104, 'medium'), + (1105, 'medium'), + (1106, 'hard'), + (1201, 'easy'), + (1202, 'medium'), + (1203, 'hard'), + (1301, 'easy'), + (1302, 'medium'), + (1303, 'hard'), + (1401, 'easy'), + (1402, 'medium'), + (1403, 'hard'), + (1411, 'easy'), + (1412, 'easy'), + (1413, 'medium'), + (1414, 'medium'), + (1415, 'medium'), + (1416, 'hard'), + (1421, 'easy'), + (1422, 'medium'), + (1423, 'hard'), + (1501, 'easy'), + (1502, 'medium'), + (1503, 'hard'), + (1511, 'easy'), + (1512, 'medium'), + (1513, 'hard'), + (1601, 'easy'), + (1602, 'medium'), + (1603, 'hard'), + (1611, 'easy'), + (1612, 'medium'), + (1613, 'hard'), + (1701, 'easy'), + (1702, 'medium'), + (1703, 'easy'), + (1711, 'medium'), + (1712, 'hard'), + (1801, 'easy'), + (1802, 'medium'), + (1803, 'medium'), + (1804, 'hard'), + (1805, 'hard') +) AS seeded(id, difficulty) +WHERE q.id = seeded.id + AND q.difficulty IS NULL; + +ALTER TABLE assignments + ALTER COLUMN pass_threshold SET DEFAULT 6.00; + +UPDATE assignments +SET pass_threshold = 6.00; + +ALTER TABLE assignment_assignees + ALTER COLUMN pass_threshold SET DEFAULT 6.00; + +UPDATE assignment_assignees +SET pass_threshold = 6.00; + +-- +goose Down + +UPDATE assignment_assignees +SET pass_threshold = 8.00; + +ALTER TABLE assignment_assignees + ALTER COLUMN pass_threshold SET DEFAULT 8.00; + +UPDATE assignments +SET pass_threshold = 8.00; + +ALTER TABLE assignments + ALTER COLUMN pass_threshold SET DEFAULT 8.00; + +ALTER TABLE questions + DROP COLUMN IF EXISTS difficulty, + DROP COLUMN IF EXISTS topic; + +DROP TYPE IF EXISTS question_difficulty; +DROP TYPE IF EXISTS question_topic; diff --git a/Backend/db/migrations/013_redo_assignment_plan.sql b/Backend/db/migrations/013_redo_assignment_plan.sql new file mode 100644 index 0000000..a8c14dd --- /dev/null +++ b/Backend/db/migrations/013_redo_assignment_plan.sql @@ -0,0 +1,11 @@ +-- +goose Up + +ALTER TABLE assignment_assignees + ADD COLUMN redo_plan TEXT, + ADD COLUMN redo_plan_generated_at TIMESTAMPTZ; + +-- +goose Down + +ALTER TABLE assignment_assignees + DROP COLUMN IF EXISTS redo_plan_generated_at, + DROP COLUMN IF EXISTS redo_plan; diff --git a/Backend/db/migrations/014_assignment_student_questions.sql b/Backend/db/migrations/014_assignment_student_questions.sql new file mode 100644 index 0000000..b6c9d05 --- /dev/null +++ b/Backend/db/migrations/014_assignment_student_questions.sql @@ -0,0 +1,30 @@ +-- +goose Up + +CREATE TABLE assignment_student_questions ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL, + student_id BIGINT NOT NULL, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position > 0), + source_bucket TEXT NOT NULL CHECK (btrim(source_bucket) <> ''), + source_topic question_topic, + source_difficulty question_difficulty, + generator_seed BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT assignment_student_questions_assignment_student_fkey + FOREIGN KEY (assignment_id, student_id) + REFERENCES assignment_assignees(assignment_id, student_id) + ON DELETE CASCADE, + CONSTRAINT assignment_student_questions_assignment_student_question_key + UNIQUE (assignment_id, student_id, question_id), + CONSTRAINT assignment_student_questions_assignment_student_position_key + UNIQUE (assignment_id, student_id, position) +); + +CREATE INDEX idx_assignment_student_questions_assignment_student + ON assignment_student_questions (assignment_id, student_id); + +-- +goose Down + +DROP INDEX IF EXISTS idx_assignment_student_questions_assignment_student; +DROP TABLE IF EXISTS assignment_student_questions; diff --git a/Backend/db/queries/assignments.sql b/Backend/db/queries/assignments.sql new file mode 100644 index 0000000..6211190 --- /dev/null +++ b/Backend/db/queries/assignments.sql @@ -0,0 +1,391 @@ +-- name: CreateAssignment :one +INSERT INTO assignments ( + classroom_id, + teacher_id, + title, + instructions, + status, + due_at, + published_at, + pass_threshold +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING *; + +-- name: AssignStudentToAssignment :exec +INSERT INTO assignment_assignees ( + assignment_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (assignment_id, student_id) DO NOTHING; + +-- name: DeleteAssignmentAssignee :exec +DELETE FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: GetAssignmentAssignee :one +SELECT * +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: UpdateAssignmentAIReview :one +UPDATE assignment_assignees +SET ai_feedback = $3, + next_step_outcome = NULLIF($4::text, '')::assignment_next_step_outcome +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING *; + +-- name: UpdateAssignmentRedoPlan :one +UPDATE assignment_assignees +SET redo_plan = NULLIF($3::text, ''), + redo_plan_generated_at = CASE + WHEN NULLIF($3::text, '') IS NULL THEN NULL + ELSE NOW() + END +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING *; + +-- name: GetAssignmentRedoPlan :one +SELECT + assignment_id, + student_id, + redo_plan, + redo_plan_generated_at +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: UpdateAssignmentTeacherFeedback :one +WITH student_question_set AS ( + SELECT asq.assignment_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), selected_questions AS ( + SELECT assignment_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aq.assignment_id, aq.question_id, aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +), score_summary AS ( + SELECT CASE + WHEN COUNT(sa.id) = 0 THEN NULL + ELSE ROUND((AVG( + CASE + WHEN sa.is_correct IS NULL THEN COALESCE(sa.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2) + END AS overall_score + FROM selected_questions aq + LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 + WHERE aq.assignment_id = $1 +), updated AS ( + UPDATE assignment_assignees aa + SET teacher_feedback = $3, + pass_status_override = NULLIF($4::text, '')::assignment_pass_status, + next_step_outcome = NULLIF($5::text, '')::assignment_next_step_outcome, + overall_score = (SELECT overall_score FROM score_summary), + pass_status = COALESCE( + NULLIF($4::text, '')::assignment_pass_status, + CASE + WHEN (SELECT overall_score FROM score_summary) IS NULL THEN 'pending'::assignment_pass_status + WHEN (SELECT overall_score FROM score_summary) >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) + FROM assignments a + WHERE aa.assignment_id = $1 + AND aa.student_id = $2 + AND a.id = aa.assignment_id + RETURNING aa.* +) +SELECT * +FROM updated; + +-- name: AddQuestionToAssignment :exec +INSERT INTO assignment_questions ( + assignment_id, + question_id, + position +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (assignment_id, question_id) DO UPDATE +SET position = EXCLUDED.position; + +-- name: DeleteAssignmentStudentQuestions :exec +DELETE FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2; + +-- name: AddAssignmentStudentQuestion :one +INSERT INTO assignment_student_questions ( + assignment_id, + student_id, + question_id, + position, + source_bucket, + source_topic, + source_difficulty, + generator_seed +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING *; + +-- name: ListAssignmentStudentQuestions :many +SELECT * +FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2 +ORDER BY position ASC, id ASC; + +-- name: ListGeneratedQuestionsForAssignmentStudent :many +SELECT + asq.id, + asq.assignment_id, + asq.student_id, + asq.question_id, + asq.position, + asq.source_bucket, + asq.source_topic, + asq.source_difficulty, + asq.generator_seed, + asq.created_at, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at AS question_created_at, + q.updated_at AS question_updated_at, + q.correct_answer, + q.topic, + q.difficulty +FROM assignment_student_questions asq +JOIN questions q ON q.id = asq.question_id +WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +ORDER BY asq.position ASC, asq.id ASC; + +-- name: ListAssignmentsByTeacher :many +SELECT * +FROM assignments +WHERE teacher_id = $1 +ORDER BY created_at DESC; + +-- name: ListAssignmentsForStudent :many +SELECT a.* +FROM assignment_assignees aa +JOIN assignments a ON a.id = aa.assignment_id +WHERE aa.student_id = $1 +ORDER BY a.created_at DESC; + +-- name: GetAssignmentByID :one +SELECT * +FROM assignments +WHERE id = $1; + +-- name: UpdateAssignmentDraft :one +UPDATE assignments +SET classroom_id = $2, + title = $3, + instructions = $4, + due_at = $5, + pass_threshold = $6, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: CloseAssignment :one +UPDATE assignments +SET status = 'closed'::assignment_status, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: ListQuestionsForAssignment :many +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at, + q.updated_at +FROM assignment_questions aq +JOIN questions q ON q.id = aq.question_id +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC; + +-- name: GetAssignmentReviewSummary :one +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.student_id, + COUNT(sq.question_id)::BIGINT AS total_questions, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.student_id +) +SELECT + $1::BIGINT AS assignment_id, + COALESCE(MAX(student_states.total_questions), 0)::BIGINT AS total_questions, + COUNT(*)::BIGINT AS total_assigned, + COUNT(*) FILTER (WHERE review_status = 'not_started')::BIGINT AS not_started, + COUNT(*) FILTER (WHERE review_status = 'in_progress')::BIGINT AS in_progress, + COUNT(*) FILTER (WHERE review_status = 'submitted')::BIGINT AS submitted, + COUNT(*) FILTER (WHERE review_status = 'reviewed')::BIGINT AS reviewed +FROM student_states; + +-- name: ListAssignmentReviewQueue :many +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.assignment_id, + aa.student_id, + aa.next_step_outcome, + u.full_name AS student_name, + u.email AS student_email, + COUNT(sq.question_id)::BIGINT AS total_questions, + COUNT(sa.id)::BIGINT AS answered_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed')::BIGINT AS reviewed_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'submitted')::BIGINT AS submitted_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress')::BIGINT AS in_progress_questions, + MAX(sa.submitted_at)::timestamptz AS latest_submitted_at, + MAX(sa.reviewed_at)::timestamptz AS latest_reviewed_at, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + JOIN users u ON u.id = aa.student_id + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.assignment_id, aa.student_id, aa.next_step_outcome, u.full_name, u.email +) +SELECT + student_states.assignment_id, + student_states.student_id, + student_states.next_step_outcome, + student_states.student_name, + student_states.student_email, + student_states.total_questions, + student_states.answered_questions, + student_states.reviewed_questions, + student_states.submitted_questions, + student_states.in_progress_questions, + student_states.review_status, + student_states.latest_submitted_at, + student_states.latest_reviewed_at +FROM student_states +WHERE ($2::text = '' OR review_status::text = $2::text) +ORDER BY student_states.student_name ASC, student_states.student_id ASC; diff --git a/Backend/db/queries/classrooms.sql b/Backend/db/queries/classrooms.sql new file mode 100644 index 0000000..747498a --- /dev/null +++ b/Backend/db/queries/classrooms.sql @@ -0,0 +1,36 @@ +-- name: CreateClassroom :one +INSERT INTO classrooms ( + teacher_id, + name, + code, + description +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: ListClassroomsByTeacher :many +SELECT * +FROM classrooms +WHERE teacher_id = $1 +ORDER BY created_at DESC; + +-- name: AddStudentToClassroom :exec +INSERT INTO classroom_students ( + classroom_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (classroom_id, student_id) DO NOTHING; + +-- name: ListStudentsForClassroom :many +SELECT u.* +FROM classroom_students cs +JOIN users u ON u.id = cs.student_id +WHERE cs.classroom_id = $1 +ORDER BY u.full_name ASC; diff --git a/Backend/db/queries/messages.sql b/Backend/db/queries/messages.sql new file mode 100644 index 0000000..6cef17c --- /dev/null +++ b/Backend/db/queries/messages.sql @@ -0,0 +1,266 @@ +-- name: ListMessageRecipientsForUser :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC; + +-- name: GetMessageRecipientByIDForUser :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $2 + AND u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +LIMIT 1; + +-- name: ListMessageThreadsForUser :many +SELECT + t.id AS thread_id, + t.subject, + t.created_by_user_id, + t.created_at AS thread_created_at, + t.updated_at AS thread_updated_at, + COALESCE(last_message.id, 0)::bigint AS last_message_id, + COALESCE(last_message.body, '') AS last_message_body, + last_message.created_at AS last_message_created_at, + COALESCE(last_message.sender_user_id, 0)::bigint AS last_message_sender_user_id, + sender.full_name AS last_message_sender_full_name, + sender_profile.preferred_name AS last_message_sender_preferred_name, + sender_profile.profile_icon_url AS last_message_sender_profile_icon_url, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $1 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_thread_participants participant +JOIN message_threads t ON t.id = participant.thread_id +LEFT JOIN LATERAL ( + SELECT m.id, m.body, m.created_at, m.sender_user_id + FROM messages m + WHERE m.thread_id = t.id + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 +) AS last_message ON TRUE +LEFT JOIN users sender ON sender.id = last_message.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +ORDER BY COALESCE(last_message.created_at, t.updated_at) DESC, t.id DESC; + +-- name: ListMessageThreadParticipantsForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id IN ( + SELECT participant.thread_id + FROM message_thread_participants participant + WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +) +ORDER BY mtp.thread_id ASC, COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC; + +-- name: GetMessageThreadForUser :one +SELECT + t.id, + t.subject, + t.created_by_user_id, + t.created_at, + t.updated_at, + participant.last_read_at, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $2 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_threads t +JOIN message_thread_participants participant ON participant.thread_id = t.id +WHERE t.id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL; + +-- name: ListMessagesForThreadForUser :many +SELECT + m.id, + m.thread_id, + m.sender_user_id, + m.body, + m.created_at, + m.updated_at, + sender.email AS sender_email, + sender.role AS sender_role, + sender.full_name AS sender_full_name, + sender_profile.preferred_name AS sender_preferred_name, + sender_profile.profile_icon_url AS sender_profile_icon_url, + sender_profile.headline AS sender_headline +FROM messages m +JOIN message_thread_participants participant ON participant.thread_id = m.thread_id +JOIN users sender ON sender.id = m.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE m.thread_id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL +ORDER BY m.created_at ASC, m.id ASC; + +-- name: ListParticipantsForThreadForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id = $1 + AND EXISTS ( + SELECT 1 + FROM message_thread_participants participant + WHERE participant.thread_id = mtp.thread_id + AND participant.user_id = $2 + AND participant.archived_at IS NULL + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC; + +-- name: CreateMessageThread :one +INSERT INTO message_threads ( + created_by_user_id, + subject +) VALUES ( + $1, + $2 +) +RETURNING *; + +-- name: AddMessageThreadParticipant :exec +INSERT INTO message_thread_participants ( + thread_id, + user_id, + last_read_at +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (thread_id, user_id) DO NOTHING; + +-- name: CreateThreadMessage :one +INSERT INTO messages ( + thread_id, + sender_user_id, + body +) VALUES ( + $1, + $2, + $3 +) +RETURNING *; + +-- name: TouchMessageThread :exec +UPDATE message_threads +SET updated_at = NOW() +WHERE id = $1; + +-- name: UpdateMessageThreadSubject :one +UPDATE message_threads +SET subject = sqlc.arg(subject), + updated_at = NOW() +WHERE id = sqlc.arg(thread_id) +RETURNING *; + +-- name: UpdateThreadMessageBody :one +UPDATE messages +SET body = sqlc.arg(body), + updated_at = NOW() +WHERE id = sqlc.arg(message_id) + AND thread_id = sqlc.arg(thread_id) + AND sender_user_id = sqlc.arg(user_id) +RETURNING *; + +-- name: DeleteThreadMessage :one +DELETE FROM messages +WHERE id = sqlc.arg(message_id) + AND thread_id = sqlc.arg(thread_id) + AND sender_user_id = sqlc.arg(user_id) +RETURNING *; + +-- name: DeleteMessageThread :one +DELETE FROM message_threads +WHERE id = sqlc.arg(thread_id) +RETURNING *; + +-- name: MarkMessageThreadRead :one +UPDATE message_thread_participants +SET last_read_at = COALESCE((SELECT MAX(m.created_at) FROM messages m WHERE m.thread_id = $1), NOW()) +WHERE message_thread_participants.thread_id = $1 + AND message_thread_participants.user_id = $2 +RETURNING *; diff --git a/Backend/db/queries/questions.sql b/Backend/db/queries/questions.sql new file mode 100644 index 0000000..160c564 --- /dev/null +++ b/Backend/db/queries/questions.sql @@ -0,0 +1,55 @@ +-- name: CreateQuestion :one +INSERT INTO questions ( + author_teacher_id, + title, + prompt, + topic, + subject, + difficulty, + source, + status, + correct_answer +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +RETURNING *; + +-- name: ListQuestionsByTeacher :many +SELECT * +FROM questions +WHERE author_teacher_id = $1 +ORDER BY created_at DESC; + +-- name: GetQuestionByID :one +SELECT * +FROM questions +WHERE id = $1; + +-- name: CreateTag :one +INSERT INTO tags (name) +VALUES ($1) +ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name +RETURNING *; + +-- name: AttachTagToQuestion :exec +INSERT INTO question_tags ( + question_id, + tag_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (question_id, tag_id) DO NOTHING; + +-- name: ListTags :many +SELECT * +FROM tags +ORDER BY name ASC; diff --git a/Backend/db/queries/student_answers.sql b/Backend/db/queries/student_answers.sql new file mode 100644 index 0000000..e38ca64 --- /dev/null +++ b/Backend/db/queries/student_answers.sql @@ -0,0 +1,228 @@ +-- name: UpsertStudentAnswer :one +INSERT INTO student_answers ( + assignment_id, + question_id, + student_id, + answer_text, + solve_mode, + working_steps, + ai_feedback, + teacher_feedback, + status, + submitted_at, + reviewed_at, + is_correct +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12 +) +ON CONFLICT (assignment_id, question_id, student_id) DO UPDATE +SET + answer_text = EXCLUDED.answer_text, + solve_mode = EXCLUDED.solve_mode, + working_steps = EXCLUDED.working_steps, + ai_feedback = EXCLUDED.ai_feedback, + teacher_feedback = EXCLUDED.teacher_feedback, + status = EXCLUDED.status, + submitted_at = EXCLUDED.submitted_at, + reviewed_at = EXCLUDED.reviewed_at, + is_correct = EXCLUDED.is_correct, + updated_at = NOW() +RETURNING *; + +-- name: UpdateAnswerAIReview :one +UPDATE student_answers +SET + ai_feedback = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: ListAnswersForAssignment :many +SELECT * +FROM student_answers +WHERE assignment_id = $1 +ORDER BY created_at ASC; + +-- name: ListAnswersForStudent :many +SELECT * +FROM student_answers +WHERE student_id = $1 +ORDER BY created_at DESC; + +-- name: ListQuestionDetailsForAssignmentStudent :many +WITH student_question_set AS ( + SELECT + asq.assignment_id, + asq.question_id, + asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), +selected_questions AS ( + SELECT + sq.assignment_id, + sq.question_id, + sq.position + FROM student_question_set sq + UNION ALL + SELECT + aq.assignment_id, + aq.question_id, + aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +) +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.title, + q.prompt, + q.subject, + q.source, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = aq.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + q.status AS question_status, + q.correct_answer, + aa.ai_feedback AS assignment_ai_feedback, + aa.teacher_feedback AS assignment_teacher_feedback, + review_summary.overall_score, + a.pass_threshold, + aa.next_step_outcome, + aa.pass_status_override, + COALESCE( + aa.pass_status_override, + CASE + WHEN review_summary.overall_score IS NULL THEN 'pending'::assignment_pass_status + WHEN review_summary.overall_score >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) AS pass_status, + sa.id AS answer_id, + sa.student_id, + sa.answer_text, + sa.solve_mode, + sa.working_steps, + sa.is_correct, + sa.ai_feedback, + sa.teacher_feedback, + sa.status AS answer_status, + sa.review_needs_attention, + sa.review_issue_reason, + sa.review_correctness_score, + sa.review_understanding_score, + sa.review_question_score, + sa.review_confidence, + sa.review_tags, + sa.submitted_at, + sa.reviewed_at, + sa.created_at AS answer_created_at, + sa.updated_at AS answer_updated_at + FROM selected_questions aq + JOIN assignments a ON a.id = aq.assignment_id + JOIN questions q ON q.id = aq.question_id + LEFT JOIN assignment_assignees aa + ON aa.assignment_id = aq.assignment_id + AND aa.student_id = $2 + LEFT JOIN LATERAL ( + SELECT CASE + WHEN COUNT(sa2.id) = 0 THEN NULL::NUMERIC(5,2) + ELSE ROUND((AVG( + CASE + WHEN sa2.is_correct IS NULL THEN COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa2.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2)::NUMERIC(5,2) + END AS overall_score + FROM selected_questions aq2 + LEFT JOIN student_answers sa2 + ON sa2.assignment_id = aq2.assignment_id + AND sa2.question_id = aq2.question_id + AND sa2.student_id = $2 + WHERE aq2.assignment_id = aq.assignment_id +) review_summary ON TRUE +LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC; + +-- name: UpdateAnswerReview :one +UPDATE student_answers +SET + status = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + review_tags = $9, + reviewed_at = CASE + WHEN $2::answer_status = 'reviewed' THEN NOW() + ELSE NULL + END, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: ListStudentPlanningPerformance :many +SELECT + sa.assignment_id, + sa.question_id, + q.topic, + q.subject, + q.difficulty, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = sa.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + sa.is_correct, + sa.review_understanding_score, + sa.review_needs_attention, + sa.review_issue_reason, + sa.status, + sa.submitted_at, + sa.reviewed_at, + sa.updated_at +FROM student_answers sa +JOIN questions q ON q.id = sa.question_id +WHERE sa.student_id = $1 + AND sa.status IN ('submitted'::answer_status, 'reviewed'::answer_status) +ORDER BY COALESCE(sa.reviewed_at, sa.submitted_at, sa.updated_at) DESC, sa.id DESC; diff --git a/Backend/db/queries/users.sql b/Backend/db/queries/users.sql new file mode 100644 index 0000000..6541992 --- /dev/null +++ b/Backend/db/queries/users.sql @@ -0,0 +1,178 @@ +-- name: CreateUser :one +INSERT INTO users ( + email, + password_hash, + role, + full_name +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: GetUserByID :one +SELECT * +FROM users +WHERE id = $1; + +-- name: GetAuthUserByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1; + +-- name: GetUserByEmail :one +SELECT * +FROM users +WHERE email = $1; + +-- name: GetAuthUserByEmail :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.password_hash AS user_password_hash, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.email = $1; + +-- name: ListUsersByRole :many +SELECT * +FROM users +WHERE role = $1 +ORDER BY full_name ASC; + +-- name: ListUsersWithProfileByRole :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.role = $1 +ORDER BY u.full_name ASC; + +-- name: GetUserWithProfileByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1; + +-- name: UpdateUserActiveStatus :one +UPDATE users +SET + is_active = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateUserFullName :one +UPDATE users +SET + full_name = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpsertUserProfile :one +INSERT INTO profiles ( + user_id, + preferred_name, + profile_icon_url, + headline, + bio, + timezone, + locale, + grade_level, + learning_goal +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +ON CONFLICT (user_id) DO UPDATE +SET + preferred_name = EXCLUDED.preferred_name, + profile_icon_url = EXCLUDED.profile_icon_url, + headline = EXCLUDED.headline, + bio = EXCLUDED.bio, + timezone = EXCLUDED.timezone, + locale = EXCLUDED.locale, + grade_level = EXCLUDED.grade_level, + learning_goal = EXCLUDED.learning_goal, + updated_at = NOW() +RETURNING *; diff --git a/Backend/db/sqlc.yaml b/Backend/db/sqlc.yaml new file mode 100644 index 0000000..01f5f0a --- /dev/null +++ b/Backend/db/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "queries/" + schema: "migrations/" + gen: + go: + package: "sqlc" + out: "../internal/sqlc" + sql_package: "pgx/v5" + emit_json_tags: true + emit_empty_slices: true diff --git a/Backend/go.mod b/Backend/go.mod new file mode 100644 index 0000000..55f381d --- /dev/null +++ b/Backend/go.mod @@ -0,0 +1,33 @@ +module boostai-backend + +go 1.24.11 + +require ( + github.com/gofiber/fiber/v2 v2.52.10 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/jackc/pgx/v5 v5.8.0 + github.com/pressly/goose/v3 v3.26.0 + golang.org/x/crypto v0.40.0 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/Backend/go.sum b/Backend/go.sum new file mode 100644 index 0000000..82b1277 --- /dev/null +++ b/Backend/go.sum @@ -0,0 +1,83 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/Backend/internal/aireview/service.go b/Backend/internal/aireview/service.go new file mode 100644 index 0000000..c404373 --- /dev/null +++ b/Backend/internal/aireview/service.go @@ -0,0 +1,469 @@ +package aireview + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Service struct { + endpoint string + apiKey string + model string + client *http.Client +} + +type AssignmentReviewInput struct { + AssignmentID int64 `json:"assignmentId"` + StudentID int64 `json:"studentId"` + AssignmentTitle string `json:"assignmentTitle"` + Instructions string `json:"instructions,omitempty"` + PassThreshold float64 `json:"passThreshold"` + Questions []AssignmentQuestionInput `json:"questions"` +} + +type AssignmentQuestionInput struct { + QuestionID int64 `json:"questionId"` + Position int32 `json:"position"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject string `json:"subject,omitempty"` + Source string `json:"source,omitempty"` + CorrectAnswer string `json:"correctAnswer,omitempty"` + QuestionTags []string `json:"questionTags,omitempty"` + SolveMode string `json:"solveMode,omitempty"` + AnswerText string `json:"answerText,omitempty"` + WorkingSteps string `json:"workingSteps,omitempty"` + IsCorrect *bool `json:"isCorrect,omitempty"` + AnswerStatus string `json:"answerStatus,omitempty"` +} + +type AssignmentReviewResult struct { + Questions []QuestionReviewResult `json:"questions"` + AssignmentSummary string `json:"assignmentSummary"` + RecommendedNextStep string `json:"recommendedNextStep"` +} + +type RedoPlanInput struct { + AssignmentID int64 `json:"assignmentId"` + StudentID int64 `json:"studentId"` + AssignmentTitle string `json:"assignmentTitle"` + Instructions string `json:"instructions,omitempty"` + TeacherFeedback string `json:"teacherFeedback,omitempty"` + PassThreshold float64 `json:"passThreshold"` + TopicScores map[string]float64 `json:"topicScores"` + WeakTags []string `json:"weakTags,omitempty"` + RecentIssues []string `json:"recentIssues,omitempty"` + AllowedTopics []string `json:"allowedTopics"` + AllowedDifficulties []string `json:"allowedDifficulties"` +} + +type RedoPlanResult struct { + Rationale string `json:"rationale"` + QuestionSet []RedoPlanQuestion `json:"questionSet"` +} + +type RedoPlanQuestion struct { + Topic string `json:"topic"` + Difficulty string `json:"difficulty"` + Tags []string `json:"tags,omitempty"` + Reason string `json:"reason"` +} + +type QuestionReviewResult struct { + QuestionID int64 `json:"questionId"` + AiFeedback string `json:"aiFeedback"` + UnderstandingScore float64 `json:"understandingScore"` + Confidence float64 `json:"confidence"` + NeedsAttention bool `json:"needsAttention"` + IssueReason string `json:"issueReason"` +} + +func NewService(endpoint, apiKey, model string) *Service { + return &Service{ + endpoint: strings.TrimSpace(endpoint), + apiKey: strings.TrimSpace(apiKey), + model: strings.TrimSpace(model), + client: &http.Client{ + Timeout: 45 * time.Second, + }, + } +} + +func (s *Service) Enabled() bool { + return s != nil && s.endpoint != "" && s.apiKey != "" && s.model != "" +} + +func (s *Service) ReviewSubmission(ctx context.Context, input AssignmentReviewInput) (*AssignmentReviewResult, error) { + if !s.Enabled() { + return nil, fmt.Errorf("AI review is not configured") + } + + payloadJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal AI review input: %w", err) + } + + body := map[string]any{ + "model": s.model, + "input": []map[string]any{ + { + "role": "system", + "content": []map[string]any{{ + "type": "input_text", + "text": strings.TrimSpace(`You are reviewing student homework submissions for a teacher workflow. + +You must assess the student's understanding by looking at the student's final answer and working against the saved correct answer when one is available. Do not re-grade weighting. + +Rules: +- correctness score is fixed at 1.0 externally and must not vary. +- question weighting is fixed at 1.0 externally and must not vary. +- understandingScore is the only variable score and must be between 0.0 and 1.0. +- confidence must be between 0.0 and 1.0. +- every question in the assignment must be included in the output, even if the student left it blank. +- if answerText and workingSteps are both empty, treat the question as unanswered and set understandingScore to 0.0. +- unanswered questions should normally set needsAttention to true and explain that no answer was submitted. +- when correctAnswer is present, explicitly compare the student's answerText and workingSteps against that correctAnswer before judging understanding. +- when the student's answer is wrong, issueReason should say what is mismatched, missing, or misunderstood relative to the correctAnswer, not just give a generic comment. +- when the student's answer is correct but the explanation is weak, issueReason should make clear that correctness was reached but understanding evidence is still limited. +- when correctAnswer is missing, fall back to judging from the student's explanation, steps, and internal consistency only. +- needsAttention should be true when the student likely needs follow-up help based on their understanding, explanation quality, or uncertainty. +- issueReason should be concise and directly tied to understanding gaps, explicitly grounded in the comparison to the correct answer whenever available. +- aiFeedback should be concise, teacher-facing, and about the student's understanding for that question, referencing the answer-vs-correct-answer comparison when it materially explains the judgment. +- recommendedNextStep must be exactly one of: redo, accept, support. + +Interpretation guidance: +- accept = understanding is generally sufficient for the assignment so the student can continue. +- support = the student shows meaningful gaps and likely needs targeted help. +- redo = the student should redo the assignment because understanding is broadly too weak or incomplete. + + Review the full assignment in one pass and produce a short assignment-level summary.`), + }}, + }, + { + "role": "user", + "content": []map[string]any{{ + "type": "input_text", + "text": string(payloadJSON), + }}, + }, + }, + "text": map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "assignment_review", + "strict": true, + "schema": reviewSchema(), + }, + }, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal AI review request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("build AI review request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", s.apiKey) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send AI review request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read AI review response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("AI review request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + + outputText, err := extractOutputText(respBytes) + if err != nil { + return nil, err + } + + var result AssignmentReviewResult + if err := json.Unmarshal([]byte(outputText), &result); err != nil { + return nil, fmt.Errorf("decode AI review structured output: %w", err) + } + + sanitizeResult(&result) + return &result, nil +} + +func (s *Service) PlanRedoAssignment(ctx context.Context, input RedoPlanInput) (*RedoPlanResult, error) { + if !s.Enabled() { + return nil, fmt.Errorf("AI review is not configured") + } + + payloadJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal redo plan input: %w", err) + } + + body := map[string]any{ + "model": s.model, + "input": []map[string]any{ + { + "role": "system", + "content": []map[string]any{{ + "type": "input_text", + "text": strings.TrimSpace(`You are planning the next redo assignment for a student. + +You are NOT writing final math questions. You are only producing a structured topic+difficulty blueprint for a later generator layer. + +Rules: +- Use only the allowedTopics values exactly as provided. +- Use only the allowedDifficulties values exactly as provided. +- Return between 5 and 10 planned items. +- Focus most heavily on the weakest topics and weak tags, while still keeping the redo assignment coherent with the current assignment title/instructions and any teacher feedback. +- Prefer a sensible progression of difficulty rather than making everything hard. +- If teacherFeedback contains a specific reteach direction, incorporate it. +- Tags should be short machine-friendly labels and may be empty. +- rationale should briefly explain why these topics/difficulties were chosen from the weakness summary. +- reason on each item should briefly explain why that topic/difficulty belongs in the redo set. +- Do not invent topics outside the allowed topic vocabulary. +- Do not output prose outside the JSON schema.`), + }}, + }, + { + "role": "user", + "content": []map[string]any{{ + "type": "input_text", + "text": string(payloadJSON), + }}, + }, + }, + "text": map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "redo_assignment_plan", + "strict": true, + "schema": redoPlanSchema(), + }, + }, + } + + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal redo plan request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("build redo plan request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", s.apiKey) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send redo plan request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read redo plan response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("redo plan request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + + outputText, err := extractOutputText(respBytes) + if err != nil { + return nil, err + } + + var result RedoPlanResult + if err := json.Unmarshal([]byte(outputText), &result); err != nil { + return nil, fmt.Errorf("decode redo plan structured output: %w", err) + } + + sanitizeRedoPlan(&result, input.AllowedTopics, input.AllowedDifficulties) + return &result, nil +} + +func reviewSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "questions": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "questionId": map[string]any{"type": "integer"}, + "aiFeedback": map[string]any{"type": "string"}, + "understandingScore": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, + "confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, + "needsAttention": map[string]any{"type": "boolean"}, + "issueReason": map[string]any{"type": "string"}, + }, + "required": []string{"questionId", "aiFeedback", "understandingScore", "confidence", "needsAttention", "issueReason"}, + }, + }, + "assignmentSummary": map[string]any{"type": "string"}, + "recommendedNextStep": map[string]any{"type": "string", "enum": []string{"redo", "accept", "support"}}, + }, + "required": []string{"questions", "assignmentSummary", "recommendedNextStep"}, + } +} + +func redoPlanSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "rationale": map[string]any{"type": "string"}, + "questionSet": map[string]any{ + "type": "array", + "minItems": 5, + "maxItems": 10, + "items": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "topic": map[string]any{"type": "string"}, + "difficulty": map[string]any{"type": "string"}, + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + "reason": map[string]any{"type": "string"}, + }, + "required": []string{"topic", "difficulty", "tags", "reason"}, + }, + }, + }, + "required": []string{"rationale", "questionSet"}, + } +} + +func extractOutputText(respBytes []byte) (string, error) { + var direct struct { + OutputText string `json:"output_text"` + } + if err := json.Unmarshal(respBytes, &direct); err == nil { + if text := strings.TrimSpace(direct.OutputText); text != "" { + return text, nil + } + } + + var raw map[string]any + if err := json.Unmarshal(respBytes, &raw); err != nil { + return "", fmt.Errorf("decode AI review raw response: %w", err) + } + + if text := strings.TrimSpace(findOutputText(raw)); text != "" { + return text, nil + } + + return "", fmt.Errorf("AI review response did not contain structured output text") +} + +func findOutputText(value any) string { + switch typed := value.(type) { + case map[string]any: + if text, ok := typed["output_text"].(string); ok && strings.TrimSpace(text) != "" { + return text + } + if text, ok := typed["text"].(string); ok && strings.TrimSpace(text) != "" { + return text + } + for _, nested := range typed { + if result := findOutputText(nested); result != "" { + return result + } + } + case []any: + for _, nested := range typed { + if result := findOutputText(nested); result != "" { + return result + } + } + } + + return "" +} + +func sanitizeResult(result *AssignmentReviewResult) { + result.AssignmentSummary = strings.TrimSpace(result.AssignmentSummary) + switch result.RecommendedNextStep { + case "redo", "accept", "support": + default: + result.RecommendedNextStep = "support" + } + + for index := range result.Questions { + question := &result.Questions[index] + question.AiFeedback = strings.TrimSpace(question.AiFeedback) + question.IssueReason = strings.TrimSpace(question.IssueReason) + question.UnderstandingScore = clamp01(question.UnderstandingScore) + question.Confidence = clamp01(question.Confidence) + } +} + +func sanitizeRedoPlan(result *RedoPlanResult, allowedTopics []string, allowedDifficulties []string) { + allowedTopicSet := make(map[string]struct{}, len(allowedTopics)) + for _, topic := range allowedTopics { + allowedTopicSet[strings.TrimSpace(topic)] = struct{}{} + } + allowedDifficultySet := make(map[string]struct{}, len(allowedDifficulties)) + for _, difficulty := range allowedDifficulties { + allowedDifficultySet[strings.TrimSpace(difficulty)] = struct{}{} + } + + result.Rationale = strings.TrimSpace(result.Rationale) + filtered := make([]RedoPlanQuestion, 0, len(result.QuestionSet)) + for _, item := range result.QuestionSet { + item.Topic = strings.TrimSpace(item.Topic) + item.Difficulty = strings.ToLower(strings.TrimSpace(item.Difficulty)) + if _, ok := allowedTopicSet[item.Topic]; !ok { + continue + } + if _, ok := allowedDifficultySet[item.Difficulty]; !ok { + continue + } + item.Reason = strings.TrimSpace(item.Reason) + cleanTags := make([]string, 0, len(item.Tags)) + for _, tag := range item.Tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + cleanTags = append(cleanTags, tag) + } + item.Tags = cleanTags + filtered = append(filtered, item) + } + result.QuestionSet = filtered +} + +func clamp01(value float64) float64 { + if value < 0 { + return 0 + } + if value > 1 { + return 1 + } + return value +} diff --git a/Backend/internal/assignmentgen/personalization.go b/Backend/internal/assignmentgen/personalization.go new file mode 100644 index 0000000..506df07 --- /dev/null +++ b/Backend/internal/assignmentgen/personalization.go @@ -0,0 +1,106 @@ +package assignmentgen + +import ( + "context" + "fmt" + + "boostai-backend/internal/sqlc" +) + +const defaultPersonalizedRatio = 0.30 + +type WeaknessSummary struct { + TopicScores map[sqlc.QuestionTopic]float64 + WeakTags []string + RecentIssues []string +} + +type MixedPlanParams struct { + StudentID int64 + PrimaryTopic sqlc.QuestionTopic + PrimaryDifficulty sqlc.QuestionDifficulty + TotalQuestions int + PersonalizedRatio float64 + BaseSeed int64 + PersonalizedDifficulty sqlc.QuestionDifficulty +} + +type MixedPlan struct { + Plan []PlanItem + WeaknessSummary WeaknessSummary + CoreCount int + PersonalizedCount int + PersonalizedTopic sqlc.QuestionTopic + PersonalizedApplied bool + BaseSeed int64 +} + +type GenerateMixedStudentQuestionSetParams struct { + AssignmentID int64 + StudentID int64 + TeacherID int64 + Subject string + QuestionStatus sqlc.QuestionStatus + QuestionSource string + PrimaryTopic sqlc.QuestionTopic + PrimaryDifficulty sqlc.QuestionDifficulty + TotalQuestions int + PersonalizedRatio float64 + Seed int64 + PersonalizedDifficulty sqlc.QuestionDifficulty +} + +type GenerateMixedStudentQuestionSetResult struct { + StoredQuestions []StoredStudentQuestion + MixedPlan MixedPlan +} + +func (s *Service) BuildWeaknessSummary(ctx context.Context, studentID int64) (WeaknessSummary, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return WeaknessSummary{}, fmt.Errorf("assignment question generator database is not configured") + } + if studentID <= 0 { + return WeaknessSummary{}, fmt.Errorf("student_id is required") + } + + queries := sqlc.New(s.db.Pool) + rows, err := queries.ListStudentPlanningPerformance(ctx, studentID) + if err != nil { + return WeaknessSummary{}, err + } + + return buildWeaknessSummary(rows), nil +} + +func (s *Service) GenerateAndStoreMixedStudentQuestions(ctx context.Context, params GenerateMixedStudentQuestionSetParams) (GenerateMixedStudentQuestionSetResult, error) { + mixedPlan, err := s.BuildMixedPlan(ctx, MixedPlanParams{ + StudentID: params.StudentID, + PrimaryTopic: params.PrimaryTopic, + PrimaryDifficulty: params.PrimaryDifficulty, + TotalQuestions: params.TotalQuestions, + PersonalizedRatio: params.PersonalizedRatio, + BaseSeed: params.Seed, + PersonalizedDifficulty: params.PersonalizedDifficulty, + }) + if err != nil { + return GenerateMixedStudentQuestionSetResult{}, err + } + + storedQuestions, err := s.GenerateAndStoreStudentQuestions(ctx, GenerateStudentQuestionSetParams{ + AssignmentID: params.AssignmentID, + StudentID: params.StudentID, + TeacherID: params.TeacherID, + Subject: params.Subject, + QuestionStatus: params.QuestionStatus, + QuestionSource: params.QuestionSource, + Plan: mixedPlan.Plan, + }) + if err != nil { + return GenerateMixedStudentQuestionSetResult{}, err + } + + return GenerateMixedStudentQuestionSetResult{ + StoredQuestions: storedQuestions, + MixedPlan: mixedPlan, + }, nil +} diff --git a/Backend/internal/assignmentgen/personalization_plan.go b/Backend/internal/assignmentgen/personalization_plan.go new file mode 100644 index 0000000..7105fb5 --- /dev/null +++ b/Backend/internal/assignmentgen/personalization_plan.go @@ -0,0 +1,172 @@ +package assignmentgen + +import ( + "context" + "fmt" + "math" + "sort" + "time" + + "boostai-backend/internal/sqlc" +) + +func (s *Service) BuildMixedPlan(ctx context.Context, params MixedPlanParams) (MixedPlan, error) { + if err := validateMixedPlanParams(params); err != nil { + return MixedPlan{}, err + } + + ratio := normalizePersonalizedRatio(params.PersonalizedRatio) + + weaknessSummary, err := s.BuildWeaknessSummary(ctx, params.StudentID) + if err != nil { + return MixedPlan{}, err + } + + personalizedTopic, personalizedApplied := selectPersonalizedTopic(params.PrimaryTopic, weaknessSummary) + personalizedCount := calculatePersonalizedCount(params.TotalQuestions, ratio, personalizedApplied) + coreCount := calculateCoreCount(params.TotalQuestions, personalizedCount) + baseSeed := normalizeBaseSeed(params.BaseSeed) + personalizedDifficulty := normalizePersonalizedDifficulty(params) + + return MixedPlan{ + Plan: buildMixedPlanItems(params, coreCount, personalizedCount, personalizedTopic, personalizedDifficulty, baseSeed), + WeaknessSummary: weaknessSummary, + CoreCount: coreCount, + PersonalizedCount: personalizedCount, + PersonalizedTopic: personalizedTopic, + PersonalizedApplied: personalizedCount > 0, + BaseSeed: baseSeed, + }, nil +} + +func validateMixedPlanParams(params MixedPlanParams) error { + if params.StudentID <= 0 { + return fmt.Errorf("student_id is required") + } + if params.PrimaryTopic == "" { + return fmt.Errorf("primary topic is required") + } + if params.PrimaryDifficulty == "" { + return fmt.Errorf("primary difficulty is required") + } + if params.TotalQuestions <= 0 { + return fmt.Errorf("total_questions must be positive") + } + if params.PersonalizedRatio < 0 || params.PersonalizedRatio >= 1 { + return fmt.Errorf("personalized_ratio must be between 0 and less than 1") + } + + return nil +} + +func normalizePersonalizedRatio(ratio float64) float64 { + if ratio == 0 { + return defaultPersonalizedRatio + } + return ratio +} + +func normalizeBaseSeed(seed int64) int64 { + if seed == 0 { + return time.Now().UnixNano() + } + return seed +} + +func normalizePersonalizedDifficulty(params MixedPlanParams) sqlc.QuestionDifficulty { + if params.PersonalizedDifficulty == "" { + return params.PrimaryDifficulty + } + return params.PersonalizedDifficulty +} + +func calculateCoreCount(totalQuestions, personalizedCount int) int { + coreCount := totalQuestions - personalizedCount + if totalQuestions > 0 && coreCount <= 0 { + return 1 + } + return coreCount +} + +func buildMixedPlanItems( + params MixedPlanParams, + coreCount int, + personalizedCount int, + personalizedTopic sqlc.QuestionTopic, + personalizedDifficulty sqlc.QuestionDifficulty, + baseSeed int64, +) []PlanItem { + plan := make([]PlanItem, 0, 2) + + if coreCount > 0 { + plan = append(plan, PlanItem{ + Topic: params.PrimaryTopic, + Difficulty: params.PrimaryDifficulty, + Count: coreCount, + SourceBucket: SourceBucketCoreTopic, + Seed: baseSeed, + }) + } + + if personalizedCount > 0 { + plan = append(plan, PlanItem{ + Topic: personalizedTopic, + Difficulty: personalizedDifficulty, + Count: personalizedCount, + SourceBucket: SourceBucketPersonalized, + Seed: baseSeed + 7919, + }) + } + + return plan +} + +func selectPersonalizedTopic(primaryTopic sqlc.QuestionTopic, summary WeaknessSummary) (sqlc.QuestionTopic, bool) { + if len(summary.TopicScores) == 0 { + return "", false + } + + type scoredTopic struct { + topic sqlc.QuestionTopic + score float64 + } + + topics := make([]scoredTopic, 0, len(summary.TopicScores)) + for topic, score := range summary.TopicScores { + topics = append(topics, scoredTopic{topic: topic, score: score}) + } + + sort.SliceStable(topics, func(i, j int) bool { + if topics[i].score == topics[j].score { + return string(topics[i].topic) < string(topics[j].topic) + } + return topics[i].score < topics[j].score + }) + + for _, candidate := range topics { + if candidate.topic != primaryTopic { + return candidate.topic, true + } + } + + return topics[0].topic, true +} + +func calculatePersonalizedCount(totalQuestions int, ratio float64, personalizedApplied bool) int { + if !personalizedApplied || totalQuestions <= 1 || ratio <= 0 { + return 0 + } + + count := int(math.Floor(float64(totalQuestions) * ratio)) + if count == 0 && totalQuestions >= 3 { + count = 1 + } + if count >= totalQuestions { + count = totalQuestions - 1 + } + if count < 0 { + count = 0 + } + + return count +} diff --git a/Backend/internal/assignmentgen/personalization_test.go b/Backend/internal/assignmentgen/personalization_test.go new file mode 100644 index 0000000..06a718e --- /dev/null +++ b/Backend/internal/assignmentgen/personalization_test.go @@ -0,0 +1,85 @@ +package assignmentgen + +import ( + "fmt" + "testing" + + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestBuildWeaknessSummaryAggregatesSignals(t *testing.T) { + rows := []sqlc.ListStudentPlanningPerformanceRow{ + { + Topic: sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopicFractions, Valid: true}, + QuestionTags: []string{"fractions", "simplify"}, + IsCorrect: pgtype.Bool{Bool: false, Valid: true}, + ReviewUnderstandingScore: mustNumeric(t, 0.2), + ReviewNeedsAttention: true, + ReviewIssueReason: pgtype.Text{String: "Missed simplification", Valid: true}, + }, + { + Topic: sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopicGeometry, Valid: true}, + QuestionTags: []string{"geometry", "angles"}, + IsCorrect: pgtype.Bool{Bool: true, Valid: true}, + ReviewUnderstandingScore: mustNumeric(t, 0.9), + ReviewIssueReason: pgtype.Text{String: "Explained angle sum well", Valid: true}, + }, + } + + summary := buildWeaknessSummary(rows) + + if got := summary.TopicScores[sqlc.QuestionTopicFractions]; got != 10 { + t.Fatalf("expected fractions score 10, got %v", got) + } + if got := summary.TopicScores[sqlc.QuestionTopicGeometry]; got != 95 { + t.Fatalf("expected geometry score 95, got %v", got) + } + if len(summary.WeakTags) == 0 || summary.WeakTags[0] != "fractions" { + t.Fatalf("expected fractions weak tag to rank first, got %#v", summary.WeakTags) + } + if len(summary.RecentIssues) != 2 { + t.Fatalf("expected 2 recent issues, got %d", len(summary.RecentIssues)) + } +} + +func TestSelectPersonalizedTopicPrefersWeakestNonPrimary(t *testing.T) { + summary := WeaknessSummary{ + TopicScores: map[sqlc.QuestionTopic]float64{ + sqlc.QuestionTopicFractions: 35, + sqlc.QuestionTopicGeometry: 82, + sqlc.QuestionTopicAlgebra: 61, + }, + } + + topic, ok := selectPersonalizedTopic(sqlc.QuestionTopicGeometry, summary) + if !ok { + t.Fatal("expected personalized topic to be selected") + } + if topic != sqlc.QuestionTopicFractions { + t.Fatalf("expected fractions, got %q", topic) + } +} + +func TestCalculatePersonalizedCountUsesSafeSplit(t *testing.T) { + if got := calculatePersonalizedCount(10, 0.3, true); got != 3 { + t.Fatalf("expected 3 personalized questions, got %d", got) + } + if got := calculatePersonalizedCount(2, 0.3, true); got != 0 { + t.Fatalf("expected 0 personalized questions for tiny set, got %d", got) + } + if got := calculatePersonalizedCount(5, 0.3, false); got != 0 { + t.Fatalf("expected 0 personalized questions without weakness topic, got %d", got) + } +} + +func mustNumeric(t *testing.T, value float64) pgtype.Numeric { + t.Helper() + + var numeric pgtype.Numeric + if err := numeric.ScanScientific(fmt.Sprintf("%f", value)); err != nil { + t.Fatalf("failed to build numeric: %v", err) + } + return numeric +} diff --git a/Backend/internal/assignmentgen/personalization_weakness.go b/Backend/internal/assignmentgen/personalization_weakness.go new file mode 100644 index 0000000..99583ad --- /dev/null +++ b/Backend/internal/assignmentgen/personalization_weakness.go @@ -0,0 +1,171 @@ +package assignmentgen + +import ( + "math" + "sort" + "strings" + + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func buildWeaknessSummary(rows []sqlc.ListStudentPlanningPerformanceRow) WeaknessSummary { + topicTotals := make(map[sqlc.QuestionTopic]float64) + topicCounts := make(map[sqlc.QuestionTopic]int) + tagStats := make(map[string]*tagWeaknessStats) + recentIssues := make([]string, 0, 5) + seenIssues := make(map[string]struct{}) + + for _, row := range rows { + score := planningScore(row.IsCorrect.Bool, numericFloat64OrZero(row.ReviewUnderstandingScore)) + accumulateTopicScore(topicTotals, topicCounts, row, score) + accumulateTagStats(tagStats, row, score) + recentIssues = appendRecentIssue(recentIssues, seenIssues, row.ReviewIssueReason.String) + if len(recentIssues) >= 5 { + break + } + } + + return WeaknessSummary{ + TopicScores: buildTopicScores(topicTotals, topicCounts), + WeakTags: collectWeakTags(tagStats), + RecentIssues: recentIssues, + } +} + +type tagWeaknessStats struct { + total float64 + count int + flaggedCount int +} + +type scoredTag struct { + tag string + score float64 + flaggedCount int +} + +func accumulateTopicScore( + topicTotals map[sqlc.QuestionTopic]float64, + topicCounts map[sqlc.QuestionTopic]int, + row sqlc.ListStudentPlanningPerformanceRow, + score float64, +) { + if !row.Topic.Valid { + return + } + + topicTotals[row.Topic.QuestionTopic] += score + topicCounts[row.Topic.QuestionTopic]++ +} + +func accumulateTagStats(tagStats map[string]*tagWeaknessStats, row sqlc.ListStudentPlanningPerformanceRow, score float64) { + for _, rawTag := range row.QuestionTags { + tag := strings.TrimSpace(strings.ToLower(rawTag)) + if tag == "" { + continue + } + + stats, ok := tagStats[tag] + if !ok { + stats = &tagWeaknessStats{} + tagStats[tag] = stats + } + + stats.total += score + stats.count++ + if row.ReviewNeedsAttention { + stats.flaggedCount++ + } + } +} + +func appendRecentIssue(recentIssues []string, seenIssues map[string]struct{}, rawIssue string) []string { + issue := strings.TrimSpace(rawIssue) + if issue == "" { + return recentIssues + } + if _, exists := seenIssues[issue]; exists { + return recentIssues + } + + seenIssues[issue] = struct{}{} + return append(recentIssues, issue) +} + +func buildTopicScores(topicTotals map[sqlc.QuestionTopic]float64, topicCounts map[sqlc.QuestionTopic]int) map[sqlc.QuestionTopic]float64 { + topicScores := make(map[sqlc.QuestionTopic]float64, len(topicTotals)) + for topic, total := range topicTotals { + count := topicCounts[topic] + if count == 0 { + continue + } + topicScores[topic] = roundToOneDecimal((total / float64(count)) * 100) + } + return topicScores +} + +func collectWeakTags(tagStats map[string]*tagWeaknessStats) []string { + if len(tagStats) == 0 { + return nil + } + + weakCandidates := make([]scoredTag, 0, len(tagStats)) + for tag, stats := range tagStats { + if stats == nil || stats.count == 0 { + continue + } + average := (stats.total / float64(stats.count)) * 100 + if average >= 70 && stats.flaggedCount == 0 { + continue + } + weakCandidates = append(weakCandidates, scoredTag{ + tag: tag, + score: roundToOneDecimal(average), + flaggedCount: stats.flaggedCount, + }) + } + + sort.SliceStable(weakCandidates, func(i, j int) bool { + if weakCandidates[i].score == weakCandidates[j].score { + if weakCandidates[i].flaggedCount == weakCandidates[j].flaggedCount { + return weakCandidates[i].tag < weakCandidates[j].tag + } + return weakCandidates[i].flaggedCount > weakCandidates[j].flaggedCount + } + return weakCandidates[i].score < weakCandidates[j].score + }) + + limit := 6 + if len(weakCandidates) < limit { + limit = len(weakCandidates) + } + + weakTags := make([]string, 0, limit) + for idx := 0; idx < limit; idx++ { + weakTags = append(weakTags, weakCandidates[idx].tag) + } + + return weakTags +} + +func planningScore(isCorrect bool, understandingScore float64) float64 { + correctness := 0.0 + if isCorrect { + correctness = 1.0 + } + return (correctness + understandingScore) / 2 +} + +func roundToOneDecimal(value float64) float64 { + return math.Round(value*10) / 10 +} + +func numericFloat64OrZero(value pgtype.Numeric) float64 { + floatValue, err := value.Float64Value() + if err != nil || !floatValue.Valid { + return 0 + } + return floatValue.Float64 +} diff --git a/Backend/internal/assignmentgen/service.go b/Backend/internal/assignmentgen/service.go new file mode 100644 index 0000000..8caa510 --- /dev/null +++ b/Backend/internal/assignmentgen/service.go @@ -0,0 +1,91 @@ +package assignmentgen + +import ( + "context" + + "boostai-backend/internal/database" + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" +) + +type SourceBucket string + +const ( + SourceBucketCoreTopic SourceBucket = "core_topic" + SourceBucketPersonalized SourceBucket = "personalized" + defaultQuestionSource = "assignment_student_generated" +) + +type PlanItem struct { + Topic sqlc.QuestionTopic + Difficulty sqlc.QuestionDifficulty + Count int + SourceBucket SourceBucket + Seed int64 +} + +type GenerateStudentQuestionSetParams struct { + AssignmentID int64 + StudentID int64 + TeacherID int64 + Subject string + QuestionStatus sqlc.QuestionStatus + QuestionSource string + Plan []PlanItem +} + +type StoredStudentQuestion struct { + Mapping sqlc.AssignmentStudentQuestion + Question sqlc.Question + Tags []string + UsedSeed int64 + SourceBucket string +} + +type Service struct { + db *database.DB + generator *questiongen.Service +} + +func NewService(db *database.DB, generator *questiongen.Service) *Service { + return &Service{db: db, generator: generator} +} + +func (s *Service) GenerateAndStoreStudentQuestions(ctx context.Context, params GenerateStudentQuestionSetParams) ([]StoredStudentQuestion, error) { + if err := s.validateGenerateRequest(params); err != nil { + return nil, err + } + + tx, err := s.db.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + queries := sqlc.New(tx) + + if err := validateAssignmentOwnership(ctx, queries, params.AssignmentID, params.TeacherID); err != nil { + return nil, err + } + if err := validateStudentAssignment(ctx, queries, params.AssignmentID, params.StudentID); err != nil { + return nil, err + } + if err := clearStudentQuestionMappings(ctx, queries, params.AssignmentID, params.StudentID); err != nil { + return nil, err + } + + questionStatus, questionSource := normalizeQuestionDefaults(params) + + stored, err := s.generateAndStorePlan(ctx, queries, params, questionStatus, questionSource) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return stored, nil +} diff --git a/Backend/internal/assignmentgen/service_generate.go b/Backend/internal/assignmentgen/service_generate.go new file mode 100644 index 0000000..70e4d46 --- /dev/null +++ b/Backend/internal/assignmentgen/service_generate.go @@ -0,0 +1,244 @@ +package assignmentgen + +import ( + "context" + "errors" + "fmt" + "strings" + + "boostai-backend/internal/questiongen" + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Service) validateGenerateRequest(params GenerateStudentQuestionSetParams) error { + if s == nil || s.db == nil || s.db.Pool == nil { + return errors.New("assignment question generator database is not configured") + } + if s.generator == nil { + return errors.New("assignment question generator is not configured") + } + if params.AssignmentID <= 0 || params.StudentID <= 0 || params.TeacherID <= 0 { + return errors.New("assignment_id, student_id, and teacher_id are required") + } + return validatePlanItems(params.Plan) +} + +func validatePlanItems(plan []PlanItem) error { + if len(plan) == 0 { + return errors.New("at least one generation plan item is required") + } + + for _, item := range plan { + if item.Count <= 0 { + return fmt.Errorf("generation count must be positive for bucket %q", item.SourceBucket) + } + if strings.TrimSpace(string(item.SourceBucket)) == "" { + return errors.New("source bucket is required for every generation plan item") + } + } + + return nil +} + +func validateAssignmentOwnership(ctx context.Context, queries *sqlc.Queries, assignmentID, teacherID int64) error { + assignment, err := queries.GetAssignmentByID(ctx, assignmentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("assignment %d not found", assignmentID) + } + return err + } + if assignment.TeacherID != teacherID { + return fmt.Errorf("assignment %d does not belong to teacher %d", assignmentID, teacherID) + } + return nil +} + +func validateStudentAssignment(ctx context.Context, queries *sqlc.Queries, assignmentID, studentID int64) error { + _, err := queries.GetAssignmentAssignee(ctx, sqlc.GetAssignmentAssigneeParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("student %d is not assigned to assignment %d", studentID, assignmentID) + } + return err + } + return nil +} + +func clearStudentQuestionMappings(ctx context.Context, queries *sqlc.Queries, assignmentID, studentID int64) error { + return queries.DeleteAssignmentStudentQuestions(ctx, sqlc.DeleteAssignmentStudentQuestionsParams{ + AssignmentID: assignmentID, + StudentID: studentID, + }) +} + +func normalizeQuestionDefaults(params GenerateStudentQuestionSetParams) (sqlc.QuestionStatus, string) { + questionStatus := params.QuestionStatus + if questionStatus == "" { + questionStatus = sqlc.QuestionStatusDraft + } + + questionSource := strings.TrimSpace(params.QuestionSource) + if questionSource == "" { + questionSource = defaultQuestionSource + } + + return questionStatus, questionSource +} + +func (s *Service) generateAndStorePlan( + ctx context.Context, + queries *sqlc.Queries, + params GenerateStudentQuestionSetParams, + questionStatus sqlc.QuestionStatus, + questionSource string, +) ([]StoredStudentQuestion, error) { + stored := make([]StoredStudentQuestion, 0) + position := int32(1) + + for _, item := range params.Plan { + generatedQuestions, usedSeed, err := s.generator.Generate(questiongen.GenerateParams{ + Topic: item.Topic, + Difficulty: item.Difficulty, + Count: item.Count, + Seed: item.Seed, + }) + if err != nil { + return nil, err + } + + storedBatch, nextPosition, err := storeGeneratedQuestionBatch( + ctx, + queries, + params, + item, + generatedQuestions, + questionStatus, + questionSource, + usedSeed, + position, + ) + if err != nil { + return nil, err + } + + stored = append(stored, storedBatch...) + position = nextPosition + } + + return stored, nil +} + +func storeGeneratedQuestionBatch( + ctx context.Context, + queries *sqlc.Queries, + params GenerateStudentQuestionSetParams, + item PlanItem, + generatedQuestions []questiongen.GeneratedQuestion, + questionStatus sqlc.QuestionStatus, + questionSource string, + usedSeed int64, + startPosition int32, +) ([]StoredStudentQuestion, int32, error) { + stored := make([]StoredStudentQuestion, 0, len(generatedQuestions)) + position := startPosition + + for _, generated := range generatedQuestions { + storedQuestion, err := storeGeneratedQuestion( + ctx, + queries, + params, + item, + generated, + questionStatus, + questionSource, + usedSeed, + position, + ) + if err != nil { + return nil, startPosition, err + } + + stored = append(stored, storedQuestion) + position++ + } + + return stored, position, nil +} + +func storeGeneratedQuestion( + ctx context.Context, + queries *sqlc.Queries, + params GenerateStudentQuestionSetParams, + item PlanItem, + generated questiongen.GeneratedQuestion, + questionStatus sqlc.QuestionStatus, + questionSource string, + usedSeed int64, + position int32, +) (StoredStudentQuestion, error) { + question, err := queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{ + AuthorTeacherID: params.TeacherID, + Title: generated.Title, + Prompt: generated.Prompt, + Topic: nullableQuestionTopic(item.Topic), + Subject: textValue(firstNonEmpty(params.Subject, questionTopicLabel(item.Topic))), + Difficulty: nullableQuestionDifficulty(item.Difficulty), + Source: textValue(questionSource), + Status: questionStatus, + CorrectAnswer: textValue(generated.CorrectAnswer), + }) + if err != nil { + return StoredStudentQuestion{}, err + } + + tags := mergeTags(generated.Tags, string(item.SourceBucket), questionSource) + if err := attachQuestionTags(ctx, queries, question.ID, tags); err != nil { + return StoredStudentQuestion{}, err + } + + mapping, err := queries.AddAssignmentStudentQuestion(ctx, sqlc.AddAssignmentStudentQuestionParams{ + AssignmentID: params.AssignmentID, + StudentID: params.StudentID, + QuestionID: question.ID, + Position: position, + SourceBucket: string(item.SourceBucket), + SourceTopic: nullableQuestionTopic(item.Topic), + SourceDifficulty: nullableQuestionDifficulty(item.Difficulty), + GeneratorSeed: pgtype.Int8{Int64: usedSeed, Valid: true}, + }) + if err != nil { + return StoredStudentQuestion{}, err + } + + return StoredStudentQuestion{ + Mapping: mapping, + Question: question, + Tags: tags, + UsedSeed: usedSeed, + SourceBucket: string(item.SourceBucket), + }, nil +} + +func attachQuestionTags(ctx context.Context, queries *sqlc.Queries, questionID int64, tagNames []string) error { + for _, tagName := range tagNames { + tag, err := queries.CreateTag(ctx, tagName) + if err != nil { + return err + } + if err := queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{ + QuestionID: questionID, + TagID: tag.ID, + }); err != nil { + return err + } + } + + return nil +} diff --git a/Backend/internal/assignmentgen/service_helpers.go b/Backend/internal/assignmentgen/service_helpers.go new file mode 100644 index 0000000..baf9a98 --- /dev/null +++ b/Backend/internal/assignmentgen/service_helpers.go @@ -0,0 +1,89 @@ +package assignmentgen + +import ( + "strings" + + "boostai-backend/internal/sqlc" + + "github.com/jackc/pgx/v5/pgtype" +) + +func nullableQuestionTopic(topic sqlc.QuestionTopic) sqlc.NullQuestionTopic { + if topic == "" { + return sqlc.NullQuestionTopic{} + } + return sqlc.NullQuestionTopic{QuestionTopic: topic, Valid: true} +} + +func nullableQuestionDifficulty(difficulty sqlc.QuestionDifficulty) sqlc.NullQuestionDifficulty { + if difficulty == "" { + return sqlc.NullQuestionDifficulty{} + } + return sqlc.NullQuestionDifficulty{QuestionDifficulty: difficulty, Valid: true} +} + +func textValue(value string) pgtype.Text { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return pgtype.Text{} + } + return pgtype.Text{String: trimmed, Valid: true} +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mergeTags(base []string, extras ...string) []string { + seen := make(map[string]struct{}, len(base)+len(extras)) + merged := make([]string, 0, len(base)+len(extras)) + + appendTag := func(tag string) { + normalized := strings.TrimSpace(strings.ToLower(tag)) + if normalized == "" { + return + } + if _, exists := seen[normalized]; exists { + return + } + seen[normalized] = struct{}{} + merged = append(merged, normalized) + } + + for _, tag := range base { + appendTag(tag) + } + for _, tag := range extras { + appendTag(tag) + } + + return merged +} + +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 "Maths" + } +} diff --git a/Backend/internal/config/config.go b/Backend/internal/config/config.go new file mode 100644 index 0000000..37e3f30 --- /dev/null +++ b/Backend/internal/config/config.go @@ -0,0 +1,50 @@ +// Path: Backend/internal/config/config.go + +package config + +import ( + "os" + "strings" +) + +type Config struct { + Port string + Environment string + AllowedOrigins string + DatabaseURL string + JWTSecret string + SessionCookie string + AIReviewEndpoint string + AIReviewAPIKey string + AIReviewModel string +} + +func Load() *Config { + return &Config{ + Port: getEnv("BACKEND_INTERNAL_PORT", "8081"), + Environment: getEnv("GO_ENV", "development"), + AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:4321,http://localhost:8080,http://windows-wsl:8080"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://boostai:boostai_dev_password@localhost:5439/boostai?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "boostai-dev-jwt-secret-change-me"), + SessionCookie: getEnv("SESSION_COOKIE_NAME", "boostai_session"), + AIReviewEndpoint: getEnv("AI_REVIEW_ENDPOINT", ""), + AIReviewAPIKey: getEnv("AI_REVIEW_API_KEY", ""), + AIReviewModel: getEnv("AI_REVIEW_MODEL", ""), + } +} + +func (c *Config) IsDevelopment() bool { + return strings.ToLower(c.Environment) == "development" +} + +func (c *Config) IsProduction() bool { + return strings.ToLower(c.Environment) == "production" +} + +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + + return fallback +} diff --git a/Backend/internal/database/postgres.go b/Backend/internal/database/postgres.go new file mode 100644 index 0000000..717cf6f --- /dev/null +++ b/Backend/internal/database/postgres.go @@ -0,0 +1,65 @@ +// Path: Backend/internal/database/postgres.go + +package database + +import ( + "context" + "time" + + "boostai-backend/db" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func NewPostgres(databaseURL string) (*DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + + config.MaxConns = 25 + config.MinConns = 5 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + return &DB{Pool: pool}, nil +} + +func (d *DB) Migrate() error { + sqlDB := stdlib.OpenDBFromPool(d.Pool) + + goose.SetBaseFS(db.Migrations) + + if err := goose.SetDialect("postgres"); err != nil { + return err + } + + return goose.Up(sqlDB, "migrations") +} + +func (d *DB) Close() { + d.Pool.Close() +} + +func (d *DB) Health(ctx context.Context) error { + return d.Pool.Ping(ctx) +} diff --git a/Backend/internal/handlers/api/answers/handler.go b/Backend/internal/handlers/api/answers/handler.go new file mode 100644 index 0000000..d54c2d0 --- /dev/null +++ b/Backend/internal/handlers/api/answers/handler.go @@ -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), " ") +} diff --git a/Backend/internal/handlers/api/answers/routes.go b/Backend/internal/handlers/api/answers/routes.go new file mode 100644 index 0000000..f10d6d7 --- /dev/null +++ b/Backend/internal/handlers/api/answers/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/api/assignments/handler.go b/Backend/internal/handlers/api/assignments/handler.go new file mode 100644 index 0000000..a61778e --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler.go @@ -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) +} diff --git a/Backend/internal/handlers/api/assignments/handler_generation.go b/Backend/internal/handlers/api/assignments/handler_generation.go new file mode 100644 index 0000000..0cc7d7a --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler_generation.go @@ -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 +} diff --git a/Backend/internal/handlers/api/assignments/handler_helpers.go b/Backend/internal/handlers/api/assignments/handler_helpers.go new file mode 100644 index 0000000..0364c9f --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler_helpers.go @@ -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 +} diff --git a/Backend/internal/handlers/api/assignments/handler_types.go b/Backend/internal/handlers/api/assignments/handler_types.go new file mode 100644 index 0000000..8833e73 --- /dev/null +++ b/Backend/internal/handlers/api/assignments/handler_types.go @@ -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"` +} diff --git a/Backend/internal/handlers/api/assignments/routes.go b/Backend/internal/handlers/api/assignments/routes.go new file mode 100644 index 0000000..91fc371 --- /dev/null +++ b/Backend/internal/handlers/api/assignments/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/api/classrooms/handler.go b/Backend/internal/handlers/api/classrooms/handler.go new file mode 100644 index 0000000..fda1c0d --- /dev/null +++ b/Backend/internal/handlers/api/classrooms/handler.go @@ -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), + } +} diff --git a/Backend/internal/handlers/api/classrooms/routes.go b/Backend/internal/handlers/api/classrooms/routes.go new file mode 100644 index 0000000..762b5ee --- /dev/null +++ b/Backend/internal/handlers/api/classrooms/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/api/handler.go b/Backend/internal/handlers/api/handler.go new file mode 100644 index 0000000..d8a7d92 --- /dev/null +++ b/Backend/internal/handlers/api/handler.go @@ -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), + } +} diff --git a/Backend/internal/handlers/api/messages/handler.go b/Backend/internal/handlers/api/messages/handler.go new file mode 100644 index 0000000..5c045bf --- /dev/null +++ b/Backend/internal/handlers/api/messages/handler.go @@ -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 © +} diff --git a/Backend/internal/handlers/api/messages/routes.go b/Backend/internal/handlers/api/messages/routes.go new file mode 100644 index 0000000..01e97c3 --- /dev/null +++ b/Backend/internal/handlers/api/messages/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/api/questions/handler.go b/Backend/internal/handlers/api/questions/handler.go new file mode 100644 index 0000000..22cf0e0 --- /dev/null +++ b/Backend/internal/handlers/api/questions/handler.go @@ -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 +} diff --git a/Backend/internal/handlers/api/questions/handler_test.go b/Backend/internal/handlers/api/questions/handler_test.go new file mode 100644 index 0000000..a24b512 --- /dev/null +++ b/Backend/internal/handlers/api/questions/handler_test.go @@ -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 +} diff --git a/Backend/internal/handlers/api/questions/routes.go b/Backend/internal/handlers/api/questions/routes.go new file mode 100644 index 0000000..111c297 --- /dev/null +++ b/Backend/internal/handlers/api/questions/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/api/routes.go b/Backend/internal/handlers/api/routes.go new file mode 100644 index 0000000..6293382 --- /dev/null +++ b/Backend/internal/handlers/api/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/api/shared/shared.go b/Backend/internal/handlers/api/shared/shared.go new file mode 100644 index 0000000..64d0292 --- /dev/null +++ b/Backend/internal/handlers/api/shared/shared.go @@ -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 ×tamp +} + +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 +} diff --git a/Backend/internal/handlers/api/users/handler.go b/Backend/internal/handlers/api/users/handler.go new file mode 100644 index 0000000..15210f2 --- /dev/null +++ b/Backend/internal/handlers/api/users/handler.go @@ -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 +} diff --git a/Backend/internal/handlers/api/users/routes.go b/Backend/internal/handlers/api/users/routes.go new file mode 100644 index 0000000..ac9b50d --- /dev/null +++ b/Backend/internal/handlers/api/users/routes.go @@ -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) +} diff --git a/Backend/internal/handlers/web/auth/auth.go b/Backend/internal/handlers/web/auth/auth.go new file mode 100644 index 0000000..6929914 --- /dev/null +++ b/Backend/internal/handlers/web/auth/auth.go @@ -0,0 +1,384 @@ +// Path: Backend/internal/handlers/auth/auth.go + +package auth + +import ( + "context" + "errors" + "strings" + "time" + + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "boostai-backend/internal/http/respond" + authmw "boostai-backend/internal/middleware" + "boostai-backend/internal/sqlc" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +const authQueryTimeout = 5 * time.Second + +type Handler struct { + db *database.DB + queries *sqlc.Queries + cfg *config.Config + auth *authmw.AuthMiddleware +} + +type authProfileResponse struct { + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` + Bio *string `json:"bio"` + Timezone *string `json:"timezone"` + Locale *string `json:"locale"` + GradeLevel *string `json:"grade_level"` + LearningGoal *string `json:"learning_goal"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +type authUserResponse 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"` + Profile authProfileResponse `json:"profile"` +} + +type authResponse struct { + User authUserResponse `json:"user"` +} + +type registerRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + RememberMe bool `json:"remember_me"` +} + +type updateProfileRequest struct { + FullName *string `json:"full_name"` + PreferredName *string `json:"preferred_name"` + ProfileIconURL *string `json:"profile_icon_url"` + Headline *string `json:"headline"` + Bio *string `json:"bio"` + Timezone *string `json:"timezone"` + Locale *string `json:"locale"` + GradeLevel *string `json:"grade_level"` + LearningGoal *string `json:"learning_goal"` +} + +func NewHandler(cfg *config.Config, db *database.DB, auth *authmw.AuthMiddleware) *Handler { + return &Handler{db: db, queries: sqlc.New(db.Pool), cfg: cfg, auth: auth} +} + +func (h *Handler) RegisterUser(c *fiber.Ctx) error { + var req registerRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + fullName := strings.TrimSpace(strings.TrimSpace(req.FirstName) + " " + strings.TrimSpace(req.LastName)) + if fullName == "" || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "first_name, last_name, email, and password are required") + } + + role := sqlc.UserRoleStudent + if strings.TrimSpace(req.Role) != "" { + role = sqlc.UserRole(strings.TrimSpace(req.Role)) + } + if role != sqlc.UserRoleStudent && role != sqlc.UserRoleTeacher { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "role must be student or teacher") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to secure password") + } + + ctx, cancel := withTimeout() + defer cancel() + + user, err := h.queries.CreateUser(ctx, sqlc.CreateUserParams{ + Email: strings.TrimSpace(strings.ToLower(req.Email)), + PasswordHash: pgtype.Text{String: string(hashedPassword), Valid: true}, + Role: role, + FullName: fullName, + }) + if err != nil { + return respond.DatabaseError(c, err) + } + + if err := h.setSessionCookie(c, user, false); err != nil { + return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to create session") + } + + authUser, err := h.queries.GetAuthUserByID(ctx, user.ID) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.Status(fiber.StatusCreated).JSON(authResponse{User: mapAuthUserByID(authUser)}) +} + +func (h *Handler) Login(c *fiber.Ctx) error { + var req loginRequest + 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.Password) == "" { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "email and password are required") + } + + ctx, cancel := withTimeout() + defer cancel() + + user, err := h.queries.GetUserByEmail(ctx, strings.TrimSpace(strings.ToLower(req.Email))) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password") + } + return respond.DatabaseError(c, err) + } + + if !user.IsActive || !user.PasswordHash.Valid { + return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash.String), []byte(req.Password)); err != nil { + return respond.Error(c, fiber.StatusUnauthorized, "invalid_credentials", "Invalid email or password") + } + + if err := h.setSessionCookie(c, user, req.RememberMe); err != nil { + return respond.Error(c, fiber.StatusInternalServerError, "auth_error", "Unable to create session") + } + + authUser, err := h.queries.GetAuthUserByID(ctx, user.ID) + if err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(authResponse{User: mapAuthUserByID(authUser)}) +} + +func (h *Handler) Me(c *fiber.Ctx) error { + userID := authmw.CurrentUserID(c) + if userID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required") + } + + ctx, cancel := withTimeout() + defer cancel() + + user, err := h.queries.GetAuthUserByID(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "User not found") + } + return respond.DatabaseError(c, err) + } + + return c.JSON(authResponse{User: mapAuthUserByID(user)}) +} + +func (h *Handler) UpdateMe(c *fiber.Ctx) error { + userID := authmw.CurrentUserID(c) + if userID == 0 { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required") + } + + var req updateProfileRequest + if err := c.BodyParser(&req); err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") + } + + ctx, cancel := 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) + current, err := queries.GetAuthUserByID(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "User not found") + } + return respond.DatabaseError(c, err) + } + + fullName, err := mergeRequiredString(current.UserFullName, req.FullName, "full_name") + if err != nil { + return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error()) + } + + if fullName != current.UserFullName { + if _, err := queries.UpdateUserFullName(ctx, sqlc.UpdateUserFullNameParams{ID: userID, FullName: fullName}); err != nil { + return respond.DatabaseError(c, err) + } + } + + if _, err := queries.UpsertUserProfile(ctx, sqlc.UpsertUserProfileParams{ + UserID: userID, + PreferredName: mergeNullableText(current.PreferredName, req.PreferredName), + ProfileIconUrl: mergeNullableText(current.ProfileIconUrl, req.ProfileIconURL), + Headline: mergeNullableText(current.Headline, req.Headline), + Bio: mergeNullableText(current.Bio, req.Bio), + Timezone: mergeNullableText(current.Timezone, req.Timezone), + Locale: mergeNullableText(current.Locale, req.Locale), + GradeLevel: mergeNullableText(current.GradeLevel, req.GradeLevel), + LearningGoal: mergeNullableText(current.LearningGoal, req.LearningGoal), + }); err != nil { + return respond.DatabaseError(c, err) + } + + updated, err := queries.GetAuthUserByID(ctx, userID) + if err != nil { + return respond.DatabaseError(c, err) + } + + if err := tx.Commit(ctx); err != nil { + return respond.DatabaseError(c, err) + } + + return c.JSON(authResponse{User: mapAuthUserByID(updated)}) +} + +func (h *Handler) Logout(c *fiber.Ctx) error { + h.clearSessionCookie(c) + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *Handler) setSessionCookie(c *fiber.Ctx, user sqlc.User, rememberMe bool) error { + ttl := 24 * time.Hour + if rememberMe { + ttl = 30 * 24 * time.Hour + } + + token, err := h.auth.CreateToken(user.ID, user.Role, user.Email, ttl) + if err != nil { + return err + } + + c.Cookie(&fiber.Cookie{ + Name: h.cfg.SessionCookie, + Value: token, + HTTPOnly: true, + Secure: h.cfg.IsProduction(), + SameSite: fiber.CookieSameSiteLaxMode, + Path: "/", + Expires: time.Now().UTC().Add(ttl), + }) + + return nil +} + +func (h *Handler) clearSessionCookie(c *fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: h.cfg.SessionCookie, + Value: "", + HTTPOnly: true, + Secure: h.cfg.IsProduction(), + SameSite: fiber.CookieSameSiteLaxMode, + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} + +func mapAuthUserByID(user sqlc.GetAuthUserByIDRow) authUserResponse { + return authUserResponse{ + ID: user.UserID, + Email: user.UserEmail, + Role: string(user.UserRole), + FullName: user.UserFullName, + IsActive: user.UserIsActive, + CreatedAt: timePointer(user.UserCreatedAt), + UpdatedAt: timePointer(user.UserUpdatedAt), + Profile: mapAuthProfile(user.PreferredName, user.ProfileIconUrl, user.Headline, user.Bio, user.Timezone, user.Locale, user.GradeLevel, user.LearningGoal, user.ProfileCreatedAt, user.ProfileUpdatedAt), + } +} + +func mapAuthProfile(preferredName, profileIconURL, headline, bio, timezone, locale, gradeLevel, learningGoal pgtype.Text, createdAt, updatedAt pgtype.Timestamptz) authProfileResponse { + return authProfileResponse{ + PreferredName: textPointer(preferredName), + ProfileIconURL: textPointer(profileIconURL), + Headline: textPointer(headline), + Bio: textPointer(bio), + Timezone: textPointer(timezone), + Locale: textPointer(locale), + GradeLevel: textPointer(gradeLevel), + LearningGoal: textPointer(learningGoal), + CreatedAt: timePointer(createdAt), + UpdatedAt: timePointer(updatedAt), + } +} + +func withTimeout() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), authQueryTimeout) +} + +func mergeRequiredString(current string, input *string, fieldName string) (string, error) { + if input == nil { + return current, nil + } + + value := strings.TrimSpace(*input) + if value == "" { + return "", errors.New(fieldName + " cannot be empty") + } + + return value, nil +} + +func mergeNullableText(current pgtype.Text, input *string) pgtype.Text { + if input == nil { + return current + } + + value := strings.TrimSpace(*input) + if value == "" { + return pgtype.Text{} + } + + return pgtype.Text{String: value, Valid: true} +} + +func textPointer(value pgtype.Text) *string { + if !value.Valid { + return nil + } + + text := value.String + return &text +} + +func timePointer(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + + timestamp := value.Time.UTC() + return ×tamp +} diff --git a/Backend/internal/handlers/web/health/health.go b/Backend/internal/handlers/web/health/health.go new file mode 100644 index 0000000..43f9c45 --- /dev/null +++ b/Backend/internal/handlers/web/health/health.go @@ -0,0 +1,46 @@ +// Path: Backend/internal/handlers/health/health.go + +package health + +import ( + "context" + "time" + + "boostai-backend/internal/database" + + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + environment string + db *database.DB +} + +func NewHandler(environment string, db *database.DB) *Handler { + return &Handler{environment: environment, db: db} +} + +func (h *Handler) Check(c *fiber.Ctx) error { + status := "healthy" + databaseStatus := "up" + httpStatus := fiber.StatusOK + + if h.db != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := h.db.Health(ctx); err != nil { + status = "unhealthy" + databaseStatus = "down" + httpStatus = fiber.StatusServiceUnavailable + } + } + + return c.Status(httpStatus).JSON(fiber.Map{ + "status": status, + "service": "boostai-backend", + "environment": h.environment, + "database": databaseStatus, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/Backend/internal/handlers/web/root/root.go b/Backend/internal/handlers/web/root/root.go new file mode 100644 index 0000000..a11316b --- /dev/null +++ b/Backend/internal/handlers/web/root/root.go @@ -0,0 +1,16 @@ +package root + +import "github.com/gofiber/fiber/v2" + +type Handler struct{} + +func NewHandler() *Handler { + return &Handler{} +} + +func (h *Handler) Index(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "name": "BoostAI Backend", + "status": "ok", + }) +} diff --git a/Backend/internal/http/params/params.go b/Backend/internal/http/params/params.go new file mode 100644 index 0000000..de9063d --- /dev/null +++ b/Backend/internal/http/params/params.go @@ -0,0 +1,21 @@ +// Path: Backend/internal/http/params/params.go + +package params + +import ( + "boostai-backend/internal/http/respond" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +func Int64PathParam(c *fiber.Ctx, name string) (int64, error) { + value := strings.TrimSpace(c.Params(name)) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid path parameter: "+name) + } + + return parsed, nil +} diff --git a/Backend/internal/http/respond/respond.go b/Backend/internal/http/respond/respond.go new file mode 100644 index 0000000..77247c8 --- /dev/null +++ b/Backend/internal/http/respond/respond.go @@ -0,0 +1,18 @@ +// Path: Backend/internal/http/respond/respond.go + +package respond + +import "github.com/gofiber/fiber/v2" + +type ErrorBody struct { + Error string `json:"error"` + Message string `json:"message"` +} + +func Error(c *fiber.Ctx, status int, code string, message string) error { + return c.Status(status).JSON(ErrorBody{Error: code, Message: message}) +} + +func DatabaseError(c *fiber.Ctx, err error) error { + return Error(c, fiber.StatusInternalServerError, "database_error", err.Error()) +} diff --git a/Backend/internal/middleware/auth.go b/Backend/internal/middleware/auth.go new file mode 100644 index 0000000..f118cd1 --- /dev/null +++ b/Backend/internal/middleware/auth.go @@ -0,0 +1,193 @@ +// Path: Backend/internal/middleware/auth.go + +package middleware + +import ( + "errors" + "strconv" + "strings" + "time" + + "boostai-backend/internal/config" + "boostai-backend/internal/sqlc" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +const DefaultTokenTTL = 7 * 24 * time.Hour + +type AuthClaims struct { + UserID int64 `json:"user_id"` + Role sqlc.UserRole `json:"role"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +type AuthMiddleware struct { + cfg *config.Config +} + +func NewAuthMiddleware(cfg *config.Config) *AuthMiddleware { + return &AuthMiddleware{cfg: cfg} +} + +func (m *AuthMiddleware) CreateToken(userID int64, role sqlc.UserRole, email string, ttl time.Duration) (string, error) { + if ttl <= 0 { + ttl = DefaultTokenTTL + } + + now := time.Now().UTC() + claims := AuthClaims{ + UserID: userID, + Role: role, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: email, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.cfg.JWTSecret)) +} + +func (m *AuthMiddleware) RequireAuth() fiber.Handler { + return func(c *fiber.Ctx) error { + claims, err := m.parseClaims(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized", + "message": "Authentication required", + }) + } + + c.Locals("auth.user_id", claims.UserID) + c.Locals("auth.role", claims.Role) + c.Locals("auth.email", claims.Email) + return c.Next() + } +} + +func (m *AuthMiddleware) RequireTeacher() fiber.Handler { + return func(c *fiber.Ctx) error { + if CurrentUserRole(c) != sqlc.UserRoleTeacher { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "Teacher access required", + }) + } + + return c.Next() + } +} + +func (m *AuthMiddleware) RequireTeacherSelf(param string) fiber.Handler { + return func(c *fiber.Ctx) error { + if CurrentUserRole(c) != sqlc.UserRoleTeacher { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "Teacher access required", + }) + } + + paramID, err := parsePositiveParam(c, param) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_request", + "message": "Invalid path parameter: " + param, + }) + } + + if CurrentUserID(c) != paramID { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "You can only access your own teacher resources", + }) + } + + return c.Next() + } +} + +func (m *AuthMiddleware) RequireStudentSelfOrTeacher(param string) fiber.Handler { + return func(c *fiber.Ctx) error { + paramID, err := parsePositiveParam(c, param) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid_request", + "message": "Invalid path parameter: " + param, + }) + } + + if CurrentUserRole(c) == sqlc.UserRoleTeacher || CurrentUserID(c) == paramID { + return c.Next() + } + + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden", + "message": "You can only access your own student resources", + }) + } +} + +func CurrentUserID(c *fiber.Ctx) int64 { + value, ok := c.Locals("auth.user_id").(int64) + if !ok { + return 0 + } + + return value +} + +func CurrentUserRole(c *fiber.Ctx) sqlc.UserRole { + value, ok := c.Locals("auth.role").(sqlc.UserRole) + if !ok { + return "" + } + + return value +} + +func (m *AuthMiddleware) parseClaims(c *fiber.Ctx) (*AuthClaims, error) { + tokenValue := strings.TrimSpace(c.Cookies(m.cfg.SessionCookie)) + if tokenValue == "" { + authorization := strings.TrimSpace(c.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { + tokenValue = strings.TrimSpace(authorization[7:]) + } + } + + if tokenValue == "" { + return nil, errors.New("missing token") + } + + parsed, err := jwt.ParseWithClaims(tokenValue, &AuthClaims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method") + } + + return []byte(m.cfg.JWTSecret), nil + }) + if err != nil { + return nil, err + } + + claims, ok := parsed.Claims.(*AuthClaims) + if !ok || !parsed.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} + +func parsePositiveParam(c *fiber.Ctx, param string) (int64, error) { + value := strings.TrimSpace(c.Params(param)) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, errors.New("invalid param") + } + + return parsed, nil +} diff --git a/Backend/internal/questiongen/service.go b/Backend/internal/questiongen/service.go new file mode 100644 index 0000000..cbea381 --- /dev/null +++ b/Backend/internal/questiongen/service.go @@ -0,0 +1,634 @@ +package questiongen + +import ( + "fmt" + "math/rand" + "sort" + "strings" + "time" + + "boostai-backend/internal/sqlc" +) + +type Service struct{} + +type GenerateParams struct { + Topic sqlc.QuestionTopic + Difficulty sqlc.QuestionDifficulty + Count int + Seed int64 +} + +type GeneratedQuestion struct { + Title string + Prompt string + CorrectAnswer string + WorkedSolution []string + Tags []string +} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Generate(params GenerateParams) ([]GeneratedQuestion, int64, error) { + count := params.Count + if count <= 0 { + count = 1 + } + + seed := params.Seed + if seed == 0 { + seed = time.Now().UnixNano() + } + + rng := rand.New(rand.NewSource(seed)) + items := make([]GeneratedQuestion, 0, count) + for i := 0; i < count; i++ { + question, err := s.generateOne(rng, params.Topic, params.Difficulty) + if err != nil { + return nil, seed, err + } + items = append(items, question) + } + + return items, seed, nil +} + +func (s *Service) generateOne(rng *rand.Rand, topic sqlc.QuestionTopic, difficulty sqlc.QuestionDifficulty) (GeneratedQuestion, error) { + switch topic { + case sqlc.QuestionTopicPlaceValue: + return generatePlaceValueQuestion(rng, difficulty), nil + case sqlc.QuestionTopicArithmetic: + return generateArithmeticQuestion(rng, difficulty), nil + case sqlc.QuestionTopicNegativeNumbers: + return generateNegativeNumbersQuestion(rng, difficulty), nil + case sqlc.QuestionTopicBidmas: + return generateBidmasQuestion(rng, difficulty), nil + case sqlc.QuestionTopicFractions: + return generateFractionsQuestion(rng, difficulty), nil + case sqlc.QuestionTopicAlgebra: + return generateAlgebraQuestion(rng, difficulty), nil + case sqlc.QuestionTopicGeometry: + return generateGeometryQuestion(rng, difficulty), nil + case sqlc.QuestionTopicData: + return generateDataQuestion(rng, difficulty), nil + default: + return GeneratedQuestion{}, fmt.Errorf("unsupported topic: %s", topic) + } +} + +// Future word_problem work should not just bolt a `word_problem` tag onto an already-built +// abstract question. Each topic generator should eventually expose dedicated word-problem +// template families so the RNG chooses both the maths structure and a fitting real-world context +// together. That will keep prompts, answers, and worked steps consistent instead of doing a late +// text rewrite after the numbers are chosen. +func buildGeneratedTags(topic sqlc.QuestionTopic, difficulty sqlc.QuestionDifficulty, extra ...string) []string { + tags := []string{string(topic), string(difficulty), "rng_generated"} + tags = append(tags, extra...) + + unique := make(map[string]struct{}, len(tags)) + normalized := make([]string, 0, len(tags)) + for _, tag := range tags { + value := strings.ToLower(strings.TrimSpace(tag)) + if value == "" { + continue + } + if _, exists := unique[value]; exists { + continue + } + unique[value] = struct{}{} + normalized = append(normalized, value) + } + sort.Strings(normalized) + return normalized +} + +func generatePlaceValueQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + var digits, targetIndex int + switch difficulty { + case sqlc.QuestionDifficultyEasy: + digits = 2 + targetIndex = randomInt(rng, 0, 1) + case sqlc.QuestionDifficultyMedium: + digits = 3 + targetIndex = randomInt(rng, 0, 2) + default: + digits = randomInt(rng, 4, 5) + targetIndex = randomInt(rng, 1, digits-1) + } + + numberDigits := make([]int, digits) + numberDigits[0] = randomInt(rng, 1, 9) + for i := 1; i < digits; i++ { + numberDigits[i] = randomInt(rng, 0, 9) + } + number := digitsToInt(numberDigits) + digit := numberDigits[targetIndex] + placePower := digits - targetIndex - 1 + placeValue := digit + for i := 0; i < placePower; i++ { + placeValue *= 10 + } + + placeName := placeNameFromPower(placePower) + prompt := fmt.Sprintf("What is the value of the digit %d in %d?", digit, number) + return GeneratedQuestion{ + Title: fmt.Sprintf("%s Place Value", strings.Title(string(difficulty))), + Prompt: prompt, + CorrectAnswer: fmt.Sprintf("%d", placeValue), + WorkedSolution: []string{ + fmt.Sprintf("In %d, the digit %d is in the %s place.", number, digit, placeName), + fmt.Sprintf("So its value is %d.", placeValue), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicPlaceValue, difficulty, placeName), + } +} + +func generateArithmeticQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + a := randomInt(rng, 1, 9) + b := randomInt(rng, 1, 9) + if rng.Intn(2) == 0 { + return GeneratedQuestion{ + Title: "Easy Addition", + Prompt: fmt.Sprintf("Calculate %d + %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a+b), + WorkedSolution: []string{ + fmt.Sprintf("Add the ones: %d + %d = %d.", a, b, a+b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "addition", "single_digit"), + } + } + + if a < b { + a, b = b, a + } + return GeneratedQuestion{ + Title: "Easy Subtraction", + Prompt: fmt.Sprintf("Calculate %d - %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a-b), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from %d.", b, a), + fmt.Sprintf("%d - %d = %d.", a, b, a-b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "subtraction", "single_digit"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + if rng.Intn(2) == 0 { + a := randomInt(rng, 10, 99) + b := randomInt(rng, 10, 99) + return GeneratedQuestion{ + Title: "Medium Addition", + Prompt: fmt.Sprintf("Work out %d + %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a+b), + WorkedSolution: []string{ + fmt.Sprintf("Add the numbers together: %d + %d = %d.", a, b, a+b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "addition", "two_digit"), + } + } + + a := randomInt(rng, 2, 12) + b := randomInt(rng, 2, 12) + return GeneratedQuestion{ + Title: "Medium Multiplication", + Prompt: fmt.Sprintf("Calculate %d × %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a*b), + WorkedSolution: []string{ + fmt.Sprintf("Use multiplication facts: %d × %d = %d.", a, b, a*b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "multiplication", "times_tables"), + } + } + + if rng.Intn(2) == 0 { + divisor := randomInt(rng, 3, 12) + quotient := randomInt(rng, 4, 12) + dividend := divisor * quotient + return GeneratedQuestion{ + Title: "Hard Division", + Prompt: fmt.Sprintf("Calculate %d ÷ %d.", dividend, divisor), + CorrectAnswer: fmt.Sprintf("%d", quotient), + WorkedSolution: []string{ + fmt.Sprintf("Use the inverse of multiplication: %d × %d = %d.", divisor, quotient, dividend), + fmt.Sprintf("So %d ÷ %d = %d.", dividend, divisor, quotient), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "division", "inverse_operations"), + } + } + + a := randomInt(rng, 20, 99) + b := randomInt(rng, 11, 49) + return GeneratedQuestion{ + Title: "Hard Subtraction", + Prompt: fmt.Sprintf("Work out %d - %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", a-b), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from %d carefully, using column subtraction if needed.", b, a), + fmt.Sprintf("%d - %d = %d.", a, b, a-b), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicArithmetic, difficulty, "subtraction", "column_method"), + } +} + +func generateNegativeNumbersQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + a := randomInt(rng, -9, 9) + b := randomInt(rng, -9, 9) + result := a + b + return GeneratedQuestion{ + Title: "Easy Negative Numbers", + Prompt: fmt.Sprintf("Calculate %d + %d.", a, b), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Start at %d on the number line.", a), + fmt.Sprintf("Move %d steps to get %d.", b, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "addition"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + a := randomInt(rng, -20, 20) + b := randomInt(rng, -20, 20) + result := a - b + return GeneratedQuestion{ + Title: "Medium Negative Numbers", + Prompt: fmt.Sprintf("Calculate %d - (%d).", a, b), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Subtracting %d is the same as adding %d.", b, -b), + fmt.Sprintf("So %d - (%d) = %d + %d = %d.", a, b, a, -b, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "subtraction"), + } + } + + a := randomInt(rng, -30, 30) + b := randomInt(rng, -30, 30) + c := randomInt(rng, -15, 15) + result := a - b + c + prompt := fmt.Sprintf("Calculate %d - (%d) + %d.", a, b, c) + return GeneratedQuestion{ + Title: "Hard Negative Numbers", + Prompt: prompt, + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("First change subtraction of a negative: %d - (%d) = %d + %d.", a, b, a, -b), + fmt.Sprintf("Then add %d to get %d.", c, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicNegativeNumbers, difficulty, "multi_step"), + } +} + +func generateBidmasQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + a := randomInt(rng, 1, 20) + b := randomInt(rng, 2, 9) + c := randomInt(rng, 2, 9) + result := a + b*c + return GeneratedQuestion{ + Title: "Easy BIDMAS", + Prompt: fmt.Sprintf("Work out %d + %d × %d.", a, b, c), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Do multiplication first: %d × %d = %d.", b, c, b*c), + fmt.Sprintf("Then add %d + %d = %d.", a, b*c, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "order_of_operations"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + a := randomInt(rng, 2, 12) + b := randomInt(rng, 3, 12) + c := randomInt(rng, 2, 10) + result := (a + b) * c + return GeneratedQuestion{ + Title: "Medium BIDMAS", + Prompt: fmt.Sprintf("Work out (%d + %d) × %d.", a, b, c), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Work inside brackets first: %d + %d = %d.", a, b, a+b), + fmt.Sprintf("Then multiply %d × %d = %d.", a+b, c, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "brackets"), + } + } + + a := randomInt(rng, 2, 12) + b := randomInt(rng, 2, 6) + c := randomInt(rng, 2, 12) + d := randomInt(rng, 2, 5) + left := a * b + right := c * d + result := left + right + return GeneratedQuestion{ + Title: "Hard BIDMAS", + Prompt: fmt.Sprintf("Work out %d × %d + %d × %d.", a, b, c, d), + CorrectAnswer: fmt.Sprintf("%d", result), + WorkedSolution: []string{ + fmt.Sprintf("Complete each multiplication first: %d × %d = %d and %d × %d = %d.", a, b, left, c, d, right), + fmt.Sprintf("Then add %d + %d = %d.", left, right, result), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicBidmas, difficulty, "multiple_operations"), + } +} + +func generateFractionsQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + denominator := randomInt(rng, 2, 9) + numerator := randomInt(rng, 1, denominator-1) + maxMultiplier := 9 / denominator + if maxMultiplier < 1 { + maxMultiplier = 1 + } + multiplier := randomInt(rng, 1, maxMultiplier) + prompt := fmt.Sprintf("What is %d/%d of %d?", numerator, denominator, denominator*multiplier) + answer := numerator * multiplier + return GeneratedQuestion{ + Title: "Easy Fractions", + Prompt: prompt, + CorrectAnswer: fmt.Sprintf("%d", answer), + WorkedSolution: []string{ + fmt.Sprintf("Find one part first: %d ÷ %d = %d.", denominator*multiplier, denominator, multiplier), + fmt.Sprintf("Then take %d parts: %d × %d = %d.", numerator, multiplier, numerator, answer), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "single_digit", "fraction_of_amount"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + denominator := randomInt(rng, 3, 10) + a := randomInt(rng, 1, denominator-1) + b := randomInt(rng, 1, denominator-1) + resultN, resultD := simplifyFraction(a+b, denominator) + return GeneratedQuestion{ + Title: "Medium Fractions", + Prompt: fmt.Sprintf("Work out %d/%d + %d/%d. Give your answer in simplest form.", a, denominator, b, denominator), + CorrectAnswer: formatFraction(resultN, resultD), + WorkedSolution: []string{ + fmt.Sprintf("The denominators are the same, so add the numerators: %d + %d = %d.", a, b, a+b), + fmt.Sprintf("This gives %d/%d.", a+b, denominator), + fmt.Sprintf("Simplify to %s.", formatFraction(resultN, resultD)), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "addition", "simplify"), + } + } + + aN := randomInt(rng, 1, 8) + aD := randomInt(rng, 2, 9) + bN := randomInt(rng, 1, 8) + bD := randomInt(rng, 2, 9) + resultN, resultD := simplifyFraction(aN*bN, aD*bD) + return GeneratedQuestion{ + Title: "Hard Fractions", + Prompt: fmt.Sprintf("Work out %d/%d × %d/%d. Give your answer in simplest form.", aN, aD, bN, bD), + CorrectAnswer: formatFraction(resultN, resultD), + WorkedSolution: []string{ + fmt.Sprintf("Multiply the numerators: %d × %d = %d.", aN, bN, aN*bN), + fmt.Sprintf("Multiply the denominators: %d × %d = %d.", aD, bD, aD*bD), + fmt.Sprintf("This gives %d/%d, which simplifies to %s.", aN*bN, aD*bD, formatFraction(resultN, resultD)), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicFractions, difficulty, "multiplication", "simplify"), + } +} + +func generateAlgebraQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + x := randomInt(rng, 1, 12) + a := randomInt(rng, 1, 12) + b := x + a + return GeneratedQuestion{ + Title: "Easy Algebra", + Prompt: fmt.Sprintf("Solve x + %d = %d.", a, b), + CorrectAnswer: fmt.Sprintf("x = %d", x), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from both sides.", a), + fmt.Sprintf("x = %d - %d = %d.", b, a, x), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "one_step"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + x := randomInt(rng, 2, 12) + a := randomInt(rng, 2, 9) + b := randomInt(rng, 1, 12) + c := a*x + b + return GeneratedQuestion{ + Title: "Medium Algebra", + Prompt: fmt.Sprintf("Solve %dx + %d = %d.", a, b, c), + CorrectAnswer: fmt.Sprintf("x = %d", x), + WorkedSolution: []string{ + fmt.Sprintf("Subtract %d from both sides to get %dx = %d.", b, a, c-b), + fmt.Sprintf("Divide both sides by %d, so x = %d.", a, x), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "two_step"), + } + } + + x := randomInt(rng, -6, 12) + a := randomInt(rng, 2, 6) + b := randomInt(rng, 1, 8) + c := randomInt(rng, 2, 6) + d := a*(x+b) - c + return GeneratedQuestion{ + Title: "Hard Algebra", + Prompt: fmt.Sprintf("Solve %d(x + %d) - %d = %d.", a, b, c, d), + CorrectAnswer: fmt.Sprintf("x = %d", x), + WorkedSolution: []string{ + fmt.Sprintf("Add %d to both sides: %d(x + %d) = %d.", c, a, b, d+c), + fmt.Sprintf("Divide by %d: x + %d = %d.", a, b, x+b), + fmt.Sprintf("Subtract %d, so x = %d.", b, x), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicAlgebra, difficulty, "brackets", "multi_step"), + } +} + +func generateGeometryQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + side := randomInt(rng, 2, 9) + perimeter := side * 4 + return GeneratedQuestion{ + Title: "Easy Geometry", + Prompt: fmt.Sprintf("A square has side length %d cm. What is its perimeter?", side), + CorrectAnswer: fmt.Sprintf("%d cm", perimeter), + WorkedSolution: []string{ + fmt.Sprintf("A square has 4 equal sides, so calculate 4 × %d.", side), + fmt.Sprintf("The perimeter is %d cm.", perimeter), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "perimeter", "square"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + length := randomInt(rng, 4, 15) + width := randomInt(rng, 3, 12) + area := length * width + return GeneratedQuestion{ + Title: "Medium Geometry", + Prompt: fmt.Sprintf("A rectangle has length %d cm and width %d cm. What is its area?", length, width), + CorrectAnswer: fmt.Sprintf("%d cm²", area), + WorkedSolution: []string{ + fmt.Sprintf("Area of a rectangle = length × width."), + fmt.Sprintf("%d × %d = %d, so the area is %d cm².", length, width, area, area), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "area", "rectangle"), + } + } + + a := randomInt(rng, 20, 100) + b := randomInt(rng, 20, 100) + missing := 180 - a - b + return GeneratedQuestion{ + Title: "Hard Geometry", + Prompt: fmt.Sprintf("Two angles in a triangle are %d° and %d°. Find the third angle.", a, b), + CorrectAnswer: fmt.Sprintf("%d°", missing), + WorkedSolution: []string{ + fmt.Sprintf("Angles in a triangle add to 180°."), + fmt.Sprintf("First add the known angles: %d + %d = %d.", a, b, a+b), + fmt.Sprintf("Then calculate 180 - %d = %d°.", a+b, missing), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicGeometry, difficulty, "angles", "triangle"), + } +} + +func generateDataQuestion(rng *rand.Rand, difficulty sqlc.QuestionDifficulty) GeneratedQuestion { + if difficulty == sqlc.QuestionDifficultyEasy { + values := sortedRandomValues(rng, 5, 1, 9) + median := values[len(values)/2] + return GeneratedQuestion{ + Title: "Easy Data", + Prompt: fmt.Sprintf("Find the median of these numbers: %s.", joinInts(values)), + CorrectAnswer: fmt.Sprintf("%d", median), + WorkedSolution: []string{ + fmt.Sprintf("Put the numbers in order: %s.", joinInts(values)), + fmt.Sprintf("The middle value is %d, so the median is %d.", median, median), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "median"), + } + } + + if difficulty == sqlc.QuestionDifficultyMedium { + values := sortedRandomValues(rng, 5, 2, 20) + sum := 0 + for _, value := range values { + sum += value + } + mean := sum / len(values) + adjustment := sum % len(values) + if adjustment != 0 { + values[len(values)-1] += len(values) - adjustment + sort.Ints(values) + sum = 0 + for _, value := range values { + sum += value + } + mean = sum / len(values) + } + return GeneratedQuestion{ + Title: "Medium Data", + Prompt: fmt.Sprintf("Find the mean of these numbers: %s.", joinInts(values)), + CorrectAnswer: fmt.Sprintf("%d", mean), + WorkedSolution: []string{ + fmt.Sprintf("Add the numbers: the total is %d.", sum), + fmt.Sprintf("Divide by %d: %d ÷ %d = %d.", len(values), sum, len(values), mean), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "mean"), + } + } + + values := sortedRandomValues(rng, 6, 5, 30) + modeIndex := randomInt(rng, 0, len(values)-1) + modeValue := values[modeIndex] + values = append(values, modeValue) + sort.Ints(values) + return GeneratedQuestion{ + Title: "Hard Data", + Prompt: fmt.Sprintf("Find the mode of these numbers: %s.", joinInts(values)), + CorrectAnswer: fmt.Sprintf("%d", modeValue), + WorkedSolution: []string{ + fmt.Sprintf("The mode is the value that appears most often."), + fmt.Sprintf("%d appears more than any other value, so the mode is %d.", modeValue, modeValue), + }, + Tags: buildGeneratedTags(sqlc.QuestionTopicData, difficulty, "mode"), + } +} + +func randomInt(rng *rand.Rand, min, max int) int { + if max <= min { + return min + } + return min + rng.Intn(max-min+1) +} + +func digitsToInt(digits []int) int { + value := 0 + for _, digit := range digits { + value = value*10 + digit + } + return value +} + +func placeNameFromPower(power int) string { + switch power { + case 0: + return "ones" + case 1: + return "tens" + case 2: + return "hundreds" + case 3: + return "thousands" + case 4: + return "ten-thousands" + default: + return "place" + } +} + +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + return a +} + +func simplifyFraction(numerator, denominator int) (int, int) { + if denominator == 0 { + return numerator, denominator + } + divisor := gcd(numerator, denominator) + return numerator / divisor, denominator / divisor +} + +func formatFraction(numerator, denominator int) string { + if denominator == 1 { + return fmt.Sprintf("%d", numerator) + } + return fmt.Sprintf("%d/%d", numerator, denominator) +} + +func sortedRandomValues(rng *rand.Rand, count, min, max int) []int { + values := make([]int, count) + for i := 0; i < count; i++ { + values[i] = randomInt(rng, min, max) + } + sort.Ints(values) + return values +} + +func joinInts(values []int) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, ", ") +} diff --git a/Backend/internal/questiongen/service_test.go b/Backend/internal/questiongen/service_test.go new file mode 100644 index 0000000..a2872e5 --- /dev/null +++ b/Backend/internal/questiongen/service_test.go @@ -0,0 +1,175 @@ +package questiongen + +import ( + "reflect" + "regexp" + "strconv" + "testing" + + "boostai-backend/internal/sqlc" +) + +func TestServiceGenerateDeterministicWithSeed(t *testing.T) { + t.Parallel() + + service := NewService() + params := GenerateParams{ + Topic: sqlc.QuestionTopicFractions, + Difficulty: sqlc.QuestionDifficultyMedium, + Count: 3, + Seed: 123456, + } + + first, firstSeed, err := service.Generate(params) + if err != nil { + t.Fatalf("first generate returned error: %v", err) + } + + second, secondSeed, err := service.Generate(params) + if err != nil { + t.Fatalf("second generate returned error: %v", err) + } + + if firstSeed != params.Seed || secondSeed != params.Seed { + t.Fatalf("expected seed %d to be reused, got %d and %d", params.Seed, firstSeed, secondSeed) + } + + if !reflect.DeepEqual(first, second) { + t.Fatalf("expected deterministic output for identical seed\nfirst: %#v\nsecond: %#v", first, second) + } +} + +func TestServiceGenerateSupportsAllTopicsAndDifficulties(t *testing.T) { + t.Parallel() + + service := NewService() + topics := []sqlc.QuestionTopic{ + sqlc.QuestionTopicPlaceValue, + sqlc.QuestionTopicArithmetic, + sqlc.QuestionTopicNegativeNumbers, + sqlc.QuestionTopicBidmas, + sqlc.QuestionTopicFractions, + sqlc.QuestionTopicAlgebra, + sqlc.QuestionTopicGeometry, + sqlc.QuestionTopicData, + } + difficulties := []sqlc.QuestionDifficulty{ + sqlc.QuestionDifficultyEasy, + sqlc.QuestionDifficultyMedium, + sqlc.QuestionDifficultyHard, + } + + for _, topic := range topics { + topic := topic + for _, difficulty := range difficulties { + difficulty := difficulty + t.Run(string(topic)+"_"+string(difficulty), func(t *testing.T) { + t.Parallel() + + items, usedSeed, err := service.Generate(GenerateParams{ + Topic: topic, + Difficulty: difficulty, + Count: 2, + Seed: 99, + }) + if err != nil { + t.Fatalf("generate returned error: %v", err) + } + if usedSeed != 99 { + t.Fatalf("expected used seed 99, got %d", usedSeed) + } + if len(items) != 2 { + t.Fatalf("expected 2 generated questions, got %d", len(items)) + } + + for i, item := range items { + if item.Title == "" { + t.Fatalf("item %d: title should not be empty", i) + } + if item.Prompt == "" { + t.Fatalf("item %d: prompt should not be empty", i) + } + if item.CorrectAnswer == "" { + t.Fatalf("item %d: correct answer should not be empty", i) + } + if len(item.WorkedSolution) == 0 { + t.Fatalf("item %d: worked solution should not be empty", i) + } + assertContainsTag(t, item.Tags, string(topic)) + assertContainsTag(t, item.Tags, string(difficulty)) + assertContainsTag(t, item.Tags, "rng_generated") + } + }) + } + } +} + +func TestFractionsEasyUsesSingleDigitPromptValues(t *testing.T) { + t.Parallel() + + service := NewService() + items, _, err := service.Generate(GenerateParams{ + Topic: sqlc.QuestionTopicFractions, + Difficulty: sqlc.QuestionDifficultyEasy, + Count: 20, + Seed: 20260522, + }) + if err != nil { + t.Fatalf("generate returned error: %v", err) + } + + for i, item := range items { + values := extractIntegers(item.Prompt) + if len(values) != 3 { + t.Fatalf("item %d: expected 3 integers in prompt, got %v from %q", i, values, item.Prompt) + } + + for _, value := range values { + if value < 0 || value > 9 { + t.Fatalf("item %d: expected easy fraction prompt values to be single-digit, got %d in %q", i, value, item.Prompt) + } + } + + assertContainsTag(t, item.Tags, "single_digit") + } +} + +func TestServiceGenerateRejectsUnsupportedTopic(t *testing.T) { + t.Parallel() + + service := NewService() + _, _, err := service.Generate(GenerateParams{ + Topic: sqlc.QuestionTopic("unsupported_topic"), + Difficulty: sqlc.QuestionDifficultyEasy, + Count: 1, + Seed: 1, + }) + if err == nil { + t.Fatal("expected unsupported topic to return an error") + } +} + +func assertContainsTag(t *testing.T, tags []string, want string) { + t.Helper() + for _, tag := range tags { + if tag == want { + return + } + } + t.Fatalf("expected tags %v to contain %q", tags, want) +} + +var integerPattern = regexp.MustCompile(`-?\d+`) + +func extractIntegers(input string) []int { + matches := integerPattern.FindAllString(input, -1) + values := make([]int, 0, len(matches)) + for _, match := range matches { + value, err := strconv.Atoi(match) + if err != nil { + continue + } + values = append(values, value) + } + return values +} diff --git a/Backend/internal/router/api.go b/Backend/internal/router/api.go new file mode 100644 index 0000000..63639dd --- /dev/null +++ b/Backend/internal/router/api.go @@ -0,0 +1,16 @@ +package router + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + "boostai-backend/internal/handlers/api" + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func registerAPIRoutes(app *fiber.App, cfg *config.Config, db *database.DB, authMiddleware *authmw.AuthMiddleware) { + apiHandler := api.NewHandler(db, cfg) + apiGroup := app.Group("", authMiddleware.RequireAuth()) + apiHandler.Register(apiGroup, authMiddleware) +} diff --git a/Backend/internal/router/router.go b/Backend/internal/router/router.go new file mode 100644 index 0000000..ea7eb39 --- /dev/null +++ b/Backend/internal/router/router.go @@ -0,0 +1,24 @@ +// Path: Backend/internal/router/router.go + +package router + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + + "github.com/gofiber/fiber/v2" +) + +func Setup(app *fiber.App, cfg *config.Config, db *database.DB) { + authMiddleware := buildAuthMiddleware(cfg) + + registerWebRoutes(app, cfg, db, authMiddleware) + registerAPIRoutes(app, cfg, db, authMiddleware) + + app.Use(func(c *fiber.Ctx) error { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "not_found", + "message": "The requested endpoint does not exist", + }) + }) +} diff --git a/Backend/internal/router/web.go b/Backend/internal/router/web.go new file mode 100644 index 0000000..7f7b7b7 --- /dev/null +++ b/Backend/internal/router/web.go @@ -0,0 +1,32 @@ +package router + +import ( + "boostai-backend/internal/config" + "boostai-backend/internal/database" + webAuth "boostai-backend/internal/handlers/web/auth" + "boostai-backend/internal/handlers/web/health" + "boostai-backend/internal/handlers/web/root" + authmw "boostai-backend/internal/middleware" + + "github.com/gofiber/fiber/v2" +) + +func buildAuthMiddleware(cfg *config.Config) *authmw.AuthMiddleware { + return authmw.NewAuthMiddleware(cfg) +} + +func registerWebRoutes(app *fiber.App, cfg *config.Config, db *database.DB, authMiddleware *authmw.AuthMiddleware) { + rootHandler := root.NewHandler() + healthHandler := health.NewHandler(cfg.Environment, db) + authHandler := webAuth.NewHandler(cfg, db, authMiddleware) + + app.Get("/", rootHandler.Index) + app.Get("/health", healthHandler.Check) + + authGroup := app.Group("/auth") + authGroup.Post("/register", authHandler.RegisterUser) + authGroup.Post("/login", authHandler.Login) + authGroup.Get("/me", authMiddleware.RequireAuth(), authHandler.Me) + authGroup.Patch("/me", authMiddleware.RequireAuth(), authHandler.UpdateMe) + authGroup.Post("/logout", authMiddleware.RequireAuth(), authHandler.Logout) +} diff --git a/Backend/internal/sqlc/assignments.sql.go b/Backend/internal/sqlc/assignments.sql.go new file mode 100644 index 0000000..a4fcf0e --- /dev/null +++ b/Backend/internal/sqlc/assignments.sql.go @@ -0,0 +1,1069 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: assignments.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addAssignmentStudentQuestion = `-- name: AddAssignmentStudentQuestion :one +INSERT INTO assignment_student_questions ( + assignment_id, + student_id, + question_id, + position, + source_bucket, + source_topic, + source_difficulty, + generator_seed +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING id, assignment_id, student_id, question_id, position, source_bucket, source_topic, source_difficulty, generator_seed, created_at +` + +type AddAssignmentStudentQuestionParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic NullQuestionTopic `json:"source_topic"` + SourceDifficulty NullQuestionDifficulty `json:"source_difficulty"` + GeneratorSeed pgtype.Int8 `json:"generator_seed"` +} + +func (q *Queries) AddAssignmentStudentQuestion(ctx context.Context, arg AddAssignmentStudentQuestionParams) (AssignmentStudentQuestion, error) { + row := q.db.QueryRow(ctx, addAssignmentStudentQuestion, + arg.AssignmentID, + arg.StudentID, + arg.QuestionID, + arg.Position, + arg.SourceBucket, + arg.SourceTopic, + arg.SourceDifficulty, + arg.GeneratorSeed, + ) + var i AssignmentStudentQuestion + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.StudentID, + &i.QuestionID, + &i.Position, + &i.SourceBucket, + &i.SourceTopic, + &i.SourceDifficulty, + &i.GeneratorSeed, + &i.CreatedAt, + ) + return i, err +} + +const addQuestionToAssignment = `-- name: AddQuestionToAssignment :exec +INSERT INTO assignment_questions ( + assignment_id, + question_id, + position +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (assignment_id, question_id) DO UPDATE +SET position = EXCLUDED.position +` + +type AddQuestionToAssignmentParams struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` +} + +func (q *Queries) AddQuestionToAssignment(ctx context.Context, arg AddQuestionToAssignmentParams) error { + _, err := q.db.Exec(ctx, addQuestionToAssignment, arg.AssignmentID, arg.QuestionID, arg.Position) + return err +} + +const assignStudentToAssignment = `-- name: AssignStudentToAssignment :exec +INSERT INTO assignment_assignees ( + assignment_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (assignment_id, student_id) DO NOTHING +` + +type AssignStudentToAssignmentParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) AssignStudentToAssignment(ctx context.Context, arg AssignStudentToAssignmentParams) error { + _, err := q.db.Exec(ctx, assignStudentToAssignment, arg.AssignmentID, arg.StudentID) + return err +} + +const closeAssignment = `-- name: CloseAssignment :one +UPDATE assignments +SET status = 'closed'::assignment_status, + updated_at = NOW() +WHERE id = $1 +RETURNING id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +` + +func (q *Queries) CloseAssignment(ctx context.Context, id int64) (Assignment, error) { + row := q.db.QueryRow(ctx, closeAssignment, id) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const createAssignment = `-- name: CreateAssignment :one +INSERT INTO assignments ( + classroom_id, + teacher_id, + title, + instructions, + status, + due_at, + published_at, + pass_threshold +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) +RETURNING id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +` + +type CreateAssignmentParams struct { + ClassroomID int64 `json:"classroom_id"` + TeacherID int64 `json:"teacher_id"` + Title string `json:"title"` + Instructions pgtype.Text `json:"instructions"` + Status AssignmentStatus `json:"status"` + DueAt pgtype.Timestamptz `json:"due_at"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` +} + +func (q *Queries) CreateAssignment(ctx context.Context, arg CreateAssignmentParams) (Assignment, error) { + row := q.db.QueryRow(ctx, createAssignment, + arg.ClassroomID, + arg.TeacherID, + arg.Title, + arg.Instructions, + arg.Status, + arg.DueAt, + arg.PublishedAt, + arg.PassThreshold, + ) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const deleteAssignmentAssignee = `-- name: DeleteAssignmentAssignee :exec +DELETE FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2 +` + +type DeleteAssignmentAssigneeParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) DeleteAssignmentAssignee(ctx context.Context, arg DeleteAssignmentAssigneeParams) error { + _, err := q.db.Exec(ctx, deleteAssignmentAssignee, arg.AssignmentID, arg.StudentID) + return err +} + +const deleteAssignmentStudentQuestions = `-- name: DeleteAssignmentStudentQuestions :exec +DELETE FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2 +` + +type DeleteAssignmentStudentQuestionsParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) DeleteAssignmentStudentQuestions(ctx context.Context, arg DeleteAssignmentStudentQuestionsParams) error { + _, err := q.db.Exec(ctx, deleteAssignmentStudentQuestions, arg.AssignmentID, arg.StudentID) + return err +} + +const getAssignmentAssignee = `-- name: GetAssignmentAssignee :one +SELECT assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2 +` + +type GetAssignmentAssigneeParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) GetAssignmentAssignee(ctx context.Context, arg GetAssignmentAssigneeParams) (AssignmentAssignee, error) { + row := q.db.QueryRow(ctx, getAssignmentAssignee, arg.AssignmentID, arg.StudentID) + var i AssignmentAssignee + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const getAssignmentByID = `-- name: GetAssignmentByID :one +SELECT id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +FROM assignments +WHERE id = $1 +` + +func (q *Queries) GetAssignmentByID(ctx context.Context, id int64) (Assignment, error) { + row := q.db.QueryRow(ctx, getAssignmentByID, id) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const getAssignmentRedoPlan = `-- name: GetAssignmentRedoPlan :one +SELECT + assignment_id, + student_id, + redo_plan, + redo_plan_generated_at +FROM assignment_assignees +WHERE assignment_id = $1 + AND student_id = $2 +` + +type GetAssignmentRedoPlanParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +type GetAssignmentRedoPlanRow struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + RedoPlan pgtype.Text `json:"redo_plan"` + RedoPlanGeneratedAt pgtype.Timestamptz `json:"redo_plan_generated_at"` +} + +func (q *Queries) GetAssignmentRedoPlan(ctx context.Context, arg GetAssignmentRedoPlanParams) (GetAssignmentRedoPlanRow, error) { + row := q.db.QueryRow(ctx, getAssignmentRedoPlan, arg.AssignmentID, arg.StudentID) + var i GetAssignmentRedoPlanRow + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const getAssignmentReviewSummary = `-- name: GetAssignmentReviewSummary :one +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.student_id, + COUNT(sq.question_id)::BIGINT AS total_questions, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.student_id +) +SELECT + $1::BIGINT AS assignment_id, + COALESCE(MAX(student_states.total_questions), 0)::BIGINT AS total_questions, + COUNT(*)::BIGINT AS total_assigned, + COUNT(*) FILTER (WHERE review_status = 'not_started')::BIGINT AS not_started, + COUNT(*) FILTER (WHERE review_status = 'in_progress')::BIGINT AS in_progress, + COUNT(*) FILTER (WHERE review_status = 'submitted')::BIGINT AS submitted, + COUNT(*) FILTER (WHERE review_status = 'reviewed')::BIGINT AS reviewed +FROM student_states +` + +type GetAssignmentReviewSummaryRow 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"` +} + +func (q *Queries) GetAssignmentReviewSummary(ctx context.Context, dollar_1 int64) (GetAssignmentReviewSummaryRow, error) { + row := q.db.QueryRow(ctx, getAssignmentReviewSummary, dollar_1) + var i GetAssignmentReviewSummaryRow + err := row.Scan( + &i.AssignmentID, + &i.TotalQuestions, + &i.TotalAssigned, + &i.NotStarted, + &i.InProgress, + &i.Submitted, + &i.Reviewed, + ) + return i, err +} + +const listAssignmentReviewQueue = `-- name: ListAssignmentReviewQueue :many +WITH student_question_set AS ( + SELECT asq.student_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 +), +students_with_personalized AS ( + SELECT DISTINCT student_id + FROM student_question_set +), +selected_questions AS ( + SELECT student_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aa.student_id, aq.question_id, aq.position + FROM assignment_assignees aa + JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id + WHERE aa.assignment_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM students_with_personalized swp + WHERE swp.student_id = aa.student_id + ) +), +student_states AS ( + SELECT + aa.assignment_id, + aa.student_id, + aa.next_step_outcome, + u.full_name AS student_name, + u.email AS student_email, + COUNT(sq.question_id)::BIGINT AS total_questions, + COUNT(sa.id)::BIGINT AS answered_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed')::BIGINT AS reviewed_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'submitted')::BIGINT AS submitted_questions, + COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress')::BIGINT AS in_progress_questions, + MAX(sa.submitted_at)::timestamptz AS latest_submitted_at, + MAX(sa.reviewed_at)::timestamptz AS latest_reviewed_at, + CASE + WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status + WHEN COUNT(sq.question_id) > 0 + AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id) + THEN 'reviewed'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status + WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status + ELSE 'not_started'::answer_status + END AS review_status + FROM assignment_assignees aa + JOIN users u ON u.id = aa.student_id + LEFT JOIN selected_questions sq + ON sq.student_id = aa.student_id + LEFT JOIN student_answers sa + ON sa.assignment_id = aa.assignment_id + AND sa.question_id = sq.question_id + AND sa.student_id = aa.student_id + WHERE aa.assignment_id = $1 + GROUP BY aa.assignment_id, aa.student_id, aa.next_step_outcome, u.full_name, u.email +) +SELECT + student_states.assignment_id, + student_states.student_id, + student_states.next_step_outcome, + student_states.student_name, + student_states.student_email, + student_states.total_questions, + student_states.answered_questions, + student_states.reviewed_questions, + student_states.submitted_questions, + student_states.in_progress_questions, + student_states.review_status, + student_states.latest_submitted_at, + student_states.latest_reviewed_at +FROM student_states +WHERE ($2::text = '' OR review_status::text = $2::text) +ORDER BY student_states.student_name ASC, student_states.student_id ASC +` + +type ListAssignmentReviewQueueParams struct { + AssignmentID int64 `json:"assignment_id"` + Column2 string `json:"column_2"` +} + +type ListAssignmentReviewQueueRow struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + 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 AnswerStatus `json:"review_status"` + LatestSubmittedAt pgtype.Timestamptz `json:"latest_submitted_at"` + LatestReviewedAt pgtype.Timestamptz `json:"latest_reviewed_at"` +} + +func (q *Queries) ListAssignmentReviewQueue(ctx context.Context, arg ListAssignmentReviewQueueParams) ([]ListAssignmentReviewQueueRow, error) { + rows, err := q.db.Query(ctx, listAssignmentReviewQueue, arg.AssignmentID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListAssignmentReviewQueueRow{} + for rows.Next() { + var i ListAssignmentReviewQueueRow + if err := rows.Scan( + &i.AssignmentID, + &i.StudentID, + &i.NextStepOutcome, + &i.StudentName, + &i.StudentEmail, + &i.TotalQuestions, + &i.AnsweredQuestions, + &i.ReviewedQuestions, + &i.SubmittedQuestions, + &i.InProgressQuestions, + &i.ReviewStatus, + &i.LatestSubmittedAt, + &i.LatestReviewedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAssignmentStudentQuestions = `-- name: ListAssignmentStudentQuestions :many +SELECT id, assignment_id, student_id, question_id, position, source_bucket, source_topic, source_difficulty, generator_seed, created_at +FROM assignment_student_questions +WHERE assignment_id = $1 + AND student_id = $2 +ORDER BY position ASC, id ASC +` + +type ListAssignmentStudentQuestionsParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) ListAssignmentStudentQuestions(ctx context.Context, arg ListAssignmentStudentQuestionsParams) ([]AssignmentStudentQuestion, error) { + rows, err := q.db.Query(ctx, listAssignmentStudentQuestions, arg.AssignmentID, arg.StudentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AssignmentStudentQuestion{} + for rows.Next() { + var i AssignmentStudentQuestion + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.StudentID, + &i.QuestionID, + &i.Position, + &i.SourceBucket, + &i.SourceTopic, + &i.SourceDifficulty, + &i.GeneratorSeed, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAssignmentsByTeacher = `-- name: ListAssignmentsByTeacher :many +SELECT id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +FROM assignments +WHERE teacher_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListAssignmentsByTeacher(ctx context.Context, teacherID int64) ([]Assignment, error) { + rows, err := q.db.Query(ctx, listAssignmentsByTeacher, teacherID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Assignment{} + for rows.Next() { + var i Assignment + if err := rows.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAssignmentsForStudent = `-- name: ListAssignmentsForStudent :many +SELECT a.id, a.classroom_id, a.teacher_id, a.title, a.instructions, a.status, a.due_at, a.published_at, a.created_at, a.updated_at, a.pass_threshold +FROM assignment_assignees aa +JOIN assignments a ON a.id = aa.assignment_id +WHERE aa.student_id = $1 +ORDER BY a.created_at DESC +` + +func (q *Queries) ListAssignmentsForStudent(ctx context.Context, studentID int64) ([]Assignment, error) { + rows, err := q.db.Query(ctx, listAssignmentsForStudent, studentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Assignment{} + for rows.Next() { + var i Assignment + if err := rows.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGeneratedQuestionsForAssignmentStudent = `-- name: ListGeneratedQuestionsForAssignmentStudent :many +SELECT + asq.id, + asq.assignment_id, + asq.student_id, + asq.question_id, + asq.position, + asq.source_bucket, + asq.source_topic, + asq.source_difficulty, + asq.generator_seed, + asq.created_at, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at AS question_created_at, + q.updated_at AS question_updated_at, + q.correct_answer, + q.topic, + q.difficulty +FROM assignment_student_questions asq +JOIN questions q ON q.id = asq.question_id +WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +ORDER BY asq.position ASC, asq.id ASC +` + +type ListGeneratedQuestionsForAssignmentStudentParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +type ListGeneratedQuestionsForAssignmentStudentRow struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic NullQuestionTopic `json:"source_topic"` + SourceDifficulty NullQuestionDifficulty `json:"source_difficulty"` + GeneratorSeed pgtype.Int8 `json:"generator_seed"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + QuestionCreatedAt pgtype.Timestamptz `json:"question_created_at"` + QuestionUpdatedAt pgtype.Timestamptz `json:"question_updated_at"` + CorrectAnswer pgtype.Text `json:"correct_answer"` + Topic NullQuestionTopic `json:"topic"` + Difficulty NullQuestionDifficulty `json:"difficulty"` +} + +func (q *Queries) ListGeneratedQuestionsForAssignmentStudent(ctx context.Context, arg ListGeneratedQuestionsForAssignmentStudentParams) ([]ListGeneratedQuestionsForAssignmentStudentRow, error) { + rows, err := q.db.Query(ctx, listGeneratedQuestionsForAssignmentStudent, arg.AssignmentID, arg.StudentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListGeneratedQuestionsForAssignmentStudentRow{} + for rows.Next() { + var i ListGeneratedQuestionsForAssignmentStudentRow + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.StudentID, + &i.QuestionID, + &i.Position, + &i.SourceBucket, + &i.SourceTopic, + &i.SourceDifficulty, + &i.GeneratorSeed, + &i.CreatedAt, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.QuestionCreatedAt, + &i.QuestionUpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuestionsForAssignment = `-- name: ListQuestionsForAssignment :many +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.author_teacher_id, + q.title, + q.prompt, + q.subject, + q.source, + q.status, + q.created_at, + q.updated_at +FROM assignment_questions aq +JOIN questions q ON q.id = aq.question_id +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC +` + +type ListQuestionsForAssignmentRow 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 pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListQuestionsForAssignment(ctx context.Context, assignmentID int64) ([]ListQuestionsForAssignmentRow, error) { + rows, err := q.db.Query(ctx, listQuestionsForAssignment, assignmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuestionsForAssignmentRow{} + for rows.Next() { + var i ListQuestionsForAssignmentRow + if err := rows.Scan( + &i.AssignmentID, + &i.QuestionID, + &i.Position, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAssignmentAIReview = `-- name: UpdateAssignmentAIReview :one +UPDATE assignment_assignees +SET ai_feedback = $3, + next_step_outcome = NULLIF($4::text, '')::assignment_next_step_outcome +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +` + +type UpdateAssignmentAIReviewParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + AiFeedback pgtype.Text `json:"ai_feedback"` + Column4 string `json:"column_4"` +} + +func (q *Queries) UpdateAssignmentAIReview(ctx context.Context, arg UpdateAssignmentAIReviewParams) (AssignmentAssignee, error) { + row := q.db.QueryRow(ctx, updateAssignmentAIReview, + arg.AssignmentID, + arg.StudentID, + arg.AiFeedback, + arg.Column4, + ) + var i AssignmentAssignee + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const updateAssignmentDraft = `-- name: UpdateAssignmentDraft :one +UPDATE assignments +SET classroom_id = $2, + title = $3, + instructions = $4, + due_at = $5, + pass_threshold = $6, + updated_at = NOW() +WHERE id = $1 +RETURNING id, classroom_id, teacher_id, title, instructions, status, due_at, published_at, created_at, updated_at, pass_threshold +` + +type UpdateAssignmentDraftParams struct { + ID int64 `json:"id"` + ClassroomID int64 `json:"classroom_id"` + Title string `json:"title"` + Instructions pgtype.Text `json:"instructions"` + DueAt pgtype.Timestamptz `json:"due_at"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` +} + +func (q *Queries) UpdateAssignmentDraft(ctx context.Context, arg UpdateAssignmentDraftParams) (Assignment, error) { + row := q.db.QueryRow(ctx, updateAssignmentDraft, + arg.ID, + arg.ClassroomID, + arg.Title, + arg.Instructions, + arg.DueAt, + arg.PassThreshold, + ) + var i Assignment + err := row.Scan( + &i.ID, + &i.ClassroomID, + &i.TeacherID, + &i.Title, + &i.Instructions, + &i.Status, + &i.DueAt, + &i.PublishedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.PassThreshold, + ) + return i, err +} + +const updateAssignmentRedoPlan = `-- name: UpdateAssignmentRedoPlan :one +UPDATE assignment_assignees +SET redo_plan = NULLIF($3::text, ''), + redo_plan_generated_at = CASE + WHEN NULLIF($3::text, '') IS NULL THEN NULL + ELSE NOW() + END +WHERE assignment_id = $1 + AND student_id = $2 +RETURNING assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +` + +type UpdateAssignmentRedoPlanParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + Column3 string `json:"column_3"` +} + +func (q *Queries) UpdateAssignmentRedoPlan(ctx context.Context, arg UpdateAssignmentRedoPlanParams) (AssignmentAssignee, error) { + row := q.db.QueryRow(ctx, updateAssignmentRedoPlan, arg.AssignmentID, arg.StudentID, arg.Column3) + var i AssignmentAssignee + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} + +const updateAssignmentTeacherFeedback = `-- name: UpdateAssignmentTeacherFeedback :one +WITH student_question_set AS ( + SELECT asq.assignment_id, asq.question_id, asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), selected_questions AS ( + SELECT assignment_id, question_id, position + FROM student_question_set + UNION ALL + SELECT aq.assignment_id, aq.question_id, aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +), score_summary AS ( + SELECT CASE + WHEN COUNT(sa.id) = 0 THEN NULL + ELSE ROUND((AVG( + CASE + WHEN sa.is_correct IS NULL THEN COALESCE(sa.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2) + END AS overall_score + FROM selected_questions aq + LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 + WHERE aq.assignment_id = $1 +), updated AS ( + UPDATE assignment_assignees aa + SET teacher_feedback = $3, + pass_status_override = NULLIF($4::text, '')::assignment_pass_status, + next_step_outcome = NULLIF($5::text, '')::assignment_next_step_outcome, + overall_score = (SELECT overall_score FROM score_summary), + pass_status = COALESCE( + NULLIF($4::text, '')::assignment_pass_status, + CASE + WHEN (SELECT overall_score FROM score_summary) IS NULL THEN 'pending'::assignment_pass_status + WHEN (SELECT overall_score FROM score_summary) >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) + FROM assignments a + WHERE aa.assignment_id = $1 + AND aa.student_id = $2 + AND a.id = aa.assignment_id + RETURNING aa.assignment_id, aa.student_id, aa.assigned_at, aa.ai_feedback, aa.teacher_feedback, aa.overall_score, aa.pass_threshold, aa.pass_status, aa.pass_status_override, aa.next_step_outcome, aa.redo_plan, aa.redo_plan_generated_at +) +SELECT assignment_id, student_id, assigned_at, ai_feedback, teacher_feedback, overall_score, pass_threshold, pass_status, pass_status_override, next_step_outcome, redo_plan, redo_plan_generated_at +FROM updated +` + +type UpdateAssignmentTeacherFeedbackParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + Column4 string `json:"column_4"` + Column5 string `json:"column_5"` +} + +type UpdateAssignmentTeacherFeedbackRow struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + AssignedAt pgtype.Timestamptz `json:"assigned_at"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + OverallScore pgtype.Numeric `json:"overall_score"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` + PassStatus AssignmentPassStatus `json:"pass_status"` + PassStatusOverride NullAssignmentPassStatus `json:"pass_status_override"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + RedoPlan pgtype.Text `json:"redo_plan"` + RedoPlanGeneratedAt pgtype.Timestamptz `json:"redo_plan_generated_at"` +} + +func (q *Queries) UpdateAssignmentTeacherFeedback(ctx context.Context, arg UpdateAssignmentTeacherFeedbackParams) (UpdateAssignmentTeacherFeedbackRow, error) { + row := q.db.QueryRow(ctx, updateAssignmentTeacherFeedback, + arg.AssignmentID, + arg.StudentID, + arg.TeacherFeedback, + arg.Column4, + arg.Column5, + ) + var i UpdateAssignmentTeacherFeedbackRow + err := row.Scan( + &i.AssignmentID, + &i.StudentID, + &i.AssignedAt, + &i.AiFeedback, + &i.TeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.PassStatus, + &i.PassStatusOverride, + &i.NextStepOutcome, + &i.RedoPlan, + &i.RedoPlanGeneratedAt, + ) + return i, err +} diff --git a/Backend/internal/sqlc/classrooms.sql.go b/Backend/internal/sqlc/classrooms.sql.go new file mode 100644 index 0000000..e071aae --- /dev/null +++ b/Backend/internal/sqlc/classrooms.sql.go @@ -0,0 +1,147 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: classrooms.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addStudentToClassroom = `-- name: AddStudentToClassroom :exec +INSERT INTO classroom_students ( + classroom_id, + student_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (classroom_id, student_id) DO NOTHING +` + +type AddStudentToClassroomParams struct { + ClassroomID int64 `json:"classroom_id"` + StudentID int64 `json:"student_id"` +} + +func (q *Queries) AddStudentToClassroom(ctx context.Context, arg AddStudentToClassroomParams) error { + _, err := q.db.Exec(ctx, addStudentToClassroom, arg.ClassroomID, arg.StudentID) + return err +} + +const createClassroom = `-- name: CreateClassroom :one +INSERT INTO classrooms ( + teacher_id, + name, + code, + description +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, teacher_id, name, code, description, created_at, updated_at +` + +type CreateClassroomParams struct { + TeacherID int64 `json:"teacher_id"` + Name string `json:"name"` + Code pgtype.Text `json:"code"` + Description pgtype.Text `json:"description"` +} + +func (q *Queries) CreateClassroom(ctx context.Context, arg CreateClassroomParams) (Classroom, error) { + row := q.db.QueryRow(ctx, createClassroom, + arg.TeacherID, + arg.Name, + arg.Code, + arg.Description, + ) + var i Classroom + err := row.Scan( + &i.ID, + &i.TeacherID, + &i.Name, + &i.Code, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listClassroomsByTeacher = `-- name: ListClassroomsByTeacher :many +SELECT id, teacher_id, name, code, description, created_at, updated_at +FROM classrooms +WHERE teacher_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListClassroomsByTeacher(ctx context.Context, teacherID int64) ([]Classroom, error) { + rows, err := q.db.Query(ctx, listClassroomsByTeacher, teacherID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Classroom{} + for rows.Next() { + var i Classroom + if err := rows.Scan( + &i.ID, + &i.TeacherID, + &i.Name, + &i.Code, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listStudentsForClassroom = `-- name: ListStudentsForClassroom :many +SELECT u.id, u.email, u.password_hash, u.role, u.full_name, u.is_active, u.created_at, u.updated_at +FROM classroom_students cs +JOIN users u ON u.id = cs.student_id +WHERE cs.classroom_id = $1 +ORDER BY u.full_name ASC +` + +func (q *Queries) ListStudentsForClassroom(ctx context.Context, classroomID int64) ([]User, error) { + rows, err := q.db.Query(ctx, listStudentsForClassroom, classroomID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []User{} + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/Backend/internal/sqlc/db.go b/Backend/internal/sqlc/db.go new file mode 100644 index 0000000..7a56507 --- /dev/null +++ b/Backend/internal/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/Backend/internal/sqlc/messages.sql.go b/Backend/internal/sqlc/messages.sql.go new file mode 100644 index 0000000..00fee10 --- /dev/null +++ b/Backend/internal/sqlc/messages.sql.go @@ -0,0 +1,742 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: messages.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addMessageThreadParticipant = `-- name: AddMessageThreadParticipant :exec +INSERT INTO message_thread_participants ( + thread_id, + user_id, + last_read_at +) VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (thread_id, user_id) DO NOTHING +` + +type AddMessageThreadParticipantParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` +} + +func (q *Queries) AddMessageThreadParticipant(ctx context.Context, arg AddMessageThreadParticipantParams) error { + _, err := q.db.Exec(ctx, addMessageThreadParticipant, arg.ThreadID, arg.UserID, arg.LastReadAt) + return err +} + +const createMessageThread = `-- name: CreateMessageThread :one +INSERT INTO message_threads ( + created_by_user_id, + subject +) VALUES ( + $1, + $2 +) +RETURNING id, created_by_user_id, subject, created_at, updated_at +` + +type CreateMessageThreadParams struct { + CreatedByUserID int64 `json:"created_by_user_id"` + Subject string `json:"subject"` +} + +func (q *Queries) CreateMessageThread(ctx context.Context, arg CreateMessageThreadParams) (MessageThread, error) { + row := q.db.QueryRow(ctx, createMessageThread, arg.CreatedByUserID, arg.Subject) + var i MessageThread + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Subject, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createThreadMessage = `-- name: CreateThreadMessage :one +INSERT INTO messages ( + thread_id, + sender_user_id, + body +) VALUES ( + $1, + $2, + $3 +) +RETURNING id, thread_id, sender_user_id, body, created_at, updated_at +` + +type CreateThreadMessageParams struct { + ThreadID int64 `json:"thread_id"` + SenderUserID int64 `json:"sender_user_id"` + Body string `json:"body"` +} + +func (q *Queries) CreateThreadMessage(ctx context.Context, arg CreateThreadMessageParams) (Message, error) { + row := q.db.QueryRow(ctx, createThreadMessage, arg.ThreadID, arg.SenderUserID, arg.Body) + var i Message + err := row.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteMessageThread = `-- name: DeleteMessageThread :one +DELETE FROM message_threads +WHERE id = $1 +RETURNING id, created_by_user_id, subject, created_at, updated_at +` + +func (q *Queries) DeleteMessageThread(ctx context.Context, threadID int64) (MessageThread, error) { + row := q.db.QueryRow(ctx, deleteMessageThread, threadID) + var i MessageThread + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Subject, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteThreadMessage = `-- name: DeleteThreadMessage :one +DELETE FROM messages +WHERE id = $1 + AND thread_id = $2 + AND sender_user_id = $3 +RETURNING id, thread_id, sender_user_id, body, created_at, updated_at +` + +type DeleteThreadMessageParams struct { + MessageID int64 `json:"message_id"` + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) DeleteThreadMessage(ctx context.Context, arg DeleteThreadMessageParams) (Message, error) { + row := q.db.QueryRow(ctx, deleteThreadMessage, arg.MessageID, arg.ThreadID, arg.UserID) + var i Message + err := row.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getMessageRecipientByIDForUser = `-- name: GetMessageRecipientByIDForUser :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $2 + AND u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +LIMIT 1 +` + +type GetMessageRecipientByIDForUserParams struct { + ID int64 `json:"id"` + ID_2 int64 `json:"id_2"` +} + +type GetMessageRecipientByIDForUserRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` +} + +func (q *Queries) GetMessageRecipientByIDForUser(ctx context.Context, arg GetMessageRecipientByIDForUserParams) (GetMessageRecipientByIDForUserRow, error) { + row := q.db.QueryRow(ctx, getMessageRecipientByIDForUser, arg.ID, arg.ID_2) + var i GetMessageRecipientByIDForUserRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + ) + return i, err +} + +const getMessageThreadForUser = `-- name: GetMessageThreadForUser :one +SELECT + t.id, + t.subject, + t.created_by_user_id, + t.created_at, + t.updated_at, + participant.last_read_at, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $2 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_threads t +JOIN message_thread_participants participant ON participant.thread_id = t.id +WHERE t.id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL +` + +type GetMessageThreadForUserParams struct { + ID int64 `json:"id"` + SenderUserID int64 `json:"sender_user_id"` +} + +type GetMessageThreadForUserRow struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + CreatedByUserID int64 `json:"created_by_user_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + UnreadCount int64 `json:"unread_count"` +} + +func (q *Queries) GetMessageThreadForUser(ctx context.Context, arg GetMessageThreadForUserParams) (GetMessageThreadForUserRow, error) { + row := q.db.QueryRow(ctx, getMessageThreadForUser, arg.ID, arg.SenderUserID) + var i GetMessageThreadForUserRow + err := row.Scan( + &i.ID, + &i.Subject, + &i.CreatedByUserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.LastReadAt, + &i.UnreadCount, + ) + return i, err +} + +const listMessageRecipientsForUser = `-- name: ListMessageRecipientsForUser :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id <> $1 + AND u.is_active = TRUE + AND ( + EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = u.id + AND cs.student_id = $1 + ) + OR EXISTS ( + SELECT 1 + FROM classrooms c + JOIN classroom_students cs ON cs.classroom_id = c.id + WHERE c.teacher_id = $1 + AND cs.student_id = u.id + ) + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC +` + +type ListMessageRecipientsForUserRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` +} + +func (q *Queries) ListMessageRecipientsForUser(ctx context.Context, id int64) ([]ListMessageRecipientsForUserRow, error) { + rows, err := q.db.Query(ctx, listMessageRecipientsForUser, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessageRecipientsForUserRow{} + for rows.Next() { + var i ListMessageRecipientsForUserRow + if err := rows.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessageThreadParticipantsForUser = `-- name: ListMessageThreadParticipantsForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id IN ( + SELECT participant.thread_id + FROM message_thread_participants participant + WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +) +ORDER BY mtp.thread_id ASC, COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC +` + +type ListMessageThreadParticipantsForUserRow struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +func (q *Queries) ListMessageThreadParticipantsForUser(ctx context.Context, userID int64) ([]ListMessageThreadParticipantsForUserRow, error) { + rows, err := q.db.Query(ctx, listMessageThreadParticipantsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessageThreadParticipantsForUserRow{} + for rows.Next() { + var i ListMessageThreadParticipantsForUserRow + if err := rows.Scan( + &i.ThreadID, + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.JoinedAt, + &i.LastReadAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessageThreadsForUser = `-- name: ListMessageThreadsForUser :many +SELECT + t.id AS thread_id, + t.subject, + t.created_by_user_id, + t.created_at AS thread_created_at, + t.updated_at AS thread_updated_at, + COALESCE(last_message.id, 0)::bigint AS last_message_id, + COALESCE(last_message.body, '') AS last_message_body, + last_message.created_at AS last_message_created_at, + COALESCE(last_message.sender_user_id, 0)::bigint AS last_message_sender_user_id, + sender.full_name AS last_message_sender_full_name, + sender_profile.preferred_name AS last_message_sender_preferred_name, + sender_profile.profile_icon_url AS last_message_sender_profile_icon_url, + COALESCE(( + SELECT COUNT(*)::bigint + FROM messages unread + WHERE unread.thread_id = t.id + AND unread.sender_user_id <> $1 + AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at) + ), 0)::bigint AS unread_count +FROM message_thread_participants participant +JOIN message_threads t ON t.id = participant.thread_id +LEFT JOIN LATERAL ( + SELECT m.id, m.body, m.created_at, m.sender_user_id + FROM messages m + WHERE m.thread_id = t.id + ORDER BY m.created_at DESC, m.id DESC + LIMIT 1 +) AS last_message ON TRUE +LEFT JOIN users sender ON sender.id = last_message.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE participant.user_id = $1 + AND participant.archived_at IS NULL +ORDER BY COALESCE(last_message.created_at, t.updated_at) DESC, t.id DESC +` + +type ListMessageThreadsForUserRow struct { + ThreadID int64 `json:"thread_id"` + Subject string `json:"subject"` + CreatedByUserID int64 `json:"created_by_user_id"` + ThreadCreatedAt pgtype.Timestamptz `json:"thread_created_at"` + ThreadUpdatedAt pgtype.Timestamptz `json:"thread_updated_at"` + LastMessageID int64 `json:"last_message_id"` + LastMessageBody string `json:"last_message_body"` + LastMessageCreatedAt pgtype.Timestamptz `json:"last_message_created_at"` + LastMessageSenderUserID int64 `json:"last_message_sender_user_id"` + LastMessageSenderFullName pgtype.Text `json:"last_message_sender_full_name"` + LastMessageSenderPreferredName pgtype.Text `json:"last_message_sender_preferred_name"` + LastMessageSenderProfileIconUrl pgtype.Text `json:"last_message_sender_profile_icon_url"` + UnreadCount int64 `json:"unread_count"` +} + +func (q *Queries) ListMessageThreadsForUser(ctx context.Context, senderUserID int64) ([]ListMessageThreadsForUserRow, error) { + rows, err := q.db.Query(ctx, listMessageThreadsForUser, senderUserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessageThreadsForUserRow{} + for rows.Next() { + var i ListMessageThreadsForUserRow + if err := rows.Scan( + &i.ThreadID, + &i.Subject, + &i.CreatedByUserID, + &i.ThreadCreatedAt, + &i.ThreadUpdatedAt, + &i.LastMessageID, + &i.LastMessageBody, + &i.LastMessageCreatedAt, + &i.LastMessageSenderUserID, + &i.LastMessageSenderFullName, + &i.LastMessageSenderPreferredName, + &i.LastMessageSenderProfileIconUrl, + &i.UnreadCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMessagesForThreadForUser = `-- name: ListMessagesForThreadForUser :many +SELECT + m.id, + m.thread_id, + m.sender_user_id, + m.body, + m.created_at, + m.updated_at, + sender.email AS sender_email, + sender.role AS sender_role, + sender.full_name AS sender_full_name, + sender_profile.preferred_name AS sender_preferred_name, + sender_profile.profile_icon_url AS sender_profile_icon_url, + sender_profile.headline AS sender_headline +FROM messages m +JOIN message_thread_participants participant ON participant.thread_id = m.thread_id +JOIN users sender ON sender.id = m.sender_user_id +LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id +WHERE m.thread_id = $1 + AND participant.user_id = $2 + AND participant.archived_at IS NULL +ORDER BY m.created_at ASC, m.id ASC +` + +type ListMessagesForThreadForUserParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +type ListMessagesForThreadForUserRow struct { + ID int64 `json:"id"` + ThreadID int64 `json:"thread_id"` + SenderUserID int64 `json:"sender_user_id"` + Body string `json:"body"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SenderEmail string `json:"sender_email"` + SenderRole UserRole `json:"sender_role"` + SenderFullName string `json:"sender_full_name"` + SenderPreferredName pgtype.Text `json:"sender_preferred_name"` + SenderProfileIconUrl pgtype.Text `json:"sender_profile_icon_url"` + SenderHeadline pgtype.Text `json:"sender_headline"` +} + +func (q *Queries) ListMessagesForThreadForUser(ctx context.Context, arg ListMessagesForThreadForUserParams) ([]ListMessagesForThreadForUserRow, error) { + rows, err := q.db.Query(ctx, listMessagesForThreadForUser, arg.ThreadID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListMessagesForThreadForUserRow{} + for rows.Next() { + var i ListMessagesForThreadForUserRow + if err := rows.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + &i.SenderEmail, + &i.SenderRole, + &i.SenderFullName, + &i.SenderPreferredName, + &i.SenderProfileIconUrl, + &i.SenderHeadline, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listParticipantsForThreadForUser = `-- name: ListParticipantsForThreadForUser :many +SELECT + mtp.thread_id, + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + p.preferred_name, + p.profile_icon_url, + p.headline, + mtp.joined_at, + mtp.last_read_at, + mtp.archived_at +FROM message_thread_participants mtp +JOIN users u ON u.id = mtp.user_id +LEFT JOIN profiles p ON p.user_id = u.id +WHERE mtp.thread_id = $1 + AND EXISTS ( + SELECT 1 + FROM message_thread_participants participant + WHERE participant.thread_id = mtp.thread_id + AND participant.user_id = $2 + AND participant.archived_at IS NULL + ) +ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC +` + +type ListParticipantsForThreadForUserParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +type ListParticipantsForThreadForUserRow struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +func (q *Queries) ListParticipantsForThreadForUser(ctx context.Context, arg ListParticipantsForThreadForUserParams) ([]ListParticipantsForThreadForUserRow, error) { + rows, err := q.db.Query(ctx, listParticipantsForThreadForUser, arg.ThreadID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListParticipantsForThreadForUserRow{} + for rows.Next() { + var i ListParticipantsForThreadForUserRow + if err := rows.Scan( + &i.ThreadID, + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.JoinedAt, + &i.LastReadAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markMessageThreadRead = `-- name: MarkMessageThreadRead :one +UPDATE message_thread_participants +SET last_read_at = COALESCE((SELECT MAX(m.created_at) FROM messages m WHERE m.thread_id = $1), NOW()) +WHERE message_thread_participants.thread_id = $1 + AND message_thread_participants.user_id = $2 +RETURNING thread_id, user_id, joined_at, last_read_at, archived_at +` + +type MarkMessageThreadReadParams struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) MarkMessageThreadRead(ctx context.Context, arg MarkMessageThreadReadParams) (MessageThreadParticipant, error) { + row := q.db.QueryRow(ctx, markMessageThreadRead, arg.ThreadID, arg.UserID) + var i MessageThreadParticipant + err := row.Scan( + &i.ThreadID, + &i.UserID, + &i.JoinedAt, + &i.LastReadAt, + &i.ArchivedAt, + ) + return i, err +} + +const touchMessageThread = `-- name: TouchMessageThread :exec +UPDATE message_threads +SET updated_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TouchMessageThread(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, touchMessageThread, id) + return err +} + +const updateMessageThreadSubject = `-- name: UpdateMessageThreadSubject :one +UPDATE message_threads +SET subject = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING id, created_by_user_id, subject, created_at, updated_at +` + +type UpdateMessageThreadSubjectParams struct { + Subject string `json:"subject"` + ThreadID int64 `json:"thread_id"` +} + +func (q *Queries) UpdateMessageThreadSubject(ctx context.Context, arg UpdateMessageThreadSubjectParams) (MessageThread, error) { + row := q.db.QueryRow(ctx, updateMessageThreadSubject, arg.Subject, arg.ThreadID) + var i MessageThread + err := row.Scan( + &i.ID, + &i.CreatedByUserID, + &i.Subject, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateThreadMessageBody = `-- name: UpdateThreadMessageBody :one +UPDATE messages +SET body = $1, + updated_at = NOW() +WHERE id = $2 + AND thread_id = $3 + AND sender_user_id = $4 +RETURNING id, thread_id, sender_user_id, body, created_at, updated_at +` + +type UpdateThreadMessageBodyParams struct { + Body string `json:"body"` + MessageID int64 `json:"message_id"` + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` +} + +func (q *Queries) UpdateThreadMessageBody(ctx context.Context, arg UpdateThreadMessageBodyParams) (Message, error) { + row := q.db.QueryRow(ctx, updateThreadMessageBody, + arg.Body, + arg.MessageID, + arg.ThreadID, + arg.UserID, + ) + var i Message + err := row.Scan( + &i.ID, + &i.ThreadID, + &i.SenderUserID, + &i.Body, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/Backend/internal/sqlc/models.go b/Backend/internal/sqlc/models.go new file mode 100644 index 0000000..6362299 --- /dev/null +++ b/Backend/internal/sqlc/models.go @@ -0,0 +1,526 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "database/sql/driver" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" +) + +type AnswerStatus string + +const ( + AnswerStatusNotStarted AnswerStatus = "not_started" + AnswerStatusInProgress AnswerStatus = "in_progress" + AnswerStatusSubmitted AnswerStatus = "submitted" + AnswerStatusReviewed AnswerStatus = "reviewed" +) + +func (e *AnswerStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AnswerStatus(s) + case string: + *e = AnswerStatus(s) + default: + return fmt.Errorf("unsupported scan type for AnswerStatus: %T", src) + } + return nil +} + +type NullAnswerStatus struct { + AnswerStatus AnswerStatus `json:"answer_status"` + Valid bool `json:"valid"` // Valid is true if AnswerStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAnswerStatus) Scan(value interface{}) error { + if value == nil { + ns.AnswerStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AnswerStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAnswerStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AnswerStatus), nil +} + +type AssignmentNextStepOutcome string + +const ( + AssignmentNextStepOutcomeRedo AssignmentNextStepOutcome = "redo" + AssignmentNextStepOutcomeAccept AssignmentNextStepOutcome = "accept" + AssignmentNextStepOutcomeSupport AssignmentNextStepOutcome = "support" +) + +func (e *AssignmentNextStepOutcome) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AssignmentNextStepOutcome(s) + case string: + *e = AssignmentNextStepOutcome(s) + default: + return fmt.Errorf("unsupported scan type for AssignmentNextStepOutcome: %T", src) + } + return nil +} + +type NullAssignmentNextStepOutcome struct { + AssignmentNextStepOutcome AssignmentNextStepOutcome `json:"assignment_next_step_outcome"` + Valid bool `json:"valid"` // Valid is true if AssignmentNextStepOutcome is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAssignmentNextStepOutcome) Scan(value interface{}) error { + if value == nil { + ns.AssignmentNextStepOutcome, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AssignmentNextStepOutcome.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAssignmentNextStepOutcome) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AssignmentNextStepOutcome), nil +} + +type AssignmentPassStatus string + +const ( + AssignmentPassStatusPending AssignmentPassStatus = "pending" + AssignmentPassStatusPass AssignmentPassStatus = "pass" + AssignmentPassStatusNoPass AssignmentPassStatus = "no_pass" +) + +func (e *AssignmentPassStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AssignmentPassStatus(s) + case string: + *e = AssignmentPassStatus(s) + default: + return fmt.Errorf("unsupported scan type for AssignmentPassStatus: %T", src) + } + return nil +} + +type NullAssignmentPassStatus struct { + AssignmentPassStatus AssignmentPassStatus `json:"assignment_pass_status"` + Valid bool `json:"valid"` // Valid is true if AssignmentPassStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAssignmentPassStatus) Scan(value interface{}) error { + if value == nil { + ns.AssignmentPassStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AssignmentPassStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAssignmentPassStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AssignmentPassStatus), nil +} + +type AssignmentStatus string + +const ( + AssignmentStatusDraft AssignmentStatus = "draft" + AssignmentStatusAssigned AssignmentStatus = "assigned" + AssignmentStatusClosed AssignmentStatus = "closed" +) + +func (e *AssignmentStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AssignmentStatus(s) + case string: + *e = AssignmentStatus(s) + default: + return fmt.Errorf("unsupported scan type for AssignmentStatus: %T", src) + } + return nil +} + +type NullAssignmentStatus struct { + AssignmentStatus AssignmentStatus `json:"assignment_status"` + Valid bool `json:"valid"` // Valid is true if AssignmentStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAssignmentStatus) Scan(value interface{}) error { + if value == nil { + ns.AssignmentStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AssignmentStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAssignmentStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AssignmentStatus), nil +} + +type QuestionDifficulty string + +const ( + QuestionDifficultyEasy QuestionDifficulty = "easy" + QuestionDifficultyMedium QuestionDifficulty = "medium" + QuestionDifficultyHard QuestionDifficulty = "hard" +) + +func (e *QuestionDifficulty) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionDifficulty(s) + case string: + *e = QuestionDifficulty(s) + default: + return fmt.Errorf("unsupported scan type for QuestionDifficulty: %T", src) + } + return nil +} + +type NullQuestionDifficulty struct { + QuestionDifficulty QuestionDifficulty `json:"question_difficulty"` + Valid bool `json:"valid"` // Valid is true if QuestionDifficulty is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionDifficulty) Scan(value interface{}) error { + if value == nil { + ns.QuestionDifficulty, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionDifficulty.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionDifficulty) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionDifficulty), nil +} + +type QuestionStatus string + +const ( + QuestionStatusDraft QuestionStatus = "draft" + QuestionStatusPublished QuestionStatus = "published" + QuestionStatusArchived QuestionStatus = "archived" +) + +func (e *QuestionStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionStatus(s) + case string: + *e = QuestionStatus(s) + default: + return fmt.Errorf("unsupported scan type for QuestionStatus: %T", src) + } + return nil +} + +type NullQuestionStatus struct { + QuestionStatus QuestionStatus `json:"question_status"` + Valid bool `json:"valid"` // Valid is true if QuestionStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionStatus) Scan(value interface{}) error { + if value == nil { + ns.QuestionStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionStatus), nil +} + +type QuestionTopic string + +const ( + QuestionTopicPlaceValue QuestionTopic = "place_value" + QuestionTopicArithmetic QuestionTopic = "arithmetic" + QuestionTopicNegativeNumbers QuestionTopic = "negative_numbers" + QuestionTopicBidmas QuestionTopic = "bidmas" + QuestionTopicFractions QuestionTopic = "fractions" + QuestionTopicAlgebra QuestionTopic = "algebra" + QuestionTopicGeometry QuestionTopic = "geometry" + QuestionTopicData QuestionTopic = "data" +) + +func (e *QuestionTopic) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = QuestionTopic(s) + case string: + *e = QuestionTopic(s) + default: + return fmt.Errorf("unsupported scan type for QuestionTopic: %T", src) + } + return nil +} + +type NullQuestionTopic struct { + QuestionTopic QuestionTopic `json:"question_topic"` + Valid bool `json:"valid"` // Valid is true if QuestionTopic is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullQuestionTopic) Scan(value interface{}) error { + if value == nil { + ns.QuestionTopic, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.QuestionTopic.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullQuestionTopic) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.QuestionTopic), nil +} + +type UserRole string + +const ( + UserRoleStudent UserRole = "student" + UserRoleTeacher UserRole = "teacher" +) + +func (e *UserRole) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = UserRole(s) + case string: + *e = UserRole(s) + default: + return fmt.Errorf("unsupported scan type for UserRole: %T", src) + } + return nil +} + +type NullUserRole struct { + UserRole UserRole `json:"user_role"` + Valid bool `json:"valid"` // Valid is true if UserRole is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullUserRole) Scan(value interface{}) error { + if value == nil { + ns.UserRole, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.UserRole.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullUserRole) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.UserRole), nil +} + +type Assignment struct { + ID int64 `json:"id"` + ClassroomID int64 `json:"classroom_id"` + TeacherID int64 `json:"teacher_id"` + Title string `json:"title"` + Instructions pgtype.Text `json:"instructions"` + Status AssignmentStatus `json:"status"` + DueAt pgtype.Timestamptz `json:"due_at"` + PublishedAt pgtype.Timestamptz `json:"published_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` +} + +type AssignmentAssignee struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + AssignedAt pgtype.Timestamptz `json:"assigned_at"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + OverallScore pgtype.Numeric `json:"overall_score"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` + PassStatus AssignmentPassStatus `json:"pass_status"` + PassStatusOverride NullAssignmentPassStatus `json:"pass_status_override"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + RedoPlan pgtype.Text `json:"redo_plan"` + RedoPlanGeneratedAt pgtype.Timestamptz `json:"redo_plan_generated_at"` +} + +type AssignmentQuestion struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` +} + +type AssignmentStudentQuestion struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + SourceBucket string `json:"source_bucket"` + SourceTopic NullQuestionTopic `json:"source_topic"` + SourceDifficulty NullQuestionDifficulty `json:"source_difficulty"` + GeneratorSeed pgtype.Int8 `json:"generator_seed"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Classroom struct { + ID int64 `json:"id"` + TeacherID int64 `json:"teacher_id"` + Name string `json:"name"` + Code pgtype.Text `json:"code"` + Description pgtype.Text `json:"description"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ClassroomStudent struct { + ClassroomID int64 `json:"classroom_id"` + StudentID int64 `json:"student_id"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` +} + +type Message struct { + ID int64 `json:"id"` + ThreadID int64 `json:"thread_id"` + SenderUserID int64 `json:"sender_user_id"` + Body string `json:"body"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageThread struct { + ID int64 `json:"id"` + CreatedByUserID int64 `json:"created_by_user_id"` + Subject string `json:"subject"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageThreadParticipant struct { + ThreadID int64 `json:"thread_id"` + UserID int64 `json:"user_id"` + JoinedAt pgtype.Timestamptz `json:"joined_at"` + LastReadAt pgtype.Timestamptz `json:"last_read_at"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` +} + +type Profile struct { + UserID int64 `json:"user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Question struct { + ID int64 `json:"id"` + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CorrectAnswer pgtype.Text `json:"correct_answer"` + Topic NullQuestionTopic `json:"topic"` + Difficulty NullQuestionDifficulty `json:"difficulty"` +} + +type QuestionTag struct { + QuestionID int64 `json:"question_id"` + TagID int64 `json:"tag_id"` +} + +type StudentAnswer struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + StudentID int64 `json:"student_id"` + AnswerText pgtype.Text `json:"answer_text"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + Status AnswerStatus `json:"status"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + SolveMode string `json:"solve_mode"` + WorkingSteps pgtype.Text `json:"working_steps"` + IsCorrect pgtype.Bool `json:"is_correct"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` +} + +type Tag struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + Role UserRole `json:"role"` + FullName string `json:"full_name"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} diff --git a/Backend/internal/sqlc/questions.sql.go b/Backend/internal/sqlc/questions.sql.go new file mode 100644 index 0000000..a330ec9 --- /dev/null +++ b/Backend/internal/sqlc/questions.sql.go @@ -0,0 +1,206 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: questions.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const attachTagToQuestion = `-- name: AttachTagToQuestion :exec +INSERT INTO question_tags ( + question_id, + tag_id +) VALUES ( + $1, + $2 +) +ON CONFLICT (question_id, tag_id) DO NOTHING +` + +type AttachTagToQuestionParams struct { + QuestionID int64 `json:"question_id"` + TagID int64 `json:"tag_id"` +} + +func (q *Queries) AttachTagToQuestion(ctx context.Context, arg AttachTagToQuestionParams) error { + _, err := q.db.Exec(ctx, attachTagToQuestion, arg.QuestionID, arg.TagID) + return err +} + +const createQuestion = `-- name: CreateQuestion :one +INSERT INTO questions ( + author_teacher_id, + title, + prompt, + topic, + subject, + difficulty, + source, + status, + correct_answer +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +RETURNING id, author_teacher_id, title, prompt, subject, source, status, created_at, updated_at, correct_answer, topic, difficulty +` + +type CreateQuestionParams struct { + AuthorTeacherID int64 `json:"author_teacher_id"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Topic NullQuestionTopic `json:"topic"` + Subject pgtype.Text `json:"subject"` + Difficulty NullQuestionDifficulty `json:"difficulty"` + Source pgtype.Text `json:"source"` + Status QuestionStatus `json:"status"` + CorrectAnswer pgtype.Text `json:"correct_answer"` +} + +func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { + row := q.db.QueryRow(ctx, createQuestion, + arg.AuthorTeacherID, + arg.Title, + arg.Prompt, + arg.Topic, + arg.Subject, + arg.Difficulty, + arg.Source, + arg.Status, + arg.CorrectAnswer, + ) + var i Question + err := row.Scan( + &i.ID, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ) + return i, err +} + +const createTag = `-- name: CreateTag :one +INSERT INTO tags (name) +VALUES ($1) +ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name +RETURNING id, name, created_at +` + +func (q *Queries) CreateTag(ctx context.Context, name string) (Tag, error) { + row := q.db.QueryRow(ctx, createTag, name) + var i Tag + err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + return i, err +} + +const getQuestionByID = `-- name: GetQuestionByID :one +SELECT id, author_teacher_id, title, prompt, subject, source, status, created_at, updated_at, correct_answer, topic, difficulty +FROM questions +WHERE id = $1 +` + +func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, error) { + row := q.db.QueryRow(ctx, getQuestionByID, id) + var i Question + err := row.Scan( + &i.ID, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ) + return i, err +} + +const listQuestionsByTeacher = `-- name: ListQuestionsByTeacher :many +SELECT id, author_teacher_id, title, prompt, subject, source, status, created_at, updated_at, correct_answer, topic, difficulty +FROM questions +WHERE author_teacher_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListQuestionsByTeacher(ctx context.Context, authorTeacherID int64) ([]Question, error) { + rows, err := q.db.Query(ctx, listQuestionsByTeacher, authorTeacherID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Question{} + for rows.Next() { + var i Question + if err := rows.Scan( + &i.ID, + &i.AuthorTeacherID, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CorrectAnswer, + &i.Topic, + &i.Difficulty, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTags = `-- name: ListTags :many +SELECT id, name, created_at +FROM tags +ORDER BY name ASC +` + +func (q *Queries) ListTags(ctx context.Context) ([]Tag, error) { + rows, err := q.db.Query(ctx, listTags) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tag{} + for rows.Next() { + var i Tag + if err := rows.Scan(&i.ID, &i.Name, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/Backend/internal/sqlc/student_answers.sql.go b/Backend/internal/sqlc/student_answers.sql.go new file mode 100644 index 0000000..2df46a8 --- /dev/null +++ b/Backend/internal/sqlc/student_answers.sql.go @@ -0,0 +1,649 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: student_answers.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listAnswersForAssignment = `-- name: ListAnswersForAssignment :many +SELECT id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +FROM student_answers +WHERE assignment_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListAnswersForAssignment(ctx context.Context, assignmentID int64) ([]StudentAnswer, error) { + rows, err := q.db.Query(ctx, listAnswersForAssignment, assignmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []StudentAnswer{} + for rows.Next() { + var i StudentAnswer + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAnswersForStudent = `-- name: ListAnswersForStudent :many +SELECT id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +FROM student_answers +WHERE student_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListAnswersForStudent(ctx context.Context, studentID int64) ([]StudentAnswer, error) { + rows, err := q.db.Query(ctx, listAnswersForStudent, studentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []StudentAnswer{} + for rows.Next() { + var i StudentAnswer + if err := rows.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuestionDetailsForAssignmentStudent = `-- name: ListQuestionDetailsForAssignmentStudent :many +WITH student_question_set AS ( + SELECT + asq.assignment_id, + asq.question_id, + asq.position + FROM assignment_student_questions asq + WHERE asq.assignment_id = $1 + AND asq.student_id = $2 +), +selected_questions AS ( + SELECT + sq.assignment_id, + sq.question_id, + sq.position + FROM student_question_set sq + UNION ALL + SELECT + aq.assignment_id, + aq.question_id, + aq.position + FROM assignment_questions aq + WHERE aq.assignment_id = $1 + AND NOT EXISTS (SELECT 1 FROM student_question_set) +) +SELECT + aq.assignment_id, + aq.question_id, + aq.position, + q.title, + q.prompt, + q.subject, + q.source, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = aq.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + q.status AS question_status, + q.correct_answer, + aa.ai_feedback AS assignment_ai_feedback, + aa.teacher_feedback AS assignment_teacher_feedback, + review_summary.overall_score, + a.pass_threshold, + aa.next_step_outcome, + aa.pass_status_override, + COALESCE( + aa.pass_status_override, + CASE + WHEN review_summary.overall_score IS NULL THEN 'pending'::assignment_pass_status + WHEN review_summary.overall_score >= a.pass_threshold THEN 'pass'::assignment_pass_status + ELSE 'no_pass'::assignment_pass_status + END + ) AS pass_status, + sa.id AS answer_id, + sa.student_id, + sa.answer_text, + sa.solve_mode, + sa.working_steps, + sa.is_correct, + sa.ai_feedback, + sa.teacher_feedback, + sa.status AS answer_status, + sa.review_needs_attention, + sa.review_issue_reason, + sa.review_correctness_score, + sa.review_understanding_score, + sa.review_question_score, + sa.review_confidence, + sa.review_tags, + sa.submitted_at, + sa.reviewed_at, + sa.created_at AS answer_created_at, + sa.updated_at AS answer_updated_at + FROM selected_questions aq + JOIN assignments a ON a.id = aq.assignment_id + JOIN questions q ON q.id = aq.question_id + LEFT JOIN assignment_assignees aa + ON aa.assignment_id = aq.assignment_id + AND aa.student_id = $2 + LEFT JOIN LATERAL ( + SELECT CASE + WHEN COUNT(sa2.id) = 0 THEN NULL::NUMERIC(5,2) + ELSE ROUND((AVG( + CASE + WHEN sa2.is_correct IS NULL THEN COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ELSE ( + ((CASE WHEN sa2.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa2.review_understanding_score, 0)::NUMERIC + ) / 2 + END + ) * 10)::NUMERIC, 2)::NUMERIC(5,2) + END AS overall_score + FROM selected_questions aq2 + LEFT JOIN student_answers sa2 + ON sa2.assignment_id = aq2.assignment_id + AND sa2.question_id = aq2.question_id + AND sa2.student_id = $2 + WHERE aq2.assignment_id = aq.assignment_id +) review_summary ON TRUE +LEFT JOIN student_answers sa + ON sa.assignment_id = aq.assignment_id + AND sa.question_id = aq.question_id + AND sa.student_id = $2 +WHERE aq.assignment_id = $1 +ORDER BY aq.position ASC, aq.question_id ASC +` + +type ListQuestionDetailsForAssignmentStudentParams struct { + AssignmentID int64 `json:"assignment_id"` + StudentID int64 `json:"student_id"` +} + +type ListQuestionDetailsForAssignmentStudentRow struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Position int32 `json:"position"` + Title string `json:"title"` + Prompt string `json:"prompt"` + Subject pgtype.Text `json:"subject"` + Source pgtype.Text `json:"source"` + QuestionTags []string `json:"question_tags"` + QuestionStatus QuestionStatus `json:"question_status"` + CorrectAnswer pgtype.Text `json:"correct_answer"` + AssignmentAiFeedback pgtype.Text `json:"assignment_ai_feedback"` + AssignmentTeacherFeedback pgtype.Text `json:"assignment_teacher_feedback"` + OverallScore pgtype.Numeric `json:"overall_score"` + PassThreshold pgtype.Numeric `json:"pass_threshold"` + NextStepOutcome NullAssignmentNextStepOutcome `json:"next_step_outcome"` + PassStatusOverride NullAssignmentPassStatus `json:"pass_status_override"` + PassStatus NullAssignmentPassStatus `json:"pass_status"` + AnswerID pgtype.Int8 `json:"answer_id"` + StudentID pgtype.Int8 `json:"student_id"` + AnswerText pgtype.Text `json:"answer_text"` + SolveMode pgtype.Text `json:"solve_mode"` + WorkingSteps pgtype.Text `json:"working_steps"` + IsCorrect pgtype.Bool `json:"is_correct"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + AnswerStatus NullAnswerStatus `json:"answer_status"` + ReviewNeedsAttention pgtype.Bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + AnswerCreatedAt pgtype.Timestamptz `json:"answer_created_at"` + AnswerUpdatedAt pgtype.Timestamptz `json:"answer_updated_at"` +} + +func (q *Queries) ListQuestionDetailsForAssignmentStudent(ctx context.Context, arg ListQuestionDetailsForAssignmentStudentParams) ([]ListQuestionDetailsForAssignmentStudentRow, error) { + rows, err := q.db.Query(ctx, listQuestionDetailsForAssignmentStudent, arg.AssignmentID, arg.StudentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQuestionDetailsForAssignmentStudentRow{} + for rows.Next() { + var i ListQuestionDetailsForAssignmentStudentRow + if err := rows.Scan( + &i.AssignmentID, + &i.QuestionID, + &i.Position, + &i.Title, + &i.Prompt, + &i.Subject, + &i.Source, + &i.QuestionTags, + &i.QuestionStatus, + &i.CorrectAnswer, + &i.AssignmentAiFeedback, + &i.AssignmentTeacherFeedback, + &i.OverallScore, + &i.PassThreshold, + &i.NextStepOutcome, + &i.PassStatusOverride, + &i.PassStatus, + &i.AnswerID, + &i.StudentID, + &i.AnswerText, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.AiFeedback, + &i.TeacherFeedback, + &i.AnswerStatus, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + &i.SubmittedAt, + &i.ReviewedAt, + &i.AnswerCreatedAt, + &i.AnswerUpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listStudentPlanningPerformance = `-- name: ListStudentPlanningPerformance :many +SELECT + sa.assignment_id, + sa.question_id, + q.topic, + q.subject, + q.difficulty, + COALESCE( + ARRAY( + SELECT t.name + FROM question_tags qt + JOIN tags t ON t.id = qt.tag_id + WHERE qt.question_id = sa.question_id + ORDER BY t.name ASC + ), + ARRAY[]::TEXT[] + )::TEXT[] AS question_tags, + sa.is_correct, + sa.review_understanding_score, + sa.review_needs_attention, + sa.review_issue_reason, + sa.status, + sa.submitted_at, + sa.reviewed_at, + sa.updated_at +FROM student_answers sa +JOIN questions q ON q.id = sa.question_id +WHERE sa.student_id = $1 + AND sa.status IN ('submitted'::answer_status, 'reviewed'::answer_status) +ORDER BY COALESCE(sa.reviewed_at, sa.submitted_at, sa.updated_at) DESC, sa.id DESC +` + +type ListStudentPlanningPerformanceRow struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + Topic NullQuestionTopic `json:"topic"` + Subject pgtype.Text `json:"subject"` + Difficulty NullQuestionDifficulty `json:"difficulty"` + QuestionTags []string `json:"question_tags"` + IsCorrect pgtype.Bool `json:"is_correct"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + Status AnswerStatus `json:"status"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListStudentPlanningPerformance(ctx context.Context, studentID int64) ([]ListStudentPlanningPerformanceRow, error) { + rows, err := q.db.Query(ctx, listStudentPlanningPerformance, studentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListStudentPlanningPerformanceRow{} + for rows.Next() { + var i ListStudentPlanningPerformanceRow + if err := rows.Scan( + &i.AssignmentID, + &i.QuestionID, + &i.Topic, + &i.Subject, + &i.Difficulty, + &i.QuestionTags, + &i.IsCorrect, + &i.ReviewUnderstandingScore, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAnswerAIReview = `-- name: UpdateAnswerAIReview :one +UPDATE student_answers +SET + ai_feedback = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + updated_at = NOW() +WHERE id = $1 +RETURNING id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +` + +type UpdateAnswerAIReviewParams struct { + ID int64 `json:"id"` + AiFeedback pgtype.Text `json:"ai_feedback"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` +} + +func (q *Queries) UpdateAnswerAIReview(ctx context.Context, arg UpdateAnswerAIReviewParams) (StudentAnswer, error) { + row := q.db.QueryRow(ctx, updateAnswerAIReview, + arg.ID, + arg.AiFeedback, + arg.ReviewNeedsAttention, + arg.ReviewIssueReason, + arg.ReviewCorrectnessScore, + arg.ReviewUnderstandingScore, + arg.ReviewQuestionScore, + arg.ReviewConfidence, + ) + var i StudentAnswer + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ) + return i, err +} + +const updateAnswerReview = `-- name: UpdateAnswerReview :one +UPDATE student_answers +SET + status = $2, + review_needs_attention = $3, + review_issue_reason = $4, + review_correctness_score = $5, + review_understanding_score = $6, + review_question_score = $7, + review_confidence = $8, + review_tags = $9, + reviewed_at = CASE + WHEN $2::answer_status = 'reviewed' THEN NOW() + ELSE NULL + END, + updated_at = NOW() +WHERE id = $1 +RETURNING id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +` + +type UpdateAnswerReviewParams struct { + ID int64 `json:"id"` + Status AnswerStatus `json:"status"` + ReviewNeedsAttention bool `json:"review_needs_attention"` + ReviewIssueReason pgtype.Text `json:"review_issue_reason"` + ReviewCorrectnessScore pgtype.Numeric `json:"review_correctness_score"` + ReviewUnderstandingScore pgtype.Numeric `json:"review_understanding_score"` + ReviewQuestionScore pgtype.Numeric `json:"review_question_score"` + ReviewConfidence pgtype.Numeric `json:"review_confidence"` + ReviewTags []string `json:"review_tags"` +} + +func (q *Queries) UpdateAnswerReview(ctx context.Context, arg UpdateAnswerReviewParams) (StudentAnswer, error) { + row := q.db.QueryRow(ctx, updateAnswerReview, + arg.ID, + arg.Status, + arg.ReviewNeedsAttention, + arg.ReviewIssueReason, + arg.ReviewCorrectnessScore, + arg.ReviewUnderstandingScore, + arg.ReviewQuestionScore, + arg.ReviewConfidence, + arg.ReviewTags, + ) + var i StudentAnswer + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ) + return i, err +} + +const upsertStudentAnswer = `-- name: UpsertStudentAnswer :one +INSERT INTO student_answers ( + assignment_id, + question_id, + student_id, + answer_text, + solve_mode, + working_steps, + ai_feedback, + teacher_feedback, + status, + submitted_at, + reviewed_at, + is_correct +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12 +) +ON CONFLICT (assignment_id, question_id, student_id) DO UPDATE +SET + answer_text = EXCLUDED.answer_text, + solve_mode = EXCLUDED.solve_mode, + working_steps = EXCLUDED.working_steps, + ai_feedback = EXCLUDED.ai_feedback, + teacher_feedback = EXCLUDED.teacher_feedback, + status = EXCLUDED.status, + submitted_at = EXCLUDED.submitted_at, + reviewed_at = EXCLUDED.reviewed_at, + is_correct = EXCLUDED.is_correct, + updated_at = NOW() +RETURNING id, assignment_id, question_id, student_id, answer_text, ai_feedback, teacher_feedback, status, submitted_at, reviewed_at, created_at, updated_at, solve_mode, working_steps, is_correct, review_needs_attention, review_issue_reason, review_correctness_score, review_understanding_score, review_question_score, review_confidence, review_tags +` + +type UpsertStudentAnswerParams struct { + AssignmentID int64 `json:"assignment_id"` + QuestionID int64 `json:"question_id"` + StudentID int64 `json:"student_id"` + AnswerText pgtype.Text `json:"answer_text"` + SolveMode string `json:"solve_mode"` + WorkingSteps pgtype.Text `json:"working_steps"` + AiFeedback pgtype.Text `json:"ai_feedback"` + TeacherFeedback pgtype.Text `json:"teacher_feedback"` + Status AnswerStatus `json:"status"` + SubmittedAt pgtype.Timestamptz `json:"submitted_at"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + IsCorrect pgtype.Bool `json:"is_correct"` +} + +func (q *Queries) UpsertStudentAnswer(ctx context.Context, arg UpsertStudentAnswerParams) (StudentAnswer, error) { + row := q.db.QueryRow(ctx, upsertStudentAnswer, + arg.AssignmentID, + arg.QuestionID, + arg.StudentID, + arg.AnswerText, + arg.SolveMode, + arg.WorkingSteps, + arg.AiFeedback, + arg.TeacherFeedback, + arg.Status, + arg.SubmittedAt, + arg.ReviewedAt, + arg.IsCorrect, + ) + var i StudentAnswer + err := row.Scan( + &i.ID, + &i.AssignmentID, + &i.QuestionID, + &i.StudentID, + &i.AnswerText, + &i.AiFeedback, + &i.TeacherFeedback, + &i.Status, + &i.SubmittedAt, + &i.ReviewedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SolveMode, + &i.WorkingSteps, + &i.IsCorrect, + &i.ReviewNeedsAttention, + &i.ReviewIssueReason, + &i.ReviewCorrectnessScore, + &i.ReviewUnderstandingScore, + &i.ReviewQuestionScore, + &i.ReviewConfidence, + &i.ReviewTags, + ) + return i, err +} diff --git a/Backend/internal/sqlc/users.sql.go b/Backend/internal/sqlc/users.sql.go new file mode 100644 index 0000000..4747c2b --- /dev/null +++ b/Backend/internal/sqlc/users.sql.go @@ -0,0 +1,577 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users ( + email, + password_hash, + role, + full_name +) VALUES ( + $1, + $2, + $3, + $4 +) +RETURNING id, email, password_hash, role, full_name, is_active, created_at, updated_at +` + +type CreateUserParams struct { + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + Role UserRole `json:"role"` + FullName string `json:"full_name"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.Email, + arg.PasswordHash, + arg.Role, + arg.FullName, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.password_hash AS user_password_hash, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.email = $1 +` + +type GetAuthUserByEmailRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserPasswordHash pgtype.Text `json:"user_password_hash"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error) { + row := q.db.QueryRow(ctx, getAuthUserByEmail, email) + var i GetAuthUserByEmailRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserPasswordHash, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ) + return i, err +} + +const getAuthUserByID = `-- name: GetAuthUserByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1 +` + +type GetAuthUserByIDRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) GetAuthUserByID(ctx context.Context, id int64) (GetAuthUserByIDRow, error) { + row := q.db.QueryRow(ctx, getAuthUserByID, id) + var i GetAuthUserByIDRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, role, full_name, is_active, created_at, updated_at +FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, password_hash, role, full_name, is_active, created_at, updated_at +FROM users +WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserWithProfileByID = `-- name: GetUserWithProfileByID :one +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.id = $1 +` + +type GetUserWithProfileByIDRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) GetUserWithProfileByID(ctx context.Context, id int64) (GetUserWithProfileByIDRow, error) { + row := q.db.QueryRow(ctx, getUserWithProfileByID, id) + var i GetUserWithProfileByIDRow + err := row.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ) + return i, err +} + +const listUsersByRole = `-- name: ListUsersByRole :many +SELECT id, email, password_hash, role, full_name, is_active, created_at, updated_at +FROM users +WHERE role = $1 +ORDER BY full_name ASC +` + +func (q *Queries) ListUsersByRole(ctx context.Context, role UserRole) ([]User, error) { + rows, err := q.db.Query(ctx, listUsersByRole, role) + if err != nil { + return nil, err + } + defer rows.Close() + items := []User{} + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUsersWithProfileByRole = `-- name: ListUsersWithProfileByRole :many +SELECT + u.id AS user_id, + u.email AS user_email, + u.role AS user_role, + u.full_name AS user_full_name, + u.is_active AS user_is_active, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + p.user_id AS profile_user_id, + p.preferred_name, + p.profile_icon_url, + p.headline, + p.bio, + p.timezone, + p.locale, + p.grade_level, + p.learning_goal, + p.created_at AS profile_created_at, + p.updated_at AS profile_updated_at +FROM users u +LEFT JOIN profiles p ON p.user_id = u.id +WHERE u.role = $1 +ORDER BY u.full_name ASC +` + +type ListUsersWithProfileByRoleRow struct { + UserID int64 `json:"user_id"` + UserEmail string `json:"user_email"` + UserRole UserRole `json:"user_role"` + UserFullName string `json:"user_full_name"` + UserIsActive bool `json:"user_is_active"` + UserCreatedAt pgtype.Timestamptz `json:"user_created_at"` + UserUpdatedAt pgtype.Timestamptz `json:"user_updated_at"` + ProfileUserID pgtype.Int8 `json:"profile_user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` + ProfileCreatedAt pgtype.Timestamptz `json:"profile_created_at"` + ProfileUpdatedAt pgtype.Timestamptz `json:"profile_updated_at"` +} + +func (q *Queries) ListUsersWithProfileByRole(ctx context.Context, role UserRole) ([]ListUsersWithProfileByRoleRow, error) { + rows, err := q.db.Query(ctx, listUsersWithProfileByRole, role) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListUsersWithProfileByRoleRow{} + for rows.Next() { + var i ListUsersWithProfileByRoleRow + if err := rows.Scan( + &i.UserID, + &i.UserEmail, + &i.UserRole, + &i.UserFullName, + &i.UserIsActive, + &i.UserCreatedAt, + &i.UserUpdatedAt, + &i.ProfileUserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.ProfileCreatedAt, + &i.ProfileUpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserActiveStatus = `-- name: UpdateUserActiveStatus :one +UPDATE users +SET + is_active = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, email, password_hash, role, full_name, is_active, created_at, updated_at +` + +type UpdateUserActiveStatusParams struct { + ID int64 `json:"id"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) UpdateUserActiveStatus(ctx context.Context, arg UpdateUserActiveStatusParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserActiveStatus, arg.ID, arg.IsActive) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateUserFullName = `-- name: UpdateUserFullName :one +UPDATE users +SET + full_name = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, email, password_hash, role, full_name, is_active, created_at, updated_at +` + +type UpdateUserFullNameParams struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` +} + +func (q *Queries) UpdateUserFullName(ctx context.Context, arg UpdateUserFullNameParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserFullName, arg.ID, arg.FullName) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.FullName, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const upsertUserProfile = `-- name: UpsertUserProfile :one +INSERT INTO profiles ( + user_id, + preferred_name, + profile_icon_url, + headline, + bio, + timezone, + locale, + grade_level, + learning_goal +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9 +) +ON CONFLICT (user_id) DO UPDATE +SET + preferred_name = EXCLUDED.preferred_name, + profile_icon_url = EXCLUDED.profile_icon_url, + headline = EXCLUDED.headline, + bio = EXCLUDED.bio, + timezone = EXCLUDED.timezone, + locale = EXCLUDED.locale, + grade_level = EXCLUDED.grade_level, + learning_goal = EXCLUDED.learning_goal, + updated_at = NOW() +RETURNING user_id, preferred_name, profile_icon_url, headline, bio, timezone, locale, grade_level, learning_goal, created_at, updated_at +` + +type UpsertUserProfileParams struct { + UserID int64 `json:"user_id"` + PreferredName pgtype.Text `json:"preferred_name"` + ProfileIconUrl pgtype.Text `json:"profile_icon_url"` + Headline pgtype.Text `json:"headline"` + Bio pgtype.Text `json:"bio"` + Timezone pgtype.Text `json:"timezone"` + Locale pgtype.Text `json:"locale"` + GradeLevel pgtype.Text `json:"grade_level"` + LearningGoal pgtype.Text `json:"learning_goal"` +} + +func (q *Queries) UpsertUserProfile(ctx context.Context, arg UpsertUserProfileParams) (Profile, error) { + row := q.db.QueryRow(ctx, upsertUserProfile, + arg.UserID, + arg.PreferredName, + arg.ProfileIconUrl, + arg.Headline, + arg.Bio, + arg.Timezone, + arg.Locale, + arg.GradeLevel, + arg.LearningGoal, + ) + var i Profile + err := row.Scan( + &i.UserID, + &i.PreferredName, + &i.ProfileIconUrl, + &i.Headline, + &i.Bio, + &i.Timezone, + &i.Locale, + &i.GradeLevel, + &i.LearningGoal, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/Caddyfile b/Caddyfile index 4c87f33..7ca0819 100644 --- a/Caddyfile +++ b/Caddyfile @@ -7,7 +7,11 @@ respond "ok" 200 } + handle_path /api/* { + reverse_proxy {$BACKEND_UPSTREAM} + } + handle { reverse_proxy {$FRONTEND_UPSTREAM} } -} \ No newline at end of file +} diff --git a/Caddyfile.prod-a b/Caddyfile.prod-a new file mode 100644 index 0000000..3cb9e68 --- /dev/null +++ b/Caddyfile.prod-a @@ -0,0 +1,13 @@ +{$BASE_DOMAIN} { + handle /health { + respond "ok" 200 + } + + handle_path /api/* { + reverse_proxy {$BACKEND_UPSTREAM} + } + + handle { + reverse_proxy {$FRONTEND_UPSTREAM} + } +} diff --git a/Earthfile b/Earthfile new file mode 100644 index 0000000..e9e969c --- /dev/null +++ b/Earthfile @@ -0,0 +1,39 @@ +VERSION 0.8 + +frontend-node-base: + FROM node:24.12.0-alpine + WORKDIR /workspace/BoostAI/Frontend + RUN corepack enable && corepack prepare pnpm@10.24.0 --activate + COPY Frontend/package.json Frontend/pnpm-lock.yaml ./ + +frontend-deps: + FROM +frontend-node-base + RUN pnpm install --frozen-lockfile + +frontend-prod-image: + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +frontend-deps + COPY Frontend/. ./ + COPY Mock-Data ../Mock-Data + RUN pnpm build + + ENV NODE_ENV=production + ENV HOST=0.0.0.0 + ENV PORT=3000 + ENV NITRO_HOST=0.0.0.0 + ENV NITRO_PORT=3000 + EXPOSE 3000 + + ENTRYPOINT ["node", ".output/server/index.mjs"] + + SAVE IMAGE $IMAGE_NAME:$TAG + +frontend-prod-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +frontend-prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG diff --git a/Frontend/Earthfile b/Frontend/Earthfile index 4bb089a..30f913a 100644 --- a/Frontend/Earthfile +++ b/Frontend/Earthfile @@ -17,7 +17,6 @@ build: SAVE ARTIFACT dist AS LOCAL ./dist dev-image: - ARG REGISTRY="registry.mangopig.tech" ARG IMAGE_NAME="boost-ai/demo-frontend-dev" ARG TAG="latest" @@ -29,6 +28,38 @@ dev-image: EXPOSE 4321 SAVE IMAGE $IMAGE_NAME:$TAG + +dev-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-frontend-dev" + ARG TAG="latest" + + FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG + SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG + +prod-image: + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +deps + COPY . . + RUN pnpm build + + ENV NODE_ENV=production + ENV HOST=0.0.0.0 + ENV PORT=3000 + EXPOSE 3000 + + ENTRYPOINT ["pnpm", "start", "--host", "0.0.0.0", "--port", "3000"] + + SAVE IMAGE $IMAGE_NAME:$TAG + +prod-image-push: + ARG REGISTRY="registry.mangopig.tech" + ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a" + ARG TAG="latest" + + FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG # image: diff --git a/Frontend/public/brand/boost-ai-logo-purple.png b/Frontend/public/brand/boost-ai-logo-purple.png new file mode 100644 index 0000000..b07609d Binary files /dev/null and b/Frontend/public/brand/boost-ai-logo-purple.png differ diff --git a/Frontend/public/favicon.ico b/Frontend/public/favicon.ico index fb282da..2dc0718 100644 Binary files a/Frontend/public/favicon.ico and b/Frontend/public/favicon.ico differ diff --git a/Frontend/src/app.tsx b/Frontend/src/app.tsx index 4b3a49a..33f3da1 100644 --- a/Frontend/src/app.tsx +++ b/Frontend/src/app.tsx @@ -1,32 +1,53 @@ // Path: Frontend/src/app.tsx -import { Router, useLocation } from "@solidjs/router"; +import { Router, useLocation, useNavigate } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; -import { Suspense, Show, type ParentComponent } from "solid-js"; -import { Transition } from "solid-transition-group"; +import { Suspense, createEffect, type ParentComponent } from "solid-js"; +import { AuthProvider, useAuth } from "./context/auth/context"; import { ThemeProvider } from "./context/theme/context"; +import { getPostAuthRedirectHref, isPublicRoute } from "./lib/routes"; import "./styles/main.scss"; const AppRoot: ParentComponent = (props) => { const location = useLocation(); - const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/"); + const navigate = useNavigate(); + const auth = useAuth(); + const routeIsPublic = () => isPublicRoute(location.pathname); + + createEffect(() => { + if (!auth.isReady()) return; + + const pathname = location.pathname; + if (pathname === "/") return; + + if (pathname.startsWith("/auth/")) { + if (auth.user()) { + navigate(getPostAuthRedirectHref(auth.user()!.role), { replace: true }); + } + return; + } + + if (!auth.user()) { + navigate("/auth/login", { replace: true }); + } + }); return ( -
- - - {(path) => ( -
- {props.children} -
- )} -
-
-
+ {!auth.isReady() && !routeIsPublic() ? ( +
+
+ Loading your session… +
+
+ ) : ( +
+ {props.children} +
+ )}
); }; @@ -34,9 +55,11 @@ const AppRoot: ParentComponent = (props) => { export default function App() { return ( - - - + + + + + ); } diff --git a/Frontend/src/components/assignment/assignment-tabs.tsx b/Frontend/src/components/assignment/assignment-tabs.tsx deleted file mode 100644 index 03db768..0000000 --- a/Frontend/src/components/assignment/assignment-tabs.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { A, useLocation, useParams } from "@solidjs/router"; -import type { Component } from "solid-js"; -import styles from "../../routes/assignment/assignment-page.module.scss"; - -const AssignmentTabs: Component = () => { - const params = useParams(); - const location = useLocation(); - - const reviewHref = () => `/assignment/${params.id}`; - const workHref = () => `/assignment/${params.id}/work`; - const isWork = () => location.pathname === workHref(); - - return ( - - ); -}; - -export default AssignmentTabs; diff --git a/Frontend/src/components/assignment/assignment.data.ts b/Frontend/src/components/assignment/assignment.data.ts deleted file mode 100644 index d371972..0000000 --- a/Frontend/src/components/assignment/assignment.data.ts +++ /dev/null @@ -1,251 +0,0 @@ -import rawAssignments from "../../../../Mock-Data/assignments.json"; -import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json"; -import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json"; -import rawQuestionBank from "../../../../Mock-Data/question_bank.json"; -import rawStudentAnswers from "../../../../Mock-Data/student_answers.json"; -import rawStudents from "../../../../Mock-Data/students.json"; -import rawClassroom from "../../../../Mock-Data/classroom.json"; - -type Assignment = { - id: number; - name: string; - topic: string; - due_date: number; - status: "DRAFT" | "PUBLISHED" | "CLOSED"; - maximum_marks: number; -}; - -type AssignmentAssignee = { - id: number; - assignment_id: number; - student_id: number; - status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; - total_marks: number; - started_at: number | null; - submitted_at: number | null; -}; - -type AssignmentQuestion = { - id: number; - assignment_id: number; - question_bank_id: number; - question_order: number; - maximum_marks: number; -}; - -type QuestionBankItem = { - id: number; - topic: string; - sub_topic: string | null; - difficulty: "EASY" | "MEDIUM" | "HARD"; - question_text: string; - correct_answer: string; - step_by_step_solution: string | null; -}; - -type StudentAnswer = { - assignee_id: number; - assignment_question_id: number; - extracted_answer: string; - ai_reasoning: string; - _solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; - _is_correct: boolean; - _time_on_task_seconds: number; -}; - -type Student = { - id: number; - fullname: string; - _persona: string; -}; - -type ClassroomFile = { - classroom: { - name: string; - target_level: number; - }; - tutor: { - fullname: string; - }; -}; - -export type AssignmentPageData = { - id: number; - title: string; - topic: string; - status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; - statusLabel: string; - dueLabel: string; - studentName: string; - classroomName: string; - tutorName: string; - headline: string; - description: string; - primaryAction: string; - primaryHref: string; - stats: Array<{ label: string; value: string }>; - coachCard: { - title: string; - description: string; - items: string[]; - }; - questions: Array<{ - id: number; - order: number; - prompt: string; - topic: string; - subTopic: string | null; - difficulty: "EASY" | "MEDIUM" | "HARD"; - marks: number; - statusLabel: string; - statusTone: "success" | "warning" | "muted"; - responseLabel: string; - responseValue: string; - feedback: string; - solveModeLabel?: string; - initialAnswer?: string; - initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; - showAnswerKey: boolean; - correctAnswer: string; - }>; -}; - -const assignments = rawAssignments as Assignment[]; -const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[]; -const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[]; -const questionBank = rawQuestionBank as QuestionBankItem[]; -const studentAnswers = rawStudentAnswers as StudentAnswer[]; -const students = rawStudents as Student[]; -const classroomFile = rawClassroom as ClassroomFile; - -const defaultStudentId = 201; - -const assignmentById = new Map(assignments.map((entry) => [entry.id, entry])); -const questionById = new Map(questionBank.map((entry) => [entry.id, entry])); -const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0]; - -const formatDate = (timestamp: number) => - new Intl.DateTimeFormat("en-GB", { - weekday: "short", - month: "short", - day: "numeric", - }).format(new Date(timestamp)); - -const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => { - switch (value) { - case "just_answer": - return "Just answer"; - case "step_by_step": - return "Step by step"; - case "solve_together": - return "Solve together"; - case "handwritten": - return "Handwritten"; - } -}; - -const personaHint = (persona: string) => { - switch (persona) { - case "fraction_inversion": - return "Slow down on fraction rules and check each step before moving on."; - case "place_value_gaps": - return "Check place value carefully before you calculate the final answer."; - case "rushed_careless": - return "Pause before submitting so small slips do not cost easy marks."; - case "solve_together_dependent": - return "Try one independent attempt first, then ask for guided help if you need it."; - case "word_problem_weak": - return "Underline the key numbers and turn the sentence into a maths step first."; - default: - return "Work through one question at a time and keep your method tidy."; - } -}; - -export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => { - const assignment = assignmentById.get(assignmentId); - if (!assignment) return null; - - const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id); - if (!assignee) return null; - - const assignmentQuestionRows = assignmentQuestions - .filter((entry) => entry.assignment_id === assignment.id) - .sort((left, right) => left.question_order - right.question_order) - .map((entry) => { - const question = questionById.get(entry.question_bank_id); - if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`); - - const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id); - - return { entry, question, answer }; - }); - - const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length; - const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length; - const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0; - - const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started"; - const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment"; - - const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({ - id: entry.id, - order: entry.question_order, - prompt: question.question_text, - topic: question.topic, - subTopic: question.sub_topic, - difficulty: question.difficulty, - marks: entry.maximum_marks, - statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered", - statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted", - responseLabel: answer ? "Your latest answer" : "Status", - responseValue: answer ? answer.extracted_answer : "No attempt yet", - feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.", - solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined, - initialAnswer: answer?.extracted_answer, - initialSolveMode: answer?._solve_mode, - showAnswerKey: assignee.status === "SUBMITTED", - correctAnswer: question.correct_answer, - })); - - return { - id: assignment.id, - title: assignment.name, - topic: assignment.topic, - status: assignee.status, - statusLabel, - dueLabel: formatDate(assignment.due_date), - studentName: student.fullname, - classroomName: classroomFile.classroom.name, - tutorName: classroomFile.tutor.fullname, - headline: - assignee.status === "SUBMITTED" - ? `Review how you did in ${assignment.topic}` - : assignee.status === "IN_PROGRESS" - ? `Keep going — you are already part way through` - : `Start this assignment with a steady first pass`, - description: - assignee.status === "SUBMITTED" - ? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.` - : assignee.status === "IN_PROGRESS" - ? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.` - : `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`, - primaryAction, - primaryHref: `/assignment/${assignment.id}/work`, - stats: [ - { label: "Status", value: statusLabel }, - { label: "Due", value: formatDate(assignment.due_date) }, - { label: "Questions", value: `${assignmentQuestionRows.length}` }, - { label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` }, - ], - coachCard: { - title: "How to approach this one", - description: personaHint(student._persona), - items: [ - `${assignment.topic} focus`, - `${accuracy}% accuracy so far`, - `${correctCount} correct answers logged`, - ], - }, - questions, - }; -}; diff --git a/Frontend/src/components/assignment/assignment-header.module.scss b/Frontend/src/components/assignment/shared/assignment-header.module.scss similarity index 65% rename from Frontend/src/components/assignment/assignment-header.module.scss rename to Frontend/src/components/assignment/shared/assignment-header.module.scss index 7cb3a76..c405c25 100644 --- a/Frontend/src/components/assignment/assignment-header.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-header.module.scss @@ -1,14 +1,24 @@ +/* Path: Frontend/src/components/assignment/shared/assignment-header.module.scss */ + .headerCard { display: grid; gap: 1.1rem; padding: 1.5rem; - border-radius: 1.5rem; + border-radius: var(--radius-3xl); background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end)); color: var(--text-on-accent); border: 1px solid var(--border-overlay); box-shadow: var(--shadow-elevated); } +@media (max-width: 768px) { + .headerCard { + gap: 0.8rem; + padding: 1rem; + border-radius: var(--radius-2xl); + } +} + .headerTop { display: flex; align-items: center; @@ -16,13 +26,21 @@ gap: 1rem; } +@media (max-width: 768px) { + .headerTop { + align-items: flex-start; + flex-wrap: wrap; + gap: 0.7rem; + } +} + .backLink, .statusPill { display: inline-flex; align-items: center; justify-content: center; padding: 0.55rem 0.85rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: var(--surface-overlay-soft); border: 1px solid var(--border-overlay); color: var(--text-on-accent); @@ -47,6 +65,21 @@ } } +@media (max-width: 768px) { + .copy { + gap: 0.22rem; + } + + .copy h1 { + font-size: clamp(1.5rem, 1.15rem + 1.35vw, 2rem); + line-height: 1.04; + } + + .copy > p:not(.eyebrow) { + display: none; + } +} + .eyebrow { text-transform: uppercase; letter-spacing: 0.1em; diff --git a/Frontend/src/components/assignment/assignment-header.tsx b/Frontend/src/components/assignment/shared/assignment-header.tsx similarity index 70% rename from Frontend/src/components/assignment/assignment-header.tsx rename to Frontend/src/components/assignment/shared/assignment-header.tsx index ff39c41..6580352 100644 --- a/Frontend/src/components/assignment/assignment-header.tsx +++ b/Frontend/src/components/assignment/shared/assignment-header.tsx @@ -1,17 +1,21 @@ -import type { Component } from "solid-js"; +// Path: Frontend/src/components/assignment/shared/assignment-header.tsx + import { A } from "@solidjs/router"; -import type { AssignmentPageData } from "./assignment.data"; +import type { Component } from "solid-js"; +import { getDashboardHomeHref } from "../../../lib/routes"; import styles from "./assignment-header.module.scss"; +import type { AssignmentPageData } from "./assignment-types"; type Props = { data: AssignmentPageData; + backHref?: string; }; const AssignmentHeader: Component = (props) => { return (
- + Back to dashboard {props.data.statusLabel} diff --git a/Frontend/src/components/assignment/assignment-overview.module.scss b/Frontend/src/components/assignment/shared/assignment-overview.module.scss similarity index 66% rename from Frontend/src/components/assignment/assignment-overview.module.scss rename to Frontend/src/components/assignment/shared/assignment-overview.module.scss index b5f464d..3643ff1 100644 --- a/Frontend/src/components/assignment/assignment-overview.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-overview.module.scss @@ -1,3 +1,5 @@ +/* Path: Frontend/src/components/assignment/shared/assignment-overview.module.scss */ + .stack { display: grid; gap: 1rem; @@ -7,7 +9,7 @@ display: grid; gap: 1rem; padding: 1.2rem; - border-radius: 1.35rem; + border-radius: var(--radius-2xl); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -39,7 +41,7 @@ display: grid; gap: 0.25rem; padding: 0.9rem; - border-radius: 1rem; + border-radius: var(--radius-md); background: var(--surface-panel-strong); border: 1px solid var(--border-divider); @@ -76,10 +78,35 @@ align-items: center; justify-content: center; padding: 0.9rem 1rem; - border-radius: 1rem; + border-radius: var(--radius-md); background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); color: var(--action-primary-text); font-weight: 600; box-shadow: var(--action-primary-shadow); text-decoration: none; } + +.feedbackBlock { + display: grid; + gap: 0.45rem; + padding: 0.95rem 1rem; + border-radius: var(--radius-md); + background: var(--surface-panel-strong); + border: 1px solid var(--border-divider); +} + +.feedbackLabel { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 700; +} + +.feedbackCopy { + font-size: 0.94rem; + line-height: 1.55; + color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/Frontend/src/components/assignment/assignment-overview.tsx b/Frontend/src/components/assignment/shared/assignment-overview.tsx similarity index 58% rename from Frontend/src/components/assignment/assignment-overview.tsx rename to Frontend/src/components/assignment/shared/assignment-overview.tsx index 7fd1c32..d4f873b 100644 --- a/Frontend/src/components/assignment/assignment-overview.tsx +++ b/Frontend/src/components/assignment/shared/assignment-overview.tsx @@ -1,8 +1,10 @@ +// Path: Frontend/src/components/assignment/shared/assignment-overview.tsx + +import { A } from "@solidjs/router"; import type { Component } from "solid-js"; import { For } from "solid-js"; -import { A } from "@solidjs/router"; -import type { AssignmentPageData } from "./assignment.data"; import styles from "./assignment-overview.module.scss"; +import type { AssignmentPageData } from "./assignment-types"; type Props = { data: AssignmentPageData; @@ -45,6 +47,29 @@ const AssignmentOverview: Component = (props) => { {props.data.primaryAction}
+ + {(props.data.assignmentAiFeedback || props.data.assignmentTeacherFeedback) && ( +
+
+

Shared assignment feedback

+

{props.data.tutorName}

+
+ + {props.data.assignmentAiFeedback && ( +
+

AI feedback

+

{props.data.assignmentAiFeedback}

+
+ )} + + {props.data.assignmentTeacherFeedback && ( +
+

Teacher feedback

+

{props.data.assignmentTeacherFeedback}

+
+ )} +
+ )} ); }; diff --git a/Frontend/src/routes/assignment/assignment-page.module.scss b/Frontend/src/components/assignment/shared/assignment-page.module.scss similarity index 89% rename from Frontend/src/routes/assignment/assignment-page.module.scss rename to Frontend/src/components/assignment/shared/assignment-page.module.scss index 01410d3..96246b4 100644 --- a/Frontend/src/routes/assignment/assignment-page.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-page.module.scss @@ -1,3 +1,5 @@ +/* Path: Frontend/src/components/assignment/shared/assignment-page.module.scss */ + .page { min-height: 100dvh; padding: 1.25rem; @@ -18,7 +20,7 @@ display: grid; gap: 1.25rem; - @media (min-width: 1080px) { + @include respond(workspace) { grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr); align-items: start; } @@ -34,7 +36,7 @@ align-items: center; gap: 0.4rem; padding: 0.35rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -52,7 +54,7 @@ align-items: center; justify-content: center; padding: 0.72rem 1rem; - border-radius: 9999px; + border-radius: var(--radius-full); text-decoration: none; color: var(--text-muted); font-weight: 500; @@ -80,7 +82,7 @@ } .sideColumn { - @media (min-width: 1080px) { + @include respond(workspace) { position: sticky; top: 1.25rem; } @@ -90,7 +92,7 @@ display: grid; gap: 0.8rem; padding: 2rem; - border-radius: 1.5rem; + border-radius: var(--radius-3xl); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -119,7 +121,7 @@ align-items: center; justify-content: center; padding: 0.85rem 1rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); color: var(--action-primary-text); text-decoration: none; diff --git a/Frontend/src/components/assignment/assignment-question-list.module.scss b/Frontend/src/components/assignment/shared/assignment-question-list.module.scss similarity index 87% rename from Frontend/src/components/assignment/assignment-question-list.module.scss rename to Frontend/src/components/assignment/shared/assignment-question-list.module.scss index 04de8bc..ca5be72 100644 --- a/Frontend/src/components/assignment/assignment-question-list.module.scss +++ b/Frontend/src/components/assignment/shared/assignment-question-list.module.scss @@ -30,7 +30,7 @@ display: grid; gap: 0.85rem; padding: 1.2rem; - border-radius: 1.35rem; + border-radius: var(--radius-2xl); background: var(--surface-panel); border: 1px solid var(--border-soft); box-shadow: var(--shadow-soft); @@ -59,7 +59,7 @@ .statusPill { padding: 0.45rem 0.75rem; - border-radius: 9999px; + border-radius: var(--radius-full); font-size: 0.82rem; font-weight: 600; white-space: nowrap; @@ -87,7 +87,7 @@ span { padding: 0.35rem 0.6rem; - border-radius: 9999px; + border-radius: var(--radius-full); background: var(--surface-panel-strong); border: 1px solid var(--border-divider); font-size: 0.82rem; @@ -96,11 +96,12 @@ } .responseBlock, -.answerKey { +.answerKey, +.supportBlock { display: grid; gap: 0.25rem; padding: 0.95rem 1rem; - border-radius: 1rem; + border-radius: var(--radius-md); background: var(--surface-panel-strong); border: 1px solid var(--border-divider); @@ -116,8 +117,12 @@ font-weight: 600; } - span { + span, + pre { font-size: 0.9rem; color: var(--text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0; } } diff --git a/Frontend/src/components/assignment/assignment-question-list.tsx b/Frontend/src/components/assignment/shared/assignment-question-list.tsx similarity index 65% rename from Frontend/src/components/assignment/assignment-question-list.tsx rename to Frontend/src/components/assignment/shared/assignment-question-list.tsx index 7538126..0498292 100644 --- a/Frontend/src/components/assignment/assignment-question-list.tsx +++ b/Frontend/src/components/assignment/shared/assignment-question-list.tsx @@ -1,7 +1,9 @@ +// Path: Frontend/src/components/assignment/shared/assignment-question-list.tsx + import type { Component } from "solid-js"; import { For, Show } from "solid-js"; -import type { AssignmentPageData } from "./assignment.data"; import styles from "./assignment-question-list.module.scss"; +import type { AssignmentPageData } from "./assignment-types"; type Props = { data: AssignmentPageData; @@ -11,8 +13,8 @@ const AssignmentQuestionList: Component = (props) => { return (
-

Sample questions

-

{props.data.questions.length} loaded from the mock dataset

+

Question review

+

{props.data.questions.length} questions in this assignment

@@ -32,8 +34,12 @@ const AssignmentQuestionList: Component = (props) => { {question.subTopic} - {question.difficulty} - {question.marks} mark + + {question.difficulty} + + + {question.marks} mark + {question.solveModeLabel} @@ -42,12 +48,21 @@ const AssignmentQuestionList: Component = (props) => {

{question.responseLabel}

{question.responseValue} - {question.feedback} + + {question.feedback} +
+ +
+

Your steps and explanation

+
{question.workingSteps}
+
+
+
-

Answer key

+

Correct answer

{question.correctAnswer}
diff --git a/Frontend/src/components/assignment/shared/assignment-tabs.tsx b/Frontend/src/components/assignment/shared/assignment-tabs.tsx new file mode 100644 index 0000000..b87002c --- /dev/null +++ b/Frontend/src/components/assignment/shared/assignment-tabs.tsx @@ -0,0 +1,39 @@ +// Path: Frontend/src/components/assignment/shared/assignment-tabs.tsx + +import { A, useLocation, useParams } from "@solidjs/router"; +import type { Component } from "solid-js"; +import type { AppRole } from "../../../lib/routes"; +import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes"; +import styles from "./assignment-page.module.scss"; + +type AssignmentTabsProps = { + showWork?: boolean; + role?: AppRole; +}; + +const AssignmentTabs: Component = (props) => { + const params = useParams(); + const location = useLocation(); + const role = () => props.role ?? (location.pathname.includes("/teacher/") ? "teacher" : "student"); + + const reviewHref = () => getAssignmentReviewHref(role(), params.id as string); + const workHref = () => getAssignmentWorkHref(params.id as string); + const isWork = () => location.pathname === workHref(); + + return ( + + ); +}; + +export default AssignmentTabs; diff --git a/Frontend/src/components/assignment/shared/assignment-types.ts b/Frontend/src/components/assignment/shared/assignment-types.ts new file mode 100644 index 0000000..4b7b5b7 --- /dev/null +++ b/Frontend/src/components/assignment/shared/assignment-types.ts @@ -0,0 +1,46 @@ +// Path: Frontend/src/components/assignment/shared/assignment-types.ts + +export type AssignmentPageData = { + id: number; + title: string; + topic: string; + status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED"; + statusLabel: string; + dueLabel: string; + studentName: string; + classroomName: string; + tutorName: string; + headline: string; + description: string; + primaryAction: string; + primaryHref: string; + stats: Array<{ label: string; value: string }>; + coachCard: { + title: string; + description: string; + items: string[]; + }; + assignmentAiFeedback?: string; + assignmentTeacherFeedback?: string; + questions: Array<{ + id: number; + order: number; + prompt: string; + topic: string; + subTopic: string | null; + difficulty: string | null; + marks: number | null; + statusLabel: string; + statusTone: "success" | "warning" | "muted"; + responseLabel: string; + responseValue: string; + feedback: string; + solveModeLabel?: string; + workingSteps?: string; + initialAnswer?: string; + initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten"; + showAnswerKey: boolean; + correctAnswer: string | null; + isCorrect?: boolean | null; + }>; +}; diff --git a/Frontend/src/components/assignment/student/assignment-review.data.ts b/Frontend/src/components/assignment/student/assignment-review.data.ts new file mode 100644 index 0000000..0512a48 --- /dev/null +++ b/Frontend/src/components/assignment/student/assignment-review.data.ts @@ -0,0 +1,204 @@ +// Path: Frontend/src/components/assignment/student/assignment-review.data.ts + +import { apiFetchJson } from "../../../lib/api"; +import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types"; +import { getAssignmentWorkHref } from "../../../lib/routes"; +import type { AssignmentPageData } from "../shared/assignment-types"; + +const formatDateLabel = (value: string | null) => { + if (!value) return "No due date"; + + return new Intl.DateTimeFormat("en-GB", { + weekday: "short", + month: "short", + day: "numeric", + }).format(new Date(value)); +}; + +const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => { + const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim(); + if (fromInstructions) return fromInstructions; + + return questions[0]?.subject ?? "Assignment"; +}; + +const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => { + const total = questions.length; + const answered = questions.filter((question) => question.answer_id).length; + const reviewed = questions.filter((question) => question.answer_status === "reviewed").length; + + if (answered === 0) return "NOT_STARTED" as const; + if (reviewed === total || assignmentStatus === "closed") return "SUBMITTED" as const; + return "IN_PROGRESS" as const; +}; + +const deriveStatusLabel = (status: AssignmentPageData["status"]) => { + switch (status) { + case "SUBMITTED": + return "Reviewed"; + case "IN_PROGRESS": + return "In progress"; + default: + return "Not started"; + } +}; + +const deriveHeadline = (status: AssignmentPageData["status"], topic: string) => { + switch (status) { + case "SUBMITTED": + return `Review how you did in ${topic}`; + case "IN_PROGRESS": + return "Keep going — you already have momentum here"; + default: + return "Start this assignment with a steady first pass"; + } +}; + +const deriveDescription = (status: AssignmentPageData["status"], answered: number, reviewed: number, total: number, topic: string) => { + switch (status) { + case "SUBMITTED": + return `${reviewed} of ${total} questions have teacher-reviewed answers. Use this review view to see what landed well and what still needs another pass in ${topic}.`; + case "IN_PROGRESS": + return `You have touched ${answered} of ${total} questions so far. Finish the remaining questions while ${topic.toLowerCase()} still feels familiar.`; + default: + return `This assignment has ${total} questions. Start with a calm first pass, then come back here for one shared review summary across the assignment.`; + } +}; + +const derivePrimaryAction = (status: AssignmentPageData["status"]) => { + switch (status) { + case "SUBMITTED": + return "Open workspace"; + case "IN_PROGRESS": + return "Continue assignment"; + default: + return "Start assignment"; + } +}; + +const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => { + if (question.is_correct === true) { + return { + statusLabel: "Correct", + statusTone: "success" as const, + }; + } + + if (question.answer_status === "reviewed") { + return { + statusLabel: "Reviewed", + statusTone: "success" as const, + }; + } + + if (question.answer_status === "submitted") { + return { + statusLabel: "Submitted", + statusTone: "warning" as const, + }; + } + + if (question.answer_status === "in_progress") { + return { + statusLabel: "In progress", + statusTone: "warning" as const, + }; + } + + return { + statusLabel: "Not started", + statusTone: "muted" as const, + }; +}; + +export const getAssignmentReviewPageData = async (assignmentId: number, studentId: number): Promise => { + if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null; + + try { + const assignment = await apiFetchJson(`/api/assignments/${assignmentId}`); + + const [student, teacher, classrooms, questionDetails] = await Promise.all([ + apiFetchJson(`/api/users/${studentId}`), + apiFetchJson(`/api/users/${assignment.teacher_id}`), + apiFetchJson>(`/api/teachers/${assignment.teacher_id}/classrooms`), + apiFetchJson>(`/api/assignments/${assignmentId}/students/${studentId}/questions`), + ]); + + const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id); + const questions = questionDetails.data; + const topic = extractTopic(assignment, questions); + const answeredCount = questions.filter((question) => question.answer_id).length; + const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length; + const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || ""; + const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || ""; + const status = deriveStudentStatus(questions, assignment.status); + const statusLabel = deriveStatusLabel(status); + + return { + id: assignment.id, + title: assignment.title, + topic, + status, + statusLabel, + dueLabel: formatDateLabel(assignment.due_at), + studentName: student.full_name, + classroomName: classroom?.name ?? "Classroom", + tutorName: teacher.full_name, + headline: deriveHeadline(status, topic), + description: deriveDescription(status, answeredCount, reviewedCount, questions.length, topic), + primaryAction: derivePrimaryAction(status), + primaryHref: getAssignmentWorkHref(assignment.id), + stats: [ + { label: "Status", value: statusLabel }, + { label: "Due", value: formatDateLabel(assignment.due_at) }, + { label: "Questions", value: `${questions.length}` }, + { label: status === "SUBMITTED" ? "Reviewed" : "Answered", value: `${status === "SUBMITTED" ? reviewedCount : answeredCount}/${questions.length}` }, + ], + coachCard: { + title: "Review notes", + description: `Use this review view to compare your latest answers with the shared AI and teacher feedback for ${topic.toLowerCase()}.`, + items: [`${answeredCount} questions attempted`, `${reviewedCount} questions reviewed`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`], + }, + assignmentAiFeedback: assignmentAiFeedback || undefined, + assignmentTeacherFeedback: assignmentTeacherFeedback || undefined, + questions: questions.map((question) => { + const questionStatus = mapQuestionStatus(question); + + return { + id: question.question_id, + order: question.position, + prompt: question.prompt, + topic: question.subject, + subTopic: null, + difficulty: "Backend", + marks: null, + statusLabel: questionStatus.statusLabel, + statusTone: questionStatus.statusTone, + responseLabel: question.answer_id ? "Latest answer" : "Status", + responseValue: question.answer_text?.trim() || "No attempt yet", + feedback: "", + workingSteps: question.working_steps?.trim() || "", + solveModeLabel: + question.solve_mode === "step_by_step" + ? "Step by step" + : question.solve_mode === "solve_together" + ? "Solve together" + : question.solve_mode === "handwritten" + ? "Handwritten" + : question.solve_mode === "just_answer" + ? "Just answer" + : undefined, + showAnswerKey: Boolean(question.correct_answer && question.answer_id), + correctAnswer: question.correct_answer?.trim() || null, + isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null, + }; + }), + }; + } catch (error) { + if (error instanceof Error && error.message === "not_found") { + return null; + } + + throw error; + } +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx new file mode 100644 index 0000000..61ba5d0 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-next-step.tsx @@ -0,0 +1,281 @@ +import type { Component } from "solid-js"; +import { A, useNavigate } from "@solidjs/router"; +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import type { TeacherAssignmentPassStatus, TeacherAssignmentReviewPageData, TeacherNextStepOutcome } from "./assignment-teacher-review.data"; +import { updateAssignmentTeacherFeedback } from "./assignment-teacher-review.data"; +import { AssignmentFeedbackSection, type TeacherReviewNotice } from "./assignment-teacher-review.sections"; +import { getAssignmentReviewHref, getTeacherAssignmentRedoPlanHref } from "../../../lib/routes"; +import styles from "./assignment-teacher-review.module.scss"; + +type Props = { + data: TeacherAssignmentReviewPageData; +}; + +type NextStepDraft = { + teacherFeedback?: string; + decision?: TeacherNextStepOutcome; + passStatusOverride?: TeacherAssignmentPassStatus | null; +}; + +const PASS_STATUS_OVERRIDE_OPTIONS: Array<{ value: TeacherAssignmentPassStatus | null; label: string; help: string }> = [ + { value: null, label: "Automatic", help: "Use the calculated status from the fixed pass rule." }, + { value: "pass", label: "Pass", help: "Override the calculated result and mark this review as pass." }, + { value: "no_pass", label: "No pass", help: "Override the calculated result and mark this review as no pass." }, +]; + +const nextStepStorageKey = (assignmentId: number, studentId: number) => `teacher-next-step-draft:${assignmentId}:${studentId}`; + +const readNextStepDraft = (assignmentId: number, studentId: number): NextStepDraft => { + if (typeof window === "undefined") return {}; + + try { + const raw = window.localStorage.getItem(nextStepStorageKey(assignmentId, studentId)); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as NextStepDraft) : {}; + } catch { + return {}; + } +}; + +const writeNextStepDraft = (assignmentId: number, studentId: number, draft: NextStepDraft) => { + if (typeof window === "undefined") return; + const key = nextStepStorageKey(assignmentId, studentId); + if (!draft.teacherFeedback && !draft.decision && draft.passStatusOverride == null) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(draft)); +}; + +const OUTCOME_OPTIONS: Array<{ value: TeacherNextStepOutcome; title: string; description: string }> = [ + { value: "redo", title: "Redo assignment", description: "Ask the student to revisit the assignment and try again with the latest review in mind." }, + { value: "accept", title: "Accept and continue", description: "Mark this review as ready to move on and continue the student into the next piece of work." }, + { value: "support", title: "Needs support", description: "Flag that the student needs extra coaching, a follow-up message, or a guided reteach step." }, +]; + +const AssignmentTeacherNextStep: Component = (props) => { + const navigate = useNavigate(); + const [teacherFeedbackDraft, setTeacherFeedbackDraft] = createSignal(""); + const [decisionDraft, setDecisionDraft] = createSignal(null); + const [passStatusOverrideDraft, setPassStatusOverrideDraft] = createSignal(null); + const [savingFeedback, setSavingFeedback] = createSignal(false); + const [notice, setNotice] = createSignal(null); + + const selectedStudentId = createMemo(() => props.data.selectedStudentId); + const canChooseNextStep = createMemo(() => { + return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0; + }); + const hasPendingAssignmentFeedback = createMemo(() => teacherFeedbackDraft() !== props.data.assignmentTeacherFeedback); + const hasPendingDecision = createMemo(() => decisionDraft() !== props.data.nextStepOutcome); + const hasPendingPassStatusOverride = createMemo(() => passStatusOverrideDraft() !== props.data.passStatusOverride); + const overallScorePercent = createMemo(() => { + if (props.data.overallScore == null) return null; + return Math.round(props.data.overallScore * 10); + }); + + createEffect(() => { + const studentId = selectedStudentId(); + if (!studentId) return; + const draft = readNextStepDraft(props.data.assignmentId, studentId); + setTeacherFeedbackDraft(draft.teacherFeedback ?? props.data.assignmentTeacherFeedback); + setDecisionDraft(draft.decision ?? props.data.nextStepOutcome); + setPassStatusOverrideDraft(draft.passStatusOverride ?? props.data.passStatusOverride); + setNotice(null); + }); + + createEffect(() => { + const studentId = selectedStudentId(); + if (!studentId) return; + writeNextStepDraft(props.data.assignmentId, studentId, { + teacherFeedback: hasPendingAssignmentFeedback() ? teacherFeedbackDraft() : undefined, + decision: hasPendingDecision() ? decisionDraft() ?? undefined : undefined, + passStatusOverride: hasPendingPassStatusOverride() ? passStatusOverrideDraft() : undefined, + }); + }); + + const saveFeedback = async () => { + const studentId = selectedStudentId(); + if (!studentId) return; + if (!canChooseNextStep()) { + setNotice({ + scope: "assignment", + tone: "error", + text: "Next step stays locked until the student has submitted work for review.", + }); + return; + } + + setSavingFeedback(true); + setNotice(null); + try { + if (hasPendingAssignmentFeedback() || hasPendingPassStatusOverride() || hasPendingDecision()) { + await updateAssignmentTeacherFeedback(props.data.assignmentId, studentId, { + teacherFeedback: teacherFeedbackDraft().trim(), + passStatusOverride: passStatusOverrideDraft(), + nextStepOutcome: decisionDraft(), + }); + } + + writeNextStepDraft(props.data.assignmentId, studentId, {}); + if (decisionDraft() === "redo") { + navigate(getTeacherAssignmentRedoPlanHref(props.data.assignmentId, studentId)); + return; + } + navigate(getAssignmentReviewHref("teacher", props.data.assignmentId)); + } catch (error) { + setNotice({ + scope: "assignment", + tone: "error", + text: error instanceof Error ? error.message : "Could not save teacher feedback right now.", + }); + } finally { + setSavingFeedback(false); + } + }; + + return ( +
+
+
+
+

Choose the next step

+

+ + {props.data.selectedStudentName} has been reviewed. Choose what should happen next, then save your feedback at the bottom before returning to the review page. + +

+
+ + Pick a student from the review queue first so you can set the right next step.
}> + This student has not submitted work yet, so next step is not available.
} + > +
+
+

Recommended outcome

+

Pick the next teaching move for {props.data.selectedStudentName ?? "this student"}. The selected outcome will be saved with this student's review.

+
+ +
+ + {(option) => ( + + )} + +
+ +
+ No next-step outcome has been picked yet.}> + Selected outcome: {OUTCOME_OPTIONS.find((option) => option.value === decisionDraft())?.title}. Save feedback to persist it for this student. + +
+ +
+
+ +
+ Pending review}> + {overallScorePercent()}% + +
+ + + Blended correctness and understanding score. + + +
+ +
+ + + + + Teacher override is active. + + +
+ +
+ + + {PASS_STATUS_OVERRIDE_OPTIONS.find((option) => option.value === passStatusOverrideDraft())?.help ?? PASS_STATUS_OVERRIDE_OPTIONS[0]!.help} +
+
+
+ + + {savingFeedback() + ? decisionDraft() === "redo" + ? "Saving and opening redo plan..." + : "Saving and returning..." + : decisionDraft() === "redo" + ? "Save feedback and open redo plan" + : "Save feedback and return to review"} + + } + /> + + +
+ + + + + ); +}; + +export default AssignmentTeacherNextStep; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts new file mode 100644 index 0000000..4473953 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.helpers.ts @@ -0,0 +1,107 @@ +import { createTeacherAssignment, generateTeacherQuestions } from "../../dashboard/teacher/dashboard-teacher-assignments.data"; +import type { TeacherRedoPlanData, TeacherRedoPlanQuestion } from "./assignment-teacher-review.types"; +import type { RedoPlanGenerationResult, RedoPlanGroupedItem } from "./assignment-teacher-redo-plan.types"; + +const compareGroupedItems = (left: RedoPlanGroupedItem, right: RedoPlanGroupedItem) => { + if (left.topic !== right.topic) return left.topic.localeCompare(right.topic); + return left.difficulty.localeCompare(right.difficulty); +}; + +export const groupRedoPlanQuestions = (questionSet: TeacherRedoPlanQuestion[]): RedoPlanGroupedItem[] => { + const groupedItems = new Map< + string, + { + topic: string; + difficulty: "easy" | "medium" | "hard"; + count: number; + tags: Set; + reasons: string[]; + } + >(); + + for (const item of questionSet) { + const key = `${item.topicKey}::${item.difficultyKey}`; + const existing = groupedItems.get(key); + if (existing) { + existing.count += 1; + item.tags.forEach((tag) => existing.tags.add(tag)); + existing.reasons.push(item.reason); + continue; + } + + groupedItems.set(key, { + topic: item.topicKey, + difficulty: item.difficultyKey, + count: 1, + tags: new Set(item.tags), + reasons: [item.reason], + }); + } + + return Array.from(groupedItems.values()) + .map((item) => ({ + topic: item.topic, + difficulty: item.difficulty, + count: item.count, + tags: Array.from(item.tags), + reasons: item.reasons, + })) + .sort(compareGroupedItems); +}; + +export const buildPlannedAreasMarkdown = (groupedItems: RedoPlanGroupedItem[]) => + groupedItems + .map( + (item, index) => + `- ${index + 1}. ${item.topic.replace(/_/g, " ")} (${item.difficulty}) x${item.count}${item.reasons.length > 0 ? ` — ${item.reasons.join("; ")}` : ""}`, + ) + .join("\n"); + +export const createRedoAssignmentForStudent = async (input: { + data: TeacherRedoPlanData; + teacherId: number; +}): Promise => { + const groupedItems = groupRedoPlanQuestions(input.data.plan?.questionSet ?? []); + const plannedAreas = buildPlannedAreasMarkdown(groupedItems); + const generatedQuestionIds: number[] = []; + const generatedDescriptions: string[] = []; + + for (const item of groupedItems) { + const result = await generateTeacherQuestions({ + topic: item.topic, + difficulty: item.difficulty, + count: item.count, + source: "redo_plan_generated", + }); + + generatedQuestionIds.push(...result.generatedQuestionIds); + generatedDescriptions.push(`${item.topic.replace(/_/g, " ")} ${item.difficulty} ×${result.count} (seed ${result.seed})`); + } + + const assignment = await createTeacherAssignment({ + teacherId: input.teacherId, + classroomId: input.data.classroomId, + title: `${input.data.selectedStudentName} redo • ${input.data.title}`, + instructions: [ + `Student-specific redo follow-up for ${input.data.selectedStudentName}.`, + `Source assignment: ${input.data.title}`, + "", + `Plan rationale: ${input.data.plan?.rationale ?? ""}`, + input.data.teacherFeedback ? `Teacher feedback: ${input.data.teacherFeedback}` : "", + input.data.weaknessSummary.weakTags.length > 0 ? `Weak tags: ${input.data.weaknessSummary.weakTags.join(", ")}` : "", + "", + "Planned focus areas:", + plannedAreas, + ] + .filter(Boolean) + .join("\n"), + dueAt: "", + selectedQuestionIds: Array.from(new Set(generatedQuestionIds)), + assignedStudentIds: [input.data.selectedStudentId], + }); + + return { + assignmentId: assignment.id, + successMessage: `Created a redo assignment for ${input.data.selectedStudentName}. ${generatedDescriptions.join("; ")}.`, + }; +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx new file mode 100644 index 0000000..d14c245 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.sections.tsx @@ -0,0 +1,177 @@ +import type { Component } from "solid-js"; +import { A } from "@solidjs/router"; +import { For, Show } from "solid-js"; +import { getAssignmentReviewHref, getTeacherAssignmentNextStepHref } from "../../../lib/routes"; +import type { TeacherRedoPlanData } from "./assignment-teacher-review.types"; +import styles from "./assignment-teacher-review.module.scss"; + +type RedoPlanMainColumnProps = { + data: TeacherRedoPlanData; +}; + +export const RedoPlanMainColumn: Component = (props) => ( +
+
+
+

Redo plan

+

+ AI prepared this redo plan for {props.data.selectedStudentName} based on the completed review, weakness summary, and teacher feedback. +

+
+ + +
{props.data.error}
+
+ + No redo plan is available yet. Save the next-step page with Redo assignment selected to generate one.
} + > +
+
+

Rationale

+

Why this student needs the planned mix of follow-up practice.

+
+
+ {props.data.plan!.rationale} +
+
+ +
+
+

Planned question set

+

These blueprint slices will be turned into a student-specific redo assignment when you generate it.

+
+
+ + {(item, index) => ( +
+
+
+

Item {index() + 1}

+

{item.topic}

+
+ {item.difficulty} +
+ 0}> +
+ {(tag) => {tag}} +
+
+
+

Reason

+ {item.reason} +
+
+ )} +
+
+
+ + + +); + +type RedoPlanSidebarProps = { + data: TeacherRedoPlanData; + isCreatingRedoAssignment: boolean; + generationError: string | null; + generationSuccess: string | null; + onGenerateRedoQuestions: () => void; +}; + +export const RedoPlanSidebar: Component = (props) => ( + +); diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx new file mode 100644 index 0000000..abdcb56 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.tsx @@ -0,0 +1,59 @@ +import type { Component } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import { createSignal } from "solid-js"; +import { useAuth } from "~/context/auth/context"; +import { getAssignmentReviewHref } from "../../../lib/routes"; +import { createRedoAssignmentForStudent } from "./assignment-teacher-redo-plan.helpers"; +import { RedoPlanMainColumn, RedoPlanSidebar } from "./assignment-teacher-redo-plan.sections"; +import type { AssignmentTeacherRedoPlanProps } from "./assignment-teacher-redo-plan.types"; +import styles from "./assignment-teacher-review.module.scss"; + +const AssignmentTeacherRedoPlan: Component = (props) => { + const auth = useAuth(); + const navigate = useNavigate(); + const [isCreatingRedoAssignment, setIsCreatingRedoAssignment] = createSignal(false); + const [generationError, setGenerationError] = createSignal(null); + const [generationSuccess, setGenerationSuccess] = createSignal(null); + + const handleGenerateRedoQuestions = async () => { + if (!props.data.plan?.questionSet.length) return; + const teacherId = auth.user()?.role === "teacher" ? auth.user()!.id : null; + if (!teacherId) { + setGenerationError("Your teacher session is still loading."); + return; + } + + setIsCreatingRedoAssignment(true); + setGenerationError(null); + setGenerationSuccess(null); + + try { + const result = await createRedoAssignmentForStudent({ + data: props.data, + teacherId, + }); + + setGenerationSuccess(result.successMessage); + void navigate(getAssignmentReviewHref("teacher", result.assignmentId)); + } catch (error) { + setGenerationError(error instanceof Error ? error.message : "Unable to create the redo assignment right now."); + } finally { + setIsCreatingRedoAssignment(false); + } + }; + + return ( +
+ + void handleGenerateRedoQuestions()} + /> +
+ ); +}; + +export default AssignmentTeacherRedoPlan; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts new file mode 100644 index 0000000..e9b43ac --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-redo-plan.types.ts @@ -0,0 +1,18 @@ +import type { TeacherRedoPlanData } from "./assignment-teacher-review.types"; + +export type AssignmentTeacherRedoPlanProps = { + data: TeacherRedoPlanData; +}; + +export type RedoPlanGroupedItem = { + topic: string; + difficulty: "easy" | "medium" | "hard"; + count: number; + tags: string[]; + reasons: string[]; +}; + +export type RedoPlanGenerationResult = { + assignmentId: number; + successMessage: string; +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts new file mode 100644 index 0000000..45d68d8 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts @@ -0,0 +1,246 @@ +// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts + +import { apiFetchJson } from "../../../lib/api"; +import type { + ApiAssignment, + ApiAssignmentRedoPlanResponse, + ApiAssignmentStudentQuestionDetail, + ApiClassroom, + ApiListResponse, + ApiReviewQueueItem, + ApiReviewSummary, +} from "../../../lib/api-types"; +import { + buildCloseSummary, + buildQuestionTags, + extractTopic, + formatDateLabel, + formatRelativeLabel, + formatSolveMode, + mapQueueReviewStatus, + mapRedoPlan, + mapTopicScores, + normalizeScore, + questionStatus, + queueStatusLabel, + queueTone, +} from "./assignment-teacher-review.formatters"; +import type { + TeacherAssignmentPassStatus, + TeacherAssignmentReviewPageData, + TeacherNextStepOutcome, + TeacherRedoPlanData, + TeacherReviewDraftFields, + TeacherReviewQuestion, +} from "./assignment-teacher-review.types"; + +type UpdateTeacherAnswerReviewInput = TeacherReviewDraftFields & { + status: "not_started" | "in_progress" | "submitted" | "reviewed"; + reviewTags: string[]; +}; + +type UpdateAssignmentTeacherFeedbackInput = { + teacherFeedback: string; + passStatusOverride: TeacherAssignmentPassStatus | null; + nextStepOutcome: TeacherNextStepOutcome | null; +}; + +export const getTeacherAssignmentReviewPageData = async (assignmentId: number, selectedStudentId?: number | null): Promise => { + if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null; + + try { + const assignment = await apiFetchJson(`/api/assignments/${assignmentId}`); + const [classrooms, summary, queueResponse] = await Promise.all([ + apiFetchJson>(`/api/teachers/${assignment.teacher_id}/classrooms`), + apiFetchJson(`/api/assignments/${assignmentId}/review-summary`), + apiFetchJson>(`/api/assignments/${assignmentId}/review`), + ]); + + const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id); + const queue = queueResponse.data; + const resolvedStudentId = selectedStudentId ?? queue[0]?.student_id ?? null; + const selectedStudent = queue.find((item) => item.student_id === resolvedStudentId) ?? null; + const questionDetails = resolvedStudentId + ? await apiFetchJson>(`/api/assignments/${assignmentId}/students/${resolvedStudentId}/questions`) + : { data: [] as ApiAssignmentStudentQuestionDetail[] }; + const questions = questionDetails.data; + const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || ""; + const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || ""; + const overallScore = normalizeScore(questions[0]?.overall_score); + const passThreshold = + typeof assignment.pass_threshold === "number" + ? assignment.pass_threshold + : typeof questions[0]?.pass_threshold === "number" + ? questions[0]!.pass_threshold! + : 6; + const nextStepOutcome = (questions[0]?.next_step_outcome as TeacherNextStepOutcome | undefined) ?? null; + const passStatusOverride = (questions[0]?.pass_status_override as TeacherAssignmentPassStatus | undefined) ?? null; + const passStatus = (questions[0]?.pass_status as TeacherAssignmentPassStatus | undefined) ?? "pending"; + const topic = extractTopic(assignment, questions); + const reviewCoverage = summary.total_assigned > 0 ? Math.round((summary.reviewed / summary.total_assigned) * 100) : 0; + + return { + assignmentId: assignment.id, + title: assignment.title, + classroomId: assignment.classroom_id, + classroomName: classroom?.name ?? "Classroom", + statusLabel: assignment.status === "draft" ? "Draft" : assignment.status === "closed" ? "Closed" : "Live", + headline: selectedStudent ? `Review ${selectedStudent.student_name}'s responses for ${topic}` : `Review submissions for ${topic}`, + description: selectedStudent + ? `${selectedStudent.student_name} has ${selectedStudent.answered_questions} answered question${selectedStudent.answered_questions === 1 ? "" : "s"}. Flag any response that still needs attention, then move to the next step when the rest is ready.` + : "Choose a student from the review queue to inspect answers, working steps, and feedback status for this assignment.", + dueLabel: formatDateLabel(assignment.due_at), + stats: [ + { label: "Assigned", value: `${summary.total_assigned}` }, + { label: "Submitted", value: `${summary.submitted}` }, + { label: "Reviewed", value: `${summary.reviewed}` }, + { label: "Coverage", value: `${reviewCoverage}%` }, + ], + closeSummary: buildCloseSummary(assignment.status, queue), + reviewQueue: queue.map((item) => ({ + studentId: item.student_id, + studentName: item.student_name, + email: item.student_email, + reviewStatus: item.review_status, + nextStepOutcome: mapQueueReviewStatus(item.next_step_outcome), + submittedQuestions: item.submitted_questions, + reviewedQuestions: item.reviewed_questions, + progressLabel: `${item.reviewed_questions}/${item.total_questions} reviewed · ${item.answered_questions} answered`, + statusLabel: queueStatusLabel(item), + statusTone: queueTone(item), + timestampLabel: formatRelativeLabel(item.latest_submitted_at ?? item.latest_reviewed_at, "No submission yet"), + })), + selectedStudentId: selectedStudent?.student_id ?? null, + selectedStudentName: selectedStudent?.student_name ?? null, + selectedStudentEmail: selectedStudent?.student_email ?? null, + selectedStudentProgress: selectedStudent ? `${selectedStudent.reviewed_questions} of ${selectedStudent.total_questions} reviewed · ${selectedStudent.submitted_questions} waiting` : null, + selectedStudentReviewStatus: selectedStudent?.review_status ?? null, + selectedStudentSubmittedQuestions: selectedStudent?.submitted_questions ?? 0, + selectedStudentReviewedQuestions: selectedStudent?.reviewed_questions ?? 0, + assignmentAiFeedback, + assignmentTeacherFeedback, + overallScore, + passThreshold, + nextStepOutcome, + passStatusOverride, + passStatus, + coachCard: { + title: selectedStudent ? "Teacher review notes" : "Review queue guidance", + description: selectedStudent + ? "Flag only the responses that still need attention. Moving to the next step will treat everything else as reviewed." + : "Start with the freshest submitted work, then move through the queue in order of urgency.", + items: [`${summary.submitted} submission${summary.submitted === 1 ? "" : "s"} waiting`, `${summary.in_progress} in progress`, `${summary.not_started} not started`], + }, + questions: questions.map((question): TeacherReviewQuestion => { + const status = questionStatus(question); + return { + id: question.question_id, + order: question.position, + prompt: question.prompt, + subject: question.subject, + source: question.source, + questionTags: buildQuestionTags(question), + answerId: question.answer_id ?? null, + answerStatus: (question.answer_status as TeacherReviewQuestion["answerStatus"]) ?? null, + statusLabel: status.statusLabel, + statusTone: status.statusTone, + answerText: question.answer_text?.trim() || "No answer has been submitted for this question yet.", + solveModeLabel: formatSolveMode(question.solve_mode), + workingSteps: question.working_steps?.trim() || "", + correctAnswer: question.correct_answer?.trim() || null, + isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null, + reviewNeedsAttention: Boolean(question.review_needs_attention), + reviewIssueReason: question.review_issue_reason?.trim() || "", + reviewCorrectnessScore: normalizeScore(question.review_correctness_score), + reviewUnderstandingScore: normalizeScore(question.review_understanding_score), + reviewQuestionScore: normalizeScore(question.review_question_score), + reviewConfidence: normalizeScore(question.review_confidence), + correctnessLabel: + typeof question.is_correct === "boolean" + ? question.is_correct + ? "Matches the saved correct answer" + : "Does not match the saved correct answer yet" + : question.correct_answer + ? "Correct answer is available for comparison" + : "No correct answer saved yet", + submittedLabel: formatDateLabel(question.submitted_at, "Not submitted"), + reviewedLabel: formatDateLabel(question.reviewed_at, "Not reviewed yet"), + }; + }), + }; + } catch (error) { + if (error instanceof Error && error.message === "not_found") { + return null; + } + throw error; + } +}; + +export const updateTeacherAnswerReview = async (answerId: number, input: UpdateTeacherAnswerReviewInput) => { + return await apiFetchJson(`/api/answers/${answerId}/review`, { + method: "PATCH", + allowNoContent: true, + body: JSON.stringify({ + status: input.status, + review_needs_attention: input.reviewNeedsAttention, + review_issue_reason: input.reviewIssueReason || null, + review_correctness_score: input.reviewCorrectnessScore, + review_understanding_score: input.reviewUnderstandingScore, + review_question_score: input.reviewQuestionScore, + review_confidence: input.reviewConfidence, + review_tags: input.reviewTags, + }), + }); +}; + +export const updateAssignmentTeacherFeedback = async (assignmentId: number, studentId: number, input: UpdateAssignmentTeacherFeedbackInput) => { + return await apiFetchJson(`/api/assignments/${assignmentId}/students/${studentId}/feedback`, { + method: "PATCH", + allowNoContent: true, + body: JSON.stringify({ + teacher_feedback: input.teacherFeedback, + pass_status_override: input.passStatusOverride, + next_step_outcome: input.nextStepOutcome, + }), + }); +}; + +export const closeTeacherAssignment = async (assignmentId: number) => { + return await apiFetchJson(`/api/assignments/${assignmentId}/close`, { + method: "POST", + parseErrorMessage: true, + }); +}; + +export const getTeacherAssignmentRedoPlanData = async (assignmentId: number, studentId: number): Promise => { + if (!Number.isFinite(assignmentId) || assignmentId <= 0 || !Number.isFinite(studentId) || studentId <= 0) return null; + + const [reviewData, redoPlan] = await Promise.all([ + getTeacherAssignmentReviewPageData(assignmentId, studentId), + apiFetchJson(`/api/assignments/${assignmentId}/students/${studentId}/redo-plan`, { notFoundAsNull: true }), + ]); + + if (!reviewData || !redoPlan || !reviewData.selectedStudentId || !reviewData.selectedStudentName || !reviewData.selectedStudentEmail) { + return null; + } + + return { + assignmentId: reviewData.assignmentId, + title: reviewData.title, + classroomId: reviewData.classroomId, + classroomName: reviewData.classroomName, + selectedStudentId: reviewData.selectedStudentId, + selectedStudentName: reviewData.selectedStudentName, + selectedStudentEmail: reviewData.selectedStudentEmail, + teacherFeedback: redoPlan.teacher_feedback?.trim() || reviewData.assignmentTeacherFeedback, + generatedAtLabel: redoPlan.redo_plan_generated_at ? formatRelativeLabel(redoPlan.redo_plan_generated_at) : null, + weaknessSummary: { + studentId: redoPlan.weakness_summary.student_id, + topicScores: mapTopicScores(redoPlan.weakness_summary), + weakTags: Array.isArray(redoPlan.weakness_summary.weak_tags) ? redoPlan.weakness_summary.weak_tags.filter(Boolean) : [], + recentIssues: Array.isArray(redoPlan.weakness_summary.recent_issues) ? redoPlan.weakness_summary.recent_issues.filter(Boolean) : [], + }, + plan: mapRedoPlan(redoPlan.plan), + error: redoPlan.error?.trim() || null, + }; +}; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts new file mode 100644 index 0000000..28311ce --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.drafts.ts @@ -0,0 +1,116 @@ +import type { TeacherReviewDraftFields, TeacherReviewQuestion } from "./assignment-teacher-review.types"; +import { assignmentUiCopy } from "~/content/ui-copy"; + +export type QuestionReviewDraft = TeacherReviewDraftFields & { + answerId: number; +}; + +export type StudentReviewDraft = { + questionReviews?: Record; +}; + +export type AssignmentReviewDraftStore = { + students: Record; +}; + +export const EMPTY_DRAFT_STORE: AssignmentReviewDraftStore = { + students: {}, +}; + +export const draftStorageKeyForAssignment = (assignmentId: number) => `teacher-review-draft:${assignmentId}`; + +export const readDraftStore = (assignmentId: number): AssignmentReviewDraftStore => { + if (typeof window === "undefined") return EMPTY_DRAFT_STORE; + + try { + const raw = window.localStorage.getItem(draftStorageKeyForAssignment(assignmentId)); + if (!raw) return EMPTY_DRAFT_STORE; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || typeof parsed.students !== "object" || parsed.students === null) { + return EMPTY_DRAFT_STORE; + } + + return { + students: parsed.students as AssignmentReviewDraftStore["students"], + }; + } catch { + return EMPTY_DRAFT_STORE; + } +}; + +export const writeDraftStore = (assignmentId: number, draftStore: AssignmentReviewDraftStore) => { + if (typeof window === "undefined") return; + const key = draftStorageKeyForAssignment(assignmentId); + if (Object.keys(draftStore.students).length === 0) { + window.localStorage.removeItem(key); + return; + } + + window.localStorage.setItem(key, JSON.stringify(draftStore)); +}; + +export const reviewFieldsFromQuestion = (question: TeacherReviewQuestion): TeacherReviewDraftFields => ({ + reviewNeedsAttention: question.reviewNeedsAttention, + reviewIssueReason: question.reviewIssueReason, + reviewCorrectnessScore: question.reviewCorrectnessScore, + reviewUnderstandingScore: question.reviewUnderstandingScore, + reviewQuestionScore: question.reviewQuestionScore, + reviewConfidence: question.reviewConfidence, +}); + +const normalizeOptionalScore = (value: number | null) => (typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, Number(value.toFixed(3)))) : null); + +export const sanitizeReviewFields = (fields: TeacherReviewDraftFields): TeacherReviewDraftFields => ({ + reviewNeedsAttention: Boolean(fields.reviewNeedsAttention), + reviewIssueReason: fields.reviewIssueReason.trim(), + reviewCorrectnessScore: 1, + reviewUnderstandingScore: normalizeOptionalScore(fields.reviewUnderstandingScore), + reviewQuestionScore: 1, + reviewConfidence: normalizeOptionalScore(fields.reviewConfidence), +}); + +export const reviewFieldsEqual = (left: TeacherReviewDraftFields, right: TeacherReviewDraftFields) => + left.reviewNeedsAttention === right.reviewNeedsAttention && + left.reviewIssueReason === right.reviewIssueReason && + left.reviewCorrectnessScore === right.reviewCorrectnessScore && + left.reviewUnderstandingScore === right.reviewUnderstandingScore && + left.reviewQuestionScore === right.reviewQuestionScore && + left.reviewConfidence === right.reviewConfidence; + +const questionStatusMeta = (status: TeacherReviewQuestion["answerStatus"]) => { + if (status === "reviewed") { + return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const }; + } + if (status === "submitted") { + return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const }; + } + if (status === "in_progress") { + return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const }; + } + return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const }; +}; + +export const applyReviewDrafts = (questions: TeacherReviewQuestion[], studentDraft: StudentReviewDraft | null) => + questions.map((question) => { + const draftReview = studentDraft?.questionReviews?.[String(question.id)] ?? null; + if (!draftReview) return question; + + const effectiveReview = sanitizeReviewFields({ + ...reviewFieldsFromQuestion(question), + ...draftReview, + }); + const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : question.answerStatus; + const statusMeta = questionStatusMeta(nextStatus); + + return { + ...question, + ...effectiveReview, + answerStatus: nextStatus, + statusLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttention : statusMeta.statusLabel, + statusTone: statusMeta.statusTone, + reviewedLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttentionDraft : question.reviewedLabel, + }; + }); + +export const countPendingQuestionDrafts = (draftStore: AssignmentReviewDraftStore) => + Object.values(draftStore.students).reduce((count, studentDraft) => count + Object.keys(studentDraft.questionReviews ?? {}).length, 0); diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts b/Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts new file mode 100644 index 0000000..8726936 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.formatters.ts @@ -0,0 +1,194 @@ +import type { + ApiAssignment, + ApiAssignmentStudentQuestionDetail, + ApiRedoPlan, + ApiRedoPlanQuestion, + ApiReviewQueueItem, + ApiStudentWeaknessSummary, +} from "../../../lib/api-types"; +import type { + TeacherAssignmentCloseSummary, + TeacherNextStepOutcome, + TeacherRedoPlanQuestion, + TeacherReviewQueueItem, +} from "./assignment-teacher-review.types"; +import { assignmentUiCopy } from "~/content/ui-copy"; + +export const formatDateLabel = (value: string | null | undefined, fallback = "No due date") => { + if (!value) return fallback; + + return new Intl.DateTimeFormat("en-GB", { + weekday: "short", + month: "short", + day: "numeric", + }).format(new Date(value)); +}; + +export const formatRelativeLabel = (value: string | null | undefined, fallback = "No recent update") => { + if (!value) return fallback; + + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return fallback; + + const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000)); + if (diffMinutes < 1) return "Just now"; + if (diffMinutes < 60) return `${diffMinutes} min ago`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours} hr ago`; + const diffDays = Math.round(diffHours / 24); + return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; +}; + +export const formatSolveMode = (value: ApiAssignmentStudentQuestionDetail["solve_mode"]) => { + switch (value) { + case "step_by_step": + return "Step by step"; + case "solve_together": + return "Solve together"; + case "handwritten": + return "Handwritten"; + case "just_answer": + return "Just answer"; + default: + return null; + } +}; + +export const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => { + const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/im)?.[1]?.trim(); + if (fromInstructions) return fromInstructions; + return questions[0]?.subject ?? "this assignment"; +}; + +export const queueTone = (item: ApiReviewQueueItem): TeacherReviewQueueItem["statusTone"] => { + if (item.review_status === "submitted") return "review"; + if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "progress"; + if (item.next_step_outcome === "accept") return "success"; + if (item.next_step_outcome === "redo" || item.next_step_outcome === "support") return "review"; + return "muted"; +}; + +export const queueStatusLabel = (item: ApiReviewQueueItem) => { + if (item.review_status === "submitted") return "Submitted"; + if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "In progress"; + if (item.next_step_outcome === "redo") return "Redo"; + if (item.next_step_outcome === "accept") return "Accept"; + if (item.next_step_outcome === "support") return "Support"; + return "Not started"; +}; + +export const buildCloseSummary = (assignmentStatus: ApiAssignment["status"], queue: ApiReviewQueueItem[]): TeacherAssignmentCloseSummary => { + if (assignmentStatus === "closed") { + return { + state: "closed", + canClose: false, + blockers: [], + summary: "This assignment is already closed.", + }; + } + + if (queue.length === 0) { + return { + state: "blocked", + canClose: false, + blockers: ["No students have been assigned yet."], + summary: "Assign at least one student before closing this assignment.", + }; + } + + const blockers = queue.flatMap((item) => { + if (item.submitted_questions > 0 || item.review_status === "submitted") { + return [`${item.student_name} still has submitted work waiting for review.`]; + } + + if (item.in_progress_questions > 0 || item.review_status === "in_progress") { + return [`${item.student_name} still has work in progress.`]; + } + + if (item.answered_questions === 0 || item.review_status === "not_started") { + return [`${item.student_name} has not started this assignment yet.`]; + } + + if (!item.next_step_outcome) { + return [`${item.student_name} still needs a next-step decision.`]; + } + + return [] as string[]; + }); + + if (blockers.length > 0) { + return { + state: "blocked", + canClose: false, + blockers, + summary: `${blockers.length} blocker${blockers.length === 1 ? "" : "s"} still need attention before this assignment can be closed.`, + }; + } + + return { + state: "ready", + canClose: true, + blockers: [], + summary: "All assigned students have been reviewed and given a next-step decision. This assignment is ready to close.", + }; +}; + +export const questionStatus = (question: ApiAssignmentStudentQuestionDetail) => { + if (question.review_needs_attention) { + return { statusLabel: assignmentUiCopy.teacherReview.status.needsAttention, statusTone: "review" as const }; + } + if (question.answer_status === "reviewed") { + return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const }; + } + if (question.answer_status === "submitted") { + return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const }; + } + if (question.answer_status === "in_progress") { + return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const }; + } + return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const }; +}; + +export const normalizeScore = (value: number | null | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : null); + +export const normalizeTags = (value: string[] | null | undefined) => + Array.isArray(value) + ? value.map((tag) => tag.trim()).filter(Boolean) + : []; + +export const buildQuestionTags = (question: ApiAssignmentStudentQuestionDetail) => { + const tags = [...normalizeTags(question.question_tags), question.subject?.trim() || "", formatSolveMode(question.solve_mode) || ""].filter(Boolean); + return Array.from(new Set(tags)); +}; + +export const formatTopicLabel = (value: string) => + value + .split("_") + .map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part)) + .join(" "); + +export const formatDifficultyLabel = (value: string) => (value ? value[0]!.toUpperCase() + value.slice(1).toLowerCase() : value); + +export const mapTopicScores = (summary: ApiStudentWeaknessSummary) => + Object.entries(summary.topic_scores ?? {}) + .map(([topic, score]) => ({ topic: formatTopicLabel(topic), score })) + .sort((left, right) => left.score - right.score || left.topic.localeCompare(right.topic)); + +export const mapRedoPlanQuestion = (item: ApiRedoPlanQuestion): TeacherRedoPlanQuestion => ({ + topic: formatTopicLabel(item.topic), + topicKey: item.topic, + difficulty: formatDifficultyLabel(item.difficulty), + difficultyKey: item.difficulty, + tags: Array.isArray(item.tags) ? item.tags.filter(Boolean) : [], + reason: item.reason?.trim() || "No reason provided.", +}); + +export const mapRedoPlan = (plan: ApiRedoPlan | null | undefined) => { + if (!plan) return null; + return { + rationale: plan.rationale?.trim() || "No rationale provided.", + questionSet: Array.isArray(plan.questionSet) ? plan.questionSet.map(mapRedoPlanQuestion) : [], + }; +}; + +export const mapQueueReviewStatus = (value: ApiReviewQueueItem["next_step_outcome"]): TeacherNextStepOutcome | null => value ?? null; diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss b/Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss new file mode 100644 index 0000000..ac3cfe3 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.module.scss @@ -0,0 +1,824 @@ +.section, +.sideCard { + background: var(--surface-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-3xl); + box-shadow: var(--shadow-soft); + padding: 1.2rem; + min-width: 0; +} + +.primaryAction, +.secondaryAction, +.queueButton { + font: inherit; +} + +.primaryAction, +.secondaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + border-radius: var(--radius-pill); + text-decoration: none; + font-weight: 600; + border: 1px solid transparent; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease, + opacity 0.2s ease; +} + +.secondaryAction { + background: var(--surface-soft); + color: var(--text); + border-color: var(--border-soft); +} + +.secondaryAction:hover { + text-decoration: none; + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); + border-color: var(--border-soft); +} + +.primaryAction { + background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end)); + color: var(--action-primary-text); + box-shadow: var(--action-primary-shadow); +} + +.primaryAction:hover { + filter: saturate(1.02); +} + +.primaryAction:disabled, +.secondaryAction:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.sideEyebrow, +.order, +.feedbackBlock label, +.responseBlock p, +.supportBlock p { + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.78rem; + font-weight: 700; + color: var(--text-subtle); +} + +.statusPill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.42rem 0.68rem; + border-radius: var(--radius-pill); + font-size: 0.78rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; + text-transform: uppercase; + border: 1px solid transparent; +} + +.review { + background: var(--surface-warning-tint); + color: var(--warning-text); + border-color: color-mix(in srgb, var(--warning-text) 24%, transparent 76%); +} + +.progress { + background: color-mix(in srgb, var(--surface-info) 20%, white 80%); + color: var(--info); + border-color: color-mix(in srgb, var(--info) 24%, transparent 76%); +} + +.success { + background: var(--surface-success-tint); + color: var(--success-text); + border-color: var(--border-success-soft); +} + +.muted { + background: var(--surface-soft); + color: var(--text-muted); + border-color: var(--border-soft); +} + +.contentGrid { + display: grid; + gap: 1.25rem; + + @include respond(desktop-lg) { + grid-template-columns: minmax(0, 1.45fr) minmax(19rem, 0.85fr); + align-items: start; + } +} + +@media (max-width: 1023px) { + .contentGrid { + display: flex; + flex-direction: column; + } + + .sideColumn { + order: -1; + } +} + +.mainColumn, +.sideColumn { + min-width: 0; +} + +.sideColumn { + display: grid; + gap: 1rem; + min-height: 0; + + @include respond(desktop-lg) { + position: sticky; + top: 1.25rem; + max-height: calc(100dvh - 2.5rem); + overflow-y: auto; + padding-right: 0.2rem; + } +} + +.queueCard { + min-height: 0; +} + +.mobileStickyNav { + position: static; +} + +.mobileStickyNavHeader { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; +} + +.mobileStickyNavActions { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.mobileStickyNavToggle { + display: none; + align-items: center; + justify-content: center; + padding: 0.7rem 0.9rem; + border-radius: var(--radius-full); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + color: var(--text); + font: inherit; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.mobileStickyNavBackToTop { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.7rem 0.9rem; + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--border-soft) 74%, white 26%); + background: color-mix(in srgb, var(--surface-panel-strong) 82%, white 18%); + color: var(--text); + font: inherit; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 8px 18px hsl(220 35% 12% / 0.1); + transition: + background-color 0.2s ease, + border-color 0.2s ease, + transform 0.2s ease; + + &:hover { + transform: translateY(-1px); + background: color-mix(in srgb, var(--surface-panel-strong) 74%, white 26%); + } +} + +.mobileStickyNavContent { + display: grid; + gap: 0.85rem; +} + +.mobileStickyNavContentExpanded { + display: grid; + gap: 0.85rem; +} + +.mobileQuestionNavList { + display: grid; + gap: 0.65rem; +} + +.mobileQuestionNavButton { + display: grid; + gap: 0.15rem; + padding: 0.85rem 0.95rem; + text-align: left; + border-radius: var(--radius-md); + border: 1px solid var(--border-divider); + background: var(--surface-panel-strong); + color: var(--text); + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; + + &:hover { + transform: translateY(-1px); + } + + strong { + font-size: 0.95rem; + color: var(--text); + } + + small { + color: var(--text-muted); + } +} + +.mobileQuestionNavAnswered { + border-color: color-mix(in srgb, var(--border-soft) 62%, var(--info) 38%); + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); +} + +.sectionHeader, +.sideCard { + display: grid; + gap: 0.4rem; +} + +.questionList, +.queueList, +.noteList { + display: grid; + gap: 0.85rem; +} + +.sideActions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.queueScroller { + min-height: 0; +} + +.assignmentFeedbackCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + margin-bottom: 0.95rem; +} + +.saveAllCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + margin-top: 1rem; +} + +.nextStepCard, +.optionCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + min-width: 0; +} + +.planList, +.scoreList { + display: grid; + gap: 0.85rem; +} + +.planCard { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + min-width: 0; +} + +.scoreRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 0.8rem 0.95rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-soft); + + span { + color: var(--text); + } + + strong { + color: var(--text-muted); + } +} + +.questionCard, +.queueButton { + display: grid; + gap: 0.85rem; + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--border-soft); + background: var(--surface-panel-strong); + min-width: 0; +} + +.optionGrid { + display: grid; + gap: 0.85rem; + + @include respond(tablet) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.optionCard { + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; + + strong { + font-size: 1rem; + color: var(--text); + } + + span { + color: var(--text-muted); + line-height: 1.5; + } + + &:hover { + border-color: color-mix(in srgb, var(--border-soft) 68%, var(--info) 32%); + background: color-mix(in srgb, var(--surface-panel-strong) 86%, white 14%); + } +} + +.optionCardActive { + border-color: color-mix(in srgb, var(--border-soft) 54%, var(--info) 46%); + background: color-mix(in srgb, var(--surface-info) 14%, white 86%); + box-shadow: var(--focus-ring-info-shadow); +} + +.questionTop { + display: flex; + justify-content: space-between; + align-items: start; + gap: 0.75rem; + + h3 { + line-height: 1.25; + } +} + +.metaRow { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + + span { + padding: 0.35rem 0.55rem; + border-radius: var(--radius-pill); + background: var(--surface-soft); + color: color-mix(in srgb, var(--text-muted) 84%, var(--info) 16%); + font-size: 0.82rem; + border: 1px solid var(--border-soft); + } +} + +.responseBlock, +.supportBlock, +.feedbackBlock { + display: grid; + gap: 0.45rem; +} + +.reviewEditorCard { + display: grid; + gap: 0.9rem; + padding: 0.95rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); +} + +.reviewEditorTop { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + h4 { + font-size: 1rem; + color: var(--text); + } +} + +.reviewCheckbox { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 600; + color: var(--text); + + input { + inline-size: 1rem; + block-size: 1rem; + } +} + +.reviewField { + display: grid; + gap: 0.35rem; + + label { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-muted); + } + + small { + color: var(--text-muted); + line-height: 1.4; + } +} + +.reviewScoreGrid { + display: grid; + gap: 0.75rem; + + @include respond(tablet) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.scoreHighlight { + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%); + background: color-mix(in srgb, var(--surface-info) 12%, white 88%); + + @include respond(tablet) { + grid-column: 1 / -1; + } + + label { + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + small { + font-size: 0.9rem; + } +} + +.scoreHighlightValue { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-height: 3rem; + + strong, + span { + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1; + font-weight: 800; + color: var(--text); + } +} + +.compactInput { + width: 100%; + min-width: 0; + padding: 0.75rem 0.85rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-panel); + color: var(--text); + font: inherit; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + + &:focus { + outline: none; + border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%); + box-shadow: var(--focus-ring-info-shadow); + background: color-mix(in srgb, var(--surface-panel) 88%, white 12%); + } + } + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tagPill { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--surface-soft); + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.2; +} + +.reviewDraftEmpty { + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + border: 1px dashed var(--border-soft); + background: var(--surface-soft); + color: var(--text-muted); + font-size: 0.94rem; + line-height: 1.45; +} + +.responseBlock, +.supportBlock { + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-soft); +} + +.responseBlock strong, +.supportBlock span, +.supportBlock pre { + font-size: 0.98rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.responseBlock strong { + color: color-mix(in srgb, var(--text) 88%, var(--info) 12%); +} + +.supportBlock span, +.supportBlock pre { + color: var(--text-muted); + margin: 0; +} + +.feedbackInput { + width: 100%; + min-height: 8rem; + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-soft); + background: var(--surface-soft); + color: var(--text); + font: inherit; + resize: vertical; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.feedbackInput:focus { + outline: none; + border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%); + box-shadow: var(--focus-ring-info-shadow); + background: color-mix(in srgb, var(--surface-soft) 88%, white 12%); +} + +.draftHint { + font-size: 0.92rem; + line-height: 1.45; + color: var(--text-muted); +} + +.actionRow { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.notice { + padding: 0.8rem 0.95rem; + border-radius: var(--radius-md); + font-size: 0.92rem; + font-weight: 600; +} + +.noticeSuccess { + background: var(--surface-success-tint); + color: var(--success-text); +} + +.noticeError { + background: var(--surface-danger); + color: var(--danger-text); +} + +.queueButton { + text-align: left; + cursor: pointer; + color: var(--text); + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + strong { + color: var(--text); + } + + p { + color: var(--text-muted); + } + + &:hover { + border-color: color-mix(in srgb, var(--border-soft) 70%, var(--info) 30%); + background: color-mix(in srgb, var(--surface-soft) 92%, white 8%); + } +} + +.queueButtonActive { + border-color: color-mix(in srgb, var(--border-soft) 60%, var(--info) 40%); + background: color-mix(in srgb, var(--surface-soft) 86%, white 14%); +} + +.queueMeta { + display: grid; + gap: 0.35rem; + justify-items: start; + + small { + color: var(--text-muted); + } +} + +.queueEmpty, +.emptyState, +.progressNote { + padding: 0.95rem; + border-radius: var(--radius-md); + background: var(--surface-soft); + color: var(--text-muted); + border: 1px dashed var(--border-soft); +} + +.closeAssignmentStatusRow { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.closeBlockerList { + display: grid; + gap: 0.55rem; + padding-left: 1rem; + + li { + color: var(--text-muted); + line-height: 1.45; + } +} + +.noteList { + padding-left: 1rem; + + li { + color: var(--text-muted); + line-height: 1.45; + } +} + +@media (max-width: 1023px) { + .mobileStickyNav { + position: sticky; + top: 0.75rem; + z-index: 25; + pointer-events: auto; + background: color-mix(in srgb, var(--surface-panel) 84%, white 16%); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid color-mix(in srgb, var(--border-soft) 78%, white 22%); + border-radius: calc(var(--radius-3xl) + 0.25rem); + box-shadow: 0 16px 36px hsl(220 35% 12% / 0.14); + } + + .mobileStickyNav { + gap: 0.75rem; + padding: 0.95rem; + } + + .mobileStickyNavHeader { + grid-template-columns: minmax(0, 1fr); + } + + .mobileStickyNavActions { + justify-content: space-between; + } + + .mobileStickyNavToggle { + display: inline-flex; + } + + .mobileStickyNavContent { + display: none; + } + + .mobileStickyNavContentExpanded { + display: grid; + } + + .queueScroller, + .mobileQuestionNavList { + max-height: min(55dvh, 24rem); + overflow-y: auto; + padding-right: 0.15rem; + } + + .section, + .sideCard, + .questionCard, + .queueButton, + .assignmentFeedbackCard, + .saveAllCard { + padding: 1rem; + } + + .questionTop, + .reviewEditorTop { + display: grid; + gap: 0.6rem; + } + + .questionCard { + scroll-margin-top: 8.5rem; + } + + .sideActions { + gap: 0.55rem; + } + + .sideActions > * { + flex: 1 1 auto; + } + + .queueMeta { + width: 100%; + grid-template-columns: minmax(0, 1fr); + } +} + +@include respond(desktop-lg) { + .mobileStickyNavToggle { + display: none; + } + + .mobileStickyNavContent { + display: grid; + } +} diff --git a/Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx b/Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx new file mode 100644 index 0000000..d270142 --- /dev/null +++ b/Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx @@ -0,0 +1,388 @@ +// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx + +import type { Component, JSX } from "solid-js"; +import { A } from "@solidjs/router"; +import { createSignal, For, Show } from "solid-js"; +import { assignmentUiCopy } from "~/content/ui-copy"; +import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types"; +import { getDashboardAssignmentsHref } from "../../../lib/routes"; +import styles from "./assignment-teacher-review.module.scss"; + +const toneClass = (tone: string) => { + switch (tone) { + case "review": + return styles.review; + case "progress": + return styles.progress; + case "success": + return styles.success; + default: + return styles.muted; + } +}; + +type AssignmentFeedbackSectionProps = { + data: TeacherAssignmentReviewPageData; + teacherFeedbackDraft: string; + hasPendingAssignmentFeedback: boolean; + busy: boolean; + notice: TeacherReviewNotice; + draftActionLabel: string; + actions?: JSX.Element; + onTeacherFeedbackInput: (value: string) => void; +}; + +export const AssignmentFeedbackSection: Component = (props) => ( +
+
+

{assignmentUiCopy.teacherReview.feedback.title}

+

Keep AI feedback and teacher feedback in one place for the whole assignment.

+
+ +
+

{assignmentUiCopy.teacherReview.feedback.aiFeedback}

+ {props.data.assignmentAiFeedback || assignmentUiCopy.teacherReview.feedback.noAiFeedback} +
+ +
+ +