Boost Azure Demo
This commit is contained in:
1
.envsitter/pepper
Normal file
1
.envsitter/pepper
Normal file
@@ -0,0 +1 @@
|
||||
RgIlJyE1N29vsJg2hyEPwkyf4Fkf7vWFNZggxti97pI=
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,3 +2,8 @@ node_modules
|
||||
.output
|
||||
.nitro
|
||||
dist
|
||||
tmp
|
||||
seed
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
30
Backend/.air.toml
Normal file
30
Backend/.air.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./cmd/server"
|
||||
delay = 1000
|
||||
exclude_dir = ["tmp", "vendor"]
|
||||
exclude_regex = ["_test.go"]
|
||||
follow_symlink = false
|
||||
include_ext = ["go"]
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
67
Backend/Earthfile
Normal file
67
Backend/Earthfile
Normal file
@@ -0,0 +1,67 @@
|
||||
VERSION 0.8
|
||||
|
||||
go-base:
|
||||
FROM golang:1.24.11-bookworm
|
||||
RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
|
||||
RUN go install github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
||||
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
deps:
|
||||
FROM +go-base
|
||||
RUN go mod download
|
||||
|
||||
dev-image:
|
||||
ARG IMAGE_NAME="boost-ai/demo-backend-dev"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +deps
|
||||
COPY . .
|
||||
|
||||
ENV GO_ENV=development
|
||||
ENV BACKEND_INTERNAL_PORT=8081
|
||||
ENV DATABASE_URL=postgres://boostai:boostai_dev_password@postgres-dev:5432/boostai?sslmode=disable
|
||||
EXPOSE 8081
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
dev-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-backend-dev"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
|
||||
prod-bin:
|
||||
FROM +deps
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/boostai-server ./cmd/server
|
||||
SAVE ARTIFACT /out/boostai-server AS LOCAL ./tmp/prod-a/boostai-server
|
||||
|
||||
prod-image:
|
||||
ARG IMAGE_NAME="boost-ai/demo-backend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY +prod-bin/boostai-server /app/boostai-server
|
||||
|
||||
ENV GO_ENV=production
|
||||
ENV BACKEND_INTERNAL_PORT=8081
|
||||
EXPOSE 8081
|
||||
|
||||
ENTRYPOINT ["/app/boostai-server"]
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
prod-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-backend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
560
Backend/cmd/backfill_historical_reviews/main.go
Normal file
560
Backend/cmd/backfill_historical_reviews/main.go
Normal file
@@ -0,0 +1,560 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var historicalAssignmentIDs = map[int64]bool{
|
||||
3001: true,
|
||||
3002: true,
|
||||
3003: true,
|
||||
3004: true,
|
||||
3005: true,
|
||||
}
|
||||
|
||||
type assignmentRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
MaximumMarks int `json:"maximum_marks"`
|
||||
}
|
||||
|
||||
type assigneeRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
Status string `json:"status"`
|
||||
OverallScore *float64 `json:"overall_score"`
|
||||
AiFeedback *string `json:"ai_feedback"`
|
||||
NextStepOutcome *string `json:"next_step_outcome"`
|
||||
}
|
||||
|
||||
type assignmentQuestionRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
QuestionBankID int64 `json:"question_bank_id"`
|
||||
}
|
||||
|
||||
type studentAnswerRecord struct {
|
||||
AssigneeID int64 `json:"assignee_id"`
|
||||
AssignmentQuestionID int64 `json:"assignment_question_id"`
|
||||
AnswerLatex string `json:"answer_latex"`
|
||||
AiReasoning string `json:"ai_reasoning"`
|
||||
AiFeedback *string `json:"ai_feedback"`
|
||||
SolveMode *string `json:"solve_mode"`
|
||||
UnderSolveMode *string `json:"_solve_mode"`
|
||||
IsCorrect *bool `json:"is_correct"`
|
||||
UnderIsCorrect *bool `json:"_is_correct"`
|
||||
MisconceptionTag *string `json:"_misconception_tag"`
|
||||
QuestionTopic *string `json:"_question_topic"`
|
||||
ReviewNeedsAttention *bool `json:"review_needs_attention"`
|
||||
ReviewIssueReason *string `json:"review_issue_reason"`
|
||||
ReviewUnderstandingScore *float64 `json:"review_understanding_score"`
|
||||
ReviewCorrectnessScore *float64 `json:"review_correctness_score"`
|
||||
ReviewQuestionScore *float64 `json:"review_question_score"`
|
||||
ReviewConfidence *float64 `json:"review_confidence"`
|
||||
ReviewTags []string `json:"review_tags"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
AnsweredAt *int64 `json:"_answered_at"`
|
||||
}
|
||||
|
||||
type questionReviewUpdate struct {
|
||||
AssignmentID int64
|
||||
StudentID int64
|
||||
QuestionID int64
|
||||
IsCorrect bool
|
||||
AIFeedback string
|
||||
NeedsAttention bool
|
||||
IssueReason string
|
||||
CorrectnessScore float64
|
||||
UnderstandingScore float64
|
||||
QuestionScore float64
|
||||
Confidence float64
|
||||
ReviewTags []string
|
||||
Topic string
|
||||
QuestionContribution float64
|
||||
AnsweredAt time.Time
|
||||
HasAnsweredAt bool
|
||||
}
|
||||
|
||||
type assigneeSummary struct {
|
||||
AssignmentID int64
|
||||
StudentID int64
|
||||
AssignmentName string
|
||||
OverallScore *float64
|
||||
AIFeedback string
|
||||
NextStepOutcome string
|
||||
QuestionUpdates []questionReviewUpdate
|
||||
CorrectCount int
|
||||
NeedsAttentionCnt int
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
db, err := database.NewPostgres(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mockDataDir, err := resolveMockDataDir()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve mock data directory: %v", err)
|
||||
}
|
||||
|
||||
assignments, err := readJSON[[]assignmentRecord](filepath.Join(mockDataDir, "assignments.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read assignments.json: %v", err)
|
||||
}
|
||||
assignees, err := readJSON[[]assigneeRecord](filepath.Join(mockDataDir, "assignment_assignees.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read assignment_assignees.json: %v", err)
|
||||
}
|
||||
assignmentQuestions, err := readJSON[[]assignmentQuestionRecord](filepath.Join(mockDataDir, "assignment_questions.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read assignment_questions.json: %v", err)
|
||||
}
|
||||
studentAnswers, err := readJSON[[]studentAnswerRecord](filepath.Join(mockDataDir, "student_answers.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read student_answers.json: %v", err)
|
||||
}
|
||||
|
||||
assignmentByID := map[int64]assignmentRecord{}
|
||||
for _, item := range assignments {
|
||||
assignmentByID[item.ID] = item
|
||||
}
|
||||
|
||||
assigneeByID := map[int64]assigneeRecord{}
|
||||
for _, item := range assignees {
|
||||
assigneeByID[item.ID] = item
|
||||
}
|
||||
|
||||
questionIDByAssignmentQuestionID := map[int64]int64{}
|
||||
for _, item := range assignmentQuestions {
|
||||
questionIDByAssignmentQuestionID[item.ID] = item.QuestionBankID
|
||||
}
|
||||
|
||||
summaries := map[string]*assigneeSummary{}
|
||||
for _, row := range studentAnswers {
|
||||
assignee, ok := assigneeByID[row.AssigneeID]
|
||||
if !ok || !historicalAssignmentIDs[assignee.AssignmentID] {
|
||||
continue
|
||||
}
|
||||
questionID, ok := questionIDByAssignmentQuestionID[row.AssignmentQuestionID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
assignment := assignmentByID[assignee.AssignmentID]
|
||||
key := fmt.Sprintf("%d:%d", assignee.AssignmentID, assignee.StudentID)
|
||||
summary := summaries[key]
|
||||
if summary == nil {
|
||||
summary = &assigneeSummary{
|
||||
AssignmentID: assignee.AssignmentID,
|
||||
StudentID: assignee.StudentID,
|
||||
AssignmentName: assignment.Name,
|
||||
}
|
||||
summaries[key] = summary
|
||||
}
|
||||
|
||||
update := buildQuestionReviewUpdate(assignee, questionID, row)
|
||||
summary.QuestionUpdates = append(summary.QuestionUpdates, update)
|
||||
if update.IsCorrect {
|
||||
summary.CorrectCount++
|
||||
}
|
||||
if update.NeedsAttention {
|
||||
summary.NeedsAttentionCnt++
|
||||
}
|
||||
}
|
||||
|
||||
for _, summary := range summaries {
|
||||
finalizeAssigneeSummary(summary)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
tx, err := db.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
updatedAnswers := 0
|
||||
updatedAssignees := 0
|
||||
for _, summary := range summaries {
|
||||
for _, update := range summary.QuestionUpdates {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE student_answers
|
||||
SET is_correct = $4,
|
||||
ai_feedback = $5,
|
||||
review_needs_attention = $6,
|
||||
review_issue_reason = $7,
|
||||
review_correctness_score = $8,
|
||||
review_understanding_score = $9,
|
||||
review_question_score = $10,
|
||||
review_confidence = $11,
|
||||
review_tags = $12,
|
||||
updated_at = NOW()
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2
|
||||
AND question_id = $3
|
||||
`,
|
||||
update.AssignmentID,
|
||||
update.StudentID,
|
||||
update.QuestionID,
|
||||
update.IsCorrect,
|
||||
nullableString(update.AIFeedback),
|
||||
update.NeedsAttention,
|
||||
nullableString(update.IssueReason),
|
||||
update.CorrectnessScore,
|
||||
update.UnderstandingScore,
|
||||
update.QuestionScore,
|
||||
update.Confidence,
|
||||
update.ReviewTags,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to update student answer (%d/%d/%d): %v", update.AssignmentID, update.StudentID, update.QuestionID, err)
|
||||
}
|
||||
updatedAnswers++
|
||||
}
|
||||
|
||||
var overall any
|
||||
var passStatus string
|
||||
if summary.OverallScore == nil {
|
||||
passStatus = "pending"
|
||||
overall = nil
|
||||
} else {
|
||||
overall = *summary.OverallScore
|
||||
if *summary.OverallScore >= 6.0 {
|
||||
passStatus = "pass"
|
||||
} else {
|
||||
passStatus = "no_pass"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE assignment_assignees
|
||||
SET overall_score = $3,
|
||||
ai_feedback = $4,
|
||||
pass_status = $5,
|
||||
pass_status_override = NULL
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2
|
||||
`, summary.AssignmentID, summary.StudentID, overall, nullableString(summary.AIFeedback), passStatus)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to update assignment assignee (%d/%d): %v", summary.AssignmentID, summary.StudentID, err)
|
||||
}
|
||||
updatedAssignees++
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Fatalf("failed to commit transaction: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("historical review backfill complete: %d answers updated, %d assignees updated", updatedAnswers, updatedAssignees)
|
||||
}
|
||||
|
||||
func buildQuestionReviewUpdate(assignee assigneeRecord, questionID int64, row studentAnswerRecord) questionReviewUpdate {
|
||||
isCorrect := false
|
||||
if row.IsCorrect != nil {
|
||||
isCorrect = *row.IsCorrect
|
||||
} else if row.UnderIsCorrect != nil {
|
||||
isCorrect = *row.UnderIsCorrect
|
||||
} else {
|
||||
isCorrect = !strings.HasPrefix(strings.ToLower(strings.TrimSpace(row.AiReasoning)), "incorrect")
|
||||
}
|
||||
|
||||
solveMode := firstNonEmptyString(row.SolveMode, row.UnderSolveMode)
|
||||
understanding := valueOrElse(row.ReviewUnderstandingScore, deriveUnderstandingScore(isCorrect, solveMode))
|
||||
confidence := valueOrElse(row.ReviewConfidence, deriveConfidenceScore(isCorrect, solveMode))
|
||||
needsAttention := boolOrElse(row.ReviewNeedsAttention, !isCorrect || understanding < 0.72)
|
||||
issueReason := stringOrElse(row.ReviewIssueReason, deriveIssueReason(row.AiReasoning, row.MisconceptionTag))
|
||||
aiFeedback := stringOrElse(row.AiFeedback, row.AiReasoning)
|
||||
reviewTags := sanitizeTags(row.ReviewTags, row.MisconceptionTag)
|
||||
correctness := valueOrElse(row.ReviewCorrectnessScore, 1.0)
|
||||
questionScore := valueOrElse(row.ReviewQuestionScore, 1.0)
|
||||
questionContribution := ((boolToFloat(isCorrect)) + understanding) / 2
|
||||
|
||||
var answeredAt time.Time
|
||||
var hasAnsweredAt bool
|
||||
if row.AnsweredAt != nil && *row.AnsweredAt > 0 {
|
||||
answeredAt = time.UnixMilli(*row.AnsweredAt).UTC()
|
||||
hasAnsweredAt = true
|
||||
} else if row.CreatedAt > 0 {
|
||||
answeredAt = time.UnixMilli(row.CreatedAt).UTC()
|
||||
hasAnsweredAt = true
|
||||
}
|
||||
|
||||
return questionReviewUpdate{
|
||||
AssignmentID: assignee.AssignmentID,
|
||||
StudentID: assignee.StudentID,
|
||||
QuestionID: questionID,
|
||||
IsCorrect: isCorrect,
|
||||
AIFeedback: aiFeedback,
|
||||
NeedsAttention: needsAttention,
|
||||
IssueReason: issueReason,
|
||||
CorrectnessScore: roundToThree(correctness),
|
||||
UnderstandingScore: roundToThree(understanding),
|
||||
QuestionScore: roundToThree(questionScore),
|
||||
Confidence: roundToThree(confidence),
|
||||
ReviewTags: reviewTags,
|
||||
Topic: stringOrElse(row.QuestionTopic, "general"),
|
||||
QuestionContribution: questionContribution,
|
||||
AnsweredAt: answeredAt,
|
||||
HasAnsweredAt: hasAnsweredAt,
|
||||
}
|
||||
}
|
||||
|
||||
func finalizeAssigneeSummary(summary *assigneeSummary) {
|
||||
if len(summary.QuestionUpdates) == 0 {
|
||||
return
|
||||
}
|
||||
var total float64
|
||||
topicScores := map[string][]float64{}
|
||||
for _, item := range summary.QuestionUpdates {
|
||||
total += item.QuestionContribution
|
||||
topicScores[item.Topic] = append(topicScores[item.Topic], item.QuestionContribution)
|
||||
}
|
||||
overall := roundToTwo(total / float64(len(summary.QuestionUpdates)) * 10)
|
||||
summary.OverallScore = &overall
|
||||
|
||||
type topicAvg struct {
|
||||
name string
|
||||
avg float64
|
||||
}
|
||||
var weakest []topicAvg
|
||||
for topic, scores := range topicScores {
|
||||
var subtotal float64
|
||||
for _, score := range scores {
|
||||
subtotal += score
|
||||
}
|
||||
weakest = append(weakest, topicAvg{name: topic, avg: subtotal / float64(len(scores))})
|
||||
}
|
||||
if len(weakest) > 1 {
|
||||
sort.Slice(weakest, func(i, j int) bool {
|
||||
if weakest[i].avg == weakest[j].avg {
|
||||
return weakest[i].name < weakest[j].name
|
||||
}
|
||||
return weakest[i].avg < weakest[j].avg
|
||||
})
|
||||
}
|
||||
weakestTopics := []string{}
|
||||
for i, item := range weakest {
|
||||
if i >= 2 {
|
||||
break
|
||||
}
|
||||
weakestTopics = append(weakestTopics, displayTopic(item.name))
|
||||
}
|
||||
weakestTopicText := "general fluency"
|
||||
if len(weakestTopics) > 0 {
|
||||
weakestTopicText = strings.Join(weakestTopics, ", ")
|
||||
}
|
||||
summary.NextStepOutcome = "accept"
|
||||
if overall < 4.5 {
|
||||
summary.NextStepOutcome = "redo"
|
||||
} else if overall < 6.0 {
|
||||
summary.NextStepOutcome = "support"
|
||||
}
|
||||
summary.AIFeedback = fmt.Sprintf(
|
||||
"Student completed %s with %d/%d correct responses. Overall score is %.2f/10. The weakest areas were %s. %d question(s) need extra attention.",
|
||||
summary.AssignmentName,
|
||||
summary.CorrectCount,
|
||||
len(summary.QuestionUpdates),
|
||||
overall,
|
||||
weakestTopicText,
|
||||
summary.NeedsAttentionCnt,
|
||||
)
|
||||
}
|
||||
|
||||
func deriveUnderstandingScore(isCorrect bool, solveMode string) float64 {
|
||||
if isCorrect {
|
||||
return map[string]float64{
|
||||
"step_by_step": 0.95,
|
||||
"handwritten": 0.85,
|
||||
"just_answer": 0.75,
|
||||
"solve_together": 0.65,
|
||||
}[defaultSolveMode(solveMode)]
|
||||
}
|
||||
return map[string]float64{
|
||||
"step_by_step": 0.40,
|
||||
"handwritten": 0.32,
|
||||
"just_answer": 0.20,
|
||||
"solve_together": 0.28,
|
||||
}[defaultSolveMode(solveMode)]
|
||||
}
|
||||
|
||||
func deriveConfidenceScore(isCorrect bool, solveMode string) float64 {
|
||||
if isCorrect {
|
||||
return map[string]float64{
|
||||
"step_by_step": 0.82,
|
||||
"handwritten": 0.78,
|
||||
"just_answer": 0.90,
|
||||
"solve_together": 0.62,
|
||||
}[defaultSolveMode(solveMode)]
|
||||
}
|
||||
return map[string]float64{
|
||||
"step_by_step": 0.55,
|
||||
"handwritten": 0.60,
|
||||
"just_answer": 0.72,
|
||||
"solve_together": 0.50,
|
||||
}[defaultSolveMode(solveMode)]
|
||||
}
|
||||
|
||||
func deriveIssueReason(aiReasoning string, misconception *string) string {
|
||||
if misconception != nil && strings.TrimSpace(*misconception) != "" {
|
||||
switch strings.TrimSpace(*misconception) {
|
||||
case "add_tops_add_bottoms":
|
||||
return "The student added the numerator and denominator directly instead of finding a common denominator."
|
||||
case "fraction_op_confusion":
|
||||
return "The student confused the fraction operation and did not apply the correct method."
|
||||
case "fraction_general_uncertainty":
|
||||
return "The student shows insecure understanding of equivalent or comparable fractions."
|
||||
case "place_value_misalignment":
|
||||
return "The student misread place value, causing digits to be aligned incorrectly."
|
||||
case "arithmetic_slip":
|
||||
return "The final answer is wrong, suggesting a careless arithmetic slip rather than a secure method."
|
||||
case "scaffolding_dependence":
|
||||
return "The student appears dependent on scaffolding and does not show secure independent understanding."
|
||||
case "word_problem_interpretation":
|
||||
return "The student did not translate the word problem into the correct calculation."
|
||||
default:
|
||||
return strings.TrimSpace(*misconception)
|
||||
}
|
||||
}
|
||||
text := strings.TrimSpace(aiReasoning)
|
||||
if text == "" {
|
||||
return "The answer shows incomplete understanding of the method."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func defaultSolveMode(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "just_answer"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func displayTopic(value string) string {
|
||||
value = strings.ReplaceAll(strings.TrimSpace(value), "_", " ")
|
||||
parts := strings.Fields(value)
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func boolToFloat(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func roundToTwo(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func roundToThree(value float64) float64 {
|
||||
return math.Round(value*1000) / 1000
|
||||
}
|
||||
|
||||
func readJSON[T any](path string) (T, error) {
|
||||
var zero T
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func resolveMockDataDir() (string, error) {
|
||||
if value := strings.TrimSpace(os.Getenv("MOCK_DATA_DIR")); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join("..", "Mock-Data"),
|
||||
filepath.Join(".", "Mock-Data"),
|
||||
filepath.Join("..", "..", "Mock-Data"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Mock-Data directory not found; set MOCK_DATA_DIR")
|
||||
}
|
||||
|
||||
func valueOrElse(value *float64, fallback float64) float64 {
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolOrElse(value *bool, fallback bool) bool {
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stringOrElse(value *string, fallback string) string {
|
||||
if value != nil && strings.TrimSpace(*value) != "" {
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...*string) string {
|
||||
for _, value := range values {
|
||||
if value != nil && strings.TrimSpace(*value) != "" {
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeTags(tags []string, misconception *string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(tags)+1)
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
result = append(result, tag)
|
||||
}
|
||||
if misconception != nil {
|
||||
tag := strings.TrimSpace(*misconception)
|
||||
if tag != "" && !seen[tag] {
|
||||
result = append(result, tag)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
81
Backend/cmd/server/main.go
Normal file
81
Backend/cmd/server/main.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Path: Backend/cmd/server/main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/config"
|
||||
"boostai-backend/internal/database"
|
||||
"boostai-backend/internal/router"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := database.NewPostgres(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
log.Println("Connected to database")
|
||||
|
||||
if err := db.Migrate(); err != nil {
|
||||
log.Fatalf("failed to run migrations: %v", err)
|
||||
}
|
||||
log.Println("Database migrations complete")
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "BoostAI Backend",
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
})
|
||||
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: cfg.AllowedOrigins,
|
||||
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
||||
AllowCredentials: true,
|
||||
MaxAge: 86400,
|
||||
}))
|
||||
|
||||
router.Setup(app, cfg, db)
|
||||
|
||||
go func() {
|
||||
if err := app.Listen(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("BoostAI backend listening on port %s", cfg.Port)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down backend server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := app.ShutdownWithContext(ctx); err != nil {
|
||||
log.Fatalf("server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Backend server exited")
|
||||
}
|
||||
6
Backend/db/embed.go
Normal file
6
Backend/db/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package db
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var Migrations embed.FS
|
||||
160
Backend/db/migrations/001_init.sql
Normal file
160
Backend/db/migrations/001_init.sql
Normal file
@@ -0,0 +1,160 @@
|
||||
-- +goose Up
|
||||
CREATE TYPE user_role AS ENUM ('student', 'teacher');
|
||||
CREATE TYPE question_status AS ENUM ('draft', 'published', 'archived');
|
||||
CREATE TYPE assignment_status AS ENUM ('draft', 'assigned', 'closed');
|
||||
CREATE TYPE answer_status AS ENUM ('not_started', 'in_progress', 'submitted', 'reviewed');
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
role user_role NOT NULL,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE classrooms (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(50) UNIQUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE classroom_students (
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||
student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (classroom_id, student_id)
|
||||
);
|
||||
|
||||
CREATE TABLE questions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
author_teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
subject VARCHAR(100),
|
||||
source TEXT,
|
||||
status question_status NOT NULL DEFAULT 'draft',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE question_tags (
|
||||
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (question_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE assignments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
instructions TEXT,
|
||||
status assignment_status NOT NULL DEFAULT 'draft',
|
||||
due_at TIMESTAMPTZ,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE assignment_assignees (
|
||||
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (assignment_id, student_id)
|
||||
);
|
||||
|
||||
CREATE TABLE assignment_questions (
|
||||
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (assignment_id, question_id),
|
||||
CONSTRAINT assignment_questions_assignment_position_key UNIQUE (assignment_id, position)
|
||||
);
|
||||
|
||||
CREATE TABLE student_answers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT,
|
||||
student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
answer_text TEXT,
|
||||
ai_feedback TEXT,
|
||||
teacher_feedback TEXT,
|
||||
status answer_status NOT NULL DEFAULT 'not_started',
|
||||
submitted_at TIMESTAMPTZ,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT student_answers_assignment_question_student_key UNIQUE (assignment_id, question_id, student_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_role ON users(role);
|
||||
CREATE INDEX idx_classrooms_teacher_id ON classrooms(teacher_id);
|
||||
CREATE INDEX idx_questions_author_teacher_id ON questions(author_teacher_id);
|
||||
CREATE INDEX idx_questions_status ON questions(status);
|
||||
CREATE INDEX idx_assignments_classroom_id ON assignments(classroom_id);
|
||||
CREATE INDEX idx_assignments_teacher_id ON assignments(teacher_id);
|
||||
CREATE INDEX idx_assignment_assignees_student_id ON assignment_assignees(student_id);
|
||||
CREATE INDEX idx_student_answers_student_id ON student_answers(student_id);
|
||||
CREATE INDEX idx_student_answers_assignment_id ON student_answers(assignment_id);
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER classrooms_updated_at BEFORE UPDATE ON classrooms
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER questions_updated_at BEFORE UPDATE ON questions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER assignments_updated_at BEFORE UPDATE ON assignments
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER student_answers_updated_at BEFORE UPDATE ON student_answers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- +goose Down
|
||||
DROP TRIGGER IF EXISTS student_answers_updated_at ON student_answers;
|
||||
DROP TRIGGER IF EXISTS assignments_updated_at ON assignments;
|
||||
DROP TRIGGER IF EXISTS questions_updated_at ON questions;
|
||||
DROP TRIGGER IF EXISTS classrooms_updated_at ON classrooms;
|
||||
DROP TRIGGER IF EXISTS users_updated_at ON users;
|
||||
DROP FUNCTION IF EXISTS update_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS student_answers;
|
||||
DROP TABLE IF EXISTS assignment_questions;
|
||||
DROP TABLE IF EXISTS assignment_assignees;
|
||||
DROP TABLE IF EXISTS assignments;
|
||||
DROP TABLE IF EXISTS question_tags;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
DROP TABLE IF EXISTS questions;
|
||||
DROP TABLE IF EXISTS classroom_students;
|
||||
DROP TABLE IF EXISTS classrooms;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
DROP TYPE IF EXISTS answer_status;
|
||||
DROP TYPE IF EXISTS assignment_status;
|
||||
DROP TYPE IF EXISTS question_status;
|
||||
DROP TYPE IF EXISTS user_role;
|
||||
44
Backend/db/migrations/002_profiles.sql
Normal file
44
Backend/db/migrations/002_profiles.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE profiles (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
preferred_name VARCHAR(100),
|
||||
profile_icon_url TEXT,
|
||||
headline VARCHAR(255),
|
||||
bio TEXT,
|
||||
timezone VARCHAR(100),
|
||||
locale VARCHAR(20),
|
||||
grade_level VARCHAR(100),
|
||||
learning_goal TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO profiles (user_id)
|
||||
SELECT id
|
||||
FROM users
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE OR REPLACE FUNCTION ensure_profile_for_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO profiles (user_id)
|
||||
VALUES (NEW.id)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER users_ensure_profile_after_insert AFTER INSERT ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION ensure_profile_for_user();
|
||||
|
||||
-- +goose Down
|
||||
DROP TRIGGER IF EXISTS users_ensure_profile_after_insert ON users;
|
||||
DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
|
||||
DROP FUNCTION IF EXISTS ensure_profile_for_user();
|
||||
DROP TABLE IF EXISTS profiles;
|
||||
48
Backend/db/migrations/003_messages.sql
Normal file
48
Backend/db/migrations/003_messages.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE message_threads (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_by_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT message_threads_subject_not_blank CHECK (length(btrim(subject)) > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE message_thread_participants (
|
||||
thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (thread_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
|
||||
sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT messages_body_not_blank CHECK (length(btrim(body)) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_message_threads_created_by_user_id ON message_threads(created_by_user_id);
|
||||
CREATE INDEX idx_message_threads_updated_at ON message_threads(updated_at DESC);
|
||||
CREATE INDEX idx_message_thread_participants_user_id ON message_thread_participants(user_id);
|
||||
CREATE INDEX idx_messages_thread_id_created_at ON messages(thread_id, created_at DESC);
|
||||
CREATE INDEX idx_messages_sender_user_id ON messages(sender_user_id);
|
||||
|
||||
CREATE TRIGGER message_threads_updated_at BEFORE UPDATE ON message_threads
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER messages_updated_at BEFORE UPDATE ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- +goose Down
|
||||
DROP TRIGGER IF EXISTS messages_updated_at ON messages;
|
||||
DROP TRIGGER IF EXISTS message_threads_updated_at ON message_threads;
|
||||
|
||||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS message_thread_participants;
|
||||
DROP TABLE IF EXISTS message_threads;
|
||||
12
Backend/db/migrations/004_student_answer_workspace.sql
Normal file
12
Backend/db/migrations/004_student_answer_workspace.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE student_answers
|
||||
ADD COLUMN solve_mode TEXT NOT NULL DEFAULT 'just_answer',
|
||||
ADD COLUMN working_steps TEXT,
|
||||
ADD CONSTRAINT student_answers_solve_mode_check
|
||||
CHECK (solve_mode IN ('just_answer', 'step_by_step', 'solve_together', 'handwritten'));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE student_answers
|
||||
DROP CONSTRAINT IF EXISTS student_answers_solve_mode_check,
|
||||
DROP COLUMN IF EXISTS working_steps,
|
||||
DROP COLUMN IF EXISTS solve_mode;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN correct_answer TEXT;
|
||||
|
||||
ALTER TABLE student_answers
|
||||
ADD COLUMN is_correct BOOLEAN;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE student_answers
|
||||
DROP COLUMN IF EXISTS is_correct;
|
||||
|
||||
ALTER TABLE questions
|
||||
DROP COLUMN IF EXISTS correct_answer;
|
||||
53
Backend/db/migrations/006_assignment_level_feedback.sql
Normal file
53
Backend/db/migrations/006_assignment_level_feedback.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ADD COLUMN ai_feedback TEXT,
|
||||
ADD COLUMN teacher_feedback TEXT;
|
||||
|
||||
UPDATE assignment_assignees aa
|
||||
SET ai_feedback = aggregated.ai_feedback
|
||||
FROM (
|
||||
SELECT
|
||||
sa.assignment_id,
|
||||
sa.student_id,
|
||||
string_agg(
|
||||
format('Question %s: %s', aq.position, btrim(sa.ai_feedback)),
|
||||
E'\n\n'
|
||||
ORDER BY aq.position ASC
|
||||
) AS ai_feedback
|
||||
FROM student_answers sa
|
||||
JOIN assignment_questions aq
|
||||
ON aq.assignment_id = sa.assignment_id
|
||||
AND aq.question_id = sa.question_id
|
||||
WHERE NULLIF(btrim(sa.ai_feedback), '') IS NOT NULL
|
||||
GROUP BY sa.assignment_id, sa.student_id
|
||||
) AS aggregated
|
||||
WHERE aa.assignment_id = aggregated.assignment_id
|
||||
AND aa.student_id = aggregated.student_id;
|
||||
|
||||
UPDATE assignment_assignees aa
|
||||
SET teacher_feedback = aggregated.teacher_feedback
|
||||
FROM (
|
||||
SELECT
|
||||
sa.assignment_id,
|
||||
sa.student_id,
|
||||
string_agg(
|
||||
format('Question %s: %s', aq.position, btrim(sa.teacher_feedback)),
|
||||
E'\n\n'
|
||||
ORDER BY aq.position ASC
|
||||
) AS teacher_feedback
|
||||
FROM student_answers sa
|
||||
JOIN assignment_questions aq
|
||||
ON aq.assignment_id = sa.assignment_id
|
||||
AND aq.question_id = sa.question_id
|
||||
WHERE NULLIF(btrim(sa.teacher_feedback), '') IS NOT NULL
|
||||
GROUP BY sa.assignment_id, sa.student_id
|
||||
) AS aggregated
|
||||
WHERE aa.assignment_id = aggregated.assignment_id
|
||||
AND aa.student_id = aggregated.student_id;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
DROP COLUMN IF EXISTS teacher_feedback,
|
||||
DROP COLUMN IF EXISTS ai_feedback;
|
||||
66
Backend/db/migrations/007_review_contract.sql
Normal file
66
Backend/db/migrations/007_review_contract.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TYPE assignment_pass_status AS ENUM ('pending', 'pass', 'no_pass');
|
||||
|
||||
ALTER TABLE student_answers
|
||||
ADD COLUMN review_needs_attention BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN review_issue_reason TEXT,
|
||||
ADD COLUMN review_correctness_score NUMERIC(4,3),
|
||||
ADD COLUMN review_understanding_score NUMERIC(4,3),
|
||||
ADD COLUMN review_question_score NUMERIC(4,3),
|
||||
ADD COLUMN review_confidence NUMERIC(4,3),
|
||||
ADD COLUMN review_tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
ADD CONSTRAINT student_answers_review_correctness_score_range_check
|
||||
CHECK (review_correctness_score IS NULL OR (review_correctness_score >= 0 AND review_correctness_score <= 1)),
|
||||
ADD CONSTRAINT student_answers_review_understanding_score_range_check
|
||||
CHECK (review_understanding_score IS NULL OR (review_understanding_score >= 0 AND review_understanding_score <= 1)),
|
||||
ADD CONSTRAINT student_answers_review_question_score_range_check
|
||||
CHECK (review_question_score IS NULL OR (review_question_score >= 0 AND review_question_score <= 1)),
|
||||
ADD CONSTRAINT student_answers_review_confidence_range_check
|
||||
CHECK (review_confidence IS NULL OR (review_confidence >= 0 AND review_confidence <= 1));
|
||||
|
||||
UPDATE student_answers
|
||||
SET review_correctness_score = CASE
|
||||
WHEN is_correct IS TRUE THEN 1.000
|
||||
WHEN is_correct IS FALSE THEN 0.000
|
||||
ELSE NULL
|
||||
END,
|
||||
review_question_score = CASE
|
||||
WHEN is_correct IS TRUE THEN 1.000
|
||||
WHEN is_correct IS FALSE THEN 0.000
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE is_correct IS NOT NULL;
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ADD COLUMN overall_score NUMERIC(5,2),
|
||||
ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00,
|
||||
ADD COLUMN pass_status assignment_pass_status NOT NULL DEFAULT 'pending',
|
||||
ADD CONSTRAINT assignment_assignees_overall_score_range_check
|
||||
CHECK (overall_score IS NULL OR (overall_score >= 0 AND overall_score <= 10)),
|
||||
ADD CONSTRAINT assignment_assignees_pass_threshold_range_check
|
||||
CHECK (pass_threshold >= 0 AND pass_threshold <= 10);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
DROP CONSTRAINT IF EXISTS assignment_assignees_pass_threshold_range_check,
|
||||
DROP CONSTRAINT IF EXISTS assignment_assignees_overall_score_range_check,
|
||||
DROP COLUMN IF EXISTS pass_status,
|
||||
DROP COLUMN IF EXISTS pass_threshold,
|
||||
DROP COLUMN IF EXISTS overall_score;
|
||||
|
||||
ALTER TABLE student_answers
|
||||
DROP CONSTRAINT IF EXISTS student_answers_review_confidence_range_check,
|
||||
DROP CONSTRAINT IF EXISTS student_answers_review_question_score_range_check,
|
||||
DROP CONSTRAINT IF EXISTS student_answers_review_understanding_score_range_check,
|
||||
DROP CONSTRAINT IF EXISTS student_answers_review_correctness_score_range_check,
|
||||
DROP COLUMN IF EXISTS review_tags,
|
||||
DROP COLUMN IF EXISTS review_confidence,
|
||||
DROP COLUMN IF EXISTS review_question_score,
|
||||
DROP COLUMN IF EXISTS review_understanding_score,
|
||||
DROP COLUMN IF EXISTS review_correctness_score,
|
||||
DROP COLUMN IF EXISTS review_issue_reason,
|
||||
DROP COLUMN IF EXISTS review_needs_attention;
|
||||
|
||||
DROP TYPE IF EXISTS assignment_pass_status;
|
||||
22
Backend/db/migrations/008_assignment_pass_threshold.sql
Normal file
22
Backend/db/migrations/008_assignment_pass_threshold.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE assignments
|
||||
ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00,
|
||||
ADD CONSTRAINT assignments_pass_threshold_range_check
|
||||
CHECK (pass_threshold >= 0 AND pass_threshold <= 10);
|
||||
|
||||
UPDATE assignments a
|
||||
SET pass_threshold = COALESCE(
|
||||
(
|
||||
SELECT MAX(aa.pass_threshold)
|
||||
FROM assignment_assignees aa
|
||||
WHERE aa.assignment_id = a.id
|
||||
),
|
||||
8.00
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE assignments
|
||||
DROP CONSTRAINT IF EXISTS assignments_pass_threshold_range_check,
|
||||
DROP COLUMN IF EXISTS pass_threshold;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ADD COLUMN pass_status_override assignment_pass_status;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
DROP COLUMN IF EXISTS pass_status_override;
|
||||
13
Backend/db/migrations/010_assignment_next_step_outcome.sql
Normal file
13
Backend/db/migrations/010_assignment_next_step_outcome.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TYPE assignment_next_step_outcome AS ENUM ('redo', 'accept', 'support');
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ADD COLUMN next_step_outcome assignment_next_step_outcome;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
DROP COLUMN IF EXISTS next_step_outcome;
|
||||
|
||||
DROP TYPE IF EXISTS assignment_next_step_outcome;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- +goose Up
|
||||
|
||||
UPDATE questions AS q
|
||||
SET correct_answer = seeded.correct_answer
|
||||
FROM (
|
||||
VALUES
|
||||
(1001, '700'),
|
||||
(1002, '25000'),
|
||||
(1003, '7/100'),
|
||||
(1004, '0.37'),
|
||||
(1101, '383'),
|
||||
(1102, '456'),
|
||||
(1103, '2627'),
|
||||
(1104, '196'),
|
||||
(1105, '24'),
|
||||
(1106, '3744'),
|
||||
(1201, '3'),
|
||||
(1202, '-5'),
|
||||
(1203, '-4'),
|
||||
(1301, '11'),
|
||||
(1302, '22'),
|
||||
(1303, '19'),
|
||||
(1401, '1/2'),
|
||||
(1402, '2/3'),
|
||||
(1403, '5/8'),
|
||||
(1411, '5/6'),
|
||||
(1412, '3/4'),
|
||||
(1413, '11/15'),
|
||||
(1414, '11/12'),
|
||||
(1415, '1/2'),
|
||||
(1416, '29/24'),
|
||||
(1421, '1/6'),
|
||||
(1422, '1/2'),
|
||||
(1423, '28'),
|
||||
(1501, '5'),
|
||||
(1502, '7'),
|
||||
(1503, '6'),
|
||||
(1511, '14'),
|
||||
(1512, '31'),
|
||||
(1513, '3n+2'),
|
||||
(1601, '28'),
|
||||
(1602, '40'),
|
||||
(1603, '27'),
|
||||
(1611, '70'),
|
||||
(1612, '75'),
|
||||
(1613, '720'),
|
||||
(1701, '8'),
|
||||
(1702, '5'),
|
||||
(1703, '7'),
|
||||
(1711, '3/8'),
|
||||
(1712, '1/3'),
|
||||
(1801, '15'),
|
||||
(1802, '3/8'),
|
||||
(1803, '51'),
|
||||
(1804, '23'),
|
||||
(1805, '96')
|
||||
) AS seeded(id, correct_answer)
|
||||
WHERE q.id = seeded.id
|
||||
AND (q.correct_answer IS NULL OR BTRIM(q.correct_answer) = '');
|
||||
|
||||
-- +goose Down
|
||||
|
||||
UPDATE questions
|
||||
SET correct_answer = NULL
|
||||
WHERE id IN (
|
||||
1001, 1002, 1003, 1004,
|
||||
1101, 1102, 1103, 1104, 1105, 1106,
|
||||
1201, 1202, 1203,
|
||||
1301, 1302, 1303,
|
||||
1401, 1402, 1403,
|
||||
1411, 1412, 1413, 1414, 1415, 1416,
|
||||
1421, 1422, 1423,
|
||||
1501, 1502, 1503,
|
||||
1511, 1512, 1513,
|
||||
1601, 1602, 1603,
|
||||
1611, 1612, 1613,
|
||||
1701, 1702, 1703,
|
||||
1711, 1712,
|
||||
1801, 1802, 1803, 1804, 1805
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TYPE question_topic AS ENUM (
|
||||
'place_value',
|
||||
'arithmetic',
|
||||
'negative_numbers',
|
||||
'bidmas',
|
||||
'fractions',
|
||||
'algebra',
|
||||
'geometry',
|
||||
'data'
|
||||
);
|
||||
|
||||
CREATE TYPE question_difficulty AS ENUM ('easy', 'medium', 'hard');
|
||||
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN topic question_topic,
|
||||
ADD COLUMN difficulty question_difficulty;
|
||||
|
||||
UPDATE questions
|
||||
SET topic = CASE BTRIM(COALESCE(subject, ''))
|
||||
WHEN 'Place Value' THEN 'place_value'::question_topic
|
||||
WHEN 'Arithmetic' THEN 'arithmetic'::question_topic
|
||||
WHEN 'Negative Numbers' THEN 'negative_numbers'::question_topic
|
||||
WHEN 'BIDMAS' THEN 'bidmas'::question_topic
|
||||
WHEN 'Fractions' THEN 'fractions'::question_topic
|
||||
WHEN 'Algebra' THEN 'algebra'::question_topic
|
||||
WHEN 'Geometry' THEN 'geometry'::question_topic
|
||||
WHEN 'Data' THEN 'data'::question_topic
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE topic IS NULL;
|
||||
|
||||
UPDATE questions AS q
|
||||
SET difficulty = seeded.difficulty::question_difficulty
|
||||
FROM (
|
||||
VALUES
|
||||
(1001, 'easy'),
|
||||
(1002, 'medium'),
|
||||
(1003, 'medium'),
|
||||
(1004, 'hard'),
|
||||
(1101, 'easy'),
|
||||
(1102, 'medium'),
|
||||
(1103, 'hard'),
|
||||
(1104, 'medium'),
|
||||
(1105, 'medium'),
|
||||
(1106, 'hard'),
|
||||
(1201, 'easy'),
|
||||
(1202, 'medium'),
|
||||
(1203, 'hard'),
|
||||
(1301, 'easy'),
|
||||
(1302, 'medium'),
|
||||
(1303, 'hard'),
|
||||
(1401, 'easy'),
|
||||
(1402, 'medium'),
|
||||
(1403, 'hard'),
|
||||
(1411, 'easy'),
|
||||
(1412, 'easy'),
|
||||
(1413, 'medium'),
|
||||
(1414, 'medium'),
|
||||
(1415, 'medium'),
|
||||
(1416, 'hard'),
|
||||
(1421, 'easy'),
|
||||
(1422, 'medium'),
|
||||
(1423, 'hard'),
|
||||
(1501, 'easy'),
|
||||
(1502, 'medium'),
|
||||
(1503, 'hard'),
|
||||
(1511, 'easy'),
|
||||
(1512, 'medium'),
|
||||
(1513, 'hard'),
|
||||
(1601, 'easy'),
|
||||
(1602, 'medium'),
|
||||
(1603, 'hard'),
|
||||
(1611, 'easy'),
|
||||
(1612, 'medium'),
|
||||
(1613, 'hard'),
|
||||
(1701, 'easy'),
|
||||
(1702, 'medium'),
|
||||
(1703, 'easy'),
|
||||
(1711, 'medium'),
|
||||
(1712, 'hard'),
|
||||
(1801, 'easy'),
|
||||
(1802, 'medium'),
|
||||
(1803, 'medium'),
|
||||
(1804, 'hard'),
|
||||
(1805, 'hard')
|
||||
) AS seeded(id, difficulty)
|
||||
WHERE q.id = seeded.id
|
||||
AND q.difficulty IS NULL;
|
||||
|
||||
ALTER TABLE assignments
|
||||
ALTER COLUMN pass_threshold SET DEFAULT 6.00;
|
||||
|
||||
UPDATE assignments
|
||||
SET pass_threshold = 6.00;
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ALTER COLUMN pass_threshold SET DEFAULT 6.00;
|
||||
|
||||
UPDATE assignment_assignees
|
||||
SET pass_threshold = 6.00;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
UPDATE assignment_assignees
|
||||
SET pass_threshold = 8.00;
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ALTER COLUMN pass_threshold SET DEFAULT 8.00;
|
||||
|
||||
UPDATE assignments
|
||||
SET pass_threshold = 8.00;
|
||||
|
||||
ALTER TABLE assignments
|
||||
ALTER COLUMN pass_threshold SET DEFAULT 8.00;
|
||||
|
||||
ALTER TABLE questions
|
||||
DROP COLUMN IF EXISTS difficulty,
|
||||
DROP COLUMN IF EXISTS topic;
|
||||
|
||||
DROP TYPE IF EXISTS question_difficulty;
|
||||
DROP TYPE IF EXISTS question_topic;
|
||||
11
Backend/db/migrations/013_redo_assignment_plan.sql
Normal file
11
Backend/db/migrations/013_redo_assignment_plan.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
ADD COLUMN redo_plan TEXT,
|
||||
ADD COLUMN redo_plan_generated_at TIMESTAMPTZ;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE assignment_assignees
|
||||
DROP COLUMN IF EXISTS redo_plan_generated_at,
|
||||
DROP COLUMN IF EXISTS redo_plan;
|
||||
30
Backend/db/migrations/014_assignment_student_questions.sql
Normal file
30
Backend/db/migrations/014_assignment_student_questions.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE assignment_student_questions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
assignment_id BIGINT NOT NULL,
|
||||
student_id BIGINT NOT NULL,
|
||||
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK (position > 0),
|
||||
source_bucket TEXT NOT NULL CHECK (btrim(source_bucket) <> ''),
|
||||
source_topic question_topic,
|
||||
source_difficulty question_difficulty,
|
||||
generator_seed BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT assignment_student_questions_assignment_student_fkey
|
||||
FOREIGN KEY (assignment_id, student_id)
|
||||
REFERENCES assignment_assignees(assignment_id, student_id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT assignment_student_questions_assignment_student_question_key
|
||||
UNIQUE (assignment_id, student_id, question_id),
|
||||
CONSTRAINT assignment_student_questions_assignment_student_position_key
|
||||
UNIQUE (assignment_id, student_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assignment_student_questions_assignment_student
|
||||
ON assignment_student_questions (assignment_id, student_id);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP INDEX IF EXISTS idx_assignment_student_questions_assignment_student;
|
||||
DROP TABLE IF EXISTS assignment_student_questions;
|
||||
391
Backend/db/queries/assignments.sql
Normal file
391
Backend/db/queries/assignments.sql
Normal file
@@ -0,0 +1,391 @@
|
||||
-- name: CreateAssignment :one
|
||||
INSERT INTO assignments (
|
||||
classroom_id,
|
||||
teacher_id,
|
||||
title,
|
||||
instructions,
|
||||
status,
|
||||
due_at,
|
||||
published_at,
|
||||
pass_threshold
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: AssignStudentToAssignment :exec
|
||||
INSERT INTO assignment_assignees (
|
||||
assignment_id,
|
||||
student_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
)
|
||||
ON CONFLICT (assignment_id, student_id) DO NOTHING;
|
||||
|
||||
-- name: DeleteAssignmentAssignee :exec
|
||||
DELETE FROM assignment_assignees
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2;
|
||||
|
||||
-- name: GetAssignmentAssignee :one
|
||||
SELECT *
|
||||
FROM assignment_assignees
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2;
|
||||
|
||||
-- name: UpdateAssignmentAIReview :one
|
||||
UPDATE assignment_assignees
|
||||
SET ai_feedback = $3,
|
||||
next_step_outcome = NULLIF($4::text, '')::assignment_next_step_outcome
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAssignmentRedoPlan :one
|
||||
UPDATE assignment_assignees
|
||||
SET redo_plan = NULLIF($3::text, ''),
|
||||
redo_plan_generated_at = CASE
|
||||
WHEN NULLIF($3::text, '') IS NULL THEN NULL
|
||||
ELSE NOW()
|
||||
END
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAssignmentRedoPlan :one
|
||||
SELECT
|
||||
assignment_id,
|
||||
student_id,
|
||||
redo_plan,
|
||||
redo_plan_generated_at
|
||||
FROM assignment_assignees
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2;
|
||||
|
||||
-- name: UpdateAssignmentTeacherFeedback :one
|
||||
WITH student_question_set AS (
|
||||
SELECT asq.assignment_id, asq.question_id, asq.position
|
||||
FROM assignment_student_questions asq
|
||||
WHERE asq.assignment_id = $1
|
||||
AND asq.student_id = $2
|
||||
), selected_questions AS (
|
||||
SELECT assignment_id, question_id, position
|
||||
FROM student_question_set
|
||||
UNION ALL
|
||||
SELECT aq.assignment_id, aq.question_id, aq.position
|
||||
FROM assignment_questions aq
|
||||
WHERE aq.assignment_id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM student_question_set)
|
||||
), score_summary AS (
|
||||
SELECT CASE
|
||||
WHEN COUNT(sa.id) = 0 THEN NULL
|
||||
ELSE ROUND((AVG(
|
||||
CASE
|
||||
WHEN sa.is_correct IS NULL THEN COALESCE(sa.review_understanding_score, 0)::NUMERIC
|
||||
ELSE (
|
||||
((CASE WHEN sa.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa.review_understanding_score, 0)::NUMERIC
|
||||
) / 2
|
||||
END
|
||||
) * 10)::NUMERIC, 2)
|
||||
END AS overall_score
|
||||
FROM selected_questions aq
|
||||
LEFT JOIN student_answers sa
|
||||
ON sa.assignment_id = aq.assignment_id
|
||||
AND sa.question_id = aq.question_id
|
||||
AND sa.student_id = $2
|
||||
WHERE aq.assignment_id = $1
|
||||
), updated AS (
|
||||
UPDATE assignment_assignees aa
|
||||
SET teacher_feedback = $3,
|
||||
pass_status_override = NULLIF($4::text, '')::assignment_pass_status,
|
||||
next_step_outcome = NULLIF($5::text, '')::assignment_next_step_outcome,
|
||||
overall_score = (SELECT overall_score FROM score_summary),
|
||||
pass_status = COALESCE(
|
||||
NULLIF($4::text, '')::assignment_pass_status,
|
||||
CASE
|
||||
WHEN (SELECT overall_score FROM score_summary) IS NULL THEN 'pending'::assignment_pass_status
|
||||
WHEN (SELECT overall_score FROM score_summary) >= a.pass_threshold THEN 'pass'::assignment_pass_status
|
||||
ELSE 'no_pass'::assignment_pass_status
|
||||
END
|
||||
)
|
||||
FROM assignments a
|
||||
WHERE aa.assignment_id = $1
|
||||
AND aa.student_id = $2
|
||||
AND a.id = aa.assignment_id
|
||||
RETURNING aa.*
|
||||
)
|
||||
SELECT *
|
||||
FROM updated;
|
||||
|
||||
-- name: AddQuestionToAssignment :exec
|
||||
INSERT INTO assignment_questions (
|
||||
assignment_id,
|
||||
question_id,
|
||||
position
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
)
|
||||
ON CONFLICT (assignment_id, question_id) DO UPDATE
|
||||
SET position = EXCLUDED.position;
|
||||
|
||||
-- name: DeleteAssignmentStudentQuestions :exec
|
||||
DELETE FROM assignment_student_questions
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2;
|
||||
|
||||
-- name: AddAssignmentStudentQuestion :one
|
||||
INSERT INTO assignment_student_questions (
|
||||
assignment_id,
|
||||
student_id,
|
||||
question_id,
|
||||
position,
|
||||
source_bucket,
|
||||
source_topic,
|
||||
source_difficulty,
|
||||
generator_seed
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListAssignmentStudentQuestions :many
|
||||
SELECT *
|
||||
FROM assignment_student_questions
|
||||
WHERE assignment_id = $1
|
||||
AND student_id = $2
|
||||
ORDER BY position ASC, id ASC;
|
||||
|
||||
-- name: ListGeneratedQuestionsForAssignmentStudent :many
|
||||
SELECT
|
||||
asq.id,
|
||||
asq.assignment_id,
|
||||
asq.student_id,
|
||||
asq.question_id,
|
||||
asq.position,
|
||||
asq.source_bucket,
|
||||
asq.source_topic,
|
||||
asq.source_difficulty,
|
||||
asq.generator_seed,
|
||||
asq.created_at,
|
||||
q.author_teacher_id,
|
||||
q.title,
|
||||
q.prompt,
|
||||
q.subject,
|
||||
q.source,
|
||||
q.status,
|
||||
q.created_at AS question_created_at,
|
||||
q.updated_at AS question_updated_at,
|
||||
q.correct_answer,
|
||||
q.topic,
|
||||
q.difficulty
|
||||
FROM assignment_student_questions asq
|
||||
JOIN questions q ON q.id = asq.question_id
|
||||
WHERE asq.assignment_id = $1
|
||||
AND asq.student_id = $2
|
||||
ORDER BY asq.position ASC, asq.id ASC;
|
||||
|
||||
-- name: ListAssignmentsByTeacher :many
|
||||
SELECT *
|
||||
FROM assignments
|
||||
WHERE teacher_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListAssignmentsForStudent :many
|
||||
SELECT a.*
|
||||
FROM assignment_assignees aa
|
||||
JOIN assignments a ON a.id = aa.assignment_id
|
||||
WHERE aa.student_id = $1
|
||||
ORDER BY a.created_at DESC;
|
||||
|
||||
-- name: GetAssignmentByID :one
|
||||
SELECT *
|
||||
FROM assignments
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateAssignmentDraft :one
|
||||
UPDATE assignments
|
||||
SET classroom_id = $2,
|
||||
title = $3,
|
||||
instructions = $4,
|
||||
due_at = $5,
|
||||
pass_threshold = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: CloseAssignment :one
|
||||
UPDATE assignments
|
||||
SET status = 'closed'::assignment_status,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListQuestionsForAssignment :many
|
||||
SELECT
|
||||
aq.assignment_id,
|
||||
aq.question_id,
|
||||
aq.position,
|
||||
q.author_teacher_id,
|
||||
q.title,
|
||||
q.prompt,
|
||||
q.subject,
|
||||
q.source,
|
||||
q.status,
|
||||
q.created_at,
|
||||
q.updated_at
|
||||
FROM assignment_questions aq
|
||||
JOIN questions q ON q.id = aq.question_id
|
||||
WHERE aq.assignment_id = $1
|
||||
ORDER BY aq.position ASC, aq.question_id ASC;
|
||||
|
||||
-- name: GetAssignmentReviewSummary :one
|
||||
WITH student_question_set AS (
|
||||
SELECT asq.student_id, asq.question_id, asq.position
|
||||
FROM assignment_student_questions asq
|
||||
WHERE asq.assignment_id = $1
|
||||
),
|
||||
students_with_personalized AS (
|
||||
SELECT DISTINCT student_id
|
||||
FROM student_question_set
|
||||
),
|
||||
selected_questions AS (
|
||||
SELECT student_id, question_id, position
|
||||
FROM student_question_set
|
||||
UNION ALL
|
||||
SELECT aa.student_id, aq.question_id, aq.position
|
||||
FROM assignment_assignees aa
|
||||
JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id
|
||||
WHERE aa.assignment_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM students_with_personalized swp
|
||||
WHERE swp.student_id = aa.student_id
|
||||
)
|
||||
),
|
||||
student_states AS (
|
||||
SELECT
|
||||
aa.student_id,
|
||||
COUNT(sq.question_id)::BIGINT AS total_questions,
|
||||
CASE
|
||||
WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status
|
||||
WHEN COUNT(sq.question_id) > 0
|
||||
AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id)
|
||||
THEN 'reviewed'::answer_status
|
||||
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status
|
||||
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status
|
||||
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status
|
||||
ELSE 'not_started'::answer_status
|
||||
END AS review_status
|
||||
FROM assignment_assignees aa
|
||||
LEFT JOIN selected_questions sq
|
||||
ON sq.student_id = aa.student_id
|
||||
LEFT JOIN student_answers sa
|
||||
ON sa.assignment_id = aa.assignment_id
|
||||
AND sa.question_id = sq.question_id
|
||||
AND sa.student_id = aa.student_id
|
||||
WHERE aa.assignment_id = $1
|
||||
GROUP BY aa.student_id
|
||||
)
|
||||
SELECT
|
||||
$1::BIGINT AS assignment_id,
|
||||
COALESCE(MAX(student_states.total_questions), 0)::BIGINT AS total_questions,
|
||||
COUNT(*)::BIGINT AS total_assigned,
|
||||
COUNT(*) FILTER (WHERE review_status = 'not_started')::BIGINT AS not_started,
|
||||
COUNT(*) FILTER (WHERE review_status = 'in_progress')::BIGINT AS in_progress,
|
||||
COUNT(*) FILTER (WHERE review_status = 'submitted')::BIGINT AS submitted,
|
||||
COUNT(*) FILTER (WHERE review_status = 'reviewed')::BIGINT AS reviewed
|
||||
FROM student_states;
|
||||
|
||||
-- name: ListAssignmentReviewQueue :many
|
||||
WITH student_question_set AS (
|
||||
SELECT asq.student_id, asq.question_id, asq.position
|
||||
FROM assignment_student_questions asq
|
||||
WHERE asq.assignment_id = $1
|
||||
),
|
||||
students_with_personalized AS (
|
||||
SELECT DISTINCT student_id
|
||||
FROM student_question_set
|
||||
),
|
||||
selected_questions AS (
|
||||
SELECT student_id, question_id, position
|
||||
FROM student_question_set
|
||||
UNION ALL
|
||||
SELECT aa.student_id, aq.question_id, aq.position
|
||||
FROM assignment_assignees aa
|
||||
JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id
|
||||
WHERE aa.assignment_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM students_with_personalized swp
|
||||
WHERE swp.student_id = aa.student_id
|
||||
)
|
||||
),
|
||||
student_states AS (
|
||||
SELECT
|
||||
aa.assignment_id,
|
||||
aa.student_id,
|
||||
aa.next_step_outcome,
|
||||
u.full_name AS student_name,
|
||||
u.email AS student_email,
|
||||
COUNT(sq.question_id)::BIGINT AS total_questions,
|
||||
COUNT(sa.id)::BIGINT AS answered_questions,
|
||||
COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed')::BIGINT AS reviewed_questions,
|
||||
COUNT(sa.id) FILTER (WHERE sa.status = 'submitted')::BIGINT AS submitted_questions,
|
||||
COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress')::BIGINT AS in_progress_questions,
|
||||
MAX(sa.submitted_at)::timestamptz AS latest_submitted_at,
|
||||
MAX(sa.reviewed_at)::timestamptz AS latest_reviewed_at,
|
||||
CASE
|
||||
WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status
|
||||
WHEN COUNT(sq.question_id) > 0
|
||||
AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id)
|
||||
THEN 'reviewed'::answer_status
|
||||
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status
|
||||
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status
|
||||
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status
|
||||
ELSE 'not_started'::answer_status
|
||||
END AS review_status
|
||||
FROM assignment_assignees aa
|
||||
JOIN users u ON u.id = aa.student_id
|
||||
LEFT JOIN selected_questions sq
|
||||
ON sq.student_id = aa.student_id
|
||||
LEFT JOIN student_answers sa
|
||||
ON sa.assignment_id = aa.assignment_id
|
||||
AND sa.question_id = sq.question_id
|
||||
AND sa.student_id = aa.student_id
|
||||
WHERE aa.assignment_id = $1
|
||||
GROUP BY aa.assignment_id, aa.student_id, aa.next_step_outcome, u.full_name, u.email
|
||||
)
|
||||
SELECT
|
||||
student_states.assignment_id,
|
||||
student_states.student_id,
|
||||
student_states.next_step_outcome,
|
||||
student_states.student_name,
|
||||
student_states.student_email,
|
||||
student_states.total_questions,
|
||||
student_states.answered_questions,
|
||||
student_states.reviewed_questions,
|
||||
student_states.submitted_questions,
|
||||
student_states.in_progress_questions,
|
||||
student_states.review_status,
|
||||
student_states.latest_submitted_at,
|
||||
student_states.latest_reviewed_at
|
||||
FROM student_states
|
||||
WHERE ($2::text = '' OR review_status::text = $2::text)
|
||||
ORDER BY student_states.student_name ASC, student_states.student_id ASC;
|
||||
36
Backend/db/queries/classrooms.sql
Normal file
36
Backend/db/queries/classrooms.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- name: CreateClassroom :one
|
||||
INSERT INTO classrooms (
|
||||
teacher_id,
|
||||
name,
|
||||
code,
|
||||
description
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListClassroomsByTeacher :many
|
||||
SELECT *
|
||||
FROM classrooms
|
||||
WHERE teacher_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: AddStudentToClassroom :exec
|
||||
INSERT INTO classroom_students (
|
||||
classroom_id,
|
||||
student_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
)
|
||||
ON CONFLICT (classroom_id, student_id) DO NOTHING;
|
||||
|
||||
-- name: ListStudentsForClassroom :many
|
||||
SELECT u.*
|
||||
FROM classroom_students cs
|
||||
JOIN users u ON u.id = cs.student_id
|
||||
WHERE cs.classroom_id = $1
|
||||
ORDER BY u.full_name ASC;
|
||||
266
Backend/db/queries/messages.sql
Normal file
266
Backend/db/queries/messages.sql
Normal file
@@ -0,0 +1,266 @@
|
||||
-- name: ListMessageRecipientsForUser :many
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.id <> $1
|
||||
AND u.is_active = TRUE
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM classrooms c
|
||||
JOIN classroom_students cs ON cs.classroom_id = c.id
|
||||
WHERE c.teacher_id = u.id
|
||||
AND cs.student_id = $1
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM classrooms c
|
||||
JOIN classroom_students cs ON cs.classroom_id = c.id
|
||||
WHERE c.teacher_id = $1
|
||||
AND cs.student_id = u.id
|
||||
)
|
||||
)
|
||||
ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC;
|
||||
|
||||
-- name: GetMessageRecipientByIDForUser :one
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.id = $2
|
||||
AND u.id <> $1
|
||||
AND u.is_active = TRUE
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM classrooms c
|
||||
JOIN classroom_students cs ON cs.classroom_id = c.id
|
||||
WHERE c.teacher_id = u.id
|
||||
AND cs.student_id = $1
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM classrooms c
|
||||
JOIN classroom_students cs ON cs.classroom_id = c.id
|
||||
WHERE c.teacher_id = $1
|
||||
AND cs.student_id = u.id
|
||||
)
|
||||
)
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListMessageThreadsForUser :many
|
||||
SELECT
|
||||
t.id AS thread_id,
|
||||
t.subject,
|
||||
t.created_by_user_id,
|
||||
t.created_at AS thread_created_at,
|
||||
t.updated_at AS thread_updated_at,
|
||||
COALESCE(last_message.id, 0)::bigint AS last_message_id,
|
||||
COALESCE(last_message.body, '') AS last_message_body,
|
||||
last_message.created_at AS last_message_created_at,
|
||||
COALESCE(last_message.sender_user_id, 0)::bigint AS last_message_sender_user_id,
|
||||
sender.full_name AS last_message_sender_full_name,
|
||||
sender_profile.preferred_name AS last_message_sender_preferred_name,
|
||||
sender_profile.profile_icon_url AS last_message_sender_profile_icon_url,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM messages unread
|
||||
WHERE unread.thread_id = t.id
|
||||
AND unread.sender_user_id <> $1
|
||||
AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at)
|
||||
), 0)::bigint AS unread_count
|
||||
FROM message_thread_participants participant
|
||||
JOIN message_threads t ON t.id = participant.thread_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT m.id, m.body, m.created_at, m.sender_user_id
|
||||
FROM messages m
|
||||
WHERE m.thread_id = t.id
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT 1
|
||||
) AS last_message ON TRUE
|
||||
LEFT JOIN users sender ON sender.id = last_message.sender_user_id
|
||||
LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id
|
||||
WHERE participant.user_id = $1
|
||||
AND participant.archived_at IS NULL
|
||||
ORDER BY COALESCE(last_message.created_at, t.updated_at) DESC, t.id DESC;
|
||||
|
||||
-- name: ListMessageThreadParticipantsForUser :many
|
||||
SELECT
|
||||
mtp.thread_id,
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline,
|
||||
mtp.joined_at,
|
||||
mtp.last_read_at,
|
||||
mtp.archived_at
|
||||
FROM message_thread_participants mtp
|
||||
JOIN users u ON u.id = mtp.user_id
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE mtp.thread_id IN (
|
||||
SELECT participant.thread_id
|
||||
FROM message_thread_participants participant
|
||||
WHERE participant.user_id = $1
|
||||
AND participant.archived_at IS NULL
|
||||
)
|
||||
ORDER BY mtp.thread_id ASC, COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC;
|
||||
|
||||
-- name: GetMessageThreadForUser :one
|
||||
SELECT
|
||||
t.id,
|
||||
t.subject,
|
||||
t.created_by_user_id,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
participant.last_read_at,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::bigint
|
||||
FROM messages unread
|
||||
WHERE unread.thread_id = t.id
|
||||
AND unread.sender_user_id <> $2
|
||||
AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at)
|
||||
), 0)::bigint AS unread_count
|
||||
FROM message_threads t
|
||||
JOIN message_thread_participants participant ON participant.thread_id = t.id
|
||||
WHERE t.id = $1
|
||||
AND participant.user_id = $2
|
||||
AND participant.archived_at IS NULL;
|
||||
|
||||
-- name: ListMessagesForThreadForUser :many
|
||||
SELECT
|
||||
m.id,
|
||||
m.thread_id,
|
||||
m.sender_user_id,
|
||||
m.body,
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
sender.email AS sender_email,
|
||||
sender.role AS sender_role,
|
||||
sender.full_name AS sender_full_name,
|
||||
sender_profile.preferred_name AS sender_preferred_name,
|
||||
sender_profile.profile_icon_url AS sender_profile_icon_url,
|
||||
sender_profile.headline AS sender_headline
|
||||
FROM messages m
|
||||
JOIN message_thread_participants participant ON participant.thread_id = m.thread_id
|
||||
JOIN users sender ON sender.id = m.sender_user_id
|
||||
LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id
|
||||
WHERE m.thread_id = $1
|
||||
AND participant.user_id = $2
|
||||
AND participant.archived_at IS NULL
|
||||
ORDER BY m.created_at ASC, m.id ASC;
|
||||
|
||||
-- name: ListParticipantsForThreadForUser :many
|
||||
SELECT
|
||||
mtp.thread_id,
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline,
|
||||
mtp.joined_at,
|
||||
mtp.last_read_at,
|
||||
mtp.archived_at
|
||||
FROM message_thread_participants mtp
|
||||
JOIN users u ON u.id = mtp.user_id
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE mtp.thread_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM message_thread_participants participant
|
||||
WHERE participant.thread_id = mtp.thread_id
|
||||
AND participant.user_id = $2
|
||||
AND participant.archived_at IS NULL
|
||||
)
|
||||
ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC;
|
||||
|
||||
-- name: CreateMessageThread :one
|
||||
INSERT INTO message_threads (
|
||||
created_by_user_id,
|
||||
subject
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: AddMessageThreadParticipant :exec
|
||||
INSERT INTO message_thread_participants (
|
||||
thread_id,
|
||||
user_id,
|
||||
last_read_at
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
)
|
||||
ON CONFLICT (thread_id, user_id) DO NOTHING;
|
||||
|
||||
-- name: CreateThreadMessage :one
|
||||
INSERT INTO messages (
|
||||
thread_id,
|
||||
sender_user_id,
|
||||
body
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: TouchMessageThread :exec
|
||||
UPDATE message_threads
|
||||
SET updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateMessageThreadSubject :one
|
||||
UPDATE message_threads
|
||||
SET subject = sqlc.arg(subject),
|
||||
updated_at = NOW()
|
||||
WHERE id = sqlc.arg(thread_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateThreadMessageBody :one
|
||||
UPDATE messages
|
||||
SET body = sqlc.arg(body),
|
||||
updated_at = NOW()
|
||||
WHERE id = sqlc.arg(message_id)
|
||||
AND thread_id = sqlc.arg(thread_id)
|
||||
AND sender_user_id = sqlc.arg(user_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteThreadMessage :one
|
||||
DELETE FROM messages
|
||||
WHERE id = sqlc.arg(message_id)
|
||||
AND thread_id = sqlc.arg(thread_id)
|
||||
AND sender_user_id = sqlc.arg(user_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteMessageThread :one
|
||||
DELETE FROM message_threads
|
||||
WHERE id = sqlc.arg(thread_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkMessageThreadRead :one
|
||||
UPDATE message_thread_participants
|
||||
SET last_read_at = COALESCE((SELECT MAX(m.created_at) FROM messages m WHERE m.thread_id = $1), NOW())
|
||||
WHERE message_thread_participants.thread_id = $1
|
||||
AND message_thread_participants.user_id = $2
|
||||
RETURNING *;
|
||||
55
Backend/db/queries/questions.sql
Normal file
55
Backend/db/queries/questions.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- name: CreateQuestion :one
|
||||
INSERT INTO questions (
|
||||
author_teacher_id,
|
||||
title,
|
||||
prompt,
|
||||
topic,
|
||||
subject,
|
||||
difficulty,
|
||||
source,
|
||||
status,
|
||||
correct_answer
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListQuestionsByTeacher :many
|
||||
SELECT *
|
||||
FROM questions
|
||||
WHERE author_teacher_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetQuestionByID :one
|
||||
SELECT *
|
||||
FROM questions
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateTag :one
|
||||
INSERT INTO tags (name)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING *;
|
||||
|
||||
-- name: AttachTagToQuestion :exec
|
||||
INSERT INTO question_tags (
|
||||
question_id,
|
||||
tag_id
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
)
|
||||
ON CONFLICT (question_id, tag_id) DO NOTHING;
|
||||
|
||||
-- name: ListTags :many
|
||||
SELECT *
|
||||
FROM tags
|
||||
ORDER BY name ASC;
|
||||
228
Backend/db/queries/student_answers.sql
Normal file
228
Backend/db/queries/student_answers.sql
Normal file
@@ -0,0 +1,228 @@
|
||||
-- name: UpsertStudentAnswer :one
|
||||
INSERT INTO student_answers (
|
||||
assignment_id,
|
||||
question_id,
|
||||
student_id,
|
||||
answer_text,
|
||||
solve_mode,
|
||||
working_steps,
|
||||
ai_feedback,
|
||||
teacher_feedback,
|
||||
status,
|
||||
submitted_at,
|
||||
reviewed_at,
|
||||
is_correct
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12
|
||||
)
|
||||
ON CONFLICT (assignment_id, question_id, student_id) DO UPDATE
|
||||
SET
|
||||
answer_text = EXCLUDED.answer_text,
|
||||
solve_mode = EXCLUDED.solve_mode,
|
||||
working_steps = EXCLUDED.working_steps,
|
||||
ai_feedback = EXCLUDED.ai_feedback,
|
||||
teacher_feedback = EXCLUDED.teacher_feedback,
|
||||
status = EXCLUDED.status,
|
||||
submitted_at = EXCLUDED.submitted_at,
|
||||
reviewed_at = EXCLUDED.reviewed_at,
|
||||
is_correct = EXCLUDED.is_correct,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAnswerAIReview :one
|
||||
UPDATE student_answers
|
||||
SET
|
||||
ai_feedback = $2,
|
||||
review_needs_attention = $3,
|
||||
review_issue_reason = $4,
|
||||
review_correctness_score = $5,
|
||||
review_understanding_score = $6,
|
||||
review_question_score = $7,
|
||||
review_confidence = $8,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListAnswersForAssignment :many
|
||||
SELECT *
|
||||
FROM student_answers
|
||||
WHERE assignment_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListAnswersForStudent :many
|
||||
SELECT *
|
||||
FROM student_answers
|
||||
WHERE student_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListQuestionDetailsForAssignmentStudent :many
|
||||
WITH student_question_set AS (
|
||||
SELECT
|
||||
asq.assignment_id,
|
||||
asq.question_id,
|
||||
asq.position
|
||||
FROM assignment_student_questions asq
|
||||
WHERE asq.assignment_id = $1
|
||||
AND asq.student_id = $2
|
||||
),
|
||||
selected_questions AS (
|
||||
SELECT
|
||||
sq.assignment_id,
|
||||
sq.question_id,
|
||||
sq.position
|
||||
FROM student_question_set sq
|
||||
UNION ALL
|
||||
SELECT
|
||||
aq.assignment_id,
|
||||
aq.question_id,
|
||||
aq.position
|
||||
FROM assignment_questions aq
|
||||
WHERE aq.assignment_id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM student_question_set)
|
||||
)
|
||||
SELECT
|
||||
aq.assignment_id,
|
||||
aq.question_id,
|
||||
aq.position,
|
||||
q.title,
|
||||
q.prompt,
|
||||
q.subject,
|
||||
q.source,
|
||||
COALESCE(
|
||||
ARRAY(
|
||||
SELECT t.name
|
||||
FROM question_tags qt
|
||||
JOIN tags t ON t.id = qt.tag_id
|
||||
WHERE qt.question_id = aq.question_id
|
||||
ORDER BY t.name ASC
|
||||
),
|
||||
ARRAY[]::TEXT[]
|
||||
)::TEXT[] AS question_tags,
|
||||
q.status AS question_status,
|
||||
q.correct_answer,
|
||||
aa.ai_feedback AS assignment_ai_feedback,
|
||||
aa.teacher_feedback AS assignment_teacher_feedback,
|
||||
review_summary.overall_score,
|
||||
a.pass_threshold,
|
||||
aa.next_step_outcome,
|
||||
aa.pass_status_override,
|
||||
COALESCE(
|
||||
aa.pass_status_override,
|
||||
CASE
|
||||
WHEN review_summary.overall_score IS NULL THEN 'pending'::assignment_pass_status
|
||||
WHEN review_summary.overall_score >= a.pass_threshold THEN 'pass'::assignment_pass_status
|
||||
ELSE 'no_pass'::assignment_pass_status
|
||||
END
|
||||
) AS pass_status,
|
||||
sa.id AS answer_id,
|
||||
sa.student_id,
|
||||
sa.answer_text,
|
||||
sa.solve_mode,
|
||||
sa.working_steps,
|
||||
sa.is_correct,
|
||||
sa.ai_feedback,
|
||||
sa.teacher_feedback,
|
||||
sa.status AS answer_status,
|
||||
sa.review_needs_attention,
|
||||
sa.review_issue_reason,
|
||||
sa.review_correctness_score,
|
||||
sa.review_understanding_score,
|
||||
sa.review_question_score,
|
||||
sa.review_confidence,
|
||||
sa.review_tags,
|
||||
sa.submitted_at,
|
||||
sa.reviewed_at,
|
||||
sa.created_at AS answer_created_at,
|
||||
sa.updated_at AS answer_updated_at
|
||||
FROM selected_questions aq
|
||||
JOIN assignments a ON a.id = aq.assignment_id
|
||||
JOIN questions q ON q.id = aq.question_id
|
||||
LEFT JOIN assignment_assignees aa
|
||||
ON aa.assignment_id = aq.assignment_id
|
||||
AND aa.student_id = $2
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT CASE
|
||||
WHEN COUNT(sa2.id) = 0 THEN NULL::NUMERIC(5,2)
|
||||
ELSE ROUND((AVG(
|
||||
CASE
|
||||
WHEN sa2.is_correct IS NULL THEN COALESCE(sa2.review_understanding_score, 0)::NUMERIC
|
||||
ELSE (
|
||||
((CASE WHEN sa2.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa2.review_understanding_score, 0)::NUMERIC
|
||||
) / 2
|
||||
END
|
||||
) * 10)::NUMERIC, 2)::NUMERIC(5,2)
|
||||
END AS overall_score
|
||||
FROM selected_questions aq2
|
||||
LEFT JOIN student_answers sa2
|
||||
ON sa2.assignment_id = aq2.assignment_id
|
||||
AND sa2.question_id = aq2.question_id
|
||||
AND sa2.student_id = $2
|
||||
WHERE aq2.assignment_id = aq.assignment_id
|
||||
) review_summary ON TRUE
|
||||
LEFT JOIN student_answers sa
|
||||
ON sa.assignment_id = aq.assignment_id
|
||||
AND sa.question_id = aq.question_id
|
||||
AND sa.student_id = $2
|
||||
WHERE aq.assignment_id = $1
|
||||
ORDER BY aq.position ASC, aq.question_id ASC;
|
||||
|
||||
-- name: UpdateAnswerReview :one
|
||||
UPDATE student_answers
|
||||
SET
|
||||
status = $2,
|
||||
review_needs_attention = $3,
|
||||
review_issue_reason = $4,
|
||||
review_correctness_score = $5,
|
||||
review_understanding_score = $6,
|
||||
review_question_score = $7,
|
||||
review_confidence = $8,
|
||||
review_tags = $9,
|
||||
reviewed_at = CASE
|
||||
WHEN $2::answer_status = 'reviewed' THEN NOW()
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListStudentPlanningPerformance :many
|
||||
SELECT
|
||||
sa.assignment_id,
|
||||
sa.question_id,
|
||||
q.topic,
|
||||
q.subject,
|
||||
q.difficulty,
|
||||
COALESCE(
|
||||
ARRAY(
|
||||
SELECT t.name
|
||||
FROM question_tags qt
|
||||
JOIN tags t ON t.id = qt.tag_id
|
||||
WHERE qt.question_id = sa.question_id
|
||||
ORDER BY t.name ASC
|
||||
),
|
||||
ARRAY[]::TEXT[]
|
||||
)::TEXT[] AS question_tags,
|
||||
sa.is_correct,
|
||||
sa.review_understanding_score,
|
||||
sa.review_needs_attention,
|
||||
sa.review_issue_reason,
|
||||
sa.status,
|
||||
sa.submitted_at,
|
||||
sa.reviewed_at,
|
||||
sa.updated_at
|
||||
FROM student_answers sa
|
||||
JOIN questions q ON q.id = sa.question_id
|
||||
WHERE sa.student_id = $1
|
||||
AND sa.status IN ('submitted'::answer_status, 'reviewed'::answer_status)
|
||||
ORDER BY COALESCE(sa.reviewed_at, sa.submitted_at, sa.updated_at) DESC, sa.id DESC;
|
||||
178
Backend/db/queries/users.sql
Normal file
178
Backend/db/queries/users.sql
Normal file
@@ -0,0 +1,178 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
full_name
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetAuthUserByID :one
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
u.is_active AS user_is_active,
|
||||
u.created_at AS user_created_at,
|
||||
u.updated_at AS user_updated_at,
|
||||
p.user_id AS profile_user_id,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.timezone,
|
||||
p.locale,
|
||||
p.grade_level,
|
||||
p.learning_goal,
|
||||
p.created_at AS profile_created_at,
|
||||
p.updated_at AS profile_updated_at
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.id = $1;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE email = $1;
|
||||
|
||||
-- name: GetAuthUserByEmail :one
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
u.is_active AS user_is_active,
|
||||
u.password_hash AS user_password_hash,
|
||||
u.created_at AS user_created_at,
|
||||
u.updated_at AS user_updated_at,
|
||||
p.user_id AS profile_user_id,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.timezone,
|
||||
p.locale,
|
||||
p.grade_level,
|
||||
p.learning_goal,
|
||||
p.created_at AS profile_created_at,
|
||||
p.updated_at AS profile_updated_at
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.email = $1;
|
||||
|
||||
-- name: ListUsersByRole :many
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE role = $1
|
||||
ORDER BY full_name ASC;
|
||||
|
||||
-- name: ListUsersWithProfileByRole :many
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
u.is_active AS user_is_active,
|
||||
u.created_at AS user_created_at,
|
||||
u.updated_at AS user_updated_at,
|
||||
p.user_id AS profile_user_id,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.timezone,
|
||||
p.locale,
|
||||
p.grade_level,
|
||||
p.learning_goal,
|
||||
p.created_at AS profile_created_at,
|
||||
p.updated_at AS profile_updated_at
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.role = $1
|
||||
ORDER BY u.full_name ASC;
|
||||
|
||||
-- name: GetUserWithProfileByID :one
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.role AS user_role,
|
||||
u.full_name AS user_full_name,
|
||||
u.is_active AS user_is_active,
|
||||
u.created_at AS user_created_at,
|
||||
u.updated_at AS user_updated_at,
|
||||
p.user_id AS profile_user_id,
|
||||
p.preferred_name,
|
||||
p.profile_icon_url,
|
||||
p.headline,
|
||||
p.bio,
|
||||
p.timezone,
|
||||
p.locale,
|
||||
p.grade_level,
|
||||
p.learning_goal,
|
||||
p.created_at AS profile_created_at,
|
||||
p.updated_at AS profile_updated_at
|
||||
FROM users u
|
||||
LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.id = $1;
|
||||
|
||||
-- name: UpdateUserActiveStatus :one
|
||||
UPDATE users
|
||||
SET
|
||||
is_active = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUserFullName :one
|
||||
UPDATE users
|
||||
SET
|
||||
full_name = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertUserProfile :one
|
||||
INSERT INTO profiles (
|
||||
user_id,
|
||||
preferred_name,
|
||||
profile_icon_url,
|
||||
headline,
|
||||
bio,
|
||||
timezone,
|
||||
locale,
|
||||
grade_level,
|
||||
learning_goal
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET
|
||||
preferred_name = EXCLUDED.preferred_name,
|
||||
profile_icon_url = EXCLUDED.profile_icon_url,
|
||||
headline = EXCLUDED.headline,
|
||||
bio = EXCLUDED.bio,
|
||||
timezone = EXCLUDED.timezone,
|
||||
locale = EXCLUDED.locale,
|
||||
grade_level = EXCLUDED.grade_level,
|
||||
learning_goal = EXCLUDED.learning_goal,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
12
Backend/db/sqlc.yaml
Normal file
12
Backend/db/sqlc.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "queries/"
|
||||
schema: "migrations/"
|
||||
gen:
|
||||
go:
|
||||
package: "sqlc"
|
||||
out: "../internal/sqlc"
|
||||
sql_package: "pgx/v5"
|
||||
emit_json_tags: true
|
||||
emit_empty_slices: true
|
||||
33
Backend/go.mod
Normal file
33
Backend/go.mod
Normal file
@@ -0,0 +1,33 @@
|
||||
module boostai-backend
|
||||
|
||||
go 1.24.11
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
)
|
||||
83
Backend/go.sum
Normal file
83
Backend/go.sum
Normal file
@@ -0,0 +1,83 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
|
||||
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
469
Backend/internal/aireview/service.go
Normal file
469
Backend/internal/aireview/service.go
Normal 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
|
||||
}
|
||||
106
Backend/internal/assignmentgen/personalization.go
Normal file
106
Backend/internal/assignmentgen/personalization.go
Normal 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
|
||||
}
|
||||
172
Backend/internal/assignmentgen/personalization_plan.go
Normal file
172
Backend/internal/assignmentgen/personalization_plan.go
Normal 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
|
||||
}
|
||||
85
Backend/internal/assignmentgen/personalization_test.go
Normal file
85
Backend/internal/assignmentgen/personalization_test.go
Normal 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
|
||||
}
|
||||
171
Backend/internal/assignmentgen/personalization_weakness.go
Normal file
171
Backend/internal/assignmentgen/personalization_weakness.go
Normal 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
|
||||
}
|
||||
91
Backend/internal/assignmentgen/service.go
Normal file
91
Backend/internal/assignmentgen/service.go
Normal 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
|
||||
}
|
||||
244
Backend/internal/assignmentgen/service_generate.go
Normal file
244
Backend/internal/assignmentgen/service_generate.go
Normal 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
|
||||
}
|
||||
89
Backend/internal/assignmentgen/service_helpers.go
Normal file
89
Backend/internal/assignmentgen/service_helpers.go
Normal 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"
|
||||
}
|
||||
}
|
||||
50
Backend/internal/config/config.go
Normal file
50
Backend/internal/config/config.go
Normal 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
|
||||
}
|
||||
65
Backend/internal/database/postgres.go
Normal file
65
Backend/internal/database/postgres.go
Normal 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)
|
||||
}
|
||||
562
Backend/internal/handlers/api/answers/handler.go
Normal file
562
Backend/internal/handlers/api/answers/handler.go
Normal 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), " ")
|
||||
}
|
||||
16
Backend/internal/handlers/api/answers/routes.go
Normal file
16
Backend/internal/handlers/api/answers/routes.go
Normal 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)
|
||||
}
|
||||
660
Backend/internal/handlers/api/assignments/handler.go
Normal file
660
Backend/internal/handlers/api/assignments/handler.go
Normal 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)
|
||||
}
|
||||
321
Backend/internal/handlers/api/assignments/handler_generation.go
Normal file
321
Backend/internal/handlers/api/assignments/handler_generation.go
Normal 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
|
||||
}
|
||||
375
Backend/internal/handlers/api/assignments/handler_helpers.go
Normal file
375
Backend/internal/handlers/api/assignments/handler_helpers.go
Normal 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
|
||||
}
|
||||
236
Backend/internal/handlers/api/assignments/handler_types.go
Normal file
236
Backend/internal/handlers/api/assignments/handler_types.go
Normal 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"`
|
||||
}
|
||||
25
Backend/internal/handlers/api/assignments/routes.go
Normal file
25
Backend/internal/handlers/api/assignments/routes.go
Normal 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)
|
||||
}
|
||||
180
Backend/internal/handlers/api/classrooms/handler.go
Normal file
180
Backend/internal/handlers/api/classrooms/handler.go
Normal 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),
|
||||
}
|
||||
}
|
||||
14
Backend/internal/handlers/api/classrooms/routes.go
Normal file
14
Backend/internal/handlers/api/classrooms/routes.go
Normal 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)
|
||||
}
|
||||
41
Backend/internal/handlers/api/handler.go
Normal file
41
Backend/internal/handlers/api/handler.go
Normal 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),
|
||||
}
|
||||
}
|
||||
708
Backend/internal/handlers/api/messages/handler.go
Normal file
708
Backend/internal/handlers/api/messages/handler.go
Normal 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 ©
|
||||
}
|
||||
20
Backend/internal/handlers/api/messages/routes.go
Normal file
20
Backend/internal/handlers/api/messages/routes.go
Normal 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)
|
||||
}
|
||||
506
Backend/internal/handlers/api/questions/handler.go
Normal file
506
Backend/internal/handlers/api/questions/handler.go
Normal 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
|
||||
}
|
||||
140
Backend/internal/handlers/api/questions/handler_test.go
Normal file
140
Backend/internal/handlers/api/questions/handler_test.go
Normal 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
|
||||
}
|
||||
17
Backend/internal/handlers/api/questions/routes.go
Normal file
17
Backend/internal/handlers/api/questions/routes.go
Normal 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)
|
||||
}
|
||||
22
Backend/internal/handlers/api/routes.go
Normal file
22
Backend/internal/handlers/api/routes.go
Normal 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)
|
||||
}
|
||||
159
Backend/internal/handlers/api/shared/shared.go
Normal file
159
Backend/internal/handlers/api/shared/shared.go
Normal 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 ×tamp
|
||||
}
|
||||
|
||||
func Int64Pointer(value pgtype.Int8) *int64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := value.Int64
|
||||
return &v
|
||||
}
|
||||
|
||||
func BoolPointer(value pgtype.Bool) *bool {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := value.Bool
|
||||
return &v
|
||||
}
|
||||
|
||||
func NullableFloat64AsNumeric(value *float64) (pgtype.Numeric, error) {
|
||||
if value == nil {
|
||||
return pgtype.Numeric{}, nil
|
||||
}
|
||||
|
||||
numeric := pgtype.Numeric{}
|
||||
if err := numeric.ScanScientific(fmt.Sprintf("%f", *value)); err != nil {
|
||||
return pgtype.Numeric{}, err
|
||||
}
|
||||
|
||||
return numeric, nil
|
||||
}
|
||||
|
||||
func NumericPointer(value pgtype.Numeric) *float64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
floatValue, err := value.Float64Value()
|
||||
if err != nil || !floatValue.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
v := floatValue.Float64
|
||||
return &v
|
||||
}
|
||||
133
Backend/internal/handlers/api/users/handler.go
Normal file
133
Backend/internal/handlers/api/users/handler.go
Normal 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
|
||||
}
|
||||
13
Backend/internal/handlers/api/users/routes.go
Normal file
13
Backend/internal/handlers/api/users/routes.go
Normal 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)
|
||||
}
|
||||
384
Backend/internal/handlers/web/auth/auth.go
Normal file
384
Backend/internal/handlers/web/auth/auth.go
Normal 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 ×tamp
|
||||
}
|
||||
46
Backend/internal/handlers/web/health/health.go
Normal file
46
Backend/internal/handlers/web/health/health.go
Normal 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),
|
||||
})
|
||||
}
|
||||
16
Backend/internal/handlers/web/root/root.go
Normal file
16
Backend/internal/handlers/web/root/root.go
Normal 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",
|
||||
})
|
||||
}
|
||||
21
Backend/internal/http/params/params.go
Normal file
21
Backend/internal/http/params/params.go
Normal 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
|
||||
}
|
||||
18
Backend/internal/http/respond/respond.go
Normal file
18
Backend/internal/http/respond/respond.go
Normal 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())
|
||||
}
|
||||
193
Backend/internal/middleware/auth.go
Normal file
193
Backend/internal/middleware/auth.go
Normal 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
|
||||
}
|
||||
634
Backend/internal/questiongen/service.go
Normal file
634
Backend/internal/questiongen/service.go
Normal 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, ", ")
|
||||
}
|
||||
175
Backend/internal/questiongen/service_test.go
Normal file
175
Backend/internal/questiongen/service_test.go
Normal 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
|
||||
}
|
||||
16
Backend/internal/router/api.go
Normal file
16
Backend/internal/router/api.go
Normal 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)
|
||||
}
|
||||
24
Backend/internal/router/router.go
Normal file
24
Backend/internal/router/router.go
Normal 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",
|
||||
})
|
||||
})
|
||||
}
|
||||
32
Backend/internal/router/web.go
Normal file
32
Backend/internal/router/web.go
Normal 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)
|
||||
}
|
||||
1069
Backend/internal/sqlc/assignments.sql.go
Normal file
1069
Backend/internal/sqlc/assignments.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
147
Backend/internal/sqlc/classrooms.sql.go
Normal file
147
Backend/internal/sqlc/classrooms.sql.go
Normal 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
|
||||
}
|
||||
32
Backend/internal/sqlc/db.go
Normal file
32
Backend/internal/sqlc/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
742
Backend/internal/sqlc/messages.sql.go
Normal file
742
Backend/internal/sqlc/messages.sql.go
Normal 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
|
||||
}
|
||||
526
Backend/internal/sqlc/models.go
Normal file
526
Backend/internal/sqlc/models.go
Normal 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"`
|
||||
}
|
||||
206
Backend/internal/sqlc/questions.sql.go
Normal file
206
Backend/internal/sqlc/questions.sql.go
Normal 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
|
||||
}
|
||||
649
Backend/internal/sqlc/student_answers.sql.go
Normal file
649
Backend/internal/sqlc/student_answers.sql.go
Normal 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
|
||||
}
|
||||
577
Backend/internal/sqlc/users.sql.go
Normal file
577
Backend/internal/sqlc/users.sql.go
Normal 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
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
respond "ok" 200
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy {$BACKEND_UPSTREAM}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy {$FRONTEND_UPSTREAM}
|
||||
}
|
||||
|
||||
13
Caddyfile.prod-a
Normal file
13
Caddyfile.prod-a
Normal file
@@ -0,0 +1,13 @@
|
||||
{$BASE_DOMAIN} {
|
||||
handle /health {
|
||||
respond "ok" 200
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy {$BACKEND_UPSTREAM}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy {$FRONTEND_UPSTREAM}
|
||||
}
|
||||
}
|
||||
39
Earthfile
Normal file
39
Earthfile
Normal file
@@ -0,0 +1,39 @@
|
||||
VERSION 0.8
|
||||
|
||||
frontend-node-base:
|
||||
FROM node:24.12.0-alpine
|
||||
WORKDIR /workspace/BoostAI/Frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||
COPY Frontend/package.json Frontend/pnpm-lock.yaml ./
|
||||
|
||||
frontend-deps:
|
||||
FROM +frontend-node-base
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
frontend-prod-image:
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +frontend-deps
|
||||
COPY Frontend/. ./
|
||||
COPY Mock-Data ../Mock-Data
|
||||
RUN pnpm build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
ENV NITRO_HOST=0.0.0.0
|
||||
ENV NITRO_PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
frontend-prod-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +frontend-prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
@@ -17,7 +17,6 @@ build:
|
||||
SAVE ARTIFACT dist AS LOCAL ./dist
|
||||
|
||||
dev-image:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-dev"
|
||||
ARG TAG="latest"
|
||||
|
||||
@@ -29,6 +28,38 @@ dev-image:
|
||||
EXPOSE 4321
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
dev-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-dev"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
|
||||
prod-image:
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +deps
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["pnpm", "start", "--host", "0.0.0.0", "--port", "3000"]
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
prod-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
|
||||
# image:
|
||||
|
||||
BIN
Frontend/public/brand/boost-ai-logo-purple.png
Normal file
BIN
Frontend/public/brand/boost-ai-logo-purple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 664 B After Width: | Height: | Size: 15 KiB |
@@ -1,32 +1,53 @@
|
||||
// Path: Frontend/src/app.tsx
|
||||
|
||||
import { Router, useLocation } from "@solidjs/router";
|
||||
import { Router, useLocation, useNavigate } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { Suspense, Show, type ParentComponent } from "solid-js";
|
||||
import { Transition } from "solid-transition-group";
|
||||
import { Suspense, createEffect, type ParentComponent } from "solid-js";
|
||||
import { AuthProvider, useAuth } from "./context/auth/context";
|
||||
import { ThemeProvider } from "./context/theme/context";
|
||||
import { getPostAuthRedirectHref, isPublicRoute } from "./lib/routes";
|
||||
import "./styles/main.scss";
|
||||
|
||||
const AppRoot: ParentComponent = (props) => {
|
||||
const location = useLocation();
|
||||
const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/");
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const routeIsPublic = () => isPublicRoute(location.pathname);
|
||||
|
||||
createEffect(() => {
|
||||
if (!auth.isReady()) return;
|
||||
|
||||
const pathname = location.pathname;
|
||||
if (pathname === "/") return;
|
||||
|
||||
if (pathname.startsWith("/auth/")) {
|
||||
if (auth.user()) {
|
||||
navigate(getPostAuthRedirectHref(auth.user()!.role), { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.user()) {
|
||||
navigate("/auth/login", { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<div classList={{
|
||||
"app-route-shell": true,
|
||||
"app-route-shell-viewport-locked": isViewportLockedRoute(),
|
||||
}}>
|
||||
<Transition name="page-fade-slide">
|
||||
<Show when={location.pathname} keyed>
|
||||
{(path) => (
|
||||
<div class="page-transition-stage" data-route={path}>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Transition>
|
||||
</div>
|
||||
{!auth.isReady() && !routeIsPublic() ? (
|
||||
<div class="app-route-shell app-route-shell-viewport-locked">
|
||||
<div style={{ display: "grid", "place-items": "center", padding: "2rem", color: "var(--text-muted)" }}>
|
||||
Loading your session…
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div classList={{
|
||||
"app-route-shell": true,
|
||||
"app-route-shell-viewport-locked": routeIsPublic(),
|
||||
}}>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -34,9 +55,11 @@ const AppRoot: ParentComponent = (props) => {
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router root={AppRoot}>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
<AuthProvider>
|
||||
<Router root={AppRoot}>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { A, useLocation, useParams } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import styles from "../../routes/assignment/assignment-page.module.scss";
|
||||
|
||||
const AssignmentTabs: Component = () => {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
|
||||
const reviewHref = () => `/assignment/${params.id}`;
|
||||
const workHref = () => `/assignment/${params.id}/work`;
|
||||
const isWork = () => location.pathname === workHref();
|
||||
|
||||
return (
|
||||
<nav class={styles.tabs} aria-label="Assignment views">
|
||||
<div class={styles.tabList}>
|
||||
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Review
|
||||
</A>
|
||||
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Work
|
||||
</A>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTabs;
|
||||
@@ -1,251 +0,0 @@
|
||||
import rawAssignments from "../../../../Mock-Data/assignments.json";
|
||||
import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json";
|
||||
import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json";
|
||||
import rawQuestionBank from "../../../../Mock-Data/question_bank.json";
|
||||
import rawStudentAnswers from "../../../../Mock-Data/student_answers.json";
|
||||
import rawStudents from "../../../../Mock-Data/students.json";
|
||||
import rawClassroom from "../../../../Mock-Data/classroom.json";
|
||||
|
||||
type Assignment = {
|
||||
id: number;
|
||||
name: string;
|
||||
topic: string;
|
||||
due_date: number;
|
||||
status: "DRAFT" | "PUBLISHED" | "CLOSED";
|
||||
maximum_marks: number;
|
||||
};
|
||||
|
||||
type AssignmentAssignee = {
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
total_marks: number;
|
||||
started_at: number | null;
|
||||
submitted_at: number | null;
|
||||
};
|
||||
|
||||
type AssignmentQuestion = {
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
question_bank_id: number;
|
||||
question_order: number;
|
||||
maximum_marks: number;
|
||||
};
|
||||
|
||||
type QuestionBankItem = {
|
||||
id: number;
|
||||
topic: string;
|
||||
sub_topic: string | null;
|
||||
difficulty: "EASY" | "MEDIUM" | "HARD";
|
||||
question_text: string;
|
||||
correct_answer: string;
|
||||
step_by_step_solution: string | null;
|
||||
};
|
||||
|
||||
type StudentAnswer = {
|
||||
assignee_id: number;
|
||||
assignment_question_id: number;
|
||||
extracted_answer: string;
|
||||
ai_reasoning: string;
|
||||
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
_is_correct: boolean;
|
||||
_time_on_task_seconds: number;
|
||||
};
|
||||
|
||||
type Student = {
|
||||
id: number;
|
||||
fullname: string;
|
||||
_persona: string;
|
||||
};
|
||||
|
||||
type ClassroomFile = {
|
||||
classroom: {
|
||||
name: string;
|
||||
target_level: number;
|
||||
};
|
||||
tutor: {
|
||||
fullname: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AssignmentPageData = {
|
||||
id: number;
|
||||
title: string;
|
||||
topic: string;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
statusLabel: string;
|
||||
dueLabel: string;
|
||||
studentName: string;
|
||||
classroomName: string;
|
||||
tutorName: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
primaryAction: string;
|
||||
primaryHref: string;
|
||||
stats: Array<{ label: string; value: string }>;
|
||||
coachCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
};
|
||||
questions: Array<{
|
||||
id: number;
|
||||
order: number;
|
||||
prompt: string;
|
||||
topic: string;
|
||||
subTopic: string | null;
|
||||
difficulty: "EASY" | "MEDIUM" | "HARD";
|
||||
marks: number;
|
||||
statusLabel: string;
|
||||
statusTone: "success" | "warning" | "muted";
|
||||
responseLabel: string;
|
||||
responseValue: string;
|
||||
feedback: string;
|
||||
solveModeLabel?: string;
|
||||
initialAnswer?: string;
|
||||
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
showAnswerKey: boolean;
|
||||
correctAnswer: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const assignments = rawAssignments as Assignment[];
|
||||
const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[];
|
||||
const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[];
|
||||
const questionBank = rawQuestionBank as QuestionBankItem[];
|
||||
const studentAnswers = rawStudentAnswers as StudentAnswer[];
|
||||
const students = rawStudents as Student[];
|
||||
const classroomFile = rawClassroom as ClassroomFile;
|
||||
|
||||
const defaultStudentId = 201;
|
||||
|
||||
const assignmentById = new Map(assignments.map((entry) => [entry.id, entry]));
|
||||
const questionById = new Map(questionBank.map((entry) => [entry.id, entry]));
|
||||
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
|
||||
|
||||
const formatDate = (timestamp: number) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
|
||||
const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => {
|
||||
switch (value) {
|
||||
case "just_answer":
|
||||
return "Just answer";
|
||||
case "step_by_step":
|
||||
return "Step by step";
|
||||
case "solve_together":
|
||||
return "Solve together";
|
||||
case "handwritten":
|
||||
return "Handwritten";
|
||||
}
|
||||
};
|
||||
|
||||
const personaHint = (persona: string) => {
|
||||
switch (persona) {
|
||||
case "fraction_inversion":
|
||||
return "Slow down on fraction rules and check each step before moving on.";
|
||||
case "place_value_gaps":
|
||||
return "Check place value carefully before you calculate the final answer.";
|
||||
case "rushed_careless":
|
||||
return "Pause before submitting so small slips do not cost easy marks.";
|
||||
case "solve_together_dependent":
|
||||
return "Try one independent attempt first, then ask for guided help if you need it.";
|
||||
case "word_problem_weak":
|
||||
return "Underline the key numbers and turn the sentence into a maths step first.";
|
||||
default:
|
||||
return "Work through one question at a time and keep your method tidy.";
|
||||
}
|
||||
};
|
||||
|
||||
export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => {
|
||||
const assignment = assignmentById.get(assignmentId);
|
||||
if (!assignment) return null;
|
||||
|
||||
const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id);
|
||||
if (!assignee) return null;
|
||||
|
||||
const assignmentQuestionRows = assignmentQuestions
|
||||
.filter((entry) => entry.assignment_id === assignment.id)
|
||||
.sort((left, right) => left.question_order - right.question_order)
|
||||
.map((entry) => {
|
||||
const question = questionById.get(entry.question_bank_id);
|
||||
if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`);
|
||||
|
||||
const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id);
|
||||
|
||||
return { entry, question, answer };
|
||||
});
|
||||
|
||||
const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length;
|
||||
const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length;
|
||||
const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0;
|
||||
|
||||
const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started";
|
||||
const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment";
|
||||
|
||||
const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({
|
||||
id: entry.id,
|
||||
order: entry.question_order,
|
||||
prompt: question.question_text,
|
||||
topic: question.topic,
|
||||
subTopic: question.sub_topic,
|
||||
difficulty: question.difficulty,
|
||||
marks: entry.maximum_marks,
|
||||
statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered",
|
||||
statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted",
|
||||
responseLabel: answer ? "Your latest answer" : "Status",
|
||||
responseValue: answer ? answer.extracted_answer : "No attempt yet",
|
||||
feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.",
|
||||
solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined,
|
||||
initialAnswer: answer?.extracted_answer,
|
||||
initialSolveMode: answer?._solve_mode,
|
||||
showAnswerKey: assignee.status === "SUBMITTED",
|
||||
correctAnswer: question.correct_answer,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.name,
|
||||
topic: assignment.topic,
|
||||
status: assignee.status,
|
||||
statusLabel,
|
||||
dueLabel: formatDate(assignment.due_date),
|
||||
studentName: student.fullname,
|
||||
classroomName: classroomFile.classroom.name,
|
||||
tutorName: classroomFile.tutor.fullname,
|
||||
headline:
|
||||
assignee.status === "SUBMITTED"
|
||||
? `Review how you did in ${assignment.topic}`
|
||||
: assignee.status === "IN_PROGRESS"
|
||||
? `Keep going — you are already part way through`
|
||||
: `Start this assignment with a steady first pass`,
|
||||
description:
|
||||
assignee.status === "SUBMITTED"
|
||||
? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.`
|
||||
: assignee.status === "IN_PROGRESS"
|
||||
? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.`
|
||||
: `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`,
|
||||
primaryAction,
|
||||
primaryHref: `/assignment/${assignment.id}/work`,
|
||||
stats: [
|
||||
{ label: "Status", value: statusLabel },
|
||||
{ label: "Due", value: formatDate(assignment.due_date) },
|
||||
{ label: "Questions", value: `${assignmentQuestionRows.length}` },
|
||||
{ label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` },
|
||||
],
|
||||
coachCard: {
|
||||
title: "How to approach this one",
|
||||
description: personaHint(student._persona),
|
||||
items: [
|
||||
`${assignment.topic} focus`,
|
||||
`${accuracy}% accuracy so far`,
|
||||
`${correctCount} correct answers logged`,
|
||||
],
|
||||
},
|
||||
questions,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,24 @@
|
||||
/* Path: Frontend/src/components/assignment/shared/assignment-header.module.scss */
|
||||
|
||||
.headerCard {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
|
||||
color: var(--text-on-accent);
|
||||
border: 1px solid var(--border-overlay);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerCard {
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.headerTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,13 +26,21 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerTop {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.backLink,
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-overlay-soft);
|
||||
border: 1px solid var(--border-overlay);
|
||||
color: var(--text-on-accent);
|
||||
@@ -47,6 +65,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.copy {
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.copy h1 {
|
||||
font-size: clamp(1.5rem, 1.15rem + 1.35vw, 2rem);
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
.copy > p:not(.eyebrow) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { Component } from "solid-js";
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-header.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import type { AssignmentPageData } from "./assignment.data";
|
||||
import type { Component } from "solid-js";
|
||||
import { getDashboardHomeHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-header.module.scss";
|
||||
import type { AssignmentPageData } from "./assignment-types";
|
||||
|
||||
type Props = {
|
||||
data: AssignmentPageData;
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
const AssignmentHeader: Component<Props> = (props) => {
|
||||
return (
|
||||
<section class={styles.headerCard}>
|
||||
<div class={styles.headerTop}>
|
||||
<A href="/dashboard" class={styles.backLink}>
|
||||
<A href={props.backHref ?? getDashboardHomeHref("student")} class={styles.backLink}>
|
||||
Back to dashboard
|
||||
</A>
|
||||
<span class={styles.statusPill}>{props.data.statusLabel}</span>
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Path: Frontend/src/components/assignment/shared/assignment-overview.module.scss */
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -7,7 +9,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -39,7 +41,7 @@
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
|
||||
@@ -76,10 +78,35 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feedbackBlock {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
}
|
||||
|
||||
.feedbackLabel {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feedbackCopy {
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-overview.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import type { AssignmentPageData } from "./assignment.data";
|
||||
import styles from "./assignment-overview.module.scss";
|
||||
import type { AssignmentPageData } from "./assignment-types";
|
||||
|
||||
type Props = {
|
||||
data: AssignmentPageData;
|
||||
@@ -45,6 +47,29 @@ const AssignmentOverview: Component<Props> = (props) => {
|
||||
{props.data.primaryAction}
|
||||
</A>
|
||||
</section>
|
||||
|
||||
{(props.data.assignmentAiFeedback || props.data.assignmentTeacherFeedback) && (
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.cardHeader}>
|
||||
<h2>Shared assignment feedback</h2>
|
||||
<p>{props.data.tutorName}</p>
|
||||
</div>
|
||||
|
||||
{props.data.assignmentAiFeedback && (
|
||||
<div class={styles.feedbackBlock}>
|
||||
<p class={styles.feedbackLabel}>AI feedback</p>
|
||||
<p class={styles.feedbackCopy}>{props.data.assignmentAiFeedback}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.data.assignmentTeacherFeedback && (
|
||||
<div class={styles.feedbackBlock}>
|
||||
<p class={styles.feedbackLabel}>Teacher feedback</p>
|
||||
<p class={styles.feedbackCopy}>{props.data.assignmentTeacherFeedback}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Path: Frontend/src/components/assignment/shared/assignment-page.module.scss */
|
||||
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
padding: 1.25rem;
|
||||
@@ -18,7 +20,7 @@
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
@include respond(workspace) {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -34,7 +36,7 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -52,7 +54,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
@@ -80,7 +82,7 @@
|
||||
}
|
||||
|
||||
.sideColumn {
|
||||
@media (min-width: 1080px) {
|
||||
@include respond(workspace) {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
}
|
||||
@@ -90,7 +92,7 @@
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 2rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -119,7 +121,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
text-decoration: none;
|
||||
@@ -30,7 +30,7 @@
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
.statusPill {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
span {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
font-size: 0.82rem;
|
||||
@@ -96,11 +96,12 @@
|
||||
}
|
||||
|
||||
.responseBlock,
|
||||
.answerKey {
|
||||
.answerKey,
|
||||
.supportBlock {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
|
||||
@@ -116,8 +117,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
span,
|
||||
pre {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-question-list.tsx
|
||||
|
||||
import type { Component } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
import type { AssignmentPageData } from "./assignment.data";
|
||||
import styles from "./assignment-question-list.module.scss";
|
||||
import type { AssignmentPageData } from "./assignment-types";
|
||||
|
||||
type Props = {
|
||||
data: AssignmentPageData;
|
||||
@@ -11,8 +13,8 @@ const AssignmentQuestionList: Component<Props> = (props) => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<div class={styles.header}>
|
||||
<h2>Sample questions</h2>
|
||||
<p>{props.data.questions.length} loaded from the mock dataset</p>
|
||||
<h2>Question review</h2>
|
||||
<p>{props.data.questions.length} questions in this assignment</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.list}>
|
||||
@@ -32,8 +34,12 @@ const AssignmentQuestionList: Component<Props> = (props) => {
|
||||
<Show when={question.subTopic}>
|
||||
<span>{question.subTopic}</span>
|
||||
</Show>
|
||||
<span>{question.difficulty}</span>
|
||||
<span>{question.marks} mark</span>
|
||||
<Show when={question.difficulty}>
|
||||
<span>{question.difficulty}</span>
|
||||
</Show>
|
||||
<Show when={question.marks !== null}>
|
||||
<span>{question.marks} mark</span>
|
||||
</Show>
|
||||
<Show when={question.solveModeLabel}>
|
||||
<span>{question.solveModeLabel}</span>
|
||||
</Show>
|
||||
@@ -42,12 +48,21 @@ const AssignmentQuestionList: Component<Props> = (props) => {
|
||||
<div class={styles.responseBlock}>
|
||||
<p>{question.responseLabel}</p>
|
||||
<strong>{question.responseValue}</strong>
|
||||
<span>{question.feedback}</span>
|
||||
<Show when={question.feedback}>
|
||||
<span>{question.feedback}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={question.workingSteps}>
|
||||
<div class={styles.supportBlock}>
|
||||
<p>Your steps and explanation</p>
|
||||
<pre>{question.workingSteps}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={question.showAnswerKey}>
|
||||
<div class={styles.answerKey}>
|
||||
<p>Answer key</p>
|
||||
<p>Correct answer</p>
|
||||
<strong>{question.correctAnswer}</strong>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -0,0 +1,39 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-tabs.tsx
|
||||
|
||||
import { A, useLocation, useParams } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import type { AppRole } from "../../../lib/routes";
|
||||
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-page.module.scss";
|
||||
|
||||
type AssignmentTabsProps = {
|
||||
showWork?: boolean;
|
||||
role?: AppRole;
|
||||
};
|
||||
|
||||
const AssignmentTabs: Component<AssignmentTabsProps> = (props) => {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const role = () => props.role ?? (location.pathname.includes("/teacher/") ? "teacher" : "student");
|
||||
|
||||
const reviewHref = () => getAssignmentReviewHref(role(), params.id as string);
|
||||
const workHref = () => getAssignmentWorkHref(params.id as string);
|
||||
const isWork = () => location.pathname === workHref();
|
||||
|
||||
return (
|
||||
<nav class={styles.tabs} aria-label="Assignment views">
|
||||
<div class={styles.tabList}>
|
||||
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Review
|
||||
</A>
|
||||
{props.showWork !== false ? (
|
||||
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Work
|
||||
</A>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTabs;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-types.ts
|
||||
|
||||
export type AssignmentPageData = {
|
||||
id: number;
|
||||
title: string;
|
||||
topic: string;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
statusLabel: string;
|
||||
dueLabel: string;
|
||||
studentName: string;
|
||||
classroomName: string;
|
||||
tutorName: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
primaryAction: string;
|
||||
primaryHref: string;
|
||||
stats: Array<{ label: string; value: string }>;
|
||||
coachCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
};
|
||||
assignmentAiFeedback?: string;
|
||||
assignmentTeacherFeedback?: string;
|
||||
questions: Array<{
|
||||
id: number;
|
||||
order: number;
|
||||
prompt: string;
|
||||
topic: string;
|
||||
subTopic: string | null;
|
||||
difficulty: string | null;
|
||||
marks: number | null;
|
||||
statusLabel: string;
|
||||
statusTone: "success" | "warning" | "muted";
|
||||
responseLabel: string;
|
||||
responseValue: string;
|
||||
feedback: string;
|
||||
solveModeLabel?: string;
|
||||
workingSteps?: string;
|
||||
initialAnswer?: string;
|
||||
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
showAnswerKey: boolean;
|
||||
correctAnswer: string | null;
|
||||
isCorrect?: boolean | null;
|
||||
}>;
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
// Path: Frontend/src/components/assignment/student/assignment-review.data.ts
|
||||
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
|
||||
import { getAssignmentWorkHref } from "../../../lib/routes";
|
||||
import type { AssignmentPageData } from "../shared/assignment-types";
|
||||
|
||||
const formatDateLabel = (value: string | null) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
|
||||
if (fromInstructions) return fromInstructions;
|
||||
|
||||
return questions[0]?.subject ?? "Assignment";
|
||||
};
|
||||
|
||||
const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => {
|
||||
const total = questions.length;
|
||||
const answered = questions.filter((question) => question.answer_id).length;
|
||||
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
|
||||
if (answered === 0) return "NOT_STARTED" as const;
|
||||
if (reviewed === total || assignmentStatus === "closed") return "SUBMITTED" as const;
|
||||
return "IN_PROGRESS" as const;
|
||||
};
|
||||
|
||||
const deriveStatusLabel = (status: AssignmentPageData["status"]) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return "Reviewed";
|
||||
case "IN_PROGRESS":
|
||||
return "In progress";
|
||||
default:
|
||||
return "Not started";
|
||||
}
|
||||
};
|
||||
|
||||
const deriveHeadline = (status: AssignmentPageData["status"], topic: string) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return `Review how you did in ${topic}`;
|
||||
case "IN_PROGRESS":
|
||||
return "Keep going — you already have momentum here";
|
||||
default:
|
||||
return "Start this assignment with a steady first pass";
|
||||
}
|
||||
};
|
||||
|
||||
const deriveDescription = (status: AssignmentPageData["status"], answered: number, reviewed: number, total: number, topic: string) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return `${reviewed} of ${total} questions have teacher-reviewed answers. Use this review view to see what landed well and what still needs another pass in ${topic}.`;
|
||||
case "IN_PROGRESS":
|
||||
return `You have touched ${answered} of ${total} questions so far. Finish the remaining questions while ${topic.toLowerCase()} still feels familiar.`;
|
||||
default:
|
||||
return `This assignment has ${total} questions. Start with a calm first pass, then come back here for one shared review summary across the assignment.`;
|
||||
}
|
||||
};
|
||||
|
||||
const derivePrimaryAction = (status: AssignmentPageData["status"]) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return "Open workspace";
|
||||
case "IN_PROGRESS":
|
||||
return "Continue assignment";
|
||||
default:
|
||||
return "Start assignment";
|
||||
}
|
||||
};
|
||||
|
||||
const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
|
||||
if (question.is_correct === true) {
|
||||
return {
|
||||
statusLabel: "Correct",
|
||||
statusTone: "success" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "reviewed") {
|
||||
return {
|
||||
statusLabel: "Reviewed",
|
||||
statusTone: "success" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "submitted") {
|
||||
return {
|
||||
statusLabel: "Submitted",
|
||||
statusTone: "warning" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "in_progress") {
|
||||
return {
|
||||
statusLabel: "In progress",
|
||||
statusTone: "warning" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusLabel: "Not started",
|
||||
statusTone: "muted" as const,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAssignmentReviewPageData = async (assignmentId: number, studentId: number): Promise<AssignmentPageData | null> => {
|
||||
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
|
||||
|
||||
try {
|
||||
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
|
||||
|
||||
const [student, teacher, classrooms, questionDetails] = await Promise.all([
|
||||
apiFetchJson<ApiUser>(`/api/users/${studentId}`),
|
||||
apiFetchJson<ApiUser>(`/api/users/${assignment.teacher_id}`),
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
|
||||
apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${studentId}/questions`),
|
||||
]);
|
||||
|
||||
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
|
||||
const questions = questionDetails.data;
|
||||
const topic = extractTopic(assignment, questions);
|
||||
const answeredCount = questions.filter((question) => question.answer_id).length;
|
||||
const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || "";
|
||||
const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || "";
|
||||
const status = deriveStudentStatus(questions, assignment.status);
|
||||
const statusLabel = deriveStatusLabel(status);
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
topic,
|
||||
status,
|
||||
statusLabel,
|
||||
dueLabel: formatDateLabel(assignment.due_at),
|
||||
studentName: student.full_name,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
tutorName: teacher.full_name,
|
||||
headline: deriveHeadline(status, topic),
|
||||
description: deriveDescription(status, answeredCount, reviewedCount, questions.length, topic),
|
||||
primaryAction: derivePrimaryAction(status),
|
||||
primaryHref: getAssignmentWorkHref(assignment.id),
|
||||
stats: [
|
||||
{ label: "Status", value: statusLabel },
|
||||
{ label: "Due", value: formatDateLabel(assignment.due_at) },
|
||||
{ label: "Questions", value: `${questions.length}` },
|
||||
{ label: status === "SUBMITTED" ? "Reviewed" : "Answered", value: `${status === "SUBMITTED" ? reviewedCount : answeredCount}/${questions.length}` },
|
||||
],
|
||||
coachCard: {
|
||||
title: "Review notes",
|
||||
description: `Use this review view to compare your latest answers with the shared AI and teacher feedback for ${topic.toLowerCase()}.`,
|
||||
items: [`${answeredCount} questions attempted`, `${reviewedCount} questions reviewed`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`],
|
||||
},
|
||||
assignmentAiFeedback: assignmentAiFeedback || undefined,
|
||||
assignmentTeacherFeedback: assignmentTeacherFeedback || undefined,
|
||||
questions: questions.map((question) => {
|
||||
const questionStatus = mapQuestionStatus(question);
|
||||
|
||||
return {
|
||||
id: question.question_id,
|
||||
order: question.position,
|
||||
prompt: question.prompt,
|
||||
topic: question.subject,
|
||||
subTopic: null,
|
||||
difficulty: "Backend",
|
||||
marks: null,
|
||||
statusLabel: questionStatus.statusLabel,
|
||||
statusTone: questionStatus.statusTone,
|
||||
responseLabel: question.answer_id ? "Latest answer" : "Status",
|
||||
responseValue: question.answer_text?.trim() || "No attempt yet",
|
||||
feedback: "",
|
||||
workingSteps: question.working_steps?.trim() || "",
|
||||
solveModeLabel:
|
||||
question.solve_mode === "step_by_step"
|
||||
? "Step by step"
|
||||
: question.solve_mode === "solve_together"
|
||||
? "Solve together"
|
||||
: question.solve_mode === "handwritten"
|
||||
? "Handwritten"
|
||||
: question.solve_mode === "just_answer"
|
||||
? "Just answer"
|
||||
: undefined,
|
||||
showAnswerKey: Boolean(question.correct_answer && question.answer_id),
|
||||
correctAnswer: question.correct_answer?.trim() || null,
|
||||
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "not_found") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
|
||||
import type { TeacherAssignmentPassStatus, TeacherAssignmentReviewPageData, TeacherNextStepOutcome } from "./assignment-teacher-review.data";
|
||||
import { updateAssignmentTeacherFeedback } from "./assignment-teacher-review.data";
|
||||
import { AssignmentFeedbackSection, type TeacherReviewNotice } from "./assignment-teacher-review.sections";
|
||||
import { getAssignmentReviewHref, getTeacherAssignmentRedoPlanHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-teacher-review.module.scss";
|
||||
|
||||
type Props = {
|
||||
data: TeacherAssignmentReviewPageData;
|
||||
};
|
||||
|
||||
type NextStepDraft = {
|
||||
teacherFeedback?: string;
|
||||
decision?: TeacherNextStepOutcome;
|
||||
passStatusOverride?: TeacherAssignmentPassStatus | null;
|
||||
};
|
||||
|
||||
const PASS_STATUS_OVERRIDE_OPTIONS: Array<{ value: TeacherAssignmentPassStatus | null; label: string; help: string }> = [
|
||||
{ value: null, label: "Automatic", help: "Use the calculated status from the fixed pass rule." },
|
||||
{ value: "pass", label: "Pass", help: "Override the calculated result and mark this review as pass." },
|
||||
{ value: "no_pass", label: "No pass", help: "Override the calculated result and mark this review as no pass." },
|
||||
];
|
||||
|
||||
const nextStepStorageKey = (assignmentId: number, studentId: number) => `teacher-next-step-draft:${assignmentId}:${studentId}`;
|
||||
|
||||
const readNextStepDraft = (assignmentId: number, studentId: number): NextStepDraft => {
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(nextStepStorageKey(assignmentId, studentId));
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as NextStepDraft) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeNextStepDraft = (assignmentId: number, studentId: number, draft: NextStepDraft) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const key = nextStepStorageKey(assignmentId, studentId);
|
||||
if (!draft.teacherFeedback && !draft.decision && draft.passStatusOverride == null) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify(draft));
|
||||
};
|
||||
|
||||
const OUTCOME_OPTIONS: Array<{ value: TeacherNextStepOutcome; title: string; description: string }> = [
|
||||
{ value: "redo", title: "Redo assignment", description: "Ask the student to revisit the assignment and try again with the latest review in mind." },
|
||||
{ value: "accept", title: "Accept and continue", description: "Mark this review as ready to move on and continue the student into the next piece of work." },
|
||||
{ value: "support", title: "Needs support", description: "Flag that the student needs extra coaching, a follow-up message, or a guided reteach step." },
|
||||
];
|
||||
|
||||
const AssignmentTeacherNextStep: Component<Props> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const [teacherFeedbackDraft, setTeacherFeedbackDraft] = createSignal("");
|
||||
const [decisionDraft, setDecisionDraft] = createSignal<TeacherNextStepOutcome | null>(null);
|
||||
const [passStatusOverrideDraft, setPassStatusOverrideDraft] = createSignal<TeacherAssignmentPassStatus | null>(null);
|
||||
const [savingFeedback, setSavingFeedback] = createSignal(false);
|
||||
const [notice, setNotice] = createSignal<TeacherReviewNotice>(null);
|
||||
|
||||
const selectedStudentId = createMemo(() => props.data.selectedStudentId);
|
||||
const canChooseNextStep = createMemo(() => {
|
||||
return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0;
|
||||
});
|
||||
const hasPendingAssignmentFeedback = createMemo(() => teacherFeedbackDraft() !== props.data.assignmentTeacherFeedback);
|
||||
const hasPendingDecision = createMemo(() => decisionDraft() !== props.data.nextStepOutcome);
|
||||
const hasPendingPassStatusOverride = createMemo(() => passStatusOverrideDraft() !== props.data.passStatusOverride);
|
||||
const overallScorePercent = createMemo(() => {
|
||||
if (props.data.overallScore == null) return null;
|
||||
return Math.round(props.data.overallScore * 10);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const studentId = selectedStudentId();
|
||||
if (!studentId) return;
|
||||
const draft = readNextStepDraft(props.data.assignmentId, studentId);
|
||||
setTeacherFeedbackDraft(draft.teacherFeedback ?? props.data.assignmentTeacherFeedback);
|
||||
setDecisionDraft(draft.decision ?? props.data.nextStepOutcome);
|
||||
setPassStatusOverrideDraft(draft.passStatusOverride ?? props.data.passStatusOverride);
|
||||
setNotice(null);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const studentId = selectedStudentId();
|
||||
if (!studentId) return;
|
||||
writeNextStepDraft(props.data.assignmentId, studentId, {
|
||||
teacherFeedback: hasPendingAssignmentFeedback() ? teacherFeedbackDraft() : undefined,
|
||||
decision: hasPendingDecision() ? decisionDraft() ?? undefined : undefined,
|
||||
passStatusOverride: hasPendingPassStatusOverride() ? passStatusOverrideDraft() : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const saveFeedback = async () => {
|
||||
const studentId = selectedStudentId();
|
||||
if (!studentId) return;
|
||||
if (!canChooseNextStep()) {
|
||||
setNotice({
|
||||
scope: "assignment",
|
||||
tone: "error",
|
||||
text: "Next step stays locked until the student has submitted work for review.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingFeedback(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (hasPendingAssignmentFeedback() || hasPendingPassStatusOverride() || hasPendingDecision()) {
|
||||
await updateAssignmentTeacherFeedback(props.data.assignmentId, studentId, {
|
||||
teacherFeedback: teacherFeedbackDraft().trim(),
|
||||
passStatusOverride: passStatusOverrideDraft(),
|
||||
nextStepOutcome: decisionDraft(),
|
||||
});
|
||||
}
|
||||
|
||||
writeNextStepDraft(props.data.assignmentId, studentId, {});
|
||||
if (decisionDraft() === "redo") {
|
||||
navigate(getTeacherAssignmentRedoPlanHref(props.data.assignmentId, studentId));
|
||||
return;
|
||||
}
|
||||
navigate(getAssignmentReviewHref("teacher", props.data.assignmentId));
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
scope: "assignment",
|
||||
tone: "error",
|
||||
text: error instanceof Error ? error.message : "Could not save teacher feedback right now.",
|
||||
});
|
||||
} finally {
|
||||
setSavingFeedback(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={styles.contentGrid}>
|
||||
<div class={styles.mainColumn}>
|
||||
<section class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h2>Choose the next step</h2>
|
||||
<p>
|
||||
<Show when={props.data.selectedStudentName} fallback="Choose what should happen after this review.">
|
||||
{props.data.selectedStudentName} has been reviewed. Choose what should happen next, then save your feedback at the bottom before returning to the review page.
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.selectedStudentId !== null} fallback={<div class={styles.emptyState}>Pick a student from the review queue first so you can set the right next step.</div>}>
|
||||
<Show
|
||||
when={canChooseNextStep()}
|
||||
fallback={<div class={styles.emptyState}>This student has not submitted work yet, so next step is not available.</div>}
|
||||
>
|
||||
<section class={styles.nextStepCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Recommended outcome</h3>
|
||||
<p>Pick the next teaching move for {props.data.selectedStudentName ?? "this student"}. The selected outcome will be saved with this student's review.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.optionGrid}>
|
||||
<For each={OUTCOME_OPTIONS}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.optionCard} ${decisionDraft() === option.value ? styles.optionCardActive : ""}`.trim()}
|
||||
onClick={() => setDecisionDraft(option.value)}
|
||||
>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.description}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={styles.draftHint}>
|
||||
<Show when={decisionDraft()} fallback={<span>No next-step outcome has been picked yet.</span>}>
|
||||
<span>Selected outcome: {OUTCOME_OPTIONS.find((option) => option.value === decisionDraft())?.title}. Save feedback to persist it for this student.</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewScoreGrid}>
|
||||
<div class={`${styles.reviewField} ${styles.scoreHighlight}`.trim()}>
|
||||
<label>Overall score</label>
|
||||
<div class={styles.scoreHighlightValue}>
|
||||
<Show when={overallScorePercent() != null} fallback={<span>Pending review</span>}>
|
||||
<strong>{overallScorePercent()}%</strong>
|
||||
</Show>
|
||||
</div>
|
||||
<small>
|
||||
<Show when={props.data.overallScore != null} fallback="The score will appear after the review has been completed.">
|
||||
Blended correctness and understanding score.
|
||||
</Show>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label>Pass status</label>
|
||||
<input
|
||||
type="text"
|
||||
class={styles.compactInput}
|
||||
value={props.data.passStatus === "no_pass" ? "No pass" : props.data.passStatus === "pass" ? "Pass" : "Pending"}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<small>
|
||||
<Show when={props.data.passStatusOverride} fallback="Currently using the automatic result.">
|
||||
Teacher override is active.
|
||||
</Show>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label>Teacher override</label>
|
||||
<select
|
||||
class={styles.compactInput}
|
||||
value={passStatusOverrideDraft() ?? ""}
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value.trim();
|
||||
setPassStatusOverrideDraft(value === "pass" || value === "no_pass" || value === "pending" ? value : null);
|
||||
}}
|
||||
>
|
||||
<For each={PASS_STATUS_OVERRIDE_OPTIONS}>
|
||||
{(option) => (
|
||||
<option value={option.value ?? ""}>{option.label}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<small>{PASS_STATUS_OVERRIDE_OPTIONS.find((option) => option.value === passStatusOverrideDraft())?.help ?? PASS_STATUS_OVERRIDE_OPTIONS[0]!.help}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AssignmentFeedbackSection
|
||||
data={props.data}
|
||||
teacherFeedbackDraft={teacherFeedbackDraft()}
|
||||
hasPendingAssignmentFeedback={hasPendingAssignmentFeedback()}
|
||||
busy={savingFeedback()}
|
||||
notice={notice()}
|
||||
draftActionLabel="Save and return to review"
|
||||
onTeacherFeedbackInput={setTeacherFeedbackDraft}
|
||||
actions={
|
||||
<button type="button" class={styles.primaryAction} disabled={savingFeedback()} onClick={saveFeedback}>
|
||||
{savingFeedback()
|
||||
? decisionDraft() === "redo"
|
||||
? "Saving and opening redo plan..."
|
||||
: "Saving and returning..."
|
||||
: decisionDraft() === "redo"
|
||||
? "Save feedback and open redo plan"
|
||||
: "Save feedback and return to review"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class={styles.sideColumn}>
|
||||
<section class={styles.sideCard}>
|
||||
<p class={styles.sideEyebrow}>Student</p>
|
||||
<h2>{props.data.selectedStudentName ?? "Review summary"}</h2>
|
||||
<p>{props.data.selectedStudentEmail ?? props.data.coachCard.description}</p>
|
||||
<Show when={props.data.selectedStudentProgress}>
|
||||
<div class={styles.progressNote}>{props.data.selectedStudentProgress}</div>
|
||||
</Show>
|
||||
<ul class={styles.noteList}>
|
||||
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
|
||||
</ul>
|
||||
<div class={styles.actionRow}>
|
||||
<A href={getAssignmentReviewHref("teacher", props.data.assignmentId)} class={styles.secondaryAction}>
|
||||
Back to review
|
||||
</A>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTeacherNextStep;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createTeacherAssignment, generateTeacherQuestions } from "../../dashboard/teacher/dashboard-teacher-assignments.data";
|
||||
import type { TeacherRedoPlanData, TeacherRedoPlanQuestion } from "./assignment-teacher-review.types";
|
||||
import type { RedoPlanGenerationResult, RedoPlanGroupedItem } from "./assignment-teacher-redo-plan.types";
|
||||
|
||||
const compareGroupedItems = (left: RedoPlanGroupedItem, right: RedoPlanGroupedItem) => {
|
||||
if (left.topic !== right.topic) return left.topic.localeCompare(right.topic);
|
||||
return left.difficulty.localeCompare(right.difficulty);
|
||||
};
|
||||
|
||||
export const groupRedoPlanQuestions = (questionSet: TeacherRedoPlanQuestion[]): RedoPlanGroupedItem[] => {
|
||||
const groupedItems = new Map<
|
||||
string,
|
||||
{
|
||||
topic: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
count: number;
|
||||
tags: Set<string>;
|
||||
reasons: string[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const item of questionSet) {
|
||||
const key = `${item.topicKey}::${item.difficultyKey}`;
|
||||
const existing = groupedItems.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
item.tags.forEach((tag) => existing.tags.add(tag));
|
||||
existing.reasons.push(item.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
groupedItems.set(key, {
|
||||
topic: item.topicKey,
|
||||
difficulty: item.difficultyKey,
|
||||
count: 1,
|
||||
tags: new Set(item.tags),
|
||||
reasons: [item.reason],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groupedItems.values())
|
||||
.map((item) => ({
|
||||
topic: item.topic,
|
||||
difficulty: item.difficulty,
|
||||
count: item.count,
|
||||
tags: Array.from(item.tags),
|
||||
reasons: item.reasons,
|
||||
}))
|
||||
.sort(compareGroupedItems);
|
||||
};
|
||||
|
||||
export const buildPlannedAreasMarkdown = (groupedItems: RedoPlanGroupedItem[]) =>
|
||||
groupedItems
|
||||
.map(
|
||||
(item, index) =>
|
||||
`- ${index + 1}. ${item.topic.replace(/_/g, " ")} (${item.difficulty}) x${item.count}${item.reasons.length > 0 ? ` — ${item.reasons.join("; ")}` : ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
export const createRedoAssignmentForStudent = async (input: {
|
||||
data: TeacherRedoPlanData;
|
||||
teacherId: number;
|
||||
}): Promise<RedoPlanGenerationResult> => {
|
||||
const groupedItems = groupRedoPlanQuestions(input.data.plan?.questionSet ?? []);
|
||||
const plannedAreas = buildPlannedAreasMarkdown(groupedItems);
|
||||
const generatedQuestionIds: number[] = [];
|
||||
const generatedDescriptions: string[] = [];
|
||||
|
||||
for (const item of groupedItems) {
|
||||
const result = await generateTeacherQuestions({
|
||||
topic: item.topic,
|
||||
difficulty: item.difficulty,
|
||||
count: item.count,
|
||||
source: "redo_plan_generated",
|
||||
});
|
||||
|
||||
generatedQuestionIds.push(...result.generatedQuestionIds);
|
||||
generatedDescriptions.push(`${item.topic.replace(/_/g, " ")} ${item.difficulty} ×${result.count} (seed ${result.seed})`);
|
||||
}
|
||||
|
||||
const assignment = await createTeacherAssignment({
|
||||
teacherId: input.teacherId,
|
||||
classroomId: input.data.classroomId,
|
||||
title: `${input.data.selectedStudentName} redo • ${input.data.title}`,
|
||||
instructions: [
|
||||
`Student-specific redo follow-up for ${input.data.selectedStudentName}.`,
|
||||
`Source assignment: ${input.data.title}`,
|
||||
"",
|
||||
`Plan rationale: ${input.data.plan?.rationale ?? ""}`,
|
||||
input.data.teacherFeedback ? `Teacher feedback: ${input.data.teacherFeedback}` : "",
|
||||
input.data.weaknessSummary.weakTags.length > 0 ? `Weak tags: ${input.data.weaknessSummary.weakTags.join(", ")}` : "",
|
||||
"",
|
||||
"Planned focus areas:",
|
||||
plannedAreas,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
dueAt: "",
|
||||
selectedQuestionIds: Array.from(new Set(generatedQuestionIds)),
|
||||
assignedStudentIds: [input.data.selectedStudentId],
|
||||
});
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
successMessage: `Created a redo assignment for ${input.data.selectedStudentName}. ${generatedDescriptions.join("; ")}.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, Show } from "solid-js";
|
||||
import { getAssignmentReviewHref, getTeacherAssignmentNextStepHref } from "../../../lib/routes";
|
||||
import type { TeacherRedoPlanData } from "./assignment-teacher-review.types";
|
||||
import styles from "./assignment-teacher-review.module.scss";
|
||||
|
||||
type RedoPlanMainColumnProps = {
|
||||
data: TeacherRedoPlanData;
|
||||
};
|
||||
|
||||
export const RedoPlanMainColumn: Component<RedoPlanMainColumnProps> = (props) => (
|
||||
<div class={styles.mainColumn}>
|
||||
<section class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h2>Redo plan</h2>
|
||||
<p>
|
||||
AI prepared this redo plan for {props.data.selectedStudentName} based on the completed review, weakness summary, and teacher feedback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.error}>
|
||||
<div class={`${styles.notice} ${styles.noticeError}`.trim()}>{props.data.error}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={props.data.plan}
|
||||
fallback={<div class={styles.emptyState}>No redo plan is available yet. Save the next-step page with <strong>Redo assignment</strong> selected to generate one.</div>}
|
||||
>
|
||||
<section class={styles.nextStepCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Rationale</h3>
|
||||
<p>Why this student needs the planned mix of follow-up practice.</p>
|
||||
</div>
|
||||
<div class={styles.responseBlock}>
|
||||
<strong>{props.data.plan!.rationale}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Planned question set</h3>
|
||||
<p>These blueprint slices will be turned into a student-specific redo assignment when you generate it.</p>
|
||||
</div>
|
||||
<div class={styles.planList}>
|
||||
<For each={props.data.plan!.questionSet}>
|
||||
{(item, index) => (
|
||||
<article class={styles.planCard}>
|
||||
<div class={styles.questionTop}>
|
||||
<div>
|
||||
<p class={styles.order}>Item {index() + 1}</p>
|
||||
<h3>{item.topic}</h3>
|
||||
</div>
|
||||
<span class={`${styles.statusPill} ${styles.progress}`.trim()}>{item.difficulty}</span>
|
||||
</div>
|
||||
<Show when={item.tags.length > 0}>
|
||||
<div class={styles.tagList}>
|
||||
<For each={item.tags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class={styles.supportBlock}>
|
||||
<p>Reason</p>
|
||||
<span>{item.reason}</span>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
type RedoPlanSidebarProps = {
|
||||
data: TeacherRedoPlanData;
|
||||
isCreatingRedoAssignment: boolean;
|
||||
generationError: string | null;
|
||||
generationSuccess: string | null;
|
||||
onGenerateRedoQuestions: () => void;
|
||||
};
|
||||
|
||||
export const RedoPlanSidebar: Component<RedoPlanSidebarProps> = (props) => (
|
||||
<aside class={styles.sideColumn}>
|
||||
<section class={styles.sideCard}>
|
||||
<p class={styles.sideEyebrow}>Student</p>
|
||||
<h2>{props.data.selectedStudentName}</h2>
|
||||
<p>{props.data.selectedStudentEmail}</p>
|
||||
<div class={styles.progressNote}>Generated for {props.data.title}</div>
|
||||
<Show when={props.data.generatedAtLabel}>
|
||||
<div class={styles.draftHint}>Generated {props.data.generatedAtLabel}</div>
|
||||
</Show>
|
||||
<Show when={props.data.plan?.questionSet.length}>
|
||||
<button type="button" class={styles.primaryAction} onClick={props.onGenerateRedoQuestions} disabled={props.isCreatingRedoAssignment}>
|
||||
{props.isCreatingRedoAssignment ? "Creating redo assignment…" : "Generate redo questions"}
|
||||
</button>
|
||||
<p class={styles.progressNote}>This creates and assigns an independent redo assignment for {props.data.selectedStudentName} only.</p>
|
||||
</Show>
|
||||
<Show when={props.generationError}>
|
||||
<div class={`${styles.notice} ${styles.noticeError}`.trim()}>{props.generationError}</div>
|
||||
</Show>
|
||||
<Show when={props.generationSuccess}>
|
||||
<div class={`${styles.notice} ${styles.noticeSuccess}`.trim()}>{props.generationSuccess}</div>
|
||||
</Show>
|
||||
<div class={styles.actionRow}>
|
||||
<A href={getTeacherAssignmentNextStepHref(props.data.assignmentId, props.data.selectedStudentId)} class={styles.secondaryAction}>
|
||||
Back to next step
|
||||
</A>
|
||||
<A href={getAssignmentReviewHref("teacher", props.data.assignmentId)} class={styles.secondaryAction}>
|
||||
Back to review
|
||||
</A>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Teacher feedback</h3>
|
||||
<p>The feedback context used when this redo plan was created.</p>
|
||||
</div>
|
||||
<div class={styles.supportBlock}>
|
||||
<span>{props.data.teacherFeedback || "No teacher feedback saved yet."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Topic scores</h3>
|
||||
<p>Historical performance snapshot used to plan the redo.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={props.data.weaknessSummary.topicScores.length > 0}
|
||||
fallback={<div class={styles.emptyState}>No topic history is available yet for this student.</div>}
|
||||
>
|
||||
<div class={styles.scoreList}>
|
||||
<For each={props.data.weaknessSummary.topicScores}>
|
||||
{(item) => (
|
||||
<div class={styles.scoreRow}>
|
||||
<span>{item.topic}</span>
|
||||
<strong>{item.score.toFixed(1)}%</strong>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Weak tags</h3>
|
||||
<p>Low-performing or flagged tags from previous work.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={props.data.weaknessSummary.weakTags.length > 0}
|
||||
fallback={<div class={styles.emptyState}>No weak tags were identified from the current review history.</div>}
|
||||
>
|
||||
<div class={styles.tagList}>
|
||||
<For each={props.data.weaknessSummary.weakTags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Recent issues</h3>
|
||||
<p>Recent issue reasons pulled into the weakness summary.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={props.data.weaknessSummary.recentIssues.length > 0}
|
||||
fallback={<div class={styles.emptyState}>No recent issue reasons were available.</div>}
|
||||
>
|
||||
<ul class={styles.noteList}>
|
||||
<For each={props.data.weaknessSummary.recentIssues}>{(issue) => <li>{issue}</li>}</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user