- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count - frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs - openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
162 lines
4.0 KiB
Go
162 lines
4.0 KiB
Go
package albums
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
"shared-album/internal/auth"
|
|
"shared-album/internal/httpx"
|
|
"shared-album/internal/models"
|
|
"shared-album/internal/roles"
|
|
"shared-album/internal/sse"
|
|
"shared-album/internal/storage"
|
|
)
|
|
|
|
type MemberLookup interface {
|
|
Role(ctx context.Context, albumID, userID string) (string, error)
|
|
}
|
|
|
|
type Service struct {
|
|
store *Store
|
|
members MemberLookup
|
|
hub *sse.Hub
|
|
storage *storage.Client
|
|
}
|
|
|
|
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *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 presigned thumbnail key, 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.PresignGet(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
|
|
}
|