Boost Azure Demo

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

30
Backend/.air.toml Normal file
View File

@@ -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

67
Backend/Earthfile Normal file
View File

@@ -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

View File

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

View File

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

6
Backend/db/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package db
import "embed"
//go:embed migrations/*.sql
var Migrations embed.FS

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 *;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 *;

12
Backend/db/sqlc.yaml Normal file
View File

@@ -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

33
Backend/go.mod Normal file
View File

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

83
Backend/go.sum Normal file
View File

@@ -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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,506 @@
// Path: Backend/internal/handlers/api/questions/handler.go
package questions
import (
"boostai-backend/internal/handlers/api/shared"
"boostai-backend/internal/http/params"
"boostai-backend/internal/http/respond"
authmw "boostai-backend/internal/middleware"
"boostai-backend/internal/questiongen"
"boostai-backend/internal/sqlc"
"errors"
"fmt"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type Handler struct {
queries *sqlc.Queries
generator *questiongen.Service
}
type QuestionResponse struct {
ID int64 `json:"id"`
AuthorTeacherID int64 `json:"author_teacher_id"`
Title string `json:"title"`
Prompt string `json:"prompt"`
Topic *string `json:"topic,omitempty"`
Subject *string `json:"subject,omitempty"`
Difficulty *string `json:"difficulty,omitempty"`
Source *string `json:"source,omitempty"`
CorrectAnswer *string `json:"correct_answer,omitempty"`
Status string `json:"status"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type TagResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
type createQuestionRequest struct {
AuthorTeacherID int64 `json:"author_teacher_id"`
Title string `json:"title"`
Prompt string `json:"prompt"`
Topic *string `json:"topic"`
Subject *string `json:"subject"`
Difficulty *string `json:"difficulty"`
Source *string `json:"source"`
CorrectAnswer *string `json:"correct_answer"`
Status string `json:"status"`
}
type createTagRequest struct {
Name string `json:"name"`
}
type attachTagToQuestionRequest struct {
TagID int64 `json:"tag_id"`
}
type generateQuestionsRequest struct {
Topic string `json:"topic"`
Difficulty string `json:"difficulty"`
Count int `json:"count"`
Seed *int64 `json:"seed"`
Status *string `json:"status"`
Source *string `json:"source"`
}
type GeneratedQuestionResponse struct {
Question QuestionResponse `json:"question"`
Tags []string `json:"tags"`
WorkedSolution []string `json:"worked_solution"`
}
type GenerateQuestionsResponse struct {
Seed int64 `json:"seed"`
Data []GeneratedQuestionResponse `json:"data"`
Count int `json:"count"`
}
func NewHandler(queries *sqlc.Queries, generator *questiongen.Service) *Handler {
return &Handler{queries: queries, generator: generator}
}
func (h *Handler) ListQuestionsByTeacher(c *fiber.Ctx) error {
teacherID, err := params.Int64PathParam(c, "teacherId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
questions, err := h.queries.ListQuestionsByTeacher(ctx, teacherID)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]QuestionResponse, 0, len(questions))
for _, question := range questions {
items = append(items, mapQuestion(question))
}
return c.JSON(shared.ListResponse[QuestionResponse]{Data: items})
}
func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
questionID, err := params.Int64PathParam(c, "questionId")
if err != nil {
return err
}
ctx, cancel := shared.WithTimeout()
defer cancel()
question, err := h.queries.GetQuestionByID(ctx, questionID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return respond.Error(c, fiber.StatusNotFound, "not_found", "Question not found")
}
return respond.DatabaseError(c, err)
}
return c.JSON(mapQuestion(question))
}
func (h *Handler) ListTags(c *fiber.Ctx) error {
ctx, cancel := shared.WithTimeout()
defer cancel()
tags, err := h.queries.ListTags(ctx)
if err != nil {
return respond.DatabaseError(c, err)
}
items := make([]TagResponse, 0, len(tags))
for _, tag := range tags {
items = append(items, mapTag(tag))
}
return c.JSON(shared.ListResponse[TagResponse]{Data: items})
}
func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
var req createQuestionRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
teacherID := authmw.CurrentUserID(c)
if teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Prompt) == "" || strings.TrimSpace(req.Status) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "teacher authentication, title, prompt, and status are required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
topic, subject, err := parseQuestionTopic(req.Topic, req.Subject)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
}
difficulty, err := parseQuestionDifficulty(req.Difficulty)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
}
question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{
AuthorTeacherID: teacherID,
Title: strings.TrimSpace(req.Title),
Prompt: strings.TrimSpace(req.Prompt),
Topic: topic,
Subject: shared.NullableText(subject),
Difficulty: difficulty,
Source: shared.NullableText(req.Source),
CorrectAnswer: shared.NullableText(req.CorrectAnswer),
Status: sqlc.QuestionStatus(strings.TrimSpace(req.Status)),
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(mapQuestion(question))
}
func (h *Handler) GenerateQuestions(c *fiber.Ctx) error {
if h.generator == nil {
return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Question generator is not available")
}
var req generateQuestionsRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
teacherID := authmw.CurrentUserID(c)
if teacherID == 0 {
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "teacher authentication is required")
}
if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Difficulty) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic and difficulty are required")
}
if req.Count < 1 || req.Count > 25 {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "count must be between 1 and 25")
}
topic, subject, err := parseQuestionTopic(&req.Topic, nil)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
}
if !topic.Valid {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic is required")
}
difficulty, err := parseQuestionDifficulty(&req.Difficulty)
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
}
if !difficulty.Valid {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "difficulty is required")
}
status := sqlc.QuestionStatusDraft
if req.Status != nil && strings.TrimSpace(*req.Status) != "" {
normalizedStatus := sqlc.QuestionStatus(strings.ToLower(strings.TrimSpace(*req.Status)))
switch normalizedStatus {
case sqlc.QuestionStatusDraft, sqlc.QuestionStatusPublished, sqlc.QuestionStatusArchived:
status = normalizedStatus
default:
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "status must be draft, published, or archived")
}
}
seed := int64(0)
if req.Seed != nil {
seed = *req.Seed
}
generated, usedSeed, err := h.generator.Generate(questiongen.GenerateParams{
Topic: topic.QuestionTopic,
Difficulty: difficulty.QuestionDifficulty,
Count: req.Count,
Seed: seed,
})
if err != nil {
return respond.Error(c, fiber.StatusBadRequest, "generation_failed", err.Error())
}
ctx, cancel := shared.WithTimeout()
defer cancel()
source := shared.NullableText(req.Source)
if !source.Valid {
defaultSource := "rng_generated"
source = shared.NullableText(&defaultSource)
}
responses := make([]GeneratedQuestionResponse, 0, len(generated))
for index, item := range generated {
title := strings.TrimSpace(item.Title)
if title == "" {
title = fmt.Sprintf("%s %s %d", questionTopicLabel(topic.QuestionTopic), strings.Title(string(difficulty.QuestionDifficulty)), index+1)
}
question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{
AuthorTeacherID: teacherID,
Title: title,
Prompt: strings.TrimSpace(item.Prompt),
Topic: topic,
Subject: shared.NullableText(subject),
Difficulty: difficulty,
Source: source,
CorrectAnswer: shared.NullableText(stringPointer(item.CorrectAnswer)),
Status: status,
})
if err != nil {
return respond.DatabaseError(c, err)
}
for _, tagName := range item.Tags {
tag, err := h.queries.CreateTag(ctx, tagName)
if err != nil {
return respond.DatabaseError(c, err)
}
if err := h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{QuestionID: question.ID, TagID: tag.ID}); err != nil {
return respond.DatabaseError(c, err)
}
}
responses = append(responses, GeneratedQuestionResponse{
Question: mapQuestion(question),
Tags: item.Tags,
WorkedSolution: item.WorkedSolution,
})
}
return c.Status(fiber.StatusCreated).JSON(GenerateQuestionsResponse{
Seed: usedSeed,
Data: responses,
Count: len(responses),
})
}
func (h *Handler) CreateTag(c *fiber.Ctx) error {
var req createTagRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
if strings.TrimSpace(req.Name) == "" {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "name is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
tag, err := h.queries.CreateTag(ctx, strings.TrimSpace(req.Name))
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(mapTag(tag))
}
func (h *Handler) AttachTagToQuestion(c *fiber.Ctx) error {
questionID, err := params.Int64PathParam(c, "questionId")
if err != nil {
return err
}
var req attachTagToQuestionRequest
if err := c.BodyParser(&req); err != nil {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
}
if req.TagID == 0 {
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "tag_id is required")
}
ctx, cancel := shared.WithTimeout()
defer cancel()
err = h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{
QuestionID: questionID,
TagID: req.TagID,
})
if err != nil {
return respond.DatabaseError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"status": "ok",
"question_id": questionID,
"tag_id": req.TagID,
})
}
func mapQuestion(question sqlc.Question) QuestionResponse {
return QuestionResponse{
ID: question.ID,
AuthorTeacherID: question.AuthorTeacherID,
Title: question.Title,
Prompt: question.Prompt,
Topic: questionTopicPointer(question.Topic),
Subject: shared.TextPointer(question.Subject),
Difficulty: questionDifficultyPointer(question.Difficulty),
Source: shared.TextPointer(question.Source),
CorrectAnswer: shared.TextPointer(question.CorrectAnswer),
Status: string(question.Status),
CreatedAt: shared.TimePointer(question.CreatedAt),
UpdatedAt: shared.TimePointer(question.UpdatedAt),
}
}
func mapTag(tag sqlc.Tag) TagResponse {
return TagResponse{
ID: tag.ID,
Name: tag.Name,
CreatedAt: shared.TimePointer(tag.CreatedAt),
}
}
func parseQuestionTopic(rawTopic, rawSubject *string) (sqlc.NullQuestionTopic, *string, error) {
topicValue := strings.TrimSpace(firstNonEmpty(rawTopic, rawSubject))
if topicValue == "" {
return sqlc.NullQuestionTopic{}, rawSubject, nil
}
normalizedTopic, ok := normalizeQuestionTopic(topicValue)
if !ok {
return sqlc.NullQuestionTopic{}, nil, errors.New("topic must match the supported seeded subjects")
}
subjectLabel := questionTopicLabel(sqlc.QuestionTopic(normalizedTopic))
return sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopic(normalizedTopic), Valid: true}, &subjectLabel, nil
}
func parseQuestionDifficulty(rawDifficulty *string) (sqlc.NullQuestionDifficulty, error) {
value := strings.TrimSpace(firstNonEmpty(rawDifficulty))
if value == "" {
return sqlc.NullQuestionDifficulty{}, nil
}
switch strings.ToLower(value) {
case "easy":
return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyEasy, Valid: true}, nil
case "medium":
return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyMedium, Valid: true}, nil
case "hard":
return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyHard, Valid: true}, nil
default:
return sqlc.NullQuestionDifficulty{}, errors.New("difficulty must be easy, medium, or hard")
}
}
func normalizeQuestionTopic(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "place value", "place_value":
return string(sqlc.QuestionTopicPlaceValue), true
case "arithmetic":
return string(sqlc.QuestionTopicArithmetic), true
case "negative numbers", "negative_numbers":
return string(sqlc.QuestionTopicNegativeNumbers), true
case "bidmas":
return string(sqlc.QuestionTopicBidmas), true
case "fractions":
return string(sqlc.QuestionTopicFractions), true
case "algebra":
return string(sqlc.QuestionTopicAlgebra), true
case "geometry":
return string(sqlc.QuestionTopicGeometry), true
case "data":
return string(sqlc.QuestionTopicData), true
default:
return "", false
}
}
func questionTopicLabel(topic sqlc.QuestionTopic) string {
switch topic {
case sqlc.QuestionTopicPlaceValue:
return "Place Value"
case sqlc.QuestionTopicArithmetic:
return "Arithmetic"
case sqlc.QuestionTopicNegativeNumbers:
return "Negative Numbers"
case sqlc.QuestionTopicBidmas:
return "BIDMAS"
case sqlc.QuestionTopicFractions:
return "Fractions"
case sqlc.QuestionTopicAlgebra:
return "Algebra"
case sqlc.QuestionTopicGeometry:
return "Geometry"
case sqlc.QuestionTopicData:
return "Data"
default:
return ""
}
}
func questionTopicPointer(topic sqlc.NullQuestionTopic) *string {
if !topic.Valid {
return nil
}
label := string(topic.QuestionTopic)
return &label
}
func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string {
if !difficulty.Valid {
return nil
}
value := string(difficulty.QuestionDifficulty)
return &value
}
func firstNonEmpty(values ...*string) string {
for _, value := range values {
if value == nil {
continue
}
trimmed := strings.TrimSpace(*value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func stringPointer(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &timestamp
}

View File

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

View File

@@ -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",
})
}

View File

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

View File

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

View File

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

View File

@@ -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, ", ")
}

View File

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

View File

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

View File

@@ -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",
})
})
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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,
}
}

View File

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

View File

@@ -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"`
}

View File

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

View File

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

View File

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