veza/docs/PLAN_V0_502_IMPLEMENTATION.md
senke 431ad133e2 docs(v0.502): plan d'implémentation Chat Server Rewrite (Rust → Go)
- Create ADR-002-chat-server.md: decision to rewrite Rust chat in Go
- Rewrite V0_502_RELEASE_SCOPE.md with 4 detailed lots (34 tasks)
- Create PLAN_V0_502_IMPLEMENTATION.md with 6 sprints and commit instructions
- Update .cursorrules scope reference for v0.502
2026-02-22 20:26:18 +01:00

38 KiB

Plan d'implémentation v0.502 — Chat Server Rewrite

Référence scope : V0_502_RELEASE_SCOPE.md ADR : ADR-002-chat-server.md Prérequis : v0.501 taguée


Vue d'ensemble

Sprint Nom Tâches Durée estimée
Sprint 1 Infrastructure & Modèles CH1-01 → CH1-05 ~2h
Sprint 2 WebSocket Core CH1-06 → CH1-08, CH2-01 → CH2-03 ~3h
Sprint 3 Handlers Messages & Real-time CH1-09 → CH1-14, CH2-06 ~3h
Sprint 4 Docker & Frontend Migration CH2-04 → CH2-05, CH3-01 → CH3-06 ~2h
Sprint 5 Tests & Validation CH4-01 → CH4-08 ~2h
Sprint 6 Finalisation FIN-01 → FIN-06 ~1h

Sprint 1 : Infrastructure & Modèles

CH1-01 : Migrations DB complémentaires

Fichiers :

  • veza-backend-api/migrations/109_read_receipts.sql
  • veza-backend-api/migrations/110_delivered_status.sql
  • veza-backend-api/migrations/111_message_reactions.sql
  • veza-backend-api/migrations/112_messages_extra_columns.sql

109_read_receipts.sql :

-- Migration 109: Create read_receipts table (v0.502 CH1-01)
CREATE TABLE IF NOT EXISTS read_receipts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    CONSTRAINT uq_read_receipts_user_message UNIQUE (user_id, message_id)
);
CREATE INDEX idx_read_receipts_message_id ON read_receipts(message_id);
CREATE INDEX idx_read_receipts_user_id ON read_receipts(user_id);

110_delivered_status.sql :

-- Migration 110: Create delivered_status table (v0.502 CH1-01)
CREATE TABLE IF NOT EXISTS delivered_status (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    delivered_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    CONSTRAINT uq_delivered_status_user_message UNIQUE (user_id, message_id)
);
CREATE INDEX idx_delivered_status_message_id ON delivered_status(message_id);
CREATE INDEX idx_delivered_status_user_id ON delivered_status(user_id);

111_message_reactions.sql :

-- Migration 111: Create message_reactions table (v0.502 CH1-01)
CREATE TABLE IF NOT EXISTS message_reactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    emoji VARCHAR(50) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    CONSTRAINT uq_message_reactions_user_message_emoji UNIQUE (user_id, message_id, emoji)
);
CREATE INDEX idx_message_reactions_message_id ON message_reactions(message_id);

112_messages_extra_columns.sql :

-- Migration 112: Add missing columns to messages (v0.502 CH1-01)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'sent';

Validation : go test ./... -run TestMigration ou vérification manuelle que les migrations s'appliquent.


CH1-02 : Modèles Go complémentaires

Fichiers :

  • veza-backend-api/internal/models/read_receipt.go (nouveau)
  • veza-backend-api/internal/models/delivered_status.go (nouveau)
  • veza-backend-api/internal/models/message_reaction.go (nouveau)
  • veza-backend-api/internal/models/message.go (modifier)

read_receipt.go :

package models

import (
    "time"
    "github.com/google/uuid"
    "gorm.io/gorm"
)

type ReadReceipt struct {
    ID        uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
    UserID    uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_read_receipts_user_message" json:"user_id"`
    MessageID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_read_receipts_user_message" json:"message_id"`
    ReadAt    time.Time `gorm:"autoCreateTime" json:"read_at"`
}

func (r *ReadReceipt) BeforeCreate(tx *gorm.DB) error {
    if r.ID == uuid.Nil { r.ID = uuid.New() }
    return nil
}

func (ReadReceipt) TableName() string { return "read_receipts" }

delivered_status.go :

package models

import (
    "time"
    "github.com/google/uuid"
    "gorm.io/gorm"
)

type DeliveredStatus struct {
    ID          uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
    UserID      uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_delivered_status_user_message" json:"user_id"`
    MessageID   uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_delivered_status_user_message" json:"message_id"`
    DeliveredAt time.Time `gorm:"autoCreateTime" json:"delivered_at"`
}

func (d *DeliveredStatus) BeforeCreate(tx *gorm.DB) error {
    if d.ID == uuid.Nil { d.ID = uuid.New() }
    return nil
}

func (DeliveredStatus) TableName() string { return "delivered_status" }

message_reaction.go :

package models

import (
    "time"
    "github.com/google/uuid"
    "gorm.io/gorm"
)

type MessageReaction struct {
    ID        uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
    UserID    uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_message_reactions_user_message_emoji" json:"user_id"`
    MessageID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uq_message_reactions_user_message_emoji" json:"message_id"`
    Emoji     string    `gorm:"size:50;not null;uniqueIndex:uq_message_reactions_user_message_emoji" json:"emoji"`
    CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}

func (mr *MessageReaction) BeforeCreate(tx *gorm.DB) error {
    if mr.ID == uuid.Nil { mr.ID = uuid.New() }
    return nil
}

func (MessageReaction) TableName() string { return "message_reactions" }

message.go — ajouter les champs suivants :

    EditedAt  *time.Time     `gorm:"" json:"edited_at,omitempty"`
    Status    string         `gorm:"size:20;default:'sent'" json:"status"`
    IsPinned  bool           `gorm:"default:false" json:"is_pinned"`
    Metadata  *string        `gorm:"type:jsonb" json:"metadata,omitempty"`

Validation : cd veza-backend-api && go build ./...


CH1-03 : Repository Chat GORM enrichi

Fichier : veza-backend-api/internal/repositories/chat_message_repository.go

Ce repository remplace l'usage de database/chat_repository.go (raw SQL) par GORM. Il doit fournir :

type ChatMessageRepository struct {
    db *gorm.DB
}

func NewChatMessageRepository(db *gorm.DB) *ChatMessageRepository

// CRUD
func (r *ChatMessageRepository) Create(ctx context.Context, msg *models.Message) error
func (r *ChatMessageRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Message, error)
func (r *ChatMessageRepository) Update(ctx context.Context, msg *models.Message) error
func (r *ChatMessageRepository) SoftDelete(ctx context.Context, id uuid.UUID) error

// Queries
func (r *ChatMessageRepository) GetConversationMessages(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]ChatMessageResult, error)
func (r *ChatMessageRepository) GetMessagesBefore(ctx context.Context, roomID uuid.UUID, beforeID uuid.UUID, limit int) ([]ChatMessageResult, error)
func (r *ChatMessageRepository) GetMessagesAfter(ctx context.Context, roomID uuid.UUID, afterID uuid.UUID, limit int) ([]ChatMessageResult, error)
func (r *ChatMessageRepository) GetMessagesSince(ctx context.Context, roomID uuid.UUID, since time.Time, limit int) ([]ChatMessageResult, error)
func (r *ChatMessageRepository) Search(ctx context.Context, roomID uuid.UUID, query string, limit, offset int) ([]ChatMessageResult, int64, error)

// ChatMessageResult includes sender_username joined from users table
type ChatMessageResult struct {
    ID             uuid.UUID
    ConversationID uuid.UUID
    SenderID       uuid.UUID
    SenderUsername string
    Content        string
    MessageType    string
    CreatedAt      time.Time
    IsEdited       bool
    EditedAt       *time.Time
    Reactions      map[string][]string // emoji -> []userID
}

Note : Le ChatMessageRepository existant dans internal/repositories/ est déjà utilisé par RoomService. Vérifier s'il peut être enrichi ou s'il faut créer un nouveau. La solution recommandée est d'enrichir l'existant.


CH1-04 : Repositories read/delivered/reactions

Fichiers :

  • veza-backend-api/internal/repositories/read_receipt_repository.go
  • veza-backend-api/internal/repositories/delivered_status_repository.go
  • veza-backend-api/internal/repositories/reaction_repository.go

Chaque repository suit le pattern GORM standard avec :

  • Create / Delete
  • GetByMessageID (liste des receipts/reactions pour un message)
  • Pour reactions : GetByMessageIDGrouped retourne map[string][]string (emoji → userIDs)

CH1-05 : Service Redis PubSub

Fichier : veza-backend-api/internal/services/chat_pubsub.go

type ChatPubSubService struct {
    redisClient *redis.Client
    logger      *zap.Logger
}

func NewChatPubSubService(redisClient *redis.Client, logger *zap.Logger) *ChatPubSubService

func (s *ChatPubSubService) Publish(ctx context.Context, roomID uuid.UUID, message []byte) error
func (s *ChatPubSubService) Subscribe(ctx context.Context, roomID uuid.UUID) (<-chan []byte, func(), error)
func (s *ChatPubSubService) PublishPresence(ctx context.Context, event []byte) error
func (s *ChatPubSubService) SubscribePresence(ctx context.Context) (<-chan []byte, func(), error)
  • Canal room : chat:room:{roomID}
  • Canal présence : chat:presence
  • Si redisClient == nil, utiliser un broadcast channel in-memory (pour dev sans Redis)

Validation : go build ./...

Commit Sprint 1

feat(chat): Sprint 1 -- migrations, models, repositories for chat rewrite

- Add migrations 109-112 (read_receipts, delivered_status, message_reactions, messages extra columns)
- Create ReadReceipt, DeliveredStatus, MessageReaction models
- Update Message model with EditedAt, Status, IsPinned, Metadata fields
- Enrich ChatMessageRepository with cursor-based pagination and search
- Add ReadReceiptRepository, DeliveredStatusRepository, ReactionRepository
- Create ChatPubSubService (Redis-backed with in-memory fallback)

Sprint 2 : WebSocket Core

CH1-06 : WebSocket Hub

Fichier : veza-backend-api/internal/websocket/chat/hub.go

Le Hub est le gestionnaire central des connexions WebSocket. Il s'inspire du pattern du playback_websocket_handler.go existant mais est adapté au multi-room.

package chat

type Hub struct {
    clients    map[uuid.UUID]*Client           // userID → client
    rooms      map[uuid.UUID]map[uuid.UUID]bool // roomID → set of userIDs
    register   chan *Client
    unregister chan *Client
    broadcast  chan *RoomMessage
    pubsub     *services.ChatPubSubService
    logger     *zap.Logger
    mu         sync.RWMutex
}

type RoomMessage struct {
    RoomID    uuid.UUID
    Data      []byte
    ExcludeID uuid.UUID // Don't send back to sender
}

func NewHub(pubsub *services.ChatPubSubService, logger *zap.Logger) *Hub
func (h *Hub) Run()
func (h *Hub) BroadcastToRoom(roomID uuid.UUID, data []byte, excludeUserID uuid.UUID)
func (h *Hub) SendToUser(userID uuid.UUID, data []byte)
func (h *Hub) JoinRoom(userID uuid.UUID, roomID uuid.UUID)
func (h *Hub) LeaveRoom(userID uuid.UUID, roomID uuid.UUID)
func (h *Hub) GetRoomUsers(roomID uuid.UUID) []uuid.UUID
func (h *Hub) IsUserOnline(userID uuid.UUID) bool

La goroutine Run() boucle sur les channels register, unregister, broadcast :

  • register : ajoute le client à clients, charge ses rooms depuis la DB
  • unregister : retire le client, nettoie les rooms, ferme la connexion
  • broadcast : envoie le message à tous les clients dans la room (sauf excluded)

Si Redis PubSub est actif, les messages sont aussi publiés/écoutés via Redis pour supporter plusieurs instances Go.


CH1-07 : Client WebSocket

Fichier : veza-backend-api/internal/websocket/chat/client.go

type Client struct {
    hub      *Hub
    conn     *websocket.Conn
    userID   uuid.UUID
    username string
    send     chan []byte
    rooms    map[uuid.UUID]bool
    handler  *MessageHandler
    mu       sync.Mutex
}

func NewClient(hub *Hub, conn *websocket.Conn, userID uuid.UUID, username string, handler *MessageHandler) *Client
func (c *Client) ReadPump(ctx context.Context)
func (c *Client) WritePump(ctx context.Context)

ReadPump :

  1. Boucle conn.Read(ctx)
  2. Parse JSON → IncomingMessage
  3. Vérifie rate limit
  4. Dispatch vers le handler approprié selon msg.Type
  5. En cas d'erreur fatale : unregister du hub

WritePump :

  1. Boucle select sur le channel send et un ticker keepalive
  2. send : conn.Write(ctx, websocket.MessageText, data)
  3. Ticker : envoyer un Ping toutes les 30s
  4. Timeout write : 10s

CH1-08 : Types de messages WebSocket

Fichier : veza-backend-api/internal/websocket/chat/messages.go

// IncomingMessage est le type entrant (client → serveur)
type IncomingMessage struct {
    Type             string  `json:"type"`
    ConversationID   *string `json:"conversation_id,omitempty"`
    Content          *string `json:"content,omitempty"`
    ParentMessageID  *string `json:"parent_message_id,omitempty"`
    MessageID        *string `json:"message_id,omitempty"`
    IsTyping         *bool   `json:"is_typing,omitempty"`
    Emoji            *string `json:"emoji,omitempty"`
    NewContent       *string `json:"new_content,omitempty"`
    Before           *string `json:"before,omitempty"`
    After            *string `json:"after,omitempty"`
    Limit            *int    `json:"limit,omitempty"`
    Query            *string `json:"query,omitempty"`
    Offset           *int    `json:"offset,omitempty"`
    Since            *string `json:"since,omitempty"`
    TargetUserID     *string `json:"target_user_id,omitempty"`
    CallerUserID     *string `json:"caller_user_id,omitempty"`
    SDP              *string `json:"sdp,omitempty"`
    Candidate        *string `json:"candidate,omitempty"`
    CallType         *string `json:"call_type,omitempty"`
    Attachments      []MessageAttachment `json:"attachments,omitempty"`
}

type MessageAttachment struct {
    ID       string `json:"id,omitempty"`
    FileName string `json:"file_name"`
    FileType string `json:"file_type"`
    FileURL  string `json:"file_url"`
    FileSize int64  `json:"file_size,omitempty"`
}

// OutgoingMessage factory functions (chacune crée le bon JSON)
func NewMessageResponse(convID, msgID, senderID, content string, createdAt time.Time, attachments []MessageAttachment) []byte
func ErrorResponse(message string) []byte
func PongResponse() []byte
func TypingResponse(convID, userID string, isTyping bool) []byte
func ReadReceiptResponse(msgID, userID, convID string, readAt time.Time) []byte
func DeliveredResponse(msgID, userID, convID string, deliveredAt time.Time) []byte
func ReactionAddedResponse(msgID, convID, userID, emoji string) []byte
func ReactionRemovedResponse(msgID, convID, userID string) []byte
func MessageEditedResponse(msgID, convID, editorID, newContent string, editedAt time.Time) []byte
func MessageDeletedResponse(msgID, convID, deleterID string, deletedAt time.Time) []byte
func HistoryChunkResponse(convID string, messages []HistoryMessage, hasMoreBefore, hasMoreAfter bool) []byte
func SearchResultsResponse(convID string, messages []HistoryMessage, query string, total int64) []byte
func SyncChunkResponse(convID string, messages []HistoryMessage, lastSync time.Time) []byte
func ActionConfirmedResponse(action string, success bool) []byte
func CallOfferResponse(convID, callerUserID, sdp, callType string) []byte
func CallAnswerResponse(convID, targetUserID, fromUserID, sdp string) []byte
func ICECandidateResponse(convID, fromUserID, candidate string) []byte
func CallHangupResponse(convID, userID string) []byte
func CallRejectedResponse(convID, userID string) []byte

Chaque factory sérialise en JSON avec le champ "type" correspondant (ex : "NewMessage", "Error", "Pong").


CH2-01 : Endpoint WebSocket dans le router Go

Fichier : veza-backend-api/internal/api/router.go (modifier)

Ajouter dans setupRoutes :

// WebSocket endpoint for chat
chatHub := chat.NewHub(chatPubSub, r.logger)
go chatHub.Run()

msgHandler := chat.NewMessageHandler(chatHub, chatMsgRepo, readReceiptRepo, deliveredRepo, reactionRepo, roomRepo, r.logger)

v1.GET("/ws", func(c *gin.Context) {
    token := c.Query("token")
    if token == "" {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
        return
    }

    claims, err := chatService.ValidateChatToken(token)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
        return
    }

    conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{
        OriginPatterns: []string{"*"},
    })
    if err != nil {
        r.logger.Error("websocket accept failed", zap.Error(err))
        return
    }

    client := chat.NewClient(chatHub, conn, claims.UserID, claims.Username, msgHandler)
    chatHub.Register(client)

    ctx := c.Request.Context()
    go client.WritePump(ctx)
    client.ReadPump(ctx)
})

CH2-02 : Mise à jour ChatService

Fichier : veza-backend-api/internal/services/chat_service.go (modifier)

Ajouter ValidateChatToken :

type ChatTokenClaims struct {
    UserID   uuid.UUID
    Username string
}

func (s *ChatService) ValidateChatToken(tokenString string) (*ChatTokenClaims, error) {
    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, _ := claims["sub"].(string)
    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
}

Mettre à jour GenerateToken pour que WSUrl soit /api/v1/ws :

return &ChatTokenResponse{
    Token:     tokenString,
    ExpiresIn: int64(expiration.Seconds()),
    WSUrl:     "/api/v1/ws",
}, nil

CH2-03 : Initialisation Hub dans le backend

Fichier : veza-backend-api/internal/api/router.go (modifier)

Dans la méthode SetupRoutes ou dans une méthode dédiée setupWebSocketRoutes :

  1. Instancier ChatPubSubService avec le Redis client (ou nil)
  2. Instancier Hub avec le PubSub service
  3. Instancier tous les repositories nécessaires
  4. Instancier le MessageHandler
  5. Lancer go hub.Run()
  6. Enregistrer la route /ws

Commit Sprint 2

feat(chat): Sprint 2 -- WebSocket hub, client, message types, route

- Create Hub (connection manager with room tracking and Redis PubSub)
- Create Client (readPump/writePump with keepalive and timeout)
- Define all incoming/outgoing WebSocket message types (JSON protocol)
- Register GET /api/v1/ws endpoint with JWT authentication
- Update ChatService with ValidateChatToken and WSUrl /api/v1/ws
- Wire Hub initialization in router with PubSub injection

Sprint 3 : Handlers Messages & Real-time

CH1-09 : Handler Messages CRUD

Fichier : veza-backend-api/internal/websocket/chat/handler_messages.go

type MessageHandler struct {
    hub             *Hub
    messageRepo     *repositories.ChatMessageRepository
    readReceiptRepo *repositories.ReadReceiptRepository
    deliveredRepo   *repositories.DeliveredStatusRepository
    reactionRepo    *repositories.ReactionRepository
    roomRepo        *repositories.RoomRepository
    permissions     *PermissionService
    rateLimiter     *RateLimiter
    logger          *zap.Logger
}

func (h *MessageHandler) HandleSendMessage(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleEditMessage(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleDeleteMessage(ctx context.Context, client *Client, msg *IncomingMessage)

handleSendMessage :

  1. Valider conversation_id et content non vides
  2. Vérifier permission CanSend(userID, roomID)
  3. Créer models.Message en DB
  4. Construire NewMessage JSON
  5. hub.BroadcastToRoom(roomID, data, uuid.Nil) (inclure le sender pour confirmation)
  6. Si Redis PubSub actif : publier aussi sur Redis

handleEditMessage :

  1. Valider message_id et new_content
  2. Charger le message depuis DB
  3. Vérifier que sender_id == client.userID
  4. Mettre à jour content, is_edited=true, edited_at=now()
  5. Broadcast MessageEdited

handleDeleteMessage :

  1. Valider message_id
  2. Charger le message, vérifier ownership
  3. Soft delete (is_deleted=true)
  4. Broadcast MessageDeleted

CH1-10 : Handler Rooms

Fichier : veza-backend-api/internal/websocket/chat/handler_rooms.go

func (h *MessageHandler) HandleJoinConversation(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleLeaveConversation(ctx context.Context, client *Client, msg *IncomingMessage)

handleJoinConversation :

  1. Parser conversation_id
  2. Vérifier permission CanJoin(userID, roomID)
  3. hub.JoinRoom(userID, roomID)
  4. Ajouter roomID aux rooms du client
  5. Envoyer ActionConfirmed au client

handleLeaveConversation :

  1. Parser conversation_id
  2. hub.LeaveRoom(userID, roomID)
  3. Retirer roomID des rooms du client
  4. Envoyer ActionConfirmed au client

CH1-11 : Handler Historique & Recherche

Fichier : veza-backend-api/internal/websocket/chat/handler_history.go

func (h *MessageHandler) HandleFetchHistory(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleSearchMessages(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleSyncMessages(ctx context.Context, client *Client, msg *IncomingMessage)

handleFetchHistory :

  1. Parser conversation_id, before (UUID optional), after (UUID optional), limit (default 50, max 100)
  2. Vérifier permission CanRead
  3. Query via messageRepo.GetMessagesBefore ou GetMessagesAfter
  4. Joindre les usernames et reactions
  5. Envoyer HistoryChunk avec has_more_before et has_more_after

handleSearchMessages :

  1. Parser conversation_id, query, limit (default 20), offset
  2. Vérifier permission CanRead
  3. Query via messageRepo.Search
  4. Envoyer SearchResults avec total count

handleSyncMessages :

  1. Parser conversation_id, since (ISO timestamp)
  2. Vérifier permission CanRead
  3. Query via messageRepo.GetMessagesSince
  4. Envoyer SyncChunk avec last_sync

CH1-12 : Handler Typing, Read, Delivered, Reactions

Fichier : veza-backend-api/internal/websocket/chat/handler_realtime.go

func (h *MessageHandler) HandleTyping(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleMarkAsRead(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleDelivered(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleAddReaction(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleRemoveReaction(ctx context.Context, client *Client, msg *IncomingMessage)

handleTyping :

  1. Parser conversation_id, is_typing
  2. Broadcast UserTyping à la room (exclure le sender)
  3. Pas de persistence (état éphémère)

handleMarkAsRead :

  1. Parser conversation_id, message_id
  2. Upsert dans read_receipts (ON CONFLICT DO NOTHING)
  3. Broadcast MessageRead avec read_at

handleDelivered :

  1. Parser conversation_id, message_id
  2. Upsert dans delivered_status
  3. Broadcast MessageDelivered avec delivered_at

handleAddReaction :

  1. Parser message_id, conversation_id, emoji
  2. Insert dans message_reactions (ON CONFLICT ignore)
  3. Broadcast ReactionAdded

handleRemoveReaction :

  1. Parser message_id, conversation_id
  2. Delete de message_reactions pour ce user et ce message (toutes ses réactions)
  3. Broadcast ReactionRemoved

CH1-13 : Handler Signalisation WebRTC

Fichier : veza-backend-api/internal/websocket/chat/handler_calls.go

func (h *MessageHandler) HandleCallOffer(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleCallAnswer(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleICECandidate(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleCallHangup(ctx context.Context, client *Client, msg *IncomingMessage)
func (h *MessageHandler) HandleCallReject(ctx context.Context, client *Client, msg *IncomingMessage)

Ces handlers sont de simples relais :

  • CallOffer : relayer l'offre SDP au target_user_id via hub.SendToUser
  • CallAnswer : relayer la réponse SDP au caller_user_id
  • ICECandidate : relayer le candidat ICE au target_user_id
  • CallHangup : envoyer CallHangup au target_user_id
  • CallReject : envoyer CallRejected au caller_user_id

CH1-14 : Rate Limiting WebSocket

Fichier : veza-backend-api/internal/websocket/chat/rate_limiter.go

type RateLimiter struct {
    redisClient *redis.Client
    limits      map[string]RateLimit
    // in-memory fallback
    counters    map[string]*rateLimitCounter
    mu          sync.RWMutex
}

type RateLimit struct {
    MaxRequests int
    Window      time.Duration
}

func NewRateLimiter(redisClient *redis.Client) *RateLimiter
func (rl *RateLimiter) Allow(userID uuid.UUID, action string) bool

Limites par défaut :

Action Max/window
SendMessage 10 / 1s
Typing 5 / 1s
AddReaction 5 / 1s
EditMessage 5 / 1s
DeleteMessage 5 / 1s
SearchMessages 2 / 1s
CallSignaling 10 / 1s
FetchHistory 5 / 1s

CH2-06 : Permissions et membership

Fichier : veza-backend-api/internal/websocket/chat/permissions.go

type PermissionService struct {
    roomRepo *repositories.RoomRepository
    db       *gorm.DB
    logger   *zap.Logger
}

func NewPermissionService(roomRepo *repositories.RoomRepository, db *gorm.DB, logger *zap.Logger) *PermissionService
func (p *PermissionService) CanRead(ctx context.Context, userID, roomID uuid.UUID) bool
func (p *PermissionService) CanSend(ctx context.Context, userID, roomID uuid.UUID) bool
func (p *PermissionService) CanJoin(ctx context.Context, userID, roomID uuid.UUID) bool
func (p *PermissionService) CanModerate(ctx context.Context, userID, roomID uuid.UUID) bool
  • CanRead : member de la room OU room publique
  • CanSend : member + non muted + non banned
  • CanJoin : room publique OU déjà member
  • CanModerate : rôle admin ou moderator dans la room

Commit Sprint 3

feat(chat): Sprint 3 -- message handlers, real-time features, permissions

- Implement SendMessage, EditMessage, DeleteMessage handlers with DB persistence
- Implement JoinConversation, LeaveConversation with permission checks
- Implement FetchHistory (cursor-based), SearchMessages (ILIKE), SyncMessages
- Implement typing indicators (broadcast, no persistence)
- Implement read receipts and delivered status (DB + broadcast)
- Implement reactions add/remove (DB + broadcast)
- Implement WebRTC call signaling relay (offer/answer/ICE/hangup/reject)
- Add per-user per-action rate limiting (Redis + in-memory fallback)
- Create PermissionService (CanRead, CanSend, CanJoin, CanModerate)

Sprint 4 : Docker & Frontend Migration

CH2-04 : Suppression du chat server Rust de Docker

Fichiers :

  • docker-compose.yml
  • docker-compose.staging.yml
  • docker-compose.prod.yml

Pour chaque fichier :

  1. Supprimer le service chat-server (ou veza-chat-server)
  2. Supprimer les variables d'environnement CHAT_SERVER_* du service backend-api
  3. Retirer chat-server des depends_on des autres services
  4. Conserver le port WebSocket (le backend Go écoute déjà sur son port HTTP)

CH2-05 : Variables d'environnement

Fichiers :

  • apps/web/.env.development (ou équivalent)

  • apps/web/.env.example

  • Supprimer ou rediriger VITE_WS_URL

  • Le frontend construit l'URL WS depuis VITE_API_URL :

    VITE_API_URL=http://localhost:8080/api/v1
    # VITE_WS_URL supprimé — WS est sur le même host que l'API
    

CH3-01 : Mise à jour URL WebSocket frontend

Fichier : apps/web/src/features/chat/hooks/useChat.ts

Remplacer la construction de l'URL WS :

// Avant :
const fullWsUrl = `${wsUrl}?token=${wsToken}`;

// Après :
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const wsProtocol = apiUrl.startsWith('https') ? 'wss' : 'ws';
const wsHost = apiUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '');
const fullWsUrl = `${wsProtocol}://${wsHost}/api/v1/ws?token=${wsToken}`;

Supprimer :

  • La vérification 127.0.0.1:8081
  • La dépendance à wsUrl du store

CH3-02 : Mise à jour du store chat

Fichier : apps/web/src/features/chat/store/chatStore.ts

  • Champ wsUrl → supprimé ou rendu optionnel
  • setWsToken → ne prend plus wsUrl, juste token
  • Le hook useChat calcule l'URL dynamiquement

CH3-03 : Mise à jour MSW handlers

Fichier : apps/web/src/mocks/handlers-misc.ts

Mettre à jour le mock POST /api/v1/chat/token :

http.post('*/api/v1/chat/token', () => {
  return HttpResponse.json({
    data: {
      token: 'mock-chat-jwt-token',
      expires_in: 900,
      ws_url: '/api/v1/ws',
    },
  });
}),

CH3-04 : Vérification composants Chat

Vérifier que les composants suivants fonctionnent sans modification :

  • ChatPage.tsx
  • ChatInterface.tsx
  • ChatMessage.tsx
  • TypingIndicator.tsx
  • VirtualizedChatMessages
  • useWebRTC.ts

Si des ajustements sont nécessaires (URL WS, format de messages), les appliquer.


CH3-05 : Ajout types manquants

Fichier : apps/web/src/features/chat/types/index.ts

Ajouter dans OutgoingMessage.type :

| 'EditMessage'
| 'DeleteMessage'
| 'FetchHistory'
| 'SearchMessages'
| 'SyncMessages'

Ajouter les champs correspondants :

new_content?: string;
before?: string;
after?: string;
limit?: number;
query?: string;
offset?: number;
since?: string;

Ajouter dans IncomingMessage.type :

| 'MessageEdited'
| 'MessageDeleted'
| 'SearchResults'
| 'SyncChunk'

Ajouter les champs :

editor_id?: string;
edited_at?: string;
deleter_id?: string;
deleted_at?: string;
new_content?: string;
query?: string;
total?: number;
last_sync?: string;

Dans useChat.ts, ajouter le handling de ces nouveaux types dans handleMessage.


CH3-06 : Storybook

Vérifier que ChatPage.stories.tsx fonctionne avec les mocks mis à jour. Ajouter si manquant :

  • Story "Connecting" (wsStatus = 'connecting')
  • Story "Error" (wsStatus = 'error')

Commit Sprint 4

feat(chat): Sprint 4 -- Docker cleanup, frontend migration to Go WS

- Remove Rust chat-server from all Docker Compose files
- Update VITE_WS_URL to use API host for WebSocket
- Update useChat hook to build WS URL from VITE_API_URL
- Simplify chatStore (remove wsUrl dependency)
- Update MSW chat handlers for new ws_url format
- Add missing message types (EditMessage, DeleteMessage, SearchMessages, etc.)
- Add message handlers in useChat for MessageEdited, MessageDeleted, etc.
- Verify all chat components work with new backend

Sprint 5 : Tests & Validation

CH4-01 : Tests unitaires Hub

Fichier : veza-backend-api/internal/websocket/chat/hub_test.go

Tests :

  • TestHub_RegisterUnregister : register un client, vérifier qu'il est dans clients, unregister, vérifier qu'il est retiré
  • TestHub_BroadcastToRoom : 3 clients dans une room, broadcast → 2 reçoivent (excluant le sender)
  • TestHub_SendToUser : envoyer un message ciblé à un user spécifique

CH4-02 : Tests unitaires message handlers

Fichier : veza-backend-api/internal/websocket/chat/handler_messages_test.go

Tests :

  • TestHandleSendMessage_Success : message créé en DB, broadcast envoyé
  • TestHandleSendMessage_EmptyContent : erreur retournée
  • TestHandleEditMessage_OwnershipCheck : seul l'auteur peut éditer
  • TestHandleDeleteMessage_SoftDelete : le message est marqué is_deleted

Utiliser une DB SQLite in-memory pour les tests, comme fait dans les tests existants.


CH4-03 : Tests unitaires typing/read/reactions

Fichier : veza-backend-api/internal/websocket/chat/handler_realtime_test.go

Tests :

  • TestHandleTyping_Broadcast : UserTyping envoyé aux autres clients
  • TestHandleMarkAsRead_Persisted : read_receipt créé en DB
  • TestHandleAddReaction_Persisted : reaction créée en DB, broadcast envoyé
  • TestHandleRemoveReaction_Deleted : reaction supprimée en DB

CH4-04 : Tests E2E connexion WebSocket

Fichier : veza-backend-api/internal/integration/e2e_chat_ws_test.go

Tests avec httptest.Server et coder/websocket :

  • TestChatWS_ConnectWithValidToken : connexion réussie
  • TestChatWS_ConnectWithoutToken : rejeté 401
  • TestChatWS_ConnectWithExpiredToken : rejeté 401
  • TestChatWS_PingPong : envoyer Ping → recevoir Pong

CH4-05 : Tests E2E flux de messages

Fichier : veza-backend-api/internal/integration/e2e_chat_messages_test.go

Tests :

  • TestChatWS_SendAndReceiveMessage : 2 clients, join room, send message → other receives
  • TestChatWS_FetchHistory : send messages, then FetchHistory → HistoryChunk
  • TestChatWS_EditMessage : edit → MessageEdited broadcast
  • TestChatWS_DeleteMessage : delete → MessageDeleted broadcast

CH4-06 : Tests E2E real-time

Tests :

  • TestChatWS_TypingIndicator : typing → UserTyping broadcast
  • TestChatWS_ReadReceipt : markAsRead → MessageRead broadcast
  • TestChatWS_Reaction : addReaction → ReactionAdded broadcast

CH4-07 : Document feature parity

Fichier : docs/CHAT_FEATURE_PARITY.md

# Chat Feature Parity — Rust → Go

| # | Feature | Rust | Go | Status |
|---|---------|------|-----|--------|
| 1 | WebSocket connection | ✅ | ✅ | Parity |
| 2 | JWT authentication | ✅ | ✅ | Parity |
| 3 | Send message | ✅ | ✅ | Parity |
| ... | ... | ... | ... | ... |

Liste exhaustive des ~25 features à valider.


CH4-08 : Tests de performance

Benchmark simple dans un test Go :

  • Créer 100 connexions WebSocket simultanées
  • Chaque client envoie 10 messages
  • Mesurer la latence moyenne de delivery
  • Critère : < 100ms pour la livraison

Commit Sprint 5

test(chat): Sprint 5 -- unit tests, E2E tests, feature parity validation

- Add Hub unit tests (register, unregister, broadcast, sendToUser)
- Add message handler unit tests (send, edit, delete with ownership checks)
- Add real-time handler tests (typing, read receipts, reactions)
- Add E2E WebSocket connection tests (auth, ping/pong)
- Add E2E message flow tests (send, receive, history, edit, delete)
- Add E2E real-time tests (typing, read, reactions)
- Create CHAT_FEATURE_PARITY.md validation document
- Add performance benchmark (100 connections, <100ms latency)

Sprint 6 : Finalisation

FIN-01 : Smoke test

Créer docs/SMOKE_TEST_V0502.md avec la checklist de validation :

  • Go backend compile (go build ./...)
  • WebSocket connexion fonctionne
  • Envoi/réception de messages
  • Typing indicators
  • Read receipts
  • Reactions
  • Call signaling
  • Frontend build OK (npm run build)
  • Storybook OK (npm run build-storybook)

FIN-02 : Mise à jour PROJECT_STATE.md

  • Version : v0.502
  • Chat Server : Go (Rust supprimé)
  • Features livrées : +10

FIN-03 : Mise à jour CHANGELOG.md

Ajouter une entrée v0.502 avec :

  • Added : WebSocket chat handler Go, Redis PubSub, permissions, rate limiting
  • Changed : Chat server Rust → Go, frontend WS URL
  • Removed : Rust chat server Docker service
  • Infrastructure : Migrations 109-112

FIN-04 : Archivage et scope suivant

  • Déplacer V0_502_RELEASE_SCOPE.mddocs/archive/
  • Créer docs/V0_503_RELEASE_SCOPE.md (placeholder)
  • Mettre à jour docs/SCOPE_CONTROL.md pour v0.503
  • Mettre à jour .cursorrules pour v0.503

FIN-05 : Rétrospective

Créer docs/RETROSPECTIVE_V0502.md :

  • Objectif : réécriture chat server Rust → Go
  • Résultat : feature parity validée, latence < 100ms
  • Points positifs, points d'attention, prochaines étapes

FIN-06 : Tag

git tag v0.502

Commit Sprint 6

docs(v0.502): Sprint 6 -- finalization, docs, and tag

- Create SMOKE_TEST_V0502.md
- Update PROJECT_STATE.md for v0.502
- Update CHANGELOG.md with v0.502 entry
- Archive V0_502_RELEASE_SCOPE.md, create V0_503_RELEASE_SCOPE.md
- Update SCOPE_CONTROL.md for v0.503
- Create RETROSPECTIVE_V0502.md
- Tag v0.502

Résumé des commits

# Sprint Message de commit
1 Sprint 1 feat(chat): Sprint 1 -- migrations, models, repositories for chat rewrite
2 Sprint 2 feat(chat): Sprint 2 -- WebSocket hub, client, message types, route
3 Sprint 3 feat(chat): Sprint 3 -- message handlers, real-time features, permissions
4 Sprint 4 feat(chat): Sprint 4 -- Docker cleanup, frontend migration to Go WS
5 Sprint 5 test(chat): Sprint 5 -- unit tests, E2E tests, feature parity validation
6 Sprint 6 docs(v0.502): Sprint 6 -- finalization, docs, and tag

Dépendances Go à ajouter

Aucune nouvelle dépendance requise. Le projet utilise déjà :

  • github.com/coder/websocket — WebSocket server
  • github.com/redis/go-redis/v9 — Redis client (PubSub)
  • gorm.io/gorm — ORM
  • github.com/golang-jwt/jwt/v5 — JWT
  • go.uber.org/zap — Logging

Dépendances frontend

Aucune nouvelle dépendance requise. Le frontend utilise déjà la WebSocket native du navigateur.