# Module `zalochat` — Tư vấn tuyển sinh qua Zalo OA

> 15/7/2026 · Giai đoạn: **MVP · Painkiller** · Bản HTML: [zalochat.html](zalochat.html)
> Nền tích hợp (LỚP 1): [`controlplane/docs/design/zalo-oa-integration.md`](https://gitlab.com/opengate.vn/controlplane) — config OA, OAuth, refresh token, webhook.
> Trạng thái code: **chưa có — scaffold mới** (store module `opengate-app-zalochat`, cài cho vinhan).

## 1. Vai trò

**Inbox chat 2 chiều với khách tuyển sinh qua Zalo OA** ngay trên web Vĩnh An: khách quét
QR/nhắn OA → hội thoại hiện trong inbox → nhân viên tuyển sinh tư vấn → chốt → **1 nút tạo
hồ sơ học viên** (đổ sẵn tên/SĐT vào form tiếp nhận WS1). Đối tượng chat là **khách vãng lai
(lead)** — không phải user OpenGate.

Kiến trúc 3 lớp:
```
LỚP 1  system/integrations (built-in)   config zalo.oa · OAuth · refresh · webhook · send
LỚP 2  zalochat (module này)            conversation · message · attachment · assign · inbox UI
LỚP 3  student WS1                      "Tạo hồ sơ từ hội thoại" → form tiếp nhận đổ sẵn
```

## 2. Feature / BO / Action

| Feature | BO chính | Action |
|---|---|---|
| `conversation` | Hội thoại (1 khách Zalo = 1 hội thoại) | view·edit·**assign**·close |
| `message` | Tin nhắn 2 chiều + đính kèm | view·**send** |

Đã chốt 15/7: đợt đầu **mọi NV tuyển sinh thấy tất cả hội thoại** (phòng TS dùng chung) +
cột "người phụ trách"; data-scope theo `assigned_to` để giai đoạn sau.

## 3. Dữ liệu chính (DB silo — prefix `zalochat_`)

### Hội thoại (`zalochat_conversation`)
| Trường | Ghi chú |
|---|---|
| `zalo_user_id` (UNIQUE) | id khách trên Zalo — khóa từ webhook |
| tên hiển thị · avatar_url | lấy từ Zalo API profile (nếu quyền cho phép) |
| `sdt` | nhập tay khi khách cho / Zalo trả (hiếm) — khóa nối lớp 3 |
| **`last_user_msg_at`** | mốc tính **cửa sổ 48h** (quy tắc #1) |
| `assigned_to` | employee NV phụ trách (bridge `users.employee_id` sẵn có) |
| trạng thái | `moi` · `dang_tu_van` · `da_chot` · `dong` |
| unread_count · last_msg_preview | hiển thị inbox |
| `lead_ref` | id hồ sơ WS1 khi chốt (đo hiệu quả kênh Zalo) |

### Tin nhắn (`zalochat_message`)
| Trường | Ghi chú |
|---|---|
| conversation_id · hướng (`in`/`out`) | |
| loại | text · image · file · sticker · khac |
| nội dung text · `attachment_path` | ảnh/file khách gửi (CCCD, khám SK…) **tải về silo** `data/zalochat/` |
| `zalo_msg_id` (UNIQUE) | **idempotent** — webhook Zalo retry không tạo tin trùng |
| sender_employee_id | tin `out`: NV nào gửi |
| trạng thái gửi | `ok` · `loi` (+ lý do — vd ngoài cửa sổ 48h) |

## 4. Quy tắc nghiệp vụ

1. **Cửa sổ 48h của Zalo OA** (ràng buộc quan trọng nhất): OA thường chỉ reply được trong
   48h kể từ **tin cuối của khách** (`last_user_msg_at`). UI phải hiện trạng thái cửa sổ
   (còn X giờ / ĐÃ ĐÓNG — disable ô nhập kèm giải thích); gửi ngoài cửa sổ → lưu tin
   trạng thái `loi` với lý do rõ. Ngoài cửa sổ → chờ khách nhắn lại hoặc ZNS trả phí (sau).
2. **Webhook idempotent** theo `zalo_msg_id` — Zalo retry không nhân đôi tin.
3. Attachment khách gửi **tải về silo ngay** (URL Zalo có hạn) — allowlist định dạng + cap dung lượng.
4. Gửi tin qua **hàm chung lớp 1** (`sendZaloMessage` — tự refresh token lazy); zalochat
   KHÔNG giữ token/secret riêng.
5. Realtime = **SSE** (express sẵn có, webhook → push inbox); fallback polling nếu vướng.
6. Hội thoại `da_chot` phải có `lead_ref`; nút "Tạo hồ sơ" mở form WS1 đổ sẵn tên + SĐT,
   nguồn = "Zalo OA".
7. Tin nhắn là **dữ liệu nghiệp vụ giữ vĩnh viễn** (không xóa) — lịch sử tư vấn = bằng chứng
   cam kết với khách.

## 5. Màn hình (MVP)

**Inbox 3 cột** (kiểu Zalo/Messenger): trái = danh sách hội thoại (lọc trạng thái/người phụ
trách, badge unread, preview) · giữa = khung chat (bubble 2 chiều, ảnh/file, ô nhập +
**thanh trạng thái cửa sổ 48h**) · phải = panel khách (tên/avatar/SĐT, người phụ trách,
trạng thái, nút **Tạo hồ sơ** / Nhận tư vấn / Đóng).

## 6. Liên kết chéo module

| Module | Quan hệ |
|---|---|
| **system/integrations (lớp 1)** | đọc config OA + gọi `sendZaloMessage`; webhook lớp 1 forward event message vào zalochat |
| **student (lớp 3, WS1)** | nút "Tạo hồ sơ" → form tiếp nhận đổ sẵn; `lead_ref` ngược lại |
| hrm | `assigned_to`/`sender_employee_id` → employee (bridge users.employee_id) |
| notify (P2) | nhắc "hội thoại chưa trả lời > N phút" (sau) |

## 6b. Cấu hình Zalo nằm ở đâu — CP hay WS? (ĐÃ CHỐT 15/7)

**Ở WS, không phải CP** — cụ thể ở **trang Quản trị của WS** (module core `system`, feature
`admin.integrations`), KHÔNG nằm trong module zalochat.

| Khía cạnh | Chốt |
|---|---|
| Nơi lưu | Key `zalo.oa` trong bảng `system_settings` thuộc **DB silo của từng WS** — CP không giữ |
| Ai cấu hình | **WS owner** tự làm (auto có quyền `system.admin.integrations.*` qua module grant) — không cần super-admin CP |
| UI | Quản trị (`/admin`) → mục **Tích hợp** của từng WS |
| OAuth/webhook | Chạy trên **domain của từng WS** (`https://<slug>.sunstech.vn/api/integrations/zalo/...`) — mỗi WS 1 app Zalo riêng (12.1) |
| zalochat | **Không giữ token/secret** — chỉ gọi helper lớp 1 (`sendZaloMessage`, đọc config, nhận event forward) |

**Lý do**: (1) secret (`app_secret`/`refresh_token`) là dữ liệu vận hành riêng của từng doanh
nghiệp — gom về CP là gom secret mọi tenant một chỗ, trái mô hình silo; (2) redirect_uri +
webhook URL bắt buộc theo domain WS; (3) đường chạy nhận/gửi tin hoàn toàn trong instance WS,
CP không tham gia. **Ngoại lệ duy nhất** config về CP: nếu sau này đổi sang "1 app Zalo chung
nền tảng" (đã bác ở 12.1) thì `app_id/secret` mới thành global, `oa_id`+token vẫn per-WS.

## 7. Lộ trình & phân rã task phát triển

| Đợt | Nội dung |
|---|---|
| **Đợt 1 — nền (lớp 1)** | Bước 11.1→11.4 spec nền: `system_settings` + quyền `integrations` + form config + OAuth connect. Prerequisite: app Zalo riêng của Vĩnh An (domain đã verify 14/7) |
| **Đợt 2 — chat MVP** | webhook message → conversation/message · inbox 3 cột · gửi text · cửa sổ 48h · assign · mọi NV thấy tất cả |
| **Đợt 3** | attachment 2 chiều · SSE · nút "Tạo hồ sơ" nối WS1 · auto-reply ngoài giờ · thống kê (hội thoại/NV, tỉ lệ chốt) |
| Sau | ZNS template (nhắc lịch khai giảng/thi — quay lại use-case kênh thông báo, cần `zalo_user_link`) · data-scope theo assigned_to |

### 7.1 Bảng task (tạo task phát triển theo từng dòng)

Repo: **WS runtime** (`ws_suns`) trừ khi ghi khác. Đợt 1 = bước 11.x của
[spec nền](https://gitlab.com/opengate.vn/controlplane) (`docs/design/zalo-oa-integration.md` §11).

| # | Task | Nơi | Nghiệm thu (test OK khi) |
|---|---|---|---|
| **Z0** | Owner Vĩnh An tạo app trên developers.zalo.me, gắn OA, khai redirect_uri + webhook URL theo domain vinhan | thao tác tay (checklist) | App có app_id/secret; domain verify đã xong 14/7 |
| **Z1.1** | Bảng `system_settings` (key/value) + helper get/set trong module `system` | core/built-in/system | Ghi/đọc 1 key qua helper; tạo bằng migration |
| **Z1.2** | Thêm feature `integrations` vào manifest `system`; backfill + bump epoch WS hiện có | core/built-in/system | Owner có `system.admin.integrations.*`; API config không 403 |
| **Z1.3** | Endpoint GET/PUT config + form FE (app_id/secret/oa_id, secret mask) trong Quản trị → Tích hợp | system + apps/web /admin | Nhập & lưu; reload thấy; secret không lộ full ra response |
| **Z1.4** | Luồng OAuth connect/callback (đổi code → token, lưu, `connected=true`, oa_name) | system | Bấm Kết nối → qua Zalo → về callback → hiện "Đã kết nối: <oa_name>" |
| **Z1.5** | Refresh token lazy on-demand (refresh_token XOAY VÒNG — ghi đè mỗi lần) | system | Ép `token_expires_at` quá khứ → lần gọi kế tự refresh + lưu token mới |
| **Z1.6** | Webhook `POST /api/integrations/zalo/webhook` + verify `X-ZEvent-Signature` + forward event message | system | Chữ ký sai → 401; đúng → event tới handler đăng ký |
| **Z2.1** | Scaffold store module `opengate-app-zalochat`: manifest (2 feature + customActions send/close), migration 2 bảng `zalochat_*` | modules/ (store) | Cài vào vinhan qua Store; bảng tạo qua migration; quyền hiện trong RBAC |
| **Z2.2** | Handler nhận event message → upsert conversation + insert message (idempotent `zalo_msg_id`), cập nhật `last_user_msg_at`/unread | zalochat BE | Gửi tin thật vào OA → conversation + message xuất hiện; webhook retry không nhân đôi |
| **Z2.3** | API list/get conversation + messages + assign + close + đánh dấu đã đọc (guard đủ) | zalochat BE | CRUD qua API với token TUYEN_SINH; thiếu quyền → 403 |
| **Z2.4** | Gửi text: `POST .../messages` → `sendZaloMessage` lớp 1; **chặn ngoài cửa sổ 48h** (lưu tin trạng thái lỗi + lý do) | zalochat BE | Trong cửa sổ: khách nhận tin trên Zalo; ngoài: API trả lỗi rõ, tin lưu trạng thái `loi` |
| **Z2.5** | FE inbox 3 cột (danh sách + khung chat + panel khách) + **thanh trạng thái cửa sổ 48h** + assign; polling 5s | zalochat FE | NV chat qua lại được với khách thật; cửa sổ đóng → ô nhập disable kèm giải thích |
| **Z3.1** | Attachment 2 chiều: nhận (tải về silo ngay, allowlist+cap) + gửi ảnh | zalochat | Khách gửi ảnh CCCD → hiện trong khung chat; NV gửi ảnh → khách nhận |
| **Z3.2** | SSE thay polling (webhook → push inbox) | zalochat | Tin mới hiện < 2s không cần reload |
| **Z3.3** | Nút "Tạo hồ sơ": mở form tiếp nhận WS1 đổ sẵn tên/SĐT, ghi `lead_ref`, nguồn "Zalo OA" | zalochat + student | Hồ sơ tạo ra link ngược hội thoại; báo cáo nguồn tuyển sinh đếm được kênh Zalo |
| **Z3.4** | Auto-reply ngoài giờ + thống kê (hội thoại/NV, thời gian phản hồi, tỉ lệ chốt) | zalochat | Nhắn ngoài giờ nhận lời chào; dashboard số đúng với dữ liệu |

> Thứ tự bắt buộc: Z0 → Z1.1→Z1.4 (nền) → Z1.6 → Z2.x → Z3.x. Z1.5 làm trước Z2.4.

## 8. Câu hỏi mở

- Auto-reply lời chào khi khách nhắn ngoài giờ hành chính — nội dung do ai soạn? (đợt 3)
- Quota tin tư vấn miễn phí trong cửa sổ 48h theo gói OA của Vĩnh An (OA đã xác thực chưa,
  gói nào) — ảnh hưởng có cần cảnh báo đếm tin không.
- Khách nhắn từ 2 tài khoản Zalo (vd vợ hỏi cho chồng) → 2 hội thoại; có cần gộp thủ công không?

## 9. Ma trận phân quyền (→ manifest & seed role)

### 9.1 `permissions[]` — manifest.json

```json
[
  { "group": "chat", "feature": "conversation", "label": "Hội thoại tuyển sinh",
    "actions": ["view", "edit", "assign", "close"] },
  { "group": "chat", "feature": "message", "label": "Tin nhắn",
    "actions": ["view", "send"] }
]
```

> `close` đã có tiền lệ customActions (shexchange); `send` khai customActions tương tự
> (`desc`: gửi tin ra kênh ngoài — core 12 không có nghĩa này).

### 9.2 Ma trận vai trò × feature

| Feature | `QTHT` | `GIAM_DOC` | `TUYEN_SINH` | `QL_DAO_TAO` |
|---|:--:|:--:|:--:|:--:|
| `conversation` | R | R | **W·assign·close** | R |
| `message` | R | R | **R·send** | R |

> **R** xem · **W** tạo/sửa. Đợt đầu TUYEN_SINH thấy tất cả hội thoại; data-scope
> `assigned_to = ctx.current_employee_id` để giai đoạn sau (plan data-scope).
