# Plan d'implémentation v0.502 — Chat Server Rewrite **Référence scope** : [V0_502_RELEASE_SCOPE.md](V0_502_RELEASE_SCOPE.md) **ADR** : [ADR-002-chat-server.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** : ```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** : ```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** : ```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** : ```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** : ```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** : ```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** : ```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 : ```go 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 : ```go 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` ```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. ```go 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` ```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` ```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` : ```go // 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` : ```go 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` : ```go 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` ```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` ```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` ```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` ```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` ```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` ```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` ```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 : ```typescript // 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` : ```typescript 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` : ```typescript | 'EditMessage' | 'DeleteMessage' | 'FetchHistory' | 'SearchMessages' | 'SyncMessages' ``` Ajouter les champs correspondants : ```typescript new_content?: string; before?: string; after?: string; limit?: number; query?: string; offset?: number; since?: string; ``` Ajouter dans `IncomingMessage.type` : ```typescript | 'MessageEdited' | 'MessageDeleted' | 'SearchResults' | 'SyncChunk' ``` Ajouter les champs : ```typescript 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` ```markdown # 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.md` → `docs/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 ```bash 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.