mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +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
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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