package photos import ( "context" "encoding/json" "errors" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "shared-album/internal/models" ) var ErrNotFound = errors.New("photo not found") type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } const photoColumns = `p.id, 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, u.id, u.name, COALESCE(u.avatar_url, ''), (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` func scanPhoto(row pgx.Row) (*models.Photo, error) { var p models.Photo var exifText 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) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } setExif(&p, exifText) return &p, nil } func setExif(p *models.Photo, raw string) { if raw == "" || raw == "null" || raw == "{}" { return } var m map[string]any if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 { p.EXIF = m } } func (s *Store) Create(ctx context.Context, p *models.Photo) error { exifJSON, err := json.Marshal(p.EXIF) if err != nil || len(exifJSON) == 0 { exifJSON = []byte("{}") } return s.pool.QueryRow(ctx, `INSERT INTO photos (id, album_id, uploader_id, storage_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::jsonb) RETURNING created_at`, p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename, p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON), ).Scan(&p.CreatedAt) } func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error { _, err := s.pool.Exec(ctx, `UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey) return err } func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) { return scanPhoto(s.pool.QueryRow(ctx, `SELECT `+photoColumns+` FROM photos p JOIN users u ON u.id = p.uploader_id WHERE p.id = $1`, id, viewerID)) } type ListParams struct { AlbumID string Viewer string Cursor string Since *time.Time Limit int } func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) { if in.Limit <= 0 || in.Limit > 100 { in.Limit = 50 } rows, err := s.pool.Query(ctx, `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 LIMIT $5`, in.AlbumID, in.Viewer, in.Cursor, in.Since, in.Limit) 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); err != nil { return nil, err } setExif(&p, exifText) out = append(out, p) } return out, rows.Err() } 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 } if tag.RowsAffected() == 0 { return ErrNotFound } 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`, albumID, photoID) } func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error { tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } type backfillRow struct { ID string StorageKey string } func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) { rows, err := s.pool.Query(ctx, `SELECT id, storage_key FROM photos WHERE exif IS NULL OR exif = '{}'::jsonb ORDER BY created_at`) if err != nil { return 0, err } var pending []backfillRow for rows.Next() { var r backfillRow if err := rows.Scan(&r.ID, &r.StorageKey); err != nil { rows.Close() return 0, err } pending = append(pending, r) } rows.Close() if err := rows.Err(); err != nil { return 0, err } updated := 0 for _, r := range pending { data, err := fetch(ctx, r.StorageKey) if err != nil { continue } meta := extractEXIF(data) if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil { continue } attrs, err := json.Marshal(meta.Attributes) if err != nil || len(attrs) == 0 { attrs = []byte("{}") } _, err = s.pool.Exec(ctx, `UPDATE photos SET exif = $2::jsonb, taken_at = COALESCE(taken_at, $3), latitude = COALESCE(latitude, $4), longitude = COALESCE(longitude, $5) WHERE id = $1`, r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude) if err != nil { continue } updated++ } return updated, nil }