From a3ef76b09b5fa95a49624c84befafff76a159909 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:18:54 +0000 Subject: [PATCH 1/3] fix(case): the agent an escalation took a case from keeps read on it Escalation hands a case to the service_manager pool, and service_agent reads crm_case with readScope own, so the agent lost the case in the same save: a Critical create landed on "Record not found" under "Case created successfully", and Escalate Case did the same on refresh. case_escalation_reassign now records whom it took the case from in a new readonly lookup, escalated_from, in the escalation write itself. A new criteria rule, case_escalated_from_sharing, shares each open case read-only with the user that column names (a field recipient, one person per case). Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv Co-authored-by: Claude --- src/sales/translations/en/objects.service.ts | 1 + .../translations/es-ES/objects.service.ts | 1 + .../translations/ja-JP/objects.service.ts | 1 + .../translations/zh-CN/objects.service.ts | 1 + src/service/index.ts | 8 ++--- src/service/objects/_case-assignment.ts | 8 ++++- src/service/objects/case.object.ts | 14 ++++++++- src/service/sharing/case.sharing.ts | 30 +++++++++++++++++++ src/service/sharing/index.ts | 1 + 9 files changed, 59 insertions(+), 6 deletions(-) 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'; From d9e338375bab9158b300637f5d2bfca423aa5929 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:35:31 +0000 Subject: [PATCH 2/3] test(case): pin the previous owner's read on an escalated case; docs A booted-stack suite drives the two escalation paths as a real agent (a case logged Critical, and Escalate on a case the agent did not create) and reads each case back as that agent, as another agent, and as the system: the agent keeps read, nobody else gains it, the manager's grant is unchanged, the grant is read-only, closing ends it, and a caller cannot name the recipient. The crm_case sharing roster gains the rule, the seeding model maps the field recipient the platform seeds, and the admin sharing pages, the cases page (25 fields, the Escalation group, the sharing list) and STATUS's field figure follow, in every locale. Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv Co-authored-by: Claude --- .changeset/2060-item-3-agent-loses-case.md | 7 ++ .../administration/sharing-and-security.mdx | 1 + .../sharing-and-security.zh-Hans.mdx | 1 + .../sharing-and-security.zh-Hant.mdx | 1 + content/docs/service/cases.mdx | 7 +- content/docs/service/cases.zh-Hans.mdx | 7 +- content/docs/service/cases.zh-Hant.mdx | 7 +- docs/STATUS.md | 2 +- src/docs/crm_admin.md | 2 + test/escalated-case-previous-owner.test.ts | 105 ++++++++++++++++++ test/sharing-coverage.test.ts | 6 +- test/sharing-seeding.test.ts | 7 ++ 12 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 .changeset/2060-item-3-agent-loses-case.md create mode 100644 test/escalated-case-previous-owner.test.ts 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/test/escalated-case-previous-owner.test.ts b/test/escalated-case-previous-owner.test.ts new file mode 100644 index 000000000..b5aa4e8a1 --- /dev/null +++ b/test/escalated-case-previous-owner.test.ts @@ -0,0 +1,105 @@ +// 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 }); + 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 From 934e991cb4668ed5602765ddcac5cdd0423ba8ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:36:48 +0000 Subject: [PATCH 3/3] test(case): the close case first proves the grant it ends Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv Co-authored-by: Claude --- test/escalated-case-previous-owner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/escalated-case-previous-owner.test.ts b/test/escalated-case-previous-owner.test.ts index b5aa4e8a1..591b56fc2 100644 --- a/test/escalated-case-previous-owner.test.ts +++ b/test/escalated-case-previous-owner.test.ts @@ -82,6 +82,7 @@ describe('the agent an escalation takes a case from keeps read on it', () => { 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');