chore(openspec): archive completed changes and sync main specs

- fix-large-album-load -> photos, realtime, media-objects
- fix-album-cover -> photos, albums
This commit is contained in:
iu
2026-10-11 14:16:38 +03:00
parent d7a37328f0
commit d99ac77587
17 changed files with 69 additions and 14 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,98 @@
# Design
## Context
Требование «Обложка по умолчанию» существовало и работало в старом инлайн-пути генерации
вариантов (`processVariants`), который вызывал `SetCoverIfEmpty`. Рефакторинг media-objects
(коммит `96268ee`) перенёс генерацию вариантов в фоновый worker и потерял этот вызов; сейчас
`SetCoverIfEmpty` вызывается только из `CopyBatch`. Дополнительно обложка не снимается при
уходе фото-обложки. См. `proposal.md — Why`.
Опорные точки:
- `internal/photos/service.go`: `Upload`, `Delete`, `MoveBatch`; `SetCoverIfEmpty` — метод
репозитория фото (`store.go`, `repository.go`, `sqlite_store.go`).
- `internal/photos/worker.go`: с недавних пор обрабатывает новые фото первыми
(`created_at DESC`) — отсюда решение не ставить обложку в worker'е.
- `albums.Update` уже поддерживает `cover_photo_id` и `clear_cover`.
- Frontend: `albums.setCover()` уже существует; контекстное меню — `openMenuForPhoto`
в `albums/[id]/+page.svelte`; секция «Действия» — `PhotoPanel.svelte` (props: `photo`, `role`).
## Goals / Non-Goals
**Goals:**
- Обложкой нового альбома становится первое загруженное фото.
- Обложка снимается, когда фото-обложка покидает альбом (удаление/перенос).
- Пункт «Установить как обложку альбома» доступен из сетки и правой панели.
**Non-Goals:**
- Backfill существующих альбомов.
- Автоперевыбор новой обложки после снятия.
- Изменение поведения Viewer и share-страниц.
## Decisions
### 1. Обложка ставится при загрузке, а не в worker'е
**Выбор:** в `Service.Upload`, после успешного сохранения фото, вызвать
`store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)`.
**Почему:** формулировка «первое загруженное фото». Worker обрабатывает новейшие фото первыми,
поэтому установка «при обработке» дала бы обложку = последнее загруженное. Загрузка идёт
последовательно по файлам запроса, поэтому первое фото детерминированно выигрывает
(`UPDATE ... WHERE cover_photo_id IS NULL`).
**Альтернативы:**
- *В worker'е по готовности вариантов* — thumb уже есть, но порядок обработки ломает «первое
загруженное».
- *В worker'е с выбором MIN(created_at)* — сложнее и не гарантирует готовность thumb
выбранного фото.
**Следствие:** `cover_thumb_url` пуст до генерации thumb; список альбомов подхватит его при
следующем запросе (JOIN по `cover_photo_id`). Реальное время смены обложки в рейле не
обновляется по SSE — вне области.
### 2. Снятие обложки — атомарный метод репозитория
**Выбор:** метод `ClearCoverIfMatches(ctx, albumID, photoID)`:
`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`.
Вызывается в `Delete` (корзина) и в `MoveBatch` для исходного альбома.
**Почему:** условный UPDATE защищает от гонки (обложку могли сменить) и не требует чтения.
`PermanentDelete` уже покрыт FK `ON DELETE SET NULL`.
**Альтернативы:**
- *Снять безусловно* — риск снести чужой выбор обложки.
- *FK при soft-delete* — FK не срабатывает при мягком удалении.
### 3. `clear_cover` → NULL
**Выбор:** явный сброс обложки оставляет `cover_photo_id = NULL` (пересчёта по умолчанию нет).
Соответствует решению «очищать, без backfill».
**Альтернативы:**
- *Пересчитать «по умолчанию» (первое фото)* — тогда сброс бессмыслен (обложка мгновенно
вернётся), плюс скрытый backfill.
### 4. Frontend: та же защита прав, что и у редактирования альбома
**Выбор:** пункт показывается при `role === 'owner' || role === 'admin'`; действие вызывает
`albums.setCover(albumId, photo.id)` (существующий `PATCH /albums/:id`). Добавляется в
`openMenuForPhoto` и в секцию «Действия» `PhotoPanel`; иконка `wallpaper`.
**Почему:** переиспользуется существующий API и гейт прав; `PhotoPanel` уже получает `role`.
`albumId` берётся из `photo.album_id`/контекста страницы.
## Risks / Trade-offs
- [Пункт меню/панели меняет обложку, но рейл не обновляется мгновенно, если thumb ещё не готов]
→ допустимо; при следующем запросе обложка появится.
- [Гонка параллельных загрузок в пустой альбом] → условный UPDATE выбирает одного победителя;
«первое» при строго параллельных запросах незначительно недетерминировано.
- [Снятие обложки при переносе может неожиданно обнулить обложку] → это и есть желаемое
поведение по спеке; пользователь может назначить заново.
- [Существующие альбомы без обложки остаются пустыми] → осознанно (no backfill).
## Migration Plan
- Миграций нет. Порядок: backend (загрузка/снятие обложки) → frontend (меню/панель).
- Откат — возврат предыдущего бинаря/фронта; данные не деградируют.
@@ -0,0 +1,37 @@
# Proposal
## Why
Автобложка «первое фото» была специфицирована, но потерялась при рефакторинге media-objects:
при загрузке обложка больше не назначается, поэтому большинство альбомов (в т.ч. семенные)
остаются без обложки. Дополнительно нужно довести жизненный цикл обложки: снимать её, когда
фото-обложка покидает альбом, и сделать явный сброс предсказуемым.
## What Changes
- **Обложка по умолчанию при загрузке.** Если у альбома нет обложки, обложкой становится
первое загруженное фото; последующие фото её не меняют.
- **Снятие обложки при уходе фото-обложки.** Помещение фото-обложки в корзину или перенос
её в другой альбом очищает обложку (без перевыбора).
- **Явный сброс обложки** (`clear_cover`) обнуляет обложку и не пересчитывает её.
- **Frontend.** Пункт «Установить как обложку альбома» в контекстном меню фото сетки и в
разделе «Действия» правой панели; доступен только владельцу и админу.
- **Без backfill** существующих альбомов без обложки.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `photos`: обложка по умолчанию назначается при загрузке (первое фото) и снимается, когда
фото-обложка покидает альбом.
- `albums`: сброс обложки делает её не заданной (без возврата к значению по умолчанию).
## Impact
- Backend: `internal/photos/service.go` (`Upload`, `Delete`, `MoveBatch`),
`internal/photos/{store,repository,sqlite_store}.go` (метод снятия обложки).
- Frontend: контекстное меню фото в сетке (`albums/[id]/+page.svelte`), раздел «Действия»
в `PhotoPanel.svelte`; используется существующий `PATCH /albums/:id` через `albums.setCover`.
- Данные/миграции: не требуются; backfill не выполняется.
@@ -0,0 +1,20 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Выбор обложки
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
обложку; после сброса обложка SHALL становиться не заданной (без возврата к значению по
умолчанию).
#### Scenario: Назначение обложки
- **WHEN** владелец выбирает фото альбома как обложку
- **THEN** система сохраняет выбор и отражает его в списке и карточках
#### Scenario: Сброс обложки
- **WHEN** админ сбрасывает обложку
- **THEN** система снимает обложку, и альбом отображается без обложки, пока обложку не назначат снова
#### Scenario: Нет прав на смену обложки
- **WHEN** участник без права редактирования пытается сменить обложку
- **THEN** система отклоняет запрос
@@ -0,0 +1,24 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Обложка по умолчанию
При загрузке фото система SHALL назначать его обложкой альбома, если обложка ещё не задана,
то есть обложкой становится первое загруженное фото. Когда фото, выбранное обложкой, покидает
альбом (помещено в корзину или перенесено в другой альбом), система SHALL снять обложку.
#### Scenario: Первое фото
- **WHEN** фото загружено, и у альбома нет обложки
- **THEN** система назначает это фото обложкой альбома
#### Scenario: Обложка уже задана
- **WHEN** фото загружено, а обложка уже задана
- **THEN** система не меняет обложку
#### Scenario: Фото-обложка в корзине
- **WHEN** фото, назначенное обложкой, помещается в корзину
- **THEN** система снимает обложку, и альбом отображается без обложки
#### Scenario: Фото-обложка перенесено
- **WHEN** фото, назначенное обложкой, переносится в другой альбом
- **THEN** система снимает обложку исходного альбома
@@ -0,0 +1,21 @@
# Tasks
## 1. Автобложка при загрузке
- [x] 1.1 В `backend/internal/photos/service.go` (`Upload`) вызывать `store.SetCoverIfEmpty(ctx, in.AlbumID, p.ID)` после успешного сохранения фото; проверить `cd backend && go test ./internal/e2e/` — тест загружает два фото в новый альбом и проверяет, что обложкой стало первое
- [x] 1.2 Добавить в тот же тест проверку сценария «обложка уже задана»: загрузка второго фото не меняет уже назначенную обложку
## 2. Снятие обложки при уходе фото-обложки
- [x] 2.1 Добавить `ClearCoverIfMatches(ctx, albumID, photoID)` в `backend/internal/photos/repository.go`, `store.go` и `sqlite_store.go` (`UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`); проверить `cd backend && go test ./internal/repo/contract/` — снятие срабатывает только при совпадении обложки
- [x] 2.2 Вызывать `ClearCoverIfMatches` в `Delete` (корзина) и `MoveBatch` (исходный альбом) в `service.go`; проверить тестом, что удаление и перенос фото-обложки снимают обложку
## 3. Пункт «Установить как обложку альбома»
- [x] 3.1 Добавить пункт в контекстное меню фото сетки (`frontend/src/routes/(app)/albums/[id]/+page.svelte`, `openMenuForPhoto`) с иконкой `wallpaper` и вызовом `albums.setCover(albumId, photo.id)`, видимый только owner/admin; проверить `cd frontend && npm run check`
- [x] 3.2 Добавить тот же пункт в секцию «Действия» компонента `frontend/src/lib/components/PhotoPanel.svelte`; проверить `npm run check` и наблюдаемо: пункт показывается только владельцу/админу
## 4. Интеграционная проверка
- [x] 4.1 `cd backend && go test ./...` и `cd frontend && npm run check` — оба зелёные
- [x] 4.2 Собрать (`make build`), загрузить фото в новый альбом (обложка = первое фото), затем удалить фото-обложку (обложка пуста) и перенести фото-обложку (обложка пуста); сбросить обложку вручную через панель — остаётся пустой