diff --git a/.changeset/2014-web-intake-hooks.md b/.changeset/2014-web-intake-hooks.md new file mode 100644 index 00000000..1657bf45 --- /dev/null +++ b/.changeset/2014-web-intake-hooks.md @@ -0,0 +1,5 @@ +--- +'hotcrm': patch +--- + +Submissions from the public Web-to-Lead (Contact Us) and Web-to-Case (Support) forms are now routed like every other incoming lead and case: a web lead goes to the sales rep with the fewest open leads, and its new-lead alert reaches that rep; a web case goes to the service agent with the fewest open cases, instead of waiting in Unassigned — triage while agents are on the rota. A web enquiry from an address already in the CRM is now flagged as a suspected duplicate, and a sales rep's new lead is now flagged when it repeats a colleague's contact or lead the rep cannot see. The visitor who fills in a public form is still told only that the submission was received — never who it went to, or whether the address was already known. diff --git a/content/docs/administration/setup.mdx b/content/docs/administration/setup.mdx index de32ff9c..65144faa 100644 --- a/content/docs/administration/setup.mdx +++ b/content/docs/administration/setup.mdx @@ -113,7 +113,7 @@ Adjust if your support promise differs — the sixteen numbers live in `src/serv ### 12. Case routing — nothing to configure -**There is no Setup → Case Settings screen**, and there are no routing rules to write. An ownerless case — a web-to-case submission is the usual way one arrives — is assigned on insert by `case_auto_assign` (`src/service/objects/_case-assignment.ts`): it goes to whichever holder of the `service_agent` position has the **fewest open cases**, counting every case whose status is neither *Resolved* nor *Closed*. It is the same self-balancing round-robin leads get: least-loaded wins, not a rotation and not a rule matching product, region or origin; there is no territory routing and no queue. It runs **on insert only**, and only on a case that arrives with no owner — it never takes a case off an owner later, and it never fires on a case an agent creates, because the platform has already stamped that agent as the owner by the time the hook runs. It never blocks intake either: when the pool cannot be read at all — which is what the anonymous web-to-case form hits — the case is still captured, just ownerless. And if nobody holds `service_agent` the hook does nothing, so the case stays ownerless and waits in the **Unassigned — triage** tab on [Cases](/docs/service/cases). That tab is this hook's do-nothing path made visible, which makes staffing the position a behaviour change in itself: new cases stop arriving ownerless, and stop appearing there. **This is not the escalation hand-off**, which is a different pool at a different moment — that one rides on the transition to *Escalated* and moves the case to the least-loaded holder of `service_manager` (see [SLA & escalation](/docs/service/sla-and-escalation)). +**There is no Setup → Case Settings screen**, and there are no routing rules to write. An ownerless case — a web-to-case submission is the usual way one arrives — is assigned on insert by `case_auto_assign` (`src/service/objects/_case-assignment.ts`): it goes to whichever holder of the `service_agent` position has the **fewest open cases**, counting every case whose status is neither *Resolved* nor *Closed*. It is the same self-balancing round-robin leads get: least-loaded wins, not a rotation and not a rule matching product, region or origin; there is no territory routing and no queue. It runs **on insert only**, and only on a case that arrives with no owner — it never takes a case off an owner later, and it never fires on a case an agent creates, because the platform has already stamped that agent as the owner by the time the hook runs. It never blocks intake either: if the pool cannot be read, the case is still captured, just ownerless. A case from the anonymous web-to-case form is routed like any other — the visitor is only told the case was received, never who it went to. And if nobody holds `service_agent` the hook does nothing, so the case stays ownerless and waits in the **Unassigned — triage** tab on [Cases](/docs/service/cases). That tab is this hook's do-nothing path made visible, which makes staffing the position a behaviour change in itself: new cases stop arriving ownerless, and stop appearing there. **This is not the escalation hand-off**, which is a different pool at a different moment — that one rides on the transition to *Escalated* and moves the case to the least-loaded holder of `service_manager` (see [SLA & escalation](/docs/service/sla-and-escalation)). - [ ] Assign the `service_agent` position to every agent who should receive intake — that pool *is* the routing table - [ ] While nobody holds it, watch the **Unassigned — triage** tab on Cases: it lists exactly the cases this hook could not place diff --git a/content/docs/administration/setup.zh-Hans.mdx b/content/docs/administration/setup.zh-Hans.mdx index 7bcb6e24..36e70be0 100644 --- a/content/docs/administration/setup.zh-Hans.mdx +++ b/content/docs/administration/setup.zh-Hans.mdx @@ -113,7 +113,7 @@ description: 让 HotCRM 为你的团队做好生产准备的第一周任务。 ### 12. 工单路由 —— 没有需要配置的东西 -**不存在“系统设置 → 工单设置”这个屏幕**,也没有任何路由规则可写。一张无所有者的工单 —— 最常见的来路是网页转工单(web-to-case)提交 —— 会在插入时由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)分配:交给持有 `service_agent` 岗位的人中**未结工单最少**的那一位,这里的“未结”指状态既不是 *Resolved* 也不是 *Closed*。这和线索那一侧是同一种自平衡轮询:比的是谁负载最轻,而不是按次序轮换,也不是按产品、区域或来源匹配的规则;没有区域路由,也没有队列。它**只在插入时运行**,而且只处理到达时就没有所有者的工单 —— 它不会在之后把工单从某个人手里拿走,也不会在客服自己新建工单时触发,因为钩子跑起来之前平台已经把创建者盖成了所有者。它也从不阻断工单进入:当人员池根本读不到时(匿名网页转工单表单就是这种情形),工单照样被记录下来,只是无主。而如果没有人持有 `service_agent`,这个钩子什么也不做,工单就一直无主,停在[工单](/zh-Hans/docs/service/cases)页面的 **未分派 — 待分诊** 页签里。那个页签正是这个钩子“什么也不做”这条路径的可见化,所以给这个岗位配上人本身就是一次行为变化:新工单不再无主到达,也不再出现在那里。**这不是升级交接**:那是另一个人员池、另一个时机 —— 它挂在转为 *Escalated* 的状态迁移上,把工单交给持有 `service_manager` 岗位的人中负载最轻的那一位(参见 [SLA 与升级](/zh-Hans/docs/service/sla-and-escalation))。 +**不存在“系统设置 → 工单设置”这个屏幕**,也没有任何路由规则可写。一张无所有者的工单 —— 最常见的来路是网页转工单(web-to-case)提交 —— 会在插入时由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)分配:交给持有 `service_agent` 岗位的人中**未结工单最少**的那一位,这里的“未结”指状态既不是 *Resolved* 也不是 *Closed*。这和线索那一侧是同一种自平衡轮询:比的是谁负载最轻,而不是按次序轮换,也不是按产品、区域或来源匹配的规则;没有区域路由,也没有队列。它**只在插入时运行**,而且只处理到达时就没有所有者的工单 —— 它不会在之后把工单从某个人手里拿走,也不会在客服自己新建工单时触发,因为钩子跑起来之前平台已经把创建者盖成了所有者。它也从不阻断工单进入:人员池读不到时,工单照样被记录下来,只是无主。匿名网页转工单表单提交的工单和其他来路一样参与分配 —— 提交者只会得知工单已收到,永远看不到它分给了谁。而如果没有人持有 `service_agent`,这个钩子什么也不做,工单就一直无主,停在[工单](/zh-Hans/docs/service/cases)页面的 **未分派 — 待分诊** 页签里。那个页签正是这个钩子“什么也不做”这条路径的可见化,所以给这个岗位配上人本身就是一次行为变化:新工单不再无主到达,也不再出现在那里。**这不是升级交接**:那是另一个人员池、另一个时机 —— 它挂在转为 *Escalated* 的状态迁移上,把工单交给持有 `service_manager` 岗位的人中负载最轻的那一位(参见 [SLA 与升级](/zh-Hans/docs/service/sla-and-escalation))。 - [ ] 把 `service_agent` 岗位分配给每一位应当接收工单的客服 —— 这个人员池*就是*路由表 - [ ] 在还没有人持有它的时候,盯住工单页面的 **未分派 — 待分诊** 页签:它列出的正是这个钩子没能分配掉的工单 diff --git a/content/docs/administration/setup.zh-Hant.mdx b/content/docs/administration/setup.zh-Hant.mdx index db7d58df..855e06f9 100644 --- a/content/docs/administration/setup.zh-Hant.mdx +++ b/content/docs/administration/setup.zh-Hant.mdx @@ -115,7 +115,7 @@ description: 讓 HotCRM 為你的團隊做好生產準備的第一週任務。 ### 12. 工單路由 —— 沒有需要設定的東西 -**不存在「Setup → Case Settings」這個畫面**,也沒有任何路由規則可寫。一張無所有者的工單 —— 最常見的來路是網頁轉工單(web-to-case)提交 —— 會在插入時由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)指派:交給持有 `service_agent` 職位的人當中**未結工單最少**的那一位,這裡的「未結」指狀態既不是 *Resolved* 也不是 *Closed*。這和潛在客戶那一側是同一種自平衡輪詢:比的是誰負載最輕,而不是按次序輪換,也不是按產品、區域或來源比對的規則;沒有區域路由,也沒有佇列。它**只在插入時執行**,而且只處理到達時就沒有所有者的工單 —— 它不會在之後把工單從某個人手上拿走,也不會在客服自己新建工單時觸發,因為掛鉤跑起來之前平台已經把建立者蓋成了所有者。它也從不阻斷工單進入:當人員池根本讀不到時(匿名網頁轉工單表單就是這種情形),工單照樣被記錄下來,只是無主。而如果沒有人持有 `service_agent`,這個掛鉤什麼也不做,工單就一直無主,停在[工單](/zh-Hant/docs/service/cases)頁面的 **未分派 — 待分診** 頁籤裡。那個頁籤正是這個掛鉤「什麼也不做」這條路徑的可見化,所以給這個職位配上人本身就是一次行為變化:新工單不再無主到達,也不再出現在那裡。**這不是升級交接**:那是另一個人員池、另一個時機 —— 它掛在轉為 *Escalated* 的狀態遷移上,把工單交給持有 `service_manager` 職位的人當中負載最輕的那一位(參見 [SLA 與升級](/zh-Hant/docs/service/sla-and-escalation))。 +**不存在「Setup → Case Settings」這個畫面**,也沒有任何路由規則可寫。一張無所有者的工單 —— 最常見的來路是網頁轉工單(web-to-case)提交 —— 會在插入時由 `case_auto_assign`(`src/service/objects/_case-assignment.ts`)指派:交給持有 `service_agent` 職位的人當中**未結工單最少**的那一位,這裡的「未結」指狀態既不是 *Resolved* 也不是 *Closed*。這和潛在客戶那一側是同一種自平衡輪詢:比的是誰負載最輕,而不是按次序輪換,也不是按產品、區域或來源比對的規則;沒有區域路由,也沒有佇列。它**只在插入時執行**,而且只處理到達時就沒有所有者的工單 —— 它不會在之後把工單從某個人手上拿走,也不會在客服自己新建工單時觸發,因為掛鉤跑起來之前平台已經把建立者蓋成了所有者。它也從不阻斷工單進入:人員池讀不到時,工單照樣被記錄下來,只是無主。匿名網頁轉工單表單提交的工單和其他來路一樣參與指派 —— 提交者只會得知工單已收到,永遠看不到它指派給了誰。而如果沒有人持有 `service_agent`,這個掛鉤什麼也不做,工單就一直無主,停在[工單](/zh-Hant/docs/service/cases)頁面的 **未分派 — 待分診** 頁籤裡。那個頁籤正是這個掛鉤「什麼也不做」這條路徑的可見化,所以給這個職位配上人本身就是一次行為變化:新工單不再無主到達,也不再出現在那裡。**這不是升級交接**:那是另一個人員池、另一個時機 —— 它掛在轉為 *Escalated* 的狀態遷移上,把工單交給持有 `service_manager` 職位的人當中負載最輕的那一位(參見 [SLA 與升級](/zh-Hant/docs/service/sla-and-escalation))。 - [ ] 把 `service_agent` 職位指派給每一位應當接收工單的客服 —— 這個人員池*就是*路由表 - [ ] 在還沒有人持有它的時候,盯住工單頁面的 **未分派 — 待分診** 頁籤:它列出的正是這個掛鉤沒能指派掉的工單 diff --git a/content/docs/sales/leads.mdx b/content/docs/sales/leads.mdx index 5ab427e1..5ad42811 100644 --- a/content/docs/sales/leads.mdx +++ b/content/docs/sales/leads.mdx @@ -105,7 +105,7 @@ Work them from the **Suspected Duplicates** view in the Leads list's view switch - **It's a new enquiry from a known person** — clear Duplicate Status and work the lead normally. A returning prospect with a fresh budget is a real opportunity. - **It's the same enquiry twice** — set the lead to *Unqualified* with the reason **Duplicate**. The form then requires you to name the surviving record and set Duplicate Status to **Confirmed**; the lead cannot be closed as a duplicate without saying what survives it, so the disqualification report always links to something you can open. -> Anonymous Web-to-Lead submissions are always accepted. If the public form cannot check for existing records (it deliberately has no read access to your CRM data), the lead lands unflagged — review it from the normal lead queue. +> Anonymous Web-to-Lead submissions are always accepted, and they are checked like any other lead: a web enquiry from an address already in your CRM is flagged. The check runs on your side — the visitor is only told the enquiry was received, so the public form never reveals whether an address is already known. ### When the surviving record is later deleted diff --git a/content/docs/sales/leads.zh-Hans.mdx b/content/docs/sales/leads.zh-Hans.mdx index 6e1177b4..ead9e8a6 100644 --- a/content/docs/sales/leads.zh-Hans.mdx +++ b/content/docs/sales/leads.zh-Hans.mdx @@ -105,7 +105,7 @@ description: 在入站咨询成为真正交易之前对其进行捕获、评分 - **是老客户的新咨询**——清空重复状态,按正常线索推进。预算重新到位的老客户是真实商机。 - **是同一次咨询重复录入**——把线索置为*未通过*,原因选**重复**。此时表单会要求你指明存活的那条记录,并把重复状态设为**已确认重复**。不指明存活记录就无法以重复为由作废,所以作废分析里的每一条都能点开。 -> 匿名 Web-to-Lead 提交永远会被接受。公开表单有意不具备读取 CRM 数据的权限,如果它无法查询已有记录,线索会以未标记的状态落库——请在常规线索队列中复核。 +> 匿名 Web-to-Lead 提交永远会被接受,并且和其他线索一样会被查重:来自 CRM 中已有地址的网页咨询会被标记。查重在你这一侧完成——访客只会得知咨询已收到,因此公开表单永远不会透露某个地址是否已被记录。 ## 转化一条已确认的线索 diff --git a/content/docs/sales/leads.zh-Hant.mdx b/content/docs/sales/leads.zh-Hant.mdx index 9c37e475..edd6573f 100644 --- a/content/docs/sales/leads.zh-Hant.mdx +++ b/content/docs/sales/leads.zh-Hant.mdx @@ -107,7 +107,7 @@ description: 在入站詢問成為真正交易之前對其進行擷取、評分 - **是老客戶的新詢問**——清空重複狀態,按正常流程推進。預算重新到位的老客戶是真實商機。 - **是同一次詢問重複輸入**——把記錄設為*未通過*,原因選**重複**。此時表單會要求你指明存活的那筆記錄,並把重複狀態設為**已確認重複**。不指明存活記錄就無法以重複為由作廢,所以作廢分析裡的每一筆都能點開。 -> 匿名 Web-to-Lead 提交永遠會被接受。公開表單刻意不具備讀取 CRM 資料的權限,如果它無法查詢既有記錄,記錄會以未標記的狀態寫入——請在常規佇列中複核。 +> 匿名 Web-to-Lead 提交永遠會被接受,並且和其他潛在客戶一樣會被查重:來自 CRM 中既有地址的網頁詢問會被標記。查重在你這一側完成——訪客只會得知詢問已收到,因此公開表單永遠不會透露某個地址是否已被記錄。 ## 轉化一筆已確認的潛在客戶 diff --git a/src/sales/objects/lead.hook.ts b/src/sales/objects/lead.hook.ts index 7f9e40ad..bbc1ee61 100644 --- a/src/sales/objects/lead.hook.ts +++ b/src/sales/objects/lead.hook.ts @@ -49,13 +49,41 @@ import type { HookApi } from './_hook-api'; * AFTER `lead_automation` (200), whose guest branch strips a client-spoofed * `owner_id` from anonymous Web-to-Lead submissions. Assigning first and being * stripped afterwards lands every web-to-lead ownerless — the exact case this - * hook exists for. + * hook exists for. It is also what keeps the elevation below from letting an + * anonymous client choose the owner: the strip runs first, so this hook only + * ever writes the pool's choice. + * + * ## `runAs: 'system'` (#2014, AGENTS.md rule 9), and the organization pin + * + * Web-to-lead is the intake this hook exists for, and as the caller it never + * reached the pool: the anonymous form writes under `guest_portal`, which + * reads nothing, so the pool read came back empty and every web lead landed + * ownerless with reps staffed (measured on 17.7.0 and again on 18.0.0-next.0). + * Who works an enquiry is the business's routing, not the submitter's + * privilege, so the hook elevates. Elevation reaches the two READS only: the + * owner is still stamped on the payload in flight (no write of its own, no + * transfer grant), and a person's own insert never gets here. + * + * What elevation reads never reaches the submitter. Maintainer ruling on + * #2014 (item 4, B): the platform fixed the door first — since + * objectstack#22437 (18.0.0-next.0) the anonymous form answers the new + * record's id and nothing else, so the owner chosen here stays inside the CRM. + * `test/hooks-runtime.test.ts` pins that answer through the real door. + * + * AGENTS.md rule 10: the pool and the load counts are organization-NEUTRAL + * predicates, so both are pinned to the new lead's `organization_id` when the + * writer named one (a system import into one organization of several). The + * anonymous form names none here — the platform stamps the organization after + * the beforeInsert hooks (ADR-0131) — and an organization-less insert lands + * only on an install with exactly one organization (with two, the platform + * refuses it), so an unpinned read has no other organization to reach. */ const leadAutoAssignHook: Hook = { name: 'lead_auto_assign', object: 'crm_lead', events: ['beforeInsert'], priority: 250, + runAs: 'system', description: 'Assign ownerless new leads to the least-loaded sales rep.', handler: async (ctx: HookContext) => { const { input } = ctx; @@ -67,17 +95,21 @@ const leadAutoAssignHook: Hook = { const api = ctx.api as HookApi | undefined; if (!api) return; + // The rule-10 pin on both reads below: the organization the writer named + // on the new lead, when it named one (see the note above the hook). + const organizationId = + typeof input.organization_id === 'string' && input.organization_id !== '' + ? input.organization_id + : undefined; + const inOrganization: Record = organizationId ? { organization_id: organizationId } : {}; + // Auto-assignment is a best-effort ENHANCEMENT — it must NEVER block lead - // creation. In particular an anonymous Web-to-Lead submission runs under the - // public-form grant, which permits only create/read-back on crm_lead and - // DENIES `find` on sys_user_position; letting that denial propagate would - // reject the whole insert and break the public form. So the pool lookup + - // load balancing are wrapped: on ANY error (permission, etc.) we log and - // leave the lead ownerless (a manager / the lead_assignment flow routes it). + // creation. So the pool lookup + load balancing are wrapped: on ANY error + // the lead is still captured, ownerless. try { // Rep pool = holders of the sales_rep position. const holders = await api.object('sys_user_position').find({ - where: { position: 'sales_rep' }, fields: ['user_id'], top: 1000, + where: { position: 'sales_rep', ...inOrganization }, fields: ['user_id'], top: 1000, }); const repIds = Array.from( new Set( @@ -93,7 +125,7 @@ const leadAutoAssignHook: Hook = { let bestCount = Infinity; for (const repId of repIds) { const openCount = await api.object('crm_lead').count({ - where: { owner_id: repId, is_converted: false }, + where: { owner_id: repId, is_converted: false, ...inOrganization }, }); if (openCount < bestCount) { bestCount = openCount; @@ -102,10 +134,9 @@ const leadAutoAssignHook: Hook = { } if (best) input.owner_id = best; } catch { - // Swallow: auto-assignment must never block the insert. Common case is the - // anonymous public-form context, which can't read sys_user_position — the - // lead is still captured, ownerless, for downstream routing. (No `console` - // here — the L2 hook sandbox doesn't define it.) + // Swallow: auto-assignment must never block the insert — the lead is + // still captured, ownerless. (No `console` here — the L2 hook sandbox + // doesn't define it.) } }, }; @@ -530,12 +561,35 @@ const leadHook: Hook = { * ⚠️ Priority 300 — after `lead_automation` (200), whose guest branch strips * client-supplied `duplicate_*` from anonymous submissions. Running earlier * would let a spoofed `duplicate_status` switch this hook off. + * + * ## `runAs: 'system'` (#2014, AGENTS.md rule 9), and the organization pin + * + * Whether an address is already in the CRM is the CRM's fact, not the + * writer's privilege. As the caller, job 2 saw only what the writer may read: + * the anonymous form reads nothing, so a web duplicate always landed + * unflagged, and a rep's scan missed a colleague's contact or lead. So the + * hook elevates. Elevation reaches the two insert-time reads, the only + * `ctx.api` calls here; what it writes is the verdict and the link on the NEW + * lead, nothing else. + * + * The verdict never reaches an anonymous submitter: ruling #2014 item 4 (B) + * waited for objectstack#22437, and since 18.0.0-next.0 the form answers the + * new record's id and nothing else, so no public form is an email-existence + * oracle. `test/hooks-runtime.test.ts` pins that answer through the real door. + * + * AGENTS.md rule 10: both reads are pinned to the new lead's + * `organization_id` when the writer named one, so a match is never found in + * another organization. A person's write is confined by their own tenant, + * which the elevated `ctx.api` keeps; the anonymous form names none here, and + * lands only on an install with exactly one organization (see + * `lead_auto_assign`). */ const leadDuplicateCheckHook: Hook = { name: 'lead_duplicate_check', object: 'crm_lead', events: ['beforeInsert', 'beforeUpdate'], priority: 300, + runAs: 'system', description: 'Normalize lead email and company match key; flag a re-captured address as a suspected duplicate of the record it repeats.', handler: async (ctx: HookContext) => { @@ -733,15 +787,20 @@ const leadDuplicateCheckHook: Hook = { return best; }; + // The rule-10 pin on both reads below (see the note above the hook). + const organizationId = + typeof input.organization_id === 'string' && input.organization_id !== '' + ? input.organization_id + : undefined; + const inOrganization: Record = organizationId ? { organization_id: organizationId } : {}; + // Best-effort ENHANCEMENT, never a gate on the write — identical to - // `lead_auto_assign` above. An anonymous Web-to-Lead submission runs under - // the `guest_portal` grant, which is INSERT-only on `crm_lead` and denies - // reads outright; letting that denial propagate would reject the very - // submission this feature exists to accept. A guest's duplicate therefore - // lands unflagged rather than not landing at all. + // `lead_auto_assign` above. A failed read must not reject the very + // submission this feature exists to accept, so the duplicate lands + // unflagged rather than not landing at all. try { const contacts = await api.object('crm_contact').find({ - where: { email }, + where: { email, ...inOrganization }, fields: ['id', 'created_at'], top: SCAN_LIMIT, }); @@ -758,7 +817,7 @@ const leadDuplicateCheckHook: Hook = { // address — pointing at the locked lead instead would name a record // nobody can work. const leads = await api.object('crm_lead').find({ - where: { email, is_converted: false }, + where: { email, is_converted: false, ...inOrganization }, fields: ['id', 'created_at'], top: SCAN_LIMIT, }); diff --git a/src/service/objects/_case-assignment.ts b/src/service/objects/_case-assignment.ts index 18ec9fb5..de51ee94 100644 --- a/src/service/objects/_case-assignment.ts +++ b/src/service/objects/_case-assignment.ts @@ -161,12 +161,35 @@ export const CLAIMABLE_TARGET_STATUSES = [ * * ## Best-effort, always * - * Auto-assignment is an ENHANCEMENT and must never reject the insert. An - * anonymous Web-to-Case submission runs under the public-form grant, which - * permits create/read-back on `crm_case` and DENIES `find` on - * `sys_user_position`; letting that denial propagate would 403 the whole - * submission and break the public support form. So every read is wrapped and - * ANY failure leaves the case ownerless for the triage view to surface. + * Auto-assignment is an ENHANCEMENT and must never reject the insert, so every + * read is wrapped and ANY failure leaves the case ownerless for the triage + * view to surface. + * + * ## `runAs: 'system'` (#2014, AGENTS.md rule 9), and the organization pin + * + * Web-to-case is the intake this hook exists for (#596), and as the caller it + * never reached the pool: the anonymous form writes under `guest_portal`, + * which reads nothing, so the read came back empty and every web case landed + * in triage with agents staffed (measured on 17.7.0 and again on + * 18.0.0-next.0). Who works a case is the business's routing, not the + * submitter's privilege, so the hook elevates. Elevation reaches the two READS + * only: the owner is still stamped on the payload in flight (door ① above, no + * transfer grant), and the strip at priority 200 runs first, so an anonymous + * client cannot choose the owner. + * + * What elevation reads never reaches the submitter. Maintainer ruling on + * #2014 (item 4, B): the platform fixed the door first — since + * objectstack#22437 (18.0.0-next.0) the anonymous form answers the new + * record's id and nothing else. `test/case-assignment.test.ts` pins that + * answer through the real door. + * + * AGENTS.md rule 10: the pool and the load counts are pinned to the new + * case's `organization_id` when the writer named one (a system import into + * one organization of several). The anonymous form names none here — the + * platform stamps the organization after the beforeInsert hooks (ADR-0131) — + * and an organization-less insert lands only on an install with exactly one + * organization (with two, the platform refuses it), so an unpinned read has + * no other organization to reach. * * @param hookName registry name for the hook (metadata only — never read by * the body, which the sandbox would not let it be). @@ -177,6 +200,7 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook object: 'crm_case', events: ['beforeInsert'], priority: 250, + runAs: 'system', description: 'Assign ownerless new cases to the least-loaded service agent.', handler: async (ctx: HookContext) => { const { input } = ctx; @@ -189,12 +213,20 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook const api = ctx.api as HookApi | undefined; if (!api) return; + // The rule-10 pin on both reads below: the organization the writer + // named on the new case, when it named one. + const organizationId = + typeof input.organization_id === 'string' && input.organization_id !== '' + ? input.organization_id + : undefined; + const inOrganization: Record = organizationId ? { organization_id: organizationId } : {}; + try { // The pool = holders of the `service_agent` position. This literal is // the exported SERVICE_AGENT_POSITION; the sandbox forbids reading the // constant here, and `test/case-assignment.test.ts` pins the parity. const holders = await api.object('sys_user_position').find({ - where: { position: 'service_agent' }, fields: ['user_id'], top: 1000, + where: { position: 'service_agent', ...inOrganization }, fields: ['user_id'], top: 1000, }); const agentIds = Array.from( new Set( @@ -214,7 +246,7 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook let bestCount = Infinity; for (const agentId of agentIds) { const openCount = await api.object('crm_case').count({ - where: { owner_id: agentId, status: { $nin: ['resolved', 'closed'] } }, + where: { owner_id: agentId, status: { $nin: ['resolved', 'closed'] }, ...inOrganization }, }); if (openCount < bestCount) { bestCount = openCount; @@ -223,11 +255,9 @@ export function createCaseRoundRobinAssign(hookName = 'case_auto_assign'): Hook } if (best) input.owner_id = best; } catch { - // Swallow: assignment must never block case creation. The common case - // is the anonymous public-form context, which cannot read - // `sys_user_position` — the case is still captured, ownerless, and the - // triage view surfaces it. (No `console` here — the L2 hook sandbox - // does not define one.) + // Swallow: assignment must never block case creation — the case is + // still captured, ownerless, and the triage view surfaces it. (No + // `console` here — the L2 hook sandbox does not define one.) } }, }; diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index 768af9b5..2978409b 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -170,8 +170,7 @@ export const CaseViews = defineView({ * NORM rather than an edge case: `sys_user_position` membership is runtime * data, so on a fresh org — and any time every agent has been unassigned * from the position — an inbound web-to-case lands ownerless. Best-effort - * assignment also stands down on a permission denial (the anonymous - * public-form context cannot read `sys_user_position` at all). + * assignment also stands down when its pool read fails. * * Those cases used to be invisible: no view filtered on the absence of an * owner, so an unowned case appeared in `All Cases` with a blank Owner diff --git a/test/case-assignment.test.ts b/test/case-assignment.test.ts index 78d5d389..4ce4e045 100644 --- a/test/case-assignment.test.ts +++ b/test/case-assignment.test.ts @@ -329,20 +329,50 @@ describe('case_auto_assign', () => { } }); - it('never rejects the insert when the pool read is DENIED', async () => { - // The anonymous Web-to-Case grant permits create/read-back on `crm_case` and - // denies `find` on `sys_user_position`. Propagating that denial would 403 - // the whole submission and break the public support form — so the case is - // captured ownerless and lands in triage instead. - const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 0 }]); + it('routes an anonymous submission like any other intake — the reads are elevated (#2014)', async () => { + // The anonymous form's own grant (`guest_portal`) reads nothing, so as the + // caller the pool came back empty and every web case landed in triage. + // The hook declares `runAs: 'system'`: the reads are the business's, and + // the least-loaded agent gets the case. + const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 1 }, { open: 0 }]); try { + const asTheForm = { publicFormGrant: { object: 'crm_case' }, permissions: ['guest_portal'], anonymous: true }; + const guestRead = await verify.kernel.getService('objectql') + .find('sys_user_position', { where: { position: SERVICE_AGENT_POSITION }, context: asTheForm }) + .catch((error: unknown) => error); + expect(JSON.stringify(guestRead ?? null), 'the form’s own grant reads the pool, so this case proves nothing') + .not.toContain(pool.people[1]!.id); const written = await guestInsert(verify, 'crm_case', { subject: 'Anonymous submission', description: 'Help.' }); - expect((await storedCase(written.id)).owner_id ?? null).toBeNull(); + expect((await storedCase(written.id)).owner_id, 'a web case was not routed to the least-loaded agent') + .toBe(pool.people[1]!.id); } finally { await pool.release(); } }); + it('stays inside the new case’s organization (AGENTS.md rule 10)', async () => { + // Elevated, the pool and the load counts are organization-neutral, so the + // hook pins both to the organization the writer named on the case. Two + // organizations, one agent each: the other organization's agent is the + // LESS loaded one, so an unpinned read picks them. On a boot of its own + // (and a build of its own), as the escalation case below explains. + const twoOrgs = await bootStack(await buildArtifactAgain(), bootOptions()); + const orgs = await twoOrgs.seed('sys_organization', [ + { name: 'Intake Org', slug: 'intake-org' }, { name: 'Other Org', slug: 'other-org' }, + ]); + const [own, other] = [String(orgs[0]!.id), String(orgs[1]!.id)]; + const ownAgent = await signUpPerson(twoOrgs, 'own.agent@case-assignment.test', { name: 'Own Agent' }); + const otherAgent = await signUpPerson(twoOrgs, 'other.agent@case-assignment.test', { name: 'Other Agent' }); + await twoOrgs.seed('sys_user_position', [ + { user_id: ownAgent.id, position: SERVICE_AGENT_POSITION, organization_id: own }, + { user_id: otherAgent.id, position: SERVICE_AGENT_POSITION, organization_id: other }, + ]); + await twoOrgs.seed('crm_case', [{ subject: 'Own load', description: 'x', status: 'new', owner_id: ownAgent.id, organization_id: own }]); + const [kase] = await twoOrgs.seed('crm_case', [{ subject: 'Org intake', description: 'x', status: 'new', organization_id: own }]); + const [after] = await twoOrgs.rows('crm_case', { id: kase!.id }); + expect(after!.owner_id, 'the intake crossed into another organization’s pool').toBe(ownAgent.id); + }, 120_000); + it('never blocks intake, whoever writes it', async () => { // The `!ctx.api` stand-down has no door on the shipped app — the engine // hands every insert a read door — so what is pinned is the property it @@ -379,36 +409,41 @@ describe('the guest strip and the assignment are ordered, not merely coexisting' it('a guest submission’s spoofed owner is stripped before anything reads it', async () => { // The shape a web-to-case submission actually takes, including the spoofed - // owner a public form can always post. Nulled, not removed (#1133). - const written = await guestInsert(verify, 'crm_case', { - subject: 'Spoofed', description: 'x', owner_id: (await signUpPerson(verify, `victim.${++k}@case-assignment.test`)).id, - }); - const kase = await storedCase(written.id); - expect(kase.owner_id ?? null, 'the guest strip stopped removing the spoofed owner').toBeNull(); - expect(kase.origin, 'guest defaults regressed').toBe('web'); + // owner a public form can always post. Nulled, not removed (#1133) — and + // the elevated round-robin after it writes the POOL's choice, never the + // submitter's: the victim is a member of the pool, and the more loaded one. + const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 1 }, { open: 0 }]); + try { + const written = await guestInsert(verify, 'crm_case', { subject: 'Spoofed', description: 'x', owner_id: pool.people[0]!.id }); + const kase = await storedCase(written.id); + expect(kase.owner_id, 'an anonymous client chose the owner').toBe(pool.people[1]!.id); + expect(kase.origin, 'guest defaults regressed').toBe('web'); + } finally { + await pool.release(); + } }); /** - * ⚠️ MEASURED DEFECT — reported as a finding on this card, pinned here so the - * fix is noticed. - * - * The round-robin exists for exactly this submission (the header: a - * web-to-case case "used to land ownerless with nobody accountable"), and the - * strip-then-assign order above is what was meant to hand it to an agent. - * But the anonymous form door writes under the `guest_portal` grant, which - * cannot read the agent pool — the hook's own stand-down, two describes up — - * so the assignment never runs for a web submission at all. Measured on - * 17.7.0, under the form door's own execution context: with an agent staffed, - * the submission lands unowned. + * The acceptance of #2014's F9 at the platform's own anonymous door, + * `POST /forms/:slug/submit` (`verify.api` serves it since 18.0.0-next.0). + * The round-robin exists for exactly this submission, and it reaches the + * pool elevated; what elevation found must not reach the submitter. Since + * objectstack#22437 the door answers the new record's id and nothing else + * (maintainer ruling on #2014, item 4 B: the platform fixed the door first). */ - it('⚠️ a web-to-case submission is never round-robined, even with an agent staffed (measured defect)', async () => { + it('a web-to-case submission is round-robined, and the anonymous answer carries only its id (#2014)', async () => { const pool = await staff(SERVICE_AGENT_POSITION, [{ open: 0 }]); try { - const written = await guestInsert(verify, 'crm_case', { subject: 'Web submission', description: 'Printer on fire.' }); - expect( - (await storedCase(written.id)).owner_id ?? null, - 'a web submission was assigned to the agent — the defect is fixed: rewrite this case to pin the hand-off', - ).toBeNull(); + const res = await verify.api('/forms/support/submit', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subject: 'Web submission', description: 'Printer on fire.', priority: 'high' }), + }); + expect(res.status).toBe(201); + const answer = await res.json() as Rec; + expect(Object.keys(answer), 'the anonymous answer carries more than the new id').toEqual(['id']); + const kase = await storedCase(String(answer.id)); + expect(kase.origin, 'not the guest submission this case is about').toBe('web'); + expect(kase.owner_id, 'a web submission was not routed to the staffed agent').toBe(pool.people[0]!.id); } finally { await pool.release(); } diff --git a/test/demo-staffing.test.ts b/test/demo-staffing.test.ts index e6aab7a7..562e0fb2 100644 --- a/test/demo-staffing.test.ts +++ b/test/demo-staffing.test.ts @@ -424,9 +424,9 @@ describe('the case-routing pools actually route (what this staffing lights)', () }; it('round-robins an ownerless case onto the demo service agent (#596)', async () => { - // Ownerless intake that reaches the pool: a case the system writes (an - // email-to-case integration, an import). An anonymous web form's grant - // cannot read the pool, so its cases wait in the triage queue instead. + // Ownerless intake: a case the system writes (an email-to-case + // integration, an import). An anonymous web form's case reaches the same + // pool, read elevated (#2014; pinned in case-assignment.test.ts). const [kase] = await verify.seed('crm_case', [{ subject: 'Email-to-case: cannot sign in', description: 'Forwarded from support@.', status: 'new', }]); diff --git a/test/flow-record-change.test.ts b/test/flow-record-change.test.ts index e5b7f647..c6714955 100644 --- a/test/flow-record-change.test.ts +++ b/test/flow-record-change.test.ts @@ -367,7 +367,8 @@ describe('lead_assignment — hot-lead SLA routing', () => { * A lead with NO owner (#2060 item 6). * * Web-to-lead lands ownerless whenever `lead_auto_assign` stands down (an - * empty `sales_rep` pool, or one the anonymous form grant cannot read), and a + * empty `sales_rep` pool, or a pool read that fails — the hook reads it + * elevated since #2014, so the form's own grant no longer decides it), and a * seed or integration write carries whatever owner it was given. The SLA stamp * is the routing decision about the lead and lands either way; the alert has * nobody to go to. `notify` refuses an audience that evaluates to nothing, so @@ -687,9 +688,10 @@ describe('record-change flows under a user-less trigger (#684)', () => { expect(run?.status, String(run?.error ?? '')).toBe('completed'); const [stored] = await verify.rows('crm_lead', { id: lead.id }); expect(stored!.next_followup_date, 'hot lead got no SLA date').toBeTruthy(); - // The anonymous form grant cannot read the rep pool, so `lead_auto_assign` - // leaves a web lead unowned (its documented stand-down). - expect(stored!.owner_id).toBeNull(); + // `lead_auto_assign` reads the rep pool elevated (#2014), so the web lead + // lands on the file's one rep — and the hot-lead alert reaches them. + expect(stored!.owner_id, 'a web lead was not routed to the rep').toBe(rep.id); + expect(await notified(rep.id, 'lead_routing', `/crm_lead/${lead.id}`), 'the web lead alerted nobody').toHaveLength(1); }); it('lead_assignment alerts the owner of an integration-written lead', async () => { diff --git a/test/guest-submission-sanitisation.test.ts b/test/guest-submission-sanitisation.test.ts index 37962ba1..8d8a938a 100644 --- a/test/guest-submission-sanitisation.test.ts +++ b/test/guest-submission-sanitisation.test.ts @@ -204,15 +204,12 @@ describe('crm_case — guest submission sanitisation', () => { ).not.toHaveProperty(retired); } - // The planted owner is gone. `case_auto_assign` runs after this strip, and - // only on a case the strip left ownerless — but a public-form submission - // writes under the `guest_portal` grant, which cannot read the agent pool, - // so the assignment stands down and the case lands unowned, for the triage - // queue (`case_unassigned_triage_sharing`). Measured under the anonymous - // door's own execution context; an empty context (which no door produces) - // read the pool and placed the case on the agent. + // The planted owner is gone. `case_auto_assign` runs after this strip, on + // the case the strip left ownerless, and reads the agent pool elevated + // (#2014) — so the case is owned by the real agent: a POSITIVE value, not + // an absence. expect(stored.owner_id).not.toBe('planted_user'); - expect(stored.owner_id).toBeNull(); + expect(stored.owner_id).toBe(id.agent); }, 60_000); it('derives is_closed from status rather than letting a guest state it', async () => { @@ -297,12 +294,10 @@ describe('crm_lead — guest submission sanitisation', () => { // submission — and the link beside it can name any record id they care to // guess. // - // What this case does NOT assert is that the dedupe then ran and wrote its - // own `suspected` verdict. It cannot: an anonymous submission holds the - // `guest_portal` grant, which is INSERT-only on `crm_lead` and denies the - // reads that check needs, so `lead_duplicate_check` swallows the denial by - // design and a guest's duplicate lands unflagged. Asserting `suspected` here - // would pin a fiction. The security property is that the guest's own verdict + // What this case does NOT assert is a verdict of the dedupe's own: the + // address repeats nothing on this stack, so the scan (elevated since #2014, + // and pinned flagging a real web duplicate in hooks-runtime-service) finds + // nothing to write. The security property is that the guest's own verdict // does not survive, and that is what is measured. const second = await insertAs(GUEST, 'crm_lead', { first_name: 'Grace', last_name: 'Hopper', company: 'Mark II', diff --git a/test/helpers/verify-stack.ts b/test/helpers/verify-stack.ts index 53ece834..2902ff96 100644 --- a/test/helpers/verify-stack.ts +++ b/test/helpers/verify-stack.ts @@ -254,13 +254,12 @@ export const predicateUpdate = async ( * branches key on. The platform's anonymous door is `POST /forms/:slug/submit` * (`@objectstack/rest` `registerFormEndpoints`), which writes under exactly * this execution context: the form's one-object grant, the `guest_portal` - * permission set, `anonymous`. The handle's dispatcher does not serve that - * route (measured on 17.7.0: `ENDPOINT_NOT_FOUND`), `hooks.run` needs a - * signed-in person, `seed` is the most trusted caller there is, and an - * unauthenticated `POST /api/v1/data/` answers 401, so the handle - * offers no way to reach that branch at all. The door also drops every key the - * form does not collect; a fixture that passes another key is modelling a form - * that collects it. + * permission set, `anonymous`. The handle's dispatcher did not serve that + * route on 17.7.0 (`ENDPOINT_NOT_FOUND`); since 18.0.0-next.0 `stack.api` + * reaches it, and a case about the door's ANSWER goes through it (#2014). This + * helper stays for the cases about what the hooks STORE: the door also drops + * every key the form does not collect, so a fixture that passes another key is + * modelling a form that collects it. */ export const guestInsert = (stack: VerifyStack, object: string, doc: Row): Promise => stack.kernel.getService('objectql').insert(object, doc, { diff --git a/test/hooks-runtime-service.test.ts b/test/hooks-runtime-service.test.ts index 7a4b501c..a67fd370 100644 --- a/test/hooks-runtime-service.test.ts +++ b/test/hooks-runtime-service.test.ts @@ -144,8 +144,11 @@ describe('case_sla_defaults', () => { // The branch OVERWRITES with a safe value; it does not remove the key // (#1133) — `delete` on a hook's `input` is a silent no-op through the real // engine. So the stored values are what is asserted. + // The planted owner is gone: the elevated round-robin after the strip + // writes the pool's choice (the agent), never the submitter's (#2014). + expect(row.owner_id, 'the planted owner survived, or the web case was not routed').toBe(agent.id); for (const [stripped, safe] of [ - ['owner_id', null], ['internal_notes', null], ['resolution', null], + ['internal_notes', null], ['resolution', null], // The flag AND the prose explaining it (#1296): a case stating an escalation // reason while not escalated contradicts itself in one field group. ['is_escalated', false], ['escalation_reason', null], @@ -1077,7 +1080,7 @@ describe('lead_automation', () => { const written = await guestInsert(verify, 'crm_lead', { first_name: 'Web', last_name: `Visitor ${++k}`, email: `visitor${k}@hooks-runtime-service.test`, company: 'FromWebForm', is_converted: true, converted_account: account.id, converted_date: '2020-01-01', - owner_id: rep.id, + owner_id: manager.id, }); const lead = await stored('crm_lead', written.id); expect(lead.lead_source).toBe('web'); @@ -1085,9 +1088,12 @@ describe('lead_automation', () => { // Overwritten with a safe value rather than removed (#1133) — see the // matching note on `case_sla_defaults` above. expect(lead.is_converted, 'public form kept is_converted').toBe(false); - for (const stripped of ['converted_account', 'converted_contact', 'converted_opportunity', 'converted_date', 'owner_id']) { + for (const stripped of ['converted_account', 'converted_contact', 'converted_opportunity', 'converted_date']) { expect(lead[stripped] ?? null, `public form kept ${stripped}`).toBeNull(); } + // The planted owner (not a rep) is gone: the elevated round-robin after the + // strip writes the pool's choice, never the submitter's (#2014). + expect(lead.owner_id, 'public form kept owner_id, or the web lead was not routed').toBe(rep.id); }); it('locks identity fields on a converted lead but leaves notes editable', async () => { @@ -1240,18 +1246,30 @@ describe('lead_duplicate_check', () => { expect(lead.duplicate_status ?? null).toBeNull(); }); - it('writes nothing and throws nothing when the lookup is denied (anonymous Web-to-Lead)', async () => { - // A guest can INSERT on crm_lead and read NOTHING. Propagating that denial - // would reject the very submission this feature exists to accept — the - // duplicate lands unflagged rather than not landing at all. + it('flags an anonymous Web-to-Lead duplicate too — the scan is elevated (#2014)', async () => { + // A guest can INSERT on crm_lead and read NOTHING, so as the caller a web + // duplicate always landed unflagged. Whether an address is known is the + // CRM's fact; what the form answers the guest is pinned in hooks-runtime. const address = fresh(); - await leadOf({ email: address }); + const first = await leadOf({ email: address }); const written = await guestInsert(verify, 'crm_lead', { first_name: 'Ada', last_name: 'Guest', company: 'Acme', email: address, }); const lead = await stored('crm_lead', written.id); - expect(lead.email).toBe(address); - expect(lead.duplicate_status ?? null).toBeNull(); + expect(lead.duplicate_status).toBe('suspected'); + expect(lead.duplicate_of_lead).toBe(first.id); + }); + + it('finds a colleague’s record the writing rep cannot read (#2014)', async () => { + // As the caller a rep's scan saw only what the rep may read, so a repeat of + // a manager's contact went unflagged. The link names a record on the new + // lead; the suspected-duplicates queue is where the pair is compared. + const address = fresh(); + const [theirs] = await verify.seed('crm_contact', [{ + first_name: 'Ada', last_name: 'Theirs', email: address, crm_account: (await accountOf({ owner_id: manager.id })).id, owner_id: manager.id, + }]); + expect(await verify.rows('crm_contact', { id: theirs!.id }, as(rep)), 'the rep can read it, so this case proves nothing').toEqual([]); + expect((await repLead({ email: address })).duplicate_of_contact).toBe(theirs!.id); }); it('runs after lead_automation, so a guest cannot spoof a verdict past the strip', async () => { @@ -1259,8 +1277,8 @@ describe('lead_duplicate_check', () => { // client-supplied `duplicate_status: 'confirmed'` that lead_automation's // guest branch is there to null, and stand down. Nulled rather than removed // (#1133): `lead_duplicate_check` stands down on a NON-BLANK verdict and its - // own `isBlank` counts `null` as blank. (The check itself then cannot read - // anything under the anonymous grant — the case above.) + // own `isBlank` counts `null` as blank. (The address is fresh, so the + // elevated scan finds nothing to flag either.) const check = (leadHooks as Rec[]).find((h) => h.name === 'lead_duplicate_check')!; const automation = (leadHooks as Rec[]).find((h) => h.name === 'lead_automation')!; expect(check.priority).toBeGreaterThan(automation.priority); @@ -1278,14 +1296,13 @@ describe('lead_duplicate_check', () => { }); }); -describe('lead_auto_assign — permission resilience', () => { - it('never throws when the rep-pool lookup is denied (anonymous Web-to-Lead)', async () => { - // The public-form grant denies `find` on sys_user_position. The hook must - // swallow that and leave the lead ownerless — NOT reject the insert. +describe('lead_auto_assign — the anonymous door', () => { + it('routes an anonymous Web-to-Lead submission to the rep pool (#2014)', async () => { + // The public-form grant reads nothing; the hook reads the pool elevated. const written = await guestInsert(verify, 'crm_lead', { first_name: 'Web', last_name: `Form ${++k}`, company: 'FromWebForm', email: `form${k}@hooks-runtime-service.test`, }); - expect((await stored('crm_lead', written.id)).owner_id ?? null).toBeNull(); + expect((await stored('crm_lead', written.id)).owner_id).toBe(rep.id); }); }); diff --git a/test/hooks-runtime.test.ts b/test/hooks-runtime.test.ts index 9e616b01..7a99210f 100644 --- a/test/hooks-runtime.test.ts +++ b/test/hooks-runtime.test.ts @@ -313,12 +313,79 @@ describe('lead_auto_assign', () => { expect((await stored('crm_lead', written!.id)).owner_id).toBe(rep.id); }); - it('never throws when the rep-pool lookup is denied (anonymous Web-to-Lead)', async () => { - // The public-form grant denies `find` on sys_user_position. The hook must - // swallow that and leave the lead ownerless — NOT reject the insert. + it('routes an anonymous Web-to-Lead submission into the rep pool (#2014)', async () => { + // The form's own grant reads nothing, so as the caller the pool read came + // back empty and every web lead landed ownerless. The hook declares + // `runAs: 'system'`: who works an enquiry is the business's routing. const written = await guestInsert(verify, 'crm_lead', lead({ company: 'FromWebForm' })); const row = await stored('crm_lead', written.id); - expect(row.company).toBe('FromWebForm'); - expect(row.owner_id).toBeNull(); // ownerless, but the insert proceeded + expect(row.lead_source, 'not the guest submission this case is about').toBe('web'); + expect([rep.id, rep2.id], 'a web lead was not routed to the rep pool').toContain(row.owner_id); }); + + /** + * The acceptance of #2014's F9 at the platform's own anonymous door, + * `POST /forms/:slug/submit` (`verify.api` serves it since 18.0.0-next.0). + * Both elevated hooks find something about this submission — an owner, and + * the contact it repeats — and neither finding may reach the submitter: + * since objectstack#22437 the door answers the new record's id and nothing + * else, so no public form is an email-existence oracle (maintainer ruling on + * #2014, item 4 B: the platform fixed the door first). + */ + it('a web lead is routed and flagged, and the anonymous answer carries only its id (#2014)', async () => { + const address = `known${++k}@hooks-runtime.test`; + const [known] = await verify.seed('crm_contact', [{ + first_name: 'Ada', last_name: 'Known', email: address, crm_account: (await accountOf()).id, owner_id: rep.id, + }]); + const res = await verify.api('/forms/contact-us/submit', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ first_name: 'Ada', last_name: 'Again', email: address.toUpperCase(), company: 'Web Co', description: 'Back again.' }), + }); + expect(res.status).toBe(201); + const answer = await res.json() as Rec; + expect(Object.keys(answer), 'the anonymous answer carries more than the new id').toEqual(['id']); + const row = await stored('crm_lead', String(answer.id)); + expect(row.lead_source, 'not the guest submission this case is about').toBe('web'); + expect([rep.id, rep2.id], 'a web lead was not routed to the rep pool').toContain(row.owner_id); + expect(row.duplicate_status, 'a web duplicate was not flagged').toBe('suspected'); + expect(row.duplicate_of_contact).toBe(known!.id); + }); + + it('both lead intake hooks stay inside the new lead’s organization (AGENTS.md rule 10)', async () => { + // Elevated, the rep pool, the load counts and the duplicate scan are + // organization-neutral, so each is pinned to the organization the writer + // named on the lead. Two organizations: the other one's rep is the LESS + // loaded, and the other one holds a contact at the same address — an + // unpinned read picks that rep and flags that contact. A boot (and a + // build) of its own: once an install holds two organizations, every + // system insert must name one. + const twoOrgs = await bootStack(await buildArtifactAgain(), bootOptions()); + const orgs = await twoOrgs.seed('sys_organization', [ + { name: 'Lead Org', slug: 'lead-org' }, { name: 'Other Org', slug: 'other-org' }, + ]); + const [own, other] = [String(orgs[0]!.id), String(orgs[1]!.id)]; + const ownRep = await signUpPerson(twoOrgs, 'own.rep@hooks-runtime.test', { name: 'Own Rep' }); + const otherRep = await signUpPerson(twoOrgs, 'other.rep@hooks-runtime.test', { name: 'Other Rep' }); + await twoOrgs.seed('sys_user_position', [ + { user_id: ownRep.id, position: 'sales_rep', organization_id: own }, + { user_id: otherRep.id, position: 'sales_rep', organization_id: other }, + ]); + await twoOrgs.seed('crm_lead', [lead({ owner_id: ownRep.id, organization_id: own })]); + const contactIn = async (organization_id: string, email: string) => { + const [acct] = await twoOrgs.seed('crm_account', [{ name: `Org Co ${++k}`, organization_id }]); + return (await twoOrgs.seed('crm_contact', [{ first_name: 'Ada', last_name: 'Org', email, crm_account: acct!.id, organization_id }]))[0]!; + }; + const ownContact = await contactIn(own, `own${++k}@hooks-runtime.test`); + const shared = `shared${++k}@hooks-runtime.test`; + await contactIn(other, shared); + const intake = async (email: string) => { + const [written] = await twoOrgs.seed('crm_lead', [lead({ email, organization_id: own })]); + return (await twoOrgs.rows('crm_lead', { id: written!.id }))[0]!; + }; + const crossing = await intake(shared); + expect(crossing.owner_id, 'the intake crossed into another organization’s rep pool').toBe(ownRep.id); + expect(crossing.duplicate_status ?? null, 'the duplicate scan matched a contact in another organization').toBeNull(); + // Anti-vacuum: the scan does run on this boot, inside the organization. + expect((await intake(String(ownContact.email))).duplicate_of_contact, 'the scan found nothing at all').toBe(ownContact.id); + }, 120_000); }); diff --git a/test/ownership-model.test.ts b/test/ownership-model.test.ts index abf24c91..dd9aaa1b 100644 --- a/test/ownership-model.test.ts +++ b/test/ownership-model.test.ts @@ -451,8 +451,8 @@ describe('lead_auto_assign assigns the platform column', () => { it('writes owner_id, not a second column', async () => { // Genuinely ownerless intake: a row written by the system with no owner — // the posture an import or a seed load writes under (the platform's own - // seed context). A public-form submission does not reach the pool at all: - // its grant cannot read `sys_user_position`, so the hook stands down. + // seed context). A public-form submission reaches the same pool, read + // elevated (#2014; pinned in hooks-runtime.test.ts). const [written] = await verify.seed('crm_lead', [lead()]); const [stored] = await verify.rows('crm_lead', { id: written!.id }); expect(stored!.owner_id, 'did not assign the least-loaded rep on owner_id').toBe(repB.id);