From 400df0124d6c9c280da500a48f656c94b489bdb8 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 26 May 2026 00:17:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(lessons):=20ListVideos=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=81=D0=BA=D0=B8=D0=B9=20endpoint=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=92=D0=B8=D0=B4=D0=B5=D0=BE-=D1=83=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/server/main.go | 4 +++ internal/handler/lesson.go | 17 ++++++++++++ internal/repository/lesson.go | 49 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1387e62..c086ce4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -123,6 +123,10 @@ func main() { r.Get("/courses/{courseId}/lessons", lessonH.ListByCourse) r.Post("/courses/{courseId}/lessons", lessonH.Create) r.Post("/courses/{courseId}/lessons/reorder", lessonH.Reorder) + // Flat-список уроков с видео для отдельной страницы «Видео-уроки». + // has_video=true игнорируется — у нас только этот режим; флаг + // зарезервирован для будущего фильтра «и без видео тоже». + r.Get("/lessons", lessonH.ListVideos) r.Get("/lessons/{id}", lessonH.Get) r.Patch("/lessons/{id}", lessonH.Update) r.Delete("/lessons/{id}", lessonH.Delete) diff --git a/internal/handler/lesson.go b/internal/handler/lesson.go index 60549ac..8c80457 100644 --- a/internal/handler/lesson.go +++ b/internal/handler/lesson.go @@ -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 { diff --git a/internal/repository/lesson.go b/internal/repository/lesson.go index 669ba00..23031b7 100644 --- a/internal/repository/lesson.go +++ b/internal/repository/lesson.go @@ -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