commit
This commit is contained in:
@@ -17,15 +17,20 @@ type MemberLookup interface {
|
||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||
}
|
||||
|
||||
type PhotoLookup interface {
|
||||
PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
members MemberLookup
|
||||
photos PhotoLookup
|
||||
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}
|
||||
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client, photos PhotoLookup) *Service {
|
||||
return &Service{store: store, members: members, photos: photos, hub: hub, storage: storage}
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
@@ -51,7 +56,23 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
return s.store.ListForUser(ctx, userID)
|
||||
list, err := s.store.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range list {
|
||||
s.attachCoverURL(ctx, &list[i])
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachCoverURL(ctx context.Context, a *models.Album) {
|
||||
if a == nil || a.CoverThumbKey == "" || s.storage == nil {
|
||||
return
|
||||
}
|
||||
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
|
||||
a.CoverURL = url
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||
@@ -62,6 +83,7 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
s.attachCoverURL(ctx, a)
|
||||
if a.OwnerID == userID {
|
||||
return a, roles.Owner, nil
|
||||
}
|
||||
@@ -107,11 +129,23 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
||||
if in.ClearCover {
|
||||
a.CoverPhotoID = nil
|
||||
} else if in.CoverPhotoID != nil {
|
||||
ok, err := s.photos.PhotoInAlbum(ctx, *in.CoverPhotoID, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, httpx.BadRequest("invalid_cover", "cover photo must belong to the album")
|
||||
}
|
||||
a.CoverPhotoID = in.CoverPhotoID
|
||||
}
|
||||
if err := s.store.Update(ctx, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fresh, err := s.store.GetByID(ctx, albumID); err == nil {
|
||||
a.CoverThumbKey = fresh.CoverThumbKey
|
||||
a.CoverURL = ""
|
||||
s.attachCoverURL(ctx, a)
|
||||
}
|
||||
s.hub.Publish(albumID, "album.updated", a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
|
||||
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
var a models.Album
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
|
||||
FROM albums WHERE id = $1`, id).
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
|
||||
a.created_at, a.updated_at
|
||||
FROM albums a WHERE a.id = $1`, id).
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -57,7 +59,9 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
|
||||
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
|
||||
a.created_at, a.updated_at
|
||||
FROM albums a
|
||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||
@@ -70,7 +74,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
||||
out := []models.Album{}
|
||||
for rows.Next() {
|
||||
var a models.Album
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
@@ -52,14 +52,14 @@ func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hu
|
||||
|
||||
albumsStore := albums.NewStore(pool)
|
||||
membersStore := members.NewStore(pool)
|
||||
photosStore := photos.NewStore(pool)
|
||||
|
||||
notifStore := notifications.NewStore(pool)
|
||||
notifSvc := notifications.NewService(notifStore, hub)
|
||||
|
||||
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
|
||||
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st, photosStore)
|
||||
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
|
||||
|
||||
photosStore := photos.NewStore(pool)
|
||||
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
|
||||
|
||||
socialStore := social.NewStore(pool)
|
||||
|
||||
@@ -26,13 +26,15 @@ func (u *User) Public() PublicUser {
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CoverThumbKey string `json:"-"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AlbumMember struct {
|
||||
|
||||
@@ -269,6 +269,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.ClearCover(ctx, p.AlbumID, p.ID)
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
|
||||
@@ -123,5 +123,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, albumID, photoID)
|
||||
}
|
||||
|
||||
func (s *Store) ClearCover(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`, albumID, photoID)
|
||||
}
|
||||
|
||||
func (s *Store) PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM photos WHERE id = $1 AND album_id = $2)`, photoID, albumID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/sharelinks"
|
||||
"shared-album/internal/sse"
|
||||
)
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
@@ -187,6 +189,96 @@ func TestMemberRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumCover(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
|
||||
|
||||
albumStore := albums.NewStore(pool)
|
||||
photosStore := photos.NewStore(pool)
|
||||
|
||||
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
|
||||
if err := albumStore.Create(ctx, a); err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
p1 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k1", Width: 1, Height: 1}
|
||||
p2 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k2", Width: 1, Height: 1}
|
||||
for _, p := range []*models.Photo{p1, p2} {
|
||||
if err := photosStore.Create(ctx, p); err != nil {
|
||||
t.Fatalf("create photo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
photosStore.SetCover(ctx, a.ID, p1.ID)
|
||||
got, _ := albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
|
||||
t.Fatalf("auto-cover not set: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
photosStore.SetCover(ctx, a.ID, p2.ID)
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
|
||||
t.Fatalf("auto-cover overwrote existing cover: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
photosStore.ClearCover(ctx, a.ID, p2.ID)
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil {
|
||||
t.Fatalf("clear with non-cover photo should be a no-op")
|
||||
}
|
||||
photosStore.ClearCover(ctx, a.ID, p1.ID)
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("cover not cleared: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if ok, err := photosStore.PhotoInAlbum(ctx, p1.ID, a.ID); err != nil || !ok {
|
||||
t.Fatalf("photo should belong to album: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
other := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "B"}
|
||||
if err := albumStore.Create(ctx, other); err != nil {
|
||||
t.Fatalf("create other album: %v", err)
|
||||
}
|
||||
pForeign := &models.Photo{ID: auth.NewID(), AlbumID: other.ID, UploaderID: owner.ID, StorageKey: "k3", Width: 1, Height: 1}
|
||||
if err := photosStore.Create(ctx, pForeign); err != nil {
|
||||
t.Fatalf("create foreign photo: %v", err)
|
||||
}
|
||||
if ok, _ := photosStore.PhotoInAlbum(ctx, pForeign.ID, a.ID); ok {
|
||||
t.Fatalf("foreign photo should not belong to album")
|
||||
}
|
||||
|
||||
svc := albums.NewService(albumStore, members.NewStore(pool), sse.NewHub(16), nil, photosStore)
|
||||
|
||||
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &pForeign.ID}); err == nil {
|
||||
t.Fatalf("expected error when setting foreign photo as cover")
|
||||
}
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("foreign cover should not be applied: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &p2.ID}); err != nil {
|
||||
t.Fatalf("valid cover update: %v", err)
|
||||
}
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != p2.ID {
|
||||
t.Fatalf("valid cover not applied: %v", got.CoverPhotoID)
|
||||
}
|
||||
|
||||
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{ClearCover: true}); err != nil {
|
||||
t.Fatalf("clear cover via service: %v", err)
|
||||
}
|
||||
got, _ = albumStore.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID != nil {
|
||||
t.Fatalf("cover not cleared via service: %v", got.CoverPhotoID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
Reference in New Issue
Block a user