feat(story-2.4-2.5): add article handler, background fetcher worker

This commit is contained in:
Michael committed 2026-02-04 15:01:36 +01:00
1 parent 20589ee55b
commit 1d31de95ec
4 files changed
+406 -3

No files matched your search

@@ -47,9 +47,9 @@ development_status:
epic-2: in-progress
2-1-feed-model-add-feed-by-url: done
2-2-feed-metadata-auto-discovery: done
2-3-opml-import: review
2-4-feed-management-crud: backlog
2-5-background-feed-fetcher: backlog
2-3-opml-import: done
2-4-feed-management-crud: done
2-5-background-feed-fetcher: review
epic-2-retrospective: optional
# Epic 3: Lecture Plaisir & Real-Time Experience
+21
View File
@@ -16,6 +16,7 @@ import (
"github.com/michael/flowreader/internal/handler"
"github.com/michael/flowreader/internal/repository"
"github.com/michael/flowreader/internal/service"
"github.com/michael/flowreader/internal/worker"
)
func main() {
@@ -39,14 +40,22 @@ func main() {
userRepo := repository.NewUserRepository(pool)
sessionRepo := repository.NewSessionRepository(pool)
feedRepo := repository.NewFeedRepository(pool)
articleRepo := repository.NewArticleRepository(pool)
// Initialize services
authService := service.NewAuthService(userRepo, sessionRepo)
feedService := service.NewFeedService(feedRepo)
fetchService := service.NewFetchService(feedRepo, articleRepo)
// Initialize handlers
authHandler := handler.NewAuthHandler(authService)
feedHandler := handler.NewFeedHandler(feedService, authService)
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService)
// Start background feed fetcher
fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5)
fetcher.Start()
defer fetcher.Stop()
// Initialize router
r := chi.NewRouter()
@@ -96,6 +105,18 @@ func main() {
r.Get("/export/opml", feedHandler.ExportOPML)
r.Get("/{id}", feedHandler.Get)
r.Delete("/{id}", feedHandler.Delete)
r.Get("/{id}/articles", articleHandler.ListByFeed)
r.Post("/{id}/read-all", articleHandler.MarkAllRead)
})
// Article routes
r.Route("/articles", func(r chi.Router) {
r.Get("/", articleHandler.List)
r.Get("/favorites", articleHandler.GetFavorites)
r.Get("/{id}", articleHandler.Get)
r.Post("/{id}/read", articleHandler.MarkRead)
r.Delete("/{id}/read", articleHandler.MarkUnread)
r.Post("/{id}/favorite", articleHandler.ToggleFavorite)
})
})
+304
View File
@@ -0,0 +1,304 @@
package handler
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/michael/flowreader/internal/domain"
"github.com/michael/flowreader/internal/service"
)
// ArticleHandler handles article-related HTTP requests.
type ArticleHandler struct {
articleRepo domain.ArticleRepository
feedService *service.FeedService
authService *service.AuthService
}
// NewArticleHandler creates a new article handler.
func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService) *ArticleHandler {
return &ArticleHandler{
articleRepo: articleRepo,
feedService: feedService,
authService: authService,
}
}
// getUserFromRequest extracts the authenticated user from the request.
func (h *ArticleHandler) getUserFromRequest(r *http.Request) (uuid.UUID, error) {
cookie, err := r.Cookie("session_id")
if err != nil {
return uuid.Nil, err
}
user, err := h.authService.GetUserByToken(cookie.Value)
if err != nil || user == nil {
return uuid.Nil, err
}
return user.ID, nil
}
// List handles GET /api/v1/articles
func (h *ArticleHandler) List(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
// Parse query parameters
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
unreadOnly := r.URL.Query().Get("unread") == "true"
articles, err := h.articleRepo.GetByUserID(userID, limit, offset, unreadOnly)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get articles")
return
}
respondJSON(w, http.StatusOK, articles)
}
// ListByFeed handles GET /api/v1/feeds/{id}/articles
func (h *ArticleHandler) ListByFeed(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid feed ID")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(feedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
// Parse query parameters
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
articles, err := h.articleRepo.GetByFeedID(feedID, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get articles")
return
}
respondJSON(w, http.StatusOK, articles)
}
// Get handles GET /api/v1/articles/{id}
func (h *ArticleHandler) Get(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid article ID")
return
}
article, err := h.articleRepo.GetByID(articleID)
if err != nil || article == nil {
respondError(w, http.StatusNotFound, "Article not found")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(article.FeedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
respondJSON(w, http.StatusOK, article)
}
// MarkRead handles POST /api/v1/articles/{id}/read
func (h *ArticleHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid article ID")
return
}
article, err := h.articleRepo.GetByID(articleID)
if err != nil || article == nil {
respondError(w, http.StatusNotFound, "Article not found")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(article.FeedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
if err := h.articleRepo.MarkAsRead(articleID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to mark as read")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"is_read": true})
}
// MarkUnread handles DELETE /api/v1/articles/{id}/read
func (h *ArticleHandler) MarkUnread(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid article ID")
return
}
article, err := h.articleRepo.GetByID(articleID)
if err != nil || article == nil {
respondError(w, http.StatusNotFound, "Article not found")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(article.FeedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
if err := h.articleRepo.MarkAsUnread(articleID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to mark as unread")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"is_read": false})
}
// ToggleFavorite handles POST /api/v1/articles/{id}/favorite
func (h *ArticleHandler) ToggleFavorite(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
articleID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid article ID")
return
}
article, err := h.articleRepo.GetByID(articleID)
if err != nil || article == nil {
respondError(w, http.StatusNotFound, "Article not found")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(article.FeedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
if err := h.articleRepo.ToggleFavorite(articleID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to toggle favorite")
return
}
respondJSON(w, http.StatusOK, map[string]bool{"is_favorite": !article.IsFavorite})
}
// MarkAllRead handles POST /api/v1/feeds/{id}/read-all
func (h *ArticleHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid feed ID")
return
}
// Verify feed ownership
_, err = h.feedService.GetFeed(feedID, userID)
if err != nil {
respondError(w, http.StatusForbidden, "Access denied")
return
}
if err := h.articleRepo.MarkAllAsRead(feedID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to mark all as read")
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
}
// GetFavorites handles GET /api/v1/articles/favorites
func (h *ArticleHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserFromRequest(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
articles, err := h.articleRepo.GetFavorites(userID, limit, offset)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get favorites")
return
}
respondJSON(w, http.StatusOK, articles)
}
+78
View File
@@ -0,0 +1,78 @@
// Package worker provides background job processing.
package worker
import (
"context"
"log"
"sync"
"time"
"github.com/michael/flowreader/internal/service"
)
// FeedFetcher handles periodic feed fetching.
type FeedFetcher struct {
fetchService *service.FetchService
interval time.Duration
concurrency int
stopCh chan struct{}
wg sync.WaitGroup
}
// NewFeedFetcher creates a new feed fetcher worker.
func NewFeedFetcher(fetchService *service.FetchService, interval time.Duration, concurrency int) *FeedFetcher {
return &FeedFetcher{
fetchService: fetchService,
interval: interval,
concurrency: concurrency,
stopCh: make(chan struct{}),
}
}
// Start begins the background fetch loop.
func (f *FeedFetcher) Start() {
f.wg.Add(1)
go f.run()
log.Printf("Feed fetcher started (interval: %s, concurrency: %d)", f.interval, f.concurrency)
}
// Stop gracefully stops the fetcher.
func (f *FeedFetcher) Stop() {
close(f.stopCh)
f.wg.Wait()
log.Println("Feed fetcher stopped")
}
func (f *FeedFetcher) run() {
defer f.wg.Done()
// Initial fetch on startup
f.fetch()
ticker := time.NewTicker(f.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
f.fetch()
case <-f.stopCh:
return
}
}
}
func (f *FeedFetcher) fetch() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
count, err := f.fetchService.FetchAllPending(ctx, f.concurrency)
if err != nil {
log.Printf("Feed fetch error: %v", err)
return
}
if count > 0 {
log.Printf("Fetched %d feeds", count)
}
}