Ví dụ ứng dụng chat bên thứ ba
Ví dụ ứng dụng chat bên thứ ba Xây dựng một ứng dụng chat nhỏ trên Enfyra từ ứng dụng SSR bên ngoài. Hãy đọc SSR Frameworks trước. Ví dụ này giả định ứng dụng của bạn đã proxy: /enfyra/** -> Enfyra app /api/** /socket.io/** -> Enfyra app /ws/socket.io/** Nội dung sẽ xây dựng Conv
Ví dụ ứng dụng chat bên thứ ba
Xây dựng một ứng dụng chat nhỏ trên Enfyra từ ứng dụng SSR bên ngoài.
Hãy đọc SSR Frameworks trước. Ví dụ này giả định ứng dụng của bạn đã proxy:
/enfyra/** -> Enfyra app /api/**
/socket.io/** -> Enfyra app /ws/socket.io/**
Nội dung sẽ xây dựng
Conversation list
-> REST read
Selected conversation
-> REST message history
Send message
-> Socket.IO event
-> Enfyra event script persists message
-> Enfyra broadcasts to room
1. Tạo bảng
chat_conversation
| Trường | Kiểu |
|---|---|
| title | string |
| kind | string |
| description | text |
| updatedAt | datetime |
| createdBy | many-to-one tới enfyra_user |
| lastMessage | many-to-one tới chat_message, có thể để trống |
chat_conversation_member
| Trường | Kiểu |
|---|---|
| conversation | many-to-one tới chat_conversation |
| member | many-to-one tới enfyra_user |
| role | string |
| joinedAt | datetime |
chat_message
| Trường | Kiểu |
|---|---|
| conversation | many-to-one tới chat_conversation |
| sender | many-to-one tới enfyra_user |
| text | text |
| persistStatus | string |
chat_message_read
| Trường | Kiểu |
|---|---|
| message | many-to-one tới chat_message |
| conversation | many-to-one tới chat_conversation |
| member | many-to-one tới enfyra_user |
| isRead | boolean |
| readAt | datetime |
Dùng cascade delete từ chat_conversation tới thành viên/tin nhắn nếu việc xóa cuộc trò chuyện phải xóa dữ liệu chat của nó.
2. Tải danh sách cuộc trò chuyện
Chỉ tải danh sách khi trang được render lần đầu.
const conversations = await fetch(
"/enfyra/chat_conversation?fields=id,title,kind,lastMessage.id,lastMessage.text,lastMessage.createdAt&limit=0",
{ credentials: "include" },
).then((res) => res.json())
Không tải tin nhắn của mọi cuộc trò chuyện khi làm mới trang. Chỉ tải tin nhắn sau khi người dùng chọn một cuộc trò chuyện.
Sắp xếp danh sách ở frontend theo conversation.lastMessage?.createdAt. Cuộc trò chuyện chỉ giữ relation tới tin nhắn mới nhất, không nhân đôi các trường text/ngày xem trước.
3. Tải tin nhắn sau khi chọn
const filter = encodeURIComponent(JSON.stringify({
conversation: { id: { _eq: conversationId } },
}))
const messages = await fetch(
`/enfyra/chat_message?filter=${filter}&fields=id,text,createdAt,sender&deep=${encodeURIComponent(JSON.stringify({
sender: {},
}))}&sort=-createdAt,-id&limit=20`,
{ credentials: "include" },
).then((res) => res.json())
Dùng limit=20 cho trang đang hiển thị. Chỉ tải tin nhắn cũ hơn khi người dùng yêu cầu.
4. Kết nối Socket.IO
import { io } from "socket.io-client"
const socket = io("/chat", {
path: "/socket.io",
withCredentials: true,
reconnection: false,
transports: ["polling"],
upgrade: false,
})
socket.emit("chat:join")
socket.on("chat:message", (payload) => {
appendMessage(payload.message)
})
/chat là websocket namespace của Enfyra. /socket.io là transport path được ứng dụng SSR proxy.
5. Thêm event tin nhắn
Tạo websocket event tên chat:message.
Event script:
const { conversationId, messageId, text } = @BODY
if (!conversationId) @THROW400("conversationId is required")
if (!text) @THROW400("text is required")
const membership = await @REPOS.chat_conversation_member.find({
filter: {
conversation: { id: { _eq: conversationId } },
member: { id: { _eq: @USER.id } }
},
fields: "id",
limit: 1
})
if (!membership.data[0]) @THROW403("Not a conversation member")
const created = await @REPOS.chat_message.create({
data: {
conversation: { id: conversationId },
sender: { id: @USER.id },
text,
persistStatus: "persisted"
}
})
await @REPOS.chat_conversation.update({
filter: { id: { _eq: conversationId } },
data: {
lastMessage: { id: created.data[0].id },
updatedAt: new Date().toISOString()
}
})
@SOCKET.emitToCurrentRoom(`conversation:${conversationId}`, "chat:message", {
message: created.data[0]
})
@SOCKET.reply("chat:message:sent", {
messageId,
message: created.data[0]
})
Client gửi:
socket.emit("chat:message", {
conversationId,
messageId: crypto.randomUUID(),
text,
})
6. Thêm event tham gia
Tạo websocket event tên chat:join.
const memberships = await @REPOS.chat_conversation_member.find({
filter: {
member: { id: { _eq: @USER.id } }
},
fields: "id,conversation",
deep: { conversation: { fields: "id" } },
limit: 0
})
for (const row of memberships.data || []) {
const conversationId = row.conversation?.id
if (conversationId) {
@SOCKET.join(`conversation:${conversationId}`)
}
}
@SOCKET.reply("chat:joined", {
joined: memberships.data?.length || 0
})
7. Giữ lastMessage chính xác khi xóa
Nếu người dùng có thể xóa từng tin nhắn, hãy thêm pre-hook và post-hook cho DELETE /chat_message.
Pre-hook tạo bản chụp tin nhắn bị xóa trước khi default delete handler chạy:
const message = await @REPOS.chat_message.find({
filter: { id: { _eq: @PARAMS.id } },
fields: "id,createdAt,conversation",
limit: 1
})
const row = message.data?.[0]
if (!row) return
const conversationId = row.conversation?.id || row.conversation
const conversation = await @REPOS.chat_conversation.find({
filter: { id: { _eq: conversationId } },
fields: "id,lastMessage",
limit: 1
})
const current = conversation.data?.[0]
const currentLastId = current?.lastMessage?.id || current?.lastMessage
const membership = await @REPOS.chat_conversation_member.find({
filter: {
conversation: { id: { _eq: conversationId } },
member: { id: { _eq: @USER.id } }
},
fields: "id",
limit: 1
})
if (!membership.data?.length) @THROW403("Not a conversation member")
@SHARE.deletedChatMessage = {
id: row.id,
conversationId,
wasLastMessage: String(currentLastId || "") === String(row.id)
}
Post-hook chỉ sửa cuộc trò chuyện khi tin nhắn bị xóa là lastMessage hiện tại:
const deleted = @SHARE.deletedChatMessage
if (!deleted?.conversationId) return
if (!deleted.wasLastMessage) return
const nextLast = await @REPOS.chat_message.find({
filter: { conversation: { id: { _eq: deleted.conversationId } } },
fields: "id",
sort: "-createdAt,-id",
limit: 1
})
await @REPOS.chat_conversation.update({
id: deleted.conversationId,
data: {
lastMessage: nextLast.data?.[0]?.id ? { id: nextLast.data[0].id } : null
}
})
8. Thêm trạng thái đã đọc
Khi người dùng mở một cuộc trò chuyện, emit chat:read.
socket.emit("chat:read", {
conversationId,
readAt: new Date().toISOString(),
})
Server đánh dấu các dòng chat_message_read là đã đọc cho @USER và emit chat:read tới các user room để những tab đang mở khác xóa dấu chưa đọc.
Lỗi thường gặp
Tải toàn bộ tin nhắn khi làm mới
Tải cuộc trò chuyện trước. Chỉ tải tin nhắn sau khi chọn.
Chỉ gửi tin nhắn qua REST
REST có thể tạo bản ghi, nhưng UX chat cần chuyển tin theo realtime. Dùng Socket.IO để gửi và broadcast.
Tin conversationId mà không kiểm tra thành viên
Mọi event tham gia room và gửi tin đều phải xác minh tư cách thành viên trước khi xử lý.