1179 lines
38 KiB
Markdown
1179 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.
|