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:
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user