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>
This commit is contained in:
@@ -45,10 +45,12 @@ func main() {
|
|||||||
|
|
||||||
testRepo := repository.NewTestRepository(pool)
|
testRepo := repository.NewTestRepository(pool)
|
||||||
attemptRepo := repository.NewAttemptRepository(pool)
|
attemptRepo := repository.NewAttemptRepository(pool)
|
||||||
|
courseRepo := repository.NewCourseRepository(pool)
|
||||||
|
|
||||||
healthH := handler.NewHealthHandler(pool)
|
healthH := handler.NewHealthHandler(pool)
|
||||||
testH := handler.NewTestHandler(testRepo)
|
testH := handler.NewTestHandler(testRepo)
|
||||||
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
|
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
|
||||||
|
courseH := handler.NewCourseHandler(courseRepo)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
@@ -84,9 +86,12 @@ func main() {
|
|||||||
r.Get("/attempts/{id}", attemptH.Get)
|
r.Get("/attempts/{id}", attemptH.Get)
|
||||||
r.Post("/attempts/{id}/submit", attemptH.Submit)
|
r.Post("/attempts/{id}/submit", attemptH.Submit)
|
||||||
|
|
||||||
// Заглушки на следующие итерации.
|
// Courses CRUD. Lessons/video — следующая итерация.
|
||||||
r.HandleFunc("/courses", notImplemented)
|
r.Get("/courses", courseH.List)
|
||||||
r.HandleFunc("/courses/{id}", notImplemented)
|
r.Post("/courses", courseH.Create)
|
||||||
|
r.Get("/courses/{id}", courseH.Get)
|
||||||
|
r.Patch("/courses/{id}", courseH.Update)
|
||||||
|
r.Delete("/courses/{id}", courseH.Delete)
|
||||||
r.HandleFunc("/courses/{id}/lessons", notImplemented)
|
r.HandleFunc("/courses/{id}/lessons", notImplemented)
|
||||||
r.HandleFunc("/lessons/{id}", notImplemented)
|
r.HandleFunc("/lessons/{id}", notImplemented)
|
||||||
r.HandleFunc("/lessons/{id}/video", notImplemented)
|
r.HandleFunc("/lessons/{id}/video", notImplemented)
|
||||||
|
|||||||
142
internal/handler/course.go
Normal file
142
internal/handler/course.go
Normal 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)
|
||||||
|
}
|
||||||
207
internal/repository/course.go
Normal file
207
internal/repository/course.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user