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>
This commit is contained in:
@@ -63,6 +63,23 @@ func (h *LessonHandler) ListByCourse(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -38,6 +38,55 @@ func scanLesson(scan func(...any) error) (*model.Lesson, error) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user