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
+2
View File
@@ -50,6 +50,8 @@ func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.A
return &models.Album{ID: albumID}, roles.Owner, nil
}
func (stubAlbums) NotifyAlbumChanged(context.Context, string) {}
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
t.Helper()
id := auth.NewID()
+20
View File
@@ -26,6 +26,9 @@ import (
type AlbumAccess interface {
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
// NotifyAlbumChanged announces a change to the album's photo composition on
// the recipients' personal streams (album cards and counts update live).
NotifyAlbumChanged(ctx context.Context, albumID string)
}
type Service struct {
@@ -237,6 +240,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
// The photo appears in the album: subscribers insert it, and the album's
// recipients see the new count (and cover, if this was the first photo).
s.hub.Publish(in.AlbumID, "photo.created", p)
s.albums.NotifyAlbumChanged(ctx, in.AlbumID)
return p, nil
}
@@ -501,6 +508,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
// A trashed cover leaves the album without one.
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
s.publishDeleted(p)
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
return nil
}
@@ -583,6 +591,7 @@ func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID st
}
if p.AlbumID != "" {
s.hub.Publish(p.AlbumID, "photo.created", p)
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
}
return p, nil
}
@@ -620,6 +629,7 @@ func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) e
}
if p.AlbumID != "" {
s.publishDeleted(p)
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
}
// Mark objects that no longer have any binding for garbage collection.
// The background worker removes the blobs from storage.
@@ -658,6 +668,7 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
moved := 0
changed := map[string]struct{}{}
for _, id := range ids {
p, err := s.store.GetByID(ctx, id, userID)
if err != nil || p.DeletedAt != nil {
@@ -686,8 +697,14 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(targetAlbumID, "photo.created", p)
}
changed[oldAlbum] = struct{}{}
changed[targetAlbumID] = struct{}{}
moved++
}
// One album.updated per affected album, not per photo.
for albumID := range changed {
s.albums.NotifyAlbumChanged(ctx, albumID)
}
return moved, nil
}
@@ -724,6 +741,9 @@ func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlb
s.hub.Publish(targetAlbumID, "photo.created", clone)
out = append(out, *clone)
}
if len(out) > 0 {
s.albums.NotifyAlbumChanged(ctx, targetAlbumID)
}
return out, nil
}