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 }