413 lines
11 KiB
Markdown
413 lines
11 KiB
Markdown
|
|
# 📬 Delivered Status + Typing Indicators — Documentation complète
|
||
|
|
|
||
|
|
**Date** : 2025-01-27
|
||
|
|
**Version** : 1.0.0
|
||
|
|
**Cible** : `veza-chat-server`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📋 TABLE DES MATIÈRES
|
||
|
|
|
||
|
|
1. [Vue d'ensemble](#vue-densemble)
|
||
|
|
2. [Delivered Status](#delivered-status)
|
||
|
|
3. [Typing Indicators](#typing-indicators)
|
||
|
|
4. [Messages WebSocket](#messages-websocket)
|
||
|
|
5. [Permissions](#permissions)
|
||
|
|
6. [Exemples de payloads](#exemples-de-payloads)
|
||
|
|
7. [Limites et considérations](#limites-et-considérations)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🎯 VUE D'ENSEMBLE
|
||
|
|
|
||
|
|
Deux fonctionnalités essentielles du chat moderne ont été implémentées :
|
||
|
|
|
||
|
|
1. **Delivered Status** : Tracking persistant des messages reçus (mais pas encore lus)
|
||
|
|
2. **Typing Indicators** : Indicateurs en temps réel de frappe avec timeout automatique
|
||
|
|
|
||
|
|
Ces systèmes s'intègrent avec :
|
||
|
|
- ✅ La couche de permissions (P0)
|
||
|
|
- ✅ Les Read Receipts (P0)
|
||
|
|
- ✅ Les événements WebSocket inbound/outbound
|
||
|
|
- ✅ La base de données PostgreSQL (pour Delivered Status)
|
||
|
|
- ✅ Un système de timeout interne (pour Typing Indicators)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📬 DELIVERED STATUS
|
||
|
|
|
||
|
|
### Architecture
|
||
|
|
|
||
|
|
Le Delivered Status est **persistant** et stocké en base de données PostgreSQL.
|
||
|
|
|
||
|
|
### Flux
|
||
|
|
|
||
|
|
```
|
||
|
|
1. Client reçoit un message via WebSocket
|
||
|
|
↓
|
||
|
|
2. Client envoie IncomingMessage::Delivered { message_id, conversation_id }
|
||
|
|
↓
|
||
|
|
3. Serveur :
|
||
|
|
- Vérifie permission can_read_conversation
|
||
|
|
- Vérifie que message appartient à conversation
|
||
|
|
- Stocke en DB (table delivered_status)
|
||
|
|
- Broadcast OutgoingMessage::MessageDelivered
|
||
|
|
```
|
||
|
|
|
||
|
|
### Base de données
|
||
|
|
|
||
|
|
**Table** : `delivered_status`
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE delivered_status (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
|
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||
|
|
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
UNIQUE(message_id, user_id)
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
**Index** :
|
||
|
|
- `idx_delivered_status_message_id` : Recherche par message
|
||
|
|
- `idx_delivered_status_user_id` : Recherche par utilisateur
|
||
|
|
- `idx_delivered_status_conversation_id` : Recherche par conversation
|
||
|
|
- `idx_delivered_status_conversation_user` : Composite pour requêtes fréquentes
|
||
|
|
|
||
|
|
### Manager
|
||
|
|
|
||
|
|
**Module** : `src/delivered_status.rs`
|
||
|
|
|
||
|
|
**Méthodes principales** :
|
||
|
|
- `mark_delivered(user_id, message_id, conversation_id)` : Marque un message comme délivré
|
||
|
|
- `get_delivered_for_message(message_id)` : Récupère tous les delivered status pour un message
|
||
|
|
- `is_delivered(message_id, user_id)` : Vérifie si un message a été délivré à un utilisateur
|
||
|
|
- `verify_message_belongs_to_conversation(message_id, conversation_id)` : Vérifie l'appartenance
|
||
|
|
|
||
|
|
### Règles
|
||
|
|
|
||
|
|
- ✅ Un seul delivered status par (message_id, user_id) — contrainte UNIQUE
|
||
|
|
- ✅ Mise à jour automatique de `delivered_at` si le status existe déjà
|
||
|
|
- ✅ Vérification de permission `can_read_conversation` avant marquage
|
||
|
|
- ✅ Vérification que le message appartient à la conversation
|
||
|
|
- ✅ Broadcast automatique à tous les participants de la conversation
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ⌨️ TYPING INDICATORS
|
||
|
|
|
||
|
|
### Architecture
|
||
|
|
|
||
|
|
Les Typing Indicators sont **éphémères** (non persistants) et gérés en mémoire.
|
||
|
|
|
||
|
|
### Flux
|
||
|
|
|
||
|
|
```
|
||
|
|
1. Client commence à taper
|
||
|
|
↓
|
||
|
|
2. Client envoie IncomingMessage::Typing { conversation_id, is_typing: true }
|
||
|
|
↓
|
||
|
|
3. Serveur :
|
||
|
|
- Vérifie permission can_send_message
|
||
|
|
- Enregistre dans TypingIndicatorManager
|
||
|
|
- Reset timeout de 3 secondes
|
||
|
|
- Broadcast OutgoingMessage::UserTyping { is_typing: true }
|
||
|
|
↓
|
||
|
|
4. Si pas de nouveau signal pendant 3s :
|
||
|
|
- Task de monitoring détecte expiration
|
||
|
|
- Broadcast OutgoingMessage::UserTyping { is_typing: false }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Manager
|
||
|
|
|
||
|
|
**Module** : `src/typing_indicator.rs`
|
||
|
|
|
||
|
|
**Structure interne** :
|
||
|
|
```rust
|
||
|
|
HashMap<conversation_id, HashMap<user_id, last_activity_timestamp>>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Méthodes principales** :
|
||
|
|
- `user_started_typing(user_id, conversation_id)` : Marque un user comme "typing"
|
||
|
|
- `user_stopped_typing(user_id, conversation_id)` : Retire un user
|
||
|
|
- `get_typing_users(conversation_id)` : Liste les users actifs (filtre les expirés)
|
||
|
|
- `monitor_timeouts()` : Détecte les expirations et retourne les changements
|
||
|
|
|
||
|
|
### Task de monitoring
|
||
|
|
|
||
|
|
Un task Tokio tourne en arrière-plan toutes les **500ms** :
|
||
|
|
|
||
|
|
```rust
|
||
|
|
tokio::spawn(async move {
|
||
|
|
let mut interval = tokio::time::interval(Duration::from_millis(500));
|
||
|
|
loop {
|
||
|
|
interval.tick().await;
|
||
|
|
let expired_changes = typing_manager.monitor_timeouts().await;
|
||
|
|
// Broadcast les changements (is_typing = false)
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Règles
|
||
|
|
|
||
|
|
- ✅ Timeout de **3 secondes** (hardcodé, configurable via `timeout_duration`)
|
||
|
|
- ✅ Un seul statut actif par (user_id, conversation_id)
|
||
|
|
- ✅ Reset automatique du timeout à chaque nouveau signal `is_typing: true`
|
||
|
|
- ✅ Broadcast automatique après expiration (via task de monitoring)
|
||
|
|
- ✅ Vérification de permission `can_send_message` avant enregistrement
|
||
|
|
- ✅ Pas de persistance — tout en mémoire
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔌 MESSAGES WEBSOCKET
|
||
|
|
|
||
|
|
### Incoming Messages
|
||
|
|
|
||
|
|
#### Typing
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"type": "Typing",
|
||
|
|
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"is_typing": true
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rust** :
|
||
|
|
```rust
|
||
|
|
IncomingMessage::Typing {
|
||
|
|
conversation_id: Uuid,
|
||
|
|
is_typing: bool,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Delivered
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"type": "Delivered",
|
||
|
|
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"message_id": "660e8400-e29b-41d4-a716-446655440001"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rust** :
|
||
|
|
```rust
|
||
|
|
IncomingMessage::Delivered {
|
||
|
|
conversation_id: Uuid,
|
||
|
|
message_id: Uuid,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Outgoing Messages
|
||
|
|
|
||
|
|
#### UserTyping
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"type": "UserTyping",
|
||
|
|
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"user_id": "770e8400-e29b-41d4-a716-446655440002",
|
||
|
|
"is_typing": true
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rust** :
|
||
|
|
```rust
|
||
|
|
OutgoingMessage::UserTyping {
|
||
|
|
conversation_id: Uuid,
|
||
|
|
user_id: Uuid,
|
||
|
|
is_typing: bool,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### MessageDelivered
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"type": "MessageDelivered",
|
||
|
|
"message_id": "660e8400-e29b-41d4-a716-446655440001",
|
||
|
|
"user_id": "770e8400-e29b-41d4-a716-446655440002",
|
||
|
|
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"delivered_at": "2025-01-27T10:30:00Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rust** :
|
||
|
|
```rust
|
||
|
|
OutgoingMessage::MessageDelivered {
|
||
|
|
message_id: Uuid,
|
||
|
|
user_id: Uuid,
|
||
|
|
conversation_id: Uuid,
|
||
|
|
delivered_at: DateTime<Utc>,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🔐 PERMISSIONS
|
||
|
|
|
||
|
|
### Delivered Status
|
||
|
|
|
||
|
|
**Permission requise** : `can_read_conversation(user_id, conversation_id)`
|
||
|
|
|
||
|
|
**Vérifications** :
|
||
|
|
1. L'utilisateur est membre de la conversation
|
||
|
|
2. Le message appartient à la conversation indiquée
|
||
|
|
3. Le message existe
|
||
|
|
|
||
|
|
**Erreurs possibles** :
|
||
|
|
- `PermissionError::NotMember` : Utilisateur non membre
|
||
|
|
- `ChatError::NotFound` : Message inexistant
|
||
|
|
- `ChatError::Validation` : Message n'appartient pas à la conversation
|
||
|
|
|
||
|
|
### Typing Indicators
|
||
|
|
|
||
|
|
**Permission requise** : `can_send_message(user_id, conversation_id)`
|
||
|
|
|
||
|
|
**Vérifications** :
|
||
|
|
1. L'utilisateur peut envoyer des messages dans la conversation
|
||
|
|
|
||
|
|
**Erreurs possibles** :
|
||
|
|
- `PermissionError::NotMember` : Utilisateur non membre
|
||
|
|
- `PermissionError::CannotSend` : Pas de permission d'écriture
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📝 EXEMPLES DE PAYLOADS
|
||
|
|
|
||
|
|
### Scénario 1 : Typing Indicator
|
||
|
|
|
||
|
|
**Client A commence à taper** :
|
||
|
|
```json
|
||
|
|
// Incoming
|
||
|
|
{ "type": "Typing", "conversation_id": "conv-123", "is_typing": true }
|
||
|
|
|
||
|
|
// Outgoing (broadcast à tous sauf Client A)
|
||
|
|
{ "type": "UserTyping", "conversation_id": "conv-123", "user_id": "user-a", "is_typing": true }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Client A continue (reset timeout)** :
|
||
|
|
```json
|
||
|
|
// Incoming (après 2s)
|
||
|
|
{ "type": "Typing", "conversation_id": "conv-123", "is_typing": true }
|
||
|
|
// → Timeout reset à 3s
|
||
|
|
```
|
||
|
|
|
||
|
|
**Client A arrête (timeout après 3s)** :
|
||
|
|
```json
|
||
|
|
// Outgoing (automatique après 3s sans signal)
|
||
|
|
{ "type": "UserTyping", "conversation_id": "conv-123", "user_id": "user-a", "is_typing": false }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Scénario 2 : Delivered Status
|
||
|
|
|
||
|
|
**Client B reçoit un message** :
|
||
|
|
```json
|
||
|
|
// Outgoing (nouveau message)
|
||
|
|
{
|
||
|
|
"type": "NewMessage",
|
||
|
|
"conversation_id": "conv-123",
|
||
|
|
"message_id": "msg-456",
|
||
|
|
"sender_id": "user-a",
|
||
|
|
"content": "Hello!",
|
||
|
|
"created_at": "2025-01-27T10:30:00Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Client B marque comme délivré** :
|
||
|
|
```json
|
||
|
|
// Incoming
|
||
|
|
{ "type": "Delivered", "conversation_id": "conv-123", "message_id": "msg-456" }
|
||
|
|
|
||
|
|
// Outgoing (broadcast à tous)
|
||
|
|
{
|
||
|
|
"type": "MessageDelivered",
|
||
|
|
"message_id": "msg-456",
|
||
|
|
"user_id": "user-b",
|
||
|
|
"conversation_id": "conv-123",
|
||
|
|
"delivered_at": "2025-01-27T10:30:01Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Client A voit que le message est délivré** :
|
||
|
|
```json
|
||
|
|
// Outgoing (reçu par Client A)
|
||
|
|
{
|
||
|
|
"type": "MessageDelivered",
|
||
|
|
"message_id": "msg-456",
|
||
|
|
"user_id": "user-b",
|
||
|
|
"conversation_id": "conv-123",
|
||
|
|
"delivered_at": "2025-01-27T10:30:01Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## ⚠️ LIMITES ET CONSIDÉRATIONS
|
||
|
|
|
||
|
|
### Delivered Status
|
||
|
|
|
||
|
|
- ✅ **Persistant** : Stocké en DB, survit aux redémarrages
|
||
|
|
- ⚠️ **Latence** : Dépend de la latence réseau client → serveur
|
||
|
|
- ⚠️ **Pas de garantie** : Si le client se déconnecte avant d'envoyer `Delivered`, le status n'est pas enregistré
|
||
|
|
- ✅ **Déduplication** : UNIQUE constraint empêche les doublons
|
||
|
|
|
||
|
|
### Typing Indicators
|
||
|
|
|
||
|
|
- ⚠️ **Non persistant** : Perdu au redémarrage du serveur
|
||
|
|
- ⚠️ **Latence de détection** : Maximum 500ms (intervalle du task de monitoring)
|
||
|
|
- ⚠️ **Pas de garantie** : Si le serveur crash, les typing indicators sont perdus
|
||
|
|
- ✅ **Performance** : Tout en mémoire, très rapide
|
||
|
|
- ⚠️ **Scalabilité** : En cas de scaling horizontal, chaque instance a son propre état (nécessiterait Redis pour partager)
|
||
|
|
|
||
|
|
### Recommandations
|
||
|
|
|
||
|
|
1. **Typing Indicators** : Pour la scalabilité horizontale, considérer Redis pour partager l'état entre instances
|
||
|
|
2. **Delivered Status** : La latence est acceptable pour la plupart des cas d'usage
|
||
|
|
3. **Monitoring** : Surveiller la taille de la HashMap des typing indicators en production
|
||
|
|
4. **Cleanup** : Le task de monitoring nettoie automatiquement les entrées expirées
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 🧪 TESTS
|
||
|
|
|
||
|
|
### Tests unitaires
|
||
|
|
|
||
|
|
**Delivered Status** :
|
||
|
|
- ✅ `test_mark_delivered_creates_status`
|
||
|
|
- ✅ `test_mark_delivered_updates_existing`
|
||
|
|
- ✅ `test_get_delivered_for_message`
|
||
|
|
- ✅ `test_is_delivered`
|
||
|
|
|
||
|
|
**Typing Indicators** :
|
||
|
|
- ✅ `test_typing_indicator_manager`
|
||
|
|
- ✅ Tests de timeout (à implémenter)
|
||
|
|
|
||
|
|
### Tests d'intégration
|
||
|
|
|
||
|
|
**À implémenter** :
|
||
|
|
- Test WebSocket : Client A tape → Client B reçoit event
|
||
|
|
- Test WebSocket : Timeout après 3s → Client B reçoit `is_typing: false`
|
||
|
|
- Test WebSocket : Delivered → Broadcast OK
|
||
|
|
- Test WebSocket : Delivered sans permission → Refus
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📚 RÉFÉRENCES
|
||
|
|
|
||
|
|
- **Migration SQL** : `migrations/004_delivered_status.sql`
|
||
|
|
- **Manager Delivered** : `src/delivered_status.rs`
|
||
|
|
- **Manager Typing** : `src/typing_indicator.rs`
|
||
|
|
- **Handler WebSocket** : `src/websocket/handler.rs`
|
||
|
|
- **Messages WebSocket** : `src/websocket/mod.rs`
|
||
|
|
- **Audit initial** : `docs/AUDIT_DELIVERED_TYPING.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**✅ Implémentation complète — Prêt pour production**
|
||
|
|
|