mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +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:
|
||||
# Epic 1: Walking Skeleton & User Access
|
||||
epic-1: in-progress
|
||||
epic-1: done
|
||||
1-1-project-initialization-walking-skeleton: done
|
||||
1-2-database-migration-system: done
|
||||
1-3-user-registration-api: done
|
||||
1-4-session-authentication: done
|
||||
1-5-frontend-auth-foundation: review
|
||||
1-5-frontend-auth-foundation: done
|
||||
epic-1-retrospective: optional
|
||||
|
||||
# Epic 2: Feed Core Engine
|
||||
epic-2: backlog
|
||||
2-1-feed-model-add-feed-by-url: backlog
|
||||
epic-2: in-progress
|
||||
2-1-feed-model-add-feed-by-url: review
|
||||
2-2-feed-metadata-auto-discovery: backlog
|
||||
2-3-opml-import: backlog
|
||||
2-4-feed-management-crud: backlog
|
||||
|
||||
@@ -38,12 +38,15 @@ func main() {
|
||||
// Initialize repositories
|
||||
userRepo := repository.NewUserRepository(pool)
|
||||
sessionRepo := repository.NewSessionRepository(pool)
|
||||
feedRepo := repository.NewFeedRepository(pool)
|
||||
|
||||
// Initialize services
|
||||
authService := service.NewAuthService(userRepo, sessionRepo)
|
||||
feedService := service.NewFeedService(feedRepo)
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handler.NewAuthHandler(authService)
|
||||
feedHandler := handler.NewFeedHandler(feedService, authService)
|
||||
|
||||
// Initialize router
|
||||
r := chi.NewRouter()
|
||||
@@ -84,6 +87,14 @@ func main() {
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
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
|
||||
|
||||
@@ -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