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) } 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} } 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 } 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 from the signed thumbnail URL, when present. func (s *Service) attachCover(ctx context.Context, a *models.Album) { if s.storage == nil || a == nil || a.CoverThumbKey == "" { return } if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil { a.CoverThumbURL = 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, "album.updated", a) 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") } 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, "album.deleted", map[string]string{"id": a.ID}) return nil }