feat(tests): UpdateQuestion (full-replace) + ReorderQuestions
UpdateQuestion (PUT /tests/{id}/questions/{questionId}):
- транзакционно: UPDATE test_questions SET ... + DELETE test_answers +
INSERT новых ответов. question_id стабилен — test_attempt_answers
(FK CASCADE) остаются. Снимок ответа в payload — для будущего
показа истории, в MVP не реализовано;
- семантика full-replace: проще на клиенте (один POST вместо
per-answer патчей), атомарно на сервере.
ReorderQuestions (POST /tests/{id}/questions/reorder):
- батч-апдейт position через UNNEST(uuid[], int[]) — один запрос
вместо N UPDATE'ов; идемпотентно;
- /reorder регистрируется ДО /{questionId} чтобы chi не заматчил
его как questionId="reorder".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -68,9 +68,12 @@ func main() {
|
|||||||
r.Patch("/tests/{id}", testH.Update)
|
r.Patch("/tests/{id}", testH.Update)
|
||||||
r.Delete("/tests/{id}", testH.Delete)
|
r.Delete("/tests/{id}", testH.Delete)
|
||||||
|
|
||||||
// Questions внутри теста
|
// Questions внутри теста. /reorder регистрируется ДО /{questionId},
|
||||||
|
// иначе chi заматчит {questionId} = "reorder".
|
||||||
r.Get("/tests/{id}/questions", testH.ListQuestions)
|
r.Get("/tests/{id}/questions", testH.ListQuestions)
|
||||||
r.Post("/tests/{id}/questions", testH.CreateQuestion)
|
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)
|
r.Delete("/tests/{id}/questions/{questionId}", testH.DeleteQuestion)
|
||||||
|
|
||||||
// Attempts: старт + получение + сабмит. Списки — отдельно
|
// Attempts: старт + получение + сабмит. Списки — отдельно
|
||||||
|
|||||||
@@ -227,6 +227,112 @@ func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, q)
|
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) {
|
func (h *TestHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, ok := userIDFromHeader(r)
|
uid, ok := userIDFromHeader(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -280,3 +280,91 @@ func (r *TestRepository) DeleteQuestion(ctx context.Context, questionID uuid.UUI
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user