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
@@ -65,6 +65,23 @@ func Run(t *testing.T, r Repos) {
func newID() string { return auth.NewID() }
// sameIDs reports whether got contains exactly the wanted ids (any order).
func sameIDs(got []string, want ...string) bool {
if len(got) != len(want) {
return false
}
set := make(map[string]struct{}, len(got))
for _, id := range got {
set[id] = struct{}{}
}
for _, id := range want {
if _, ok := set[id]; !ok {
return false
}
}
return true
}
func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User {
t.Helper()
u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name}
@@ -249,6 +266,9 @@ func testMembers(t *testing.T, ctx context.Context, r Repos) {
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list: %v %+v", err, list)
}
if aud, err := r.Members.Audience(ctx, a.ID); err != nil || !sameIDs(aud, owner.ID, member.ID) {
t.Fatalf("audience: %v %v", err, aud)
}
if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
@@ -284,6 +304,9 @@ func testMembers(t *testing.T, ctx context.Context, r Repos) {
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("member not removed: %q", role)
}
if aud, _ := r.Members.Audience(ctx, a.ID); !sameIDs(aud, owner.ID) {
t.Fatalf("audience after remove = %v", aud)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
}