Карточка альбома не получала ссылку на обложку: она появляется в объекте альбома только после генерации thumb/medium, а событие об этом шаге в персональные потоки не публиковалось — счётчик обновлялся, обложка нет. - albums.Service.NotifyCoverReady публикует album.updated получателям, когда варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы массовая загрузка не рассылала событие на каждый файл) - photos: порт AlbumAccess расширен, worker вызывает публикацию после photo.updated - project.md §11/§12: триггер добавлен в контракт персонального потока
268 lines
8.0 KiB
Go
268 lines
8.0 KiB
Go
package albums
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"galo/internal/auth"
|
|
"galo/internal/httpx"
|
|
"galo/internal/models"
|
|
"galo/internal/roles"
|
|
"galo/internal/sse"
|
|
"galo/internal/storage"
|
|
)
|
|
|
|
type MemberLookup interface {
|
|
Role(ctx context.Context, albumID, userID string) (string, error)
|
|
// Audience resolves the users the album is visible to (owner + members) for
|
|
// personal event fan-out.
|
|
Audience(ctx context.Context, albumID string) ([]string, error)
|
|
}
|
|
|
|
type Service struct {
|
|
store Repository
|
|
members MemberLookup
|
|
hub *sse.Hub
|
|
storage storage.Backend
|
|
}
|
|
|
|
func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service {
|
|
return &Service{store: store, members: members, hub: hub, storage: storage}
|
|
}
|
|
|
|
// Event names published to personal streams for the album lifecycle. They are
|
|
// part of the client contract documented in project.md.
|
|
const (
|
|
EventAlbumCreated = "album.created"
|
|
EventAlbumUpdated = "album.updated"
|
|
EventAlbumDeleted = "album.deleted"
|
|
)
|
|
|
|
// OwnerID returns the owner of the album, delegating to the store so callers
|
|
// that only need the owner (members invites, role checks) can use this service
|
|
// as their album reader.
|
|
func (s *Service) OwnerID(ctx context.Context, albumID string) (string, error) {
|
|
return s.store.OwnerID(ctx, albumID)
|
|
}
|
|
|
|
// PublishAlbumTo fans an album lifecycle event out to the personal streams of
|
|
// the given users. Events other than deletion carry the album re-read through
|
|
// Store.GetByID and enriched with attachCover, so the payload has the same
|
|
// shape as an element of GET /api/albums; deletion carries only {id} because
|
|
// the album is already gone when it is published.
|
|
func (s *Service) PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string) {
|
|
if len(userIDs) == 0 || s.hub == nil {
|
|
return
|
|
}
|
|
var payload any
|
|
if eventType == EventAlbumDeleted {
|
|
payload = map[string]string{"id": albumID}
|
|
} else {
|
|
a, err := s.store.GetByID(ctx, albumID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.attachCover(ctx, a)
|
|
payload = a
|
|
}
|
|
for _, uid := range userIDs {
|
|
s.hub.PublishUser(uid, eventType, payload)
|
|
}
|
|
}
|
|
|
|
// NotifyAlbumChanged re-reads the album and publishes album.updated to the
|
|
// personal streams of its owner and members, without touching the per-album
|
|
// channel. Photo-composition changes use it: the open album page learns about
|
|
// them from photo.* events, and a per-album album.updated would force a REST
|
|
// reload for every photo.
|
|
func (s *Service) NotifyAlbumChanged(ctx context.Context, albumID string) {
|
|
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
|
|
}
|
|
|
|
// NotifyCoverReady republishes the album to personal streams when the photo that
|
|
// is currently its cover gains its variant URLs. The album object only carries a
|
|
// cover link once thumb/medium exist, so without this event an album card keeps
|
|
// showing a placeholder. Readiness of any other photo does not change the album
|
|
// object and is ignored, which keeps bulk uploads from fanning out an event per
|
|
// file.
|
|
func (s *Service) NotifyCoverReady(ctx context.Context, albumID, photoID string) {
|
|
a, err := s.store.GetByID(ctx, albumID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if a.CoverPhotoID == nil || *a.CoverPhotoID != photoID {
|
|
return
|
|
}
|
|
s.NotifyAlbumChanged(ctx, albumID)
|
|
}
|
|
|
|
// albumAudience resolves the users an album is visible to. Resolution failures
|
|
// are swallowed: a missed fan-out is recovered by the client's reconnect sync
|
|
// and must not fail the mutation that triggered it.
|
|
func (s *Service) albumAudience(ctx context.Context, albumID string) []string {
|
|
if s.members == nil {
|
|
return nil
|
|
}
|
|
ids, err := s.members.Audience(ctx, albumID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return ids
|
|
}
|
|
|
|
type CreateInput struct {
|
|
Title string
|
|
Description string
|
|
}
|
|
|
|
func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*models.Album, error) {
|
|
title := strings.TrimSpace(in.Title)
|
|
if title == "" {
|
|
return nil, httpx.BadRequest("invalid_title", "title is required")
|
|
}
|
|
a := &models.Album{
|
|
ID: auth.NewID(),
|
|
OwnerID: userID,
|
|
Title: title,
|
|
Description: strings.TrimSpace(in.Description),
|
|
}
|
|
if err := s.store.Create(ctx, a); err != nil {
|
|
return nil, err
|
|
}
|
|
s.PublishAlbumTo(ctx, a.ID, EventAlbumCreated, s.albumAudience(ctx, a.ID))
|
|
return a, nil
|
|
}
|
|
|
|
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
|
|
out, err := s.store.ListForUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
s.attachCover(ctx, &out[i])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// attachCover fills CoverThumbURL and CoverMediumURL from signed URLs. The
|
|
// medium URL falls back to the thumbnail while the medium variant is not ready,
|
|
// so a cover URL suitable for large display is always present when a cover is
|
|
// set.
|
|
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
|
|
if s.storage == nil || a == nil {
|
|
return
|
|
}
|
|
if a.CoverThumbKey != "" {
|
|
if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil {
|
|
a.CoverThumbURL = url
|
|
}
|
|
}
|
|
key := a.CoverMediumKey
|
|
if key == "" {
|
|
key = a.CoverThumbKey
|
|
}
|
|
if key != "" {
|
|
if url, err := s.storage.URL(ctx, key); err == nil {
|
|
a.CoverMediumURL = url
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
|
a, err := s.store.GetByID(ctx, albumID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, "", httpx.NotFound("album_not_found", "album not found")
|
|
}
|
|
return nil, "", err
|
|
}
|
|
if a.OwnerID == userID {
|
|
return a, roles.Owner, nil
|
|
}
|
|
role, err := s.members.Role(ctx, albumID, userID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if role == "" {
|
|
return nil, "", httpx.NotFound("album_not_found", "album not found")
|
|
}
|
|
return a, role, nil
|
|
}
|
|
|
|
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
|
a, role, err := s.Access(ctx, albumID, userID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
s.attachCover(ctx, a)
|
|
return a, role, nil
|
|
}
|
|
|
|
type UpdateInput struct {
|
|
Title *string
|
|
Description *string
|
|
CoverPhotoID *string
|
|
ClearCover bool
|
|
}
|
|
|
|
func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateInput) (*models.Album, error) {
|
|
a, role, err := s.Access(ctx, albumID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !roles.CanEditAlbum(role) {
|
|
return nil, httpx.Forbidden("forbidden", "only owner and admin can edit the album")
|
|
}
|
|
if in.Title != nil {
|
|
title := strings.TrimSpace(*in.Title)
|
|
if title == "" {
|
|
return nil, httpx.BadRequest("invalid_title", "title cannot be empty")
|
|
}
|
|
a.Title = title
|
|
}
|
|
if in.Description != nil {
|
|
a.Description = strings.TrimSpace(*in.Description)
|
|
}
|
|
if in.ClearCover {
|
|
a.CoverPhotoID = nil
|
|
} else if in.CoverPhotoID != nil {
|
|
a.CoverPhotoID = in.CoverPhotoID
|
|
}
|
|
if err := s.store.Update(ctx, a); err != nil {
|
|
return nil, err
|
|
}
|
|
if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil {
|
|
a = refreshed
|
|
}
|
|
s.attachCover(ctx, a)
|
|
s.hub.Publish(albumID, EventAlbumUpdated, a)
|
|
// The per-album event above keeps the mobile client and the open album page
|
|
// working; the personal fan-out below reaches everyone whose album list
|
|
// changes, whether or not they are looking at this album.
|
|
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
|
|
return a, nil
|
|
}
|
|
|
|
func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
|
a, role, err := s.Access(ctx, albumID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if role != roles.Owner {
|
|
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
|
}
|
|
// The audience must be computed before deletion: album_members is removed by
|
|
// cascade with the album, so the recipient list is unavailable afterwards.
|
|
audience := s.albumAudience(ctx, a.ID)
|
|
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.Delete(ctx, a.ID); err != nil {
|
|
return err
|
|
}
|
|
s.hub.Publish(albumID, EventAlbumDeleted, map[string]string{"id": a.ID})
|
|
s.PublishAlbumTo(ctx, albumID, EventAlbumDeleted, audience)
|
|
return nil
|
|
}
|