feat(story-2.1): add feed model and add-feed-by-url API

This commit is contained in:
Michael committed 2026-02-04 14:52:40 +01:00
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
+11
View File
@@ -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
+44
View File
@@ -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)
}
+37
View File
@@ -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
}
+148
View File
@@ -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"})
}
+345
View File
@@ -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
}
+130
View File
@@ -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
}
+5
View File
@@ -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;
+57
View File
@@ -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();