Files
learning/internal/repository/lesson.go
Ilya 400df0124d
All checks were successful
CI / test (push) Successful in 19s
Build and Deploy / build-and-deploy (push) Successful in 26s
feat(lessons): ListVideos — плоский endpoint для раздела «Видео-уроки»
LessonRepository.ListVideos: SELECT с INNER JOIN courses, фильтр
video_key != '' + (course.is_published OR course.owner_user_id = viewer).
Возвращает LessonWithCourse — урок + denorm course_{title,slug,
is_published,owner_user_id} чтобы фронт сгруппировал по курсу
без N+1.

LessonHandler.ListVideos: GET /lessons?has_video=true. Гейт уже на
SQL-уровне, в коде только X-User-Id из headers.

Route регистрируется ДО /lessons/{id}, иначе chi бы заматчил
{id}="lessons".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:17:34 +03:00

247 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// LessonWithCourse — урок + минимальные данные курса для плоского
// раздела «Видео-уроки». Не вытаскиваем весь Course (нам нужны только
// id/title/is_published для UI-рендера), это экономит транзит.
type LessonWithCourse struct {
model.Lesson
CourseTitle string `json:"course_title"`
CourseSlug string `json:"course_slug"`
CourseIsPublished bool `json:"course_is_published"`
CourseOwnerUserID string `json:"course_owner_user_id"`
}
// ListVideos — все уроки с непустым video_key, доступные данному юзеру:
// либо курс опубликован, либо его автор == viewerID. JOIN courses для
// фильтра + denorm. Сортировка свежие сверху по updated_at.
//
// MVP: без access_grants. Когда добавим — сюда же visibleCourseIDs-фильтр.
func (r *LessonRepository) ListVideos(ctx context.Context, viewerID uuid.UUID) ([]LessonWithCourse, error) {
rows, err := r.pool.Query(ctx, `
SELECT
l.id, l.course_id, l.position, l.title, l.markdown, l.video_key,
l.video_duration_sec, l.test_id, l.is_required,
l.created_at, l.updated_at,
c.title, c.slug, c.is_published, c.owner_user_id
FROM lessons l
INNER JOIN courses c ON c.id = l.course_id
WHERE l.video_key <> ''
AND (c.is_published = TRUE OR c.owner_user_id = $1)
ORDER BY l.updated_at DESC
LIMIT 500`, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []LessonWithCourse{}
for rows.Next() {
var lwc LessonWithCourse
if err := rows.Scan(
&lwc.ID, &lwc.CourseID, &lwc.Position, &lwc.Title, &lwc.Markdown,
&lwc.VideoKey, &lwc.VideoDurationSec, &lwc.TestID, &lwc.IsRequired,
&lwc.CreatedAt, &lwc.UpdatedAt,
&lwc.CourseTitle, &lwc.CourseSlug, &lwc.CourseIsPublished, &lwc.CourseOwnerUserID,
); err != nil {
return nil, err
}
out = append(out, lwc)
}
return out, rows.Err()
}
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
}