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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user