veza/veza-backend-api/internal/services/chat_service.go
senke e8d97741e4 feat(chat): Sprint 2 -- WebSocket hub, client, message types, route
- Create Hub with register/unregister/broadcast, room/user index
- Create Client with readPump/writePump goroutines, 30s ping keepalive
- Define all 18 incoming + 18 outgoing message types matching Rust protocol
- Add ValidateChatToken to ChatService for JWT validation
- Update WSUrl from /ws to /api/v1/ws
- Register GET /api/v1/ws endpoint in router
- Create ChatWebSocketHandler for WebSocket upgrade and auth
2026-02-22 20:41:39 +01:00

171 lines
4.4 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"time"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
type ChatService struct {
jwtSecret string
logger *zap.Logger
db *gorm.DB
}
func NewChatService(jwtSecret string, logger *zap.Logger) *ChatService {
if logger == nil {
logger = zap.NewNop()
}
return &ChatService{
jwtSecret: jwtSecret,
logger: logger,
}
}
// NewChatServiceWithDB crée un nouveau ChatService avec accès à la base de données
func NewChatServiceWithDB(jwtSecret string, db *gorm.DB, logger *zap.Logger) *ChatService {
if logger == nil {
logger = zap.NewNop()
}
return &ChatService{
jwtSecret: jwtSecret,
logger: logger,
db: db,
}
}
type ChatTokenResponse struct {
Token string `json:"token"`
ExpiresIn int64 `json:"expires_in"`
WSUrl string `json:"ws_url"`
}
func (s *ChatService) GenerateToken(userID uuid.UUID, username string) (*ChatTokenResponse, error) {
if s.jwtSecret == "" {
return nil, errors.New("JWT secret is not configured")
}
now := time.Now()
expiration := 15 * time.Minute
exp := now.Add(expiration)
claims := jwt.MapClaims{
"sub": userID.String(),
"name": username,
"aud": "veza-chat",
"iss": "veza-backend",
"iat": now.Unix(),
"exp": exp.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(s.jwtSecret))
if err != nil {
return nil, fmt.Errorf("failed to sign token: %w", err)
}
return &ChatTokenResponse{
Token: tokenString,
ExpiresIn: int64(expiration.Seconds()),
WSUrl: "/api/v1/ws",
}, nil
}
// ChatTokenClaims contains the validated claims from a chat JWT token
type ChatTokenClaims struct {
UserID uuid.UUID
Username string
}
// ValidateChatToken validates a chat JWT token and returns the claims
func (s *ChatService) ValidateChatToken(tokenString string) (*ChatTokenClaims, error) {
if s.jwtSecret == "" {
return nil, errors.New("JWT secret is not configured")
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.jwtSecret), nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
sub, ok := claims["sub"].(string)
if !ok {
return nil, errors.New("missing sub claim")
}
userID, err := uuid.Parse(sub)
if err != nil {
return nil, fmt.Errorf("invalid user ID in token: %w", err)
}
username, _ := claims["name"].(string)
return &ChatTokenClaims{
UserID: userID,
Username: username,
}, nil
}
// ChatStats représente les statistiques du chat
type ChatStats struct {
ActiveUsers int64 `json:"active_users"`
TotalMessages int64 `json:"total_messages"`
RoomsActive int64 `json:"rooms_active"`
}
// GetStats récupère les statistiques du chat
// BE-API-006: Implement chat stats endpoint
func (s *ChatService) GetStats(ctx context.Context) (*ChatStats, error) {
if s.db == nil {
return nil, errors.New("database connection not available")
}
stats := &ChatStats{}
// Total messages (non supprimés)
if err := s.db.WithContext(ctx).Model(&models.Message{}).
Where("is_deleted = ?", false).
Count(&stats.TotalMessages).Error; err != nil {
return nil, fmt.Errorf("failed to count total messages: %w", err)
}
// Active users: utilisateurs distincts qui ont envoyé des messages dans les dernières 24h
activeSince := time.Now().Add(-24 * time.Hour)
var activeUsersCount int64
if err := s.db.WithContext(ctx).Model(&models.Message{}).
Where("is_deleted = ? AND created_at >= ?", false, activeSince).
Distinct("user_id").
Count(&activeUsersCount).Error; err != nil {
return nil, fmt.Errorf("failed to count active users: %w", err)
}
stats.ActiveUsers = activeUsersCount
// Rooms actives: rooms qui ont eu des messages dans les dernières 24h
var roomsActiveCount int64
if err := s.db.WithContext(ctx).Model(&models.Message{}).
Where("is_deleted = ? AND created_at >= ?", false, activeSince).
Distinct("room_id").
Count(&roomsActiveCount).Error; err != nil {
return nil, fmt.Errorf("failed to count active rooms: %w", err)
}
stats.RoomsActive = roomsActiveCount
return stats, nil
}