mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-16 11:46:56 +02:00
feat(story-2.1): add feed model and add-feed-by-url API
This commit is contained in:
1 parent
dbea4b47af
commit
1249e1bd42
9 files changed
+781
-4
No files matched your search
@@ -35,17 +35,17 @@ story_location: "_bmad-output/implementation-artifacts"
|
|||||||
|
|
||||||
development_status:
|
development_status:
|
||||||
# Epic 1: Walking Skeleton & User Access
|
# Epic 1: Walking Skeleton & User Access
|
||||||
epic-1: in-progress
|
epic-1: done
|
||||||
1-1-project-initialization-walking-skeleton: done
|
1-1-project-initialization-walking-skeleton: done
|
||||||
1-2-database-migration-system: done
|
1-2-database-migration-system: done
|
||||||
1-3-user-registration-api: done
|
1-3-user-registration-api: done
|
||||||
1-4-session-authentication: done
|
1-4-session-authentication: done
|
||||||
1-5-frontend-auth-foundation: review
|
1-5-frontend-auth-foundation: done
|
||||||
epic-1-retrospective: optional
|
epic-1-retrospective: optional
|
||||||
|
|
||||||
# Epic 2: Feed Core Engine
|
# Epic 2: Feed Core Engine
|
||||||
epic-2: backlog
|
epic-2: in-progress
|
||||||
2-1-feed-model-add-feed-by-url: backlog
|
2-1-feed-model-add-feed-by-url: review
|
||||||
2-2-feed-metadata-auto-discovery: backlog
|
2-2-feed-metadata-auto-discovery: backlog
|
||||||
2-3-opml-import: backlog
|
2-3-opml-import: backlog
|
||||||
2-4-feed-management-crud: backlog
|
2-4-feed-management-crud: backlog
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ func main() {
|
|||||||
// Initialize repositories
|
// Initialize repositories
|
||||||
userRepo := repository.NewUserRepository(pool)
|
userRepo := repository.NewUserRepository(pool)
|
||||||
sessionRepo := repository.NewSessionRepository(pool)
|
sessionRepo := repository.NewSessionRepository(pool)
|
||||||
|
feedRepo := repository.NewFeedRepository(pool)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
authService := service.NewAuthService(userRepo, sessionRepo)
|
authService := service.NewAuthService(userRepo, sessionRepo)
|
||||||
|
feedService := service.NewFeedService(feedRepo)
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
|
feedHandler := handler.NewFeedHandler(feedService, authService)
|
||||||
|
|
||||||
// Initialize router
|
// Initialize router
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -84,6 +87,14 @@ func main() {
|
|||||||
r.Route("/users", func(r chi.Router) {
|
r.Route("/users", func(r chi.Router) {
|
||||||
r.Get("/me", authHandler.Me)
|
r.Get("/me", authHandler.Me)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Feed routes
|
||||||
|
r.Route("/feeds", func(r chi.Router) {
|
||||||
|
r.Get("/", feedHandler.List)
|
||||||
|
r.Post("/", feedHandler.Add)
|
||||||
|
r.Get("/{id}", feedHandler.Get)
|
||||||
|
r.Delete("/{id}", feedHandler.Delete)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create server
|
// Create server
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Article represents an item from an RSS/Atom feed.
|
||||||
|
type Article struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FeedID uuid.UUID `json:"feed_id"`
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||||
|
IsRead bool `json:"is_read"`
|
||||||
|
IsFavorite bool `json:"is_favorite"`
|
||||||
|
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Virtual fields (from joins)
|
||||||
|
FeedTitle string `json:"feed_title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleRepository defines the interface for article data access.
|
||||||
|
type ArticleRepository interface {
|
||||||
|
Create(article *Article) error
|
||||||
|
CreateBatch(articles []*Article) error
|
||||||
|
GetByID(id uuid.UUID) (*Article, error)
|
||||||
|
GetByFeedID(feedID uuid.UUID, limit, offset int) ([]*Article, error)
|
||||||
|
GetByUserID(userID uuid.UUID, limit, offset int, unreadOnly bool) ([]*Article, error)
|
||||||
|
GetByGUID(feedID uuid.UUID, guid string) (*Article, error)
|
||||||
|
MarkAsRead(id uuid.UUID) error
|
||||||
|
MarkAsUnread(id uuid.UUID) error
|
||||||
|
MarkAllAsRead(feedID uuid.UUID) error
|
||||||
|
ToggleFavorite(id uuid.UUID) error
|
||||||
|
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
|
||||||
|
CountUnread(feedID uuid.UUID) (int, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feed represents an RSS/Atom feed subscription.
|
||||||
|
type Feed struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
SiteURL string `json:"site_url,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"`
|
||||||
|
FetchError string `json:"fetch_error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Virtual fields (not in DB)
|
||||||
|
UnreadCount int `json:"unread_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedRepository defines the interface for feed data access.
|
||||||
|
type FeedRepository interface {
|
||||||
|
Create(feed *Feed) error
|
||||||
|
GetByID(id uuid.UUID) (*Feed, error)
|
||||||
|
GetByUserID(userID uuid.UUID) ([]*Feed, error)
|
||||||
|
GetByURL(userID uuid.UUID, url string) (*Feed, error)
|
||||||
|
Update(feed *Feed) error
|
||||||
|
Delete(id uuid.UUID) error
|
||||||
|
GetFeedsToFetch(limit int) ([]*Feed, error)
|
||||||
|
UpdateFetchStatus(id uuid.UUID, fetchedAt time.Time, fetchError string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/michael/flowreader/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedHandler handles feed-related HTTP requests.
|
||||||
|
type FeedHandler struct {
|
||||||
|
feedService *service.FeedService
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeedHandler creates a new feed handler.
|
||||||
|
func NewFeedHandler(feedService *service.FeedService, authService *service.AuthService) *FeedHandler {
|
||||||
|
return &FeedHandler{
|
||||||
|
feedService: feedService,
|
||||||
|
authService: authService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserFromRequest extracts the authenticated user from the request.
|
||||||
|
func (h *FeedHandler) getUserFromRequest(r *http.Request) (uuid.UUID, error) {
|
||||||
|
cookie, err := r.Cookie("session_id")
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, errors.New("not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.authService.GetUserByToken(cookie.Value)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return uuid.Nil, errors.New("invalid session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/v1/feeds
|
||||||
|
func (h *FeedHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds, err := h.feedService.GetUserFeeds(userID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to get feeds")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, feeds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add handles POST /api/v1/feeds
|
||||||
|
func (h *FeedHandler) Add(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req service.AddFeedRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.UserID = userID
|
||||||
|
|
||||||
|
resp, err := h.feedService.AddFeed(req)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrInvalidURL):
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid URL format")
|
||||||
|
case errors.Is(err, service.ErrFeedExists):
|
||||||
|
respondError(w, http.StatusConflict, "Feed already exists")
|
||||||
|
default:
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to add feed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusCreated, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/v1/feeds/{id}
|
||||||
|
func (h *FeedHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid feed ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := h.feedService.GetFeed(feedID, userID)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrFeedNotFound):
|
||||||
|
respondError(w, http.StatusNotFound, "Feed not found")
|
||||||
|
case errors.Is(err, service.ErrUnauthorized):
|
||||||
|
respondError(w, http.StatusForbidden, "Access denied")
|
||||||
|
default:
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to get feed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/v1/feeds/{id}
|
||||||
|
func (h *FeedHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.getUserFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid feed ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.feedService.DeleteFeed(feedID, userID)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrFeedNotFound):
|
||||||
|
respondError(w, http.StatusNotFound, "Feed not found")
|
||||||
|
case errors.Is(err, service.ErrUnauthorized):
|
||||||
|
respondError(w, http.StatusForbidden, "Access denied")
|
||||||
|
default:
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to delete feed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, map[string]string{"message": "Feed deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/michael/flowreader/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedRepository implements domain.FeedRepository using PostgreSQL.
|
||||||
|
type FeedRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeedRepository creates a new feed repository.
|
||||||
|
func NewFeedRepository(pool *pgxpool.Pool) *FeedRepository {
|
||||||
|
return &FeedRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new feed into the database.
|
||||||
|
func (r *FeedRepository) Create(feed *domain.Feed) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO feeds (id, user_id, url, title, description, site_url, image_url, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, query,
|
||||||
|
feed.ID,
|
||||||
|
feed.UserID,
|
||||||
|
feed.URL,
|
||||||
|
feed.Title,
|
||||||
|
feed.Description,
|
||||||
|
feed.SiteURL,
|
||||||
|
feed.ImageURL,
|
||||||
|
feed.CreatedAt,
|
||||||
|
feed.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves a feed by its ID.
|
||||||
|
func (r *FeedRepository) GetByID(id uuid.UUID) (*domain.Feed, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, url, title, description, site_url, image_url,
|
||||||
|
last_fetched_at, fetch_error, created_at, updated_at
|
||||||
|
FROM feeds
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var feed domain.Feed
|
||||||
|
var description, siteURL, imageURL, fetchError *string
|
||||||
|
var lastFetchedAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, query, id).Scan(
|
||||||
|
&feed.ID,
|
||||||
|
&feed.UserID,
|
||||||
|
&feed.URL,
|
||||||
|
&feed.Title,
|
||||||
|
&description,
|
||||||
|
&siteURL,
|
||||||
|
&imageURL,
|
||||||
|
&lastFetchedAt,
|
||||||
|
&fetchError,
|
||||||
|
&feed.CreatedAt,
|
||||||
|
&feed.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting feed by ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if description != nil {
|
||||||
|
feed.Description = *description
|
||||||
|
}
|
||||||
|
if siteURL != nil {
|
||||||
|
feed.SiteURL = *siteURL
|
||||||
|
}
|
||||||
|
if imageURL != nil {
|
||||||
|
feed.ImageURL = *imageURL
|
||||||
|
}
|
||||||
|
if fetchError != nil {
|
||||||
|
feed.FetchError = *fetchError
|
||||||
|
}
|
||||||
|
feed.LastFetchedAt = lastFetchedAt
|
||||||
|
|
||||||
|
return &feed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUserID retrieves all feeds for a user.
|
||||||
|
func (r *FeedRepository) GetByUserID(userID uuid.UUID) ([]*domain.Feed, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT f.id, f.user_id, f.url, f.title, f.description, f.site_url, f.image_url,
|
||||||
|
f.last_fetched_at, f.fetch_error, f.created_at, f.updated_at,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM articles a WHERE a.feed_id = f.id AND a.is_read = false), 0) as unread_count
|
||||||
|
FROM feeds f
|
||||||
|
WHERE f.user_id = $1
|
||||||
|
ORDER BY f.title ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying feeds: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var feeds []*domain.Feed
|
||||||
|
for rows.Next() {
|
||||||
|
var feed domain.Feed
|
||||||
|
var description, siteURL, imageURL, fetchError *string
|
||||||
|
var lastFetchedAt *time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&feed.ID,
|
||||||
|
&feed.UserID,
|
||||||
|
&feed.URL,
|
||||||
|
&feed.Title,
|
||||||
|
&description,
|
||||||
|
&siteURL,
|
||||||
|
&imageURL,
|
||||||
|
&lastFetchedAt,
|
||||||
|
&fetchError,
|
||||||
|
&feed.CreatedAt,
|
||||||
|
&feed.UpdatedAt,
|
||||||
|
&feed.UnreadCount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if description != nil {
|
||||||
|
feed.Description = *description
|
||||||
|
}
|
||||||
|
if siteURL != nil {
|
||||||
|
feed.SiteURL = *siteURL
|
||||||
|
}
|
||||||
|
if imageURL != nil {
|
||||||
|
feed.ImageURL = *imageURL
|
||||||
|
}
|
||||||
|
if fetchError != nil {
|
||||||
|
feed.FetchError = *fetchError
|
||||||
|
}
|
||||||
|
feed.LastFetchedAt = lastFetchedAt
|
||||||
|
|
||||||
|
feeds = append(feeds, &feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feeds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByURL retrieves a feed by its URL for a specific user.
|
||||||
|
func (r *FeedRepository) GetByURL(userID uuid.UUID, url string) (*domain.Feed, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, url, title, description, site_url, image_url,
|
||||||
|
last_fetched_at, fetch_error, created_at, updated_at
|
||||||
|
FROM feeds
|
||||||
|
WHERE user_id = $1 AND url = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
var feed domain.Feed
|
||||||
|
var description, siteURL, imageURL, fetchError *string
|
||||||
|
var lastFetchedAt *time.Time
|
||||||
|
|
||||||
|
err := r.pool.QueryRow(ctx, query, userID, url).Scan(
|
||||||
|
&feed.ID,
|
||||||
|
&feed.UserID,
|
||||||
|
&feed.URL,
|
||||||
|
&feed.Title,
|
||||||
|
&description,
|
||||||
|
&siteURL,
|
||||||
|
&imageURL,
|
||||||
|
&lastFetchedAt,
|
||||||
|
&fetchError,
|
||||||
|
&feed.CreatedAt,
|
||||||
|
&feed.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting feed by URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if description != nil {
|
||||||
|
feed.Description = *description
|
||||||
|
}
|
||||||
|
if siteURL != nil {
|
||||||
|
feed.SiteURL = *siteURL
|
||||||
|
}
|
||||||
|
if imageURL != nil {
|
||||||
|
feed.ImageURL = *imageURL
|
||||||
|
}
|
||||||
|
if fetchError != nil {
|
||||||
|
feed.FetchError = *fetchError
|
||||||
|
}
|
||||||
|
feed.LastFetchedAt = lastFetchedAt
|
||||||
|
|
||||||
|
return &feed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a feed in the database.
|
||||||
|
func (r *FeedRepository) Update(feed *domain.Feed) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE feeds
|
||||||
|
SET title = $2, description = $3, site_url = $4, image_url = $5, updated_at = $6
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, query,
|
||||||
|
feed.ID,
|
||||||
|
feed.Title,
|
||||||
|
feed.Description,
|
||||||
|
feed.SiteURL,
|
||||||
|
feed.ImageURL,
|
||||||
|
time.Now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a feed from the database.
|
||||||
|
func (r *FeedRepository) Delete(id uuid.UUID) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `DELETE FROM feeds WHERE id = $1`
|
||||||
|
_, err := r.pool.Exec(ctx, query, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeedsToFetch returns feeds that need to be fetched.
|
||||||
|
func (r *FeedRepository) GetFeedsToFetch(limit int) ([]*domain.Feed, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, url, title, description, site_url, image_url,
|
||||||
|
last_fetched_at, fetch_error, created_at, updated_at
|
||||||
|
FROM feeds
|
||||||
|
WHERE last_fetched_at IS NULL
|
||||||
|
OR last_fetched_at < NOW() - INTERVAL '15 minutes'
|
||||||
|
ORDER BY last_fetched_at ASC NULLS FIRST
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying feeds to fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var feeds []*domain.Feed
|
||||||
|
for rows.Next() {
|
||||||
|
var feed domain.Feed
|
||||||
|
var description, siteURL, imageURL, fetchError *string
|
||||||
|
var lastFetchedAt *time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&feed.ID,
|
||||||
|
&feed.UserID,
|
||||||
|
&feed.URL,
|
||||||
|
&feed.Title,
|
||||||
|
&description,
|
||||||
|
&siteURL,
|
||||||
|
&imageURL,
|
||||||
|
&lastFetchedAt,
|
||||||
|
&fetchError,
|
||||||
|
&feed.CreatedAt,
|
||||||
|
&feed.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if description != nil {
|
||||||
|
feed.Description = *description
|
||||||
|
}
|
||||||
|
if siteURL != nil {
|
||||||
|
feed.SiteURL = *siteURL
|
||||||
|
}
|
||||||
|
if imageURL != nil {
|
||||||
|
feed.ImageURL = *imageURL
|
||||||
|
}
|
||||||
|
if fetchError != nil {
|
||||||
|
feed.FetchError = *fetchError
|
||||||
|
}
|
||||||
|
feed.LastFetchedAt = lastFetchedAt
|
||||||
|
|
||||||
|
feeds = append(feeds, &feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feeds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFetchStatus updates the fetch status of a feed.
|
||||||
|
func (r *FeedRepository) UpdateFetchStatus(id uuid.UUID, fetchedAt time.Time, fetchError string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if fetchError == "" {
|
||||||
|
query = `UPDATE feeds SET last_fetched_at = $2, fetch_error = NULL WHERE id = $1`
|
||||||
|
args = []interface{}{id, fetchedAt}
|
||||||
|
} else {
|
||||||
|
query = `UPDATE feeds SET last_fetched_at = $2, fetch_error = $3 WHERE id = $1`
|
||||||
|
args = []interface{}{id, fetchedAt, fetchError}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating fetch status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/michael/flowreader/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feed service errors
|
||||||
|
var (
|
||||||
|
ErrInvalidURL = errors.New("invalid feed URL")
|
||||||
|
ErrFeedExists = errors.New("feed already exists")
|
||||||
|
ErrFeedNotFound = errors.New("feed not found")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized access")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedService handles feed-related business logic.
|
||||||
|
type FeedService struct {
|
||||||
|
feedRepo domain.FeedRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFeedService creates a new feed service.
|
||||||
|
func NewFeedService(feedRepo domain.FeedRepository) *FeedService {
|
||||||
|
return &FeedService{feedRepo: feedRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFeedRequest contains the data needed to add a new feed.
|
||||||
|
type AddFeedRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
UserID uuid.UUID `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFeedResponse contains the created feed data.
|
||||||
|
type AddFeedResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFeed creates a new feed subscription.
|
||||||
|
func (s *FeedService) AddFeed(req AddFeedRequest) (*AddFeedResponse, error) {
|
||||||
|
// Validate URL
|
||||||
|
parsedURL, err := url.ParseRequestURI(req.URL)
|
||||||
|
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
|
||||||
|
return nil, ErrInvalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize URL
|
||||||
|
normalizedURL := parsedURL.String()
|
||||||
|
|
||||||
|
// Check if feed already exists for this user
|
||||||
|
existing, err := s.feedRepo.GetByURL(req.UserID, normalizedURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("checking existing feed: %w", err)
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return nil, ErrFeedExists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create feed (title will be updated after first fetch)
|
||||||
|
now := time.Now()
|
||||||
|
feed := &domain.Feed{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: req.UserID,
|
||||||
|
URL: normalizedURL,
|
||||||
|
Title: normalizedURL, // Temporary title until fetched
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.feedRepo.Create(feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("creating feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AddFeedResponse{
|
||||||
|
ID: feed.ID,
|
||||||
|
URL: feed.URL,
|
||||||
|
Title: feed.Title,
|
||||||
|
CreatedAt: feed.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFeeds returns all feeds for a user.
|
||||||
|
func (s *FeedService) GetUserFeeds(userID uuid.UUID) ([]*domain.Feed, error) {
|
||||||
|
feeds, err := s.feedRepo.GetByUserID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting user feeds: %w", err)
|
||||||
|
}
|
||||||
|
return feeds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeed returns a single feed by ID, verifying ownership.
|
||||||
|
func (s *FeedService) GetFeed(feedID, userID uuid.UUID) (*domain.Feed, error) {
|
||||||
|
feed, err := s.feedRepo.GetByID(feedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting feed: %w", err)
|
||||||
|
}
|
||||||
|
if feed == nil {
|
||||||
|
return nil, ErrFeedNotFound
|
||||||
|
}
|
||||||
|
if feed.UserID != userID {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
return feed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFeed removes a feed subscription.
|
||||||
|
func (s *FeedService) DeleteFeed(feedID, userID uuid.UUID) error {
|
||||||
|
feed, err := s.feedRepo.GetByID(feedID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting feed: %w", err)
|
||||||
|
}
|
||||||
|
if feed == nil {
|
||||||
|
return ErrFeedNotFound
|
||||||
|
}
|
||||||
|
if feed.UserID != userID {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.feedRepo.Delete(feedID); err != nil {
|
||||||
|
return fmt.Errorf("deleting feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback: 003_create_feeds
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_feeds_updated_at ON feeds;
|
||||||
|
DROP TABLE IF EXISTS articles;
|
||||||
|
DROP TABLE IF EXISTS feeds;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Migration: 003_create_feeds
|
||||||
|
-- Description: Create feeds and articles tables for RSS management
|
||||||
|
|
||||||
|
-- Feeds table
|
||||||
|
CREATE TABLE IF NOT EXISTS feeds (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
url VARCHAR(2048) NOT NULL,
|
||||||
|
title VARCHAR(512),
|
||||||
|
description TEXT,
|
||||||
|
site_url VARCHAR(2048),
|
||||||
|
image_url VARCHAR(2048),
|
||||||
|
last_fetched_at TIMESTAMPTZ,
|
||||||
|
fetch_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Unique constraint: one user can't add same feed twice
|
||||||
|
UNIQUE(user_id, url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for feeds
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feeds_user_id ON feeds(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feeds_last_fetched ON feeds(last_fetched_at);
|
||||||
|
|
||||||
|
-- Articles table
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
feed_id UUID NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
||||||
|
guid VARCHAR(2048) NOT NULL,
|
||||||
|
title VARCHAR(1024) NOT NULL,
|
||||||
|
url VARCHAR(2048),
|
||||||
|
content TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
author VARCHAR(256),
|
||||||
|
image_url VARCHAR(2048),
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_favorite BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Unique constraint: one article per feed (by guid)
|
||||||
|
UNIQUE(feed_id, guid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for articles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_is_read ON articles(feed_id, is_read);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_is_favorite ON articles(feed_id, is_favorite);
|
||||||
|
|
||||||
|
-- Trigger for feeds updated_at
|
||||||
|
CREATE TRIGGER update_feeds_updated_at
|
||||||
|
BEFORE UPDATE ON feeds
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
Reference in new issue
Block a user