Fintalent OSW — Lean Launch v3: Setup → Segments → SDR Loop → KAM Calls
v3 — re-walk toàn bộ trên build UAT 19/07 (UI redesign lớn: Segments, bảng Simple/Full, Inbox, SDR list độc quyền), re-verify 20/07 với FE develop @ 714f6e4cf2 (đọc source + đi live). Kế thừa v1 (nền 23 flows + verify API 15–17/07) và v2 (delta 17/07 chiều); mọi khác biệt của build mới nằm ở §Δ3 và đã cập nhật vào từng flow. Ảnh mới: bộ v3s-* (19/07) + v3s-140 (view-as-role, 20/07). Guideline người dùng song hành: v4.html.
admin-role-preview, KHÔNG đổi data/quyền (§Δ3 #11, ADM.0.7+). (2) Work experience: Unlink retired → Relink (company-scoped bulk-relink + alias match) (§3.2). (3) Compose: dock toggle + suggested recipients; document picker chỉ Common+Contact (§5.5). (4) Inbox bỏ chip "In Conversation" (ME hide mới; SDR luôn ẩn), thêm InQueueTag (§5.8). (5) Row lifecycle dropdown 14 option (không phải 9); ContactLifecycleEnum=18 (không phải 20), EmailLogCategorizeEnum=11 (giữ nguyên) (§5.9). (6) Simple/Full mở cho MỌI role trừ SDR/KAM, default Simple (§Δ3 #2). (7) Fix: WhatsApp URL wa.me/‹E164›, LinkedIn mở URL as-is, ⋮/🗓 ẩn với SDR/KAM, rows-per-page là selector riêng, assign chỉ Data Manager/cao nhất. Enum coverage/SDR-list KHÔNG đổi so với 19/07.0 · Cách đọc & Thuật ngữ
Badge trạng thái
✅ Có đã chạy thử / có UI thật
🟡 Một phần nền tảng có, thiếu mảnh
🔵 Đang làm in progress
❌ Chưa có phải build
P0 chặn launch P1 nên có sớm P2 backlog
Khối as-is / to-be
As-is = đúng hiện trạng UAT hôm nay. To-be = contract đề xuất, chưa tồn tại — không được nhầm là đã chạy.
5 từ khoá
| SDR | sale tuyến đầu: gửi email chào hàng, nhận trả lời |
| KAM | sale tuyến sau: khách quan tâm thì gọi Zoom chốt |
| Ops | người vận hành: chuẩn bị data, tạo list, giao việc |
| Account | 1 công ty khách tiềm năng (kèm các contact) |
| Queue (từ của spec) | không có hệ thống riêng — chính là contact list được giao cho SDR; SDR làm việc trên list, gửi mail theo trường hợp (§1.3) |
Δ3 · Build 19/07 — re-walk toàn bộ, có gì đổi ✅ verify UI + behavior 19/07
Đi lại toàn bộ chuỗi flow bằng admin (dev@fintalent.io) + Login-as SDR Internal Fintalent + Login-as KAM Fintalent; test hành vi trên list QA tự tạo/tự xoá. Xếp theo mức ảnh hưởng:
| # | Thay đổi (verify 19/07) | Ảnh hưởng |
|---|---|---|
| 1 | Route + menu đổi: Lists → Segments (Lists) (/segments; /lists 404) · Inboxes → Inbox (/inbox) · mới: /message-templates (template EN 01–05 của inbox_script — route cũ /sales-triggers 404) · /snippets · /documents · /meetings · /deals · Contacts Deleted β. Sidebar 3 nhóm: Sales Management (v2, chính) · (v1) · WIP β. | Mọi flow; mọi URL trong docs cũ |
| 2 | Contacts redesign: bảng phẳng Title · Account · Country · City · Lifecycle Stage · (Signals) · Last Contact↓ · Action. SDR/KAM chỉ ✉/WhatsApp/LinkedIn; admin thêm 🗓 Schedule call β (disabled "Coming soon") + ⋮ More. [20/07] Density Simple ⇄ Full: MỌI role TRỪ SDR/KAM đều toggle được, default Simple (hotfix dae328ce07; bản cũ khoá admin thường ở Full) — không phải "chỉ Admin". Column Settings ⚙ chỉ ẩn/hiện cột; rows-per-page là selector riêng cạnh pager (10…1000). WhatsApp mở wa.me/‹E164›, LinkedIn mở URL đã lưu as-is (chỉ validate host). Filter encode URL (?contactFiltersV2_bLifeCycles=in:Prospect). | SDR.0.1/0.3 · B1 · mọi bảng contacts |
| 3 | SDR list độc quyền — BYPASS là hành vi chuẩn (chốt 19/07): Create List có checkbox "SDR list"; contact đã thuộc SDR list A, add vào SDR list B → bypass (không vào B, không rời A) + toast báo skip. Muốn chuyển list: remove khỏi list cũ trước rồi add. ⚠️ 2 lỗi cosmetic chờ dev: (a) form text đang ghi "removes it from other SDR lists" — cần sửa thành skip; (b) Timeline của B vẫn ghi "Members added — 1 added" dù bị skip. | SDR.1.20 · OPS.0.8 · SYS single-rep lock — §4.1b |
| 4 | Inbox thành menu riêng (cả SDR/KAM): bảng Account | Last Email (subject + preview + date), đủ filter + Add filter. Mở row → Conversations 2-pane (thread list | thread, Expand all). Per-message chip classify: Interested/Not Interested/Call Scheduled/Open trên từng message + trên item thread list — hệ thống tự đánh giá reply để gán (chốt 19/07), SDR không chỉnh chip; SDR chỉ set lifecycle cấp contact. | SDR.0.15/0.16 — thay "view Replies" |
| 5 | SDR/KAM view: menu chỉ còn Segments (Lists) · Inbox · Meetings · Message Templates · Snippets · Documents · Campaigns · Marketing Emails (không Contacts/Companies). List detail của SDR: cột Signals (click headline → drawer detail; badge "+N" → expand inline toàn bộ signals), icon 💬 = aging meter trên Last Contact (xám ≲2 tuần · vàng ~2–4 tuần · đỏ ≳1 tháng; chấm đỏ = đến hạn follow-up) — click mở drawer conversation-first (thread mở sẵn + pager 1/N duyệt lần lượt); action icons ✉ (compose) / WhatsApp (wa.me/‹E164› — xám cả khi số quá mơ hồ, không chỉ khi thiếu) / LinkedIn (mở URL đã lưu as-is, chỉ validate host linkedin.com/lnkd.in) — sáng/xám theo dữ liệu contact; chỉ filter Lifecycles ⇒ công thức 3 view cũ retired. Landing sau login = /projects. [20/07] SDR/KAM KHÔNG có ⋮ More / 🗓 Schedule call. | SDR.0.1 · SDR.0.25 · C0–C2 |
| 6 | List detail: tabs Members · Data Health β · Timeline (audit: List created / Members added / removed + author + timestamp, kèm diff). Panel trái Lifecycles | Companies. "Assign owner" → Assign assignee ([20/07] chỉ Data Manager/authority cao nhất mới thấy action assign); cột Assignee badge role; Actions per-row = Add members (👤+) · Assign (user-switch, gated) · Delete (🗑) — KHÔNG có ⚙ settings, mở detail bằng click row/name; "SDR" là giá trị cột Category (category:SDR), không phải badge riêng. | SDR.1.20 · OPS.0.6/0.8 |
| 7 | Member count lag nặng hơn trên list mới tạo (0 kéo dài >60s dù Members tab đã có người) — verify count phải qua Members tab/Timeline, không tin cột count. | SDR.1.20 · QA checks |
| 8 | Profile redesign: action bar Send email · WhatsApp · LinkedIn · Schedule call β · More; Owner nhiều người; WE/Education/Certificates collapse; Conversations 2-pane + New Email + search. Compose: bug subject chưa prefill VẪN CÒN; bottom bar 📎 · 🗂 Document library · Snippets · Template · </>. | SDR.0.3/0.10/0.11 |
| 9 | [20/07] Team coverage gộp vào bảng members ("held by teammates"): chọn company ở rail trái list detail → contact ở company đó đang nằm trong list (còn sống) của người khác + lifecycle nóng (Interested · Call Scheduled · Platform Sign Up · In Project · Project Completed) được append thành dòng xám view-only cuối bảng + pill "N held by teammates". Query riêng contactsV2InDifferentOwner — verify code + API + UI 20/07. | SDR.0.3/0.10 — chống double-touch cấp COMPANY · §5.4b |
| 10 | [20/07] Cột Country · City tách riêng + SORT được (thay cột Location; click header A→Z/Z→A, giá trị rỗng dồn đầu khi ascending — verify UI 20/07, commit FE f71fed708f). List detail giờ là trang riêng /segments/‹id›?tab=members — URL share được. | SDR.0.1/0.25 · mọi bảng contacts |
| 11 | [20/07 re-check] View as role: admin authority cao nhất preview UI của role khác (DataManager · Admin · SaleAdmin · CampaignManager · SDR · KAM). Control = eye ở sider profile (gate isAccessTestFeature‖SuperAdmin) + banner "Only the interface changes — actions still run with your real permissions" + Exit preview. State ở sessionStorage admin-role-preview (sống qua reload, mất khi đóng tab). Chỉ đổi UI, KHÔNG đổi identity/data/quyền (preview SDR vẫn thấy list của CHÍNH admin, không phải scope SDR thật) — bổ trợ, không thay "Login as". Ảnh v3s-140. Store: core/stores/role-preview-store.ts. | ADM.0.1/0.7 · công cụ chụp màn SDR/KAM |
| 12 | [20/07] Work experience: Unlink retired (15/07) → Relink: dòng WE đã link nay có icon Relink = trỏ WE sang company KHÁC. Relink company-scoped (đổi mọi WE đang link company cũ, match theo text company cũ), rebuild emails, dời alias; vẫn có bước propagate "Apply to N other contacts". Không có "undo" 1-click — relink ngược lại để revert. GQL relinkWorkExperienceCompany + bulkRelinkContactsWorkExperienceCompany + findContactsWithLinkedAliasMatch. | ADM.0.3 (§3.2) |
| 13 | [20/07] Compose (commit 73cde420f2 "fix: reply v2"): dock left/right toggle (nhớ preference) + suggested recipients (người trong conversation xếp trên search ở To/Cc). Cc/Bcc vẫn ẩn. Document picker từ compose chỉ Common+Contact (Published), title "Select Documents" — KHÔNG có Campaign. Subject prefill vẫn bug (template không mang subject field). | SDR.0.11 (§5.5) |
| 14 | [20/07] Inbox v2 bỏ chip "In Conversation": SDR inbox (/inboxes-v2) vốn đã ẩn; ME inbox nay cũng ẩn (commit 001950af9a) — campaign inbox tab VẪN còn ⇒ BUG thiếu nhất quán (chốt với Đông 20/07: campaign inbox nên ẩn như SDR/ME — xem GAP G19). Bộ lifecycle của inbox = 17–18 stage (Stages rail trái) + pinned Last Email. Thêm InQueueTag (pill cam "In Queue") khi email outbound chưa có sentDate (đang trong send queue) — nối trang admin Email Queues. | SDR.0.15/0.16 |
| 15 | [20/07] Menu admin Sales (v2) thêm Email Queues · Email Logs · Signals · Deals · Job Search cạnh nhóm cũ (SDR/KAM vẫn bị trim đúng như doc). Per-message chip = BE ConversationEmailLog.lifecycle (read-only); EmailLogType (draft/replied/sentOut) là hướng, KHÁC enum categorize — đừng lẫn. | C0 · SDR.0.16 |


SDR-list gate — semantics đã kiểm chứng
flowchart LR
ADD["Add contact C vào SDR list B"] --> Q{"C đã thuộc
SDR list khác (A)?"}
Q -->|"Không"| OK["C vào B (membership bình thường)"]
Q -->|"Có"| SKIP["BYPASS — C giữ nguyên ở A
toast: skipped (đã thuộc SDR list khác)"]
SKIP -.-> W1["⚠️ form text nói 'removes from other SDR lists' — copy sai"]
SKIP -.-> W2["⚠️ Timeline của B vẫn ghi 'Members added' — log sai"]
classDef default fill:#fff,stroke:#CCC8D5;
Không đổi (re-verified 19/07): Create Contact dedupe (Duplicate Found panel) · Add-to-list semantics thường (Selected/All matching cap 10.000/run · dedup im lặng · add nhiều list 1 lần "Add to N list(s)") · add-members drawer auto-exclude · Remove from list + confirm · xoá list còn members được · Assign theo role badge · Update Admin Information 5 tab.
1 · Tổng quan motion
1.1 · Bức tranh end-to-end
flowchart TD
subgraph P0["Phase 0 · Setup (Admin)"]
direction LR
A1["ADM.0.1
Users + roles ✅"] --> A3["ADM.0.3
Import data ✅"] --> A7["ADM.0.7
Admin sửa info ✅"]
end
subgraph P1["Phase 1 · Contact lists (Ops)"]
direction LR
L1["SDR.1.20
Create list ✅"] --> L2["OPS.0.8
Assign list ✅"] --> L3["OPS.0.6
List rules ❌"]
end
subgraph P2["Phase 2 · SDR Loop — làm việc trên contact list được giao"]
direction LR
Q["SDR.0.1
List + lọc ✅"] --> W["SDR.0.3
Workspace ✅"] --> E["SDR.0.11
Compose ✅"] --> S["SDR.0.12
Send→log ✅"] --> F["SDR.0.13
Follow-up ✅"] --> R["SDR.0.15
Reply ✅"] --> C["SDR.0.16
Classify ✅"]
end
subgraph P3["Phase 3 · KAM ❌"]
direction LR
H["SDR.0.17
Handoff ❌"] --> K1["KAM.0.1
Book Zoom ❌"] --> K5["KAM.0.5
Call+record ❌"] --> K7["KAM.0.7
AI outcome ❌"]
end
P0 --> P1
P1 --> P2
P2 -->|"Interested"| P3
click A1 "#adm01"
click A3 "#adm03"
click A7 "#adm07"
click L1 "#sdr120"
click L2 "#ops08"
click L3 "#ops06"
click Q "#sdr01"
click W "#sdr03"
click E "#sdr011"
click S "#sdr012"
click F "#sdr013"
click R "#sdr015"
click C "#sdr016"
click H "#sdr017"
click K1 "#kam"
click K5 "#kam"
click K7 "#kam"
classDef default fill:#fff,stroke:#CCC8D5;
✅ đã chạy được · 🟡 một phần (nền tảng có, còn thiếu mảnh nhỏ) · 🔵 đang làm · ❌ chưa có — chi tiết từng flow ở §3–§6
Nhìn nhanh — flow chưa trọn vẹn đang thiếu đúng cái gì (click mã flow để xem chi tiết)
| Flow | Còn thiếu gì | |
|---|---|---|
| OPS.0.6 List rules | ❌ | List approval (draft/approved) + bộ hằng số N ngày · re-entry 🥶 chưa được sign-off |
| SDR.0.17 Handoff KAM | ❌ | Deal pipeline + handoff — build từ đầu (G2, P0); contract đề xuất đã viết sẵn ở §5.10 |
| KAM.0.1–1.8 Call flow | ❌ | Zoom stack (book · record · transcript · AI outcome) + 3 quyết định: #10 consent ghi âm · #11 outcome enum · #12 transcript source |
SDR.0.18 hiển thị ✅ nhưng còn 1 gap an toàn: chưa chặn cứng suppression trên mọi send path (mới cảnh báo) — G1/P0, xem §8.
1.2 · Lifecycle = state machine của contact list chốt 16/07
Trục xương sống của toàn bộ SDR loop là contact lifecycle. Không có "queue" nào khác ngoài contact list — trạng thái contact nhảy trên ladder này, và cách nhìn list (cột Lifecycle / Last Contact + quick-filter pin) chỉ là các lát cắt của ladder.
flowchart LR P["Prospect (Lead)
chưa đụng vào"] -->|"send email đầu tiên
(tự động qua date filter — xem ⚠️ §1.3)"| IC["In Conversation
đã connect · Replied / No reply"] IC -->|"khách reply → SDR classify (đặt lifecycle)"| CL{" "} CL --> OKG["✅ Interested"] --> SC["Schedule call"] --> PS["Platform signup"] --> PJ["Projects…"] CL --> COLD["🥶 Not Now · OOO ·
Email Updated · No Categories"] CL --> DEAD["🆘 OptedOut · Do Not Contact ·
Left The Company · Not Interested"] COLD -.->|"re-entry theo rule OPS.0.6
(hằng số CHƯA chốt)"| IC classDef default fill:#fff,stroke:#CCC8D5;
| Nhóm nhãn | Giá trị lifecycle | Hệ quả trên contact list |
|---|---|---|
| ✅ Đường tiến | Interested → Schedule call → Platform signup → Projects | Rời SDR loop, chuyển sang phase KAM / deal |
| 🥶 Tạm dừng | Not Now · OOO · Email Updated · No Categories | Ẩn khỏi các view làm việc; quay lại theo re-entry rule (VD Not Now → 3 tháng — chưa chốt, xem OPS.0.6) |
| 🆘 Chấm dứt | OptedOut · Do Not Contact · Left The Company · Not Interested | Suppression — mọi đường gửi phải né (SDR.0.18) |
[20/07 recount từ FE HEAD] ContactLifecycleEnum = 18 giá trị (không phải 20) — ngoài các nhãn trên còn nhãn hệ thống/tự động: Open · Click · Bounce · Dropped · PlatformSignUp · InProject · ProjectCompleted (KHÔNG có 'Replied' — verify source graphql.ts 22/07). Row lifecycle dropdown render 14 option (CONTACT_LIFECYCLE_OPTIONS): 9 nhãn SDR set tay + No Categories · Call Scheduled · Platform Sign Up · In Project · Project Completed (3 cái cuối read-only khi đã set). Bộ nhãn classify (EmailLogCategorizeEnum) = 11 giá trị (CallScheduled · DoNotContact · EmailUpdated · Interested · LeftTheCompany · NoCategories · NotInterested · NotNow · NotResponded · OptedOut · OutOfOffice — verify FE HEAD 20/07). Lưu ý drift: constant options của row dropdown có InConversation (KHÔNG có trong ContactLifecycleEnum) và thiếu OutOfOffice (có trong enum + facet) — cần BE confirm.
lastEmailSentDate, sync trễ cỡ phút), KHÔNG phải lifecycle enum (enum không tự nhảy — verified 16/07 chiều, xem ⚠️ §1.3). (3) Có filter In Conversation: Received a reply / No reply tách sub-state ngay trên UI (ảnh 14).CONTACT_LIFECYCLE_OPTIONS) = 14 stage chọn được (verify live 21/07, theo đúng thứ tự filter): Prospect · In Conversation · Interested · Not Now · Opted Out · Email Updated · Do Not Contact · Left The Company · Not Interested · No Categories · Call Scheduled · Platform Sign Up · In Project · Project Completed. Cách viết chuẩn "Call Scheduled" (KHÔNG phải "Scheduled Call"). Đây là những stage SDR nhìn/đặt được trên UI.
(2) ContactLifecycleEnum (backend) = 18 giá trị — superset của 14 stage trên + các giá trị hệ thống/không-chọn-tay (Open · Click · Bounce · Dropped…) do sync/campaign tự gán.
(3) "OOO / Out Of Office" KHÔNG phải là 1 lifecycle stage — nó tồn tại như EmailLogCategorizeEnum.OutOfOffice, tức 1 chip phân loại per-reply (§5.9), KHÔNG nằm trong dropdown lifecycle. Đây chính là lý do các bản guideline SDR cũ liệt kê nhầm "OOO" như 1 lifecycle: lifecycle (state của contact) và reply-categorize (nhãn của 1 email trả lời) là 2 enum khác nhau.
(4) LOCKED_LIFECYCLE_VALUES = {PlatformSignUp, InProject, ProjectCompleted} — contact đang ở 1 trong 3 stage này thì chip lifecycle read-only (không rời được bằng tay; chi tiết cơ chế khoá ở §5.4c).1.3 · Không còn “queue” / 3 view — SDR làm việc trên list, gửi mail theo trường hợp cập nhật 22/07
Không có "queue" riêng, và SDR KHÔNG dựng được "3 view" bằng filter: trên members bar của list, SDR/KAM bị khoá "+ Add filter" (lockFilters = isRoleSdr‖isRoleKam → showAddFilter=false, contacts-list-template/index.tsx:772) — chỉ còn quick-filter pin sẵn (Lifecycles · Has signal · New signal since follow-up), comment source gọi là "a fixed slice, not a filter workbench". Vì thiếu filter In Conversation / Last Email Sent Date, SDR không lọc ra được Replies/Follow-ups trên list. Thay vào đó SDR làm việc trên list + Inbox và quyết định gửi mail theo TRƯỜNG HỢP. Bảng dưới: mỗi trường hợp = 1 lý do gửi + cách SDR nhận biết (số kiểm chứng API 16/07 vẫn đúng ở tầng logic):
| Trường hợp gửi mail | SDR nhận biết thế nào (do bị khoá filter) | Logic / field (API) | Ưu tiên |
|---|---|---|---|
| B · Trả lời reply của khách | Ở Inbox (SDR có đủ filter ở Inbox) — thread có reply ↙ inbound | In Conversation: Received a reply (bConversationSnapshot.syncDateRepliedEmailLog) · API 16/07: 3/398 | 1 — trước tiên |
| D · Có signal mới sau email cuối | Quick-filter New signal since follow-up = Yes (pin sẵn — SDR dùng được) | aHasSignalSinceReply:true (signalDate > lastEmailSentDate) — §5.1 | 2 — cớ có ngữ cảnh |
| C · Follow-up khi im lâu | Đọc cột Last Contact + aging meter 💬 (KHÔNG filter được ngày trên members bar) — sort/scan | In Conversation: No reply + Last Email Sent Date ≤ hôm nay − N · API 16/07: 26/398 (N=0) | 3 |
| A · Chưa từng gửi (first touch) | Quick-filter Lifecycles = Prospect (SDR có) — hoặc contact chưa có Last Contact | Last Email Sent Date không tồn tại (chính xác hơn Prospect) · API 16/07: 369/398 (Prospect 321) | 4 |
Nguyên liệu của các công thức lọc không chỉ có trên UI — chúng là field chính thức của ContactV2ConditionInput (API): bListIds · bLifeCycles · bSyncDateRepliedEmailLog · bLastEmailSentDate (introspect 16/07, xem DTO §5.1).
Các mốc tự vận hành ở tầng data (không cần code thêm): gửi xong → sync ghi lastEmailSentDate (trễ cỡ phút) → contact hết là "chưa gửi". Khách reply → sync ghi syncDateRepliedEmailLog → nổi lên Inbox. Signal mới về sau email cuối → khớp aHasSignalSinceReply. Số kiểm chứng 16/07 (list 16072026v1): 369 chưa gửi + 29 đã gửi (3 reply + 26 im lặng) = 398 = cả list ✓ — verify ở tầng API/admin; SDR trên members bar chỉ tiếp cận tập con qua quick-filter pin sẵn.
1.4 · Readiness 23 flows (sau các quyết định 16/07)
| Phase | ✅ Chạy được | 🟡 / 🔵 Gần xong | ❌ Chưa có |
|---|---|---|---|
| 0 · Setup | ADM.0.1 · ADM.0.3 · ADM.0.7 | — | — |
| 1 · Lists | SDR.1.20 · OPS.0.8 | — | OPS.0.6 (list rules/approval) |
| 2 · SDR Loop | SDR.0.1 · SDR.0.25 · SDR.0.3 · SDR.0.10 · SDR.0.11 · SDR.0.12 · SDR.0.13 · SDR.0.15 · SDR.0.16 · SDR.0.18 | — | SDR.0.17 (handoff) |
| 3 · KAM | — | — | KAM.0.1 · 0.2 · 0.5 · 0.6 · 0.7 · 1.8 |
So với audit 15/07 (5 ✅ / 7 🟡 / 11 ❌), bức tranh sáng lên đáng kể nhờ 2 quyết định: "queue" = chính contact list + view filter (không cần hệ thống riêng) và ADM.0.7 đổi nghĩa thành admin sửa info (đã có sẵn).
2 · Catalog GraphQL operations
Toàn bộ operation mà chuỗi lean flows chạm vào, đã verify qua target/uat-lean-flow-check.py (15/07, 23 bước PASS) và session UI 16/07:
| Operation | Loại | Dùng cho flow | Trạng thái |
|---|---|---|---|
customLists / customList | query | SDR.1.20 · SDR.0.25 | ✅ PASS |
createCustomList | mutation | SDR.1.20 | ✅ PASS |
addContactsCustomList / UpdateMmCompaniesCustomListV2 | mutation | SDR.1.20 (add members) | ✅ PASS count có trễ Atlas sync vài giây |
updateCustomList / bulkUpdateCustomList (assignedToIds) | mutation | OPS.0.8 | ✅ PASS |
customLists filter bAssignedToIds | query | OPS.0.8 · SDR.0.25 | ✅ PASS |
selectAdmins | query | OPS.0.8 (picker, filter Roles) | ✅ |
mmCompaniesV2 / mmCompanyV2 | query | SDR.0.3 (workspace) | ✅ PASS |
contactsV2 / contactV2 | query | SDR.0.3 · SDR.0.10 · views §1.3 | ✅ PASS totalItems luôn 0 (bug) → đếm bằng contactV2Count |
contactV2Count | query | Đếm số view §1.3 (thay totalItems) | ✅ PASS 16/07 — số khớp tuyệt đối |
mmCompanyHistories / contactHistories | query | SDR.0.3 (timeline chống double-touch) | ✅ PASS |
sendEmailLog | mutation | SDR.0.11 · SDR.0.12 | ✅ PASS bug mở: chưa prefill subject |
emailLogs (kèm preEmailLogId thread, categorize) | query | SDR.0.12 · SDR.0.15 | ✅ PASS |
updateLifecycleContactV2 | mutation | SDR.0.16 · SDR.0.18 | ✅ PASS |
EmailLogCategorizeEnum (11 giá trị: CallScheduled · DoNotContact · EmailUpdated · Interested · LeftTheCompany · NoCategories · NotInterested · NotNow · NotResponded · OptedOut · OutOfOffice — OOO = OutOfOffice) | enum | SDR.0.16 per-reply | ✅ auto-chip hiển thị per-reply setter tay + side-effect lifecycle = P1 (G5/G20) |
setCategorizeEmailLog | mutation | SDR.0.16 per-reply setter tay | 🟡 BE có, chạy OK · auto-classify đã live · CHƯA side-effect lifecycle (verified) — setter tay per-reply còn để ngỏ (G20) |
contactsV2InDifferentOwner / …Count | query | SDR.0.10 team coverage (§5.4b) | ✅ verify 20/07 = contactsV2 bỏ scoping owner; FE bound = company + 5 lifecycle nóng |
relinkWorkExperienceCompany / bulkRelinkContactsWorkExperienceCompany / findContactsWithLinkedAliasMatch | mutation/query | ADM.0.3 Relink WE (§3.2) | ✅ mới 20/07 thay Unlink; company-scoped + alias match ^alias$ |
EmailLogType (draft · replied · sentOut) | enum | SDR.0.15 conversation thread (hướng message) | ✅ là HƯỚNG email, KHÁC categorize; chip lifecycle = ConversationEmailLog.lifecycle read-only |
| "View as role" preview | client-side | ADM.0.1/0.7 (§Δ3 #11) | ✅ mới sessionStorage admin-role-preview, không gọi API — chỉ đổi render |
| Deal pipeline (tạo deal từ Interested) | — | SDR.0.17 | ❌ chưa có Deal v2 hiện là data M&A crawl, khác nghĩa |
| Zoom booking / recording / transcript | — | KAM.0.1–1.8 | ❌ chưa có recording/transcript đã chạy cho Interview/Meeting, chưa nối KAM call |
target/uat-introspect-lean-ops.py → target/_lean_ops_shapes.json — không chép từ spec. Phát hiện đáng giá: (1) filter bLastEmailSentDate · bLifeCycles · bSyncDateRepliedEmailLog · bListIds là field chính thức của ContactV2ConditionInput ⇒ các công thức lọc của §1.3 chạy được cả ở tầng API; (2) mutation setCategorizeEmailLog đã tồn tại ⇒ classify per-reply có sẵn ở tầng data (nay BE đã có producer auto-chip; setter tay còn để ngỏ).3 · Phase 0 — Setup (Admin)
3.1 · ADM.0.1 — Tạo user + gán role SDR / KAM ✅ Có



| # | Step (spec) | Thực tế UAT |
|---|---|---|
| 1 | Admin tạo user account cho từng người | ✅ modal Create new Admin (hình 2) |
| 2 | Gán role SDR / KAM / Ops / Admin | ✅ role SDR + KAM đã có; ❌ chưa có role Ops riêng (Ops đang dùng Admin/Sale Admin) |
| 3 | Verify quyền đúng theo role ("KAM calls-only" là permission) | 🟡 phân quyền chi tiết đang In Progress; chưa có permission "KAM calls-only" |
| 4 | User login thấy đúng phạm vi của mình | ✅ hình 3 — SDR chỉ thấy 2 list được giao |
selectAdmins(query: AdminQueryInput) → SelectAdmin{id, fullName, email, firstName, lastName, avatar, isActive}; lọc theo role qua AdminConditionInput.bRoles (AdminV2FilterInput). Mutation tạo/sửa admin (form "Create new Admin", gán role, generate password) chưa nằm trong introspection lean-ops — // mutation shape (createAdmin/updateAdmin) cần introspect lại.3.2 · ADM.0.3 — Import companies + contacts ✅ Có
Data companies + contacts đã có sẵn trong hệ thống (import từ nguồn crawl/CRM cũ). SDR có quyền sửa record và tạo contact mới khi thiếu:

DUPLICATE_FOUND của createContactV2 (enum: CREATED · DUPLICATE_FOUND · MERGED, có force để merge).
(2) Crawl-on-create: nhập LinkedIn URL → hệ thống tự crawl name, location, avatar, work experience về profile.
(3) Link WE → company: mỗi dòng work experience chưa link có nút "Link ‹company› to a company" → picker company (company phải tồn tại trong Companies) → click row là link ngay, không confirm (toast "Linked") → màn bonus "Apply ‹company› to N other contacts" áp cùng link cho mọi contact có cùng company text (công cụ normalize hàng loạt). [20/07] Unlink đã retired (15/07) → thay bằng Relink: dòng ĐÃ link nay có icon Relink = trỏ WE sang company khác (cùng picker, mode=relink); relink company-scoped + bulk theo alias, rebuild emails. Muốn "gỡ" = relink lại về company cũ. GQL: relinkWorkExperienceCompany / bulkRelinkContactsWorkExperienceCompany / findContactsWithLinkedAliasMatch. Chi tiết + ảnh: user-guideline-v3 · A3+.DTO — tạo contact lẻ + dedupe (verify introspection)
3.3 · ADM.0.7 — Admin cập nhật info cho SDR / KAM ✅ Có

Admin (introspect): signature: String (rich-text, bake vào body email khi gửi — không có field BE riêng trên email log, xem §5.5) · senderName: String · attachments: [AdminFileAttachment!] (file đính kèm mặc định — cùng kho với "Attach from document library" ở §5.5). Mutation lưu (updateAdmin / update-email-config) chưa nằm trong introspection lean-ops — // mutation shape cần introspect lại; các field đích thì đã chắc từ type Admin.4 · Phase 1 — Chuẩn bị contact lists (PIC: Ops)
4.1 · SDR.1.20 — Tạo list + add contacts ✅ Có · UAT 4/4 PASS



| # | Step | Operation | Kết quả 15/07 |
|---|---|---|---|
| 1 | Ops mở Lists V2 | customLists | PASS |
| 2 | Filter contacts đã import | contactsV2 | PASS |
| 3 | Tạo list từ kết quả filter | createCustomList | PASS |
| 4 | Add members + verify count | addContactsCustomList → customList.memberCount | PASS count trễ vài giây (Atlas sync) |
DTO input/output — verify UAT 16/07
Sequence — tạo list & add members xuyên tầng
sequenceDiagram
autonumber
participant Ops as Ops (UI /contacts · Add to list)
participant GQL as GraphQL admin-api-uat
participant DB as Mongo (custom_lists · contacts)
Ops->>GQL: createCustomList(input{name, type: Contact})
GQL->>DB: insert custom_lists
GQL-->>Ops: AdminCustomListDetail {id, memberCount: 0}
Ops->>GQL: addContactsCustomList(ids:[listId], input{contactIds})
GQL->>DB: push members
GQL-->>Ops: [AdminCustomList]
Note over GQL,DB: memberCount sync Atlas trễ vài giây
(verify nên retry)
Ops->>GQL: customList(id) — verify memberCount
4.1b · Bản đồ Add-to-list — 7 entry point + hành vi membership ✅ deep-check UI 17/07
Add-to-list không chỉ có 1 đường từ /contacts — có 7 entry point, tất cả hội tụ về cùng 1 kho membership (custom_lists.members) với cùng semantics. Đã chạy thật từng đường trên list QA riêng (tự tạo → verify count → tự xoá):
flowchart LR
subgraph EP["Entry points (UI)"]
E1["/contacts — tick n người
hoặc All matching (cap 10.000/run)"]
E2["Chip Lists trên từng contact row
→ drawer Manage lists for ‹contact›"]
E3["List detail → + Add members
(drawer tự exclude member cũ
qua chip ✕ Custom List)"]
E4["Company detail → tab Employees"]
E5["/companies — tick company
→ Add to list (list Company)
+ Preview contacts"]
E6["Campaign detail → tab People"]
E7["Marketing Email → tab People"]
end
P["Drawer Add to list
Apply to: Selected (n) ⊕ All matching
picker chọn list — KHÔNG tạo được list mới"]
M[("membership
custom_lists ⟷ contacts")]
E1 --> P
E3 --> P
E4 --> P
E6 --> P
E7 --> P
E2 -->|"tick/untick nhiều list → nút xác nhận
nhãn động: Add to N / Remove from N list(s)
(có + Create New riêng)"| M
E5 -->|"list loại Company"| M
P --> M
M --> V["Bộ lọc của SDR (§1.3)
+ member count (sync trễ vài giây)"]
classDef default fill:#fff,stroke:#CCC8D5;
| Hành vi | Kết quả kiểm chứng 17/07 (UI, list QA) |
|---|---|
| Dedup khi add trùng | Im lặng bỏ qua — add Selected(1) → Members 1; add lại đúng người đó → vẫn 1, không cảnh báo. Membership là upsert idempotent. |
| All matching sau khi đổi filter | Tính lại theo filter hiện tại lúc bấm Add — không giữ tập cũ. Cap 10.000/run (note ngay trong drawer: "This run adds up to 10,000 of N contacts"). |
| Member count | Sync trễ vài giây (Atlas) — verify count nên retry; UI Lists page có thể hiện 0 ngay sau add rồi mới nhảy. |
| Tạo list mới ở đâu | Drawer Add-to-list không tạo được (search tên lạ → "No lists found", nút Add disabled). Tạo ở: (1) trang Lists → + Create New; (2) drawer Manage lists per-contact cũng có + Create New. |
| Remove members | Tick member trong list → action bar Remove from list → confirm "Remove N contact(s) from this list?" → count giảm ngay. Không đụng contact gốc. |
| Xoá list còn members | Được, không cảnh báo về members — membership chỉ là link, contacts nguyên vẹn. |
| Manage lists per-contact (E2) | Drawer "Manage lists for ‹tên›": toàn bộ list + checkbox (tick = member), sửa nhiều list 1 lần → nút xác nhận nhãn động (Add to N / Remove from N list(s); "Save" disabled khi chưa đổi); kèm Actions (assign owner / delete list) từng row. Verified: tick + Add to 1 list(s) → member count +1. Là đường add/remove 1 người lẻ nhanh nhất. |
| Campaign / Marketing Email People (E6/E7) | Đều là bảng contacts đầy đủ filter (campaign thêm Done · Skipped · Personalize) + chip Add to list per-row + bulk selection — cùng component với /contacts. |
| Add vào NHIỀU list 1 lần | Được — picker cho tick nhiều list, nút xác nhận thành "Add to N list(s)", 1 click add vào tất cả (verified: 2 list cùng +1). |
| "Select all N matching" trên action bar | Có ngưỡng: tập vượt bulk limit (15,465) → toàn bộ nút action disabled + link Undo; tập nhỏ (21) → enabled bình thường. Muốn add tập lớn: radio All matching trong drawer (tự cap 10.000/run). |
| Selection persistence | Giữ nguyên qua Load more / chuyển tab (verified: 1 selected → Load more → vẫn 1 selected). |
| Preview contacts (E5) | Tick companies → Preview contacts mở bảng contact gộp của các company đã chọn → tick → Add to list hoạt động như thường (verified 17/07). |
| Scope theo role (SDR) | Verified qua Login-as: SDR chỉ thấy contacts thuộc list được giao, không có + Create Contact, Segments không có + Create New/Actions — quyền "Ops tạo list, SDR làm việc" được UI enforce. (19/07: menu SDR bỏ hẳn trang Contacts.) |
| [v3] SDR-list gate | List có flag SDR list: membership độc quyền. Contact đã thuộc SDR list khác → add bị bypass + toast skip (không move như form text; Timeline vẫn ghi added — 2 lệch cần dev sửa, xem Δ3#3). |
| [v3] Tạo list | Chỉ ở Segments → + Create New (modal Type/List name/SDR list). Drawer Add-to-list nay có pagination + filter riêng nhưng vẫn không tạo được list. |
| [v3] Member count | Lag nặng hơn với list mới (0 kéo dài >60s) — nguồn sự thật là Members tab + Timeline. |


addContactsCustomList(ids:[listId], input{contactIds}) (bulk) hoặc cập nhật membership qua updateCustomList/bulkUpdateCustomList (drawer Manage lists sửa nhiều list 1 lần). Remove members = biến thể remove của cùng nhóm mutation. Số liệu view đọc bằng contactV2Count với filter bListIds (§2).4.2 · OPS.0.8 — Assign list cho SDR ✅ Có


| # | Step | Thực tế |
|---|---|---|
| 1 | Ops chọn list | ✅ |
| 2 | Assign cho 1 SDR + priority flag | ✅ assignedToIds (updateCustomList / bulkUpdateCustomList) — ❌ chưa có priority flag (P2) |
| 3 | Members mới vào list của rep | ✅ theo model mới — member mới mặc định lifecycle Prospect → SDR lọc Lifecycles: Prospect (case A "chưa từng gửi", §1.3) |
| 4 | Rep nhìn thấy list được giao | ✅ hình 10 + filter bAssignedToIds PASS |
DTO — assign qua assignedToIds (verify UAT 16/07)
4.3 · OPS.0.6 — List rules (approval + hằng số vận hành) ❌ Chưa có
As-is
Chưa có approval gate (spec lean gốc từng chấp nhận "Ops tạo = Ops duyệt", gate để B2 — nay chốt là hạng mục riêng chưa làm). Re-entry 🥶 chưa được sign-off — blocker #7 (phần N follow-up đã gỡ: per-SDR, 16/07).
To-be (đề xuất)
(1) Ops sign-off re-entry per nhãn 🥶 + thứ tự view (N follow-up: mỗi SDR tự đặt, khỏi sign-off). (2) Encode vào saved filters đặt tên chuẩn (Q1-Replies · Q2-Followups · Q3-NewProspects) — hằng số sống trong filter, đổi rule = sửa filter, không cần config UI. (3) Approval: thêm trạng thái draft/approved trên custom list, chỉ list approved mới assign được (contract nhỏ trên updateCustomList).
Activity — hành trình Ops chuẩn bị & giao list (kèm approval gate to-be)
flowchart TD
A(["Ops cần feed việc cho SDR"]) --> B["Filter /contacts theo ICP (§4.1)"]
B --> C["Add to list — tạo mới / chọn list có sẵn"]
C --> D{"Chất lượng list ổn?"}
D -->|"Chưa — lọc lại"| B
D -->|"Ổn"| E{{"❌ to-be OPS.0.6: approval gate
draft → approved (chỉ list approved mới assign được)"}}
E --> F["Assign list cho SDR — assignedToIds (§4.2)"]
F --> G["Member chưa-từng-gửi → SDR lọc
Lifecycles: Prospect (case A, §1.3)"]
G --> H(["SDR bắt đầu vòng lặp §5"])
classDef default fill:#fff,stroke:#CCC8D5;
5 · Phase 2 — The SDR Loop
Toàn bộ 11 flow của Phase 2 là MỘT vòng lặp: contact list → workspace → email → follow-up → reply → classify → quay lại list hoặc thoát ra deal/suppression.
Activity — một ngày làm việc của SDR (không còn 3 view — gửi mail theo trường hợp)
flowchart TD
A(["SDR login → mở list được giao (§5.2)"]) --> INB{"INBOX: có reply
mới của khách?"}
INB -->|"Có"| R["Trả lời reply trong thread (§5.8)
hệ thống auto-nhãn → SDR set lifecycle (§5.9)"]
R --> Q1{"Interested?"}
Q1 -->|"✅"| G["(to-be G2) tạo deal → handoff KAM"]
Q1 -->|"🥶 / 🆘"| INB
G --> INB
INB -->|"Hết reply"| L["Quét MEMBERS list
quick-filter pin: Lifecycles · Has signal · New signal since follow-up
+ cột Last Contact 💬 — SDR KHÔNG '+ Add filter'"]
L --> C{"Trường hợp gửi?"}
C -->|"Signal mới sau email cuối"| D["Gửi lại có ngữ cảnh (⚡/💼)"]
C -->|"Chưa từng gửi (Prospect)"| N["First-touch email"]
C -->|"Đã gửi · Last Contact dài"| F["Follow-up trong thread (§5.7)"]
D --> O["Gửi tay → auto-log → lifecycle/sent-date đổi (§5.6)"]
N --> O
F --> O
O --> C
C -->|"Hết"| P(["Xong việc hôm nay"])
classDef default fill:#fff,stroke:#CCC8D5;
Không còn thứ tự 3 view cứng. Ưu tiên: reply (Inbox) → signal mới → chưa gửi → follow-up im lâu; nhận biết qua Inbox + quick-filter pin + cột Last Contact (do bị khoá "+ Add filter").
Vòng lặp SDR ↔ User guide Flow — cả vòng đã chạy, chỉ hở đúng bước hand-off
flowchart TD L["Login
(Flow 1)"] --> O["Mở list được giao & tìm contact
(Flow 2)"] O --> M["Quản lý / classify contact
(Flow 3)"] M --> S["Gửi email 1:1
(Flow 4)"] S --> RP(["Khách reply về"]) RP --> IN["Inbox: đọc & trả lời
(Flow 5)"] IN --> FU["Follow-up khi khách im lặng
(Flow 6)"] FU --> OC["Kênh khác — WhatsApp / LinkedIn
(Flow 7)"] OC --> Q{"Interested?"} Q -->|"Chưa · 🥶 tạm dừng · 🆘 loại"| O Q -->|"Có ✅"| HO["Hand-off sang KAM
(Flow —, ❌ SDR.0.17 chưa build)"] class L,O,M,S,RP,IN,FU,OC,Q ok; class HO gap; classDef default fill:#fff,stroke:#CCC8D5; classDef ok fill:#ECFDF3,stroke:#12B76A; classDef gap fill:#FEE4E2,stroke:#D92D20,color:#912018;
Xanh = bước đã chạy được (Flow 1–7 map thẳng vào User guide) · Đỏ = lối ra Interested→KAM chưa build (SDR.0.17). Vòng lặp trọn vẹn trừ đúng 1 hand-off.
5.0 · View & quyền của SDR/KAM (khác Ops/Admin) ✅ verify source + live 20–23/07
Nguyên tắc phân vai: Ops/Admin chuẩn bị & sở hữu data (tạo list, giao việc, sửa CRM tổng); SDR/KAM chỉ LÀM VIỆC trên list được giao. UI enforce khác biệt này ở 3 tầng: (1) menu, (2) trang bị ẩn, (3) hành động bị khoá trong từng trang. Ma trận từng hành động + evidence file ở §5.4c — mục này là bản tóm nhìn-nhanh.
admin-role-preview): (1) Menu = 8 (Segments · Inbox · Meetings · Message Templates · Snippets · Documents · Campaigns · Marketing Emails) — ẩn đúng 7. (2) Members bar của list = 3 filter (Lifecycles · Has signal · New signal since follow-up) + ô search keyword + ⚙ column settings + rows-per-page — KHÔNG có "+ Add filter" (hasAddFilter=false). "Prospect"/"In Conversation" chỉ là value TRONG dropdown Lifecycles, không phải filter riêng. (3) Inbox thì NGƯỢC LẠI: CÓ "+ Add filter" + full set (Lifecycles · Contact Classification · Owner · Work Experience · Countries · Cities) ⇒ đúng model: members list khoá filter → replies xử lý ở Inbox. Cột members: Title · Account · Country · City · Lifecycle Stage · Signals · Last Contact · Action.
Tầng 1 — Menu Sales Management (SDR thấy 8 / 15)
| Menu (Sales v2) | Ops/Admin | SDR/KAM |
|---|---|---|
| Segments (Lists) · Inbox · Meetings · Message Templates · Snippets · Documents · Campaigns · Marketing Emails | ✅ | ✅ 8 mục |
| Contacts · Companies · Email Queues · Email Logs · Signals · Deals · Job Search | ✅ | ✘ ẩn (7 mục) |
Admin Sales v2 = 15 mục; SDR/KAM bị trim còn 8 ⇒ SDR không tiếp cận CRM tổng (Contacts/Companies), chỉ list được giao + Inbox (§5.2). Landing sau login = /projects (G16 — nên đổi về /segments).
Tầng 2–3 — SDR/KAM làm được gì / KHÔNG (Ops/Admin đầy đủ)
✅ SDR/KAM LÀM ĐƯỢC
• Xem list được giao (không thấy list người khác) + lọc: Lifecycles · Has signal · New signal since follow-up
• Mở contact detail; set lifecycle CẤP CONTACT (chip editable — trừ 3 stage khoá PlatformSignUp/InProject/ProjectCompleted, §5.4c)
• Comms per-row: ✉ Compose · WhatsApp · LinkedIn
• Inbox: đọc thread + reply / compose (đủ filter)
• Message Templates · Snippets · Documents: full CRUD
• Campaigns / Marketing Emails: create / edit / delete
• Sửa field cơ bản contact: name · phone · location · LinkedIn (Relink WE nếu có contact.experience.edit)
✘ KHÔNG (chỉ Ops/Admin)
• KHÔNG có trang Contacts / Companies, KHÔNG + Create Contact
• List: KHÔNG Create · Assign · Add members · Remove · Delete · bulk
• Density Simple bị khoá (không toggle Full)
• Row ⋮ More ẩn: Add to Campaign / Marketing Email / another List · Update bucket
• KHÔNG đổi Type · Owner · Lists · Emails (sales fields hidden ở detail)
• KHÔNG classify reply — chip do hệ thống auto-gán (§5.9); KHÔNG Add people vào Campaign/Marketing
• Dòng team-coverage view-only (§5.4b) · 🗓 Schedule call ẩn
useAdminRealRoles()) cho membership capability — Add/Remove/export vẫn hiện với super-admin đang preview; chỉ SDR/KAM thật mới mất. Còn visibility (route · menu · create/assign · Simple lock · ⋮ hide · coverage lock) thì impersonation-aware. Chi tiết + file: §5.4c.5.1 · SDR.0.1 — Login → làm việc trên list được giao (gửi mail theo trường hợp) ✅ Có ↔ User guide: Flow 1–2
SDR login → mở list được giao. SDR bị khoá "+ Add filter" (chỉ quick-filter pin sẵn — lockFilters, §1.3) nên KHÔNG tự dựng 3 view; SDR gửi mail theo trường hợp (§1.3). Các công thức lọc dưới đây mô tả LOGIC từng trường hợp (chạy ở tầng API + Inbox); ảnh minh hoạ:


Lifecycles: Prospect trên list 419 members → 322 contacts chưa đụng vào.
In Conversation: Received a reply (Yes/No + Received a reply/No reply) → 7 contacts cần xử lý.») filter = Lifecycle + Has signal nhưng lọc trên COMPANIES. ⇒ "Has signal" tồn tại 2 CẤP: members bar = contact đích thân có signal; rail = company có signal. Evidence URL/field: Has signal (members) → clMemberContact_bHasSignal=eq:true · Lifecycles (members) → clMemberContact_bLifeCycles=in:Prospect · chọn company ở rail → clMemberContact_bWorkExperiences … linkedCompanyIds:[‹id›]. ⇒ ContactV2ConditionInput có thêm field bHasSignal (// field name suy từ URL param — confirm khi introspect lại, chưa có trong bản introspect 16/07). Số kiểm chứng (list 17072026v3 = 359 members / 111 companies): Lifecycles ✓ Prospect (members) 359 → 107 · Has signal Yes (members) 359 → 246 · Has signal Yes (rail company) 111 → 29.contact.section.ts = "Has new signals since our last email" — cùng field), tri-state Yes / No (mặc định hiện — isDefault). Vai trò trong flow: bổ trợ view Follow-ups (§5.7) — Follow-ups thuần = “im quá N ngày” (không có cớ mới); filter này = “im NHƯNG vừa có tin mới” ⇒ danh sách follow-up “nóng”, có ngữ cảnh (nhắc đúng sự kiện công ty / đổi vai trò). Kết hợp: In Conversation: No reply + Has new signals since last email: Yes. Source (verify 22/07): ContactFilterKeyEnum.aHasSignalSinceReply (label ở contact.section.ts:363), option value aHasSignalSinceReply:true|false (contact/domain/entities/filter.ts:17); field aHasSignalSinceReply: Boolean (tri-state) + hasSignalSinceReply: Boolean trên ContactConditionInput (graphql.ts:15183/15248) — cùng field cũng có trên CampaignTargetConditionInput + MarketingEmailRecipientConditionInput (dùng lại cùng logic ở Campaign / Marketing Email). // tên code là “SinceReply” nhưng label UI + ngữ nghĩa mốc là “since last email mình gửi” — cùng 1 thứ, đừng lẫn với reply của khách.Ba bề mặt filter của lean — mỗi thanh lọc cái gì (số kiểm chứng list 17072026v3)
flowchart TD
subgraph MB["List · thanh members — lọc CONTACT"]
MB1["Lifecycles
Prospect: 359 → 107"]
MB2["Has signal (cấp CONTACT)
Yes: 359 → 246"]
end
subgraph CR["List · rail company — lọc COMPANY (rồi ra người của company đó)"]
CR1["Lifecycle"]
CR2["Has signal (cấp COMPANY)
Yes: 111 → 29"]
end
subgraph IB["Inbox · thanh filter — lọc thread/reply"]
IB1["Lifecycles · Contact Classification · Owner"]
IB2["Work Experience → Company type
Sponsor: 27 → 25"]
IB3["Countries · Cities · + Add filter"]
end
MB --> R1(["Tập CONTACT đã lọc"])
CR --> R2(["Company khớp → người của company đó"])
IB --> R3(["Thread / reply đã lọc"])
MB2 -.->|"cùng tên 'Has signal' · KHÁC cấp"| CR2
classDef default fill:#fff,stroke:#CCC8D5;
Ba bề mặt độc lập: thanh members lọc trực tiếp trên CONTACT · rail company lọc trên COMPANY rồi trả về người của company khớp · thanh Inbox lọc thread/reply. "Has signal" tồn tại 2 cấp — contact đích thân có signal (members) vs company có signal (rail).
As-is
SDR chỉ có quick-filter pin sẵn (Lifecycles · Has signal · New signal since follow-up), không "+ Add filter" (verify live 23/07); replies xem ở Inbox, follow-up qua cột Last Contact. Chưa có "zero-decision entry" — login xong phải tự điều hướng vào list.
To-be
Saved/pinned filters (🔵 In Progress) → pin thêm quick-filter cho từng trường hợp (signal mới · prospect). Sau đó: landing mặc định của role SDR = list view thay vì /projects (G16, P1, FE nhỏ).
DTO — công thức lọc ở tầng API (chạy thật 16/07 chiều, số khớp tuyệt đối)
lastEmailSentDate xuất hiện (qua sync job, trễ cỡ phút — contact QA sau ~5 phút chưa thấy). (2) Lifecycle enum KHÔNG tự nhảy khi send (test: send xong lifecycle vẫn rỗng) — enum chỉ đổi khi SDR classify. Hệ quả: view nên dựa trên bConversationSnapshot (nguồn sự thật đã-gửi/đã-reply); lifecycle enum dùng cho classify + suppression. Công thức lifecycle Prospect vẫn chạy (321/398) nhưng sót 48 contact chưa gửi mà không mang nhãn Prospect (VD contact tạo tay có lifecycle rỗng — verified).5.2 · SDR.0.25 — Xem list được giao ✅ Có ↔ User guide: Flow 1–2
Hình 10 (§4.2): SDR thấy đúng các list của mình kèm member count. Danh sách list "được giao" = query customLists lọc theo assignee = user hiện tại; row hiển thị memberCount + badge role của assignedToIds + cột Category (category:SDR). Gap P2: chưa có cột "work-through progress" (VD "34 of 120 worked") — lean có thể suy từ count các bộ lọc (worked = tổng − New Prospects).
DTO — list được giao (verify introspection)
5.3 · SDR.0.3 — Account / Contact workspace ✅ Có · UAT 5/5 PASS ↔ User guide: Flow 2 (workspace)

| # | Step | Operation | Kết quả 15/07 |
|---|---|---|---|
| 1 | Mở account từ contact list | mmCompaniesV2 | PASS |
| 2 | Company summary | mmCompanyV2 | PASS summary + contacts/signals/deals count |
| 3 | Contacts của company | contactsV2 (linked company) | PASS |
| 4–5 | Activity timeline (chống double-touch) | mmCompanyHistories + contactHistories | PASS |
DTO — contact workspace (verify introspection)
5.4 · SDR.0.10 — Tự chọn contact đích ✅ Có ↔ User guide: Flow 2 (pick contact)
V1 đúng chủ trương "con người quyết định who": SDR đứng ở workspace (hình 14), xem work experience + lifecycle + hoạt động cũ rồi tự chọn ai để email. Không có AI gợi ý — đúng lean, không phải gap.
contactsV2(query: ContactV2QueryInput) → items AdminContactV2, đếm bằng contactV2Count (vì totalItems luôn 0). Dữ liệu từng dòng để SDR ra quyết định chọn người, tất cả nằm trong AdminContactV2: lifeCycles: [String!] (cột Lifecycle Stage) · titles · addressCountry · addressCity (cột Country/City tách riêng, sort được — §Δ3 #10) · lastEmail: EmailLog + conversationSnapshot{lastSentDate, lastRepliedDate} (cột Last Contact + aging meter 💬) · owners/ownerIds · customLists. Filter membership + các công thức lọc qua bListIds · bLifeCycles · bConversationSnapshot của ContactV2ConditionInput (§1.3/§5.1). Cột Signals của list detail lấy từ nguồn signal riêng, KHÔNG phải field trên AdminContactV2 — shape signal chưa introspect ở lean-ops.5.4b · Team coverage — "held by teammates" (contact thuộc different owner) ✅ verify code + API + UI 20/07 ↔ User guide: Flow 2 (held by teammates)
Tính năng lên UAT sau đợt capture 19/07 (FE plan merge-coverage-into-member-table): trong list detail, chọn 1 company ở rail trái → cuối bảng members xuất hiện block dòng xám view-only + pill "N held by teammates" — những contact ở CÙNG COMPANY đó nhưng không thuộc list đang mở, đang được người khác giữ trong list của họ. Mục đích: chống double-touch ở cấp company/account — SDR list độc quyền (§Δ3 #3) chỉ chặn trùng CONTACT, không chặn 2 rep cùng đánh 1 COMPANY qua 2 contact khác nhau; block này lấp đúng lỗ đó (soft guard: chỉ hiển thị, chưa chặn gửi — G1 vẫn mở).

Điều kiện hiển thị — contact phải thỏa ĐỦ 4 điều kiện
use-coverage-rows.ts, contacts-v2-in-different-owner*) byte-identical so với 19/07 — logic không đổi.Semantics đã kiểm chứng (API 20/07, token SDR "Dong SDR" + đối chứng admin)
| Khía cạnh | Kết quả verify |
|---|---|
| Nguồn dữ liệu | Query riêng contactsV2InDifferentOwner (+…Count) — chính là contactsV2 nhưng BỎ scoping "chỉ list của tôi"; FE tự bound = company đang chọn (WE ongoing đã link) + 5 lifecycle nóng; không chọn company → không fetch (guard chống đọc cả CRM). Count phải gọi query riêng vì totalItems trả null. |
| "Người khác giữ" nghĩa là gì | Nằm trong ≥1 list còn tồn tại mà người khác giữ — hiểu RỘNG: mọi loại list có người quản (kể cả list của Admin/Sale Admin như Thao/Omer), không riêng SDR list. List đã xoá không tính: 6 contact chỉ còn listIds trỏ list đã xoá bị loại. |
| Contact ngoài mọi list | Không bao giờ hiện — probe coverage + bListIds exists:false trả rỗng; contact 0-list tại đúng company không xuất hiện. |
| Số đối chứng Battery Ventures (company-only) | Admin đếm: tổng ongoing 181 · có list 179 · ngoài list 2. Coverage token SDR: 173 = 179 − 6 (list đã xoá). EQT Group + 5 lifecycle nóng: coverage = 3/3 khớp tuyệt đối admin. |
| Super admin gọi cùng query | Rule "different owner" bị bypass — trả cả 181 (kể cả 2 unlisted). Rule chỉ siết với role sales. |
| 2 case (pill vs ⚠) — chính xác từ code | coverage.rows (contact ở company + list người khác + hot lifecycle) chia 2 nhánh theo có/không là member list đang mở (use-coverage-rows + drawer index.tsx:668-704): (case 2) KHÔNG ở list đang mở → coverageDeduped → dòng xám + pill "{coverageDeduped.length} held by teammates" (:1257). (case 1) ĐANG ở list đang mở (vừa list mình vừa list người khác) → coverageDups → KHÔNG vào pill; thay vào đó admin/test thấy ⚠ "{coverageDups.length} in two lists" (:1282) + highlight amber dòng member. Tức held-by = case 2 (KHÔNG ở list đang mở), ⚠ = case 1 (ở cả 2 list) — chốt Đông 20/07: đúng intent. |
| Quyền trên dòng coverage | Role sales: dòng bị khoá selection (view-only — biết để né, không thao tác được); click pill scroll xuống block. |
Chưa chốt (cần dev confirm): (1) contact nằm trong list KHÁC của chính mình có bị BE loại không — FE chỉ dedup member của list đang mở; (2) list không assign cho ai có tính là "người khác giữ" không (mọi list ở Battery đều có assignee nên chưa tách được). Nguồn FE: use-coverage-rows.ts · contacts-v2-in-different-owner.gql.ts (commit "merge team-coverage rows into member table").
5.4c · Member-row actions + role gating + lifecycle lock ✅ verify source 20/07
Action trên 1 dòng member (list detail) — bảng chia 2 mode (Lean/Simple vs Full); SDR/KAM hard-lock ở Simple. Gating verify từ source HEAD:
| Action | Vị trí | SDR/KAM | Admin | Evidence |
|---|---|---|---|---|
| Open detail | Click name/title → overlay contact-v2:detail | ✔ | ✔ | ungated |
| Lifecycle chip (classify) | Cột Lifecycle (lean) / inline (full) | ✔ (chip vẫn editable cho SDR) | ✔ | contacts-v2-columns.tsx:657 không readonly |
| Signal · Last Contact · ✉ Send · WhatsApp · LinkedIn | Cột + icon comms | ✔ | ✔ | comms icons luôn render contact-actions/index.tsx:227,240 |
| Add to Campaign / Marketing Email / another List · Remove from list · Delete · Update bucket | ⋮ More menu | ✘ ẩn | ✔ | contact-actions-cell/index.tsx:56 hideMore={isRoleSdr‖isRoleKam}; MoreMenu render !hideMore (:244) |
| Update type · Assign owner | Detail drawer (KHÔNG phải row action; assign owner drawer-only cả với admin) | ✘ (sales fields hidden) | ✔ | owner bỏ khỏi CUSTOM_LIST_MEMBERS_COLUMN_PRESET; SDR: hideSalesFields |
| Schedule call 🗓 | Icon β | ✘ | disabled "Coming soon" (highest-authority) | use-contact-actions.ts:227 |
LOCKED_LIFECYCLE_VALUES = {PlatformSignUp, InProject, ProjectCompleted} (contact-lifecycle-options.ts:75). Khoá theo giá trị HIỆN TẠI của contact (lifecycle-badge/index.tsx:113,141): nếu contact đang ở 1 trong 3 → cả chip read-only (không mở dropdown, tooltip "set automatically... can't be edited") ⇒ không rời được 3 stage này bằng tay. Stage khác → dropdown thường, và 3 giá trị này VẪN chọn được (không bị filter — chỉ Bounce/Click/Dropped/Open bị ẩn). ⇒ khoá kiểu "cấm rời", KHÔNG cấm vào — chốt Đông 20/07 = đúng intent: contact đang ở 3 stage này không tự rời được, nhưng vẫn set/chuyển VÀO được từ stage khác (by design, không phải bug).SDR fixed list + per-menu capability (verify live + source 20/07)
| Surface | SDR/KAM | Evidence (flag/file) |
|---|---|---|
| Segments page | Chỉ xem list được giao; không Create/Assign/Actions/Created-By filter; lock tab Contact | custom-list-list/index.tsx:22-25 hideActions/hideCreateButton/hideCreatedByFilter/lockedType = isSalesScoped; assign canAssign=DataManager‖highestAuthority |
| List detail (fixed list) | Không Add members, không Remove (không selection) | drawer :1228 hideHeaderActions=realIsSalesRole · :1229 hideSelection=realIsSalesRole (key theo REAL role → super-admin view-as-SDR VẪN thấy; chỉ SDR thật mất) |
| Density | Simple khoá, không toggle | use-contact-view-mode.ts:92 |
| Inbox | Đọc thread + reply/compose; đủ filter | page không role-gate |
| Meetings | View-only (HubSpot sync — không ai create) | không có nút create |
| Message Templates · Snippets · Documents | Full CRUD (không role gate); Snippet "Private" chỉ là tag hiển thị theo ownerId, FE không enforce owner-only | grep isRoleSdr/Kam = 0 match trong 3 module |
| Campaigns | Create (trong folder, gate id!=='All') · edit/delete · Create Folder; KHÔNG Add people | people-tab-v2/index.tsx:94 canAddContacts=!(isRoleSdr‖isRoleKam) |
| Marketing Emails | Create/edit/delete; KHÔNG Add people | ...people-tab-v2/index.tsx:91 cùng rule |
⚠️ View-as-role không 100% trung thực cho membership capability: add/remove/export khoá theo useAdminRealRoles() (REAL role) chứ không theo impersonated flag → super-admin "view as SDR" vẫn thấy +Add members/Remove; chỉ SDR/KAM thật mới mất. Visibility gate (route, sidebar, create/actions/assign, Simple lock, ⋮ hide, coverage lock) thì impersonation-aware. Nguồn: gt2-260720-*.md (member-row / contact-detail / sdr-gating).
5.5 · SDR.0.11 — Compose email 1:1 ✅ Có ↔ User guide: Flow 4

attachments: [EmailLogAttachmentInput!] của sendEmailLog · Snippets — block text tái sử dụng, search + toggle EN/DE, click chèn tại con trỏ · Template — 2 mode Insert Template (chèn thêm) / Replace Template (ghi đè draft). Ảnh chi tiết: user-guideline-v3 · C4.types:[Common,Contact], compose-attachments.tsx). (d) Subject prefill vẫn bug: applyTemplate() chỉ ghi body; template item không có field subject (reply/forward thì có subject qua payload open()). (e) </> HTML toggle gate isAccessTestFeature — nhiều khả năng ẩn với SDR prod.inbox-compose-floating, zustand singleton) mở từ profile "Send email" (mode:new, subject:'') / list row ✉ / Conversations New Email·Reply·Forward — auto-log Conversations. (B) per-email hover ✉ = mailto: handoff sang Outlook (BCC inbox log, useSendContactEmail) — KHÔNG mở composer. Flow composer: From = mailbox của chính mình; To prefill + suggested = participants; Cc toggle, Bcc ẩn; Subject reply/forward = RE:/FW:, new = rỗng (bug); body ReactQuill, signature bake vào body (không field BE riêng); footer 📎 / 🗂 (Published + Common/Contact) / Snippets(EN/DE) / Template(Insert prepend no-sig · Replace overwrite +baseline+sig, default Insert). On Send: useAdminEmailLogSendEmail('replyInbox') → invalidate ['contact-conversation'] (auto-log) → reset window, no recall; nếu isFromBlocked (UAT chặn Omer, Prod chặn test accounts Long/Thao/Tin) → ẩn nút Send. KHÔNG có block riêng theo SDR/KAM trên form (chỉ entry-point + sales cluster khác).updateContactV2Fields(id,input) (patch cache, toast "Saved", history refresh +2s): First/Last name (click → 2 input, blur commit; ghi firstName+lastName+fullName) · Phone (regex) · Location = 2 dropdown Country+City (2 save riêng addressCountry/addressCity) · LinkedIn URL (+ nút ✨ Enrich enrichContactsFromLinkedinV2) · Link/Relink WE→company (gate permission contact.experience.edit; overlay contact-v2:link-we / relink-we). Education/Certificates read-only. SDR/KAM: cụm Type/Owner/Lists/Emails bị ẩn (hideSalesFields) ⇒ rep không đổi type/owner/list từ detail.5.6 · SDR.0.12 — Send → auto-log → rời việc hôm nay ✅ Có (theo model mới) ↔ User guide: Flow 4 (send/log)

sendEmailLog tự sinh log: subject, from/to, content, sent date, source Task; tab All / Sent Out / Replied / Draft.| # | Step | Thực tế |
|---|---|---|
| 1 | Gửi email | ✅ sendEmailLog PASS (gửi thật khi test có --send-to) |
| 2 | Activity tự động log | ✅ emailLogs ghi tự động — hình 16 |
| 3 | Account rời việc hôm nay | ✅ tự vận hành — lifecycle tự nhảy In Conversation (as-is) + sent date hôm nay → không match view nào (§1.3) |
DTO — send & email log (verify UAT 16/07)
Sequence — send → auto-log → lifecycle tự nhảy → rời việc hôm nay
sequenceDiagram
autonumber
participant SDR as SDR (Compose)
participant GQL as GraphQL
participant DB as Mongo (email_logs · contacts)
SDR->>GQL: sendEmailLog(input{from, to, subject, content})
GQL->>DB: insert email_logs (auto-log — không ghi tay)
DB-->>DB: sync job ghi contact.lastEmailSentDate
(⚠️ trễ cỡ phút — lifecycle enum KHÔNG tự nhảy, verified 16/07)
GQL-->>SDR: true
Note over DB: lastEmailSentDate xuất hiện ⇒ hết là "chưa gửi",
Last Contact về mới — đúng nghĩa "rời việc hôm nay"
5.7 · SDR.0.13 — Follow-up ✅ Có — N do mỗi SDR tự đặt ↔ User guide: Flow 6

≥ 2026-06-16 chạy trên member view của Ops/Admin; chiều follow-up = ≤ hôm nay − N. SDR KHÔNG có filter này — dùng cột Last Contact.Logic trường hợp Follow-up (tầng API/admin) — 3 điều kiện
Tinh chỉnh (mới): thêm điều kiện tùy chọn thứ 4 Has new signals since our last email = Yes (§5.1, field aHasSignalSinceReply) → tách nhóm follow-up “nóng” — contact đã im NHƯNG company/vai trò vừa có tin mới (⚡/💼) sau email cuối → ưu tiên gửi lại vì có ngữ cảnh.
Việc còn lại: không còn gì chặn. N do từng SDR tự đặt; UAT sẵn aiGenerateFollowupEmail (AI viết follow-up) có thể gắn vào bước reply — P2, không chặn.
ContactV2ConditionInput: bLastEmailSentDate (email cuối gửi đi ≤ hôm nay − N ⇒ đã im đủ lâu) đi kèm bSyncDateRepliedEmailLog exists:false (chưa có reply — loại khách đã trả lời). Đây cũng là 2 field conversationSnapshot.lastSentDate/lastRepliedDate ở tầng đọc. Gửi follow-up = sendEmailLog với preEmailLogId nối thread (§5.6) → lastEmailSentDate reset → contact lặn N ngày. Mutation soạn nội dung bằng AI aiGenerateFollowupEmail chưa nằm trong introspection lean-ops — // AI-gen shape cần introspect lại; nó chỉ sinh draft, không thay cơ chế filter trên.5.8 · SDR.0.15 — Mở reply kèm thread ✅ Có ↔ User guide: Flow 5


Đã verify live 21/07: Conversations 2-pane (thread list | thread, Expand all) chạy thật; đọc "là reply hay không" bằng mũi tên hướng ↙ inbound (khách) / ↗ outbound (mình) — §5.9. Cấu trúc dữ liệu đúng: thread qua preEmailLogId PASS, Inbox v2 đã Done Release Prod. Còn 1 gap hạ tầng (không chặn flow): đường sync reply trên UAT chập chờn (lúc nhận lúc không) nên E2E "khách reply → tự nổi lên Inbox" chưa test ổn định được — theo dõi ở G7 (P1).
/inboxes-v2, template inboxes-list-template) không có chip "In Conversation" (đã strip từ trước; ME inbox nay cũng ẩn — commit 001950af9a; campaign inbox tab vẫn còn → BUG thiếu nhất quán, cần ẩn (G19)). Bộ lọc Inbox thực tế (verify live 20/07): Lifecycles · Contact Classification · Owner · Work Experience · Countries · Cities · Last Email · + Add filter. Filter theo lifecycle stage (Stages rail trái ~17–18 giá trị), không có In Conversation. (2) InQueueTag = pill cam "In Queue" trên thread row / message card khi email outbound chưa có sentDate (đang trong send queue) — conversation-status-tag/index.tsx; nối trang admin Email Queues. (3) Per-message chip = ConversationEmailLog.lifecycle (read-only, BE tự gán từ nội dung); derive qua positive EmailLogType:[SENT_OUT,REPLIED] (commit 3becc245e0). EmailLogType = hướng message, KHÔNG phải enum categorize — đừng lẫn.inboxFiltersV2_bWorkExperiences … linkedCompanyFlags:["isSponsor"]; kiểm chứng list 17072026v3: 27 → 25 (rows toàn firm sponsor: Ardian · General Atlantic · Carlyle).Sequence — reply → nổi lên Inbox → đọc thread → classify
sequenceDiagram autonumber participant M as Mail (inbound) participant SYS as Sync (BE) participant DB as Mongo participant SDR as SDR (Inbox v2) participant GQL as GraphQL M-->>SYS: reply của khách (⚠️ UAT chập chờn — G7) SYS->>DB: email_logs mới (preEmailLogId → thread)
contact.syncDateRepliedEmailLog = now Note over DB: contact tự nổi lên Inbox (In Conversation + Received a reply) SDR->>GQL: emailLogs(condition{emails: [contact]}) — đọc thread đầy đủ SDR->>GQL: as-is — updateLifecycleContactV2(id, {nextLifeCycles: [NotNow]}) SDR->>GQL: to-be (BE đã có) — setCategorizeEmailLog(logId,
{categorize: NotNow, contactId}) GQL->>DB: cập nhật lifecycle + side-effects ⇒ contact rời khỏi nhóm chờ ở Inbox
5.9 · SDR.0.16 — Classify reply ✅ Có per-reply auto-chip ✅ · SDR set lifecycle cấp contact tay · ↔ User guide: Flow 5
categorize/setCategorizeEmailLog đã introspect 16/07, nay BE đã có producer tự động.EmailLogType (replied inbound vs sentOut outbound) — đây là hướng, KHÁC enum categorize. Chip categorize (EmailLogCategorizeEnum, 11 nhãn) hiện trên CẢ 2 chiều: reply của khách được gán nhãn thật (Out Of Office / Interested / Not Interested / Call Scheduled…), còn email bạn gửi / chưa phân loại hiện "Open" (= trạng thái UI chưa-classify) ⇒ có chip ≠ là reply. Dùng mũi tên, đừng dựa vào sự-hiện-diện của chip. Nhắc lại: "Out Of Office" là chip reply-categorize (EmailLogCategorizeEnum.OutOfOffice), KHÔNG phải lifecycle (§1.2).Reply hay send? — đọc HƯỚNG mũi tên, không đọc sự-hiện-diện của chip
flowchart TD
MSG(["1 message trong thread"]) --> DIR{"Mũi tên hướng?
(EmailLogType)"}
DIR -->|"↙ inbound (replied)"| INB["Reply THẬT của khách"]
DIR -->|"↗ outbound (sentOut)"| OUT["Email MÌNH gửi đi"]
INB --> CHIP1["Chip categorize THẬT
Interested / OOO / Not Interested…"]
OUT --> CHIP2["Chip 'Open'
(= chưa classify)"]
CHIP1 --> READ["SDR đọc reply"]
READ --> LC["SDR set lifecycle contact — tay
updateLifecycleContactV2
(chỉ khi lifecycle hiện tại rỗng HOẶC ∉
PlatformSignUp·InProject·ProjectCompleted — §5.4c)"]
W["⚠️ CẢ 2 chiều đều có chip ⇒ đừng dùng sự-hiện-diện của chip.
Tell tin cậy = HƯỚNG mũi tên"] -.-> DIR
classDef default fill:#fff,stroke:#CCC8D5;


As-is
SDR đọc reply rồi đổi lifecycle của contact (hình 20, updateLifecycleContactV2 PASS). Chip EmailLogCategorizeEnum per-reply nay do BE tự gán (auto-classify, đã live) — hiển thị read-only trên từng message; SDR KHÔNG classify reply. Setter tay setCategorizeEmailLog vẫn tồn tại nhưng không nằm trong luồng SDR.
Đã chốt (auto-classify · 19/07)
Chip per-reply hệ thống tự gán (BE producer đọc nội dung reply), SDR không chỉnh — click chip chỉ mở/đóng message. Chip KHÔNG tự đổi lifecycle contact: SDR đọc chip như gợi ý rồi tự set lifecycle cấp CONTACT. Còn lại (P1, FE-only): tab lọc “chưa classify” dùng field categorizes có sẵn. Enum verbatim (OOO = OutOfOffice).
DTO — classify (verify UAT 16/07)
Cơ chế classify thực tế — reply auto-classify (BE) · SDR set lifecycle contact (có khoá)
- Reply về → BE producer tự đọc nội dung → gán chip categorize (1/11 nhãn
EmailLogCategorizeEnum) cho chính message đó, hiển thị read-only trên message + item thread list. SDR KHÔNG bấm classify reply. - SDR đọc reply — chip là gợi ý + lịch sử quét nhanh cả thread; không sửa được (click chỉ mở/đóng message).
- SDR set lifecycle ở cấp CONTACT (
updateLifecycleContactV2) — quyết định chính thức, TÁCH khỏi chip reply (chip KHÔNG tự đổi lifecycle contact — verified 16/07). Chỉ set được khi lifecycle hiện tại RỖNG hoặc KHÔNG thuộc{PlatformSignUp, InProject, ProjectCompleted}— ở 3 stage này chip read-only (“cấm rời”, §5.4c). - Lọc “chưa classify”: field
categorizescủaEmailLogConditionInputcó sẵn → thêm tab/filter trong Inbox (P1, FE-only). - Lifecycle Interested → điểm nối handoff KAM (SDR.0.17): hiện toast "Tạo deal & chuyển KAM?" khi G2 ship.
flowchart LR A(["Reply của khách
trong thread"]) --> B["BE tự đọc nội dung reply
→ gán chip categorize (auto · read-only)"] B --> R["SDR đọc reply (chip = gợi ý)"] R --> LK{"Lifecycle contact hiện tại ∈
PlatformSignUp·InProject·ProjectCompleted?"} LK -->|"Có → chip khoá (§5.4c)"| X["KHÔNG đổi được bằng tay"] LK -->|"Không / rỗng"| C["SDR set lifecycle CONTACT
updateLifecycleContactV2"] C --> E{"Lifecycle?"} E -->|"✅ Interested"| F["(to-be G2) tạo deal → handoff KAM"] E -->|"🥶 NotNow / OOO…"| G["Ẩn khỏi view làm việc,
re-entry rule"] E -->|"🆘 OptedOut / DNC…"| H["Suppression — mọi send path né"] W["⚠️ SDR KHÔNG classify reply — chip do hệ thống gán"] -.-> B classDef default fill:#fff,stroke:#CCC8D5;
Chip reply = hệ thống gán tự động (BE producer), read-only với SDR/KAM. SDR chỉ đọc rồi set lifecycle cấp contact (1 call updateLifecycleContactV2) — trừ khi contact đang ở 3 stage khoá (§5.4c). Filter "chưa classify" dùng field categorizes có sẵn.
5.10 · SDR.0.17 — Interested → deal → handoff KAM ❌ Chưa có ↔ User guide: chưa có Flow (hand-off chưa tài liệu hoá — deferred)
As-is
Classify Interested chỉ đổi lifecycle. Không tự tạo deal, không có handoff. Deal v2 hiện tại là data M&A crawl — khác nghĩa pipeline deal, phải build từ đầu.
To-be (P0 — conversion event của cả motion)
(1) Trigger: lifecycle → Interested. (2) Tạo record deal {contact, company, SDR nguồn, ngày}. (3) Gán KAM (round-robin hoặc Ops chọn) → contact xuất hiện trong view của KAM (lean: filter lifecycle = Interested + owner = KAM). (4) Thông báo KAM. Contract chi tiết viết được ngay — không kẹt quyết định nào.
5.11 · SDR.0.18 — Xử lý OptedOut / Do Not Contact ✅ Có (tay) · gap chặn cứng ↔ User guide: Flow 3 (lifecycle)

Lifecycles: Opted Out (+1) → các contact Opted Out / Do Not Contact với badge đỏ ngay trên row.| # | Step | Thực tế |
|---|---|---|
| 1 | Classify OptedOut / DNC | ✅ set lifecycle PASS (hình 21) |
| 2 | Suppression flag ghi nhận | ✅ flag = lifecycle; 3 filter ngăn §1.3 tự loại nhóm 🆘 |
| 3 | Mọi send path chặn contact này | 🟡 mới ở mức cảnh báo campaign health (CONTACT_DO_NOT_CONTACT_LIFECYCLE), chưa chặn cứng khi gửi — task To Do (P0) |
updateLifecycleContactV2; (2) bộ lọc §1.3 tự loại nhóm 🆘 khỏi màn làm việc; (3) cảnh báo campaign health dựa CONTACT_DO_NOT_CONTACT_LIFECYCLE. CHƯA CÓ: chốt chặn ở tầng gửi — sendEmailLog (và các send path campaign/marketing) không kiểm tra lifecycle 🆘 trước khi gửi ⇒ vẫn có thể gửi cho contact OptedOut/DNC. Việc cần: thêm check reject trong sendEmailLog + mọi send path (G1).6 · Phase 3 — KAM Calls + AI Outcome ❌ Toàn bộ chưa build
6 flow của KAM chưa có UI/BE — phần này là to-be thuần, chia 2 mức theo độ sẵn sàng viết spec:
| Flow | Steps (spec) | Mức sẵn sàng |
|---|---|---|
| KAM.0.2 Calls list trong ngày | Mở danh sách calls của mình → prep = mở workspace | Viết spec được ngay — lean: filter lifecycle = Schedule call + owner = tôi, cùng pattern §1.3 |
| KAM.0.1 Book call qua Zoom API | Từ deal Interested → book → chọn giờ, gửi invite → call xuất hiện trong calls list | Kẹt hạ tầng Zoom — phụ thuộc SDR.0.17 (deal) trước |
| KAM.0.5 Call + ghi âm | Thực hiện call → recording | Kẹt quyết định ⚠️ blocker #10: consent ghi âm Đức (§201 StGB) + GDPR — deadline cứng nhất. Hạ tầng recording đã chạy cho Interview/Meeting, chưa nối KAM call |
| KAM.0.6 Recording → transcript → memory | Nhận recording → transcript → lưu account memory | Kẹt quyết định ⚠️ blocker #12: Zoom native vs ASR riêng. Transcript ingestion đã chạy cho interview/meeting |
| KAM.0.7 AI classify outcome | AI đọc transcript → {outcome, why, confidence} → move deal → low confidence = needs_review | Kẹt quyết định ⚠️ blocker #11: chốt enum won / lost / follow_up / no_show |
| KAM.1.8 Review outcome AI flag | Mở danh sách needs_review → confirm/sửa → lưu làm eval data | Viết spec được ngay sau khi #11 chốt |
Activity — KAM call flow (đề xuất — toàn bộ chưa build)
flowchart TD A(["Contact Interested
(handoff từ SDR — G2)"]) --> B["KAM book call qua Zoom API (KAM.0.1)"] B --> C["Call vào calls list — filter
lifecycle CallScheduled (KAM.0.2)"] C --> D["Prep: mở workspace của account"] D --> E{"⚠️ #10 Consent ghi âm
(Đức §201 StGB + GDPR)?"} E -->|"OK"| F["Call + recording (KAM.0.5)"] F --> G["Transcript (⚠️ #12 Zoom native vs ASR)
→ account memory (KAM.0.6)"] G --> H["AI outcome {outcome, why, confidence}
(⚠️ #11 chốt enum) (KAM.0.7)"] H --> I{"Confidence thấp?"} I -->|"Có"| J["needs_review → KAM confirm / sửa (KAM.1.8)
→ lưu làm eval data"] I -->|"Không"| K["Move deal theo outcome"] J --> K classDef default fill:#fff,stroke:#CCC8D5;
7 · SYS behaviors (chạy ngầm)
| SYS | Behavior | Trạng thái theo model mới |
|---|---|---|
| SYS.1 | Queue computation (tên spec) | ✅ thay bằng filter §1.3 — deterministic từ lifecycle + sent date; không cần materialize |
| SYS.3 | Activity auto-log khi send | ✅ emailLogs (hình 16) |
| SYS.6 | Queue exit sau action (tên spec) | ✅ tự vận hành — lifecycle nhảy + sent date reset (§5.6) |
| SYS.7 | Single-rep lock (list = exclusive ownership) | 🟡 Đã có: assign qua updateCustomList/bulkUpdateCustomList (assignedToIds — §4.2) · SDR-list độc quyền chặn trùng CONTACT (§4.1b) · team-coverage "held by teammates" soft-guard cấp COMPANY (§5.4b, chỉ hiển thị). Chưa có: owner-check khi gửi — sendEmailLog không verify contact có thuộc list của người gửi ⇒ lock mới là quy ước, chưa enforce runtime. |
| SYS.8 | Sign-up detection → deal closed-won | 🟡 Đã có: lifecycle Platform Sign Up tồn tại trong ContactLifecycleEnum + nằm trong LOCKED_LIFECYCLE_VALUES (chip read-only khi đạt — §5.4c, hình 14). Chưa có: transition sang Platform Sign Up KHÔNG tạo/di chuyển deal record — deal pipeline chưa build (G2/§5.10), nên mắt xích sign-up→closed-won còn đứt. |
8 · GAP register hợp nhất
| # | GAP | Sev | Flow | Việc cần làm |
|---|---|---|---|---|
| G1 | Chặn cứng suppression trên mọi send path (mới có cảnh báo) | P0 | SDR.0.18 / SYS | Check lifecycle 🆘 trong sendEmailLog + campaign path → reject |
| G2 | Deal pipeline + handoff KAM chưa có | P0 | SDR.0.17 | Contract §5.10 to-be — viết được ngay |
| G3 | Re-entry rules nhóm 🥶 chưa chốt (blocker #7) — N follow-up đã gỡ: per-SDR (16/07) | P2 | OPS.0.6 | Lean: SDR rà tay filter 🥶; muốn tự động cần field "ngày đổi lifecycle" |
| G4 | Saved filters (công thức lọc → nút 1-click) | P1 | SDR.0.1 | 🔵 In Progress trong sprint — nicety trên model lọc-tự-do đã chạy |
| G5 v3 | Đóng | SDR.0.16 | — | |
| G6 | SDR chưa xem được Email Logs của mình | P1 | SDR.0.12 | Mở màn log read-only scope theo user |
| G7 | Reply sync UAT chập chờn → chưa verify được E2E | P1 | SDR.0.15 | Ổn định inbound sync rồi test lại |
| G8 | List approval gate | P1 | OPS.0.6 | Trạng thái draft/approved trên custom list |
| G9 | Role Ops riêng + permission "KAM calls-only" | P1 | ADM.0.1 | Phân quyền đang In Progress |
| G10 | Priority flag khi assign list | P2 | OPS.0.8 | Field nhỏ trên custom list |
| G11 | Work-through progress per list | P2 | SDR.0.25 | Suy từ count 3 filter ngăn |
| G12 | Subject chưa prefill khi compose | P2 | SDR.0.11 | Bug To Do — vẫn còn 19/07 |
| G13 v3 | SDR-list: behavior bypass + toast là chuẩn (chốt 19/07). Còn 2 lỗi cosmetic: form text nói "removes from other SDR lists" (sai — cần đổi thành skip) · Timeline ghi "Members added" cả khi add bị skip | P2 | SDR.1.20 | Sửa copy form + Timeline không ghi (hoặc ghi "skipped") khi bypass |
| G14 v3 | Đóng | SDR.0.1 | — | |
| G15 v3 | Member count lag dài trên list mới (0 >60s dù Members tab có người) | P2 | SDR.1.20 | Đẩy sync count hoặc đọc count từ members query |
| G16 v3 | Landing sau login của SDR/KAM là /projects (talent platform) thay vì workspace Sales | P2 | C0 | Default route theo role → /segments |
| G17 v3 | Đóng | SDR.0.16 | — | |
| G18 v3 | Drawer chi tiết Signal (click headline từ list) treo skeleton kéo dài (>30s chưa render, verify 19/07) — "+N" expand inline vẫn hoạt động tốt. [21/07] re-check: drawer render OK ~2s (BauWatch, list 17072026v3) + có action "Use this email content to reply" → compose prefill (§5.5); skeleton-hang không tái hiện lần này — cần verify lại trên signal data nặng hơn trước khi đóng | P2 | SDR.0.3 | Fix data-load của signal detail drawer |
| G20 | setCategorizeEmailLog chưa side-effect lifecycle contact (verified 16/07) | P1 | SDR.0.16 | BE thêm side-effect, hoặc FE gọi kèm updateLifecycleContactV2 |
| G21 | Toàn bộ Zoom stack (book/record/transcript/AI) | P0 | KAM.* | Chốt blockers #10 · #11 · #12 trước, rồi build |
| G19 v3 | Inbox "In Conversation" không nhất quán: SDR inbox + ME inbox đã ẩn chip, campaign inbox tab vẫn hiển thị (chốt với Đông 20/07 = BUG) | P2 | SDR.0.15 / campaign | Ẩn chip In Conversation ở campaign inbox tab cho khớp SDR/ME (inbox-v2-filter-toolbar.tsx) |
| # | Câu hỏi phải trả lời | Vì sao chặn | Chặn flow |
|---|---|---|---|
| #10 | Xin đồng ý ghi âm kiểu gì? (thông báo đầu call · disclaimer Zoom · điều khoản trong invite) + lưu bằng chứng đồng ý ở đâu, giữ recording bao lâu | Luật Đức §201 StGB coi ghi âm không được các bên đồng ý là phạm luật hình sự + GDPR; khách chủ yếu ở DACH → ràng buộc pháp lý, "deadline cứng nhất" | KAM.0.5 |
| #11 | Bộ giá trị kết quả cuộc gọi là gì? Đề xuất: won / lost / follow_up / no_show — có thêm not_now riêng không? | Output của AI, logic move deal và mọi báo cáo xây trên bộ enum này — đổi sau là phải migrate data | KAM.0.7 |
| #12 | Lấy transcript bằng đường nào? Transcript có sẵn của Zoom vs tự chạy ASR riêng (VD Whisper) | Đánh đổi chi phí · chất lượng tiếng Đức · privacy (audio có ra bên thứ ba?) · độ trễ — pipeline ingest khác hẳn nhau theo lựa chọn | KAM.0.6 |
Tin tốt: 3 blocker khác trong spec đã giải xong bằng verify thực tế — #6 inbound reply (UAT đã sync được reply của khách) · #14 lifecycle enum (schema có đủ nhãn) · #3 schema tokens (đã xác định assignedToIds).
9 · Kế hoạch đề xuất (phased)
- Saved/pinned filters (G4) — pin quick-filter cho từng trường hợp (signal mới · prospect) cho SDR; follow-up "im lâu" vẫn qua cột Last Contact (SDR không filter ngày). Xong bước này: SDR loop end-to-end trọn vẹn. Effort: nhỏ.
- Chặn cứng suppression (G1) — điều kiện an toàn để gửi thật ở quy mô. Effort: BE nhỏ, P0.
- Per-reply classify + Email Logs cho SDR (G5, G6) — hoàn thiện nhánh xử lý reply. G5 đang chạy sẵn trong sprint.
- Deal + handoff KAM (G2) — conversion event; mở khoá phase 3. Kèm KAM.0.2 (calls view = filter) và KAM.1.8 spec.
- Zoom stack (G21) — sau khi chốt #10/#11/#12. Tận dụng hạ tầng recording/transcript sẵn có của Interview/Meeting.
- Polish (G8–G12) — approval gate, role Ops, priority flag, progress, subject prefill.
10 · Phụ lục — nguồn
| Nguồn | Vai trò |
|---|---|
fintalent-osw-lean-launch.md.pdf (15/07) + fintalent-osw-lean-launch-23-flows.md | Spec gốc 23 flows + sub-steps + blockers |
target/uat-lean-flow-check.py → uat-lean-flow-check-results.json | Chạy thử từng bước qua GraphQL trên UAT 15/07 (23 PASS) |
target/uat-introspect-lean-ops.py → target/_lean_ops_shapes.json | Introspection schema UAT 16/07 — nguồn của mọi khối DTO input/output + sequence |
target/uat-lean-deep-check-260716.py → _lean_deep_check_260716.json · _lean_view_counts_260716.json | Vòng verify sâu 16/07 chiều: chạy thật 3 công thức view (369+3+26=398 ✓), test send→lifecycle (không tự nhảy), setCategorizeEmailLog (không side-effect), contact mới lifecycle rỗng |
21 ảnh capture LEAN-LAUNCH/*.png (16/07, admin-uat.fintalent.io) | Bằng chứng UI; ảnh 14–21 đã che thông tin khách thật; bản gốc trong _originals/ |
| Quyết định vận hành 16/07 (session với Đông) | ADM.0.7 = admin sửa info · "queue" = chính contact list (revised 22/07: SDR bị khoá "+ Add filter" → làm việc theo trường hợp, không dựng 3 view — §1.3) · Prospect→In Conversation tự động · follow-up = reply trong thread (SDR canh cột Last Contact) · OPS.0.6 = list rules/approval |
plans/reports/audit-260715-1424-uat-osw-lean-flows.md · fintalent-flows-260716.html · fintalent-report-260716.html | Audit UAT 15/07 · flow readiness · kanban window report |
[20/07] Verify team-coverage/different-owner + auto-chip + sort Country/City: đọc code FE (use-coverage-rows.ts, contacts-v2-in-different-owner.gql.ts) + probe API bằng token SDR (Battery Ventures 181/179/173/2 · EQT 3/3) + QA list tự tạo/tự xoá trên UAT + 2 ảnh v3s-120/121 | Nguồn của §5.4b, callout [20/07] §5.9 và Δ3 #9–#10 |
[20/07 re-check ~30 commit] Re-verify toàn bộ với FE develop @ 714f6e4cf2 (vs basis doc f71fed708f): 5 report source-audit (plans/reports/gt-260720-*.md) + edit-map (plans/reports/edit-map-260720-1920-*.md) + live walk admin + View-as-role SDR (menu trim, inbox filter set, Simple/Full, Country/City, link-WE) + ảnh v3s-140 (view-as-role). Enum recount từ generated/admin/graphql.ts: ContactLifecycleEnum=18, EmailLogCategorizeEnum=11. | Nguồn của Δ3 #11–#15 + mọi callout [20/07 re-check] |
Tài liệu song hành cho người dùng cuối (Admin/Ops/SDR): v4.html + bản riêng reps v4.html — hướng dẫn thao tác theo luồng công việc. Cập nhật tài liệu: sửa trực tiếp LEAN-LAUNCH/index-v3.html (v1/v2 giữ làm mốc). Mermaid render local qua mermaid.min.js (không cần mạng). In PDF: Ctrl/Cmd+P.