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 TestHandler struct { repo *repository.TestRepository } func NewTestHandler(repo *repository.TestRepository) *TestHandler { return &TestHandler{repo: repo} } // List — GET /tests. Параметры: // ?mine=true — только мои (owner_user_id = X-User-Id); // без mine — published тесты (для прохождения). // // MVP: ещё не подключён access_grants-фильтр; до этого момента «published» // = «всем видно». Следующая итерация: handler через AccessRepository // получит visibleIDs и передаст в repo.List. func (h *TestHandler) 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 } tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil) if err != nil { writeRepoError(w, r, err, "list tests") return } writeJSON(w, http.StatusOK, map[string]any{"items": tests}) } func (h *TestHandler) Get(w http.ResponseWriter, r *http.Request) { id, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } t, err := h.repo.Get(r.Context(), id) if err != nil { writeRepoError(w, r, err, "get test") return } writeJSON(w, http.StatusOK, t) } func (h *TestHandler) Create(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } var req model.CreateTestRequest 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 } t, err := h.repo.Create(r.Context(), uid, req) if err != nil { writeRepoError(w, r, err, "create test") return } writeJSON(w, http.StatusCreated, t) } // Update / Delete: пока без access-проверки (только owner). Когда подключим // access_grants с can_manage, разрешим co-owner'ам тоже редактировать. func (h *TestHandler) 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 test for update") return } if existing.OwnerUserID != uid { writeError(w, http.StatusForbidden, "only owner can edit") return } var req model.UpdateTestRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } t, err := h.repo.Update(r.Context(), id, req) if err != nil { writeRepoError(w, r, err, "update test") return } writeJSON(w, http.StatusOK, t) } func (h *TestHandler) 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 test 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 test") return } w.WriteHeader(http.StatusNoContent) } // ListQuestions — вопросы теста с ответами. Если запросивший не владелец — // is_correct поле обнуляется в ответе (показывать правильные ответы до // сабмита нельзя — иначе тест теряет смысл). func (h *TestHandler) ListQuestions(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } testID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } t, err := h.repo.Get(r.Context(), testID) if err != nil { writeRepoError(w, r, err, "get test") return } qs, err := h.repo.ListQuestions(r.Context(), testID) if err != nil { writeRepoError(w, r, err, "list questions") return } // Не-владельцу скрываем is_correct, чтобы он не подсмотрел через // DevTools правильные ответы до сабмита. if t.OwnerUserID != uid { for i := range qs { for j := range qs[i].Answers { qs[i].Answers[j].IsCorrect = false } // Объяснение тоже не светим — оно потенциально содержит // разбор правильного ответа. qs[i].Explanation = "" } } writeJSON(w, http.StatusOK, map[string]any{"items": qs}) } func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } testID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } t, err := h.repo.Get(r.Context(), testID) if err != nil { writeRepoError(w, r, err, "get test") return } if t.OwnerUserID != uid { writeError(w, http.StatusForbidden, "only owner can add questions") return } var req model.CreateQuestionRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } if strings.TrimSpace(req.Text) == "" { writeError(w, http.StatusBadRequest, "text is required") return } if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" { writeError(w, http.StatusBadRequest, "kind must be single|multi|text") return } if req.Points <= 0 { req.Points = 1 } q, err := h.repo.CreateQuestion(r.Context(), testID, req) if err != nil { writeRepoError(w, r, err, "create question") return } writeJSON(w, http.StatusCreated, q) } // UpdateQuestion — PUT /tests/{id}/questions/{questionId}. Full-replace // семантика (см. repository.UpdateQuestion): метаданные обновляются, // ответы пересоздаются. question_id стабилен. func (h *TestHandler) UpdateQuestion(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } testID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid test id") return } qID, err := parseUUID(chi.URLParam(r, "questionId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid question id") return } t, err := h.repo.Get(r.Context(), testID) if err != nil { writeRepoError(w, r, err, "get test") return } if t.OwnerUserID != uid { writeError(w, http.StatusForbidden, "only owner can edit questions") return } var req model.CreateQuestionRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } if strings.TrimSpace(req.Text) == "" { writeError(w, http.StatusBadRequest, "text is required") return } if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" { writeError(w, http.StatusBadRequest, "kind must be single|multi|text") return } if req.Points <= 0 { req.Points = 1 } q, err := h.repo.UpdateQuestion(r.Context(), qID, req) if err != nil { writeRepoError(w, r, err, "update question") return } writeJSON(w, http.StatusOK, q) } // ReorderQuestions — POST /tests/{id}/questions/reorder. Body: // {"items": [{"id": "uuid", "position": 0}, ...]} func (h *TestHandler) ReorderQuestions(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } testID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid test id") return } t, err := h.repo.Get(r.Context(), testID) if err != nil { writeRepoError(w, r, err, "get test") return } if t.OwnerUserID != uid { writeError(w, http.StatusForbidden, "only owner can reorder") return } var req struct { Items []struct { ID string `json:"id"` Position int `json:"position"` } `json:"items"` } if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid body") return } items := make([]struct { ID uuid.UUID Position int }, 0, len(req.Items)) for _, it := range req.Items { id, err := parseUUID(it.ID) if err != nil { writeError(w, http.StatusBadRequest, "invalid item id") return } items = append(items, struct { ID uuid.UUID Position int }{ID: id, Position: it.Position}) } if err := h.repo.ReorderQuestions(r.Context(), testID, items); err != nil { writeRepoError(w, r, err, "reorder questions") return } w.WriteHeader(http.StatusNoContent) } func (h *TestHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFromHeader(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } testID, err := parseUUID(chi.URLParam(r, "id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } qID, err := parseUUID(chi.URLParam(r, "questionId")) if err != nil { writeError(w, http.StatusBadRequest, "invalid question id") return } t, err := h.repo.Get(r.Context(), testID) if err != nil { writeRepoError(w, r, err, "get test") return } if t.OwnerUserID != uid { writeError(w, http.StatusForbidden, "only owner can delete questions") return } if err := h.repo.DeleteQuestion(r.Context(), qID); err != nil { writeRepoError(w, r, err, "delete question") return } w.WriteHeader(http.StatusNoContent) }