Files
learning/internal/repository/course.go
Ilya 350703ab83
All checks were successful
CI / test (push) Successful in 14s
Build and Deploy / build-and-deploy (push) Successful in 25s
feat(courses): CRUD курсов
Базовая работа с курсами (без уроков — добавятся в следующей итерации).

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>
2026-05-25 23:31:20 +03:00

208 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}