Boost Azure Demo

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

View File

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