From 851512df5f2a53e981cab8c7995d3800b8406480 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:32:27 +0000 Subject: [PATCH 1/2] fix(lead): a rep's own lead no longer tells them it awaits assignment lead_assignment alerts the lead's owner, but crm.lead_routing_new read "New lead to assign: ... is awaiting assignment" and crm.lead_routing_hot "Hot lead - assign within 24h ... needs an owner today": an assigner's message sent to the person the lead was already assigned to. A write that carries a user is stamped with that user as owner, so every lead a rep created, from the form or through the API, sent that rep the message. The standard gate e10 now also asks who put the lead in the owner's name: it alerts only when created_by is blank, absent or a different person (the round-robin's pick on web-to-lead or a user-less write, an integration's named owner, a colleague logging it for them). The hot gate e8 is unchanged on purpose: the hot alert is the 24h follow-up SLA and the rating may be the system's own score. Both templates now address the owner in all four locales ("New lead assigned to you", "Hot lead - follow up within 24h"). Nobody who assigns leads is a messaging audience, so no "awaiting assignment" alert is sent at all. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude --- ...73-lead-routing-alert-to-assigned-owner.md | 9 ++ content/docs/administration/automation.mdx | 2 +- .../administration/automation.zh-Hans.mdx | 2 +- .../administration/automation.zh-Hant.mdx | 2 +- content/docs/sales/leads.mdx | 13 ++- content/docs/sales/leads.zh-Hans.mdx | 13 ++- content/docs/sales/leads.zh-Hant.mdx | 13 ++- src/sales/email-templates/en.ts | 16 +-- src/sales/email-templates/es-ES.ts | 8 +- src/sales/email-templates/ja-JP.ts | 8 +- src/sales/email-templates/zh-CN.ts | 8 +- src/sales/flows/lead-assignment.flow.ts | 48 +++++++-- test/flow-record-change.test.ts | 98 ++++++++++++++++++- test/hot-lead-threshold-parity.test.ts | 4 +- 14 files changed, 200 insertions(+), 44 deletions(-) create mode 100644 .changeset/2073-lead-routing-alert-to-assigned-owner.md diff --git a/.changeset/2073-lead-routing-alert-to-assigned-owner.md b/.changeset/2073-lead-routing-alert-to-assigned-owner.md new file mode 100644 index 000000000..cbe61cdec --- /dev/null +++ b/.changeset/2073-lead-routing-alert-to-assigned-owner.md @@ -0,0 +1,9 @@ +--- +"hotcrm": patch +--- + +**A lead you create for yourself no longer sends you an "awaiting assignment" alert.** New-lead routing alerts the lead's owner, but its standard alert read *"New lead to assign: … is awaiting assignment"*, an assigner's message sent to the person the lead was already assigned to. Every lead a rep created, from the form or through the API, therefore told that same rep the lead was waiting to be assigned. + +Now the standard alert reads *"New lead assigned to you"*, and it is sent only when someone else put the lead in the owner's name: the round-robin gave them a web-to-lead enquiry, an integration named them as owner, or a colleague logged the lead for them. A lead you create for yourself sends you no standard alert. Its three-day follow-up date is still set, and it is in **My Leads**. + +The hot-lead alert (4 stars or better) now reads *"Hot lead — follow up within 24h"* instead of *"Hot lead — assign within 24h … needs an owner today"*. It still goes to the owner whoever created the lead, by inbox and email, because the rating may be the system's own score. Both alerts are reworded in all four languages. A lead with no owner still gets its follow-up date and no alert. diff --git a/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index 9148f0435..82fab1b1b 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -65,7 +65,7 @@ A flow fires one of three ways, set by its start node: | **Escalate Case** | Screen | Collect an escalation reason, then flag and re-prioritise the case | | **Stamp Case Escalation** | Subflow | Called by **Escalate Case** to write the escalation stamps with system privilege | | **Close Case** | Screen | Collect the resolution, then close the case and stop the SLA clock | -| **New Lead Routing & SLA** | Record change (insert) | Stamp a rating-based follow-up SLA and alert the new lead's owner | +| **New Lead Routing & SLA** | Record change (insert) | Stamp a rating-based follow-up SLA and alert the lead's owner (for a standard lead, only when someone else assigned it) | | **Contact Welcome** | Record change (insert) | Prompt the owner to welcome a newly created contact | | **Urgent Task Alert** | Record change (insert) | Notify the owner when a task is created at *Urgent* | | **Large Deal Approval** | Record change (update) | Tiered sign-off via approval nodes — Sales Manager ≥ $100K, Sales Director > $500K | diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index c506be2b8..87770982c 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -65,7 +65,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 | **升级工单** | 屏幕 | 收集升级原因,然后标记工单并调整其优先级 | | **标记工单升级** | 子流程 | 由**升级工单**调用,以系统权限写入升级标记 | | **关闭工单** | 屏幕 | 收集处理结论,然后关闭工单并停掉 SLA 计时 | -| **新线索路由与 SLA** | 记录变更(插入) | 按评级写入跟进 SLA,并提醒新线索的负责人 | +| **新线索路由与 SLA** | 记录变更(插入) | 按评级写入跟进 SLA,并提醒线索负责人(标准线索只在别人分配时提醒) | | **联系人欢迎** | 记录变更(插入) | 新建联系人后,提醒负责人去做首次问候 | | **紧急任务提醒** | 记录变更(插入) | 任务以 *Urgent* 创建时通知其负责人 | | **大额商机审批** | 记录变更(更新) | 通过 approval 节点分级签核 —— 销售经理 ≥ $100K,销售总监 > $500K | diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index 557c75cdd..6b6f8d7b4 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -67,7 +67,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 | **升級工單** | 螢幕 | 收集升級原因,然後標記工單並調整其優先級 | | **標記工單升級** | 子流程 | 由**升級工單**呼叫,以系統權限寫入升級標記 | | **關閉工單** | 螢幕 | 收集處理結論,然後關閉工單並停掉 SLA 計時 | -| **新線索路由與 SLA** | 記錄變更(插入) | 按評級寫入跟進 SLA,並提醒新線索的負責人 | +| **新線索路由與 SLA** | 記錄變更(插入) | 按評級寫入跟進 SLA,並提醒線索負責人(標準線索只在別人指派時提醒) | | **聯絡人歡迎** | 記錄變更(插入) | 新建聯絡人後,提醒負責人去做首次問候 | | **緊急任務提醒** | 記錄變更(插入) | 任務以 *Urgent* 建立時通知其負責人 | | **大額商機審批** | 記錄變更(更新) | 透過 approval 節點分級簽核 —— 銷售經理 ≥ $100K,銷售總監 > $500K | diff --git a/content/docs/sales/leads.mdx b/content/docs/sales/leads.mdx index 63dc00993..cb7ca4db9 100644 --- a/content/docs/sales/leads.mdx +++ b/content/docs/sales/leads.mdx @@ -84,11 +84,18 @@ New-lead routing reads the rating once, as the lead lands: | Rating | Next Follow-up Date it stamps | Alert to the lead's owner | | --- | --- | --- | -| **4 stars or better** | **Tomorrow** | *"Hot lead — assign within 24h"*, sent to both the **inbox and email**, flagged as a warning | -| **Everything else**, including a lead with no rating at all | **Three days out** | *"New lead to assign"*, **inbox only** | +| **4 stars or better** | **Tomorrow** | *"Hot lead — follow up within 24h"*, sent to both the **inbox and email**, flagged as a warning | +| **Everything else**, including a lead with no rating at all | **Three days out** | *"New lead assigned to you"*, **inbox only**, and only when someone else gave you the lead | Four stars is the whole of the hot threshold: a 5-star lead is routed exactly like a 4-star one, and nothing separate happens at five. +Both alerts go to the lead's owner and tell them the lead is theirs to follow up. Who gets the standard one depends on who put the lead in your name: + +- **Someone else did** — the round-robin gave you a web-to-lead enquiry, an integration named you, or a colleague logged the lead for you. You get *"New lead assigned to you"*. +- **You did** — you created the lead yourself, from the form or through the API. You get no standard alert: the follow-up date is on the lead, and it is in **My Leads**. + +A hot lead alerts its owner either way, because the rating is the system's verdict, not necessarily yours: a lead created without a rating is scored on the spot. A lead nobody owns gets its follow-up date and no alert, because HotCRM has no lead queue to tell. + > The weights above live in the lead's automation code, not in a setup screen — see the admin tips at the end of this page. ## Duplicate leads @@ -171,7 +178,7 @@ The Leads list curates no tab row, so the switcher offers every saved view there | --- | --- | | **All Leads** *(the landing view)* | Every lead, newest first, with the name as the link into the record plus company, email, status, score, source and owner. 25 rows a page, export to CSV or XLSX, and, for users who may enroll campaign members, **Add to Campaign** on the selection toolbar. On an empty org it says so — *No Leads Yet · Get started by creating your first lead*. | | **My Leads** | The leads you own, highest star rating first and newest within a rating. Also reachable from **My Work › My Leads** in the sidebar. | -| **🔥 Hot Leads** | Leads still **New** or **Contacted** rated **4 stars or better** — exactly the leads new-lead routing calls hot, gives a next-day follow-up date and alerts their owner about ("assign within 24h"). Ordered by **Next Follow-up Date**, soonest first, with phone, email and owner on the row so it can be worked top to bottom. | +| **🔥 Hot Leads** | Leads still **New** or **Contacted** rated **4 stars or better** — exactly the leads new-lead routing calls hot, gives a next-day follow-up date and alerts their owner about ("follow up within 24h"). Ordered by **Next Follow-up Date**, soonest first, with phone, email and owner on the row so it can be worked top to bottom. | | **High Priority** | The same population — **4 stars and up**, still New or Contacted — laid out to scan rather than to work through: no follow-up date, but the lead source on the row. | | **Suspected Duplicates** | The review queue described above: leads whose **Duplicate Status** is *Suspected*, oldest suspicion first, with the duplicate-link fields on the row. Working it drains it — a lead set to *Confirmed* or cleared drops out. When there is nothing to review it says *No Suspected Duplicates · Nothing to review — every re-captured email has been checked.* | | **Lead Pipeline** | The kanban board, one column per status, each column summing **Annual Revenue**. A card is titled by the person's name, with the company, email and star rating below it. Opening a card slides the lead in from the side rather than taking you off the board. | diff --git a/content/docs/sales/leads.zh-Hans.mdx b/content/docs/sales/leads.zh-Hans.mdx index 025af5371..3b4e8fe71 100644 --- a/content/docs/sales/leads.zh-Hans.mdx +++ b/content/docs/sales/leads.zh-Hans.mdx @@ -84,11 +84,18 @@ description: 在入站咨询成为真正交易之前对其进行捕获、评分 | 评级 | 盖的下次跟进日期 | 给线索负责人的提醒 | | --- | --- | --- | -| **四星及以上** | **明天** | *"Hot lead — assign within 24h"*,**站内收件箱和邮件**两个通道都发,标为警告级 | -| **其余全部**,包括完全没有评级的线索 | **三天后** | *"New lead to assign"*,**仅站内收件箱** | +| **四星及以上** | **明天** | *"Hot lead — follow up within 24h"*,**站内收件箱和邮件**两个通道都发,标为警告级 | +| **其余全部**,包括完全没有评级的线索 | **三天后** | *"New lead assigned to you"*,**仅站内收件箱**,且只在线索是别人交给你时才发 | 热门的门槛就只有四星这一道:五星线索的路由与四星完全相同,满五星不会另外触发任何事。 +两种提醒都发给线索负责人,告诉对方这条线索归他跟进。标准提醒发不发,要看是谁把线索记到你名下: + +- **别人** —— 轮询分配把一条 Web-to-Lead 咨询分给了你、某个集成写明了你,或者同事替你录入了这条线索。你会收到 *"New lead assigned to you"*。 +- **你自己** —— 线索是你自己创建的,无论通过表单还是 API。你不会收到标准提醒:跟进日期已经写在线索上,它也在**我的线索**里。 + +热门线索无论如何都会提醒负责人,因为评级是系统的判断,不一定是你填的:创建时没有评级的线索会当场打分。没有负责人的线索只盖跟进日期、不发提醒,因为 HotCRM 没有可以通知的线索队列。 + > 上面这些权重写在线索的自动化代码里,而不是某个设置界面——参见本页末尾的「给管理员的提示」。 ## 重复线索 @@ -159,7 +166,7 @@ Copilot 会推荐下一步行动(例如,安排通话、发送跟进、取消 | --- | --- | | **全部线索**(默认打开) | 所有线索,最新的在前;姓名是进入记录的链接,同行还有公司、邮箱、状态、评分、来源和负责人。每页 25 行,可导出 CSV / XLSX;可以加入营销活动成员的用户勾选后可批量**加入营销活动**。组织里还没有线索时它会直说——*还没有线索 · 创建第一条线索,从这里开始*。 | | **我的线索** | 你负责的线索,评分高的在前,同评分内最新的在前。侧边栏的**我的工作 › 我的线索**进的也是它。 | -| **🔥 高热度线索** | 仍处于**新建**或**已联系**、评分**四星及以上**的线索——正是新线索路由判为热、给出次日跟进日期、并向负责人发出「24 小时内认领」告警的那一批。按**下次跟进日期**由近及远排序,行上带电话、邮箱和负责人,从上往下打就行。 | +| **🔥 高热度线索** | 仍处于**新建**或**已联系**、评分**四星及以上**的线索——正是新线索路由判为热、给出次日跟进日期、并向负责人发出「24 小时内跟进」告警的那一批。按**下次跟进日期**由近及远排序,行上带电话、邮箱和负责人,从上往下打就行。 | | **高优先级** | 同一批线索(**四星及以上**,同样只看新建和已联系),只是摆成一张速览表而不是工作队列:不带跟进日期,行上给出线索来源。 | | **疑似重复线索** | 上文说的复核队列:**重复状态**为*疑似重复*的线索,最早被怀疑的排在最前,重复关联字段就在行上。处理一条少一条——确认或清除之后它就掉出队列。没有待复核的时候它会写明:*没有疑似重复 · 无需复核——每个重复录入的邮箱都已检查过。* | | **线索流水线** | 看板视图,一个状态一列,每列汇总**年营收**。卡片以姓名为标题,下面是公司、邮箱和星级评分。点开卡片是从侧边滑出的抽屉,不会把你带离看板。 | diff --git a/content/docs/sales/leads.zh-Hant.mdx b/content/docs/sales/leads.zh-Hant.mdx index 89131223f..712f0aaa1 100644 --- a/content/docs/sales/leads.zh-Hant.mdx +++ b/content/docs/sales/leads.zh-Hant.mdx @@ -86,11 +86,18 @@ description: 在入站詢問成為真正交易之前對其進行擷取、評分 | 評級 | 蓋的下次跟進日期 | 給線索負責人的提醒 | | --- | --- | --- | -| **四星及以上** | **明天** | *"Hot lead — assign within 24h"*,**站內收件匣和郵件**兩個通道都發,標為警告級 | -| **其餘全部**,包括完全沒有評級的潛在客戶 | **三天後** | *"New lead to assign"*,**僅站內收件匣** | +| **四星及以上** | **明天** | *"Hot lead — follow up within 24h"*,**站內收件匣和郵件**兩個通道都發,標為警告級 | +| **其餘全部**,包括完全沒有評級的潛在客戶 | **三天後** | *"New lead assigned to you"*,**僅站內收件匣**,且只在潛在客戶是別人交給你時才發 | 熱門的門檻就只有四星這一道:五星線索的路由與四星完全相同,滿五星不會另外觸發任何事。 +兩種提醒都發給潛在客戶的負責人,告訴對方這筆潛在客戶歸他跟進。標準提醒發不發,要看是誰把潛在客戶記到你名下: + +- **別人** —— 輪詢指派把一筆 Web-to-Lead 詢問分給了你、某個整合寫明了你,或者同事替你建立了這筆潛在客戶。你會收到 *"New lead assigned to you"*。 +- **你自己** —— 潛在客戶是你自己建立的,無論透過表單還是 API。你不會收到標準提醒:跟進日期已經寫在潛在客戶上,它也在**我的線索**裡。 + +熱門潛在客戶無論如何都會提醒負責人,因為評級是系統的判斷,不一定是你填的:建立時沒有評級的潛在客戶會當場評分。沒有負責人的潛在客戶只蓋跟進日期、不發提醒,因為 HotCRM 沒有可以通知的潛在客戶佇列。 + > 上面這些權重寫在潛在客戶的自動化程式碼裡,而不是某個設定介面——參見本頁末尾的「給管理員的提示」。 ## 重複的潛在客戶 @@ -161,7 +168,7 @@ Copilot 會推薦下一步行動(例如,安排通話、發送跟進、取消 | --- | --- | | **全部線索**(預設打開) | 所有線索,最新的在前;姓名是進入記錄的連結,同列還有公司、郵箱、狀態、評分、來源和負責人。每頁 25 列,可匯出 CSV / XLSX;可以加入行銷活動成員的使用者勾選後可批次**加入行銷活動**。組織裡還沒有線索時它會直說——*還沒有線索 · 建立第一條線索,從這裡開始*。 | | **我的線索** | 你負責的線索,評分高的在前,同評分內最新的在前。側邊欄的 **My Work › My Leads** 進的也是它。 | -| **🔥 高熱度線索** | 仍處於**新建**或**已聯絡**、評分**四星及以上**的線索——正是新線索路由判為熱、給出次日跟進日期、並向負責人發出「24 小時內認領」告警的那一批。按**下次跟進日期**由近及遠排序,列上帶電話、郵箱和負責人,從上往下打就行。 | +| **🔥 高熱度線索** | 仍處於**新建**或**已聯絡**、評分**四星及以上**的線索——正是新線索路由判為熱、給出次日跟進日期、並向負責人發出「24 小時內跟進」告警的那一批。按**下次跟進日期**由近及遠排序,列上帶電話、郵箱和負責人,從上往下打就行。 | | **高優先級** | 同一批線索(**四星及以上**,同樣只看新建和已聯絡),只是擺成一張速覽表而不是工作佇列:不帶跟進日期,列上給出線索來源。 | | **疑似重複線索** | 上文說的複核佇列:**重複狀態**為*疑似重複*的線索,最早被懷疑的排在最前,重複關聯欄位就在列上。處理一條少一條——確認或清除之後它就掉出佇列。沒有待複核的時候它會寫明:*沒有疑似重複 · 無需複核——每個重複錄入的郵箱都已檢查過。* | | **線索流水線** | 看板視圖,一個狀態一欄,每欄彙總**年營收**。卡片以姓名為標題,下面是公司、郵箱和星級評分。點開卡片是從側邊滑出的抽屜,不會把你帶離看板。 | diff --git a/src/sales/email-templates/en.ts b/src/sales/email-templates/en.ts index dbb90875c..27de4e032 100644 --- a/src/sales/email-templates/en.ts +++ b/src/sales/email-templates/en.ts @@ -9,10 +9,10 @@ import type { EmailTemplatePack } from './_bundle'; * the same convention `src/sales/translations/` already follows for the UI * locale packs. * - * Every string here is the text its `notify` node already sent, with the + * Every string here began as the text its `notify` node already sent, with the * flow's `{var.path}` interpolations re-expressed as the `{{key}}` holes the * renderer fills from that node's `templateData`. A conversion, not a rewrite: - * no notification says anything new. + * no notification said anything new. A text changed since says why beside it. * * ⚠️ Registered under `en-US`, not `en` — see the ladder note on `bundle()`. */ @@ -57,15 +57,19 @@ export const en: EmailTemplatePack = { subject: 'Lead converted: {{first_name}} {{last_name}}', bodyHtml: '

Lead {{first_name}} {{last_name}} was converted into an account and contact.

', }, + // The two routing alerts go to the lead's OWNER (`lead_assignment`), so they + // say the lead is theirs to follow up. They used to read as an assigner's + // queue ("assign within 24h", "awaiting assignment"), sent to the person the + // lead was already assigned to (#2073 item 7). 'crm.lead_routing_hot': { label: 'Hot Lead Routing', - subject: 'Hot lead — assign within 24h: {{first_name}} {{last_name}}', - bodyHtml: '

{{first_name}} {{last_name}} from {{company}} (rating {{rating}}) needs an owner today.

', + subject: 'Hot lead — follow up within 24h: {{first_name}} {{last_name}}', + bodyHtml: '

{{first_name}} {{last_name}} from {{company}} (rating {{rating}}) is yours. Follow up by tomorrow.

', }, 'crm.lead_routing_new': { label: 'New Lead Routing', - subject: 'New lead to assign: {{first_name}} {{last_name}}', - bodyHtml: '

{{first_name}} {{last_name}} from {{company}} is awaiting assignment.

', + subject: 'New lead assigned to you: {{first_name}} {{last_name}}', + bodyHtml: '

{{first_name}} {{last_name}} from {{company}} is now yours. Follow up within 3 days.

', }, 'crm.opportunity_approved': { label: 'Deal Approved', diff --git a/src/sales/email-templates/es-ES.ts b/src/sales/email-templates/es-ES.ts index edfe25b24..bc8c6cd4f 100644 --- a/src/sales/email-templates/es-ES.ts +++ b/src/sales/email-templates/es-ES.ts @@ -46,13 +46,13 @@ export const esES: EmailTemplatePack = { }, 'crm.lead_routing_hot': { label: 'Asignación de cliente potencial caliente', - subject: 'Cliente potencial caliente — asignar en 24 h: {{first_name}} {{last_name}}', - bodyHtml: '

{{first_name}} {{last_name}} de {{company}} (puntuación {{rating}}) necesita propietario hoy.

', + subject: 'Cliente potencial caliente — haz el seguimiento en 24 h: {{first_name}} {{last_name}}', + bodyHtml: '

{{first_name}} {{last_name}} de {{company}} (puntuación {{rating}}) es tuyo. Haz el seguimiento antes de que termine mañana.

', }, 'crm.lead_routing_new': { label: 'Asignación de cliente potencial', - subject: 'Nuevo cliente potencial por asignar: {{first_name}} {{last_name}}', - bodyHtml: '

{{first_name}} {{last_name}} de {{company}} está pendiente de asignación.

', + subject: 'Nuevo cliente potencial asignado a ti: {{first_name}} {{last_name}}', + bodyHtml: '

{{first_name}} {{last_name}} de {{company}} ahora es tuyo. Haz el seguimiento en los próximos 3 días.

', }, 'crm.opportunity_approved': { label: 'Oportunidad aprobada', diff --git a/src/sales/email-templates/ja-JP.ts b/src/sales/email-templates/ja-JP.ts index 1ef4fc1da..1dfe03fe2 100644 --- a/src/sales/email-templates/ja-JP.ts +++ b/src/sales/email-templates/ja-JP.ts @@ -46,13 +46,13 @@ export const jaJP: EmailTemplatePack = { }, 'crm.lead_routing_hot': { label: 'ホットリードの割り当て', - subject: 'ホットリード — 24 時間以内に割り当て: {{first_name}} {{last_name}}', - bodyHtml: '

{{company}} の {{first_name}} {{last_name}}(評価 {{rating}})に本日中に担当者を割り当ててください。

', + subject: 'ホットリード — 24 時間以内にフォローアップ: {{first_name}} {{last_name}}', + bodyHtml: '

{{company}} の {{first_name}} {{last_name}}(評価 {{rating}})はあなたの担当です。明日までにフォローアップしてください。

', }, 'crm.lead_routing_new': { label: '新規リードの割り当て', - subject: '割り当て待ちの新規リード: {{first_name}} {{last_name}}', - bodyHtml: '

{{company}} の {{first_name}} {{last_name}} が割り当てを待っています。

', + subject: '新規リードがあなたに割り当てられました: {{first_name}} {{last_name}}', + bodyHtml: '

{{company}} の {{first_name}} {{last_name}} があなたの担当になりました。3 日以内にフォローアップしてください。

', }, 'crm.opportunity_approved': { label: '商談承認済み', diff --git a/src/sales/email-templates/zh-CN.ts b/src/sales/email-templates/zh-CN.ts index 63188deeb..a2376b17c 100644 --- a/src/sales/email-templates/zh-CN.ts +++ b/src/sales/email-templates/zh-CN.ts @@ -46,13 +46,13 @@ export const zhCN: EmailTemplatePack = { }, 'crm.lead_routing_hot': { label: '热门线索分配', - subject: '热门线索 — 24 小时内分配:{{first_name}} {{last_name}}', - bodyHtml: '

来自 {{company}} 的 {{first_name}} {{last_name}}(评分 {{rating}})需要今天就指定负责人。

', + subject: '热门线索 — 请在 24 小时内跟进:{{first_name}} {{last_name}}', + bodyHtml: '

来自 {{company}} 的 {{first_name}} {{last_name}}(评分 {{rating}})由您负责,请最迟明天跟进。

', }, 'crm.lead_routing_new': { label: '新线索分配', - subject: '待分配新线索:{{first_name}} {{last_name}}', - bodyHtml: '

来自 {{company}} 的 {{first_name}} {{last_name}} 正在等待分配。

', + subject: '新线索已分配给您:{{first_name}} {{last_name}}', + bodyHtml: '

来自 {{company}} 的 {{first_name}} {{last_name}} 现已分配给您,请在三天内跟进。

', }, 'crm.opportunity_approved': { label: '商机审批通过', diff --git a/src/sales/flows/lead-assignment.flow.ts b/src/sales/flows/lead-assignment.flow.ts index 72b40f4d7..bc27a430e 100644 --- a/src/sales/flows/lead-assignment.flow.ts +++ b/src/sales/flows/lead-assignment.flow.ts @@ -8,7 +8,23 @@ type Flow = Automation.Flow; * Lead Assignment & Routing — react the instant a new lead lands. * * Fires on lead *insert*, sets a rating-based `next_followup_date` SLA, and - * alerts the lead's OWNER — the accountable party — to follow up. + * alerts the lead's OWNER — the accountable party — to follow up: + * + * - a HOT lead alerts its owner whoever wrote it. The alert IS the 24h + * follow-up SLA, and "hot" is the system's verdict (the rating is scored on + * insert when the writer gave none), so the owner hears it even for a lead + * they typed in themselves. + * - a STANDARD lead alerts its owner only when someone ELSE put it in their + * name: `lead_auto_assign`'s pick on web-to-lead or a user-less import, an + * integration's named owner, a colleague with transfer rights logging it for + * them. A write that carries a user is stamped with that user as owner, so + * before the creator test on `e10` every lead a rep created told that same + * rep "New lead to assign … is awaiting assignment" (#2073 item 7). + * + * Both templates address the owner: the lead is theirs to follow up. ⛔ Neither + * may say the lead awaits assignment: no lead reaching a notify node does (the + * owner gates below), and whoever assigns leads is no messaging audience (the + * position note below), so an "awaiting assignment" alert has nobody to go to. * * A lead with NO owner gets its SLA stamp and no alert, and the run ends * there successfully (#2060 item 6). Web-to-lead lands ownerless whenever @@ -23,9 +39,10 @@ type Flow = Automation.Flow; * Positions are not a messaging audience: the messaging service resolves * `user:` / `role:` (sys_member) / `team:` / `owner_of:`, and a bare position * name is stored verbatim as `sys_inbox_message.user_id`, matching no real user, - * so the alert reaches nobody. With no territory / round-robin table modelled, - * the owner is the correct recipient. (Swap to a `team:`/`role:` audience once a - * manager group is seeded.) + * so the alert reaches nobody. `role:` is organization membership + * (owner / admin / member), not a sales rung, and this app seeds no `team:`. + * With no territory / round-robin table modelled, the owner is the correct + * recipient. (Swap to a `team:`/`role:` audience once a manager group is seeded.) * * Trigger wiring: the `start` node declares `triggerType: 'record-after-create'` * so the record-change trigger provider binds it to the ObjectQL `afterInsert` @@ -36,7 +53,7 @@ type Flow = Automation.Flow; export const LeadAssignmentFlow: Flow = { name: 'lead_assignment', label: 'New Lead Routing & SLA', - description: 'On new lead: set a rating-based follow-up SLA and alert the lead owner.', + description: 'On new lead: set a rating-based follow-up SLA and alert the lead owner (a standard lead only when someone else assigned it).', type: 'record_change', status: 'active', // A record-change flow fired by a SYSTEM write carries no trigger user @@ -88,6 +105,8 @@ export const LeadAssignmentFlow: Flow = { // carries a user is stamped with that user as owner, and // `lead_auto_assign` fills an ownerless one from the rep pool when it // can; a lead still ownerless never reaches this node (`owner_hot`). + // Unlike `notify_std`, a lead its owner wrote still comes here — see + // the header. recipients: [cel`record.owner_id`], channels: ['inbox', 'email'], severity: 'warning', @@ -108,14 +127,14 @@ export const LeadAssignmentFlow: Flow = { }, { // Gateway only — the predicate lives on its out-edges `e10` / `e11`. - id: 'owner_std', type: 'decision', label: 'Has an Owner?', + id: 'owner_std', type: 'decision', label: 'Assigned by Someone Else?', }, { - id: 'notify_std', type: 'notify', label: 'Alert — New Lead', + id: 'notify_std', type: 'notify', label: 'Alert — New Lead Assigned', config: { // Route to the lead's owner — see notify_hot for why a bare // 'sales_manager' position never reached anyone, and `owner_std` for - // the lead that has no owner. + // the lead that has no owner or was written by its owner. recipients: [cel`record.owner_id`], channels: ['inbox'], topic: 'lead_routing', @@ -163,9 +182,18 @@ export const LeadAssignmentFlow: Flow = { // `isBlank` answers true for exactly what `notify` would refuse as no // recipient — null, a blank string, an empty list — and `has()` covers the // key a sparse driver leaves absent. + // + // The standard gate `e10` also asks WHO put the lead in the owner's name + // (#2073 item 7; the hot gate `e8` deliberately does not — see the header). + // `created_by` is the platform's insert stamp of the writing user: equal + // to `owner_id` when a person created the lead for themselves (their own + // owner stamp), and blank or absent on a write with no user (web-to-lead, + // an import or integration), whose owner is then the round-robin's pick or + // the one the writer named. A colleague who logs a lead for a rep differs + // from the owner too. Only the owner's own lead takes the default edge. { id: 'e8', source: 'owner_hot', target: 'notify_hot', type: 'conditional', condition: P`has(record.owner_id) && !isBlank(record.owner_id)`, label: 'Owned' }, { id: 'e9', source: 'owner_hot', target: 'end', type: 'default', isDefault: true, label: 'No owner' }, - { id: 'e10', source: 'owner_std', target: 'notify_std', type: 'conditional', condition: P`has(record.owner_id) && !isBlank(record.owner_id)`, label: 'Owned' }, - { id: 'e11', source: 'owner_std', target: 'end', type: 'default', isDefault: true, label: 'No owner' }, + { id: 'e10', source: 'owner_std', target: 'notify_std', type: 'conditional', condition: P`has(record.owner_id) && !isBlank(record.owner_id) && (!has(record.created_by) || isBlank(record.created_by) || record.created_by != record.owner_id)`, label: 'Assigned by someone else' }, + { id: 'e11', source: 'owner_std', target: 'end', type: 'default', isDefault: true, label: 'No owner, or the owner wrote it' }, ], }; diff --git a/test/flow-record-change.test.ts b/test/flow-record-change.test.ts index c67149559..11218c2dc 100644 --- a/test/flow-record-change.test.ts +++ b/test/flow-record-change.test.ts @@ -313,11 +313,14 @@ describe('task_urgent_alert — start condition', () => { }); describe('lead_assignment — hot-lead SLA routing', () => { + // Each lead is one the administrator logs in the rep's name: a standard lead + // the rep wrote for themselves alerts nobody (#2073 item 7, the describe + // below), so both branches are read off a lead someone else assigned. const create = (rating: number): Promise => verify.hooks.run('crm_lead', 'insert', { first_name: 'Ada', last_name: `Lead ${++k}`, company: `Acme ${k}`, - email: `lead${k}@flow-record-change.test`, rating, - }, as(rep)); + email: `lead${k}@flow-record-change.test`, rating, owner_id: rep.id, + }, { as: admin }); const alerts = (lead: Rec) => notified(rep.id, 'lead_routing', `/crm_lead/${lead.id}`); /** @@ -363,6 +366,97 @@ describe('lead_assignment — hot-lead SLA routing', () => { }); }); +/** + * Who the routing alert reaches (#2073 item 7). + * + * A write that carries a user is stamped with that user as owner, so before + * the creator test on edge `e10` every lead a rep created alerted that same rep + * "New lead to assign … is awaiting assignment" — measured on the 2026-10-12 + * hosted pass for a form lead (owner = the rep) and an API lead with no owner, + * and re-measured on 18.0.0-next.5 through the doors below (the hot path and + * the web form sent the same assigner wording to the owner). + * The standard alert is now the assignment notice: it goes to an owner someone + * ELSE put the lead with (the round-robin, a colleague), and a person's own + * standard lead sends nothing. The hot alert is the 24h follow-up SLA and still + * reaches the owner of their own hot lead (the flow's header says why). + * + * Each lead is written through a door a person uses — the data API as the rep + * (the create form posts there), the anonymous web form — and every alert this + * flow sent about it is read, to anyone, on any channel. The empty answers are + * read after the run has landed and passed its notify node over, so "nobody" + * is a verdict, not a race. + */ +describe('lead_assignment — who the routing alert reaches (#2073 item 7)', () => { + type Sent = { to: string; channel: string; template: string }; + /** Every routing alert sent about lead `id`, to anyone, on any channel. */ + const sentAbout = async (id: string): Promise => + (await verify.rows('sys_notification_delivery', { topic: 'lead_routing' })) + .filter((n) => n.payload?.actionUrl === `/crm_lead/${id}`) + .map((n) => ({ to: String(n.recipient_id), channel: String(n.channel), template: String(n.payload?.template) })) + .sort((a, b) => a.channel.localeCompare(b.channel)); + /** POST /data/crm_lead as `token`; the new lead's id. */ + const post = async (token: string, lead: Rec): Promise => { + const res = await verify.apiAs(token, 'POST', '/data/crm_lead', { + first_name: 'Ada', last_name: `Routed ${++k}`, company: `Routed Co ${k}`, + email: `routed${k}@flow-record-change.test`, ...lead, + }); + expect(res.status, 'the lead was not created').toBe(201); + return String(((await res.json()) as Rec).id); + }; + /** The lead's one finished run, and the status of `notify` in it. */ + const ranPast = async (id: string, notify: string) => { + const [run, ...more] = await landedRuns('lead_assignment', id); + expect(more).toHaveLength(0); + expect(run?.status).toBe('completed'); + return (run?.summary?.nodes ?? []).find((n: Rec) => n.nodeId === notify)?.status; + }; + + it('a rep’s own lead from the create form (owner = the rep) alerts nobody', async () => { + const id = await post(rep.token, { owner_id: rep.id, rating: 2 }); + expect(await ranPast(id, 'notify_std'), 'notify_std was not passed over').toBe('skipped'); + const [stored] = await verify.rows('crm_lead', { id }); + expect(stored!.next_followup_date, 'the SLA stamp did not land').toBeTruthy(); + expect(await sentAbout(id), 'the rep was alerted about a lead they created').toEqual([]); + }, 30_000); + + it('a rep’s own lead through the API with no owner (stamped the rep’s) alerts nobody', async () => { + const id = await post(rep.token, { rating: 2 }); + const [stored] = await verify.rows('crm_lead', { id }); + expect(stored!.owner_id, 'not the creator-stamped lead this case is about').toBe(rep.id); + expect(await ranPast(id, 'notify_std'), 'notify_std was not passed over').toBe('skipped'); + expect(await sentAbout(id), 'the rep was alerted about a lead they created').toEqual([]); + }, 30_000); + + it('a web-to-lead lead alerts the rep the round-robin gave it to, with the assignment notice', async () => { + const res = await verify.api('/forms/contact-us/submit', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ first_name: 'Ada', last_name: `Web ${++k}`, email: `web${k}@flow-record-change.test`, company: 'Web Co', description: 'Please call.' }), + }); + expect(res.status).toBe(201); + const id = String(((await res.json()) as Rec).id); + await ranPast(id, 'notify_std'); + const [stored] = await verify.rows('crm_lead', { id }); + expect(stored!.owner_id, 'the web lead was not routed to the rep').toBe(rep.id); + expect(stored!.rating, 'not the standard lead this case is about').toBeLessThan(4); + expect(await sentAbout(id)).toEqual([{ to: rep.id, channel: 'inbox', template: 'crm.lead_routing_new' }]); + }, 30_000); + + it('a lead a colleague logs in the rep’s name alerts the rep, not the colleague', async () => { + const id = await post(admin, { owner_id: rep.id, rating: 2 }); + await ranPast(id, 'notify_std'); + expect(await sentAbout(id)).toEqual([{ to: rep.id, channel: 'inbox', template: 'crm.lead_routing_new' }]); + }, 30_000); + + it('a rep’s own HOT lead still alerts the rep: the 24h follow-up SLA, inbox and email', async () => { + const id = await post(rep.token, { rating: 5 }); + await ranPast(id, 'notify_hot'); + expect(await sentAbout(id)).toEqual([ + { to: rep.id, channel: 'email', template: 'crm.lead_routing_hot' }, + { to: rep.id, channel: 'inbox', template: 'crm.lead_routing_hot' }, + ]); + }, 30_000); +}); + /** * A lead with NO owner (#2060 item 6). * diff --git a/test/hot-lead-threshold-parity.test.ts b/test/hot-lead-threshold-parity.test.ts index b269d9e43..81389f321 100644 --- a/test/hot-lead-threshold-parity.test.ts +++ b/test/hot-lead-threshold-parity.test.ts @@ -12,7 +12,7 @@ import stack from './helpers/composed-stack'; * * The `lead_assignment` flow is the SEMANTICS PRODUCER. Its `check_hot` * decision (edge `e2`) is what stamps a 1-day follow-up SLA on a new lead and - * fires "Hot lead — assign within 24h" at the owner. The `hot_leads` list view + * fires "Hot lead — follow up within 24h" at the owner. The `hot_leads` list view * is the queue that alert sends that owner to. If the two disagree on where * the line sits, the product tells a rep a lead is hot and then hides it. * @@ -241,7 +241,7 @@ describe('a lead routing calls hot is a lead in the Hot Leads queue', () => { expect( row.queued, row.hot - ? `rating ${row.rating} is routed HOT (1-day SLA + "assign within 24h" alert) ` + + ? `rating ${row.rating} is routed HOT (1-day SLA + "follow up within 24h" alert) ` + `but is NOT in the 🔥 Hot Leads queue that alert points at` : `rating ${row.rating} is NOT routed hot but sits in the 🔥 Hot Leads queue`, ).toBe(row.hot); From 5023cbebf2227444a3f837849e18ef23a2d6a3d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:41:44 +0000 Subject: [PATCH 2/2] test(marketing): give the member-writing Enroll Members cases a 30 s budget The partly refused Enroll Members case ran past vitest's 5 s default on CI (Build and Test, 22.x). An Enroll Members run that writes members enrolls every eligible lead the caller can see, and that set grows with each world() in the file. Measured on head and on base alike: about 2 s alone, about 4.3 s beside two other booted files under coverage, as CI runs the suite. The lead_assignment change on this branch does not run in this file (world() seeds its leads, and a seed fires no record-change flow). The two clean runs (it.each timeout argument) and the partly refused describe (describe option) get 30 s, the budget the escalation-task suite uses. The global testTimeout is unchanged. Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r Co-authored-by: Claude --- test/campaign-enrollment-doors.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/campaign-enrollment-doors.test.ts b/test/campaign-enrollment-doors.test.ts index 35a6af211..0ea9cb7bf 100644 --- a/test/campaign-enrollment-doors.test.ts +++ b/test/campaign-enrollment-doors.test.ts @@ -60,6 +60,15 @@ beforeAll(async () => { who.admin = { token: adminToken, id: String((await verify.contextFor(adminToken)).userId) }; }, 180_000); +/** + * The budget of the cases whose Enroll Members run writes members: the clean + * runs and the partly refused one. Such a run enrolls every eligible lead the + * caller can see, and that set grows with each `world()`. Measured: about 2 s + * alone, about 4.3 s beside two other booted files under coverage (as CI runs + * the suite), and the partly refused case ran past the 5 s default on CI once. + */ +const ENROLL_BUDGET_MS = 30_000; + /** The personas whose sets refuse both rights, and those that hold them. */ const REFUSED = ['sales_rep', 'sales_manager'] as const; const ALLOWED = ['marketing_user', 'admin'] as const; @@ -210,10 +219,10 @@ describe('a profile with allowCreate enrolls through every door, as itself (#216 expect(result.successMessage).toBe('Eligible members enrolled in campaign.'); const enrolled = (await membersOf(w.campaign.id)).map((m) => m.crm_lead); expect(enrolled).toEqual(expect.arrayContaining([w.leadA.id, w.leadB.id])); - }); + }, ENROLL_BUDGET_MS); }); -describe('a partly refused Enroll Members run reports both counts (#2167, Q2 letter a)', () => { +describe('a partly refused Enroll Members run reports both counts (#2167, Q2 letter a)', { timeout: ENROLL_BUDGET_MS }, () => { it('ends refused with the enrolled and refused counts, and writes every member it was allowed to', async () => { const marketer = who.marketing_user!; const w = await world(marketer);