items vs pageIds
items = mọi thứ store biết. pageIds = thứ tự trang server hiện tại.
List render theo pageIds; getById đọc từ items.
pages/helpdesk/** + components/helpdesk/** │ gọi composable (không tự $fetch) ▼app/composables/use-helpdesk-*.ts ← 14 entity store + logic/glue composable │ $fetch (cookie-auth, cùng origin) ▼server/api/helpdesk/** ← BFF: assertPermission({module:'helpdesk', action}) + customEndpoint proxy │ @odp/sdk userApi.request() ▼backend/extensions/helpdesk (ODP API) ← xem [Kiến trúc tổng thể](/helpdesk/ky-thuat/kien-truc/)Mỗi entity store bọc BFF CRUD của một collection hd_*, giữ list trong useState + ensureLoaded()/mutation helper. Tất cả nằm ở app/composables/:
Bên cạnh 14 store trên còn một lớp logic/glue composable không map 1:1 collection: use-helpdesk-api.ts (raw $fetch wrapper), use-helpdesk-counts.ts, use-helpdesk-conversation-filters.ts, use-helpdesk-current-agent.ts, use-helpdesk-composer.ts, use-helpdesk-field-registry.ts, use-helpdesk-reports.ts, use-helpdesk-channels.ts, v.v.
Pattern CRUD chuẩn trong use-helpdesk-api.ts (ví dụ Teams):
async function listTeams(query?: Record<string, string>) { return await $fetch<{ data: Team[] }>("/api/helpdesk/teams", { query })}async function getTeam(id: string) { const res = await $fetch<{ data: Team }>(`/api/helpdesk/teams/${id}`) return res.data}async function createTeam(body: Partial<Team>) { const res = await $fetch<{ data: Team }>("/api/helpdesk/teams", { method: "POST", body }) return res.data}async function updateTeam(id: string, body: Partial<Team>) { const res = await $fetch<{ data: Team }>(`/api/helpdesk/teams/${id}`, { method: "PATCH", body }) return res.data}async function removeTeam(id: string) { await $fetch(`/api/helpdesk/teams/${id}`, { method: "DELETE" })}list* trả nguyên { data, meta? } (chưa unwrap) — entity store tự xử lý meta khi cần (conversations là ví dụ duy nhất cần meta.filter_count/total_count).
Mọi route server/api/helpdesk/** theo đúng một khuôn: chặn quyền trước, proxy sau, không transform (trừ vài route cần bọc lại envelope).
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => { assertPermission(event, { module: "helpdesk", action: "read" }) const userApi = event.context.userApi const query = getQuery(event)
const result = await userApi.request(customEndpoint({ path: "/helpdesk/conversations/", method: "GET", params: query, }))
return result})Route counts phải bọc lại vì backend Knex trả object trần, không phải mảng theo envelope chuẩn của SDK:
import { customEndpoint } from "@odp/sdk"
export default defineEventHandler(async (event) => { assertPermission(event, { module: "helpdesk", action: "read" }) const userApi = event.context.userApi const query = getQuery(event)
const result = await userApi.request(customEndpoint({ path: "/helpdesk/conversations/counts", method: "GET", params: query, }))
// Backend trả { data: {...} }; SDK unwrap envelope 1-key nên phải bọc lại thủ công. return { data: result }})use-helpdesk-conversations.ts giữ 4 state riêng biệt (PAGE_SIZE = 25):
const PAGE_SIZE = 25
export function useHelpdeskConversations() { const api = useHelpdeskApi() // `items` = mọi conversation client biết: trang hiện tại + item lấy qua // ensureOne (deep-link / mở thread ở trang khác trang đang load). `pageIds` // = tập id có THỨ TỰ của trang server hiện tại — list render từ đây nên item // ensureOne'd không leak vào danh sách nhưng vẫn tồn tại trong store (getById). const items = useState<Conversation[]>("helpdesk:conversations", () => []) const pageIds = useState<string[]>("helpdesk:conversations:pageIds", () => []) const meta = useState<{ filterCount: number, totalCount: number, page: number, limit: number }>( "helpdesk:conversations:meta", () => ({ filterCount: 0, totalCount: 0, page: 1, limit: PAGE_SIZE }), ) const lastQuery = useState<Record<string, string>>("helpdesk:conversations:lastQuery", () => ({}))items vs pageIds — đây là điểm hay nhầm nhất khi đọc code module này: items là “mọi thứ store biết”, pageIds là “thứ tự trang đang hiển thị”. List UI render qua pageConversations (lọc items theo pageIds), nên một conversation được ensureOne vào tồn tại trong items (getById thấy) nhưng không xuất hiện giữa các row của trang.
async function fetchPage(filterQuery: Record<string, string> = {}, page = 1, append = false): Promise<void> { const query: Record<string, string> = { ...filterQuery, page: String(page), limit: String(PAGE_SIZE) } const res = await api.listConversations(query) _merge(res.data) const ids = res.data.map(c => c.id) pageIds.value = append ? [...new Set([...pageIds.value, ...ids])] : ids meta.value = { filterCount: Number(res.meta?.filter_count ?? res.data.length), totalCount: Number(res.meta?.total_count ?? res.data.length), page, limit: PAGE_SIZE, } lastQuery.value = filterQuery loaded.value = true}page/limit gửi thẳng lên server; meta.filter_count/total_count do backend Knex MetaService trả về — không có “đếm ở client” nữa.
async function loadMore(): Promise<void> { if (pageIds.value.length >= meta.value.filterCount) return await fetchPage(lastQuery.value, meta.value.page + 1, true)}ensureOne — cơ chế deep-link (mở conversation qua URL dù nó không nằm trong trang đang load):
async function ensureOne(id: string): Promise<Conversation | undefined> { const found = getById(id) if (found) return found const conv = await api.getConversation(id).catch(() => null) if (conv) _merge([conv]) return getById(id)}ensureOne chỉ _merge vào items, không thêm vào pageIds — trang conversations/[id].vue gọi hàm này khi getById miss để tự vá store, không phải reload cả trang.
refetchCurrent — dùng bởi SLA ticker, gộp trang 1..currentPage vào 1 request (cap 100):
async function refetchCurrent(): Promise<void> { if (!loaded.value) return const page = meta.value.page || 1 const limit = Math.min(PAGE_SIZE * page, 100) const res = await api.listConversations({ ...lastQuery.value, page: "1", limit: String(limit) }) _merge(res.data) pageIds.value = res.data.map(c => c.id) meta.value = { filterCount: Number(res.meta?.filter_count ?? 0), totalCount: Number(res.meta?.total_count ?? 0), page, limit: PAGE_SIZE }}useHelpdeskConversationCountsSidebar badge (Mine/Unassigned/Mentions/Smart views/theo status/inbox/label) không đếm client-side nữa — 1 endpoint Knex aggregate trả toàn bộ về:
// Aggregate sidebar counts (server-computed).async function getConversationCounts(query?: Record<string, string>) { const res = await $fetch<{ data: ConversationCounts }>("/api/helpdesk/conversations/counts", { query }) return res.data}helpdesk-sla-ticker.client.ts không tính SLA/snooze ở client nữa (server cron mỗi phút lo việc đó) — nó chỉ poll mỗi 60s để đồng bộ lại:
export default defineNuxtPlugin(() => { const { loggedIn } = useCurrentUser() if (!loggedIn.value) return
const interval = setInterval(() => { const convs = useHelpdeskConversations() // Refetch trang đang hiển thị (merge theo id — giữ nguyên item ensureOne'd // / optimistic update) và refresh counts server. KHÔNG BAO GIỜ replace // toàn bộ store. if (convs.loaded.value) { convs.refetchCurrent() useHelpdeskCounts().refresh() } }, 60_000)
onScopeDispose(() => clearInterval(interval))})“Merge không replace” nằm trong _merge() — patch từng item theo id, spread cũ rồi đè giá trị mới lên trên:
function _merge(list: Conversation[]): void { const byId = new Map(items.value.map(c => [c.id, c])) for (const c of list) byId.set(c.id, { ...byId.get(c.id), ...c }) items.value = [...byId.values()]}items không bao giờ bị xoá trắng — chỉ pageIds (con trỏ trang hiển thị) bị gán lại toàn bộ theo response mới nhất. Nhờ vậy item ensureOne’d hoặc optimistic-updated không bị ticker “đè mất” giữa 2 lần tick.
use-helpdesk-conversation-filters.ts chỉ là lớp lọc mỏng phủ lên trang đã được server lọc sẵn — search match 3 nguồn:
if (f.q) { const q = f.q.toLowerCase() const lastMsg = msgsByConvSearch.get(c.id) ?? "" const subject = c.subject?.toLowerCase() ?? "" const contact = c.contact_id ? contactSearchById.get(String(c.contact_id)) ?? "" : "" if (!subject.includes(q) && !lastMsg.includes(q) && !contact.includes(q)) return false}subject + nội dung tin nhắn cuối (build từ store messages) + display_name + primary_email của requester. counts trong composable này chỉ re-export từ useHelpdeskCounts(), không tự tính lại.
use-helpdesk-messages.tsStore message giữ tối đa 2000 message, LRU theo conversation (không theo từng message lẻ):
const MAX_MESSAGES = 2000const MAX_LOADED_CONVS = 20
function evictIfNeeded() { if (items.value.length <= MAX_MESSAGES) return const keep = new Set([...loadedConvs.value].slice(-MAX_LOADED_CONVS)) items.value = items.value.filter(m => keep.has(m.conversation_id)) loadedConvs.value = keep}
function touchRecency(conversationId: string) { const next = new Set(loadedConvs.value) next.delete(conversationId) next.add(conversationId) loadedConvs.value = next}loadedConvs là Set<string> giữ thứ tự “recency” qua insertion order (delete rồi add lại = đẩy về cuối). Khi tổng message vượt 2000, chỉ 20 conversation được truy cập gần nhất còn giữ message; các conversation cũ hơn bị evict toàn bộ message. evictIfNeeded() chạy sau mỗi fetchByConversation/ensureLoadedForMany.
items vs pageIds
items = mọi thứ store biết. pageIds = thứ tự trang server hiện tại.
List render theo pageIds; getById đọc từ items.
ensureOne = vá store, không reload
Miss trong items → fetch 1 item → _merge vào items (không đụng
pageIds). Dùng cho deep-link và “conv mất sau assignee update”.
counts = client-only
import.meta.server guard — SSR không có cookie nên fetch counts lúc SSR
sẽ 401 và đóng băng state rỗng.
ticker = patch, không replace
_merge theo id giữ nguyên item ensureOne’d/optimistic; chỉ pageIds
bị gán lại toàn bộ mỗi 60s.
customEndpoint