feat(epic-3): implémentation du Dashboard, mode lecture et notifications temps réel

This commit is contained in:
Michael committed 2026-02-04 15:12:54 +01:00
1 parent 1d31de95ec
commit dc26f22f51
20 files changed
+1237 -107

No files matched your search

+1
View File
@@ -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)
+26
View File
@@ -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)
+41
View File
@@ -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)
}
+18
View File
@@ -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
View File
@@ -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),
})
}
}
+27
View File
@@ -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)
}
+190
View File
@@ -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
}
}
}
}