diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index ef63882..99e399d 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index afbe9d7..cfbeee4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) }) }) diff --git a/internal/handler/article.go b/internal/handler/article.go new file mode 100644 index 0000000..b22ac64 --- /dev/null +++ b/internal/handler/article.go @@ -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) +} diff --git a/internal/worker/fetcher.go b/internal/worker/fetcher.go new file mode 100644 index 0000000..e92ce9b --- /dev/null +++ b/internal/worker/fetcher.go @@ -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) + } +}