mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-20 13:48:10 +02:00
feat(story-2.4-2.5): add article handler, background fetcher worker
This commit is contained in:
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
|
epic-2: in-progress
|
||||||
2-1-feed-model-add-feed-by-url: done
|
2-1-feed-model-add-feed-by-url: done
|
||||||
2-2-feed-metadata-auto-discovery: done
|
2-2-feed-metadata-auto-discovery: done
|
||||||
2-3-opml-import: review
|
2-3-opml-import: done
|
||||||
2-4-feed-management-crud: backlog
|
2-4-feed-management-crud: done
|
||||||
2-5-background-feed-fetcher: backlog
|
2-5-background-feed-fetcher: review
|
||||||
epic-2-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|
||||||
# Epic 3: Lecture Plaisir & Real-Time Experience
|
# Epic 3: Lecture Plaisir & Real-Time Experience
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/michael/flowreader/internal/handler"
|
"github.com/michael/flowreader/internal/handler"
|
||||||
"github.com/michael/flowreader/internal/repository"
|
"github.com/michael/flowreader/internal/repository"
|
||||||
"github.com/michael/flowreader/internal/service"
|
"github.com/michael/flowreader/internal/service"
|
||||||
|
"github.com/michael/flowreader/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -39,14 +40,22 @@ func main() {
|
|||||||
userRepo := repository.NewUserRepository(pool)
|
userRepo := repository.NewUserRepository(pool)
|
||||||
sessionRepo := repository.NewSessionRepository(pool)
|
sessionRepo := repository.NewSessionRepository(pool)
|
||||||
feedRepo := repository.NewFeedRepository(pool)
|
feedRepo := repository.NewFeedRepository(pool)
|
||||||
|
articleRepo := repository.NewArticleRepository(pool)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
authService := service.NewAuthService(userRepo, sessionRepo)
|
authService := service.NewAuthService(userRepo, sessionRepo)
|
||||||
feedService := service.NewFeedService(feedRepo)
|
feedService := service.NewFeedService(feedRepo)
|
||||||
|
fetchService := service.NewFetchService(feedRepo, articleRepo)
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
feedHandler := handler.NewFeedHandler(feedService, 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
|
// Initialize router
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -96,6 +105,18 @@ func main() {
|
|||||||
r.Get("/export/opml", feedHandler.ExportOPML)
|
r.Get("/export/opml", feedHandler.ExportOPML)
|
||||||
r.Get("/{id}", feedHandler.Get)
|
r.Get("/{id}", feedHandler.Get)
|
||||||
r.Delete("/{id}", feedHandler.Delete)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user