Lean Launch Guide · Full Flow · v3

Fintalent OSW — Lean Launch v3: SetupSegmentsSDR LoopKAM 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.

Re-check 20/07 — ~30 commit sau bản 19/07, đổi gì (chi tiết từng dòng ở §Δ3 và các flow)(1) View as role — admin preview UI của role khác (SDR/KAM…), eye ở sider profile, sessionStorage 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á

SDRsale tuyến đầu: gửi email chào hàng, nhận trả lời
KAMsale tuyến sau: khách quan tâm thì gọi Zoom chốt
Opsngười vận hành: chuẩn bị data, tạo list, giao việc
Account1 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)
Tóm cả máy trong 1 câuOps phát danh sách công ty cho SDR → SDR làm việc trên list được giao, quyết định gửi email cho từng contact theo trường hợp (chưa từng gửi · trả lời reply của khách · follow-up khi im lâu · vừa có signal mới) → gửi email tay → hệ thống tự ghi log + lifecycle tự nhảy → khách trả lời thì hệ thống tự dán nhãn reply, SDR đọc & set lifecycle contact → ai "Interested" thì chuyển KAM gọi Zoom chốt → AI nghe ghi âm và tự ghi kết quả (phase 3, chưa build).

Δ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
1Route + 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ũ
2Contacts 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
3SDR 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
4Inbox 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"
5SDR/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
6List 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
7Member 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
8Profile 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
Create list SDR
[v3] Create List — checkbox SDR list + rule text (thực tế behavior = skip, xem #3).
Thread chips
[v3] Conversations — chip classify per-message (Interested/Not Interested/Open).

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)

FlowCòn thiếu gì
OPS.0.6 List rulesList approval (draft/approved) + bộ hằng số N ngày · re-entry 🥶 chưa được sign-off
SDR.0.17 Handoff KAMDeal pipeline + handoff — build từ đầu (G2, P0); contract đề xuất đã viết sẵn ở §5.10
KAM.0.1–1.8 Call flowZoom 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ãnGiá trị lifecycleHệ quả trên contact list
✅ Đường tiếnInterested → Schedule call → Platform signup → ProjectsRời SDR loop, chuyển sang phase KAM / deal
🥶 Tạm dừngNot 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ứtOptedOut · Do Not Contact · Left The Company · Not InterestedSuppression — 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.

Đã xác nhận 16/07(1) Enum lifecycle trên UI đủ giá trị (ảnh 11). (2) "Đã connect" tự động khi send — nhưng qua date filter (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).
[21/07 verify live] 3 enum khác nhau — đừng lẫn: dropdown lifecycle vs ContactLifecycleEnum vs EmailLogCategorizeEnum (1) Dropdown lifecycle (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‖isRoleKamshowAddFilter=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 mailSDR 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áchInbox (SDR có đủ filter ở Inbox) — thread có reply ↙ inboundIn Conversation: Received a reply (bConversationSnapshot.syncDateRepliedEmailLog) · API 16/07: 3/3981 — trước tiên
D · Có signal mới sau email cuốiQuick-filter New signal since follow-up = Yes (pin sẵn — SDR dùng được)aHasSignalSinceReply:true (signalDate > lastEmailSentDate) — §5.12 — 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/scanIn 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 ContactLast 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.

Hệ quả của việc SDR bị khoá filter(1) Replies xử lý ở Inbox (nơi SDR có đủ filter), không lọc trên list. (2) Follow-up "im lâu" nhận biết bằng cột Last Contact / aging meter 💬, không phải filter ngày. (3) Saved filters (G4, 🔵) nếu ship = quick-filter pin thêm cho SDR — SDR vẫn không tự tạo filter. (4) Re-entry nhóm 🥶 (OPS.0.6) chưa chốt; N follow-up: mỗi SDR tự canh theo Last Contact (16/07).

1.4 · Readiness 23 flows (sau các quyết định 16/07)

Phase✅ Chạy được🟡 / 🔵 Gần xong❌ Chưa có
0 · SetupADM.0.1 · ADM.0.3 · ADM.0.7
1 · ListsSDR.1.20 · OPS.0.8OPS.0.6 (list rules/approval)
2 · SDR LoopSDR.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.18SDR.0.17 (handoff)
3 · KAMKAM.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:

OperationLoạiDùng cho flowTrạng thái
customLists / customListquerySDR.1.20 · SDR.0.25✅ PASS
createCustomListmutationSDR.1.20✅ PASS
addContactsCustomList / UpdateMmCompaniesCustomListV2mutationSDR.1.20 (add members)✅ PASS count có trễ Atlas sync vài giây
updateCustomList / bulkUpdateCustomList (assignedToIds)mutationOPS.0.8✅ PASS
customLists filter bAssignedToIdsqueryOPS.0.8 · SDR.0.25✅ PASS
selectAdminsqueryOPS.0.8 (picker, filter Roles)
mmCompaniesV2 / mmCompanyV2querySDR.0.3 (workspace)✅ PASS
contactsV2 / contactV2querySDR.0.3 · SDR.0.10 · views §1.3✅ PASS totalItems luôn 0 (bug) → đếm bằng contactV2Count
contactV2CountqueryĐếm số view §1.3 (thay totalItems)✅ PASS 16/07 — số khớp tuyệt đối
mmCompanyHistories / contactHistoriesquerySDR.0.3 (timeline chống double-touch)✅ PASS
sendEmailLogmutationSDR.0.11 · SDR.0.12✅ PASS bug mở: chưa prefill subject
emailLogs (kèm preEmailLogId thread, categorize)querySDR.0.12 · SDR.0.15✅ PASS
updateLifecycleContactV2mutationSDR.0.16 · SDR.0.18✅ PASS
EmailLogCategorizeEnum (11 giá trị: CallScheduled · DoNotContact · EmailUpdated · Interested · LeftTheCompany · NoCategories · NotInterested · NotNow · NotResponded · OptedOut · OutOfOffice — OOO = OutOfOffice)enumSDR.0.16 per-reply✅ auto-chip hiển thị per-reply setter tay + side-effect lifecycle = P1 (G5/G20)
setCategorizeEmailLogmutationSDR.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 / …CountquerySDR.0.10 team coverage (§5.4b)✅ verify 20/07 = contactsV2 bỏ scoping owner; FE bound = company + 5 lifecycle nóng
relinkWorkExperienceCompany / bulkRelinkContactsWorkExperienceCompany / findContactsWithLinkedAliasMatchmutation/queryADM.0.3 Relink WE (§3.2)✅ mới 20/07 thay Unlink; company-scoped + alias match ^alias$
EmailLogType (draft · replied · sentOut)enumSDR.0.15 conversation thread (hướng message) là HƯỚNG email, KHÁC categorize; chip lifecycle = ConversationEmailLog.lifecycle read-only
"View as role" previewclient-sideADM.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 / transcriptKAM.0.1–1.8❌ chưa có recording/transcript đã chạy cho Interview/Meeting, chưa nối KAM call
Shape input/output đã verify bằng introspection 16/07Toàn bộ khối DTO trong tài liệu lấy từ schema UAT thật qua target/uat-introspect-lean-ops.pytarget/_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ó

Users list
Hình 1 — Trang quản lý Admins. Cột Roles có badge SDR, KAM riêng (SDR Internal Fintalent, KAM Fintalent, SDR Fintalent) bên cạnh các role cũ (Admin, Super Admin, Data Manager…). Filter theo Roles ngay trên đầu trang.
Create user
Hình 2 — Modal Create new Admin. First/Last name, Email (req), Roles (req — dropdown chọn SDR/KAM), password tự generate.
SDR login
Hình 3 — Login bằng account SDR. SDR vào menu Sales Management và chỉ thấy list được giao (scope đúng).
#Step (spec)Thực tế UAT
1Admin tạo user account cho từng người modal Create new Admin (hình 2)
2Gán role SDR / KAM / Ops / Admin role SDR + KAM đã có; ❌ chưa có role Ops riêng (Ops đang dùng Admin/Sale Admin)
3Verify 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"
4User login thấy đúng phạm vi của mình hình 3 — SDR chỉ thấy 2 list được giao
[21/07 verify live] Bộ role + tầng APICột Roles của trang Admins có 7 role đã xác nhận live: SDR · KAM · Data Manager · Super Admin · Admin · Sale Admin · Campaign Manager (role là multi-select — 1 user có thể mang nhiều role). Tầng API: picker/list admin dùng 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:

Create contact
Hình 4 — Create Contact. Identifier = LinkedIn URL hoặc email (bắt buộc 1 trong 2 — chống trùng ngay từ input); First/Last name optional.
Ghi chú leanBulk import theo file (step 1–5 của spec) không cần lặp lại cho launch vì data đã nạp; dedupe đầy đủ theo data standards vẫn là backlog. Việc còn lại của flow này chỉ là bổ sung contact lẻ (hình 4).
Kiểm chứng UI 16–17/07 — 3 hành vi kỹ thuật của Create Contact (1) Dedupe UI: nhập email/LinkedIn đã tồn tại → mở panel "Duplicate Found — Step 2 of 2" hiển thị contact cũ để sửa tại chỗ (Back/Done), không tạo record — khớp API status 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)

// mutation createContactV2(input) → AdminCreateContactV2Result CreateContactV2Input { email? · linkedinUrl? // identifier — cần ≥1 (chống trùng ngay từ input) firstName? · lastName? · fullName? · title? · companyName? · phoneNumber? linkedCompanyId?: MongoID mmId?: Int addressCountry? · location? · emails?: [String!] force?: Boolean // true = bỏ qua cảnh báo trùng, merge vào contact khớp } AdminCreateContactV2Result { status*: CreateContactV2Status // CREATED · DUPLICATE_FOUND · MERGED contact: AdminContact winnerId?: String otherMatchedIds?: [String!] existingContacts?: [AdminContactDuplicatePreview{id, matchedBy, fullName, companyName, linkedinUrl, emails}] conflicts?: [AdminCreateContactV2Conflict{field, winnerValue, incomingValue}] } // status=DUPLICATE_FOUND ⇒ FE mở panel "Duplicate Found — Step 2/2", liệt kê existingContacts để sửa tại chỗ (không tạo record). // Bulk import file (step 1–5 spec) KHÔNG cần cho launch — data đã nạp sẵn; dedupe theo data-standards còn là backlog.

3.3 · ADM.0.7 — Admin cập nhật info cho SDR / KAM ✅ Có

Đổi nghĩa 16/07 (khác spec gốc)Spec gốc ADM.0.7 = "Configure sending domains + mailboxes" (SPF/DKIM). Vận hành lean chốt lại: Admin sửa thông tin của SDR/KAM (họ không tự sửa). Cấu hình gửi mail per-user (signature, sender name) nằm ngay trong màn này — hình 5. Phần sending domain/SPF/DKIM tổng thể do dev/infra quản, ngoài phạm vi flow.
Update admin info
Hình 5 — Update Admin Information · tab Email Configuration. Admin sửa cho từng SDR: Signature (rich text), Sender Name, file đính kèm mặc định. Các tab khác: General Setting · Billing · Payment · Activities Log.
[21/07 verify live] Update Admin drawer = 5 tab; field lưu ở Email ConfigurationDrawer "Update Admin Information" có 5 tab: General Setting · Billing · Payment · Activities Log · Email Configuration. Tab Email Configuration ghi thẳng vào record 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

Contacts filter
Hình 6 — /contacts với filter panel. Ops filter 15.459 contacts theo Lifecycles · In Conversation · Classification · Owner · Work Experience · Countries · Cities · Campaign · Last Email Sent Date · Custom List… rồi chọn và Add to list.
Add to list
Hình 7 — Drawer Add to list. Apply cho 20 đã chọn hoặc toàn bộ matching (cap 10.000/lần). Chọn list có sẵn hoặc tạo mới.
List detail
Hình 8 — List detail (16072026v1 · 419 members). Tab Members với đầy đủ bộ filter của /contacts áp trong phạm vi list — đây là surface Ops/Admin (đủ filter); SDR bị khoá còn quick-filter pin sẵn (§1.3); panel trái group theo Lifecycles/Companies.
#StepOperationKết quả 15/07
1Ops mở Lists V2customListsPASS
2Filter contacts đã importcontactsV2PASS
3Tạo list từ kết quả filtercreateCustomListPASS
4Add members + verify countaddContactsCustomListcustomList.memberCountPASS count trễ vài giây (Atlas sync)

DTO input/output — verify UAT 16/07

// mutation createCustomList(input) → AdminCustomListDetail CreateCustomListInput { name*: RequiredStringMax2kChars type*: CustomListTypeEnum // Contact | Company | Talent contactIds? · mmCompanyIds? · talentIds?: [MongoID] assignedToIds?: [MongoID] // giao SDR được ngay lúc tạo category?: CustomListCategoryEnum } // mutation addContactsCustomList(ids: [MongoID!]!, input) → [AdminCustomList!] AdminAddContactsCustomListInput { contactIds*: [MongoID!] } // Output AdminCustomListDetail (field chính) { id, name, type, category, assignedToIds, assignedTo[], memberCount, createdBy, createdDate }

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 viKết quả kiểm chứng 17/07 (UI, list QA)
Dedup khi add trùngIm 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 filterTí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 countSync 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 ở đâuDrawer 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 membersTick 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 barCó 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 persistenceGiữ 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 gateList 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 listChỉ ở 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 countLag nặng hơn với list mới (0 kéo dài >60s) — nguồn sự thật là Members tab + Timeline.
Manage lists
Hình 8b — Drawer "Manage lists for ‹contact›" (mở từ chip Lists trên row): checkbox từng list, + Create New, Save.
Campaign people
Hình 8c — Campaign · tab People. Bảng contacts + chip Add to list per-row — entry point E6.
Mapping GraphQLMọi entry point cuối cùng đều gọi 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ó

Assign owner
Hình 9 — Assign owner trên list. Picker filter được theo Roles: SDR — chọn đúng người theo role (điểm spec từng ghi "chưa ràng buộc role" nay đã có ở picker).
SDR my lists
Hình 10 — Góc nhìn SDR sau khi được giao. Login SDR Internal Fintalent → Lists chỉ hiện 2 list của mình (16072026v1 · 15072026v2).
#StepThực tế
1Ops chọn list
2Assign cho 1 SDR + priority flag assignedToIds (updateCustomList / bulkUpdateCustomList) — ❌ chưa có priority flag (P2)
3Members 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)
4Rep nhìn thấy list được giao hình 10 + filter bAssignedToIds PASS

DTO — assign qua assignedToIds (verify UAT 16/07)

// mutation updateCustomList(id: MongoID!, input) → AdminCustomListDetail — assign = set assignedToIds UpdateCustomListInput { name*, type*, assignedToIds?: [MongoID], contactIds?, category?, … } // bulkUpdateCustomList: adds/removes assignedToIds trên nhiều list một lần (PASS 15/07) // SDR đọc list của mình: query customLists(query{condition{bAssignedToIds…}, pagination}) → AdminCustomListPaginatedResponse

4.3 · OPS.0.6 — List rules (approval + hằng số vận hành) ❌ Chưa có

Đổi nghĩa 16/07"Queue rules" trong spec = list rules trong lean: (a) list approval — duyệt list trước khi giao cho SDR; (b) rule vận hành nằm trong định nghĩa 3 filter §1.3: re-entry rule cho nhóm 🥶 (VD Not Now → 3 tháng), thứ tự ưu tiên view. Riêng N ngày follow-up: mỗi SDR tự đặt theo lead (16/07), không cần chốt chung.

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ớichưa gửifollow-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.

[23/07 verify live] View-as-role SDR trên UAT — xác nhận trim + filter-lockPreview "Viewing UI as SDR" (admin dev@fintalent.io, sessionStorage 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.
SDR members filter bar (view-as role)
[23/07] View-as SDR — members bar của list 17072026v3. Banner "Viewing UI as SDR"; đúng 3 filter Lifecycles · Has signal · New signal since follow-up, KHÔNG "+ Add filter"; sidebar bị trim.

Tầng 1 — Menu Sales Management (SDR thấy 8 / 15)

Menu (Sales v2)Ops/AdminSDR/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

⚠️ View-as-role KHÔNG 100% trung thựcAdmin “view as SDR” khoá theo REAL role (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ạ:

Lifecycle filter
Hình 11 — Filter Lifecycles trong list member view. Dropdown đủ enum: Prospect · In Conversation · Interested · Not Now · Opted Out · Email Updated · Do Not Contact · Left The Company · Not Interested… (mỗi giá trị có include/exclude).
New prospects bucket
Hình 12 — Case A (chưa từng gửi). Lọc Lifecycles: Prospect trên list 419 members → 322 contacts chưa đụng vào.
Replies bucket
Hình 13 — View Replies. Filter In Conversation: Received a reply (Yes/No + Received a reply/No reply) → 7 contacts cần xử lý.
[21/07 verify live] Cơ chế panel filter Lifecycles + "+ Add filter"(1) Mỗi giá trị lifecycle trong panel = 1 checkbox + nút xanh ✓ (include) + nút đỏ ✕ (exclude) ⇒ chọn được cả 2 chiều "phải có nhãn X" / "loại nhãn X" trên cùng 1 dòng. Đầu panel có 2 radio Has a lifecycle / No lifecycle (lọc contact có/không mang bất kỳ nhãn nào) + ô search + ⊕ Paste list (dán loạt giá trị). (2) "+ Add filter" mở panel "Customize Quick Filters" — pin/reset từng filter vào thanh quick bar (checkbox per-filter, nút "Reset defaults", dòng đếm "N filters pinned to quick bar"); "Last Email" là cột, không phải filter trong registry.
[21/07 verify live] Thanh filter list-detail = Lifecycles + Has signal (signal có 2 CẤP)Sửa lại bản trước — "chỉ filter Lifecycles" (§Δ3 #5) là do stale localStorage view-pref giấu mất Has signal + cột Signals; build 21/07 thực tế: (1) Thanh filter members (phải, lọc CONTACT) = Lifecycles · Has signal · New signal since follow-up (filter thứ 3 mới — logic ở callout ngay dưới; verify live 23/07 KHÔNG "+ Add filter"). (2) Cột Signals của bảng members hiện 2 loại chip — ⚡ event (tin công ty: fund launch/acquisition…) vs 💼 job (đổi chức danh cấp cá nhân), kèm tuổi ("2mo", "+2") — cùng phân loại với §5.5 Way-4. (3) Rail company trái (khi expand ») 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.
[23/07 verify live] Filter thứ 3 — chip UI “New signal since follow-up” (trigger follow-up)Logic: lọc contact đã được gửi email rồi mà nay có signal MỚI xuất hiện SAU email cuối mình gửi (⚡ company event / 💼 job). KHÁC filter Has signal (chỉ hỏi “có signal hay không”, bất kể thời điểm) — cái này gắn mốc thời gian signalDate > lastEmailSentDate. UI (live 23/07, view-as SDR): chip trên members bar tên “New signal since follow-up” (label trong registry 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)

// Đếm: dùng contactV2Count(query) — LƯU Ý totalItems của contactsV2 luôn = 0 (bug BE đã biết) // Membership: bListIds — filter shape: GroupFieldFilterInput{operator, items:[FieldFilterInput{operator!, value:[JSON]}]} // "In Conversation" của UI = bConversationSnapshot (composite theo NGÀY, không phải lifecycle enum): ConversationDateV2FilterItemInput { syncDateRepliedEmailLog: [FieldFilterInput], lastEmailSentDate: [FieldFilterInput] } // === 3 trường hợp (logic API) — verify trên list 16072026v1 (398 members) — 369 + 29(=3+26) = 398 ✓ === // NEW PROSPECTS — chưa từng gửi → 369 { bListIds: {operator:"and", items:[{operator:"in", value:[listId]}]}, bConversationSnapshot: {operator:"and", items:[{ lastEmailSentDate: [{operator:"exists", value:[false]}] }]} } // REPLIES — đã có trả lời (ưu tiên 1) → 3 { bListIds: …, bConversationSnapshot: {operator:"and", items:[{ syncDateRepliedEmailLog: [{operator:"gte", value:["1980-01-01"]}] }]} } // FOLLOW-UPS — gửi rồi, im lặng quá N ngày (N mỗi SDR tự đặt) → 26 (N=0) { bListIds: …, bConversationSnapshot: {operator:"and", items:[{ syncDateRepliedEmailLog: [{operator:"exists", value:[false]}], lastEmailSentDate: [{operator:"lte", value:["(now − N)"]}] }]} }
⚠️ 2 điểm verify 16/07 chiều — chỉnh lại cách hiểu buổi sáng(1) "In Conversation" của UI là filter theo ngày gửi/reply (bConversationSnapshot), không phải lifecycle enum — vì vậy nó "tự động" đúng nghĩa: gửi xong 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)

// query customLists(query: CustomListQueryInput) → AdminCustomListPaginatedResponse{items:[AdminCustomList!], totalItems} CustomListConditionInput { bAssignedToIds: GroupFieldFilterInput // = [userId của SDR] ⇒ chỉ list assign cho mình type: CustomListTypeEnum // Contact keyword? · bMemberCount? · bCreatedBy? · bCreatedDate? } AdminCustomList { id · name · memberCount: Int · assignedToIds: [MongoID!] · assignedTo: [Admin!] category: CustomListCategoryEnum // SDR | KAM — "SDR" là giá trị cột Category, không phải badge riêng type: CustomListTypeEnum · contactIds: [MongoID!] · createdBy: MetadataUser } // ▸ FE lấy filter membership qua bAssignedToIds; count list qua totalItems của response này (KHÁC contactsV2 vốn trả totalItems=0).
[21/07 verify live] View-as-role scoping — SDR thấy 8/15 menuMenu Sales Management (v2) của admin đủ = 15 mục: Segments (Lists) · Inbox · Meetings · Contacts · Companies · Email Queues · Email Logs · Signals · Deals · Job Search · Message Templates · Snippets · Documents · Campaigns · Marketing Emails. View SDR (view-as-role hoặc SDR thật) ẩn 7 mục {Contacts · Companies · Email Queues · Email Logs · Signals · Deals · Job Search} ⇒ SDR còn 8 mục. Đây là lý do SDR chỉ tiếp cận list được giao + inbox, không đụng CRM tổng.

5.3 · SDR.0.3 — Account / Contact workspace ✅ Có · UAT 5/5 PASS ↔ User guide: Flow 2 (workspace)

Contact detail
Hình 14 — Contact detail. Panel trái: liên hệ, Lifecycle, Type, Owner, Lists, Emails. Giữa: Work Experience đầy đủ (company + badge Sponsor/Corporate). Tab: Overview · Timeline · Signals · Conversations (41).
#StepOperationKết quả 15/07
1Mở account từ contact listmmCompaniesV2PASS
2Company summarymmCompanyV2PASS summary + contacts/signals/deals count
3Contacts của companycontactsV2 (linked company)PASS
4–5Activity timeline (chống double-touch)mmCompanyHistories + contactHistoriesPASS

DTO — contact workspace (verify introspection)

// query contactV2(id: MongoID) → AdminContactV2Detail — nguồn cho panel detail của workspace AdminContactV2Detail { id · firstName · lastName · fullName · avatar · titles: [String!] · emails: [String!] lifeCycles: [String!] types: [String!] totalYearsOfExperience: Float // panel trái: Lifecycle · Type · Experience phoneNumber · addressCountry · addressCity · linkedinUrl // facts rail workExperiences: [AdminContactV2WorkExperience{company, position, from, to, ongoing, linkedCompany, emails}] labels: [String!] listIds: [String!] ownerIds: [String!] owners: [Admin!] customLists: [CustomList!] lastEmail: EmailLog conversationSnapshot: {lastSentDate, lastRepliedDate, firstRepliedDate, lastConversationDate} companies: [AdminMmCompanyV2Detail!] dataHealth: {tier, issues, missingFields} campaignInfos · marketingEmailInfos } // SDR/KAM: cụm Type/Owner/Lists/Emails bị ẩn (hideSalesFields) — xem §5.4c/§5.5.

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.

Tầng data của bảng members (verify introspection)Bảng member của list detail chạy trên 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ở).

Held by teammates
[20/07] QA thật trên UAT — list QA chỉ có 1 member (Shashank Jain, EQT). Chọn card EQT Group ở rail trái → chip "Viewing as EQT Group" + pill "2 held by teammates" + 2 dòng xám Salil Dias Sabir, Julia Zhu (đang nằm trong list của SDR Internal Fintalent / Omer / Admin 02 — lifecycle Platform Sign Up). Dấu ⚠ đỏ cạnh pill = cảnh báo anomaly (chỉ admin thấy): Shashank vừa là member vừa match coverage.

Điều kiện hiển thị — contact phải thỏa ĐỦ 4 điều kiện

Company X — ai HIỆN trong block "held by teammates"? (sau khi chọn company X ở rail trái)
A — member của list ĐANG MỞ (list bạn) · lifecycle bất kỳ
hiện — dòng MEMBER bình thường
B — 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 (= từ Interested trở lên trong funnel — chốt Đông 20/07)
✔ HIỆN — dòng xám "held by teammates" (view-only)
C — trong list người khác nhưng lifecycle Prospect / In Conversation / nhãn đóng (Not Interested, Opted Out…)
✘ không hiện — chưa tính là va chạm
D — KHÔNG thuộc list nào (hoặc chỉ thuộc list đã xoá)
✘ không bao giờ hiện — chưa ai giữ
Điều kiện company = có work experience ongoing đã link đúng company đang chọn. Query bị bound chặt (không chọn company → không fetch gì). Member của list đang mở bị dedup khỏi block. Verify UAT 20/07: Battery Ventures 181 người @company → coverage 173; 2 người ngoài list + 6 người chỉ còn list-đã-xoá không bao giờ hiện. [20/07 re-check FE] Coverage files (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ạnhKết quả verify
Nguồn dữ liệuQuery 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 listKhô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 queryRule "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ừ codecoverage.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 coverageRole sales: dòng bị khoá selection (view-only — biết để né, không thao tác được); click pill scroll xuống block.
Ý nghĩa trong flow SDR (quy trình đề xuất)Định email ai đó ở company X → chọn X ở rail trái: (1) block rỗng = company "sạch" → cứ gửi. (2) Block có người = đồng đội đang giữ contact ở stage nóng tại company này → đừng email ai ở company X vội: nhìn cột Owner/Lists của dòng xám để biết ai giữ → hỏi người đó trước (phối hợp hoặc nhường), hoặc chuyển sang company khác trong list. "Bỏ qua hay phối hợp" là quy ước team (giống handoff #5) — hệ thống chỉ hiển thị, không chặn. Khi deal pipeline chưa build (G2), block này chính là proxy "account đang có deal của ai".

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:

ActionVị tríSDR/KAMAdminEvidence
Open detailClick name/title → overlay contact-v2:detailungated
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 · LinkedInCột + icon commscomms 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✘ ẩncontact-actions-cell/index.tsx:56 hideMore={isRoleSdr‖isRoleKam}; MoreMenu render !hideMore (:244)
Update type · Assign ownerDetail 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
Lifecycle lock (chính xác)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àochố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)

SurfaceSDR/KAMEvidence (flag/file)
Segments pageChỉ xem list được giao; không Create/Assign/Actions/Created-By filter; lock tab Contactcustom-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)
DensitySimple khoá, không toggleuse-contact-view-mode.ts:92
InboxĐọc thread + reply/compose; đủ filterpage không role-gate
MeetingsView-only (HubSpot sync — không ai create)không có nút create
Message Templates · Snippets · DocumentsFull CRUD (không role gate); Snippet "Private" chỉ là tag hiển thị theo ownerId, FE không enforce owner-onlygrep isRoleSdr/Kam = 0 match trong 3 module
CampaignsCreate (trong folder, gate id!=='All') · edit/delete · Create Folder; KHÔNG Add peoplepeople-tab-v2/index.tsx:94 canAddContacts=!(isRoleSdr‖isRoleKam)
Marketing EmailsCreate/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

Compose
Hình 15 — Compose từ contact. From = mailbox SDR (finuat423@gmail.com), To prefill từ contact, có Snippets + Template. Bug mở (To Do): subject chưa prefill khi tạo mail mới.
Khảo sát bottom bar compose 17/07 — 4 công cụ 📎 upload file từ máy · 🗂 "Attach from document library" → modal Select Documents ([20/07] chỉ tab Common · Contact, Published only — xem callout dưới) — kho file chuẩn công ty, cùng nguồn với "Attach files" per-user ở §3.3; map vào 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.
[20/07 re-check] Compose "reply v2" (commit 73cde420f2) + document picker(a) Dock toggle left/right (nhớ preference qua đóng/mở). (b) Suggested recipients: người đã trong conversation xếp trên kết quả search ở dropdown To/Cc (dropdown mở khi gõ). Cc/Bcc vẫn ẩn. (c) Select Documents từ compose chỉ 2 tab Common + Contact (Published only) — kho chung vẫn có type Campaign nhưng compose KHÔNG offer (hard-scope 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.
[20/07 re-check #2] Send-mail — 2 cơ chế + full flow (verify source)2 nút "send" khác nhau: (A) floating composer (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).
[21/07] Entry path thứ 5 — compose prefill từ SignalNgoài profile "Send email" / list row ✉ / Conversations New·Reply·Forward, còn vào compose từ Signal. Cột Signals2 loại: ⚡ Signal (tin công ty — fund launch/acquisition/auction, vd "BauWatch ticks live…", "Wafra launches fund…") và 💼 Job (đổi chức danh/vai trò cấp cá nhân, vd "Associate Partner – M&A…"). Click headline signal detail → top-right "Use this email content to reply" (hoặc Copy email — EN/DE chỉ lấy text) → floating composer mở với body đã draft sẵn từ nội dung signal, To rỗng (SDR tự thêm contact), Subject theo bug "new" = rỗng. Chỉ dạng prefill email — 💼 Job không draft. User guide: SDR flows · Way 4.
[20/07] Contact detail — field sửa đượcTất cả qua 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)

Email logs
Hình 16 — Email Logs sau khi send (capture từ màn admin — xem gap dưới). Mỗi email gửi qua sendEmailLog tự sinh log: subject, from/to, content, sent date, source Task; tab All / Sent Out / Replied / Draft.
#StepThực tế
1Gửi email sendEmailLog PASS (gửi thật khi test có --send-to)
2Activity tự động log emailLogs ghi tự động — hình 16
3Account 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)
Gap quyền (P1)SDR chưa xem được Email Logs — hình 16 phải chụp từ account admin. SDR cần thấy log của chính mình để tự tránh double-touch. Đề xuất: mở màn Email Logs (read-only, scope = log của user) cho role SDR.

DTO — send & email log (verify UAT 16/07)

// mutation sendEmailLog(input) → Boolean AdminEmailLogSendEmailInput { from?: FintalentEmail // mailbox SDR (cấu hình ở §3.3) to*: [FintalentEmail!] cc? · bcc?: [FintalentEmail!] subject*: String content*: String stringContent*: String preEmailLogId?: MongoID // nối THREAD — chính là cơ chế reply / follow-up trong chuỗi cũ attachments?: [EmailLogAttachmentInput!] emailAliases?: [String!] } // query emailLogs(query{condition{emails · from · sentDate · categorizes…}, pagination}) → AdminEmailLogPaginatedResponse AdminEmailLog { id, subject, from, to[], sentDate, status, categorize, createdDate }

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

Đổi nghĩa 16/07 · cập nhật 22/07Không có scheduler đặt lịch. Follow-up = SDR reply tiếp trong thread khi contact đã im lâu. SDR nhận biết bằng cột Last Contact + aging meter 💬 (xám→vàng→đỏ; chấm đỏ = đến hạn) — KHÔNG lọc được theo ngày trên members bar (SDR khoá "+ Add filter", §1.3); "im bao lâu" mỗi SDR tự canh mắt theo Last Contact. Gửi tiếp → sent date reset → Last Contact về mới. Logic ngày ở dưới là tầng API/admin (nguồn sự thật), không phải filter SDR bấm.
Last email sent date filter
Hình 17 — Filter Last Email Sent Date (surface Ops/Admin). Chip ≥ 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

// Follow-up = gửi rồi mà khách IM LẶNG quá hạn — 3 điều kiện (chạy ở tầng API/admin; SDR nhận biết bằng cột Last Contact): 1. Lifecycles = In Conversation // đã từng gửi (lifecycle tự nhảy khi send) 2. In Conversation = No reply // LOẠI khách đã trả lời — reply xử lý ở Inbox 3. Last Email Sent Date ≤ hôm nay − N ngày // đo ĐỘ IM LẶNG: email cuối gửi đi đã đủ lâu chưa // N KHÔNG phải hằng số chung (quyết định 16/07): mỗi SDR tự đặt theo lead mình nhận — // chỉnh mốc ngày ngay trên filter. Gửi follow-up → sent date reset → contact lặn N ngày rồi tự nổi lại.

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.

Tầng API của cơ chế follow-up (verify introspection)Việc "gom contact đến hạn follow-up" không cần op riêng — chạy trên 2 field ngưỡng-ngày của 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

Reply thread
Hình 18 — Conversations của contact có reply. Thread 3 messages (gửi → khách trả lời → gửi tiếp); nhãn "Replied" trên message của khách; lifecycle đã set Not Interested sau khi đọc.
Reply in thread
Hình 19 — Reply ngay trong thread. Nút ↩ mở compose quote đầy đủ context — đây cũng chính là thao tác follow-up (§5.7).

Đã 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).

[20/07 re-check] Inbox v2 + InQueueTag(1) Inbox SDR (/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.
[21/07 verify live] Inbox filter Work Experience → Company type SponsorPanel Work Experience của Inbox có filter Company type (Sponsor/Corporate/Portfolio). Chọn Sponsor → encode 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

[20/07] Cơ chế nhãn per-reply trên build hiện tại — hệ thống TỰ ĐỌC NỘI DUNG reply để gán nhãnMỗi message trong Conversations/Inbox mang chip nhãn tự động: hệ thống đánh giá nội dung câu trả lời của khách rồi gán nhãn cho chính message đó. Quan sát thật trên thread QA (cùng 1 contact): reply "we have a long-standing exclusive arrangement… no need to follow up" → chip Not Interested; reply sau "Is your 48-hour shortlist offer still on the table?" → chip Interested; thread đã chốt lịch → Call Scheduled; không phân loại được → Open. SDR/KAM không sửa được chip (click chỉ mở/đóng message) và chip không tự đổi lifecycle contact — SDR vẫn classify cấp contact bằng dropdown lifecycle. Đọc đúng: chip = "hệ thống đọc reply giùm" (gợi ý + lịch sử quét nhanh cả thread), lifecycle contact = quyết định chính thức của SDR. Tầng data: nhãn nằm per-email-log — đúng field categorize/setCategorizeEmailLog đã introspect 16/07, nay BE đã có producer tự động.
[21/07 verify live] "Là reply hay không" — đọc HƯỚNG message, không đọc chipTell tin cậy = mũi tên hướng: ↙ inbound + tên contact = reply thật của khách; ↗ outbound + tên bạn = email bạn gửi đi. Tầng data = 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;
Auto chips per reply
[20/07] Nhãn tự động per-reply — cùng 1 contact: reply cũ chip Not Interested, reply mới chip Interested — hệ thống đọc nội dung TỪNG message và gán nhãn riêng cho message đó.
Classify lifecycle
Hình 20 — As-is: classify ở cấp CONTACT. Dropdown lifecycle ngay trên row: Prospect · In Conversation · Interested · Not Now · Opted Out · Email Updated · Do Not Contact · Left The Company · Not Interested.

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)

// AS-IS — classify cấp CONTACT: mutation updateLifecycleContactV2(id, input) → AdminContactDetail UpdateLifeCycleInputV2 { nextLifeCycles*: [ContactLifecycleEnum!], email?: FintalentEmail } // BE ĐÃ CÓ — classify cấp REPLY: mutation setCategorizeEmailLog(id, input) → AdminEmailLogDetail AdminSetCategorizeEmailLogInput { categorize?: EmailLogCategorizeEnum, contactId?: MongoID, campaignTargetId?: MongoID } // EmailLogCategorizeEnum (11): CallScheduled · Interested · NotInterested · DoNotContact · NotNow · // NoCategories · OutOfOffice · OptedOut · LeftTheCompany · EmailUpdated · NotResponded // ContactLifecycleEnum (18 — recount FE HEAD 20/07, KHÔNG có 'Replied'): thêm Open · Click · Bounce · Dropped · PlatformSignUp · InProject · ProjectCompleted · CallScheduled · NoCategories · OutOfOffice · NotResponded… // Row dropdown render 14 option (CONTACT_LIFECYCLE_OPTIONS): 9 nhãn tay + NoCategories · CallScheduled · PlatformSignUp · InProject · ProjectCompleted

Cơ chế classify thực tế — reply auto-classify (BE) · SDR set lifecycle contact (có khoá)

  1. 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.
  2. 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).
  3. 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).
  4. Lọc “chưa classify”: field categorizes của EmailLogConditionInput có sẵn → thêm tab/filter trong Inbox (P1, FE-only).
  5. 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)

Opted out contacts
Hình 21 — Suppression qua lifecycle. Filter Lifecycles: Opted Out (+1) → các contact Opted Out / Do Not Contact với badge đỏ ngay trên row.
#StepThực tế
1Classify OptedOut / DNC set lifecycle PASS (hình 21)
2Suppression flag ghi nhận flag = lifecycle; 3 filter ngăn §1.3 tự loại nhóm 🆘
3Mọ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)
// Đặt suppression = set lifecycle (verify introspection): mutation updateLifecycleContactV2(id, input) → AdminContactDetail UpdateLifeCycleInputV2 { nextLifeCycles*: [ContactLifecycleEnum!], email?: FintalentEmail } // SDR chọn OptedOut / DoNotContact / LeftTheCompany / NotInterested ⇒ contact vào nhóm 🆘. // Constant FE CONTACT_DO_NOT_CONTACT_LIFECYCLE = tập nhãn 🆘 dùng để CẢNH BÁO campaign health.
Gap chặn cứng vẫn mở (G1 · P0) — nêu rõ ranh giới đã-có / chưa-cóĐÃ CÓ: (1) set nhãn 🆘 bằng 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:

FlowSteps (spec)Mức sẵn sàng
KAM.0.2 Calls list trong ngàyMở danh sách calls của mình → prep = mở workspaceViết spec được ngay — lean: filter lifecycle = Schedule call + owner = tôi, cùng pattern §1.3
KAM.0.1 Book call qua Zoom APITừ deal Interested → book → chọn giờ, gửi invite → call xuất hiện trong calls listKẹt hạ tầng Zoom — phụ thuộc SDR.0.17 (deal) trước
KAM.0.5 Call + ghi âmThực hiện call → recordingKẹ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 → memoryNhận recording → transcript → lưu account memoryKẹt quyết định ⚠️ blocker #12: Zoom native vs ASR riêng. Transcript ingestion đã chạy cho interview/meeting
KAM.0.7 AI classify outcomeAI đọc transcript → {outcome, why, confidence} → move deal → low confidence = needs_reviewKẹt quyết định ⚠️ blocker #11: chốt enum won / lost / follow_up / no_show
KAM.1.8 Review outcome AI flagMở danh sách needs_review → confirm/sửa → lưu làm eval dataViế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;
Nguyên tắc tài liệu cho phần chưa buildFlow đủ điều kiện (KAM.0.2, KAM.1.8, SDR.0.17) → viết contract chi tiết để dev cầm là code được. Flow kẹt quyết định (KAM.0.1/0.5/0.6/0.7) → chỉ ghi steps + Decision needed (ai chốt, chặn gì), tránh viết mò rồi sửa lại.

7 · SYS behaviors (chạy ngầm)

SYSBehaviorTrạng thái theo model mới
SYS.1Queue computation (tên spec)✅ thay bằng filter §1.3 — deterministic từ lifecycle + sent date; không cần materialize
SYS.3Activity auto-log khi send emailLogs (hình 16)
SYS.6Queue exit sau action (tên spec)✅ tự vận hành — lifecycle nhảy + sent date reset (§5.6)
SYS.7Single-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.8Sign-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

Bug (defect) tách riêngDanh sách lỗi đã build nhưng sai/thiếu (subject prefill, SDR-list copy, campaign inbox chip, member-count lag, signal skeleton, suppression chưa chặn cứng…) nay gom vào file riêng: bugs-v3.html. Bảng dưới đây là GAP + flow chưa build (list approval, deal/handoff, KAM, Zoom) — giữ nguyên để theo dõi readiness.
#GAPSevFlowViệc cần làm
G1Chặn cứng suppression trên mọi send path (mới có cảnh báo)P0SDR.0.18 / SYSCheck lifecycle 🆘 trong sendEmailLog + campaign path → reject
G2Deal pipeline + handoff KAM chưa cóP0SDR.0.17Contract §5.10 to-be — viết được ngay
G3Re-entry rules nhóm 🥶 chưa chốt (blocker #7) — N follow-up đã gỡ: per-SDR (16/07)P2OPS.0.6Lean: SDR rà tay filter 🥶; muốn tự động cần field "ngày đổi lifecycle"
G4Saved filters (công thức lọc → nút 1-click)P1SDR.0.1🔵 In Progress trong sprint — nicety trên model lọc-tự-do đã chạy
G5 v3Classify per-reply UX trong InboxĐÓNG (chốt 19/07): reply auto-classify (BE tự gán chip), SDR KHÔNG classify reply → không build UX classify tay; SDR chỉ set lifecycle cấp contact (có khoá 3 stage — §5.4c)ĐóngSDR.0.16
G6SDR chưa xem được Email Logs của mìnhP1SDR.0.12Mở màn log read-only scope theo user
G7Reply sync UAT chập chờn → chưa verify được E2EP1SDR.0.15Ổn định inbound sync rồi test lại
G8List approval gateP1OPS.0.6Trạng thái draft/approved trên custom list
G9Role Ops riêng + permission "KAM calls-only"P1ADM.0.1Phân quyền đang In Progress
G10Priority flag khi assign listP2OPS.0.8Field nhỏ trên custom list
G11Work-through progress per listP2SDR.0.25Suy từ count 3 filter ngăn
G12Subject chưa prefill khi composeP2SDR.0.11Bug To Do — vẫn còn 19/07
G13 v3SDR-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ị skipP2SDR.1.20Sửa copy form + Timeline không ghi (hoặc ghi "skipped") khi bypass
G14 v3SDR list detail chỉ có filter LifecyclesĐÓNG (sửa 21/07): members bar thực tế có Lifecycles + Has signal + "+ Add filter" (Customize Quick Filters); "chỉ Lifecycles" trước đây là do stale localStorage view-pref (§5.1). Lọc follow-up theo ngày dùng Inbox (Flow 5) hoặc saved filters (G4)ĐóngSDR.0.1
G15 v3Member count lag dài trên list mới (0 >60s dù Members tab có người)P2SDR.1.20Đẩy sync count hoặc đọc count từ members query
G16 v3Landing sau login của SDR/KAM là /projects (talent platform) thay vì workspace SalesP2C0Default route theo role → /segments
G17 v3Per-message chip cần setter cho SDRĐÓNG (chốt 19/07): chip do hệ thống tự đánh giá reply để xếp lifecycle, SDR không chỉnh là by-design. G5 (classify per-reply UX) theo đó cũng đóng theo hướng auto-classification.ĐóngSDR.0.16
G18 v3Drawer 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 đóngP2SDR.0.3Fix data-load của signal detail drawer
G20setCategorizeEmailLog chưa side-effect lifecycle contact (verified 16/07)P1SDR.0.16BE thêm side-effect, hoặc FE gọi kèm updateLifecycleContactV2
G21Toàn bộ Zoom stack (book/record/transcript/AI)P0KAM.*Chốt blockers #10 · #11 · #12 trước, rồi build
G19 v3Inbox "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)P2SDR.0.15 / campaignẨn chip In Conversation ở campaign inbox tab cho khớp SDR/ME (inbox-v2-filter-toolbar.tsx)
3 quyết định phải chốt trước khi ship phase 3 (blockers)Đây không phải task code — là 3 câu hỏi mà người có thẩm quyền (sếp / legal / Ops) phải trả lời trước; chưa trả lời thì dev build kiểu gì cũng phải làm lại.
#Câu hỏi phải trả lờiVì sao chặnChặn flow
#10Xin đồ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âuLuậ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
#11Bộ 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 dataKAM.0.7
#12Lấ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ọnKAM.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)

  1. 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ỏ.
  2. Chặn cứng suppression (G1) — điều kiện an toàn để gửi thật ở quy mô. Effort: BE nhỏ, P0.
  3. 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.
  4. Deal + handoff KAM (G2) — conversion event; mở khoá phase 3. Kèm KAM.0.2 (calls view = filter) và KAM.1.8 spec.
  5. 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.
  6. Polish (G8–G12) — approval gate, role Ops, priority flag, progress, subject prefill.

10 · Phụ lục — nguồn

NguồnVai trò
fintalent-osw-lean-launch.md.pdf (15/07) + fintalent-osw-lean-launch-23-flows.mdSpec gốc 23 flows + sub-steps + blockers
target/uat-lean-flow-check.pyuat-lean-flow-check-results.jsonChạy thử từng bước qua GraphQL trên UAT 15/07 (23 PASS)
target/uat-introspect-lean-ops.pytarget/_lean_ops_shapes.jsonIntrospection 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.jsonVò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.htmlAudit 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/121Nguồ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.