From 8777d3b9700193fbab0557c14f9a4af6c17090a8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 4 Feb 2026 15:17:59 +0100 Subject: [PATCH] feat(epic-4): synchro multi-appareils, outils d'admin et optimisations performance --- .../sprint-status.yaml | 10 +-- cmd/server/main.go | 10 ++- docker-compose.yaml | 17 +++-- internal/domain/user.go | 10 ++- internal/handler/admin.go | 76 +++++++++++++++++++ internal/handler/article.go | 29 ++++++- internal/repository/user.go | 67 +++++++++++++--- migrations/000003_add_user_roles.down.sql | 1 + migrations/000003_add_user_roles.up.sql | 5 ++ web/src/hooks/useWebsocket.ts | 2 +- 10 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 internal/handler/admin.go create mode 100644 migrations/000003_add_user_roles.down.sql create mode 100644 migrations/000003_add_user_roles.up.sql diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 20f3efd..dcca086 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -63,9 +63,9 @@ development_status: epic-3-retrospective: optional # Epic 4: Advanced Systems & Robustness - epic-4: backlog - 4-1-multi-device-sync: backlog - 4-2-admin-user-list: backlog - 4-3-delete-user-account: backlog - 4-4-performance-ram-optimization: backlog + epic-4: done + 4-1-multi-device-sync: done + 4-2-admin-user-list: done + 4-3-delete-user-account: done + 4-4-performance-ram-optimization: done epic-4-retrospective: optional diff --git a/cmd/server/main.go b/cmd/server/main.go index 80cd593..a85e8da 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -56,8 +56,9 @@ func main() { // Initialize handlers authHandler := handler.NewAuthHandler(authService) feedHandler := handler.NewFeedHandler(feedService, authService) - articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService) + articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, hub) wsHandler := handler.NewWSHandler(hub, authService) + adminHandler := handler.NewAdminHandler(userRepo, authService) // Start background feed fetcher fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5) @@ -129,6 +130,13 @@ func main() { // WebSocket route r.Get("/ws", wsHandler.Connect) + + // Admin routes + r.Route("/admin", func(r chi.Router) { + r.Use(adminHandler.AdminOnly) + r.Get("/users", adminHandler.ListUsers) + r.Delete("/users/{id}", adminHandler.DeleteUser) + }) }) // Create server diff --git a/docker-compose.yaml b/docker-compose.yaml index 424ea08..8b799bc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,11 +13,15 @@ services: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"] + test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8080/health" ] interval: 10s timeout: 5s retries: 3 start_period: 10s + deploy: + resources: + limits: + memory: 64M db: image: postgres:16-alpine @@ -31,16 +35,17 @@ services: - "5432:5432" restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "pg_isready -U flowreader -d flowreader"] + test: [ "CMD-SHELL", "pg_isready -U flowreader -d flowreader" ] interval: 5s timeout: 5s retries: 5 # RAM Optimization (from Architecture NFR1) command: > - postgres - -c shared_buffers=24MB - -c max_connections=20 - -c work_mem=2MB + postgres -c shared_buffers=24MB -c max_connections=20 -c work_mem=2MB + deploy: + resources: + limits: + memory: 80M volumes: postgres_data: diff --git a/internal/domain/user.go b/internal/domain/user.go index 3e06d66..caec94d 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -15,12 +15,20 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Role string `json:"role"` } -// UserRepository defines the interface for user data access. +const ( + RoleAdmin = "admin" + RoleUser = "user" +) + +// UserRepository defines the interface for user persistence.ss. type UserRepository interface { Create(user *User) error GetByEmail(email string) (*User, error) GetByID(id uuid.UUID) (*User, error) Exists(email string) (bool, error) + List() ([]*User, error) + Delete(id uuid.UUID) error } diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..f2d711a --- /dev/null +++ b/internal/handler/admin.go @@ -0,0 +1,76 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/michael/flowreader/internal/domain" + "github.com/michael/flowreader/internal/service" +) + +// AdminHandler handles administrative tasks. +type AdminHandler struct { + userRepo domain.UserRepository + authService *service.AuthService +} + +// NewAdminHandler creates a new admin handler. +func NewAdminHandler(userRepo domain.UserRepository, authService *service.AuthService) *AdminHandler { + return &AdminHandler{ + userRepo: userRepo, + authService: authService, + } +} + +// ListUsers handles GET /api/v1/admin/users +func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.userRepo.List() + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to list users") + return + } + + respondJSON(w, http.StatusOK, users) +} + +// DeleteUser handles DELETE /api/v1/admin/users/{id} +func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { + userID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid user ID") + return + } + + // Logic to delete user and all associated data + if err := h.userRepo.Delete(userID); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to delete user") + return + } + + respondJSON(w, http.StatusOK, map[string]string{"message": "User deleted successfully"}) +} + +// AdminOnly middleware restricts access to admins. +func (h *AdminHandler) AdminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_id") + if err != nil { + respondError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + user, err := h.authService.GetUserByToken(cookie.Value) + if err != nil || user == nil { + respondError(w, http.StatusUnauthorized, "Invalid session") + return + } + + if user.Role != domain.RoleAdmin { + respondError(w, http.StatusForbidden, "Admin access required") + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/article.go b/internal/handler/article.go index 048e266..08e8fc7 100644 --- a/internal/handler/article.go +++ b/internal/handler/article.go @@ -9,6 +9,7 @@ import ( "github.com/michael/flowreader/internal/domain" "github.com/michael/flowreader/internal/service" "github.com/michael/flowreader/internal/utils" + "github.com/michael/flowreader/internal/ws" ) // ArticleHandler handles article-related HTTP requests. @@ -17,15 +18,17 @@ type ArticleHandler struct { feedService *service.FeedService authService *service.AuthService sanitizer *utils.ContentSanitizer + hub *ws.Hub } // NewArticleHandler creates a new article handler. -func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService) *ArticleHandler { +func NewArticleHandler(articleRepo domain.ArticleRepository, feedService *service.FeedService, authService *service.AuthService, hub *ws.Hub) *ArticleHandler { return &ArticleHandler{ articleRepo: articleRepo, feedService: feedService, authService: authService, sanitizer: utils.NewContentSanitizer(), + hub: hub, } } @@ -184,6 +187,14 @@ func (h *ArticleHandler) MarkRead(w http.ResponseWriter, r *http.Request) { return } + // Broadcast update + if h.hub != nil { + h.hub.Broadcast("article_updated", map[string]interface{}{ + "id": articleID, + "is_read": true, + }) + } + respondJSON(w, http.StatusOK, map[string]bool{"is_read": true}) } @@ -219,6 +230,14 @@ func (h *ArticleHandler) MarkUnread(w http.ResponseWriter, r *http.Request) { return } + // Broadcast update + if h.hub != nil { + h.hub.Broadcast("article_updated", map[string]interface{}{ + "id": articleID, + "is_read": false, + }) + } + respondJSON(w, http.StatusOK, map[string]bool{"is_read": false}) } @@ -254,6 +273,14 @@ func (h *ArticleHandler) ToggleFavorite(w http.ResponseWriter, r *http.Request) return } + // Broadcast update + if h.hub != nil { + h.hub.Broadcast("article_updated", map[string]interface{}{ + "id": articleID, + "is_favorite": !article.IsFavorite, + }) + } + respondJSON(w, http.StatusOK, map[string]bool{"is_favorite": !article.IsFavorite}) } diff --git a/internal/repository/user.go b/internal/repository/user.go index 30b273a..6ee31b4 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -26,18 +26,30 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository { func (r *UserRepository) Create(user *domain.User) error { ctx := context.Background() + // Check if this is the first user + var count int + err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + return fmt.Errorf("counting users: %w", err) + } + + if count == 0 { + user.Role = domain.RoleAdmin + } else if user.Role == "" { + user.Role = domain.RoleUser + } + query := ` - INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO users (id, email, password_hash, created_at, role) + VALUES ($1, $2, $3, $4, $5) ` - _, err := r.pool.Exec(ctx, query, + _, err = r.pool.Exec(ctx, query, user.ID, user.Email, user.PasswordHash, - user.IsAdmin, user.CreatedAt, - user.UpdatedAt, + user.Role, ) if err != nil { @@ -52,7 +64,7 @@ func (r *UserRepository) GetByEmail(email string) (*domain.User, error) { ctx := context.Background() query := ` - SELECT id, email, password_hash, is_admin, created_at, updated_at + SELECT id, email, password_hash, created_at, role FROM users WHERE email = $1 ` @@ -62,9 +74,8 @@ func (r *UserRepository) GetByEmail(email string) (*domain.User, error) { &user.ID, &user.Email, &user.PasswordHash, - &user.IsAdmin, &user.CreatedAt, - &user.UpdatedAt, + &user.Role, ) if err != nil { @@ -82,7 +93,7 @@ func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) { ctx := context.Background() query := ` - SELECT id, email, password_hash, is_admin, created_at, updated_at + SELECT id, email, password_hash, created_at, role FROM users WHERE id = $1 ` @@ -92,9 +103,8 @@ func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) { &user.ID, &user.Email, &user.PasswordHash, - &user.IsAdmin, &user.CreatedAt, - &user.UpdatedAt, + &user.Role, ) if err != nil { @@ -107,7 +117,40 @@ func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) { return &user, nil } -// Exists checks if a user with the given email exists. +// List retrieves all users. +func (r *UserRepository) List() ([]*domain.User, error) { + ctx := context.Background() + query := `SELECT id, email, created_at, role FROM users ORDER BY created_at ASC` + + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("listing users: %w", err) + } + defer rows.Close() + + var users []*domain.User + for rows.Next() { + var u domain.User + if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.Role); err != nil { + return nil, fmt.Errorf("scanning user: %w", err) + } + users = append(users, &u) + } + + return users, nil +} + +// Delete removes a user and their data (cascaded by DB). +func (r *UserRepository) Delete(id uuid.UUID) error { + ctx := context.Background() + _, err := r.pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id) + if err != nil { + return fmt.Errorf("deleting user: %w", err) + } + return nil +} + +// Exists checks if an email already exists. func (r *UserRepository) Exists(email string) (bool, error) { ctx := context.Background() diff --git a/migrations/000003_add_user_roles.down.sql b/migrations/000003_add_user_roles.down.sql new file mode 100644 index 0000000..103bc7f --- /dev/null +++ b/migrations/000003_add_user_roles.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN role; diff --git a/migrations/000003_add_user_roles.up.sql b/migrations/000003_add_user_roles.up.sql new file mode 100644 index 0000000..227d103 --- /dev/null +++ b/migrations/000003_add_user_roles.up.sql @@ -0,0 +1,5 @@ +-- Add role column to users table +ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user'; + +-- Set first user as admin (if any) +UPDATE users SET role = 'admin' WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1); diff --git a/web/src/hooks/useWebsocket.ts b/web/src/hooks/useWebsocket.ts index 03385a6..8d64add 100644 --- a/web/src/hooks/useWebsocket.ts +++ b/web/src/hooks/useWebsocket.ts @@ -19,7 +19,7 @@ export function useWebsocket() { const data = JSON.parse(event.data); console.log('WS Message:', data); - if (data.type === 'new_articles') { + if (data.type === 'new_articles' || data.type === 'article_updated') { // Invalidate articles and feeds query to trigger refetch queryClient.invalidateQueries({ queryKey: ['articles'] }); queryClient.invalidateQueries({ queryKey: ['feeds'] });