Read Receipts — Đồng bộ DM và Room Messages (dùng username + timestamp)
Tài liệu kỹ thuật mô tả cách hệ thống lưu trữ và xử lý trạng thái "đã đọc" cho tin nhắn DM và Room, bao gồm thờigian đọc và latency.
Tóm tắt
- DM:
is_read boolean trong PostgreSQL + Redis Set dm:read:{messageId} lưu username đã đọc + Redis Hash dm:readat:{messageId} lưu { username: timestamp }.
- Room: Redis Set
msg:read:{messageId} lưu username đã đọc + Redis Hash msg:readat:{messageId} lưu { username: timestamp } (TTL 24h).
- Latency:
latencyMs = readAt - message.created_at, tính runtime khi query.
- WebSocket events: Trả về
username + readAt + latencyMs.
- Không sửa DB schema.
1. Kiến trúc tổng quan
┌─────────────────────────────────────────────────────────────────┐
│ Client (React) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ markDMRead │ │ messageRead │ │ getMessageStatus │ │
│ │ (WS) │ │ (WS) │ │ (WS) │ │
│ └──────┬──────┘ └──────┬──────┘ └────────────┬────────────┘ │
└─────────┼────────────────┼──────────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ NestJS Chat Gateway │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ client.data.user = { id, email, username } │ │
│ │ Lấy username từ Redis cache (user:profile:{userId}) │ │
│ └───────────────────────────────────────────────────────────┘ │
└───────────────────────────┬─────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ DM Read Receipts │ │ Room Read Receipts │
├─────────────────────────┤ ├─────────────────────────┤
│ PostgreSQL: │ │ PostgreSQL: │
│ dm_messages.is_read │ │ (không có cột read) │
│ │ │ │
│ Redis Set: │ │ Redis Set: │
│ dm:read:{msgId} │ │ msg:read:{msgId} │
│ → Set<username> │ │ → Set<username> │
│ │ │ │
│ Redis Hash (MỚI): │ │ Redis Hash (MỚI): │
│ dm:readat:{msgId} │ │ msg:readat:{msgId} │
│ → { alice: "2026-..." }│ │ → { bob: "2026-..." } │
│ TTL: 2 tuần │ │ TTL: 24 giờ │
└─────────────────────────┘ └─────────────────────────┘
2. Redis Key Patterns
DM
| Key |
Type |
Mô tả |
TTL |
dm:read:{messageId} |
Set |
Danh sách username đã đọc |
2 tuần |
dm:readat:{messageId} |
Hash |
{ username: ISO_timestamp } |
2 tuần |
user:dm:unread:{userId}:{otherUserId} |
String |
Số tin nhắn chưa đọc |
2 tuần |
dmmsg:{messageId} |
Hash |
Cache thông tin DM message |
2 tuần |
Room
| Key |
Type |
Mô tả |
TTL |
msg:read:{messageId} |
Set |
Danh sách username đã đọc |
24 giờ |
msg:readat:{messageId} |
Hash |
{ username: ISO_timestamp } |
24 giờ |
msg:delivery:{messageId} |
Hash |
Thông tin delivery |
24 giờ |
room:messages:{roomId} |
Sorted Set |
Danh sách message IDs |
24 giờ |
3. WebSocket Events
Client → Server
| Event |
Payload |
Mô tả |
markDMRead |
{ conversationId: string } |
Đánh dấu DM đã đọc |
messageRead |
{ messageId: string, roomId?: string } |
Đánh dấu room message đã đọc |
messageDelivered |
{ messageId: string, roomId?: string } |
Đánh dấu đã nhận |
getMessageStatus |
{ messageId: string } |
Lấy trạng thái đọc |
Server → Client
| Event |
Payload |
Mô tả |
dmRead |
{ conversationId, readBy: string, readAt?: string, latencyMs?: number } |
readBy = username |
messageRead |
{ messageId, readBy: string, readCount, readAt: string, latencyMs?: number } |
readBy = username |
messageDelivered |
{ messageId, deliveredTo: string, deliveredAt: string } |
deliveredTo = username |
messageStatus |
{ messageId, delivered, deliveredAt, deliveredCount, readBy: Array<{username, readAt}>, readCount } |
readBy = read receipts có timestamp |
4. Code Flow
4.1 DM — Đánh dấu đã đọc + lưu timestamp
1. Client emit 'markDMRead' { conversationId }
│
▼
2. Gateway: handleMarkDMRead()
├── Lấy username từ client.data.user.username
├── timestamp = new Date().toISOString()
│
▼
3. DMsService.markAsRead()
├── UPDATE dm_messages SET is_read = true (DB)
├── SADD dm:read:{msgId} {username} (Redis Set)
├── HSET dm:readat:{msgId} {username} {timestamp} (Redis Hash) ← MỚI
└── DEL user:dm:unread:{me}:{other} (Redis)
│
▼
4. Gateway broadcast
└── client.to(dmRoom).emit('dmRead', {
conversationId,
readBy: username,
readAt: timestamp, // ← MỚI
latencyMs: ... // ← MỚI (nếu tính được)
})
4.2 Room — Đánh dấu đã đọc + lưu timestamp
1. Client emit 'messageRead' { messageId, roomId }
│
▼
2. Gateway: handleMessageRead()
├── Lấy username từ client.data.user.username
├── timestamp = new Date().toISOString()
│
▼
3. Redis operations
├── SADD msg:read:{msgId} {username}
├── HSET msg:readat:{msgId} {username} {timestamp} ← MỚI
├── EXPIRE msg:read:{msgId} 86400
└── SCARD msg:read:{msgId} → readCount
│
▼
4. Gateway broadcast
└── client.to(room).emit('messageRead', {
messageId,
readBy: username,
readCount,
readAt: timestamp, // ← MỚI
latencyMs: ... // ← MỚI
})
4.3 Lấy trạng thái đọc có timestamp
1. Client emit 'getMessageStatus' { messageId }
│
▼
2. Gateway: handleGetMessageStatus()
├── HGETALL msg:delivery:{messageId}
├── SMEMBERS msg:read:{messageId}
└── HGETALL msg:readat:{messageId} ← MỚI
│
▼
3. Gateway emit
└── client.emit('messageStatus', {
messageId,
delivered: true/false,
deliveredAt: ...,
deliveredCount: ...,
readBy: [
{ username: "alice", readAt: "2026-05-04T10:00:05Z" },
{ username: "bob", readAt: "2026-05-04T10:00:12Z" }
],
readCount: 2
})
5. HTTP API
5.1 DM Messages (có read receipts + timestamp)
GET /api/dms/:conversationId/messages
Response:
{
"messages": [
{
"id": "msg-uuid",
"conversation_id": "conv-uuid",
"sender_id": "user-uuid",
"sender_username": "alice",
"content": "Hello!",
"is_read": true,
"read_by_usernames": ["bob"],
"created_at": "2026-05-04T10:00:00Z"
}
],
"total": 100,
"hasMore": true
}
5.2 DM Message Read Receipts + Latency (MỚI)
GET /api/dms/messages/:messageId/read-receipts
Response:
{
"success": true,
"data": {
"messageId": "msg-uuid",
"createdAt": "2026-05-04T10:00:00Z",
"readReceipts": [
{
"username": "bob",
"readAt": "2026-05-04T10:00:03Z",
"latencyMs": 3000
}
],
"averageLatencyMs": 3000
}
}
5.3 Room Message Read Receipts + Latency (MỚI)
GET /api/messages/:messageId/read-receipts
Response:
{
"success": true,
"data": {
"messageId": "msg-uuid",
"createdAt": "2026-05-04T09:00:00Z",
"readReceipts": [
{
"username": "alice",
"readAt": "2026-05-04T09:00:05Z",
"latencyMs": 5000
},
{
"username": "bob",
"readAt": "2026-05-04T09:00:12Z",
"latencyMs": 12000
}
],
"averageLatencyMs": 8500
}
}
6. Socket User Data
{
id: string;
email: string;
username: string;
}
7. Lưu ý quan trọng
| # |
Lưu ý |
Giải thích |
| 1 |
Username immutable |
Đã xác nhận qua migration 007. Không thể đổi. |
| 2 |
Không sửa DB schema |
dm_messages.is_read vẫn là boolean. read_at chỉ lưu ở Redis. |
| 3 |
Redis TTL khác nhau |
DM: 2 tuần, Room: 24 giờ. |
| 4 |
Latency tính runtime |
latencyMs = readAt - message.created_at, tính khi query. |
| 5 |
2 Redis keys per message |
Set để đếm/check, Hash để lấy timestamp. |
| 6 |
DM 1-1 |
readReceipts thường chỉ có 1 phần tử (người còn lại). |
| 7 |
Room group |
readReceipts có thể có nhiều phần tử, averageLatencyMs tính trung bình. |
| 8 |
Cache profile |
user:profile:{userId} cache 1 giờ, có field username. |
8. Troubleshooting
| Vấn đề |
Nguyên nhân |
Cách fix |
readAt là null |
Redis Hash chưa được populate |
Đảm bảo HSET được gọi khi mark as read |
latencyMs âm |
Clock skew giữa server và DB |
Dùng Math.max(0, latencyMs) |
read_receipts rỗng |
Redis key expired |
Normal — DM TTL 2 tuần, Room TTL 24h |
username không khớp |
Case sensitivity |
Luôn normalize lowercase |
9. Migration liên quan
007_add_username.sql — Thêm cột username vào profiles
10. Các file liên quan
| File |
Vai trò |
src/gateways/chat.gateway.ts |
WebSocket handlers (lưu timestamp, broadcast) |
src/gateways/types/socket.types.ts |
Event type definitions |
src/gateways/guards/ws-jwt.guard.ts |
Auth guard (lấy username từ cache) |
src/redis/keys.ts |
Redis key patterns |
src/modules/dms/dms.service.ts |
DM business logic + latency calculation |
src/modules/dms/dms.controller.ts |
DM HTTP API |
src/modules/messages/messages.service.ts |
Room message logic + latency calculation |
src/modules/messages/messages.controller.ts |
Room HTTP API |
src/modules/users/users.service.ts |
User profile queries |