feat(tests): UpdateQuestion (full-replace) + ReorderQuestions
All checks were successful
CI / test (push) Successful in 14s
Build and Deploy / build-and-deploy (push) Successful in 27s

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:
Ilya
2026-05-25 23:18:05 +03:00
parent 4f9b1b1491
commit 47a76bef7c
3 changed files with 198 additions and 1 deletions

View File

@@ -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
}