feat(lessons): ListVideos — плоский endpoint для раздела «Видео-уроки»
All checks were successful
CI / test (push) Successful in 19s
Build and Deploy / build-and-deploy (push) Successful in 26s

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>
This commit is contained in:
Ilya
2026-05-26 00:17:34 +03:00
parent 80c019b791
commit 400df0124d
3 changed files with 70 additions and 0 deletions

View File

@@ -123,6 +123,10 @@ func main() {
r.Get("/courses/{courseId}/lessons", lessonH.ListByCourse) r.Get("/courses/{courseId}/lessons", lessonH.ListByCourse)
r.Post("/courses/{courseId}/lessons", lessonH.Create) r.Post("/courses/{courseId}/lessons", lessonH.Create)
r.Post("/courses/{courseId}/lessons/reorder", lessonH.Reorder) r.Post("/courses/{courseId}/lessons/reorder", lessonH.Reorder)
// Flat-список уроков с видео для отдельной страницы «Видео-уроки».
// has_video=true игнорируется — у нас только этот режим; флаг
// зарезервирован для будущего фильтра «и без видео тоже».
r.Get("/lessons", lessonH.ListVideos)
r.Get("/lessons/{id}", lessonH.Get) r.Get("/lessons/{id}", lessonH.Get)
r.Patch("/lessons/{id}", lessonH.Update) r.Patch("/lessons/{id}", lessonH.Update)
r.Delete("/lessons/{id}", lessonH.Delete) r.Delete("/lessons/{id}", lessonH.Delete)

View File

@@ -63,6 +63,23 @@ func (h *LessonHandler) ListByCourse(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
} }
// ListVideos — GET /lessons?has_video=true. Плоский список уроков с
// видео для отдельной страницы «Видео-уроки» в портале. Гейтит на уровне
// репо: только опубликованные курсы или мои.
func (h *LessonHandler) ListVideos(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
items, err := h.repo.ListVideos(r.Context(), uid)
if err != nil {
writeRepoError(w, r, err, "list video lessons")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (h *LessonHandler) Get(w http.ResponseWriter, r *http.Request) { func (h *LessonHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id")) id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil { if err != nil {

View File

@@ -38,6 +38,55 @@ func scanLesson(scan func(...any) error) (*model.Lesson, error) {
return &l, nil 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) { func (r *LessonRepository) ListByCourse(ctx context.Context, courseID uuid.UUID) ([]model.Lesson, error) {
rows, err := r.pool.Query(ctx, rows, err := r.pool.Query(ctx,
`SELECT `+lessonCols+` FROM lessons `SELECT `+lessonCols+` FROM lessons