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-3-retrospective: optional
|
||||||
|
|
||||||
# Epic 4: Advanced Systems & Robustness
|
# Epic 4: Advanced Systems & Robustness
|
||||||
epic-4: backlog
|
epic-4: done
|
||||||
4-1-multi-device-sync: backlog
|
4-1-multi-device-sync: done
|
||||||
4-2-admin-user-list: backlog
|
4-2-admin-user-list: done
|
||||||
4-3-delete-user-account: backlog
|
4-3-delete-user-account: done
|
||||||
4-4-performance-ram-optimization: backlog
|
4-4-performance-ram-optimization: done
|
||||||
epic-4-retrospective: optional
|
epic-4-retrospective: optional
|
||||||
+9
-1
@@ -56,8 +56,9 @@ func main() {
|
|||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
feedHandler := handler.NewFeedHandler(feedService, authService)
|
feedHandler := handler.NewFeedHandler(feedService, authService)
|
||||||
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService)
|
articleHandler := handler.NewArticleHandler(articleRepo, feedService, authService, hub)
|
||||||
wsHandler := handler.NewWSHandler(hub, authService)
|
wsHandler := handler.NewWSHandler(hub, authService)
|
||||||
|
adminHandler := handler.NewAdminHandler(userRepo, authService)
|
||||||
|
|
||||||
// Start background feed fetcher
|
// Start background feed fetcher
|
||||||
fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5)
|
fetcher := worker.NewFeedFetcher(fetchService, 15*time.Minute, 5)
|
||||||
@@ -129,6 +130,13 @@ func main() {
|
|||||||
|
|
||||||
// WebSocket route
|
// WebSocket route
|
||||||
r.Get("/ws", wsHandler.Connect)
|
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
|
// Create server
|
||||||
|
|||||||
+11
-6
@@ -13,11 +13,15 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
|
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8080/health" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -31,16 +35,17 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U flowreader -d flowreader"]
|
test: [ "CMD-SHELL", "pg_isready -U flowreader -d flowreader" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
# RAM Optimization (from Architecture NFR1)
|
# RAM Optimization (from Architecture NFR1)
|
||||||
command: >
|
command: >
|
||||||
postgres
|
postgres -c shared_buffers=24MB -c max_connections=20 -c work_mem=2MB
|
||||||
-c shared_buffers=24MB
|
deploy:
|
||||||
-c max_connections=20
|
resources:
|
||||||
-c work_mem=2MB
|
limits:
|
||||||
|
memory: 80M
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
@@ -15,12 +15,20 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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 {
|
type UserRepository interface {
|
||||||
Create(user *User) error
|
Create(user *User) error
|
||||||
GetByEmail(email string) (*User, error)
|
GetByEmail(email string) (*User, error)
|
||||||
GetByID(id uuid.UUID) (*User, error)
|
GetByID(id uuid.UUID) (*User, error)
|
||||||
Exists(email string) (bool, 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/domain"
|
||||||
"github.com/michael/flowreader/internal/service"
|
"github.com/michael/flowreader/internal/service"
|
||||||
"github.com/michael/flowreader/internal/utils"
|
"github.com/michael/flowreader/internal/utils"
|
||||||
|
"github.com/michael/flowreader/internal/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ArticleHandler handles article-related HTTP requests.
|
// ArticleHandler handles article-related HTTP requests.
|
||||||
@@ -17,15 +18,17 @@ type ArticleHandler struct {
|
|||||||
feedService *service.FeedService
|
feedService *service.FeedService
|
||||||
authService *service.AuthService
|
authService *service.AuthService
|
||||||
sanitizer *utils.ContentSanitizer
|
sanitizer *utils.ContentSanitizer
|
||||||
|
hub *ws.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticleHandler creates a new article handler.
|
// 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{
|
return &ArticleHandler{
|
||||||
articleRepo: articleRepo,
|
articleRepo: articleRepo,
|
||||||
feedService: feedService,
|
feedService: feedService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
sanitizer: utils.NewContentSanitizer(),
|
sanitizer: utils.NewContentSanitizer(),
|
||||||
|
hub: hub,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +187,14 @@ func (h *ArticleHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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})
|
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
|
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})
|
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
|
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})
|
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 {
|
func (r *UserRepository) Create(user *domain.User) error {
|
||||||
ctx := context.Background()
|
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 := `
|
query := `
|
||||||
INSERT INTO users (id, email, password_hash, is_admin, created_at, updated_at)
|
INSERT INTO users (id, email, password_hash, created_at, role)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx, query,
|
_, err = r.pool.Exec(ctx, query,
|
||||||
user.ID,
|
user.ID,
|
||||||
user.Email,
|
user.Email,
|
||||||
user.PasswordHash,
|
user.PasswordHash,
|
||||||
user.IsAdmin,
|
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.UpdatedAt,
|
user.Role,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,7 +64,7 @@ func (r *UserRepository) GetByEmail(email string) (*domain.User, error) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, email, password_hash, is_admin, created_at, updated_at
|
SELECT id, email, password_hash, created_at, role
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
`
|
`
|
||||||
@@ -62,9 +74,8 @@ func (r *UserRepository) GetByEmail(email string) (*domain.User, error) {
|
|||||||
&user.ID,
|
&user.ID,
|
||||||
&user.Email,
|
&user.Email,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.IsAdmin,
|
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.Role,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,7 +93,7 @@ func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, email, password_hash, is_admin, created_at, updated_at
|
SELECT id, email, password_hash, created_at, role
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -92,9 +103,8 @@ func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) {
|
|||||||
&user.ID,
|
&user.ID,
|
||||||
&user.Email,
|
&user.Email,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.IsAdmin,
|
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.Role,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,7 +117,40 @@ func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) {
|
|||||||
return &user, nil
|
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) {
|
func (r *UserRepository) Exists(email string) (bool, error) {
|
||||||
ctx := context.Background()
|
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);
|
const data = JSON.parse(event.data);
|
||||||
console.log('WS Message:', 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
|
// Invalidate articles and feeds query to trigger refetch
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
queryClient.invalidateQueries({ queryKey: ['feeds'] });
|
||||||
|
|||||||
Reference in new issue
Block a user