package repository import ( "context" "errors" "fmt" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "learning-service/internal/model" ) type LessonRepository struct { pool *pgxpool.Pool } func NewLessonRepository(pool *pgxpool.Pool) *LessonRepository { return &LessonRepository{pool: pool} } const lessonCols = ` id, course_id, position, title, markdown, video_key, video_duration_sec, test_id, is_required, created_at, updated_at ` func scanLesson(scan func(...any) error) (*model.Lesson, error) { var l model.Lesson if err := scan( &l.ID, &l.CourseID, &l.Position, &l.Title, &l.Markdown, &l.VideoKey, &l.VideoDurationSec, &l.TestID, &l.IsRequired, &l.CreatedAt, &l.UpdatedAt, ); err != nil { return nil, err } return &l, nil } func (r *LessonRepository) ListByCourse(ctx context.Context, courseID uuid.UUID) ([]model.Lesson, error) { rows, err := r.pool.Query(ctx, `SELECT `+lessonCols+` FROM lessons WHERE course_id = $1 ORDER BY position, created_at`, courseID) if err != nil { return nil, err } defer rows.Close() out := []model.Lesson{} for rows.Next() { l, err := scanLesson(rows.Scan) if err != nil { return nil, err } out = append(out, *l) } return out, rows.Err() } func (r *LessonRepository) Get(ctx context.Context, id uuid.UUID) (*model.Lesson, error) { l, err := scanLesson(r.pool.QueryRow(ctx, `SELECT `+lessonCols+` FROM lessons WHERE id = $1`, id).Scan) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } return l, nil } func (r *LessonRepository) Create(ctx context.Context, courseID uuid.UUID, req model.CreateLessonRequest) (*model.Lesson, error) { isRequired := true if req.IsRequired != nil { isRequired = *req.IsRequired } if strings.TrimSpace(req.Title) == "" { return nil, fmt.Errorf("title is required") } var id uuid.UUID err := r.pool.QueryRow(ctx, ` INSERT INTO lessons (course_id, position, title, markdown, test_id, is_required) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, courseID, req.Position, req.Title, req.Markdown, req.TestID, isRequired, ).Scan(&id) if err != nil { return nil, err } return r.Get(ctx, id) } func (r *LessonRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateLessonRequest) (*model.Lesson, error) { sets := []string{} args := []any{} add := func(col string, val any) { args = append(args, val) sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) } if req.Position != nil { add("position", *req.Position) } if req.Title != nil { add("title", *req.Title) } if req.Markdown != nil { add("markdown", *req.Markdown) } if req.VideoKey != nil { add("video_key", *req.VideoKey) } if req.VideoDurationSec != nil { add("video_duration_sec", *req.VideoDurationSec) } if req.TestID != nil { // req.TestID == &uuid.Nil → сбросить связь. Стандартное поведение // «явный null» делается отдельным эндпоинтом — здесь Update только // устанавливает не-nil значение. add("test_id", *req.TestID) } if req.IsRequired != nil { add("is_required", *req.IsRequired) } if len(sets) == 0 { return r.Get(ctx, id) } sets = append(sets, "updated_at = NOW()") args = append(args, id) q := fmt.Sprintf(`UPDATE lessons SET %s WHERE id = $%d`, strings.Join(sets, ", "), len(args)) tag, err := r.pool.Exec(ctx, q, args...) if err != nil { return nil, err } if tag.RowsAffected() == 0 { return nil, ErrNotFound } return r.Get(ctx, id) } // SetVideo — отдельный helper для post-upload UPDATE: ставит video_key // и duration после успешного PutObject в MinIO. Возвращает старый key // (если был) — handler удалит его из MinIO best-effort'ом, чтобы не // плодить «висящие» объекты при замене видео. func (r *LessonRepository) SetVideo(ctx context.Context, id uuid.UUID, key string, durationSec int) (oldKey string, err error) { err = r.pool.QueryRow(ctx, ` UPDATE lessons SET video_key = $2, video_duration_sec = $3, updated_at = NOW() WHERE id = $1 RETURNING (SELECT video_key FROM lessons WHERE id = $1)`, id, key, durationSec).Scan(&oldKey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } return "", err } return oldKey, nil } func (r *LessonRepository) Delete(ctx context.Context, id uuid.UUID) (videoKey string, err error) { // Возвращаем video_key чтобы handler мог удалить объект из MinIO. err = r.pool.QueryRow(ctx, ` DELETE FROM lessons WHERE id = $1 RETURNING video_key`, id).Scan(&videoKey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } return "", err } return videoKey, nil } // ReorderInCourse — батч-апдейт position'ов одного курса через UNNEST. // Тот же паттерн что у TestRepository.ReorderQuestions. func (r *LessonRepository) ReorderInCourse(ctx context.Context, courseID uuid.UUID, items []struct { ID uuid.UUID Position int }) error { if len(items) == 0 { return nil } ids := make([]uuid.UUID, len(items)) positions := make([]int, len(items)) for i, it := range items { ids[i] = it.ID positions[i] = it.Position } _, err := r.pool.Exec(ctx, ` UPDATE lessons l SET position = u.pos, updated_at = NOW() FROM UNNEST($1::uuid[], $2::int[]) AS u(id, pos) WHERE l.id = u.id AND l.course_id = $3`, ids, positions, courseID) return err }