feat: context menus, unified photo panel, tags, album trash and counters
- 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)
This commit is contained in:
@@ -150,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
||||
if role != roles.Owner {
|
||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
||||
}
|
||||
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.storage != nil {
|
||||
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
|
||||
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
|
||||
|
||||
@@ -107,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
|
||||
// or permanently removed from the trash after the album is gone.
|
||||
func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -139,11 +139,15 @@ func (s *Server) Router() *gin.Engine {
|
||||
authed.GET("/photos/:id/download", photosHandler.Download)
|
||||
authed.POST("/photos/:id/restore", photosHandler.Restore)
|
||||
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
|
||||
authed.POST("/photos/:id/tags", photosHandler.AddTag)
|
||||
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
|
||||
authed.GET("/tags", photosHandler.Tags)
|
||||
|
||||
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.GET("/trash/count", photosHandler.TrashCount)
|
||||
|
||||
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
||||
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
||||
|
||||
@@ -78,6 +78,7 @@ type Photo struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
AlbumTitle string `json:"album_title,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -91,6 +92,11 @@ type Photo struct {
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
PhotoID string `json:"photo_id"`
|
||||
|
||||
@@ -143,7 +143,11 @@ func (h *Handler) PermanentDelete(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) Restore(c *gin.Context) {
|
||||
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||
var req struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
@@ -160,6 +164,49 @@ func (h *Handler) Trash(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"photos": list})
|
||||
}
|
||||
|
||||
func (h *Handler) TrashCount(c *gin.Context) {
|
||||
n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": n})
|
||||
}
|
||||
|
||||
func (h *Handler) Tags(c *gin.Context) {
|
||||
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
|
||||
func (h *Handler) AddTag(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
|
||||
return
|
||||
}
|
||||
p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveTag(c *gin.Context) {
|
||||
p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||
}
|
||||
|
||||
type batchRequest struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
AlbumID string `json:"album_id"`
|
||||
|
||||
@@ -339,7 +339,10 @@ func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string)
|
||||
}
|
||||
|
||||
// Restore returns a photo from the trash back to its album.
|
||||
func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.Photo, error) {
|
||||
// Restore returns a photo from the trash back to an album. When targetAlbumID is
|
||||
// empty the photo goes back to its own album; if that album is gone, an
|
||||
// album_required error is returned so the client can prompt for a target.
|
||||
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
|
||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
@@ -347,24 +350,52 @@ func (s *Service) Restore(ctx context.Context, photoID, userID string) (*models.
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
if targetAlbumID != "" {
|
||||
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.CanUpload(role) {
|
||||
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||
}
|
||||
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.AlbumID = targetAlbumID
|
||||
p.DeletedAt = nil
|
||||
} else {
|
||||
if p.AlbumID == "" {
|
||||
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
|
||||
}
|
||||
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
|
||||
}
|
||||
_, 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 {
|
||||
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
p.DeletedAt = nil
|
||||
}
|
||||
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)
|
||||
if p.AlbumID != "" {
|
||||
s.hub.Publish(p.AlbumID, "photo.created", p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -492,3 +523,66 @@ func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TrashCount returns the number of photos in the user's trash.
|
||||
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
|
||||
return s.store.TrashCount(ctx, userID)
|
||||
}
|
||||
|
||||
// Tags lists the user's tags, optionally filtered by a search query.
|
||||
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
||||
return s.store.ListTags(ctx, userID, q)
|
||||
}
|
||||
|
||||
func (s *Service) taggablePhoto(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
|
||||
}
|
||||
if p.DeletedAt != nil {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
if p.AlbumID == "" {
|
||||
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||
}
|
||||
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
|
||||
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
|
||||
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag, err := s.store.EnsureTag(ctx, userID, name)
|
||||
if err != nil {
|
||||
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
|
||||
}
|
||||
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, photoID, userID)
|
||||
}
|
||||
|
||||
// RemoveTag detaches one of the user's tags from a photo.
|
||||
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
|
||||
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, httpx.NotFound("tag_not_found", "tag not found")
|
||||
}
|
||||
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, photoID, userID)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
@@ -21,7 +23,7 @@ type Store struct {
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
||||
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
|
||||
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
||||
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
@@ -29,17 +31,20 @@ 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, p.deleted_at`
|
||||
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
|
||||
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
|
||||
|
||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
var exifText string
|
||||
var exifText, tagsText string
|
||||
err := row.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.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -47,9 +52,20 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
setTags(&p, tagsText)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func setTags(p *models.Photo, raw string) {
|
||||
if raw == "" || raw == "null" || raw == "[]" {
|
||||
return
|
||||
}
|
||||
var tags []models.Tag
|
||||
if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
|
||||
p.Tags = tags
|
||||
}
|
||||
}
|
||||
|
||||
func setExif(p *models.Photo, raw string) {
|
||||
if raw == "" || raw == "null" || raw == "{}" {
|
||||
return
|
||||
@@ -118,16 +134,17 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
var exifText string
|
||||
var exifText, tagsText 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); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
setTags(&p, tagsText)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -199,16 +216,20 @@ func (s *Store) HardDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTrash returns soft-deleted photos from albums the user belongs to.
|
||||
// ListTrash returns soft-deleted photos from albums the user belongs to, plus
|
||||
// their own photos whose album no longer exists.
|
||||
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT `+photoColumns+`, a.title
|
||||
`SELECT `+photoColumns+`, COALESCE(a.title, '')
|
||||
FROM photos p
|
||||
JOIN users u ON u.id = p.uploader_id
|
||||
JOIN albums a ON a.id = p.album_id
|
||||
LEFT 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))
|
||||
AND (
|
||||
(a.id 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)))
|
||||
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||
)
|
||||
ORDER BY p.deleted_at DESC
|
||||
LIMIT 200`,
|
||||
userID, userID)
|
||||
@@ -220,21 +241,127 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
var exifText string
|
||||
var exifText, tagsText 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 {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
setTags(&p, tagsText)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TrashCount counts soft-deleted photos accessible to the user.
|
||||
func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM photos p
|
||||
LEFT JOIN albums a ON a.id = p.album_id
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (
|
||||
(a.id 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)))
|
||||
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||
)`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
|
||||
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AlbumExists reports whether an album row exists.
|
||||
func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, name FROM tags
|
||||
WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $2 || '%')
|
||||
ORDER BY lower(name) LIMIT 50`, userID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.Tag{}
|
||||
for rows.Next() {
|
||||
var t models.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EnsureTag returns the user's tag with the given name, creating it if needed.
|
||||
func (s *Store) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > 64 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var t models.Tag
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
||||
Scan(&t.ID, &t.Name)
|
||||
if err == nil {
|
||||
return &t, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
id := auth.NewID()
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`INSERT INTO tags (id, user_id, name) VALUES ($1,$2,$3) RETURNING id, name`, id, userID, name).
|
||||
Scan(&t.ID, &t.Name)
|
||||
if err != nil {
|
||||
if e2 := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
||||
Scan(&t.ID, &t.Name); e2 == nil {
|
||||
return &t, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
|
||||
DELETE FROM photos WHERE album_id IS NULL;
|
||||
ALTER TABLE photos ALTER COLUMN album_id SET NOT NULL;
|
||||
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
|
||||
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
|
||||
ALTER TABLE photos ALTER COLUMN album_id DROP NOT NULL;
|
||||
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
|
||||
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS photo_tags;
|
||||
DROP TABLE IF EXISTS tags;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id text PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_user_name_idx ON tags (user_id, lower(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_tags (
|
||||
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (photo_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photo_tags_tag_idx ON photo_tags (tag_id);
|
||||
Reference in New Issue
Block a user