diff --git a/.changeset/2060-item-3-agent-loses-case.md b/.changeset/2060-item-3-agent-loses-case.md new file mode 100644 index 000000000..9c8f493e6 --- /dev/null +++ b/.changeset/2060-item-3-agent-loses-case.md @@ -0,0 +1,7 @@ +--- +"hotcrm": patch +--- + +**A service agent no longer loses a case the moment it is escalated.** An escalated case is handed to the Service Manager with the fewest open cases, and an agent only sees the cases they own. So an agent who logged a Critical case saw "Case created successfully" over a "Record not found" page. An agent who pressed Escalate Case got the same page when it refreshed. + +The case now records the agent it was taken from in a new read-only **Escalated From** field. A new sharing rule, *Escalated Cases — Previous Owner*, lets that agent read the case until it is closed. They can follow the case but cannot edit it: the manager works it. No other agent gains access, and the manager's and director's access is unchanged. A case escalated with no owner names nobody, and nobody can type a name into the field. diff --git a/content/docs/administration/sharing-and-security.mdx b/content/docs/administration/sharing-and-security.mdx index f09aec672..11f1639a9 100644 --- a/content/docs/administration/sharing-and-security.mdx +++ b/content/docs/administration/sharing-and-security.mdx @@ -95,6 +95,7 @@ Criteria-based rules are the enforced flavour: matching records materialise real | Escalated Cases Sharing | Case | Edit | `service_manager` — open critical cases | | Escalated Cases — Service Director | Case | Read | `service_director` — the same cases, one rung up | | Unassigned Cases — Triage | Case | Edit | `service_agent` — unowned cases, until they are Resolved or Closed | +| Escalated Cases — Previous Owner | Case | Read | the user in the case's `escalated_from` field — the agent an escalation took it from, until it is Closed | | Live Campaigns — Marketing Manager | Campaign | Edit | `marketing_manager` — planning / in-progress campaigns | | Live Campaigns — Marketing Director | Campaign | Edit | `marketing_director` — the same campaigns | diff --git a/content/docs/administration/sharing-and-security.zh-Hans.mdx b/content/docs/administration/sharing-and-security.zh-Hans.mdx index 412b38750..ffe1e3f5c 100644 --- a/content/docs/administration/sharing-and-security.zh-Hans.mdx +++ b/content/docs/administration/sharing-and-security.zh-Hans.mdx @@ -95,6 +95,7 @@ na_sales_team eu_sales_team (区域分组) | Escalated Cases Sharing | 工单 | 编辑 | `service_manager` —— 未关闭的严重工单 | | Escalated Cases — Service Director | 工单 | 读取 | `service_director` —— 同一批工单,上一层级 | | Unassigned Cases — Triage | 工单 | 编辑 | `service_agent` —— 没有负责人的工单,直到它被 Resolved 或 Closed | +| Escalated Cases — Previous Owner | 工单 | 读取 | 工单 `escalated_from` 字段里的那个人 —— 升级从他手上转走的工单,直到它被 Closed | | Live Campaigns — Marketing Manager | 营销活动 | 编辑 | `marketing_manager` —— 筹划中 / 进行中的活动 | | Live Campaigns — Marketing Director | 营销活动 | 编辑 | `marketing_director` —— 同一批活动 | diff --git a/content/docs/administration/sharing-and-security.zh-Hant.mdx b/content/docs/administration/sharing-and-security.zh-Hant.mdx index 55b5e391a..f16b3ecac 100644 --- a/content/docs/administration/sharing-and-security.zh-Hant.mdx +++ b/content/docs/administration/sharing-and-security.zh-Hant.mdx @@ -97,6 +97,7 @@ na_sales_team eu_sales_team (區域分組) | Escalated Cases Sharing | 工單 | 編輯 | `service_manager` —— 未關閉的嚴重工單 | | Escalated Cases — Service Director | 工單 | 讀取 | `service_director` —— 同一批工單,上一層級 | | Unassigned Cases — Triage | 工單 | 編輯 | `service_agent` —— 沒有負責人的工單,直到它被 Resolved 或 Closed | +| Escalated Cases — Previous Owner | 工單 | 讀取 | 工單 `escalated_from` 欄位裡的那個人 —— 升級從他手上轉走的工單,直到它被 Closed | | Live Campaigns — Marketing Manager | 行銷活動 | 編輯 | `marketing_manager` —— 籌劃中 / 進行中的活動 | | Live Campaigns — Marketing Director | 行銷活動 | 編輯 | `marketing_director` —— 同一批活動 | diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index c14a1b377..019b06de3 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -52,7 +52,7 @@ This drives reporting (which channels generate the most volume) and is auto-fill ## What a case record stores -`crm_case` declares **24 fields** (`src/service/objects/case.object.ts`), and three different things organise them: the object's own field groups, the case detail screen, and the case form. Only the first of the three has six sections, and no two of them agree — so it is worth knowing which one you are being shown. +`crm_case` declares **25 fields** (`src/service/objects/case.object.ts`), and three different things organise them: the object's own field groups, the case detail screen, and the case form. Only the first of the three has six sections, and no two of them agree — so it is worth knowing which one you are being shown. ### The object's field groups @@ -64,7 +64,7 @@ The six names below are real, but not one of them is a screen: they are `crm_cas | **Origin & Routing** | Case Owner, Case Origin | | **SLA & Priority** | Priority, Closed Date, First Response Date, Resolution Time (Hours), SLA Due Date, SLA Violated | | **Resolution** | Resolution, Resolved by Article | -| **Escalation** *(collapsed by default)* | Escalated, Escalated Date, Escalation Reason | +| **Escalation** *(collapsed by default)* | Escalated, Escalated Date, Escalation Reason, Escalated From | | **System** *(collapsed by default)* | Internal Notes, Is Closed | Five of those six rows used to be written differently on this page, and the differences were not cosmetic: **Status** and **Case Type** are in *Case Information*, not *SLA & Priority*; **Closed Date** is in *SLA & Priority*, not *Resolution*; and *System* holds **Internal Notes** and **Is Closed** — there is no *audit trail* field on a case at all, and the timestamps sit in *SLA & Priority*. Only *Escalation* was right as it stood. **Priority Rank**, the hidden sort key that orders *My Open Cases*, is in no group at all. @@ -85,7 +85,7 @@ The tab is shorter than its section names suggest, and that is deliberate: a sec ### The form -The case form (`src/service/views/case.view.ts`) is **not** tabbed. It is a single section, holding **9** of the 24 fields: +The case form (`src/service/views/case.view.ts`) is **not** tabbed. It is a single section, holding **9** of the 25 fields: | Section | Fields | | --- | --- | @@ -162,6 +162,7 @@ Nothing else claims a case, and each exclusion is deliberate: - By default, cases are **private** — only the owner. Positions are flat and **nothing rolls up**, so no manager sees a case by sitting above its owner. - **Critical open cases** are auto-shared by two rules, one per rung: *Escalated Cases Sharing* grants read/edit to `service_manager`, and *Escalated Cases — Service Director* grants read to `service_director`. Both rungs are named explicitly because neither inherits from the other. - **Unowned open cases** are shared with every `service_agent` by *Unassigned Cases — Triage*, read and edit — the grant behind the **Unassigned — triage** tab. It lasts exactly as long as the case has no owner and is neither *Resolved* nor *Closed*, and claiming the case is what ends it: from that point you hold the case the ordinary way, as its owner. +- **An escalated case stays readable by the agent it was taken from.** The hand-off records that agent in **Escalated From**, and *Escalated Cases — Previous Owner* shares the case with them, read-only, until it is *Closed* — so the agent who logs a Critical case or presses **Escalate Case** can still open it. Working it is the manager's: the agent cannot edit it. ## Field-level security diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index 1e6b1a5b3..0646fcdde 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -52,7 +52,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ## 工单记录存储的内容 -`crm_case` 一共声明 **24 个字段**(`src/service/objects/case.object.ts`),而把它们组织起来的东西有三样:对象自己的字段分组、工单详情界面、工单表单。三者里只有第一样是六个分区,而且两两都不一致——所以要紧的是分清你看的是哪一样。 +`crm_case` 一共声明 **25 个字段**(`src/service/objects/case.object.ts`),而把它们组织起来的东西有三样:对象自己的字段分组、工单详情界面、工单表单。三者里只有第一样是六个分区,而且两两都不一致——所以要紧的是分清你看的是哪一样。 ### 对象自己的字段分组 @@ -64,7 +64,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 | **Origin & Routing** | 负责人、来源 | | **SLA & Priority** | 优先级、关闭日期、首次响应日期、解决时间(小时)、SLA 截止日期、SLA Violated | | **Resolution** | 解决方案、解决所用文章 | -| **Escalation** *(默认折叠)* | 升级标记、升级日期、升级原因 | +| **Escalation** *(默认折叠)* | 升级标记、升级日期、升级原因、升级前负责人 | | **System** *(默认折叠)* | 内部备注、关闭标记 | 这六行里有五行,本页原先写的是另一个样子,而且差的不是细枝末节:**状态**与**类型**在 *Case Information* 里,不在 *SLA & Priority*;**关闭日期**在 *SLA & Priority* 里,不在 *Resolution*;*System* 装的是**内部备注**与**关闭标记**——工单上根本没有名为「审计跟踪」的字段,时间戳则待在 *SLA & Priority* 里。六行里只有 *Escalation* 原样就是对的。**优先级排序**——把 *我的待处理工单* 排出先后的那个隐藏排序键——则不属于任何一个分组。 @@ -85,7 +85,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 ### 表单 -工单表单(`src/service/views/case.view.ts`)**不是**分页签的。它只有一个分区,装着 24 个字段中的 **9** 个: +工单表单(`src/service/views/case.view.ts`)**不是**分页签的。它只有一个分区,装着 25 个字段中的 **9** 个: | 分区 | 字段 | | --- | --- | @@ -162,6 +162,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 - 默认情况下,工单是**私有的**——只有所有者可见。岗位是扁平的,**什么都不会上卷**,没有哪位经理因为“在所有者之上”就能看到某个工单。 - **严重未结工单**由两条规则自动共享,一个层级一条:*Escalated Cases Sharing* 把读/写授予 `service_manager`,*Escalated Cases — Service Director* 把读授予 `service_director`。两个层级都被显式点名,因为谁也不会从对方那里继承。 - **无主的未结工单**由 *Unassigned Cases — Triage* 共享给每一位 `service_agent`,读 + 编辑——这就是 **未分派 — 待分诊** 标签页背后的那条授权。它的有效期恰好是「这张工单没有负责人,且既不是 *Resolved* 也不是 *Closed*」这段时间,而认领正是终结它的那个动作:从那一刻起,你以负责人的身份、用最普通的方式持有这张工单。 +- **升级后的工单,被转走的那位客服仍能查看。** 交接会把这位客服记在**升级前负责人**里,*Escalated Cases — Previous Owner* 把工单以只读方式共享给他,直到它被 *Closed*——所以登记 Critical 工单或点了 **Escalate Case** 的客服仍能打开它。处理它是经理的事:这位客服不能编辑。 ## 字段级安全 diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index e5d4d9507..8ff6871bd 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -54,7 +54,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ## 工單記錄儲存的內容 -`crm_case` 一共宣告 **24 個欄位**(`src/service/objects/case.object.ts`),而把它們組織起來的東西有三樣:物件自己的欄位分組、工單詳情介面、工單表單。三者裡只有第一樣是六個分區,而且兩兩都不一致——所以要緊的是分清你看的是哪一樣。 +`crm_case` 一共宣告 **25 個欄位**(`src/service/objects/case.object.ts`),而把它們組織起來的東西有三樣:物件自己的欄位分組、工單詳情介面、工單表單。三者裡只有第一樣是六個分區,而且兩兩都不一致——所以要緊的是分清你看的是哪一樣。 ### 物件自己的欄位分組 @@ -66,7 +66,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 | **Origin & Routing** | 負責人、來源 | | **SLA & Priority** | 優先順序、關閉日期、首次回應日期、解決時間(小時)、SLA 截止日期、SLA Violated | | **Resolution** | 解決方案、解決所用文章 | -| **Escalation** *(預設折疊)* | 升級標記、升級日期、升級原因 | +| **Escalation** *(預設折疊)* | 升級標記、升級日期、升級原因、升級前負責人 | | **System** *(預設折疊)* | 內部備註、關閉標記 | 這六列裡有五列,本頁原先寫的是另一個樣子,而且差的不是細枝末節:**狀態**與**類型**在 *Case Information* 裡,不在 *SLA & Priority*;**關閉日期**在 *SLA & Priority* 裡,不在 *Resolution*;*System* 裝的是**內部備註**與**關閉標記**——工單上根本沒有名為「審計追蹤」的欄位,時間戳則待在 *SLA & Priority* 裡。六列裡只有 *Escalation* 原樣就是對的。**優先順序排序**——把 *我的待處理工單* 排出先後的那個隱藏排序鍵——則不屬於任何一個分組。 @@ -87,7 +87,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 ### 表單 -工單表單(`src/service/views/case.view.ts`)**不是**分頁籤的。它只有一個分區,裝著 24 個欄位中的 **9** 個: +工單表單(`src/service/views/case.view.ts`)**不是**分頁籤的。它只有一個分區,裝著 25 個欄位中的 **9** 個: | 分區 | 欄位 | | --- | --- | @@ -164,6 +164,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 - 預設情況下,工單是**私有的**——只有所有者可見。職位是扁平的,**什麼都不會上捲**,沒有哪位經理因為「在所有者之上」就能看到某個工單。 - **嚴重未結工單**由兩條規則自動共享,一個層級一條:*Escalated Cases Sharing* 把讀/寫授予 `service_manager`,*Escalated Cases — Service Director* 把讀授予 `service_director`。兩個層級都被顯式點名,因為誰也不會從對方那裡繼承。 - **無主的未結工單**由 *Unassigned Cases — Triage* 共享給每一位 `service_agent`,讀 + 編輯——這就是 **未分派 — 待分診** 標籤頁背後的那條授權。它的有效期恰好是「這張工單沒有負責人,且既不是 *Resolved* 也不是 *Closed*」這段時間,而認領正是終結它的那個動作:從那一刻起,你以負責人的身分、用最普通的方式持有這張工單。 +- **升級後的工單,被轉走的那位客服仍能檢視。** 交接會把這位客服記在**升級前負責人**裡,*Escalated Cases — Previous Owner* 把工單以唯讀方式共用給他,直到它被 *Closed*——所以登記 Critical 工單或點了 **Escalate Case** 的客服仍能開啟它。處理它是經理的事:這位客服不能編輯。 ## 欄位級安全 diff --git a/docs/STATUS.md b/docs/STATUS.md index b66036eee..02740fc92 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,7 +19,7 @@ loader registers: ```text HotCRM v4.0.1 -Data: 18 Objects 357 Fields +Data: 18 Objects 358 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions Logic: 33 Flows Security: 12 Positions 7 Permissions diff --git a/src/docs/crm_admin.md b/src/docs/crm_admin.md index 69b529b7b..cd3501f8c 100644 --- a/src/docs/crm_admin.md +++ b/src/docs/crm_admin.md @@ -19,6 +19,7 @@ sources: - sharing_rule:opportunity_executive_sharing - sharing_rule:case_escalation_sharing - sharing_rule:case_director_sharing + - sharing_rule:case_escalated_from_sharing - sharing_rule:campaign_leadership_manager - sharing_rule:campaign_leadership_director - position:crm_positions @@ -65,6 +66,7 @@ Ownership-based access is widened by these rules: | Large Open Deals — Executive | Opportunity | **Read** | The same deals at the Executive rung. | | Case Escalation | Case | **Edit** | Escalated cases become editable by the escalation handler. | | Escalated Cases — Service Director | Case | **Read** | Service Director sees what the manager is paged about. | +| Escalated Cases — Previous Owner | Case | **Read** | The agent an escalation took the case from keeps sight of it until it is closed. | | Live Campaigns — Marketing Manager / Director | Campaign | **Edit** | Marketing leadership works any live campaign. | Detail objects — opportunity line items, quote line items, campaign members and diff --git a/src/sales/translations/en/objects.service.ts b/src/sales/translations/en/objects.service.ts index 21413b271..9bbf3cf3e 100644 --- a/src/sales/translations/en/objects.service.ts +++ b/src/sales/translations/en/objects.service.ts @@ -66,6 +66,7 @@ export const service: Record = { display_title: { label: 'Display Title' }, priority_rank: { label: 'Priority Rank' }, escalated_date: { label: 'Escalated Date' }, + escalated_from: { label: 'Escalated From' }, }, _views: { all_cases: { label: 'All Cases' }, diff --git a/src/sales/translations/es-ES/objects.service.ts b/src/sales/translations/es-ES/objects.service.ts index fbfb6c161..bfe137757 100644 --- a/src/sales/translations/es-ES/objects.service.ts +++ b/src/sales/translations/es-ES/objects.service.ts @@ -75,6 +75,7 @@ export const service: Record = { is_sla_violated: { label: 'SLA Incumplido' }, is_escalated: { label: 'Escalado' }, escalated_date: { label: 'Fecha de Escalación' }, + escalated_from: { label: 'Escalado desde' }, escalation_reason: { label: 'Motivo de Escalación' }, resolution: { label: 'Resolución' }, resolved_by_article: { label: 'Resuelto por Artículo', help: 'Artículo de la base de conocimiento que resolvió este caso — la señal de desvío.' }, diff --git a/src/sales/translations/ja-JP/objects.service.ts b/src/sales/translations/ja-JP/objects.service.ts index 7698fcfaa..b53c76459 100644 --- a/src/sales/translations/ja-JP/objects.service.ts +++ b/src/sales/translations/ja-JP/objects.service.ts @@ -73,6 +73,7 @@ export const service: Record = { is_sla_violated: { label: 'SLA違反' }, is_escalated: { label: 'エスカレーション済' }, escalated_date: { label: 'エスカレーション日' }, + escalated_from: { label: 'エスカレーション元' }, escalation_reason: { label: 'エスカレーション理由' }, resolution: { label: '解決内容' }, resolved_by_article: { label: '解決したナレッジ記事', help: 'このケースを解決したナレッジ記事 — 逸らし率の指標。' }, diff --git a/src/sales/translations/zh-CN/objects.service.ts b/src/sales/translations/zh-CN/objects.service.ts index 1a70499b4..9a974fddc 100644 --- a/src/sales/translations/zh-CN/objects.service.ts +++ b/src/sales/translations/zh-CN/objects.service.ts @@ -86,6 +86,7 @@ export const service: Record = { display_title: { label: '显示名称' }, priority_rank: { label: '优先级排序' }, escalated_date: { label: '升级日期' }, + escalated_from: { label: '升级前负责人' }, }, _views: { all_cases: { label: '全部工单' }, diff --git a/src/service/index.ts b/src/service/index.ts index e06024947..9584886ed 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -21,7 +21,7 @@ import { CasesByStatusPriorityReport, SlaPerformanceReport, CasesOpenedByDayPriorityReport, } from './reports/index.js'; import { - CaseDirectorSharingRule, CaseEscalationSharingRule, CaseUnassignedTriageSharingRule, + CaseDirectorSharingRule, CaseEscalatedFromSharingRule, CaseEscalationSharingRule, CaseUnassignedTriageSharingRule, } from './sharing/index.js'; import { CaseTriageSkill } from './skills/index.js'; import { CaseViews, KnowledgeArticleViews } from './views/index.js'; @@ -50,7 +50,7 @@ export const serviceSkills = [CaseTriageSkill]; /** This package's sharing rules — see {@link serviceHooks}. */ export const serviceSharingRules = [ - CaseEscalationSharingRule, CaseDirectorSharingRule, CaseUnassignedTriageSharingRule, + CaseEscalationSharingRule, CaseDirectorSharingRule, CaseUnassignedTriageSharingRule, CaseEscalatedFromSharingRule, ]; /** @@ -73,7 +73,7 @@ export type HotCrmServiceCollections = Pick; * every item authored against them: their hooks, views, the case detail page, * the seven case flows, the case and article actions, `case_metrics` and the * three reports over it, the Service Overview dashboard, the case-triage skill, - * the three case sharing rules, the two case notification templates and the + * the four case sharing rules, the two case notification templates and the * case and knowledge seed rows. * * Three properties are load-bearing: @@ -109,7 +109,7 @@ export type HotCrmServiceCollections = Pick; * * So this list is exactly what THIS package's metadata needs: `automation` and * `triggers` for the seven case flows, `analytics` for `case_metrics` and the - * items over it, `sharing` for the three case sharing rules. The app package's + * items over it, `sharing` for the four case sharing rules. The app package's * surface capabilities (`auth`, `ui`, `approvals`, `hierarchy-security`) are * not repeated. * diff --git a/src/service/objects/_case-assignment.ts b/src/service/objects/_case-assignment.ts index de51ee949..faa85bba0 100644 --- a/src/service/objects/_case-assignment.ts +++ b/src/service/objects/_case-assignment.ts @@ -433,7 +433,13 @@ export function createCaseEscalationReassign(hookName = 'case_escalation_reassig best = managerId; } } - if (best) input.owner_id = best; + if (!best) return; + input.owner_id = best; + // Record whom the case was taken from, in the same payload: the owner + // loses own-scope read the moment this write lands, and + // `case_escalated_from_sharing` keeps them on it, read-only, while it + // is open (#2060 item 3). An ownerless case was taken from nobody. + if (currentOwner) input.escalated_from = currentOwner; } catch { // Swallow: the hand-off must never reject the escalation. A failed // pool read leaves the case where it is, escalated. diff --git a/src/service/objects/case.object.ts b/src/service/objects/case.object.ts index a2fba6b5c..3a625229e 100644 --- a/src/service/objects/case.object.ts +++ b/src/service/objects/case.object.ts @@ -323,7 +323,19 @@ export const Case = ObjectSchema.create({ label: 'Escalation Reason', group: 'escalation', }), - + + // The owner the escalation hand-off took this case from — stamped only by + // `case_escalation_reassign` (`_case-assignment.ts`), in the escalation + // write itself. `case_escalated_from_sharing` names this column, so the + // person it holds keeps READ on the case while it is open (#2060 item 3). + // ⛔ `readonly` is load-bearing: a writable recipient column would let any + // editor share the case with anyone. Never typed by a person. + escalated_from: Field.lookup('sys_user', { + label: 'Escalated From', + group: 'escalation', + readonly: true, + }), + // No `parent_case`. A case hierarchy was declared here and never // traversed: nothing rolled child cases up, nothing closed or notified them // with the parent, and no service flow read the link. Related cases that diff --git a/src/service/sharing/case.sharing.ts b/src/service/sharing/case.sharing.ts index 1dab795ae..27b5f34f4 100644 --- a/src/service/sharing/case.sharing.ts +++ b/src/service/sharing/case.sharing.ts @@ -187,3 +187,33 @@ export const CaseUnassignedTriageSharingRule = { accessLevel: 'edit' as const, sharedWith: { type: 'position' as const, value: 'service_agent' }, }; + +/** + * The agent an escalation took the case from keeps READ on it while it is open + * (#2060 item 3). + * + * `case_escalation_reassign` hands an escalating case to the `service_manager` + * pool, and `service_agent` reads `crm_case` with `readScope: 'own'` — so the + * agent who logged a Critical case (escalated on create) or pressed Escalate + * lost it in the same save, and the console answered "Record not found" over + * the success toast. The escalation notice links that same agent to the case. + * + * The recipient is RECORD-RELATIVE (`type: 'field'`, #14103): the user the + * hand-off stamps into `escalated_from`, one person per case — never a pool. + * Not `created_by`: measured on the demo, every case an agent owns from intake, + * triage or seed has none, so the creator is the wrong person to key on. + * + * `read`, not `edit`: the manager works the case now; the agent follows it. + * Bounded by `is_closed == false`, the window the two rules above keep on + * purpose (read the note on `CaseEscalationSharingRule`). An empty column + * shares with nobody. + */ +export const CaseEscalatedFromSharingRule = { + name: 'case_escalated_from_sharing', + label: 'Escalated Cases — Previous Owner', + object: 'crm_case', + type: 'criteria' as const, + condition: P`record.is_closed == false`, + accessLevel: 'read' as const, + sharedWith: { type: 'field' as const, value: 'escalated_from' }, +}; diff --git a/src/service/sharing/index.ts b/src/service/sharing/index.ts index 112a95611..a59a0dd96 100644 --- a/src/service/sharing/index.ts +++ b/src/service/sharing/index.ts @@ -10,6 +10,7 @@ export { CaseDirectorSharingRule, + CaseEscalatedFromSharingRule, CaseEscalationSharingRule, CaseUnassignedTriageSharingRule, } from './case.sharing'; diff --git a/test/escalated-case-previous-owner.test.ts b/test/escalated-case-previous-owner.test.ts new file mode 100644 index 000000000..591b56fc2 --- /dev/null +++ b/test/escalated-case-previous-owner.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { hotcrmStack, signUpPerson, systemUpdate, type Person } from './helpers/verify-stack'; + +/** + * #2060 item 3 (B-04): the agent an escalation takes a case from keeps READ on + * it, while it is open — and nobody else gains anything. + * + * `case_escalation_reassign` hands an escalating case to the `service_manager` + * pool and `service_agent` reads `crm_case` own-scope, so the agent lost the + * case in the save that escalated it. The hand-off now stamps + * `escalated_from`, and `case_escalated_from_sharing` (a `field` recipient) + * shares the case with that one user. Every case below is a real write on the + * booted app, read back as the person named. + */ + +type Rec = Record; + +let verify: VerifyStack; +let agent: Person; +let other: Person; +let manager: Person; +let k = 0; + +beforeAll(async () => { + verify = await hotcrmStack(); + const asAgent = { positions: ['service_agent'], permissionSets: ['service_agent'] }; + agent = await signUpPerson(verify, 'agent@escalated-from.test', { name: 'Logging Agent', ...asAgent }); + other = await signUpPerson(verify, 'other@escalated-from.test', { name: 'Other Agent', ...asAgent }); + manager = await signUpPerson(verify, 'manager@escalated-from.test', { + name: 'Pool Manager', positions: ['service_manager'], permissionSets: ['service_manager'], + }); +}, 120_000); + +const stored = async (id: string): Promise => (await verify.rows('crm_case', { id }))[0]!; +const reads = async (who: Person, id: string) => (await verify.rows('crm_case', { id }, { as: who.token })).length === 1; +const newCase = (over: Rec = {}) => ({ subject: `Case ${++k}`, description: 'Customer is down.', status: 'new', ...over }); + +/** The rule-materialised grants on one case, by rule name. */ +async function grants(id: string): Promise { + const rules = new Map((await verify.rows('sys_sharing_rule', {})).map((r: Rec) => [String(r.id), String(r.name)])); + const who = new Map([[agent.id, 'agent'], [other.id, 'other'], [manager.id, 'manager']]); + return (await verify.rows('sys_record_share', { record_id: id, source: 'rule' })) + .map((g: Rec) => `${rules.get(String(g.source_id))}→${who.get(String(g.recipient_id)) ?? g.recipient_id}:${g.access_level}`) + .sort(); +} + +describe('the agent an escalation takes a case from keeps read on it', () => { + it('a case logged Critical: the agent still reads it right after the save', async () => { + const kase = await verify.hooks.run('crm_case', 'insert', newCase({ priority: 'critical' }), { as: agent.token }); + const after = await stored(kase.id); + expect(after.owner_id, 'the case never reached the pool, so this proves nothing').toBe(manager.id); + expect(after.escalated_from).toBe(agent.id); + expect(await reads(agent, kase.id), 'the agent lost the case they just logged').toBe(true); + expect(await reads(other, kase.id), 'another agent can read it').toBe(false); + // The manager's grant is the one it always was; the new one is the agent's, read-only. + expect(await grants(kase.id)).toEqual([ + 'case_escalated_from_sharing→agent:read', + 'case_escalation_sharing→manager:edit', + ]); + }); + + it('a case the agent did not create: escalating it keeps them on it', async () => { + // Seeded as the system, the way intake and the demo book arrive: no creator. + const [kase] = await verify.seed('crm_case', [newCase({ status: 'in_progress', priority: 'high', owner_id: agent.id })]); + expect(kase!.created_by ?? null, 'the agent created this case, so the creator would do').toBeNull(); + await verify.hooks.run('crm_case', 'update', { + id: kase!.id, status: 'escalated', priority: 'critical', escalation_reason: 'Needs a manager.', + }, { as: agent.token }); + expect((await stored(kase!.id)).escalated_from).toBe(agent.id); + expect(await reads(agent, kase!.id)).toBe(true); + expect(await reads(other, kase!.id)).toBe(false); + }); + + it('read only: the agent cannot change the case the manager now works', async () => { + const kase = await verify.hooks.run('crm_case', 'insert', newCase({ priority: 'critical' }), { as: agent.token }); + await expect(verify.hooks.run('crm_case', 'update', { id: kase.id, subject: 'Mine again' }, { as: agent.token })) + .rejects.toMatchObject({ code: 'FORBIDDEN', status: 403 }); + }); + + it('closing the case ends the grant', async () => { + const kase = await verify.hooks.run('crm_case', 'insert', newCase({ priority: 'critical' }), { as: agent.token }); + expect(await reads(agent, kase.id), 'no grant to end, so this proves nothing').toBe(true); + await systemUpdate(verify, 'crm_case', { id: kase.id, status: 'closed', resolution: 'Fixed.' }); + expect(await reads(agent, kase.id)).toBe(false); + expect(await grants(kase.id)).not.toContain('case_escalated_from_sharing→agent:read'); + }); + + it('nobody can name the recipient: a caller-supplied escalated_from is dropped', async () => { + const kase = await verify.hooks.run('crm_case', 'insert', newCase({ escalated_from: other.id }), { as: agent.token }); + await verify.hooks.run('crm_case', 'update', { id: kase.id, escalated_from: other.id }, { as: agent.token }); + expect((await stored(kase.id)).escalated_from ?? null).toBeNull(); + expect(await reads(other, kase.id)).toBe(false); + }); + + it('an ownerless case was taken from nobody: no recipient is stamped', async () => { + const [kase] = await verify.seed('crm_case', [newCase({ status: 'in_progress', owner_id: agent.id })]); + await systemUpdate(verify, 'crm_case', { id: kase!.id, owner_id: null }); + await systemUpdate(verify, 'crm_case', { id: kase!.id, status: 'escalated', escalation_reason: 'Breached.' }); + const after = await stored(kase!.id); + expect(after.owner_id, 'the pool did not take the case').toBe(manager.id); + expect(after.escalated_from ?? null).toBeNull(); + }); +}); diff --git a/test/sharing-coverage.test.ts b/test/sharing-coverage.test.ts index 6096aa60d..b2339070f 100644 --- a/test/sharing-coverage.test.ts +++ b/test/sharing-coverage.test.ts @@ -226,7 +226,7 @@ describe('what a shared account carries into its related lists', () => { }); /** - * `crm_case`'s three grants, named — including the triage widening (#1096). + * `crm_case`'s four grants, named — including the triage widening (#1096). * * The ledger above answers `crm_case: 'partial'`, and 'partial' says only "some * rule widens some records". That was true before #1096 and is true after it, @@ -245,7 +245,7 @@ describe('what a shared account carries into its related lists', () => { describe('who can read a case they do not own', () => { const caseRules = () => rulesOn('crm_case'); - it('exactly three rules widen crm_case, and each names its position', () => { + it('exactly four rules widen crm_case, and each names its recipient', () => { const roster = caseRules() .map((r) => `${r.name}→${r.sharedWith?.value}:${r.accessLevel ?? 'read'}`) .sort(); @@ -256,6 +256,8 @@ describe('who can read a case they do not own', () => { 'both belong in a PR that also updates the admin docs and this list.', ).toEqual([ 'case_director_sharing→service_director:read', + // A FIELD recipient (#2060 item 3): the one user the case names, not a position. + 'case_escalated_from_sharing→escalated_from:read', 'case_escalation_sharing→service_manager:edit', 'case_unassigned_triage_sharing→service_agent:edit', ]); diff --git a/test/sharing-seeding.test.ts b/test/sharing-seeding.test.ts index 015e94d44..1b71520a6 100644 --- a/test/sharing-seeding.test.ts +++ b/test/sharing-seeding.test.ts @@ -102,6 +102,8 @@ const MAPPABLE_RECIPIENTS = new Set([ 'position', 'business_unit', 'unit_and_subordinates', + // Record-relative (#14103); mapped and seeded since plugin-sharing #15072. + 'field', ]); /** `P` compiles to `{ dialect: 'cel', source }`. */ @@ -443,6 +445,11 @@ const RULE_WITNESSES: Record = { reaches: { priority: 'critical', is_closed: false }, misses: [{ priority: 'high', is_closed: false }, { priority: 'critical', is_closed: true }], }, + case_escalated_from_sharing: { + // Reaches every open case; WHO it grants is the case's own `escalated_from`. + reaches: { is_closed: false }, + misses: [{ is_closed: true }], + }, case_unassigned_triage_sharing: { // `owner_id == null` is the one rule whose witness turns on a null, and // null semantics are exactly where drivers have been measured to disagree