Files
BoostAI/Backend/internal/seeddata/seed.go
2026-05-26 13:43:09 +01:00

976 lines
28 KiB
Go

package seeddata
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"boostai-backend/internal/database"
sharedapi "boostai-backend/internal/handlers/api/shared"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"golang.org/x/crypto/bcrypt"
)
const SeededPassword = "password123"
type studentRecord struct {
ID int64 `json:"id"`
FullName string `json:"fullname"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
IsDeleted bool `json:"is_deleted"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type classroomFile struct {
Classroom classroomRecord `json:"classroom"`
Tutor tutorRecord `json:"tutor"`
ClassroomStudentRs []classroomStudentRecord `json:"classroom_student_rs"`
}
type classroomRecord struct {
ID int64 `json:"id"`
Name string `json:"name"`
TutorID int64 `json:"tutor_id"`
InviteCode string `json:"invite_code"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type tutorRecord struct {
ID int64 `json:"id"`
FullName string `json:"fullname"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
IsDeleted bool `json:"is_deleted"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type classroomStudentRecord struct {
ClassroomID int64 `json:"classroom_id"`
StudentID int64 `json:"student_id"`
CreatedAt int64 `json:"created_at"`
}
type questionRecord struct {
ID int64 `json:"id"`
Topic string `json:"topic"`
SubTopic *string `json:"sub_topic"`
Tag *string `json:"tag"`
Difficulty string `json:"difficulty"`
QuestionText string `json:"question_text"`
CorrectAnswer string `json:"correct_answer"`
Source string `json:"source"`
TeacherID int64 `json:"teacher_id"`
IsDeleted bool `json:"is_deleted"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type assignmentRecord struct {
ID int64 `json:"id"`
Name string `json:"name"`
TeacherID int64 `json:"teacher_id"`
Topic string `json:"topic"`
DueDate int64 `json:"due_date"`
Status string `json:"status"`
IsDeleted bool `json:"is_deleted"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type assignmentQuestionRecord struct {
ID int64 `json:"id"`
AssignmentID int64 `json:"assignment_id"`
QuestionBankID int64 `json:"question_bank_id"`
QuestionOrder int32 `json:"question_order"`
CreatedAt int64 `json:"created_at"`
}
type assignmentAssigneeRecord struct {
ID int64 `json:"id"`
AssignmentID int64 `json:"assignment_id"`
StudentID int64 `json:"student_id"`
Status string `json:"status"`
StartedAt *int64 `json:"started_at"`
SubmittedAt *int64 `json:"submitted_at"`
OverallScore *float64 `json:"overall_score"`
AIFeedback *string `json:"ai_feedback"`
NextStepOutcome *string `json:"next_step_outcome"`
IsActive bool `json:"is_active"`
CreatedAt int64 `json:"created_at"`
}
type studentAnswerRecord struct {
ID int64 `json:"id"`
AssigneeID int64 `json:"assignee_id"`
AssignmentQuestionID int64 `json:"assignment_question_id"`
AnswerLatex *string `json:"answer_latex"`
ExtractedAnswer *string `json:"extracted_answer"`
SolveMode *string `json:"solve_mode"`
WorkingSteps *string `json:"working_steps"`
AIReasoning *string `json:"ai_reasoning"`
IsCorrect *bool `json:"is_correct"`
AIFeedback *string `json:"ai_feedback"`
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"`
GradingStatus string `json:"grading_status"`
IsActive bool `json:"is_active"`
CreatedAt int64 `json:"created_at"`
AnsweredAt *int64 `json:"_answered_at"`
UnderSolveMode *string `json:"_solve_mode"`
UnderIsCorrect *bool `json:"_is_correct"`
UnderMisconceptionTag *string `json:"_misconception_tag"`
}
type assignmentQuestionRef struct {
AssignmentID int64
QuestionID int64
Position int32
}
const DefaultMockDataDir = "../Mock-Data"
type Summary struct {
Users int `json:"users"`
Classrooms int `json:"classrooms"`
Questions int `json:"questions"`
Tags int `json:"tags"`
Assignments int `json:"assignments"`
AssignmentLinks int `json:"assignment_links"`
StudentAnswers int `json:"student_answers"`
MockDataDir string `json:"mock_data_dir"`
}
func Run(ctx context.Context, db *database.DB, mockDataDir string) (Summary, error) {
if strings.TrimSpace(mockDataDir) == "" {
mockDataDir = filepath.Clean(filepath.Join("..", "Mock-Data"))
}
var (
students []studentRecord
classroomPayload classroomFile
questions []questionRecord
assignments []assignmentRecord
assignmentQuestions []assignmentQuestionRecord
assignmentAssignees []assignmentAssigneeRecord
studentAnswers []studentAnswerRecord
)
if err := loadJSON(filepath.Join(mockDataDir, "students.json"), &students); err != nil {
return Summary{}, err
}
if err := loadJSON(filepath.Join(mockDataDir, "classroom.json"), &classroomPayload); err != nil {
return Summary{}, err
}
if err := loadJSON(filepath.Join(mockDataDir, "question_bank.json"), &questions); err != nil {
return Summary{}, err
}
if err := loadJSON(filepath.Join(mockDataDir, "assignments.json"), &assignments); err != nil {
return Summary{}, err
}
if err := loadJSON(filepath.Join(mockDataDir, "assignment_questions.json"), &assignmentQuestions); err != nil {
return Summary{}, err
}
if err := loadJSON(filepath.Join(mockDataDir, "assignment_assignees.json"), &assignmentAssignees); err != nil {
return Summary{}, err
}
if err := loadJSON(filepath.Join(mockDataDir, "student_answers.json"), &studentAnswers); err != nil {
return Summary{}, err
}
if err := db.Migrate(); err != nil {
return Summary{}, fmt.Errorf("migrate database: %w", err)
}
tx, err := db.Pool.Begin(ctx)
if err != nil {
return Summary{}, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
if err := resetSeedData(ctx, tx); err != nil {
return Summary{}, fmt.Errorf("reset data: %w", err)
}
if err := seedUsers(ctx, tx, classroomPayload.Tutor, students); err != nil {
return Summary{}, fmt.Errorf("seed users: %w", err)
}
if err := seedClassroom(ctx, tx, classroomPayload); err != nil {
return Summary{}, fmt.Errorf("seed classroom: %w", err)
}
tagIDs, err := seedQuestionsAndTags(ctx, tx, questions)
if err != nil {
return Summary{}, fmt.Errorf("seed questions: %w", err)
}
if err := seedAssignments(ctx, tx, classroomPayload.Classroom.ID, assignments); err != nil {
return Summary{}, fmt.Errorf("seed assignments: %w", err)
}
if err := seedAssignmentAssignees(ctx, tx, assignmentAssignees); err != nil {
return Summary{}, fmt.Errorf("seed assignment assignees: %w", err)
}
assignmentQuestionMap, err := seedAssignmentQuestions(ctx, tx, assignmentQuestions)
if err != nil {
return Summary{}, fmt.Errorf("seed assignment questions: %w", err)
}
if err := seedStudentAnswers(ctx, tx, assignmentAssignees, assignmentQuestionMap, studentAnswers); err != nil {
return Summary{}, fmt.Errorf("seed student answers: %w", err)
}
if err := seedMessages(ctx, tx, classroomPayload.Tutor, students); err != nil {
return Summary{}, fmt.Errorf("seed messages: %w", err)
}
if err := syncSequences(ctx, tx); err != nil {
return Summary{}, fmt.Errorf("sync sequences: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Summary{}, fmt.Errorf("commit seed transaction: %w", err)
}
return Summary{
Users: len(students) + 1,
Classrooms: 1,
Questions: len(questions),
Tags: len(tagIDs),
Assignments: len(assignments),
AssignmentLinks: len(assignmentQuestions),
StudentAnswers: len(studentAnswers),
MockDataDir: mockDataDir,
}, nil
}
func loadJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
return nil
}
func resetSeedData(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
TRUNCATE TABLE
messages,
message_thread_participants,
message_threads,
assignment_student_questions,
student_answers,
assignment_questions,
assignment_assignees,
assignments,
question_tags,
tags,
questions,
classroom_students,
classrooms,
users
RESTART IDENTITY CASCADE`)
return err
}
func seedUsers(ctx context.Context, tx pgx.Tx, tutor tutorRecord, students []studentRecord) error {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(SeededPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := insertUser(ctx, tx, tutor.ID, tutor.Email, string(hashedPassword), "teacher", tutor.FullName, tutor.Active && !tutor.IsDeleted, tutor.CreatedAt, tutor.UpdatedAt); err != nil {
return err
}
for _, student := range students {
if student.IsDeleted {
continue
}
if err := insertUser(ctx, tx, student.ID, student.Email, string(hashedPassword), "student", student.FullName, student.Active && !student.IsDeleted, student.CreatedAt, student.UpdatedAt); err != nil {
return err
}
}
return nil
}
func seedMessages(ctx context.Context, tx pgx.Tx, tutor tutorRecord, students []studentRecord) error {
activeStudents := make([]studentRecord, 0, len(students))
for _, student := range students {
if student.IsDeleted || !student.Active {
continue
}
activeStudents = append(activeStudents, student)
}
if len(activeStudents) == 0 {
return nil
}
threadSeedCount := len(activeStudents)
if threadSeedCount > 3 {
threadSeedCount = 3
}
now := time.Now().UTC()
for idx := 0; idx < threadSeedCount; idx++ {
student := activeStudents[idx]
threadCreatedAt := now.Add(-time.Duration(threadSeedCount-idx) * 6 * time.Hour)
subject := fmt.Sprintf("Study check-in for %s", firstName(student.FullName))
var threadID int64
if err := tx.QueryRow(ctx, `
INSERT INTO message_threads (created_by_user_id, subject, created_at, updated_at)
VALUES ($1, $2, $3, $3)
RETURNING id`, tutor.ID, subject, threadCreatedAt).Scan(&threadID); err != nil {
return err
}
teacherBody := fmt.Sprintf("Hi %s, send me a quick update when you finish today's maths block. If one question feels sticky, tell me which one and I'll help.", firstName(student.FullName))
studentBody := fmt.Sprintf("Thanks %s — I started the assignment set and I'm feeling better about the fraction questions now.", firstName(tutor.FullName))
messageTimes := []time.Time{threadCreatedAt.Add(12 * time.Minute), threadCreatedAt.Add(54 * time.Minute)}
if _, err := tx.Exec(ctx, `
INSERT INTO messages (thread_id, sender_user_id, body, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4), ($1, $5, $6, $7, $7)`, threadID, tutor.ID, teacherBody, messageTimes[0], student.ID, studentBody, messageTimes[1]); err != nil {
return err
}
teacherReadAt := pgtype.Timestamptz{Time: messageTimes[1], Valid: true}
studentReadAt := pgtype.Timestamptz{}
if idx == 0 {
studentReadAt = pgtype.Timestamptz{Time: messageTimes[1], Valid: true}
}
if _, err := tx.Exec(ctx, `
INSERT INTO message_thread_participants (thread_id, user_id, joined_at, last_read_at)
VALUES ($1, $2, $3, $4), ($1, $5, $3, $6)`, threadID, tutor.ID, threadCreatedAt, teacherReadAt, student.ID, studentReadAt); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE message_threads SET updated_at = $2 WHERE id = $1`, threadID, messageTimes[1]); err != nil {
return err
}
}
return nil
}
func insertUser(ctx context.Context, tx pgx.Tx, id int64, email, passwordHash, role, fullName string, active bool, createdAtMs, updatedAtMs int64) error {
_, err := tx.Exec(ctx, `
INSERT INTO users (id, email, password_hash, role, full_name, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4::user_role, $5, $6, $7, $8)`,
id,
email,
passwordHash,
role,
fullName,
active,
msToTime(createdAtMs),
msToTime(updatedAtMs),
)
return err
}
func seedClassroom(ctx context.Context, tx pgx.Tx, payload classroomFile) error {
_, err := tx.Exec(ctx, `
INSERT INTO classrooms (id, teacher_id, name, code, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
payload.Classroom.ID,
payload.Classroom.TutorID,
payload.Classroom.Name,
sharedapi.NullableText(optionalString(payload.Classroom.InviteCode)),
sharedapi.NullableText(classroomDescription(payload.Classroom.Name)),
msToTime(payload.Classroom.CreatedAt),
msToTime(payload.Classroom.UpdatedAt),
)
if err != nil {
return err
}
for _, relation := range payload.ClassroomStudentRs {
if _, err := tx.Exec(ctx, `
INSERT INTO classroom_students (classroom_id, student_id, joined_at)
VALUES ($1, $2, $3)`, relation.ClassroomID, relation.StudentID, msToTime(relation.CreatedAt)); err != nil {
return err
}
}
return nil
}
func seedQuestionsAndTags(ctx context.Context, tx pgx.Tx, questions []questionRecord) ([]int64, error) {
tagSet := map[string]struct{}{}
for _, question := range questions {
if question.Tag != nil {
tag := strings.TrimSpace(*question.Tag)
if tag != "" {
tagSet[tag] = struct{}{}
}
}
}
tagNames := make([]string, 0, len(tagSet))
for tag := range tagSet {
tagNames = append(tagNames, tag)
}
sort.Strings(tagNames)
tagIDByName := make(map[string]int64, len(tagNames))
tagIDs := make([]int64, 0, len(tagNames))
for _, tagName := range tagNames {
var tagID int64
if err := tx.QueryRow(ctx, `
INSERT INTO tags (name)
VALUES ($1)
RETURNING id`, tagName).Scan(&tagID); err != nil {
return nil, err
}
tagIDByName[tagName] = tagID
tagIDs = append(tagIDs, tagID)
}
for _, question := range questions {
if err := insertQuestion(ctx, tx, question); err != nil {
return nil, err
}
if question.Tag == nil {
continue
}
tagName := strings.TrimSpace(*question.Tag)
if tagName == "" {
continue
}
tagID := tagIDByName[tagName]
if _, err := tx.Exec(ctx, `INSERT INTO question_tags (question_id, tag_id) VALUES ($1, $2)`, question.ID, tagID); err != nil {
return nil, err
}
}
return tagIDs, nil
}
func insertQuestion(ctx context.Context, tx pgx.Tx, record questionRecord) error {
status := "published"
if record.IsDeleted {
status = "archived"
}
_, err := tx.Exec(ctx, `
INSERT INTO questions (id, author_teacher_id, title, prompt, topic, subject, difficulty, source, correct_answer, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5::question_topic, $6, $7::question_difficulty, $8, $9, $10::question_status, $11, $12)`,
record.ID,
record.TeacherID,
questionTitle(record.QuestionText),
record.QuestionText,
nullableTopic(record.Topic),
nullableSubject(record.SubTopic, record.Topic),
nullableDifficulty(record.Difficulty),
nullableSource(record.Source),
sharedapi.NullableText(&record.CorrectAnswer),
status,
msToTime(record.CreatedAt),
msToTime(record.UpdatedAt),
)
return err
}
func seedAssignments(ctx context.Context, tx pgx.Tx, classroomID int64, assignments []assignmentRecord) error {
for _, assignment := range assignments {
if assignment.IsDeleted {
continue
}
assignmentStatus := normalizeAssignmentStatus(assignment.Status)
publishedAt := optionalPublishedAt(assignmentStatus, assignment.CreatedAt, assignment.UpdatedAt)
_, err := tx.Exec(ctx, `
INSERT INTO assignments (id, teacher_id, classroom_id, title, instructions, due_at, published_at, pass_threshold, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::assignment_status, $10, $11)`,
assignment.ID,
assignment.TeacherID,
classroomID,
assignment.Name,
sharedapi.NullableText(optionalString(assignmentInstructions(assignment.Topic))),
optionalDueAt(assignment.DueDate),
publishedAt,
requiredNumeric(6.0),
assignmentStatus,
msToTime(assignment.CreatedAt),
msToTime(assignment.UpdatedAt),
)
if err != nil {
return err
}
}
return nil
}
func seedAssignmentQuestions(ctx context.Context, tx pgx.Tx, rows []assignmentQuestionRecord) (map[int64]assignmentQuestionRef, error) {
assignmentQuestionMap := make(map[int64]assignmentQuestionRef, len(rows))
for _, row := range rows {
if _, err := tx.Exec(ctx, `
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`, row.AssignmentID, row.QuestionBankID, row.QuestionOrder); err != nil {
return nil, err
}
assignmentQuestionMap[row.ID] = assignmentQuestionRef{
AssignmentID: row.AssignmentID,
QuestionID: row.QuestionBankID,
Position: row.QuestionOrder,
}
}
return assignmentQuestionMap, nil
}
func seedAssignmentAssignees(ctx context.Context, tx pgx.Tx, rows []assignmentAssigneeRecord) error {
for _, row := range rows {
assignedAt := firstValidMs(row.StartedAt, row.SubmittedAt, row.CreatedAt)
_, err := tx.Exec(ctx, `
INSERT INTO assignment_assignees (assignment_id, student_id, assigned_at, ai_feedback, overall_score, pass_threshold, pass_status, next_step_outcome)
VALUES ($1, $2, $3, $4, $5, $6, $7::assignment_pass_status, $8::assignment_next_step_outcome)`,
row.AssignmentID,
row.StudentID,
assignedAt,
sharedapi.NullableText(row.AIFeedback),
optionalNumeric(row.OverallScore),
requiredNumeric(6.0),
normalizePassStatus(row.OverallScore, 6.0),
normalizeNextStepOutcome(row.NextStepOutcome),
)
if err != nil {
return err
}
}
return nil
}
func seedStudentAnswers(ctx context.Context, tx pgx.Tx, assignees []assignmentAssigneeRecord, assignmentQuestionMap map[int64]assignmentQuestionRef, answers []studentAnswerRecord) error {
type assigneeRef struct {
AssignmentID int64
StudentID int64
}
assigneeAssignment := make(map[int64]assigneeRef, len(assignees))
for _, row := range assignees {
assigneeAssignment[row.ID] = assigneeRef{AssignmentID: row.AssignmentID, StudentID: row.StudentID}
}
for _, answer := range answers {
questionRef, ok := assignmentQuestionMap[answer.AssignmentQuestionID]
if !ok {
return fmt.Errorf("missing assignment question mapping for %d", answer.AssignmentQuestionID)
}
assigneeRef, ok := assigneeAssignment[answer.AssigneeID]
if !ok {
return fmt.Errorf("missing assignee mapping for %d", answer.AssigneeID)
}
if assigneeRef.AssignmentID != questionRef.AssignmentID {
return fmt.Errorf("assignment mismatch for assignee %d and assignment question %d", answer.AssigneeID, answer.AssignmentQuestionID)
}
answerText := firstNonEmpty(answer.ExtractedAnswer, answer.AnswerLatex)
answerStatus := normalizeAnswerStatus(answer.GradingStatus)
solveMode := normalizeSolveMode(firstNonEmpty(answer.SolveMode, answer.UnderSolveMode))
reviewedAt := optionalReviewedAt(answerStatus, answer.CreatedAt)
_, err := tx.Exec(ctx, `
INSERT INTO student_answers (
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
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::answer_status, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)`,
answer.ID,
assigneeRef.AssignmentID,
questionRef.QuestionID,
assigneeRef.StudentID,
sharedapi.NullableText(answerText),
sharedapi.NullableText(answer.AIFeedback),
nil,
answerStatus,
optionalMsToTime(answer.AnsweredAt),
reviewedAt,
msToTime(answer.CreatedAt),
deriveUpdatedAt(answer.CreatedAt, questionRef.Position),
solveMode,
sharedapi.NullableText(answer.WorkingSteps),
sharedapi.NullableBool(firstNonNilBool(answer.IsCorrect, answer.UnderIsCorrect)),
boolOrDefault(answer.ReviewNeedsAttention, false),
sharedapi.NullableText(answer.ReviewIssueReason),
optionalNumeric(answer.ReviewCorrectnessScore),
optionalNumeric(answer.ReviewUnderstandingScore),
optionalNumeric(answer.ReviewQuestionScore),
optionalNumeric(answer.ReviewConfidence),
stringsArray(answer.ReviewTags),
)
if err != nil {
return err
}
}
return nil
}
func syncSequences(ctx context.Context, tx pgx.Tx) error {
statements := []string{
"SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1))",
"SELECT setval('classrooms_id_seq', COALESCE((SELECT MAX(id) FROM classrooms), 1))",
"SELECT setval('questions_id_seq', COALESCE((SELECT MAX(id) FROM questions), 1))",
"SELECT setval('tags_id_seq', COALESCE((SELECT MAX(id) FROM tags), 1))",
"SELECT setval('assignments_id_seq', COALESCE((SELECT MAX(id) FROM assignments), 1))",
"SELECT setval('assignment_student_questions_id_seq', COALESCE((SELECT MAX(id) FROM assignment_student_questions), 1))",
"SELECT setval('student_answers_id_seq', COALESCE((SELECT MAX(id) FROM student_answers), 1))",
"SELECT setval('message_threads_id_seq', COALESCE((SELECT MAX(id) FROM message_threads), 1))",
"SELECT setval('messages_id_seq', COALESCE((SELECT MAX(id) FROM messages), 1))",
}
for _, statement := range statements {
if _, err := tx.Exec(ctx, statement); err != nil {
return err
}
}
return nil
}
func msToTime(value int64) time.Time {
if value <= 0 {
return time.Now().UTC()
}
return time.UnixMilli(value).UTC()
}
func optionalMsToTime(value *int64) pgtype.Timestamptz {
if value == nil || *value <= 0 {
return pgtype.Timestamptz{}
}
return pgtype.Timestamptz{Time: time.UnixMilli(*value).UTC(), Valid: true}
}
func optionalDueAt(value int64) pgtype.Timestamptz {
if value <= 0 {
return pgtype.Timestamptz{}
}
return pgtype.Timestamptz{Time: time.UnixMilli(value).UTC(), Valid: true}
}
func deriveUpdatedAt(createdAtMs int64, position int32) time.Time {
created := msToTime(createdAtMs)
if position <= 0 {
return created
}
return created.Add(time.Duration(position) * time.Minute)
}
func normalizeAssignmentStatus(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "published", "assigned", "open":
return "assigned"
case "closed", "complete", "completed":
return "closed"
default:
return "draft"
}
}
func normalizeAnswerStatus(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "in_progress":
return "in_progress"
case "submitted":
return "submitted"
case "reviewed", "graded":
return "reviewed"
default:
return "not_started"
}
}
func normalizeSolveMode(primary *string) string {
if primary == nil {
return "just_answer"
}
switch strings.TrimSpace(strings.ToLower(*primary)) {
case "just_answer", "mental":
return "just_answer"
case "step_by_step", "calculator":
return "step_by_step"
case "solve_together":
return "solve_together"
case "handwritten", "written":
return "handwritten"
default:
return "handwritten"
}
}
func nullableTopic(value string) any {
return normalizeQuestionTopic(value)
}
func nullableSubject(subTopic *string, topic string) any {
if subTopic != nil {
if trimmed := strings.TrimSpace(*subTopic); trimmed != "" {
return sharedapi.NullableText(&trimmed)
}
}
trimmed := strings.TrimSpace(topic)
if trimmed == "" {
return nil
}
return sharedapi.NullableText(&trimmed)
}
func nullableDifficulty(value string) any {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return nil
}
return trimmed
}
func nullableSource(value string) any {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return nil
}
return trimmed
}
func assignmentInstructions(topic string) string {
trimmed := strings.TrimSpace(topic)
if trimmed == "" {
return "Complete each question and show your reasoning where needed."
}
return fmt.Sprintf("Complete each %s question and show your reasoning where needed.", strings.ReplaceAll(trimmed, "_", " "))
}
func questionTitle(prompt string) string {
trimmed := strings.TrimSpace(prompt)
if trimmed == "" {
return "Seeded question"
}
if len(trimmed) <= 60 {
return trimmed
}
return trimmed[:57] + "..."
}
func firstName(fullName string) string {
parts := strings.Fields(strings.TrimSpace(fullName))
if len(parts) == 0 {
return "there"
}
return parts[0]
}
func firstNonEmpty(values ...*string) *string {
for _, value := range values {
if value == nil {
continue
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
continue
}
copyValue := trimmed
return &copyValue
}
return nil
}
func firstNonNilBool(values ...*bool) *bool {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func optionalNumeric(value *float64) pgtype.Numeric {
if value == nil {
return pgtype.Numeric{}
}
numeric, err := sharedapi.NullableFloat64AsNumeric(value)
if err != nil {
return pgtype.Numeric{}
}
return numeric
}
func stringsArray(values []string) []string {
if len(values) == 0 {
return []string{}
}
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
out = append(out, trimmed)
}
return out
}
func normalizeQuestionTopic(value string) any {
trimmed := strings.TrimSpace(strings.ToLower(value))
if trimmed == "" {
return nil
}
mapped := map[string]string{
"place value": "place_value",
"place_value": "place_value",
"arithmetic": "arithmetic",
"negative numbers": "negative_numbers",
"negative_numbers": "negative_numbers",
"bidmas": "bidmas",
"fractions": "fractions",
"algebra": "algebra",
"geometry": "geometry",
"data": "data",
}
if normalized, ok := mapped[trimmed]; ok {
return normalized
}
return nil
}
func normalizePassStatus(score *float64, threshold float64) string {
if score == nil {
return "pending"
}
if *score >= threshold {
return "pass"
}
return "no_pass"
}
func normalizeNextStepOutcome(value *string) any {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(strings.ToLower(*value))
switch trimmed {
case "redo", "accept", "support":
return trimmed
default:
return nil
}
}
func requiredNumeric(value float64) pgtype.Numeric {
numeric, err := sharedapi.NullableFloat64AsNumeric(&value)
if err != nil {
return pgtype.Numeric{}
}
return numeric
}
func boolOrDefault(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func firstValidMs(values ...any) time.Time {
for _, value := range values {
switch typed := value.(type) {
case *int64:
if typed != nil && *typed > 0 {
return time.UnixMilli(*typed).UTC()
}
case int64:
if typed > 0 {
return time.UnixMilli(typed).UTC()
}
}
}
return time.Now().UTC()
}
func optionalPublishedAt(status string, createdAtMs, updatedAtMs int64) pgtype.Timestamptz {
if status != "assigned" && status != "closed" {
return pgtype.Timestamptz{}
}
timestamp := createdAtMs
if updatedAtMs > 0 {
timestamp = updatedAtMs
}
return optionalDueAt(timestamp)
}
func optionalReviewedAt(status string, createdAtMs int64) pgtype.Timestamptz {
if status != "reviewed" {
return pgtype.Timestamptz{}
}
return pgtype.Timestamptz{Time: msToTime(createdAtMs), Valid: true}
}
func optionalString(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func classroomDescription(name string) *string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return nil
}
description := fmt.Sprintf("Seeded classroom for %s", trimmed)
return &description
}