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 }