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

1178 lines
38 KiB
Markdown

# 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.