feat(audiobooks): RSS feeds — auth handlers + pgx store
POST /api/feeds/item/{itemId}/open (auto-gen or custom slug),
GET /api/feeds (owner+profile scope, only open feeds),
POST /api/feeds/{id}/close (idempotent, owner-gated).
Slug validation via ^[a-z0-9-]{4,64}$. 409 on collision via pgx
unique-violation substring detection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ebe8ad3bd7
commit
fa5b0c59c9
@@ -215,6 +215,9 @@ type Dependencies struct {
|
||||
// SmartCollectionStore persists abs_smart_collections rows
|
||||
// (migration 153). May be nil; handlers respond 503 when unset.
|
||||
SmartCollectionStore SmartCollectionStore
|
||||
// RSSFeedStore persists abs_rss_feeds rows (migration 155).
|
||||
// May be nil; handlers respond 503 when unset.
|
||||
RSSFeedStore RSSFeedStore
|
||||
// SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil;
|
||||
// the route is only registered when a non-nil value is supplied.
|
||||
SocketIO SocketIOServer
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RSSFeedStore is the storage contract for the abs_rss_feeds table.
|
||||
type RSSFeedStore interface {
|
||||
ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error)
|
||||
GetFeed(ctx context.Context, id string) (RSSFeed, error)
|
||||
GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error)
|
||||
CreateFeed(ctx context.Context, f RSSFeed) error
|
||||
CloseFeed(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// RSSFeed mirrors an abs_rss_feeds row.
|
||||
type RSSFeed struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
LibraryItemID string
|
||||
Slug string
|
||||
Minified bool
|
||||
CreatedAt time.Time
|
||||
ClosedAt *time.Time
|
||||
}
|
||||
|
||||
// rssFeedToABS shapes a feed in the ABS wire format. `url` is built
|
||||
// from the supplied base URL + slug.
|
||||
func rssFeedToABS(f RSSFeed, baseURL string) map[string]any {
|
||||
url := baseURL + "/feed/" + f.Slug + ".xml"
|
||||
return map[string]any{
|
||||
"id": f.ID,
|
||||
"userId": f.UserID,
|
||||
"libraryItemId": f.LibraryItemID,
|
||||
"slug": f.Slug,
|
||||
"minified": f.Minified,
|
||||
"createdAt": f.CreatedAt.UnixMilli(),
|
||||
"url": url,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9-]{4,64}$`)
|
||||
|
||||
type feedOpenBody struct {
|
||||
Slug string `json:"slug"`
|
||||
Minified bool `json:"minified"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleListRSSFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := absAuthFrom(r)
|
||||
if !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.RSSFeedStore == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"feeds": []any{}})
|
||||
return
|
||||
}
|
||||
rows, err := h.deps.RSSFeedStore.ListUserFeeds(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs feed list failed", "err", err, "user", a.UserID)
|
||||
http.Error(w, "feed list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
base := h.absBaseURL(r)
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, f := range rows {
|
||||
out = append(out, rssFeedToABS(f, base))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"feeds": out})
|
||||
}
|
||||
|
||||
func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := absAuthFrom(r)
|
||||
if !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.RSSFeedStore == nil {
|
||||
http.Error(w, "feed store unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID)
|
||||
if err != nil || item == nil {
|
||||
http.Error(w, "item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body feedOpenBody
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
|
||||
|
||||
slug := strings.ToLower(strings.TrimSpace(body.Slug))
|
||||
if slug == "" {
|
||||
slug = randomSlug()
|
||||
} else if !slugRe.MatchString(slug) {
|
||||
http.Error(w, "invalid slug", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
f := RSSFeed{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: a.UserID,
|
||||
ProfileID: a.ProfileID,
|
||||
LibraryItemID: itemID,
|
||||
Slug: slug,
|
||||
Minified: body.Minified,
|
||||
}
|
||||
if err := h.deps.RSSFeedStore.CreateFeed(r.Context(), f); err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
|
||||
http.Error(w, "slug taken", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
slog.Error("abs feed create failed", "err", err, "user", a.UserID)
|
||||
http.Error(w, "feed persist failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
persisted, err := h.deps.RSSFeedStore.GetFeed(r.Context(), f.ID)
|
||||
if errors.Is(err, ErrNotFound) || err != nil {
|
||||
f.CreatedAt = time.Now()
|
||||
persisted = f
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rssFeedToABS(persisted, h.absBaseURL(r)))
|
||||
}
|
||||
|
||||
func (h *Handler) handleCloseFeed(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := absAuthFrom(r)
|
||||
if !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.RSSFeedStore == nil {
|
||||
http.Error(w, "feed not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
f, err := h.deps.RSSFeedStore.GetFeed(r.Context(), id)
|
||||
if errors.Is(err, ErrNotFound) || (err == nil && f.UserID != a.UserID) {
|
||||
http.Error(w, "feed not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs feed get-for-close failed", "err", err, "id", id)
|
||||
http.Error(w, "feed get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.deps.RSSFeedStore.CloseFeed(r.Context(), id); err != nil {
|
||||
slog.Error("abs feed close failed", "err", err, "id", id)
|
||||
http.Error(w, "feed close failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func randomSlug() string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
for i, b := range buf {
|
||||
buf[i] = alphabet[int(b)%len(alphabet)]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package abs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
type memRSSFeedStore struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]RSSFeed
|
||||
}
|
||||
|
||||
func newMemRSSFeedStore() *memRSSFeedStore { return &memRSSFeedStore{rows: map[string]RSSFeed{}} }
|
||||
|
||||
func (m *memRSSFeedStore) ListUserFeeds(_ context.Context, userID, profileID string) ([]RSSFeed, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]RSSFeed, 0)
|
||||
for _, f := range m.rows {
|
||||
if f.UserID == userID && f.ProfileID == profileID && f.ClosedAt == nil {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memRSSFeedStore) GetFeed(_ context.Context, id string) (RSSFeed, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
f, ok := m.rows[id]
|
||||
if !ok {
|
||||
return RSSFeed{}, ErrNotFound
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (m *memRSSFeedStore) GetFeedBySlug(_ context.Context, slug string) (RSSFeed, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, f := range m.rows {
|
||||
if f.Slug == slug && f.ClosedAt == nil {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
return RSSFeed{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *memRSSFeedStore) CreateFeed(_ context.Context, f RSSFeed) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, existing := range m.rows {
|
||||
if existing.Slug == f.Slug && existing.ClosedAt == nil {
|
||||
return errors.New("unique violation duplicate key")
|
||||
}
|
||||
}
|
||||
f.CreatedAt = time.Now()
|
||||
m.rows[f.ID] = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memRSSFeedStore) CloseFeed(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
f, ok := m.rows[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
f.ClosedAt = &now
|
||||
m.rows[id] = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func newFeedsHarness(t *testing.T, knownItems ...string) (*Handler, *memRSSFeedStore) {
|
||||
t.Helper()
|
||||
known := map[string]*models.MediaItem{}
|
||||
for _, id := range knownItems {
|
||||
known[id] = nil
|
||||
}
|
||||
store := newMemRSSFeedStore()
|
||||
h := New(Dependencies{MediaStore: &stubMediaStore{known: known}, RSSFeedStore: store})
|
||||
return h, store
|
||||
}
|
||||
|
||||
func TestFeed_Open_GeneratesSlug(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
|
||||
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
slug, _ := got["slug"].(string)
|
||||
if len(slug) != 16 {
|
||||
t.Errorf("slug = %q (len %d), want 16-char auto-generated", slug, len(slug))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_Open_CustomSlug(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
|
||||
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"my-cool-feed"}`), "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got["slug"] != "my-cool-feed" {
|
||||
t.Errorf("slug = %v, want my-cool-feed", got["slug"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_Open_InvalidSlug_400(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
|
||||
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"BAD!"}`), "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_Open_Collision_409(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
body := []byte(`{"slug":"taken-slug"}`)
|
||||
_ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed)
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_Open_UnknownItem_404(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t)
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/ghost/open",
|
||||
map[string]string{"itemId": "ghost"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_List_OwnerOnly(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
_ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/feeds", nil, nil, "2", "", h.handleListRSSFeeds)
|
||||
var env map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
feeds, _ := env["feeds"].([]any)
|
||||
if len(feeds) != 0 {
|
||||
t.Errorf("user 2 sees %d feeds, want 0", len(feeds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_Close_Owner(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
var open map[string]any
|
||||
_ = json.Unmarshal(openRec.Body.Bytes(), &open)
|
||||
id, _ := open["id"].(string)
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "1", "", h.handleCloseFeed)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeed_Close_NonOwner_404(t *testing.T) {
|
||||
h, _ := newFeedsHarness(t, "book-1")
|
||||
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
|
||||
var open map[string]any
|
||||
_ = json.Unmarshal(openRec.Body.Bytes(), &open)
|
||||
id, _ := open["id"].(string)
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "2", "", h.handleCloseFeed)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package audiobooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
|
||||
)
|
||||
|
||||
type ABSRSSFeedStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
var _ abs.RSSFeedStore = (*ABSRSSFeedStore)(nil)
|
||||
|
||||
func (s *ABSRSSFeedStore) ListUserFeeds(ctx context.Context, userID, profileID string) ([]abs.RSSFeed, error) {
|
||||
uid, err := strconv.Atoi(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs_rss_feed_store: invalid user id %q: %w", userID, err)
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at
|
||||
FROM abs_rss_feeds
|
||||
WHERE user_id = $1
|
||||
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
AND closed_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
uid, profileArg(profileID),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs_rss_feed_store: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]abs.RSSFeed, 0)
|
||||
for rows.Next() {
|
||||
var f abs.RSSFeed
|
||||
var uidScan int
|
||||
var profileScan *string
|
||||
if err := rows.Scan(&f.ID, &uidScan, &profileScan, &f.LibraryItemID, &f.Slug, &f.Minified, &f.CreatedAt, &f.ClosedAt); err != nil {
|
||||
return nil, fmt.Errorf("abs_rss_feed_store: list scan: %w", err)
|
||||
}
|
||||
f.UserID = strconv.Itoa(uidScan)
|
||||
if profileScan != nil {
|
||||
f.ProfileID = *profileScan
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("abs_rss_feed_store: list rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ABSRSSFeedStore) GetFeed(ctx context.Context, id string) (abs.RSSFeed, error) {
|
||||
return s.getFeedRow(ctx, "id = $1", id)
|
||||
}
|
||||
|
||||
func (s *ABSRSSFeedStore) GetFeedBySlug(ctx context.Context, slug string) (abs.RSSFeed, error) {
|
||||
return s.getFeedRow(ctx, "slug = $1 AND closed_at IS NULL", slug)
|
||||
}
|
||||
|
||||
func (s *ABSRSSFeedStore) getFeedRow(ctx context.Context, where string, arg string) (abs.RSSFeed, error) {
|
||||
var f abs.RSSFeed
|
||||
var uidScan int
|
||||
var profileScan *string
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at
|
||||
FROM abs_rss_feeds WHERE `+where, arg)
|
||||
if err := row.Scan(&f.ID, &uidScan, &profileScan, &f.LibraryItemID, &f.Slug, &f.Minified, &f.CreatedAt, &f.ClosedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return abs.RSSFeed{}, abs.ErrNotFound
|
||||
}
|
||||
return abs.RSSFeed{}, fmt.Errorf("abs_rss_feed_store: get: %w", err)
|
||||
}
|
||||
f.UserID = strconv.Itoa(uidScan)
|
||||
if profileScan != nil {
|
||||
f.ProfileID = *profileScan
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *ABSRSSFeedStore) CreateFeed(ctx context.Context, f abs.RSSFeed) error {
|
||||
uid, err := strconv.Atoi(f.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("abs_rss_feed_store: invalid user id %q: %w", f.UserID, err)
|
||||
}
|
||||
if _, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO abs_rss_feeds (id, user_id, profile_id, library_item_id, slug, minified)
|
||||
VALUES ($1, $2, $3::uuid, $4, $5, $6)`,
|
||||
f.ID, uid, profileArg(f.ProfileID), f.LibraryItemID, f.Slug, f.Minified,
|
||||
); err != nil {
|
||||
return fmt.Errorf("abs_rss_feed_store: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ABSRSSFeedStore) CloseFeed(ctx context.Context, id string) error {
|
||||
if _, err := s.Pool.Exec(ctx, `UPDATE abs_rss_feeds SET closed_at = now() WHERE id = $1 AND closed_at IS NULL`, id); err != nil {
|
||||
return fmt.Errorf("abs_rss_feed_store: close: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user