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>
247 lines
7.6 KiB
Go
247 lines
7.6 KiB
Go
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
|
||
}
|