diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 3ce69c6..3eb749d 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index 19152ba..1aecbec 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/internal/domain/article.go b/internal/domain/article.go new file mode 100644 index 0000000..19109aa --- /dev/null +++ b/internal/domain/article.go @@ -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) +} diff --git a/internal/domain/feed.go b/internal/domain/feed.go new file mode 100644 index 0000000..305e58c --- /dev/null +++ b/internal/domain/feed.go @@ -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 +} diff --git a/internal/handler/feed.go b/internal/handler/feed.go new file mode 100644 index 0000000..7ce1c30 --- /dev/null +++ b/internal/handler/feed.go @@ -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"}) +} diff --git a/internal/repository/feed.go b/internal/repository/feed.go new file mode 100644 index 0000000..ce7a07b --- /dev/null +++ b/internal/repository/feed.go @@ -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 +} diff --git a/internal/service/feed.go b/internal/service/feed.go new file mode 100644 index 0000000..e0e90cf --- /dev/null +++ b/internal/service/feed.go @@ -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 +} diff --git a/migrations/003_create_feeds.down.sql b/migrations/003_create_feeds.down.sql new file mode 100644 index 0000000..51bf465 --- /dev/null +++ b/migrations/003_create_feeds.down.sql @@ -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; diff --git a/migrations/003_create_feeds.up.sql b/migrations/003_create_feeds.up.sql new file mode 100644 index 0000000..47e6779 --- /dev/null +++ b/migrations/003_create_feeds.up.sql @@ -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();