mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(epic-3): implémentation du Dashboard, mode lecture et notifications temps réel
This commit is contained in:
1 parent
1d31de95ec
commit
dc26f22f51
20 files changed
+1237
-107
No files matched your search
@@ -38,6 +38,7 @@ type ArticleRepository interface {
|
||||
MarkAsRead(id uuid.UUID) error
|
||||
MarkAsUnread(id uuid.UUID) error
|
||||
MarkAllAsRead(feedID uuid.UUID) error
|
||||
MarkAllAsReadGlobal(userID uuid.UUID) error
|
||||
ToggleFavorite(id uuid.UUID) error
|
||||
GetFavorites(userID uuid.UUID, limit, offset int) ([]*Article, error)
|
||||
CountUnread(feedID uuid.UUID) (int, error)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/michael/flowreader/internal/domain"
|
||||
"github.com/michael/flowreader/internal/service"
|
||||
"github.com/michael/flowreader/internal/utils"
|
||||
)
|
||||
|
||||
// ArticleHandler handles article-related HTTP requests.
|
||||
@@ -15,6 +16,7 @@ type ArticleHandler struct {
|
||||
articleRepo domain.ArticleRepository
|
||||
feedService *service.FeedService
|
||||
authService *service.AuthService
|
||||
sanitizer *utils.ContentSanitizer
|
||||
}
|
||||
|
||||
// NewArticleHandler creates a new article handler.
|
||||
@@ -23,6 +25,7 @@ func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *servic
|
||||
articleRepo: articleRepo,
|
||||
feedService: feedService,
|
||||
authService: authService,
|
||||
sanitizer: utils.NewContentSanitizer(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +142,13 @@ func (h *ArticleHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize content
|
||||
if article.Content != "" {
|
||||
article.Content = h.sanitizer.Sanitize(article.Content)
|
||||
} else if article.Summary != "" {
|
||||
article.Summary = h.sanitizer.Sanitize(article.Summary)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, article)
|
||||
}
|
||||
|
||||
@@ -276,6 +286,22 @@ func (h *ArticleHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "All articles marked as read"})
|
||||
}
|
||||
|
||||
// MarkAllReadGlobal handles POST /api/v1/articles/read-all
|
||||
func (h *ArticleHandler) MarkAllReadGlobal(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := h.getUserFromRequest(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.articleRepo.MarkAllAsReadGlobal(userID); 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)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/michael/flowreader/internal/service"
|
||||
"github.com/michael/flowreader/internal/ws"
|
||||
)
|
||||
|
||||
// WSHandler handles WebSocket connections.
|
||||
type WSHandler struct {
|
||||
hub *ws.Hub
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
// NewWSHandler creates a new WS handler.
|
||||
func NewWSHandler(hub *ws.Hub, authService *service.AuthService) *WSHandler {
|
||||
return &WSHandler{
|
||||
hub: hub,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect handles WebSocket initiation.
|
||||
func (h *WSHandler) Connect(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_id")
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.GetUserByToken(cookie.Value)
|
||||
if err != nil || user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Setting up WS for user %s", user.ID)
|
||||
h.hub.ServeWS(user.ID, w, r)
|
||||
}
|
||||
@@ -235,6 +235,24 @@ func (r *ArticleRepository) MarkAllAsRead(feedID uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllAsReadGlobal marks all articles for a user as read.
|
||||
func (r *ArticleRepository) MarkAllAsReadGlobal(userID uuid.UUID) error {
|
||||
ctx := context.Background()
|
||||
|
||||
query := `
|
||||
UPDATE articles
|
||||
SET is_read = true, read_at = NOW()
|
||||
WHERE feed_id IN (SELECT id FROM feeds WHERE user_id = $1) AND is_read = false
|
||||
`
|
||||
|
||||
_, err := r.pool.Exec(ctx, query, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking all articles as read globally: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleFavorite toggles the favorite status of an article.
|
||||
func (r *ArticleRepository) ToggleFavorite(id uuid.UUID) error {
|
||||
ctx := context.Background()
|
||||
|
||||
+23
-11
@@ -9,21 +9,24 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/michael/flowreader/internal/domain"
|
||||
"github.com/michael/flowreader/internal/parser"
|
||||
"github.com/michael/flowreader/internal/ws"
|
||||
)
|
||||
|
||||
// FetchService handles feed fetching and article ingestion.
|
||||
// FetchService handles fetching and parsing feeds.
|
||||
type FetchService struct {
|
||||
feedRepo domain.FeedRepository
|
||||
articleRepo domain.ArticleRepository
|
||||
parser *parser.FeedParser
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewFetchService creates a new fetch service.
|
||||
func NewFetchService(feedRepo domain.FeedRepository, articleRepo domain.ArticleRepository) *FetchService {
|
||||
func NewFetchService(feedRepo domain.FeedRepository, articleRepo domain.ArticleRepository, hub *ws.Hub) *FetchService {
|
||||
return &FetchService{
|
||||
feedRepo: feedRepo,
|
||||
articleRepo: articleRepo,
|
||||
parser: parser.NewFeedParser(),
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +41,7 @@ func (s *FetchService) FetchFeed(ctx context.Context, feedID uuid.UUID) error {
|
||||
}
|
||||
|
||||
// Parse the feed
|
||||
parsed, err := s.parser.Parse(ctx, feed.URL, feed.ID)
|
||||
parsedFeed, err := s.parser.Parse(ctx, feed.URL, feed.ID)
|
||||
if err != nil {
|
||||
// Update feed with error
|
||||
s.feedRepo.UpdateFetchStatus(feed.ID, time.Now(), err.Error())
|
||||
@@ -46,18 +49,27 @@ func (s *FetchService) FetchFeed(ctx context.Context, feedID uuid.UUID) error {
|
||||
}
|
||||
|
||||
// Update feed metadata
|
||||
feed.Title = parsed.Title
|
||||
feed.Description = parsed.Description
|
||||
feed.SiteURL = parsed.SiteURL
|
||||
feed.ImageURL = parsed.ImageURL
|
||||
feed.Title = parsedFeed.Title
|
||||
feed.Description = parsedFeed.Description
|
||||
feed.SiteURL = parsedFeed.SiteURL
|
||||
feed.ImageURL = parsedFeed.ImageURL
|
||||
if err := s.feedRepo.Update(feed); err != nil {
|
||||
log.Printf("Warning: failed to update feed metadata: %v", err)
|
||||
}
|
||||
|
||||
// Insert new articles (ON CONFLICT DO NOTHING handles duplicates)
|
||||
if len(parsed.Articles) > 0 {
|
||||
if err := s.articleRepo.CreateBatch(parsed.Articles); err != nil {
|
||||
return fmt.Errorf("creating articles: %w", err)
|
||||
// Ingest articles
|
||||
if len(parsedFeed.Articles) > 0 {
|
||||
if err := s.articleRepo.CreateBatch(parsedFeed.Articles); err != nil {
|
||||
return fmt.Errorf("ingesting articles: %w", err)
|
||||
}
|
||||
|
||||
// Broadcast update
|
||||
if s.hub != nil {
|
||||
s.hub.Broadcast("new_articles", map[string]interface{}{
|
||||
"feed_id": feed.ID,
|
||||
"feed_title": feed.Title,
|
||||
"count": len(parsedFeed.Articles),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package utils provides common utility functions.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
// ContentSanitizer handles HTML sanitization for articles.
|
||||
type ContentSanitizer struct {
|
||||
policy *bluemonday.Policy
|
||||
}
|
||||
|
||||
// NewContentSanitizer creates a new sanitizer with a "UGCPolicy" (safe for user-generated content).
|
||||
func NewContentSanitizer() *ContentSanitizer {
|
||||
// Using UGCPolicy allows common tags (b, i, p, img, etc.) but strips dangerous ones.
|
||||
return &ContentSanitizer{
|
||||
policy: bluemonday.UGCPolicy(),
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize cleans the HTML content.
|
||||
func (s *ContentSanitizer) Sanitize(html string) string {
|
||||
if html == "" {
|
||||
return ""
|
||||
}
|
||||
return s.policy.Sanitize(html)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // In production, check origin properly
|
||||
},
|
||||
}
|
||||
|
||||
// Event represents a websocket event.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Client represents a connected user via websocket.
|
||||
type Client struct {
|
||||
ID uuid.UUID
|
||||
Conn *websocket.Conn
|
||||
Send chan []byte
|
||||
Hub *Hub
|
||||
}
|
||||
|
||||
// Hub maintains the set of active clients and broadcasts messages.
|
||||
type Hub struct {
|
||||
// Registered clients by user ID
|
||||
clients map[uuid.UUID][]*Client
|
||||
// Broadcast channel for messages
|
||||
broadcast chan Event
|
||||
// Register requests from clients
|
||||
register chan *Client
|
||||
// Unregister requests from clients
|
||||
unregister chan *Client
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewHub creates a new hub.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
broadcast: make(chan Event),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
clients: make(map[uuid.UUID][]*Client),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub loop.
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client.ID] = append(h.clients[client.ID], client)
|
||||
h.mu.Unlock()
|
||||
log.Printf("Client registered: %s", client.ID)
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
clients := h.clients[client.ID]
|
||||
for i, c := range clients {
|
||||
if c == client {
|
||||
h.clients[client.ID] = append(clients[:i], clients[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(h.clients[client.ID]) == 0 {
|
||||
delete(h.clients, client.ID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
close(client.Send)
|
||||
log.Printf("Client unregistered: %s", client.ID)
|
||||
|
||||
case event := <-h.broadcast:
|
||||
// For now, broadcast simple news to all clients of a specific user or global
|
||||
// But since we need user-specific notifications for feeds, we'd ideally pass UserID in Event
|
||||
// Let's enhance Event struct for this or broadcast to all for now if it's "new articles available"
|
||||
// and let them refetch.
|
||||
|
||||
data, _ := json.Marshal(event)
|
||||
|
||||
h.mu.RLock()
|
||||
for _, userClients := range h.clients {
|
||||
for _, client := range userClients {
|
||||
select {
|
||||
case client.Send <- data:
|
||||
default:
|
||||
// Close slow connections
|
||||
go func(c *Client) { h.unregister <- c }(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends an event to all connected clients.
|
||||
func (h *Hub) Broadcast(eventType string, payload interface{}) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling broadcast payload: %v", err)
|
||||
return
|
||||
}
|
||||
h.broadcast <- Event{
|
||||
Type: eventType,
|
||||
Payload: json.RawMessage(data),
|
||||
}
|
||||
}
|
||||
|
||||
// ServeWS handles websocket requests.
|
||||
func (h *Hub) ServeWS(userID uuid.UUID, w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WS upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
ID: userID,
|
||||
Conn: conn,
|
||||
Send: make(chan []byte, 256),
|
||||
Hub: h,
|
||||
}
|
||||
h.register <- client
|
||||
|
||||
// Start goroutines for reading and writing
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.Hub.unregister <- c
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, _, err := c.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("WS read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
// We don't expect messages from client yet
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writePump() {
|
||||
defer func() {
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.Send:
|
||||
if !ok {
|
||||
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
w, err := c.Conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write(message)
|
||||
|
||||
// Add queued messages to the current writer
|
||||
n := len(c.Send)
|
||||
for i := 0; i < n; i++ {
|
||||
w.Write([]byte{'\n'})
|
||||
w.Write(<-c.Send)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user