feat: photo multiselect, move/copy, trash and compact chrome
- backend: soft delete + trash (photos.deleted_at, migration 000005), GET /trash, restore, permanent delete with storage-key refcount (S3 objects removed only when no other album references them); batch endpoints photo-batch/{delete,move,copy} - metadata only, copies clone rows sharing storage keys
- frontend: Ctrl/Shift multiselect; right-panel 'Действия' as icon text rows (Открыть (просмотр), Переместить, Копировать, Удалить); drag selected photos onto rail album -> TransferDialog; /trash route; trash entry as last item in the left rail; empty right panel hidden on overview
- frontend: compact top bars (app header and viewer), compact theme switcher
- openspec: sync lightroom-workspace-ui artifacts (photos/ui-workspace specs, design #20-24, tasks 9.11-9.17)
This commit is contained in:
@@ -137,6 +137,13 @@ func (s *Server) Router() *gin.Engine {
|
||||
authed.PATCH("/photos/:id", photosHandler.Rename)
|
||||
authed.DELETE("/photos/:id", photosHandler.Delete)
|
||||
authed.GET("/photos/:id/download", photosHandler.Download)
|
||||
authed.POST("/photos/:id/restore", photosHandler.Restore)
|
||||
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
|
||||
|
||||
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
|
||||
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
|
||||
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
|
||||
authed.GET("/trash", photosHandler.Trash)
|
||||
|
||||
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
||||
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
||||
|
||||
@@ -78,6 +78,8 @@ type Photo struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
AlbumTitle string `json:"album_title,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
|
||||
@@ -133,3 +133,76 @@ func (h *Handler) Delete(c *gin.Context) {
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) PermanentDelete(c *gin.Context) {
|
||||
if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Restore(c *gin.Context) {
|
||||
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||
}
|
||||
|
||||
func (h *Handler) Trash(c *gin.Context) {
|
||||
list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photos": list})
|
||||
}
|
||||
|
||||
type batchRequest struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
AlbumID string `json:"album_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteBatch(c *gin.Context) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required"))
|
||||
return
|
||||
}
|
||||
n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
func (h *Handler) MoveBatch(c *gin.Context) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
|
||||
return
|
||||
}
|
||||
n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"moved": n})
|
||||
}
|
||||
|
||||
func (h *Handler) CopyBatch(c *gin.Context) {
|
||||
var req batchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
|
||||
return
|
||||
}
|
||||
created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photos": created})
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if p.DeletedAt != nil {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -230,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if p.DeletedAt != nil {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,23 +303,192 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
own := p.UploaderID == userID
|
||||
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
||||
if p.DeletedAt != nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
keys = append(keys, p.PreviewKey)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, keys...)
|
||||
s.publishDeleted(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func canDeletePhoto(role string, own bool) bool {
|
||||
return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))
|
||||
}
|
||||
|
||||
func (s *Service) publishDeleted(p *models.Photo) {
|
||||
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
|
||||
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete.
|
||||
func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) {
|
||||
deleted := 0
|
||||
for _, id := range ids {
|
||||
if err := s.Delete(ctx, id, userID); err != nil {
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// Restore returns a photo from the trash back to its album.
|
||||
func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.Photo, error) {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
|
||||
}
|
||||
if p.DeletedAt == nil {
|
||||
return p, nil
|
||||
}
|
||||
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.DeletedAt = nil
|
||||
if err := s.attachURLs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.hub.Publish(p.AlbumID, "photo.created", p)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// PermanentDelete removes the photo row; stored objects are deleted only when no
|
||||
// other album references the same storage key.
|
||||
func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||
}
|
||||
if err := s.store.HardDelete(ctx, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.AlbumID != "" {
|
||||
s.publishDeleted(p)
|
||||
}
|
||||
if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 {
|
||||
keys := []string{p.StorageKey}
|
||||
if p.ThumbKey != "" {
|
||||
keys = append(keys, p.ThumbKey)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
keys = append(keys, p.PreviewKey)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, keys...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveBatch moves photos to another album (metadata only; stored objects untouched).
|
||||
func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
|
||||
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !roles.CanUpload(targetRole) {
|
||||
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||
}
|
||||
moved := 0
|
||||
for _, id := range ids {
|
||||
p, err := s.store.GetByID(ctx, id, userID)
|
||||
if err != nil || p.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
oldAlbum := p.AlbumID
|
||||
if oldAlbum == targetAlbumID {
|
||||
continue
|
||||
}
|
||||
_, srcRole, err := s.albums.Access(ctx, oldAlbum, userID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !canDeletePhoto(srcRole, p.UploaderID == userID) {
|
||||
continue
|
||||
}
|
||||
if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
|
||||
continue
|
||||
}
|
||||
p.AlbumID = targetAlbumID
|
||||
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
|
||||
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
|
||||
})
|
||||
if err := s.attachURLs(ctx, p); err == nil {
|
||||
s.hub.Publish(targetAlbumID, "photo.created", p)
|
||||
}
|
||||
moved++
|
||||
}
|
||||
return moved, nil
|
||||
}
|
||||
|
||||
// CopyBatch clones photos into another album (metadata only; stored objects shared).
|
||||
func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) {
|
||||
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanUpload(targetRole) {
|
||||
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||
}
|
||||
out := []models.Photo{}
|
||||
for _, id := range ids {
|
||||
src, err := s.store.GetByID(ctx, id, userID)
|
||||
if err != nil || src.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil {
|
||||
continue
|
||||
}
|
||||
newID := auth.NewID()
|
||||
if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil {
|
||||
continue
|
||||
}
|
||||
clone, err := s.store.GetByID(ctx, newID, userID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.attachURLs(ctx, clone); err != nil {
|
||||
continue
|
||||
}
|
||||
s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID)
|
||||
s.hub.Publish(targetAlbumID, "photo.created", clone)
|
||||
out = append(out, *clone)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Trash lists soft-deleted photos from all albums the user belongs to.
|
||||
func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||
out, err := s.store.ListTrash(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
_ = s.attachURLs(ctx, &out[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/models"
|
||||
@@ -28,7 +29,7 @@ const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
||||
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
|
||||
COALESCE(p.exif, '{}'::jsonb)::text`
|
||||
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at`
|
||||
|
||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
@@ -38,7 +39,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText)
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -103,6 +104,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.album_id = $1
|
||||
AND p.deleted_at IS NULL
|
||||
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
||||
AND ($4::timestamptz IS NULL OR p.created_at > $4)
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
@@ -122,7 +124,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
@@ -131,7 +133,62 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
|
||||
var tag pgconn.CommandTag
|
||||
var err error
|
||||
if deleted {
|
||||
tag, err = s.pool.Exec(ctx,
|
||||
`UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
} else {
|
||||
tag, err = s.pool.Exec(ctx,
|
||||
`UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone creates a new photo row that references the same stored objects as src.
|
||||
func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
|
||||
exifJSON, err := json.Marshal(src.EXIF)
|
||||
if err != nil || len(exifJSON) == 0 {
|
||||
exifJSON = []byte("{}")
|
||||
}
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
|
||||
original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
|
||||
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
|
||||
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
|
||||
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
|
||||
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// HardDelete permanently removes the photo row.
|
||||
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -142,6 +199,49 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTrash returns soft-deleted photos from albums the user belongs to.
|
||||
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT `+photoColumns+`, a.title
|
||||
FROM photos p
|
||||
JOIN users u ON u.id = p.uploader_id
|
||||
JOIN albums a ON a.id = p.album_id
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (a.owner_id = $1 OR EXISTS(
|
||||
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))
|
||||
ORDER BY p.deleted_at DESC
|
||||
LIMIT 200`,
|
||||
userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
var exifText string
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx,
|
||||
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
||||
|
||||
Reference in New Issue
Block a user