Files
jellybit/openspec/changes/archive/2026-08-10-bulk-delete-page/tasks.md
T
av 288be8ec34 web-ui: добавлена страница группового удаления загрузок
- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
2026-08-10 17:43:36 +03:00

152 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 1. Единая точка допуска
- [x] 1.1 Завести `(store.State).CanDelete()` рядом с `IsTerminal`/
`IsObservable`, перечень `done`/`orphaned`/`target_missing` — одним списком
- [x] 1.2 Перевести `switch` в `worker.Delete` на `CanDelete` (проверку в ядре
не снимать)
- [x] 1.3 Перевести сборку `Deletable` в `internal/httpapi/download.go` на
`CanDelete`
- [x] 1.4 Перевести выбор клавиатуры в `internal/tgbot/render.go` на
`CanDelete` — иначе Telegram остаётся четвёртым перечнем состояний
- [x] 1.5 Тест: `CanDelete` истинно ровно для трёх состояний и ложно для
остальных (перебор всех состояний)
- [x] 1.6 Тест `internal/tgbot`: клавиатура с действием удаления приходит ровно
для тех состояний, где `CanDelete` истинно
## 2. Чтение списка разрешённых к удалению
- [x] 2.1 Добавить в `Reader` метод выборки загрузок, разрешённых к удалению,
без пагинации; порядок — как в основном списке
- [x] 2.2 Реализовать выборку в `store` через существующую механику фильтра по
состояниям (перечень состояний берётся из единой точки, второго списка не
заводить)
- [x] 2.3 Тест `store`: выборка возвращает только `done`/`orphaned`/
`target_missing`
## 3. Страница выбора
- [x] 3.1 Шаблон страницы `/delete`: строки с чекбоксами, заголовок, состояние
каждой загрузки, кнопка «Удалить выбранные», названный предел размера пачки
рядом с кнопкой, пустое состояние
- [x] 3.2 Ссылка на страницу в `web/templates/partials/header.html`, активный
пункт навигации
- [x] 3.3 Обработчик `GET /delete`: сборка представления, без `hx-*`
самообновления
- [x] 3.4 Тест: подставной читатель отдаёт задачи во всех состояниях — в
разметке есть строки только у `done`/`orphaned`/`target_missing`
- [x] 3.5 Тест: в разметке страницы нет `hx-trigger="every`
- [x] 3.6 Тест: страница называет предел размера пачки
- [x] 3.7 Тест: пустое состояние — нет разрешённых, удаление не предлагается
## 4. Разбор входа (общий для обеих границ)
- [x] 4.1 Общая функция разбора пачки: `ident.Parse` по каждому идентификатору
с отказом всего запроса, схлопывание дублей, проверка предела, отказ на пустом
наборе. Предел — именованная константа, значение **20**
- [x] 4.2 Обработчик `POST /ui/delete/confirm`: разбор через общую функцию,
чтение выбранных загрузок; ненайденная запись — строка «загрузка не найдена»,
а не молчаливый пропуск
- [x] 4.3 Отказ по пределу возвращает страницу выбора с сохранёнными отметками
и объяснением
- [x] 4.4 Шаблон страницы подтверждения: каждая выбранная загрузка названа
заголовком, идентификатором и состоянием; для `orphaned` — отметка «источник
пропал, библиотечная ссылка — последняя копия данных»; скрытые поля с
идентификаторами, признак подтверждения, кнопка исполнения и ссылка возврата
- [x] 4.5 Тест: страница подтверждения содержит заголовки всех выбранных и не
делает ни одного вызова `Delete`
- [x] 4.6 Тест: строка `orphaned` на подтверждении несёт отметку о последней
копии
- [x] 4.7 Тест: пачка сверх предела отклоняется целиком, `Delete` не зовётся, а
ответ несёт страницу выбора с сохранёнными отметками
- [x] 4.8 Тест: неразобранный идентификатор отклоняет запрос целиком
- [x] 4.9 Тест: пустой выбор — подтверждение не показывается, `Delete` не
зовётся
- [x] 4.10 Тест: идентификатор без записи назван строкой на подтверждении и в
отчёте, остальные загрузки удалены
## 5. Исполнение и отчёт
- [x] 5.1 Обработчик `POST /ui/delete`: отказ без признака подтверждения **до**
цикла, ни одного вызова `Delete`
- [x] 5.2 Тот же разбор входа, что и на подтверждении (пункт 4.1): исполняющий
запрос — самостоятельная входная граница
- [x] 5.3 Последовательный вызов `Reviewer.Delete` по каждой подтверждённой
загрузке, сбор исхода по каждой; контекст исполнения отвязан от `r.Context()`
(`context.WithoutCancel`); ошибка — через публичный канал (`userErr`)
- [x] 5.3a Потолок времени на проход (`bulkBudget`, 2 минуты), проверяемый
между единицами: начатое удаление не обрывается. Остаток — в отчёт строками
«не выполнено»
- [x] 5.4 Остановка прохода после **трёх подряд** отказов внешнего сервиса
(`ErrConflict` системным не считается и счётчик не двигает; успех счётчик
сбрасывает); остаток пачки — в отчёт строками «не выполнено» с причиной
остановки. Порог — именованная константа
- [x] 5.5 Шаблон страницы результата: удалённые поимённо, отказавшие поимённо с
причиной, невыполненный остаток и причина остановки, ссылка обратно на
`/delete`
- [x] 5.6 Тест: POST без признака подтверждения — отказ, ноль вызовов `Delete`
- [x] 5.7 Тест: гарды исполняющего запроса не слабее гардов подтверждения —
неразобранный идентификатор, пачка сверх предела и пустой набор отклоняются с
признаком подтверждения тоже, `Delete` не зовётся
- [x] 5.8 Тест: второй `Delete` возвращает ошибку — первая и третья удалены,
страница результата называет вторую и её причину
- [x] 5.9 Тест: `Delete` для `downloading` возвращает `ErrConflict` — страница
показывает отказ по ней, остальные выбранные удалены
- [x] 5.10 Тест: все удалены — отчёт называет обе как удалённые, отказов нет
- [x] 5.11 Тест: отмена запроса не прекращает пачку — `Delete` вызван по всем
подтверждённым
- [x] 5.12 Тест: сырой текст ошибки внешнего сервиса в разметку не попадает
- [x] 5.13 Тест: страница и действия работают без htmx (обычные POST-формы,
`action` рабочий)
## 6. Сдача
- [x] 6.1 `openspec validate --strict bulk-delete-page`
- [x] 6.2 `task gate` зелёный
- [x] 6.3 Поведенческая проверка вживую: поднять `task run`, пройти путь
список → подтверждение → результат
- [x] 6.4 Три числовых порога (размер пачки, число подряд идущих отказов,
потолок времени на проход) — в `docs/database.md`, раздел настроек с
числовым значением
## Критерии приёмки (из постановки)
- [x] П1 Страница открывается из шапки и показывает только те загрузки, для
которых удаление разрешено поштучно (оракул: тест `internal/httpapi`
подставной читатель отдаёт задачи во всех состояниях, в разметке строки есть
у `done`/`orphaned`/`target_missing` и нет у остальных)
- [x] П2 Удаление уходит только после явного подтверждения, и подтверждение
называет каждую выбранную раздачу поимённо (оракул: тест — POST без признака
подтверждения отвечает отказом и не делает ни одного вызова `Delete` у
подставного воркера; ответ подтверждения содержит заголовки всех выбранных)
- [x] П3 Отказ на одной загрузке не отменяет остальных, а результат называет
удалённые и отказавшие поимённо с причиной (оракул: тест, где второй `Delete`
возвращает ошибку — первая и третья удалены, страница результата называет
вторую и её причину)
- [x] П4 Групповой путь не расширяет прав поштучного (оракул: тест — `Delete`
для `downloading` возвращает `ErrConflict`, страница показывает отказ,
остальные выбранные не затронуты)
- [x] П5 Поведение страницы записано дельта-спекой и проходит валидацию
(оракул: `openspec validate --strict` и `task gate`)
## Приёмочные критерии из рубрики ревью дизайна
- [x] Р1 Исполняется ровно подтверждённое множество, названное списком
идентификаторов, а не предикатом «всё, что сейчас в состоянии X» (оракул:
тест — задача, ставшая разрешённой после показа подтверждения, не удаляется)
- [x] Р2 Множество допустимых состояний имеет единственный дом, и групповой путь
прав не расширяет (оракул: пункты 1.5, 1.6, 5.9)
- [x] Р3 Экран подтверждения называет не только предмет, но и последствие —
состояние и снятие последней копии для `orphaned` (оракул: пункт 4.6)
- [x] Р4 Вход валидируется целиком до первого эффекта, на обеих границах
(оракул: пункты 4.74.9, 5.7)
- [x] Р5 Частичный отказ не отменяет остальных, повтор идемпотентен (оракул:
пункты 5.8, 5.9; повтор — уже `deleted`, `CanDelete` ложно)
- [x] Р6 Обрыв или отмена запроса не оставляет пачку на середине, исход каждой
единицы переживает потерю ответа (оракул: пункт 5.11 и `logCmd` по каждому
вызову)
- [x] Р7 Предел пачки виден там, где формируется вход, и отказ по нему не стирает
выбор (оракул: пункты 3.6, 4.7)
- [x] Р8 Транспорт доменной логики не содержит: зовётся тот же
`Reviewer.Delete`, переход состояния — в воркере (оракул: чтение диффа на
ревью кода)