- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
643 lines
25 KiB
Go
643 lines
25 KiB
Go
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
|
|
}
|