mirror of
https://github.com/R0m1k3/FlowReader.git
synced 2026-10-11 17:28:05 +02:00
feat(epic-4): synchro multi-appareils, outils d'admin et optimisations performance
This commit is contained in:
1 parent
dc26f22f51
commit
8777d3b970
10 files changed
+200
-27
No files matched your search
@@ -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
|
||||
+9
-1
@@ -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
|
||||
|
||||
+11
-6
@@ -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:
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
+55
-12
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN role;
|
||||
@@ -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);
|
||||
@@ -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'] });
|
||||
|
||||
Reference in new issue
Block a user