From 350703ab83266f65b5e7f156d2c4a51fe345b365 Mon Sep 17 00:00:00 2001 From: Ilya Date: Mon, 25 May 2026 23:31:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(courses):=20CRUD=20=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Базовая работа с курсами (без уроков — добавятся в следующей итерации). 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 --- cmd/server/main.go | 11 +- internal/handler/course.go | 142 +++++++++++++++++++++++ internal/repository/course.go | 207 ++++++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 internal/handler/course.go create mode 100644 internal/repository/course.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 567afb5..c90172a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -45,10 +45,12 @@ func main() { testRepo := repository.NewTestRepository(pool) attemptRepo := repository.NewAttemptRepository(pool) + courseRepo := repository.NewCourseRepository(pool) healthH := handler.NewHealthHandler(pool) testH := handler.NewTestHandler(testRepo) attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) + courseH := handler.NewCourseHandler(courseRepo) r := chi.NewRouter() r.Use(chimw.RequestID) @@ -84,9 +86,12 @@ func main() { r.Get("/attempts/{id}", attemptH.Get) r.Post("/attempts/{id}/submit", attemptH.Submit) - // Заглушки на следующие итерации. - r.HandleFunc("/courses", notImplemented) - r.HandleFunc("/courses/{id}", notImplemented) + // Courses CRUD. Lessons/video — следующая итерация. + r.Get("/courses", courseH.List) + 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("/lessons/{id}", notImplemented) r.HandleFunc("/lessons/{id}/video", notImplemented) diff --git a/internal/handler/course.go b/internal/handler/course.go new file mode 100644 index 0000000..5d08261 --- /dev/null +++ b/internal/handler/course.go @@ -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) +} diff --git a/internal/repository/course.go b/internal/repository/course.go new file mode 100644 index 0000000..cf036b7 --- /dev/null +++ b/internal/repository/course.go @@ -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 +}