diff --git a/cmd/server/main.go b/cmd/server/main.go index ea504ac..567afb5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -68,9 +68,12 @@ func main() { r.Patch("/tests/{id}", testH.Update) r.Delete("/tests/{id}", testH.Delete) - // Questions внутри теста + // Questions внутри теста. /reorder регистрируется ДО /{questionId}, + // иначе chi заматчит {questionId} = "reorder". r.Get("/tests/{id}/questions", testH.ListQuestions) r.Post("/tests/{id}/questions", testH.CreateQuestion) + r.Post("/tests/{id}/questions/reorder", testH.ReorderQuestions) + r.Put("/tests/{id}/questions/{questionId}", testH.UpdateQuestion) r.Delete("/tests/{id}/questions/{questionId}", testH.DeleteQuestion) // Attempts: старт + получение + сабмит. Списки — отдельно diff --git a/internal/handler/test.go b/internal/handler/test.go index 0699114..8bd1ca8 100644 --- a/internal/handler/test.go +++ b/internal/handler/test.go @@ -227,6 +227,112 @@ func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/internal/repository/test.go b/internal/repository/test.go index 766d2c8..9de1c9e 100644 --- a/internal/repository/test.go +++ b/internal/repository/test.go @@ -280,3 +280,91 @@ func (r *TestRepository) DeleteQuestion(ctx context.Context, questionID uuid.UUI } return nil } + +// UpdateQuestion — транзакционная замена вопроса: фиксированный question_id, +// обновлённые поля + полностью пересозданные ответы. Зачем не PATCH per-answer: +// +// 1. Конструктор в UI хранит локальный state, юзеру удобно жать «Сохранить» +// один раз; отдельные ADD/DELETE/UPDATE для каждого ответа = много сетевых +// round-trip'ов и сложнее undo'ить наполовину применённое; +// 2. question_id сохраняется → test_attempt_answers (FK CASCADE) остаются. +// Старые payload'ы хранят answer_id как строку в JSONB; при пересоздании +// ответов эти id могут больше не существовать, но correct/score уже +// зафиксированы в attempt_answers и для итоговой оценки это не критично. +// Если в будущем понадобится «показать что юзер выбрал по истории» — +// добавим snapshot текста ответа прямо в payload. +func (r *TestRepository) UpdateQuestion(ctx context.Context, questionID uuid.UUID, req model.CreateQuestionRequest) (*model.Question, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + tag, err := tx.Exec(ctx, ` + UPDATE test_questions + SET position = $2, kind = $3, text = $4, points = $5, explanation = $6 + WHERE id = $1`, + questionID, req.Position, req.Kind, req.Text, req.Points, req.Explanation) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrNotFound + } + // Пересоздаём ответы. CASCADE FK очистит test_answers; на пустых тестах + // (text-вопрос) это no-op. + if _, err := tx.Exec(ctx, `DELETE FROM test_answers WHERE question_id = $1`, questionID); err != nil { + return nil, err + } + for _, a := range req.Answers { + if _, err := tx.Exec(ctx, ` + INSERT INTO test_answers (question_id, position, text, is_correct) + VALUES ($1, $2, $3, $4)`, + questionID, a.Position, a.Text, a.IsCorrect); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + // Перечитаем через ListQuestions (один из элементов). + var testID uuid.UUID + if err := r.pool.QueryRow(ctx, `SELECT test_id FROM test_questions WHERE id = $1`, questionID).Scan(&testID); err != nil { + return nil, err + } + all, err := r.ListQuestions(ctx, testID) + if err != nil { + return nil, err + } + for _, q := range all { + if q.ID == questionID { + return &q, nil + } + } + return nil, ErrNotFound +} + +// ReorderQuestions — батч-апдейт position'ов одного теста. Идемпотентно; +// принимает массив (id, position) в одной транзакции через UNNEST. +// Используется фронтом при drag-and-drop вопросов или up/down кнопках. +func (r *TestRepository) ReorderQuestions(ctx context.Context, testID uuid.UUID, items []struct { + ID uuid.UUID + Position int +}) error { + if len(items) == 0 { + return nil + } + ids := make([]uuid.UUID, len(items)) + positions := make([]int, len(items)) + for i, it := range items { + ids[i] = it.ID + positions[i] = it.Position + } + _, err := r.pool.Exec(ctx, ` + UPDATE test_questions q + SET position = u.pos + FROM UNNEST($1::uuid[], $2::int[]) AS u(id, pos) + WHERE q.id = u.id AND q.test_id = $3`, + ids, positions, testID) + return err +}