- 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
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.sqlveza-backend-api/migrations/110_delivered_status.sqlveza-backend-api/migrations/111_message_reactions.sqlveza-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.goveza-backend-api/internal/repositories/delivered_status_repository.goveza-backend-api/internal/repositories/reaction_repository.go
Chaque repository suit le pattern GORM standard avec :
Create/DeleteGetByMessageID(liste des receipts/reactions pour un message)- Pour reactions :
GetByMessageIDGroupedretournemap[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 DBunregister: retire le client, nettoie les rooms, ferme la connexionbroadcast: 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 :
- Boucle
conn.Read(ctx) - Parse JSON →
IncomingMessage - Vérifie rate limit
- Dispatch vers le handler approprié selon
msg.Type - En cas d'erreur fatale : unregister du hub
WritePump :
- Boucle
selectsur le channelsendet un ticker keepalive send:conn.Write(ctx, websocket.MessageText, data)- Ticker : envoyer un Ping toutes les 30s
- 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 :
- Instancier
ChatPubSubServiceavec le Redis client (ou nil) - Instancier
Hubavec le PubSub service - Instancier tous les repositories nécessaires
- Instancier le
MessageHandler - Lancer
go hub.Run() - 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 :
- Valider
conversation_idetcontentnon vides - Vérifier permission
CanSend(userID, roomID) - Créer
models.Messageen DB - Construire
NewMessageJSON hub.BroadcastToRoom(roomID, data, uuid.Nil)(inclure le sender pour confirmation)- Si Redis PubSub actif : publier aussi sur Redis
handleEditMessage :
- Valider
message_idetnew_content - Charger le message depuis DB
- Vérifier que
sender_id == client.userID - Mettre à jour
content,is_edited=true,edited_at=now() - Broadcast
MessageEdited
handleDeleteMessage :
- Valider
message_id - Charger le message, vérifier ownership
- Soft delete (
is_deleted=true) - 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 :
- Parser
conversation_id - Vérifier permission
CanJoin(userID, roomID) hub.JoinRoom(userID, roomID)- Ajouter
roomIDaux rooms du client - Envoyer
ActionConfirmedau client
handleLeaveConversation :
- Parser
conversation_id hub.LeaveRoom(userID, roomID)- Retirer
roomIDdes rooms du client - Envoyer
ActionConfirmedau 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 :
- Parser
conversation_id,before(UUID optional),after(UUID optional),limit(default 50, max 100) - Vérifier permission
CanRead - Query via
messageRepo.GetMessagesBeforeouGetMessagesAfter - Joindre les usernames et reactions
- Envoyer
HistoryChunkavechas_more_beforeethas_more_after
handleSearchMessages :
- Parser
conversation_id,query,limit(default 20),offset - Vérifier permission
CanRead - Query via
messageRepo.Search - Envoyer
SearchResultsavectotalcount
handleSyncMessages :
- Parser
conversation_id,since(ISO timestamp) - Vérifier permission
CanRead - Query via
messageRepo.GetMessagesSince - Envoyer
SyncChunkaveclast_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 :
- Parser
conversation_id,is_typing - Broadcast
UserTypingà la room (exclure le sender) - Pas de persistence (état éphémère)
handleMarkAsRead :
- Parser
conversation_id,message_id - Upsert dans
read_receipts(ON CONFLICT DO NOTHING) - Broadcast
MessageReadavecread_at
handleDelivered :
- Parser
conversation_id,message_id - Upsert dans
delivered_status - Broadcast
MessageDeliveredavecdelivered_at
handleAddReaction :
- Parser
message_id,conversation_id,emoji - Insert dans
message_reactions(ON CONFLICT ignore) - Broadcast
ReactionAdded
handleRemoveReaction :
- Parser
message_id,conversation_id - Delete de
message_reactionspour ce user et ce message (toutes ses réactions) - 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 autarget_user_idviahub.SendToUserCallAnswer: relayer la réponse SDP aucaller_user_idICECandidate: relayer le candidat ICE autarget_user_idCallHangup: envoyerCallHangupautarget_user_idCallReject: envoyerCallRejectedaucaller_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 publiqueCanSend: member + non muted + non bannedCanJoin: room publique OU déjà memberCanModerate: rôleadminoumoderatordans 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.ymldocker-compose.staging.ymldocker-compose.prod.yml
Pour chaque fichier :
- Supprimer le service
chat-server(ouveza-chat-server) - Supprimer les variables d'environnement
CHAT_SERVER_*du servicebackend-api - Retirer
chat-serverdesdepends_ondes autres services - 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 à
wsUrldu 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 pluswsUrl, justetoken- Le hook
useChatcalcule 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.tsxChatInterface.tsxChatMessage.tsxTypingIndicator.tsxVirtualizedChatMessagesuseWebRTC.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 dansclients, 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éeTestHandleEditMessage_OwnershipCheck: seul l'auteur peut éditerTestHandleDeleteMessage_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 clientsTestHandleMarkAsRead_Persisted: read_receipt créé en DBTestHandleAddReaction_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éussieTestChatWS_ConnectWithoutToken: rejeté 401TestChatWS_ConnectWithExpiredToken: rejeté 401TestChatWS_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 receivesTestChatWS_FetchHistory: send messages, then FetchHistory → HistoryChunkTestChatWS_EditMessage: edit → MessageEdited broadcastTestChatWS_DeleteMessage: delete → MessageDeleted broadcast
CH4-06 : Tests E2E real-time
Tests :
TestChatWS_TypingIndicator: typing → UserTyping broadcastTestChatWS_ReadReceipt: markAsRead → MessageRead broadcastTestChatWS_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.md→docs/archive/ - Créer
docs/V0_503_RELEASE_SCOPE.md(placeholder) - Mettre à jour
docs/SCOPE_CONTROL.mdpour v0.503 - Mettre à jour
.cursorrulespour 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 servergithub.com/redis/go-redis/v9— Redis client (PubSub)gorm.io/gorm— ORMgithub.com/golang-jwt/jwt/v5— JWTgo.uber.org/zap— Logging
Dépendances frontend
Aucune nouvelle dépendance requise. Le frontend utilise déjà la WebSocket native du navigateur.