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 ©Value } 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 }