# 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 │ │ → Set │ │ │ │ │ │ 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) ```http GET /api/dms/:conversationId/messages ``` **Response:** ```json { "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) ```http GET /api/dms/messages/:messageId/read-receipts ``` **Response:** ```json { "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) ```http GET /api/messages/:messageId/read-receipts ``` **Response:** ```json { "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 ```typescript // 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 | |--------|-------------|----------| | `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 |