092_UI_core / docs /READ_RECEIPTS.md
anotherath's picture
update space and room
7aa8153

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

// AuthenticatedSocket.data.user
{
  id: string;        // ← userId (UUID) — vẫn giữ
  email: string;     // ← email
  username: string;  // ← username (lấy từ Redis cache)
}

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
readAtnull 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