feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото

Публиковать album.created/updated/deleted и изменения состава фото в личный поток
SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые
страницы альбома обновлялись без перезагрузки.

- members.Repository получил Audience (владелец + участники) для дешёвого веера
- albums.Service рассылает события в личные потоки получателей, перечитывая альбом
  через GetByID и attachCover, чтобы payload совпадал с GET /api/albums
- members.Service публикует при принятии инвайта, смене роли и исключении
- photos.Service публикует photo.created при загрузке и album.updated при
  добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении
- photos_count теперь считает фото вне корзины
- web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и
  применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
iu
2026-10-15 23:25:07 +03:00
parent cbff7a340c
commit e82f45647c
31 changed files with 2157 additions and 28 deletions
+16 -6
View File
@@ -314,13 +314,23 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) {
t.Fatalf("upload: %v", err)
}
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
// The upload itself publishes photo.created; variant readiness then publishes
// photo.updated. Wait for the latter, allowing the former.
deadline := time.After(5 * time.Second)
for {
select {
case ev := <-sub.Events():
switch ev.Type {
case "photo.updated":
return
case "photo.created":
// expected from the upload, keep waiting
default:
t.Fatalf("unexpected album event %q", ev.Type)
}
case <-deadline:
t.Fatalf("no photo.updated event published for generated variants")
}
case <-time.After(5 * time.Second):
t.Fatalf("no photo.updated event published for generated variants")
}
}