Базовая работа с курсами (без уроков — добавятся в следующей итерации). 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>
208 lines
6.0 KiB
Go
208 lines
6.0 KiB
Go
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
|
||
}
|