diff --git a/.changeset/my-open-cases-by-priority.md b/.changeset/my-open-cases-by-priority.md new file mode 100644 index 000000000..7ed388533 --- /dev/null +++ b/.changeset/my-open-cases-by-priority.md @@ -0,0 +1,16 @@ +--- +'hotcrm': patch +--- + +The **Customer Service** dashboard's case table is personal again: **Open Cases +by Priority** → **My Open Cases by Priority**, showing only the open cases the +signed-in user owns. It had been team-wide since the analytics query path could +not resolve `{current_user_id}`; the pinned platform (17.4.0) now does, and two +different reps measured on one dashboard each saw only their own cases. + +The widget ignores the dashboard's **Agent** filter. Picking another agent used +to leave "my" cases empty; it now keeps showing your own cases, while every +other widget still follows the pick. + +`{current_user_id}` scopes what the widget shows — it is not an access boundary; +row-level security still decides which cases a user can reach. diff --git a/content/docs/analytics/dashboards.mdx b/content/docs/analytics/dashboards.mdx index f50dcbb56..b4be9d719 100644 --- a/content/docs/analytics/dashboards.mdx +++ b/content/docs/analytics/dashboards.mdx @@ -110,7 +110,7 @@ The service team's daily ops view. - **Cases by Origin** — Email / Phone / Web / Chat / Portal (bar). - **Daily Case Volume** — new cases created over the last 30 days. - **SLA Compliance** — share of cases resolved within SLA, against a 95% target line (gauge). -- **Open Cases by Priority** — open cases and their SLA-violation rate per priority (table). +- **My Open Cases by Priority** — the signed-in user's own open cases and their SLA-violation rate per priority (table). It ignores the Agent filter: it always shows your own cases. **Controls**: a date-range picker over the date a case was created (defaults to the last 90 days), Agent and Priority filters, and a one-minute refresh — service desks need fresh numbers. It is the only picker that windows a case date rather than the opportunity close date, and the only one that does not default to this quarter, so every number on this page is bounded by the last 90 days until you widen it. Daily Case Volume opts out of the picker and keeps its own 30-day window, so the chart under that title always covers the 30 days it names. diff --git a/content/docs/analytics/dashboards.zh-Hans.mdx b/content/docs/analytics/dashboards.zh-Hans.mdx index 57ab7d78d..f4e23f154 100644 --- a/content/docs/analytics/dashboards.zh-Hans.mdx +++ b/content/docs/analytics/dashboards.zh-Hans.mdx @@ -110,7 +110,7 @@ HotCRM 内置五个仪表盘。每个都为特定角色而构建,回答该角 - **Cases by Origin** — Email / Phone / Web / Chat / Portal(柱状图)。 - **Daily Case Volume** — 近 30 天每天新建的工单数。 - **SLA Compliance** — 在 SLA 内解决的工单占比,对比 95% 的目标线(仪表图)。 -- **Open Cases by Priority** — 每个优先级的未结工单数及其 SLA 违约率(表格)。 +- **My Open Cases by Priority** — 当前登录用户自己名下、每个优先级的未结工单数及其 SLA 违约率(表格)。它不受 Agent 筛选影响,始终显示你自己的工单。 **控件**:一个基于工单创建日期的日期范围选择器(默认最近 90 天),外加 Agent 和 Priority 筛选器,以及每分钟一次的刷新——服务台需要新鲜的数字。它是唯一一个按工单日期、而不是按商机预计成交日期取窗的选择器,也是唯一一个不默认本季度的,所以在你把窗口放宽之前,这一页上的每一个数字都被最近 90 天限定着。Daily Case Volume 退出了这个选择器,保留自带的 30 天时间窗,因此那张图始终覆盖它标题上写的 30 天。 diff --git a/content/docs/analytics/dashboards.zh-Hant.mdx b/content/docs/analytics/dashboards.zh-Hant.mdx index b11333795..37911a390 100644 --- a/content/docs/analytics/dashboards.zh-Hant.mdx +++ b/content/docs/analytics/dashboards.zh-Hant.mdx @@ -112,7 +112,7 @@ HotCRM 內建五個儀表板。每個都為特定角色而建構,回答該角 - **Cases by Origin** — Email / Phone / Web / Chat / Portal(長條圖)。 - **Daily Case Volume** — 近 30 天每天新建的工單數。 - **SLA Compliance** — 在 SLA 內解決的工單占比,對比 95% 的目標線(量表圖)。 -- **Open Cases by Priority** — 每個優先順序的未結工單數及其 SLA 違約率(表格)。 +- **My Open Cases by Priority** — 目前登入使用者自己名下、每個優先順序的未結工單數及其 SLA 違約率(表格)。它不受 Agent 篩選影響,一律顯示你自己的工單。 **控制項**:一個以工單創建日期為準的日期範圍選擇器(預設最近 90 天),外加 Agent 和 Priority 篩選器,以及每分鐘一次的重新整理——服務台需要新鮮的數字。它是唯一一個以工單日期、而不是以商機預計成交日期取窗的選擇器,也是唯一一個不預設本季的,所以在你把時間窗放寬之前,這一頁上的每一個數字都被最近 90 天限定著。Daily Case Volume 退出了這個選擇器,保留自帶的 30 天時間窗,因此那張圖始終涵蓋它標題上寫的 30 天。 diff --git a/content/docs/service/index.mdx b/content/docs/service/index.mdx index 2f927c76b..0d12a72e5 100644 --- a/content/docs/service/index.mdx +++ b/content/docs/service/index.mdx @@ -51,7 +51,7 @@ The [service skills](/docs/ai-copilot/service-copilot) are built into every case ## Standard dashboards & reports -- **Service Overview** (the dashboard's own title is **Customer Service**) — the KPIs **Open Cases**, **Critical Cases**, **Avg Resolution Time** and **SLA Violations**; the charts **Cases by Status**, **Cases by Priority** and **Cases by Origin**; **Daily Case Volume**; an **SLA Compliance** gauge; an **Open Cases by Priority** table; and the knowledge-deflection row — a **KB Deflection Rate** tile with its two halves, **Resolved by KB** and **Closed Cases**, printed beside it so the percentage can be checked rather than trusted, plus a **Top Resolving Articles** table ranking articles by the closed cases they resolved. What it does not carry is a *top agents* tile or an *oldest open cases* tile, and neither is merely a widget nobody has built yet: `case_metrics`, the dataset behind every service widget, declares no owner dimension, so nothing in analytics can rank agents — the [Cases](/docs/service/cases) page says the same — and a dashboard table aggregates its dataset instead of listing records, so the oldest individual cases cannot be shown there either. +- **Service Overview** (the dashboard's own title is **Customer Service**) — the KPIs **Open Cases**, **Critical Cases**, **Avg Resolution Time** and **SLA Violations**; the charts **Cases by Status**, **Cases by Priority** and **Cases by Origin**; **Daily Case Volume**; an **SLA Compliance** gauge; a **My Open Cases by Priority** table — the open cases the signed-in user owns; and the knowledge-deflection row — a **KB Deflection Rate** tile with its two halves, **Resolved by KB** and **Closed Cases**, printed beside it so the percentage can be checked rather than trusted, plus a **Top Resolving Articles** table ranking articles by the closed cases they resolved. What it does not carry is a *top agents* tile or an *oldest open cases* tile, and neither is merely a widget nobody has built yet: `case_metrics`, the dataset behind every service widget, declares no owner dimension, so nothing in analytics can rank agents — the [Cases](/docs/service/cases) page says the same — and a dashboard table aggregates its dataset instead of listing records, so the oldest individual cases cannot be shown there either. - **Cases Opened by Priority × Day** — daily inflow, as a matrix of priority in the rows against day in the columns, in that order. - **Cases by Status and Priority** — current snapshot: case count and average resolution time, by status and priority. - **SLA Performance Report** — case count, **SLA Violation Rate** and average resolution time, by priority, over closed cases only. It does not report a compliance percentage: that is the service dashboard's **SLA Compliance** gauge, which plots `sla_compliance_rate` from the same dataset. See [SLA & Escalation](/docs/service/sla-and-escalation). diff --git a/content/docs/service/index.zh-Hans.mdx b/content/docs/service/index.zh-Hans.mdx index e38dc566f..6c9bed688 100644 --- a/content/docs/service/index.zh-Hans.mdx +++ b/content/docs/service/index.zh-Hans.mdx @@ -51,7 +51,7 @@ New ──► In Progress ──► Waiting on Customer ──► Resolved ─ ## 标准仪表盘与报表 -- **服务概览**(Service Overview;仪表盘自身的标题是 **Customer Service**)——KPI 有 **Open Cases**、**Critical Cases**、**Avg Resolution Time**、**SLA Violations**;分布图有 **Cases by Status**、**Cases by Priority**、**Cases by Origin**;再加 **Daily Case Volume**、一个 **SLA Compliance** 仪表图,一张 **Open Cases by Priority** 表格;以及知识转移那一行——**KB Deflection Rate**(知识库转移率)配上它的分子分母 **Resolved by KB** 与 **Closed Cases**,两个数字就印在百分比旁边,让人能核对而不是只能相信,再加一张 **Top Resolving Articles** 表格,按解决的已关闭工单数给文章排名。它没有 *顶尖客服* 部件,也没有 *最久未结工单* 部件,而且这两个都不是"只是还没做"那么简单:`case_metrics`——服务侧每个部件都建在它上面的那个数据集——没有负责人维度,所以分析层里没有任何东西能给客服排名([工单](/zh-Hans/docs/service/cases)页写的是同一件事);而仪表盘表格聚合的是数据集,不列出单条记录,所以最久未结的那几张单也放不进去。 +- **服务概览**(Service Overview;仪表盘自身的标题是 **Customer Service**)——KPI 有 **Open Cases**、**Critical Cases**、**Avg Resolution Time**、**SLA Violations**;分布图有 **Cases by Status**、**Cases by Priority**、**Cases by Origin**;再加 **Daily Case Volume**、一个 **SLA Compliance** 仪表图,一张 **My Open Cases by Priority** 表格(当前登录用户自己名下的未结工单);以及知识转移那一行——**KB Deflection Rate**(知识库转移率)配上它的分子分母 **Resolved by KB** 与 **Closed Cases**,两个数字就印在百分比旁边,让人能核对而不是只能相信,再加一张 **Top Resolving Articles** 表格,按解决的已关闭工单数给文章排名。它没有 *顶尖客服* 部件,也没有 *最久未结工单* 部件,而且这两个都不是"只是还没做"那么简单:`case_metrics`——服务侧每个部件都建在它上面的那个数据集——没有负责人维度,所以分析层里没有任何东西能给客服排名([工单](/zh-Hans/docs/service/cases)页写的是同一件事);而仪表盘表格聚合的是数据集,不列出单条记录,所以最久未结的那几张单也放不进去。 - **Cases Opened by Priority × Day**(按优先级 × 天的建单量)——每日流入量:一张优先级作行、天作列的矩阵,顺序就是这个。 - **Cases by Status and Priority**(按状态与优先级划分的工单)——当前快照:按状态与优先级给出工单数与平均解决时长。 - **SLA Performance Report**(SLA 表现报表)——按优先级给出工单数、**SLA Violation Rate**(SLA 违约率)与平均解决时长,并且只统计已关闭的工单。它报的不是达成率百分比:达成率在服务仪表盘的 **SLA Compliance** 仪表图上,绘制的是同一个数据集里的 `sla_compliance_rate`。参见 [SLA 与升级](/zh-Hans/docs/service/sla-and-escalation)。 diff --git a/content/docs/service/index.zh-Hant.mdx b/content/docs/service/index.zh-Hant.mdx index 4245c2008..962343e77 100644 --- a/content/docs/service/index.zh-Hant.mdx +++ b/content/docs/service/index.zh-Hant.mdx @@ -53,7 +53,7 @@ New ──► In Progress ──► Waiting on Customer ──► Resolved ─ ## 標準儀表板與報表 -- **服務概覽**(Service Overview;儀表板自身的標題是 **Customer Service**)——KPI 有 **Open Cases**、**Critical Cases**、**Avg Resolution Time**、**SLA Violations**;分布圖有 **Cases by Status**、**Cases by Priority**、**Cases by Origin**;再加 **Daily Case Volume**、一個 **SLA Compliance** 量表圖,一張 **Open Cases by Priority** 表格;以及知識轉移那一列——**KB Deflection Rate**(知識庫轉移率)配上它的分子分母 **Resolved by KB** 與 **Closed Cases**,兩個數字就印在百分比旁邊,讓人能核對而不是只能相信,再加一張 **Top Resolving Articles** 表格,按解決的已關閉工單數給文章排名。它沒有 *頂尖客服* 部件,也沒有 *最久未結工單* 部件,而且這兩個都不是"只是還沒做"那麼簡單:`case_metrics`——服務側每個部件都建在它上面的那個資料集——沒有負責人維度,所以分析層裡沒有任何東西能給客服排名([工單](/zh-Hant/docs/service/cases)頁寫的是同一件事);而儀表板表格聚合的是資料集,不列出單筆記錄,所以最久未結的那幾張單也放不進去。 +- **服務概覽**(Service Overview;儀表板自身的標題是 **Customer Service**)——KPI 有 **Open Cases**、**Critical Cases**、**Avg Resolution Time**、**SLA Violations**;分布圖有 **Cases by Status**、**Cases by Priority**、**Cases by Origin**;再加 **Daily Case Volume**、一個 **SLA Compliance** 量表圖,一張 **My Open Cases by Priority** 表格(目前登入使用者自己名下的未結工單);以及知識轉移那一列——**KB Deflection Rate**(知識庫轉移率)配上它的分子分母 **Resolved by KB** 與 **Closed Cases**,兩個數字就印在百分比旁邊,讓人能核對而不是只能相信,再加一張 **Top Resolving Articles** 表格,按解決的已關閉工單數給文章排名。它沒有 *頂尖客服* 部件,也沒有 *最久未結工單* 部件,而且這兩個都不是"只是還沒做"那麼簡單:`case_metrics`——服務側每個部件都建在它上面的那個資料集——沒有負責人維度,所以分析層裡沒有任何東西能給客服排名([工單](/zh-Hant/docs/service/cases)頁寫的是同一件事);而儀表板表格聚合的是資料集,不列出單筆記錄,所以最久未結的那幾張單也放不進去。 - **Cases Opened by Priority × Day**(按優先順序 × 天的建單量)——每日流入量:一張優先順序作列、天作欄的矩陣,順序就是這個。 - **Cases by Status and Priority**(按狀態與優先順序劃分的工單)——當前快照:按狀態與優先順序給出工單數與平均解決時長。 - **SLA Performance Report**(SLA 表現報表)——按優先順序給出工單數、**SLA Violation Rate**(SLA 違約率)與平均解決時長,並且只統計已關閉的工單。它報的不是達成率百分比:達成率在服務儀表板的 **SLA Compliance** 量表圖上,繪製的是同一個資料集裡的 `sla_compliance_rate`。參見 [SLA 與升級](/zh-Hant/docs/service/sla-and-escalation)。 diff --git a/src/sales/apps/crm.app.ts b/src/sales/apps/crm.app.ts index a1c57988f..b622fe3f1 100644 --- a/src/sales/apps/crm.app.ts +++ b/src/sales/apps/crm.app.ts @@ -117,15 +117,11 @@ export const CrmApp = App.create({ label: 'My Work', icon: 'list-checks', expanded: true, - // These are ListViews, not a dashboard, and that is deliberate. A - // "My Day" dashboard was built and removed: dashboard widget filters do - // NOT interpolate `{current_user}` / `{current_user_id}` — the literal - // string reaches the query and matches no owner, so every widget renders - // 0. Proven side by side on one dashboard: `{current_user}` → 0, - // `{current_user_id}` → 0, no owner filter → 10,100,081. The token is - // implemented in platform-objects (the ListView data path) and has no - // counterpart in service-analytics. Filed upstream; until it lands, a - // ListView is the only surface where "mine" actually means mine. + // These are ListViews: they list the records themselves, which a + // dashboard widget (an aggregate over a dataset, ADR-0021) cannot. Both + // surfaces resolve `{current_user_id}` since objectstack#12230 — the + // Service dashboard's "My Open Cases by Priority" is the aggregate + // counterpart of "My Cases" (#510). // // Every item here is object + `viewName`, which makes this group the // app's view-entry exemplar since #1259 retired the "Pipeline" row. @@ -136,10 +132,8 @@ export const CrmApp = App.create({ { id: 'nav_my_deals', type: 'object', objectName: 'crm_opportunity', viewName: 'my_open_deals', label: 'My Deals', icon: 'target' }, { id: 'nav_my_leads', type: 'object', objectName: 'crm_lead', viewName: 'my_leads', label: 'My Leads', icon: 'user-plus' }, { id: 'nav_my_cases', type: 'object', objectName: 'crm_case', viewName: 'my_open_cases', label: 'My Cases', icon: 'life-buoy' }, - // #592 — the rep's own calendar. Same reasoning as `nav_my_tasks`: a - // ListView is the only surface where "mine" actually means mine - // (`{current_user_id}` interpolates on the list-view data path and - // nowhere else), so the personal calendar is a view, not a dashboard. + // #592 — the rep's own calendar: a list of events, so a view, not a + // dashboard. { id: 'nav_my_calendar', type: 'object', objectName: 'crm_event', viewName: 'my_events', label: 'My Calendar', icon: 'calendar-days' }, // No "All Tasks" entry: "My Tasks" opens the `crm_task` list page, // whose tab strip leads with *All Tasks* — `TaskViews.list`, name diff --git a/src/sales/translations/en/app.ts b/src/sales/translations/en/app.ts index addfd1ce0..68dd25daf 100644 --- a/src/sales/translations/en/app.ts +++ b/src/sales/translations/en/app.ts @@ -144,7 +144,7 @@ export const appSurface: Omit = { kb_resolved_cases: { title: 'Resolved by KB', description: 'Closed cases pointing at the article that resolved them' }, closed_cases_total: { title: 'Closed Cases', description: 'The denominator behind the deflection rate' }, top_resolving_articles: { title: 'Top Resolving Articles', description: 'Knowledge articles ranked by the closed cases they resolved' }, - open_cases_by_priority: { title: 'Open Cases by Priority', description: 'Open cases and their SLA-violation rate, broken down by priority' }, + my_open_cases_by_priority: { title: 'My Open Cases by Priority', description: 'Your open cases and their SLA-violation rate, broken down by priority' }, }, }, }, diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index cabb06a60..c475471d2 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -180,7 +180,7 @@ export const appSurface: Omit = { kb_resolved_cases: { title: 'Resueltos por KB', description: 'Casos cerrados que señalan el artículo que los resolvió' }, closed_cases_total: { title: 'Casos Cerrados', description: 'El denominador de la tasa de desvío' }, top_resolving_articles: { title: 'Artículos que Más Resuelven', description: 'Artículos de la base de conocimiento ordenados por los casos cerrados que resolvieron' }, - open_cases_by_priority: { title: 'Casos abiertos por prioridad', description: 'Casos abiertos y su tasa de incumplimiento de SLA, desglosados por prioridad' }, + my_open_cases_by_priority: { title: 'Mis casos abiertos por prioridad', description: 'Tus casos abiertos y su tasa de incumplimiento de SLA, desglosados por prioridad' }, }, }, }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 19892fd36..26f0a136b 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -180,7 +180,7 @@ export const appSurface: Omit = { kb_resolved_cases: { title: 'ナレッジで解決', description: '解決した記事が紐づくクローズ済みケース' }, closed_cases_total: { title: 'クローズ済みケース', description: 'ナレッジ解決率の分母' }, top_resolving_articles: { title: '解決件数の多い記事', description: '解決したクローズ済みケース数で並べたナレッジ記事' }, - open_cases_by_priority: { title: '優先度別オープンケース', description: 'オープンケースとそのSLA違反率を優先度別に集計' }, + my_open_cases_by_priority: { title: '優先度別の私のオープンケース', description: 'あなたのオープンケースとそのSLA違反率を優先度別に集計' }, }, }, }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 3ebee6f5a..45b832f03 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -179,7 +179,7 @@ export const appSurface: Omit = { kb_resolved_cases: { title: '知识库解决数', description: '已关联解决文章的已关闭工单' }, closed_cases_total: { title: '已关闭工单', description: '转移率的分母' }, top_resolving_articles: { title: '解决工单最多的文章', description: '按解决的已关闭工单数排名的知识文章' }, - open_cases_by_priority: { title: '按优先级统计未关闭工单', description: '未关闭工单及其 SLA 违约率,按优先级细分' }, + my_open_cases_by_priority: { title: '我的未关闭工单(按优先级)', description: '你的未关闭工单及其 SLA 违约率,按优先级细分' }, }, }, }, diff --git a/src/service/dashboards/service.dashboard.ts b/src/service/dashboards/service.dashboard.ts index 7216d5f2a..fa610323d 100644 --- a/src/service/dashboards/service.dashboard.ts +++ b/src/service/dashboards/service.dashboard.ts @@ -326,22 +326,21 @@ export const ServiceDashboard: Dashboard = { }, }, - // ─── Row 5: Open Cases by Priority ──────────────────────────────── + // ─── Row 5: My Open Cases by Priority ───────────────────────────── // A dashboard `table` binds to an analytics cube and aggregates; it cannot - // list individual cases (ADR-0021). This is deliberately TEAM-WIDE, not - // "my cases": the analytics query path resolves no user token at all — - // `{current_user}` (and even `{current_user_id}`) reach the query as - // literal strings and match no owner, so a personal filter renders 0 for - // everyone (see the proven note in crm.app.ts's My Work group). For a - // per-agent queue, use the my_open_cases ListView ("My Cases" in the - // My Work nav group). Restoring a personal widget here is tracked in - // issue #510. + // list individual cases (ADR-0021) — for the case-by-case queue, use the + // my_open_cases ListView ("My Cases" in the My Work nav group). + // `{current_user_id}` scopes what this widget SHOWS; it is presentation + // scope, not an access boundary — row-level security owns that (#510). { - id: 'open_cases_by_priority', - title: 'Open Cases by Priority', - description: 'Open cases and their SLA-violation rate, broken down by priority', + id: 'my_open_cases_by_priority', + title: 'My Open Cases by Priority', + description: 'Your open cases and their SLA-violation rate, broken down by priority', type: 'table', - filter: { is_closed: false }, + filter: { is_closed: false, owner_id: '{current_user_id}' }, + // Opts out of the Agent global filter (keyed by its field, `owner_id`): + // ANDing an agent pick into "mine" empties the widget for anyone else. + filterBindings: { owner_id: false }, colorVariant: 'default', dataset: 'case_metrics', dimensions: ['priority'], values: ['case_count', 'avg_sla_violated'], layout: { x: 0, y: 16, w: 12, h: 4 }, diff --git a/src/service/views/case.view.ts b/src/service/views/case.view.ts index 7dd9fa8ba..f146ce47d 100644 --- a/src/service/views/case.view.ts +++ b/src/service/views/case.view.ts @@ -117,11 +117,9 @@ export const CaseViews = defineView({ }, /** - * The agent's personal queue. This is a LIST view on purpose: the list - * data path resolves `{current_user_id}` (proven by my_leads / - * my_open_tasks), while the dashboard/analytics path resolves no user - * token at all — which is why service_dashboard has no "my" widget - * (see the note there and the proven record in crm.app.ts). + * The agent's personal queue: the cases themselves, one row each. Its + * aggregate counterpart is service_dashboard's "My Open Cases by + * Priority" (#510) — a dashboard widget counts, it cannot list. */ my_open_cases: { name: 'my_open_cases', diff --git a/test/dashboard-agent-global-filter.test.ts b/test/dashboard-agent-global-filter.test.ts index d7f377d07..a410c80c9 100644 --- a/test/dashboard-agent-global-filter.test.ts +++ b/test/dashboard-agent-global-filter.test.ts @@ -193,6 +193,11 @@ const utcNoonDaysAgo = (offset: number): string => { const truthMatches = (row: FixtureCase, filter: AnyRec | undefined): boolean => { for (const [field, cond] of Object.entries(filter ?? {})) { if (field === AGENT_FIELD) { + // `{current_user_id}` is resolved by the caller (see `truthFor`), so an + // unresolved placeholder here is a mis-modelled filter: throw. + if (typeof cond === 'string' && cond.startsWith('{')) { + throw new Error(`truthMatches: unresolved owner placeholder ${cond}`); + } if (row.owner !== cond) return false; continue; } @@ -338,7 +343,7 @@ const widgetFilter = (w: AnyRec, opts: { agent?: string; window?: AnyRec } = {}) }; /** One widget, run through the real dataset executor exactly as declared. */ -const run = async (w: AnyRec, opts: { agent?: string; window?: AnyRec } = {}): Promise => { +const run = async (w: AnyRec, opts: { agent?: string; window?: AnyRec; viewer?: string } = {}): Promise => { const filter = widgetFilter(w, opts); const result = await analytics.queryDataset( CaseDataset as never, @@ -347,7 +352,9 @@ const run = async (w: AnyRec, opts: { agent?: string; window?: AnyRec } = {}): P measures: w.values, ...(filter ? { runtimeFilter: filter as never } : {}), } as never, - { isSystem: true } as never, + // `viewer` signs the query in, so a `{current_user_id}` widget filter has + // someone to resolve to (#510); without one the platform refuses it. + { isSystem: true, ...(opts.viewer ? { userId: opts.viewer } : {}) } as never, ); return (result.rows ?? []) as AnyRec[]; }; @@ -617,3 +624,43 @@ describe('the Agent filter composes with the dashboard date window', () => { expect(wrong, `the two dashboard filters do not compose:\n ${wrong.join('\n ')}`).toEqual([]); }, 30_000); }); + +// ═══════════════════ 7 · the personal widget answers for its viewer (#510) ══ + +describe('My Open Cases by Priority ignores the Agent pick and answers for its viewer', () => { + const MY_WIDGET = 'my_open_cases_by_priority'; + + /** The widget's own filter with `{current_user_id}` resolved to `viewer`. */ + const truthFor = (w: AnyRec, viewer: string): number => { + const filter = { ...(w.filter as AnyRec) }; + expect(filter[AGENT_FIELD], `${MY_WIDGET} is not scoped to its viewer`).toBe('{current_user_id}'); + filter[AGENT_FIELD] = viewer; + return COUNT_MEASURES.case_count(CASE_ROWS.filter((r) => truthMatches(r, filter))); + }; + + it('opts out of the Agent filter by its field name', () => { + // Measured in the console (#510): with the opt-out, picking another agent + // leaves this widget on the viewer's own cases; without it, the pick is + // ANDed in and "mine AND theirs" is empty. + const w = widget(MY_WIDGET); + expect(w, `service_dashboard has no ${MY_WIDGET} widget`).toBeTruthy(); + expect(w.filterBindings?.[AGENT_FIELD]).toBe(false); + }); + + it('shows each viewer their own open cases, whatever agent is picked', async () => { + const w = widget(MY_WIDGET); + const alpha = total(await run(w, { viewer: ALPHA, agent: BETA }), 'case_count'); + const beta = total(await run(w, { viewer: BETA, agent: ALPHA }), 'case_count'); + const all = COUNT_MEASURES.case_count(CASE_ROWS.filter((r) => !r.is_closed)); + + expect(alpha).toBe(truthFor(w, ALPHA)); + expect(beta).toBe(truthFor(w, BETA)); + // Two viewers, two different answers that together are every open case. + expect(alpha).toBeGreaterThan(0); + expect(beta).toBeGreaterThan(0); + expect(alpha).not.toBe(beta); + expect(alpha + beta).toBe(all); + // A viewer who owns nothing sees nothing — not the team's numbers. + expect(total(await run(w, { viewer: GHOST }), 'case_count')).toBe(0); + }, 30_000); +}); diff --git a/test/dashboard-date-range-window.test.ts b/test/dashboard-date-range-window.test.ts index 468b6504d..93655fe9a 100644 --- a/test/dashboard-date-range-window.test.ts +++ b/test/dashboard-date-range-window.test.ts @@ -185,10 +185,25 @@ const CASE_ROWS: CaseRow[] = [ mk(200, 'closed', 'high', 'phone', 'problem', true, false, 20, null), ]; +/** + * Who owns each row (#510). The Service dashboard carries a PERSONAL widget — + * `owner_id: '{current_user_id}'` — so every widget runs as a signed-in + * VIEWER, and two of the open rows belong to someone else. Those two sit + * inside every preset, so a personal widget that ignored its owner predicate + * would count them and disagree with the truth below; the viewer still owns + * an open row at offset 0, so the personal widget is not blank at + * `last_7_days` either. + */ +const VIEWER = 'usr_fixture_viewer'; +const OTHER_OWNER = 'usr_fixture_other'; +const OTHER_OWNED_OFFSETS = new Set([2, 29]); +const ownerOf = (row: CaseRow): string => (OTHER_OWNED_OFFSETS.has(row.offset) ? OTHER_OWNER : VIEWER); + const CASE_OBJECT = { name: 'crm_case', fields: { id: { type: 'text' }, + owner_id: { type: 'text' }, subject: { type: 'text' }, status: { type: 'text' }, priority: { type: 'text' }, @@ -237,7 +252,7 @@ beforeAll(async () => { for (const row of CASE_ROWS) { const { offset, ...rest } = row; - await api.object('crm_case').insert({ ...rest, created_date: utcNoonDaysAgo(offset).toISOString() }); + await api.object('crm_case').insert({ ...rest, owner_id: ownerOf(row), created_date: utcNoonDaysAgo(offset).toISOString() }); } analytics = new AnalyticsService({ @@ -389,7 +404,9 @@ describe('every datetime-windowed dashboard answers under its own picker', () => measures: widget.values, ...(filter ? { runtimeFilter: filter as never } : {}), }, - { isSystem: true } as never, + // A signed-in viewer: `{current_user_id}` has no answer for a bare system + // caller, and the platform refuses the query rather than guess (#510). + { isSystem: true, userId: VIEWER } as never, ); return result.rows as AnyRec[]; }; @@ -443,7 +460,7 @@ describe('every datetime-windowed dashboard answers under its own picker', () => const window = w.filterBindings?.dateRange === false ? null : days; const rows = await runWidget(w, widgetFilter(d, w, window ? presetWindow(days) : null)); const expected = CASE_ROWS.filter((r) => truthMatches(r, w.filter, window)).length; - // `case_count` only: a widget like `open_cases_by_priority` also + // `case_count` only: a widget like `my_open_cases_by_priority` also // carries `avg_sla_violated`, and summing a rate into a row count // compares two different quantities. const actual = sumOf(rows, 'case_count'); @@ -494,6 +511,15 @@ function truthMatches(row: CaseRow, filter: AnyRec | undefined, windowDays: numb if (row.offset > Number(m[1])) return false; continue; } + if (field === 'owner_id') { + // The one placeholder a widget filter here authors; the runner signs in + // as VIEWER. Any other owner spelling throws rather than mis-model. + if (cond !== '{current_user_id}') { + throw new Error(`truthMatches: unsupported owner_id filter ${JSON.stringify(cond)}`); + } + if (ownerOf(row) !== VIEWER) return false; + continue; + } if (cond !== null && typeof cond === 'object') { throw new Error(`truthMatches: unsupported operator filter on ${field}: ${JSON.stringify(cond)}`); } diff --git a/test/view-references.test.ts b/test/view-references.test.ts index 481fa265d..cd8b88c97 100644 --- a/test/view-references.test.ts +++ b/test/view-references.test.ts @@ -146,10 +146,10 @@ describe('filter template tokens are resolvable', () => { * - ANALYTICS path (dashboard widgets / dataset reports, * `/api/v1/analytics/...`): resolves the DATE_MACRO_TOKENS vocabulary (the * YTD revenue widget returns a non-zero sum, impossible with a literal - * token), but still NO user token — `{current_user}` and even - * `{current_user_id}` match no owner (see crm.app.ts's My Work note). That - * half is unchanged and tracked at #510, so the two rules below remain - * asymmetric — just not in the direction they were written for. + * token) and, since objectstack#12230 (in the 17.4.0 pin), the user tokens + * too — both the direct query door and the dataset-scope channel. Measured + * by #510 with two reps on one widget: each saw only their own cases. So + * both paths now accept the same two classes. */ const dashboards: AnyRec[] = (stack as any).dashboards ?? []; const reports: AnyRec[] = (stack as any).reports ?? []; @@ -203,8 +203,8 @@ describe('filter template tokens are resolvable', () => { expect(good).toEqual([]); }); - it('dashboard widget and report filters only use date macros', () => { - const allowed = (t: string) => isDateMacroToken(t); + it('dashboard widget and report filters only use resolvable tokens', () => { + const allowed = (t: string) => USER_TOKENS.has(t) || isDateMacroToken(t); const bad: string[] = []; for (const d of dashboards) { for (const w of d.widgets ?? []) badTokensIn(`${d.name}/${w.id}`, w.filter, allowed, bad);