feat(courses): CRUD курсов
All checks were successful
CI / test (push) Successful in 14s
Build and Deploy / build-and-deploy (push) Successful in 25s

Базовая работа с курсами (без уроков — добавятся в следующей итерации).

CourseRepository:
- List с тем же паттерном что TestRepository: ownerFilter +
  onlyPublished + visibleIDs (для будущего access_grants).
- Get / GetBySlug — slug нужен для public-страниц.
- Create — slugify(title) если slug не задан; collision retry до 5 раз
  (UNIQUE constraint courses_slug_key).
- Update / Delete с CASCADE на lessons.
- courseCols + lessons_count subquery, UI получает бейдж без отдельного
  запроса.

CourseHandler — стандартный набор. Гейтит owner для write/delete;
read доступен всем (внутри сервиса), portal проксирует под
service.learning.access.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 23:31:20 +03:00
parent 47a76bef7c
commit 350703ab83
3 changed files with 357 additions and 3 deletions

142
internal/handler/course.go Normal file
View File

@@ -0,0 +1,142 @@
package handler
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"learning-service/internal/model"
"learning-service/internal/repository"
)
type CourseHandler struct {
repo *repository.CourseRepository
}
func NewCourseHandler(repo *repository.CourseRepository) *CourseHandler {
return &CourseHandler{repo: repo}
}
// List — GET /courses?mine=true. Поведение зеркально TestHandler.List:
//
// mine=true → только мои (owner_user_id = X-User-Id);
// без mine → только опубликованные.
//
// В будущей итерации access_grants добавят visibleIDs-фильтр.
func (h *CourseHandler) List(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
mine := r.URL.Query().Get("mine") == "true"
var ownerFilter *uuid.UUID
if mine {
ownerFilter = &uid
}
items, err := h.repo.List(r.Context(), ownerFilter, !mine, nil)
if err != nil {
writeRepoError(w, r, err, "list courses")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (h *CourseHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
c, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get course")
return
}
writeJSON(w, http.StatusOK, c)
}
func (h *CourseHandler) Create(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req model.CreateCourseRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
c, err := h.repo.Create(r.Context(), uid, req)
if err != nil {
writeRepoError(w, r, err, "create course")
return
}
writeJSON(w, http.StatusCreated, c)
}
func (h *CourseHandler) Update(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get course for update")
return
}
if existing.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can edit")
return
}
var req model.UpdateCourseRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
c, err := h.repo.Update(r.Context(), id, req)
if err != nil {
writeRepoError(w, r, err, "update course")
return
}
writeJSON(w, http.StatusOK, c)
}
func (h *CourseHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get course for delete")
return
}
if existing.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can delete")
return
}
if err := h.repo.Delete(r.Context(), id); err != nil {
writeRepoError(w, r, err, "delete course")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,207 @@
package repository
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"learning-service/internal/model"
)
type CourseRepository struct {
pool *pgxpool.Pool
}
func NewCourseRepository(pool *pgxpool.Pool) *CourseRepository {
return &CourseRepository{pool: pool}
}
// courseCols + lessons_count correlated subquery — UI получает «N уроков»
// без отдельного запроса на каждый курс в списке.
const courseCols = `
c.id, c.title, c.description, c.slug, c.cover_image_key,
c.is_published, c.owner_user_id, c.created_at, c.updated_at,
(SELECT COUNT(*)::int FROM lessons l WHERE l.course_id = c.id) AS lessons_count
`
func scanCourse(scan func(...any) error) (*model.Course, error) {
var c model.Course
if err := scan(
&c.ID, &c.Title, &c.Description, &c.Slug, &c.CoverImageKey,
&c.IsPublished, &c.OwnerUserID, &c.CreatedAt, &c.UpdatedAt,
&c.LessonsCount,
); err != nil {
return nil, err
}
return &c, nil
}
// List — листинг курсов. Семантика та же что у TestRepository.List:
// ownerFilter — только мои; onlyPublished — только опубликованные;
// visibleIDs — фильтр по access_grants (resolve'ится в handler'е).
func (r *CourseRepository) List(ctx context.Context, ownerFilter *uuid.UUID, onlyPublished bool, visibleIDs []uuid.UUID) ([]model.Course, error) {
conds := []string{}
args := []any{}
push := func(cond string, val any) {
args = append(args, val)
conds = append(conds, strings.Replace(cond, "?", fmt.Sprintf("$%d", len(args)), 1))
}
if ownerFilter != nil {
push("c.owner_user_id = ?", *ownerFilter)
}
if onlyPublished {
conds = append(conds, "c.is_published = TRUE")
}
if visibleIDs != nil {
if len(visibleIDs) == 0 {
return []model.Course{}, nil
}
push("c.id = ANY(?)", visibleIDs)
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ")
}
q := fmt.Sprintf(`SELECT %s FROM courses c %s ORDER BY c.updated_at DESC`, courseCols, where)
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Course{}
for rows.Next() {
c, err := scanCourse(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
func (r *CourseRepository) Get(ctx context.Context, id uuid.UUID) (*model.Course, error) {
c, err := scanCourse(r.pool.QueryRow(ctx,
fmt.Sprintf(`SELECT %s FROM courses c WHERE c.id = $1`, courseCols), id).Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return c, nil
}
func (r *CourseRepository) GetBySlug(ctx context.Context, slug string) (*model.Course, error) {
c, err := scanCourse(r.pool.QueryRow(ctx,
fmt.Sprintf(`SELECT %s FROM courses c WHERE c.slug = $1`, courseCols), slug).Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return c, nil
}
// slugify — упрощённый ascii-slug: пробелы/непечатные → '-', нижний регистр,
// удалить лишние '-'. Cyrillic-safe: транслитерация не реализована, иначе
// нужна большая таблица; вместо этого если slug пустой после фильтра —
// генерируем 8-символьный uuid (вроде «c-1bbc7b53»).
var slugSafeRe = regexp.MustCompile(`[^a-z0-9-]+`)
var slugMultiDashRe = regexp.MustCompile(`-+`)
func slugify(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
s = strings.ReplaceAll(s, " ", "-")
s = slugSafeRe.ReplaceAllString(s, "")
s = slugMultiDashRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func (r *CourseRepository) Create(ctx context.Context, ownerID uuid.UUID, req model.CreateCourseRequest) (*model.Course, error) {
slug := slugify(req.Slug)
if slug == "" {
slug = slugify(req.Title)
}
if slug == "" {
slug = "c-" + uuid.NewString()[:8]
}
// UNIQUE slug на уровне БД — пробуем вставить, на конфликте суффикс.
var id uuid.UUID
for attempt := 0; attempt < 5; attempt++ {
err := r.pool.QueryRow(ctx, `
INSERT INTO courses (title, description, slug, owner_user_id)
VALUES ($1, $2, $3, $4) RETURNING id`,
req.Title, req.Description, slug, ownerID,
).Scan(&id)
if err == nil {
return r.Get(ctx, id)
}
if strings.Contains(err.Error(), "courses_slug_key") {
slug = slug + "-" + uuid.NewString()[:4]
continue
}
return nil, err
}
return nil, fmt.Errorf("failed to generate unique slug after 5 attempts")
}
func (r *CourseRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateCourseRequest) (*model.Course, 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.Title != nil {
add("title", *req.Title)
}
if req.Description != nil {
add("description", *req.Description)
}
if req.Slug != nil {
s := slugify(*req.Slug)
if s == "" {
return nil, fmt.Errorf("slug becomes empty after normalization")
}
add("slug", s)
}
if req.CoverImageKey != nil {
add("cover_image_key", *req.CoverImageKey)
}
if req.IsPublished != nil {
add("is_published", *req.IsPublished)
}
if len(sets) == 0 {
return r.Get(ctx, id)
}
sets = append(sets, "updated_at = NOW()")
args = append(args, id)
q := fmt.Sprintf(`UPDATE courses 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)
}
func (r *CourseRepository) Delete(ctx context.Context, id uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM courses WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}