package db import ( "context" "database/sql" "encoding/json" "fmt" "time" ) type LearningRepository struct { db *PostgresDB } func NewLearningRepository(db *PostgresDB) *LearningRepository { return &LearningRepository{db: db} } func (r *LearningRepository) RunMigrations(ctx context.Context) error { migrations := []string{ `CREATE TABLE IF NOT EXISTS learning_user_profiles ( user_id UUID PRIMARY KEY, display_name VARCHAR(255), profile JSONB NOT NULL DEFAULT '{}', resume_file_id UUID, resume_extracted_text TEXT, onboarding_completed BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS learning_courses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug VARCHAR(255) NOT NULL UNIQUE, title VARCHAR(500) NOT NULL, short_description TEXT, category VARCHAR(100) NOT NULL DEFAULT 'general', tags TEXT[] DEFAULT '{}', difficulty VARCHAR(50) NOT NULL DEFAULT 'beginner', duration_hours INT DEFAULT 0, base_outline JSONB NOT NULL DEFAULT '{}', landing JSONB NOT NULL DEFAULT '{}', cover_image TEXT, fingerprint VARCHAR(128) UNIQUE, status VARCHAR(50) NOT NULL DEFAULT 'draft', enrolled_count INT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_learning_courses_status ON learning_courses(status)`, `CREATE INDEX IF NOT EXISTS idx_learning_courses_category ON learning_courses(category)`, `CREATE TABLE IF NOT EXISTS learning_enrollments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, course_id UUID NOT NULL REFERENCES learning_courses(id) ON DELETE CASCADE, status VARCHAR(50) NOT NULL DEFAULT 'active', plan JSONB NOT NULL DEFAULT '{}', progress JSONB NOT NULL DEFAULT '{"completed_modules": [], "current_module": 0, "score": 0}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, course_id) )`, `CREATE INDEX IF NOT EXISTS idx_learning_enrollments_user ON learning_enrollments(user_id)`, `CREATE TABLE IF NOT EXISTS learning_tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), enrollment_id UUID NOT NULL REFERENCES learning_enrollments(id) ON DELETE CASCADE, module_index INT NOT NULL DEFAULT 0, title VARCHAR(500) NOT NULL, task_type VARCHAR(50) NOT NULL DEFAULT 'code', instructions_md TEXT NOT NULL, rubric JSONB NOT NULL DEFAULT '{}', sandbox_template JSONB NOT NULL DEFAULT '{}', verification_cmd TEXT, status VARCHAR(50) NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_learning_tasks_enrollment ON learning_tasks(enrollment_id)`, `CREATE TABLE IF NOT EXISTS learning_submissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task_id UUID NOT NULL REFERENCES learning_tasks(id) ON DELETE CASCADE, sandbox_session_id UUID, result JSONB NOT NULL DEFAULT '{}', score INT DEFAULT 0, max_score INT DEFAULT 100, feedback_md TEXT, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_learning_submissions_task ON learning_submissions(task_id)`, `CREATE TABLE IF NOT EXISTS learning_trend_candidates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), topic VARCHAR(500) NOT NULL, category VARCHAR(100) NOT NULL DEFAULT 'general', signals JSONB NOT NULL DEFAULT '{}', score FLOAT DEFAULT 0, fingerprint VARCHAR(128) UNIQUE, fail_count INT NOT NULL DEFAULT 0, last_error TEXT, last_failed_at TIMESTAMPTZ, picked_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_learning_trends_score ON learning_trend_candidates(score DESC)`, `CREATE INDEX IF NOT EXISTS idx_learning_trends_fail ON learning_trend_candidates(fail_count, last_failed_at)`, // Backward-compatible schema upgrades (older DBs) `ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS fail_count INT NOT NULL DEFAULT 0`, `ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS last_error TEXT`, `ALTER TABLE learning_trend_candidates ADD COLUMN IF NOT EXISTS last_failed_at TIMESTAMPTZ`, `CREATE TABLE IF NOT EXISTS sandbox_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, task_id UUID REFERENCES learning_tasks(id) ON DELETE SET NULL, opensandbox_id VARCHAR(255), status VARCHAR(50) NOT NULL DEFAULT 'creating', last_active_at TIMESTAMPTZ DEFAULT NOW(), metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_sandbox_sessions_user ON sandbox_sessions(user_id)`, `CREATE TABLE IF NOT EXISTS sandbox_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES sandbox_sessions(id) ON DELETE CASCADE, event_type VARCHAR(50) NOT NULL, payload JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_sandbox_events_session ON sandbox_events(session_id)`, } for _, m := range migrations { if _, err := r.db.db.ExecContext(ctx, m); err != nil { return fmt.Errorf("learning migration failed: %w", err) } } return nil } // --- Course types --- type LearningCourse struct { ID string `json:"id"` Slug string `json:"slug"` Title string `json:"title"` ShortDescription string `json:"shortDescription"` Category string `json:"category"` Tags []string `json:"tags"` Difficulty string `json:"difficulty"` DurationHours int `json:"durationHours"` BaseOutline json.RawMessage `json:"baseOutline"` Landing json.RawMessage `json:"landing"` CoverImage string `json:"coverImage,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` Status string `json:"status"` EnrolledCount int `json:"enrolledCount"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type LearningUserProfile struct { UserID string `json:"userId"` DisplayName string `json:"displayName"` Profile json.RawMessage `json:"profile"` ResumeFileID *string `json:"resumeFileId,omitempty"` ResumeExtractedText string `json:"resumeExtractedText,omitempty"` OnboardingCompleted bool `json:"onboardingCompleted"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type LearningEnrollment struct { ID string `json:"id"` UserID string `json:"userId"` CourseID string `json:"courseId"` Status string `json:"status"` Plan json.RawMessage `json:"plan"` Progress json.RawMessage `json:"progress"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Course *LearningCourse `json:"course,omitempty"` } type LearningTask struct { ID string `json:"id"` EnrollmentID string `json:"enrollmentId"` ModuleIndex int `json:"moduleIndex"` Title string `json:"title"` TaskType string `json:"taskType"` InstructionsMD string `json:"instructionsMd"` Rubric json.RawMessage `json:"rubric"` SandboxTemplate json.RawMessage `json:"sandboxTemplate"` VerificationCmd string `json:"verificationCmd,omitempty"` Status string `json:"status"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type LearningSubmission struct { ID string `json:"id"` TaskID string `json:"taskId"` SandboxSessionID *string `json:"sandboxSessionId,omitempty"` Result json.RawMessage `json:"result"` Score int `json:"score"` MaxScore int `json:"maxScore"` FeedbackMD string `json:"feedbackMd,omitempty"` CreatedAt time.Time `json:"createdAt"` } type LearningTrendCandidate struct { ID string `json:"id"` Topic string `json:"topic"` Category string `json:"category"` Signals json.RawMessage `json:"signals"` Score float64 `json:"score"` Fingerprint string `json:"fingerprint"` FailCount int `json:"failCount,omitempty"` LastError *string `json:"lastError,omitempty"` LastFailedAt *time.Time `json:"lastFailedAt,omitempty"` PickedAt *time.Time `json:"pickedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` } type SandboxSession struct { ID string `json:"id"` UserID string `json:"userId"` TaskID *string `json:"taskId,omitempty"` OpenSandboxID string `json:"opensandboxId,omitempty"` Status string `json:"status"` LastActiveAt time.Time `json:"lastActiveAt"` Metadata json.RawMessage `json:"metadata"` CreatedAt time.Time `json:"createdAt"` } // --- Courses --- func (r *LearningRepository) ListCourses(ctx context.Context, category, difficulty, search string, limit, offset int) ([]*LearningCourse, int, error) { where := "status = 'published'" args := make([]interface{}, 0) argIdx := 1 if category != "" { where += fmt.Sprintf(" AND category = $%d", argIdx) args = append(args, category) argIdx++ } if difficulty != "" { where += fmt.Sprintf(" AND difficulty = $%d", argIdx) args = append(args, difficulty) argIdx++ } if search != "" { where += fmt.Sprintf(" AND (title ILIKE $%d OR short_description ILIKE $%d)", argIdx, argIdx) args = append(args, "%"+search+"%") argIdx++ } countQuery := fmt.Sprintf("SELECT COUNT(*) FROM learning_courses WHERE %s", where) var total int if err := r.db.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, err } query := fmt.Sprintf(`SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours, base_outline, landing, cover_image, status, enrolled_count, created_at, updated_at FROM learning_courses WHERE %s ORDER BY enrolled_count DESC, created_at DESC LIMIT $%d OFFSET $%d`, where, argIdx, argIdx+1) args = append(args, limit, offset) rows, err := r.db.db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var courses []*LearningCourse for rows.Next() { c := &LearningCourse{} var tags []byte var coverImg sql.NullString if err := rows.Scan(&c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags, &c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, 0, err } if coverImg.Valid { c.CoverImage = coverImg.String } json.Unmarshal(tags, &c.Tags) courses = append(courses, c) } return courses, total, nil } func (r *LearningRepository) GetCourseBySlug(ctx context.Context, slug string) (*LearningCourse, error) { c := &LearningCourse{} var coverImg sql.NullString var tags []byte err := r.db.db.QueryRowContext(ctx, `SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours, base_outline, landing, cover_image, fingerprint, status, enrolled_count, created_at, updated_at FROM learning_courses WHERE slug = $1`, slug).Scan( &c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags, &c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg, &c.Fingerprint, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if coverImg.Valid { c.CoverImage = coverImg.String } json.Unmarshal(tags, &c.Tags) return c, nil } func (r *LearningRepository) GetCourseByID(ctx context.Context, id string) (*LearningCourse, error) { c := &LearningCourse{} var coverImg sql.NullString var tags []byte err := r.db.db.QueryRowContext(ctx, `SELECT id, slug, title, short_description, category, tags, difficulty, duration_hours, base_outline, landing, cover_image, fingerprint, status, enrolled_count, created_at, updated_at FROM learning_courses WHERE id = $1`, id).Scan( &c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &tags, &c.Difficulty, &c.DurationHours, &c.BaseOutline, &c.Landing, &coverImg, &c.Fingerprint, &c.Status, &c.EnrolledCount, &c.CreatedAt, &c.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if coverImg.Valid { c.CoverImage = coverImg.String } json.Unmarshal(tags, &c.Tags) return c, nil } func (r *LearningRepository) CreateCourse(ctx context.Context, c *LearningCourse) error { tagsJSON, _ := json.Marshal(c.Tags) return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_courses (slug, title, short_description, category, tags, difficulty, duration_hours, base_outline, landing, cover_image, fingerprint, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id, created_at, updated_at`, c.Slug, c.Title, c.ShortDescription, c.Category, string(tagsJSON), c.Difficulty, c.DurationHours, c.BaseOutline, c.Landing, sql.NullString{String: c.CoverImage, Valid: c.CoverImage != ""}, sql.NullString{String: c.Fingerprint, Valid: c.Fingerprint != ""}, c.Status, ).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt) } func (r *LearningRepository) UpdateCourseStatus(ctx context.Context, id, status string) error { _, err := r.db.db.ExecContext(ctx, "UPDATE learning_courses SET status=$2, updated_at=NOW() WHERE id=$1", id, status) return err } func (r *LearningRepository) FingerprintExists(ctx context.Context, fp string) (bool, error) { var exists bool err := r.db.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM learning_courses WHERE fingerprint=$1)", fp).Scan(&exists) return exists, err } // --- User profiles --- func (r *LearningRepository) GetProfile(ctx context.Context, userID string) (*LearningUserProfile, error) { p := &LearningUserProfile{} var resumeFileID sql.NullString var resumeText sql.NullString err := r.db.db.QueryRowContext(ctx, `SELECT user_id, display_name, profile, resume_file_id, resume_extracted_text, onboarding_completed, created_at, updated_at FROM learning_user_profiles WHERE user_id=$1`, userID).Scan( &p.UserID, &p.DisplayName, &p.Profile, &resumeFileID, &resumeText, &p.OnboardingCompleted, &p.CreatedAt, &p.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if resumeFileID.Valid { p.ResumeFileID = &resumeFileID.String } if resumeText.Valid { p.ResumeExtractedText = resumeText.String } return p, nil } func (r *LearningRepository) UpsertProfile(ctx context.Context, p *LearningUserProfile) error { _, err := r.db.db.ExecContext(ctx, `INSERT INTO learning_user_profiles (user_id, display_name, profile, resume_file_id, resume_extracted_text, onboarding_completed) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (user_id) DO UPDATE SET display_name=EXCLUDED.display_name, profile=EXCLUDED.profile, resume_file_id=EXCLUDED.resume_file_id, resume_extracted_text=EXCLUDED.resume_extracted_text, onboarding_completed=EXCLUDED.onboarding_completed, updated_at=NOW()`, p.UserID, p.DisplayName, p.Profile, sql.NullString{String: func() string { if p.ResumeFileID != nil { return *p.ResumeFileID }; return "" }(), Valid: p.ResumeFileID != nil}, sql.NullString{String: p.ResumeExtractedText, Valid: p.ResumeExtractedText != ""}, p.OnboardingCompleted) return err } // --- Enrollments --- func (r *LearningRepository) CreateEnrollment(ctx context.Context, e *LearningEnrollment) error { err := r.db.db.QueryRowContext(ctx, `INSERT INTO learning_enrollments (user_id, course_id, status, plan, progress) VALUES ($1,$2,$3,$4,$5) RETURNING id, created_at, updated_at`, e.UserID, e.CourseID, e.Status, e.Plan, e.Progress).Scan(&e.ID, &e.CreatedAt, &e.UpdatedAt) if err != nil { return err } r.db.db.ExecContext(ctx, "UPDATE learning_courses SET enrolled_count = enrolled_count + 1 WHERE id=$1", e.CourseID) return nil } func (r *LearningRepository) GetEnrollment(ctx context.Context, id string) (*LearningEnrollment, error) { e := &LearningEnrollment{} err := r.db.db.QueryRowContext(ctx, `SELECT id, user_id, course_id, status, plan, progress, created_at, updated_at FROM learning_enrollments WHERE id=$1`, id).Scan( &e.ID, &e.UserID, &e.CourseID, &e.Status, &e.Plan, &e.Progress, &e.CreatedAt, &e.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return e, nil } func (r *LearningRepository) ListEnrollments(ctx context.Context, userID string) ([]*LearningEnrollment, error) { rows, err := r.db.db.QueryContext(ctx, `SELECT e.id, e.user_id, e.course_id, e.status, e.plan, e.progress, e.created_at, e.updated_at, c.id, c.slug, c.title, c.short_description, c.category, c.difficulty, c.duration_hours, c.cover_image, c.status FROM learning_enrollments e JOIN learning_courses c ON e.course_id=c.id WHERE e.user_id=$1 ORDER BY e.updated_at DESC`, userID) if err != nil { return nil, err } defer rows.Close() var enrollments []*LearningEnrollment for rows.Next() { e := &LearningEnrollment{} c := &LearningCourse{} var coverImg sql.NullString if err := rows.Scan(&e.ID, &e.UserID, &e.CourseID, &e.Status, &e.Plan, &e.Progress, &e.CreatedAt, &e.UpdatedAt, &c.ID, &c.Slug, &c.Title, &c.ShortDescription, &c.Category, &c.Difficulty, &c.DurationHours, &coverImg, &c.Status); err != nil { return nil, err } if coverImg.Valid { c.CoverImage = coverImg.String } e.Course = c enrollments = append(enrollments, e) } return enrollments, nil } func (r *LearningRepository) UpdateEnrollmentProgress(ctx context.Context, id string, progress json.RawMessage) error { _, err := r.db.db.ExecContext(ctx, "UPDATE learning_enrollments SET progress=$2, updated_at=NOW() WHERE id=$1", id, progress) return err } func (r *LearningRepository) UpdateEnrollmentPlan(ctx context.Context, id string, plan json.RawMessage) error { _, err := r.db.db.ExecContext(ctx, "UPDATE learning_enrollments SET plan=$2, updated_at=NOW() WHERE id=$1", id, plan) return err } // --- Tasks --- func (r *LearningRepository) CreateTask(ctx context.Context, t *LearningTask) error { return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_tasks (enrollment_id, module_index, title, task_type, instructions_md, rubric, sandbox_template, verification_cmd, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, created_at, updated_at`, t.EnrollmentID, t.ModuleIndex, t.Title, t.TaskType, t.InstructionsMD, t.Rubric, t.SandboxTemplate, t.VerificationCmd, t.Status).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt) } func (r *LearningRepository) GetTask(ctx context.Context, id string) (*LearningTask, error) { t := &LearningTask{} err := r.db.db.QueryRowContext(ctx, `SELECT id, enrollment_id, module_index, title, task_type, instructions_md, rubric, sandbox_template, verification_cmd, status, created_at, updated_at FROM learning_tasks WHERE id=$1`, id).Scan( &t.ID, &t.EnrollmentID, &t.ModuleIndex, &t.Title, &t.TaskType, &t.InstructionsMD, &t.Rubric, &t.SandboxTemplate, &t.VerificationCmd, &t.Status, &t.CreatedAt, &t.UpdatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return t, nil } func (r *LearningRepository) ListTasksByEnrollment(ctx context.Context, enrollmentID string) ([]*LearningTask, error) { rows, err := r.db.db.QueryContext(ctx, `SELECT id, enrollment_id, module_index, title, task_type, instructions_md, rubric, sandbox_template, verification_cmd, status, created_at, updated_at FROM learning_tasks WHERE enrollment_id=$1 ORDER BY module_index, created_at`, enrollmentID) if err != nil { return nil, err } defer rows.Close() var tasks []*LearningTask for rows.Next() { t := &LearningTask{} if err := rows.Scan(&t.ID, &t.EnrollmentID, &t.ModuleIndex, &t.Title, &t.TaskType, &t.InstructionsMD, &t.Rubric, &t.SandboxTemplate, &t.VerificationCmd, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil { return nil, err } tasks = append(tasks, t) } return tasks, nil } func (r *LearningRepository) UpdateTaskStatus(ctx context.Context, id, status string) error { _, err := r.db.db.ExecContext(ctx, "UPDATE learning_tasks SET status=$2, updated_at=NOW() WHERE id=$1", id, status) return err } // --- Submissions --- func (r *LearningRepository) CreateSubmission(ctx context.Context, s *LearningSubmission) error { return r.db.db.QueryRowContext(ctx, `INSERT INTO learning_submissions (task_id, sandbox_session_id, result, score, max_score, feedback_md) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, created_at`, s.TaskID, sql.NullString{String: func() string { if s.SandboxSessionID != nil { return *s.SandboxSessionID }; return "" }(), Valid: s.SandboxSessionID != nil}, s.Result, s.Score, s.MaxScore, s.FeedbackMD).Scan(&s.ID, &s.CreatedAt) } func (r *LearningRepository) GetLatestSubmission(ctx context.Context, taskID string) (*LearningSubmission, error) { s := &LearningSubmission{} var sessID sql.NullString err := r.db.db.QueryRowContext(ctx, `SELECT id, task_id, sandbox_session_id, result, score, max_score, feedback_md, created_at FROM learning_submissions WHERE task_id=$1 ORDER BY created_at DESC LIMIT 1`, taskID).Scan( &s.ID, &s.TaskID, &sessID, &s.Result, &s.Score, &s.MaxScore, &s.FeedbackMD, &s.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if sessID.Valid { s.SandboxSessionID = &sessID.String } return s, nil } // --- Trends --- func (r *LearningRepository) CreateTrend(ctx context.Context, t *LearningTrendCandidate) error { err := r.db.db.QueryRowContext(ctx, `INSERT INTO learning_trend_candidates (topic, category, signals, score, fingerprint) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (fingerprint) DO NOTHING RETURNING id, created_at`, t.Topic, t.Category, t.Signals, t.Score, t.Fingerprint).Scan(&t.ID, &t.CreatedAt) if err == sql.ErrNoRows { return nil } return err } func (r *LearningRepository) PickTopTrend(ctx context.Context) (*LearningTrendCandidate, error) { t := &LearningTrendCandidate{} var lastErr sql.NullString var lastFailed sql.NullTime err := r.db.db.QueryRowContext(ctx, `UPDATE learning_trend_candidates SET picked_at=NOW() WHERE id = ( SELECT id FROM learning_trend_candidates WHERE picked_at IS NULL AND fail_count < 5 AND (last_failed_at IS NULL OR last_failed_at < NOW() - INTERVAL '15 minutes') ORDER BY score DESC, fail_count ASC, created_at ASC LIMIT 1 ) RETURNING id, topic, category, signals, score, fingerprint, fail_count, last_error, last_failed_at, created_at`).Scan( &t.ID, &t.Topic, &t.Category, &t.Signals, &t.Score, &t.Fingerprint, &t.FailCount, &lastErr, &lastFailed, &t.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if lastErr.Valid { t.LastError = &lastErr.String } if lastFailed.Valid { t.LastFailedAt = &lastFailed.Time } return t, nil } func (r *LearningRepository) MarkTrendFailed(ctx context.Context, id, errMsg string) error { _, err := r.db.db.ExecContext(ctx, `UPDATE learning_trend_candidates SET fail_count = fail_count + 1, last_error = $2, last_failed_at = NOW(), picked_at = NULL WHERE id = $1`, id, errMsg) return err } func (r *LearningRepository) SlugExists(ctx context.Context, slug string) (bool, error) { var exists bool err := r.db.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM learning_courses WHERE slug=$1)", slug).Scan(&exists) return exists, err } // --- Sandbox sessions --- func (r *LearningRepository) CreateSandboxSession(ctx context.Context, s *SandboxSession) error { return r.db.db.QueryRowContext(ctx, `INSERT INTO sandbox_sessions (user_id, task_id, opensandbox_id, status, metadata) VALUES ($1,$2,$3,$4,$5) RETURNING id, created_at`, s.UserID, sql.NullString{String: func() string { if s.TaskID != nil { return *s.TaskID }; return "" }(), Valid: s.TaskID != nil}, s.OpenSandboxID, s.Status, s.Metadata).Scan(&s.ID, &s.CreatedAt) } func (r *LearningRepository) GetSandboxSession(ctx context.Context, id string) (*SandboxSession, error) { s := &SandboxSession{} var taskID sql.NullString err := r.db.db.QueryRowContext(ctx, `SELECT id, user_id, task_id, opensandbox_id, status, last_active_at, metadata, created_at FROM sandbox_sessions WHERE id=$1`, id).Scan( &s.ID, &s.UserID, &taskID, &s.OpenSandboxID, &s.Status, &s.LastActiveAt, &s.Metadata, &s.CreatedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if taskID.Valid { s.TaskID = &taskID.String } return s, nil } func (r *LearningRepository) UpdateSandboxSessionStatus(ctx context.Context, id, status string) error { _, err := r.db.db.ExecContext(ctx, "UPDATE sandbox_sessions SET status=$2, last_active_at=NOW() WHERE id=$1", id, status) return err } func (r *LearningRepository) CreateSandboxEvent(ctx context.Context, sessionID, eventType string, payload json.RawMessage) error { _, err := r.db.db.ExecContext(ctx, `INSERT INTO sandbox_events (session_id, event_type, payload) VALUES ($1,$2,$3)`, sessionID, eventType, payload) return err }