salesforce-reports-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +43 -29
- package/dist/reports/api.js +19 -0
- package/dist/reports/dashboards.js +308 -0
- package/dist/server.js +210 -3
- package/dist/widget/dashboard.html +946 -0
- package/dist/widget/report.html +722 -63
- package/dist/widget/view.js +32 -10
- package/package.json +4 -1
- package/skills/report-designer/transport.md +6 -2
- package/skills/report-runner/SKILL.md +11 -1
- package/skills/report-runner/transport.md +6 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "salesforce-reports",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Explore, design and deploy Salesforce reports with Claude: three skills and an MCP server over the Reports and Dashboards REST API.",
|
|
5
5
|
"author": { "name": "nelsben" },
|
|
6
6
|
"homepage": "https://github.com/nelsben/salesforce-reports-mcp",
|
package/README.md
CHANGED
|
@@ -1,23 +1,46 @@
|
|
|
1
1
|
# salesforce-reports-mcp
|
|
2
2
|
|
|
3
|
-
Claude finds, builds, runs and saves Salesforce reports.
|
|
3
|
+
Claude finds, builds, runs and saves Salesforce reports, and reads your dashboards, from an MCP server on your own machine. Nothing is installed in Salesforce, no Salesforce MCP licence is needed, and no admin has to approve anything. You sign in with the Salesforce CLI you already use.
|
|
4
4
|
|
|
5
|
-
This repo is working software, and a demonstration
|
|
5
|
+
This repo is working software, and a demonstration of how to put MCP in front of Salesforce without Salesforce's own MCP. The same tools are also packaged as a feature inside the org, for people who cannot run a local server, and every design choice is written down with what it costs. Built by [Ben Nelsen](https://github.com/nelsben).
|
|
6
6
|
|
|
7
|
-
> **You:**
|
|
7
|
+
> **You:** I'm a seller. How is our pipeline looking this fiscal year? Is there anything I should worry about, and can you show it to me by owner too?
|
|
8
8
|
>
|
|
9
|
-
> **Claude:**
|
|
9
|
+
> **Claude:** $3.09M across 8 open opportunities, here by stage and by owner. Worries: one owner holds 91% of the dollars, one deal alone is 29% of the pipeline, nothing is closed won this fiscal year, and one deal has no amount. Salesforce applied its default probability filter. Nothing was saved; say the word and I will save it as a report.
|
|
10
10
|
>
|
|
11
|
-
> *
|
|
11
|
+
> *Claude Desktop, through the local MCP server, signed in with the sf CLI ([rehearsal](docs/rehearsals/runner.md)).*
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
In Claude Desktop the run also draws as a card: the headline numbers, the filters Salesforce applied, the groups with a bar beside each amount, and a button that asks Claude to save it. Dashboards draw the same way, every component as its own chart.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
*Drawn by the [render test](docs/decisions/0014-render-tests-through-a-test-host.md)'s host page from anonymised test data.*
|
|
20
|
+
|
|
21
|
+
## Run it on your own org
|
|
22
|
+
|
|
23
|
+
Two commands in Claude Code, with Node 20 and the Salesforce CLI signed in:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
claude plugin marketplace add nelsben/salesforce-reports-mcp
|
|
27
|
+
claude plugin install salesforce-reports@salesforce-reports
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That installs the three skills and the MCP server. For Claude Desktop, [docs/claude-desktop.md](docs/claude-desktop.md) is the full recipe: the config entry with the two paths that trip people up, how to check it works, the safety flags, and what each Claude surface, including mobile, can do with a local server. [docs/install.md](docs/install.md#the-local-mcp-server) has every option.
|
|
31
|
+
|
|
32
|
+
## Why I built this
|
|
33
|
+
|
|
34
|
+
Most of what a company knows about its customers and its pipeline sits in Salesforce reports, and most of the people who need those answers cannot build a report. Assistants like Claude are becoming the way people ask, and the hard part is not the model: it is where the code runs, whose identity each call uses, and what it is allowed to change. I built this to work through those questions end to end for one real job, in the open, with every decision written down. The first answer is the local server, which needs nothing from Salesforce but an API session. The in-org packages show what it takes when that is not enough.
|
|
12
35
|
|
|
13
36
|
## What this repo shows
|
|
14
37
|
|
|
15
38
|
| Claim | Where to look | Evidence you can check |
|
|
16
39
|
| --- | --- | --- |
|
|
17
|
-
| **1.
|
|
18
|
-
| **2. A useful feature added to Salesforce itself**, packaged the way an admin expects | [A feature inside Salesforce](#a-feature-inside-salesforce) | Three package directories; layered Apex with a CI test that fails if the layers leak;
|
|
40
|
+
| **1. MCP on Salesforce without Salesforce MCP**, plus two in-org doors for when a local server is not an option | [Three ways to connect](#three-ways-to-connect) | One tool contract and result shape on every door (16 tools locally, 15 in the org); a [parity check](docs/decisions/0010-verification-by-parity.md) runs the same calls through two doors against a real org and compares them |
|
|
41
|
+
| **2. A useful feature added to Salesforce itself**, packaged the way an admin expects | [A feature inside Salesforce](#a-feature-inside-salesforce) | Three package directories; layered Apex with a CI test that fails if the layers leak; 90 Apex tests at 99% coverage |
|
|
19
42
|
| **3. Security, authentication and integration** done deliberately, per door | [Security and identity](#security-and-identity) | A different identity per door, a per-user JWT named credential with no stored secret, writes behind a custom permission, and a test that keeps org values out of the repo |
|
|
20
|
-
| **4. Product and architecture judgment**, shown in the work | [How it was built](#how-it-was-built) | Personas and [experiences](docs/experiences.md), [
|
|
43
|
+
| **4. Product and architecture judgment**, shown in the work | [How it was built](#how-it-was-built) | Personas and [experiences](docs/experiences.md), [7 probes](docs/probes/) before building, [19 decision records](docs/decisions/README.md), [live findings](docs/design.md), [7 rehearsals](docs/rehearsals/README.md) |
|
|
21
44
|
|
|
22
45
|
## The product
|
|
23
46
|
|
|
@@ -37,15 +60,13 @@ This repo is working software, and a demonstration. It takes one useful job, rep
|
|
|
37
60
|
|
|
38
61
|
**Answers you can trust.** Every run returns the same compact shape: the filters that were applied, including the ones Salesforce adds on its own, the groups and totals, and notes that say when rows were cut or Salesforce ignored part of the request. The [experiences page](docs/experiences.md) has the personas and the facts that shaped them.
|
|
39
62
|
|
|
40
|
-
**A report card in the chat.**
|
|
63
|
+
**A report card in the chat.** The card at the top is an MCP App the local server carries with each run: the headline numbers, the filters, the groups with a bar beside each amount (or, for a matrix, a heat grid), the totals, a link to open it in Salesforce and, for an unsaved report, a button that asks Claude to save it. Claude's reply then leads with what the card cannot show instead of repeating it. The card loads nothing from the network, is styled with the Lightning Design System's own tokens so it reads as Salesforce ([decision 0018](docs/decisions/0018-cards-follow-the-lightning-design-system.md)), and every heat cell passes WCAG AA contrast in light and dark. Clients that show only text, such as Claude Code, get the same result as text ([decision 0013](docs/decisions/0013-report-view-as-an-mcp-app.md), [decision 0015](docs/decisions/0015-report-card-visuals.md)).
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*Drawn by the [render test](docs/decisions/0014-render-tests-through-a-test-host.md)'s host page from anonymised test data.*
|
|
65
|
+
**Dashboards, read and refreshed.** Claude can find a dashboard, describe its components and filters, read the numbers Salesforce last cached, and refresh it when the data is missing or old. A refresh counts as a write, so read-only mode leaves it out, and no tool saves a dashboard ([decision 0016](docs/decisions/0016-dashboards-read-and-refresh-only.md)). In Claude Desktop the result draws as the dashboard card above, with every component as its own chart in the dashboard's layout ([decision 0017](docs/decisions/0017-dashboard-card.md)).
|
|
45
66
|
|
|
46
67
|
## Three ways to connect
|
|
47
68
|
|
|
48
|
-
The same tools reach Claude
|
|
69
|
+
The local server is the lead: it is the whole product for anyone on Claude Code or Claude Desktop, and it needs nothing from Salesforce beyond an API session. The same tools also reach Claude two more ways, for people who cannot run a local process: a seller on claude.ai, a Flow, an Agentforce agent. Those doors live inside the org as Apex. They differ in where the code runs, who hosts it and how the user signs in.
|
|
49
70
|
|
|
50
71
|
```mermaid
|
|
51
72
|
flowchart LR
|
|
@@ -76,14 +97,14 @@ flowchart LR
|
|
|
76
97
|
| **Installed in the org** | Nothing | `force-app` | `force-app`, `mcp-app`, optionally `callout-app` |
|
|
77
98
|
| **Signs in with** | Your `sf` CLI session or a token | Your own Salesforce session | OAuth with PKCE through an External Client App |
|
|
78
99
|
| **Clients** | Claude Code, Claude Desktop, any MCP client | Claude Code and any agent with a shell; Flow, Agentforce, any HTTP client | claude.ai and any remote MCP client, with nothing to host |
|
|
79
|
-
| **Tools** | All
|
|
80
|
-
| **Choose it when** |
|
|
100
|
+
| **Tools** | All 16, including dashboards; delete only with a flag | 11, no delete | 11, no delete |
|
|
101
|
+
| **Choose it when** | You can run a process on your machine. No org change, no Salesforce MCP | An admin wants the feature in the org, used from scripts and agents | A team wants Claude in the browser, governed by the org |
|
|
81
102
|
|
|
82
|
-
Each door gets the same tool names, the same input and the same result ([decision 0001](docs/decisions/0001-one-contract-three-doors.md)). The skills bind to tool names, not to a door, so one skill works over all three. [docs/architecture.md](docs/architecture.md) has
|
|
103
|
+
Each door gets the same tool names, the same input and the same result ([decision 0001](docs/decisions/0001-one-contract-three-doors.md)). The skills bind to tool names, not to a door, so one skill works over all three. [docs/architecture.md](docs/architecture.md) has a numbered sequence diagram for each door, with every step explained.
|
|
83
104
|
|
|
84
105
|
## A feature inside Salesforce
|
|
85
106
|
|
|
86
|
-
The in-org doors are a Salesforce feature an admin installs, not a script. They ship as three package directories in [salesforce/](salesforce/README.md) ([decision 0009](docs/decisions/0009-two-shipping-units.md)):
|
|
107
|
+
The in-org doors are the same tools, rebuilt in Apex so they run where a local server cannot. They are a Salesforce feature an admin installs, not a script. They ship as three package directories in [salesforce/](salesforce/README.md) ([decision 0009](docs/decisions/0009-two-shipping-units.md)):
|
|
87
108
|
|
|
88
109
|
| Package | Holds | Needs |
|
|
89
110
|
| --- | --- | --- |
|
|
@@ -118,27 +139,21 @@ The method was the same for every feature: probe the platform, decide in writing
|
|
|
118
139
|
|
|
119
140
|
| Step | What it produced |
|
|
120
141
|
| --- | --- |
|
|
121
|
-
| **Probe** before building | [
|
|
122
|
-
| **Decide** in writing | [
|
|
142
|
+
| **Probe** before building | [7 probe write-ups](docs/probes/): can a hosted MCP server wrap Apex, can Apex call its own org's API, how big a result fits in the heap, which identity works under a hosted sign-in, how dashboards refresh and filter |
|
|
143
|
+
| **Decide** in writing | [19 decision records](docs/decisions/README.md), each with what it costs and what was turned down |
|
|
123
144
|
| **Build** to one contract | [design.md](docs/design.md): the input and output shapes, and the live findings where Salesforce did not behave as documented |
|
|
124
|
-
| **Verify** against a real org |
|
|
145
|
+
| **Verify** against a real org | 256 unit and server tests, 24 render tests that draw the report and dashboard cards in a browser, 90 Apex tests, a live end-to-end check that leaves no report behind, and a parity check across doors; `npm run verify` runs all of it against one org and ends with one table of counts |
|
|
125
146
|
| **Rehearse** the experience | [7 rehearsals](docs/rehearsals/README.md): real Claude runs of each skill on each door, with what went wrong and what changed |
|
|
126
147
|
|
|
127
148
|
## Get started
|
|
128
149
|
|
|
129
150
|
| You want | Start here |
|
|
130
151
|
| --- | --- |
|
|
131
|
-
| Claude
|
|
152
|
+
| Claude Desktop on your own org, nothing installed in Salesforce | [Claude Desktop guide](docs/claude-desktop.md) |
|
|
153
|
+
| Claude Code or another MCP client, the same way | [Local MCP server](docs/install.md#the-local-mcp-server) |
|
|
132
154
|
| The feature inside your org, called from the CLI, scripts or agents | [The REST door](docs/install.md#the-rest-door) |
|
|
133
155
|
| Claude in the browser, signed in through Salesforce | [The MCP door](docs/install.md#the-mcp-door), then [the callout tools](docs/install.md#the-callout-tools-on-the-mcp-door) |
|
|
134
156
|
|
|
135
|
-
The quickest try is the Claude Code plugin, which bundles the skills and the local server:
|
|
136
|
-
|
|
137
|
-
```
|
|
138
|
-
claude plugin marketplace add nelsben/salesforce-reports-mcp
|
|
139
|
-
claude plugin install salesforce-reports@salesforce-reports
|
|
140
|
-
```
|
|
141
|
-
|
|
142
157
|
The tools, the result shape, the safety rails and the limits are in [docs/reference.md](docs/reference.md).
|
|
143
158
|
|
|
144
159
|
## Develop
|
|
@@ -156,7 +171,6 @@ npm run capture -- --org <alias> # refresh the test fixtures (anon
|
|
|
156
171
|
|
|
157
172
|
## Roadmap
|
|
158
173
|
|
|
159
|
-
- Dashboards: list, describe, refresh, and read component data.
|
|
160
174
|
- Joined reports, report charts, and subscriptions.
|
|
161
175
|
|
|
162
176
|
## License
|
package/dist/reports/api.js
CHANGED
|
@@ -59,6 +59,25 @@ export class ReportsApi {
|
|
|
59
59
|
const out = await this.api.request("GET", `/query?q=${enc(query)}`);
|
|
60
60
|
return out.records;
|
|
61
61
|
}
|
|
62
|
+
// ---- dashboards. `filters` is a query string from resolveFilters, such as "?filter1=0IC…", or "". ----
|
|
63
|
+
describeDashboard(id) {
|
|
64
|
+
return this.api.request("GET", `/analytics/dashboards/${enc(id)}/describe`);
|
|
65
|
+
}
|
|
66
|
+
/** The cached results. Never refreshes. */
|
|
67
|
+
dashboardResults(id, filters = "") {
|
|
68
|
+
return this.api.request("GET", `/analytics/dashboards/${enc(id)}${filters}`);
|
|
69
|
+
}
|
|
70
|
+
dashboardStatus(id, filters = "") {
|
|
71
|
+
return this.api.request("GET", `/analytics/dashboards/${enc(id)}/status${filters}`);
|
|
72
|
+
}
|
|
73
|
+
/** Starts a refresh. The filter goes on the query string: a body is accepted and ignored. */
|
|
74
|
+
async refreshDashboard(id, filters = "") {
|
|
75
|
+
await this.api.request("PUT", `/analytics/dashboards/${enc(id)}${filters}`);
|
|
76
|
+
}
|
|
77
|
+
/** Waits between polls. Tests pass a sleep that returns at once. */
|
|
78
|
+
pause(ms) {
|
|
79
|
+
return this.sleep(ms);
|
|
80
|
+
}
|
|
62
81
|
async runAsync(id, overrides) {
|
|
63
82
|
const started = await this.api.request("POST", `/analytics/reports/${enc(id)}/instances?includeDetails=true`, overrides ? { reportMetadata: overrides } : undefined);
|
|
64
83
|
const instanceId = started.id ?? started.attributes?.id;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { InputError } from "../format.js";
|
|
2
|
+
import { SalesforceError } from "../salesforce/client.js";
|
|
3
|
+
import { soqlLike } from "./api.js";
|
|
4
|
+
import { shapeResult } from "./shape.js";
|
|
5
|
+
import { humanize, reportUrl } from "./summarize.js";
|
|
6
|
+
/** Seconds between status polls after a refresh, and how many polls: 20 polls 3 seconds apart is 60 seconds. */
|
|
7
|
+
export const REFRESH_POLL_MS = 3000;
|
|
8
|
+
export const REFRESH_POLLS = 20;
|
|
9
|
+
/** Default cap on detail rows per component. */
|
|
10
|
+
export const COMPONENT_ROWS = 20;
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
export function dashboardUrl(baseUrl, id) {
|
|
13
|
+
return baseUrl && id ? `${baseUrl}/lightning/r/Dashboard/${id}/view` : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* SOQL on Dashboard. GET /analytics/dashboards lists only recently viewed dashboards, so listing goes through SOQL,
|
|
17
|
+
* which returns the dashboards the signed-in user can see. One row past the limit tells whether the list was cut.
|
|
18
|
+
*/
|
|
19
|
+
export function dashboardListSoql(input) {
|
|
20
|
+
const where = [];
|
|
21
|
+
if (input.query)
|
|
22
|
+
where.push(`(Title LIKE '%${soqlLike(input.query)}%' OR DeveloperName LIKE '%${soqlLike(input.query)}%')`);
|
|
23
|
+
if (input.folder)
|
|
24
|
+
where.push(`FolderName LIKE '%${soqlLike(input.folder)}%'`);
|
|
25
|
+
return `SELECT Id, Title, DeveloperName, FolderName, FolderId, Description, Type, LastModifiedDate FROM Dashboard${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY LastModifiedDate DESC LIMIT ${input.limit + 1}`;
|
|
26
|
+
}
|
|
27
|
+
/** The newest refresh date in a status answer, or null when no component was ever refreshed. */
|
|
28
|
+
export function latestRefresh(status) {
|
|
29
|
+
const dates = (status?.componentStatus ?? []).map((c) => c.refreshDate).filter((d) => Boolean(d));
|
|
30
|
+
if (!dates.length)
|
|
31
|
+
return null;
|
|
32
|
+
return dates.reduce((a, b) => (time(b) > time(a) ? b : a));
|
|
33
|
+
}
|
|
34
|
+
export function optionLabel(option) {
|
|
35
|
+
if (option.alias)
|
|
36
|
+
return String(option.alias);
|
|
37
|
+
if (option.value !== null && option.value !== undefined && option.value !== "")
|
|
38
|
+
return String(option.value);
|
|
39
|
+
const start = option.startValue;
|
|
40
|
+
const end = option.endValue;
|
|
41
|
+
if (start && end)
|
|
42
|
+
return `${start} to ${end}`;
|
|
43
|
+
if (start)
|
|
44
|
+
return `${start} or more`;
|
|
45
|
+
if (end)
|
|
46
|
+
return `up to ${end}`;
|
|
47
|
+
return String(option.id);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Filter choices by name and option label, mapped to the option ids the data calls take. Salesforce reads the
|
|
51
|
+
* option id from `filter<n>` on the query string, n being the filter's place in describe's `filters` (only
|
|
52
|
+
* filter1 was proven, on a one-filter dashboard). The option text itself is refused with 400, so it is never sent.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveFilters(meta, choices) {
|
|
55
|
+
const filters = (meta.filters ?? []);
|
|
56
|
+
const all = (f) => `${f.name}: all (no option chosen)`;
|
|
57
|
+
if (!choices?.length)
|
|
58
|
+
return { query: "", words: filters.map(all), applied: [] };
|
|
59
|
+
const listing = () => filters.map((f) => `${f.name} (${(f.options ?? []).map(optionLabel).join(", ")})`).join("; ");
|
|
60
|
+
if (!filters.length)
|
|
61
|
+
throw new InputError("This dashboard has no filters. Leave out `filters`.");
|
|
62
|
+
const chosen = new Map();
|
|
63
|
+
for (const choice of choices) {
|
|
64
|
+
const index = filters.findIndex((f) => same(f.name, choice.name));
|
|
65
|
+
if (index < 0)
|
|
66
|
+
throw new InputError(`No dashboard filter named "${choice.name}". This dashboard's filters: ${listing()}.`);
|
|
67
|
+
const filter = filters[index];
|
|
68
|
+
if (chosen.has(index))
|
|
69
|
+
throw new InputError(`The filter ${filter.name} is given twice. Give one option per filter.`);
|
|
70
|
+
const options = (filter.options ?? []);
|
|
71
|
+
const option = options.find((o) => o.id === choice.option || same(optionLabel(o), choice.option) || same(o.value, choice.option) || same(o.alias, choice.option));
|
|
72
|
+
if (!option)
|
|
73
|
+
throw new InputError(`${filter.name} has no option "${choice.option}". Its options: ${options.map(optionLabel).join(", ")}.`);
|
|
74
|
+
chosen.set(index, option);
|
|
75
|
+
}
|
|
76
|
+
const query = `?${[...chosen.entries()]
|
|
77
|
+
.sort(([a], [b]) => a - b)
|
|
78
|
+
.map(([i, o]) => `filter${i + 1}=${encodeURIComponent(String(o.id))}`)
|
|
79
|
+
.join("&")}`;
|
|
80
|
+
const words = filters.map((f, i) => (chosen.has(i) ? `${f.name}: ${optionLabel(chosen.get(i))}` : all(f)));
|
|
81
|
+
return { query, words, applied: words.filter((_, i) => chosen.has(i)) };
|
|
82
|
+
}
|
|
83
|
+
/** Whose data the dashboard shows, in words. */
|
|
84
|
+
export function runningUserWords(meta) {
|
|
85
|
+
const name = meta.runningUser?.displayName;
|
|
86
|
+
switch (meta.dashboardType) {
|
|
87
|
+
case "SpecifiedUser":
|
|
88
|
+
return `${name ?? "A set user"}: the dashboard always runs as this user, so every viewer sees the same data.`;
|
|
89
|
+
case "LoggedInUser":
|
|
90
|
+
return "The viewer: each person sees the data they can see, so these results are the signed-in user's.";
|
|
91
|
+
case "MyTeamUser":
|
|
92
|
+
return "The viewer, who as a manager can also view it as someone on their team. These results are the signed-in user's.";
|
|
93
|
+
default:
|
|
94
|
+
return name ? `${name} (${meta.dashboardType ?? "unknown type"})` : "Unknown";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** describe's components in layout order (top to bottom, then left to right), each with its grid position. */
|
|
98
|
+
function inLayoutOrder(meta) {
|
|
99
|
+
const components = (meta.components ?? []);
|
|
100
|
+
const cells = (meta.layout?.components ?? []);
|
|
101
|
+
const placed = components.map((component, i) => {
|
|
102
|
+
const cell = cells[i];
|
|
103
|
+
const position = cell ? { row: cell.row, column: cell.column, rows: cell.rowspan, columns: cell.colspan } : null;
|
|
104
|
+
return { component, position, i };
|
|
105
|
+
});
|
|
106
|
+
placed.sort((a, b) => {
|
|
107
|
+
if (!a.position || !b.position)
|
|
108
|
+
return a.i - b.i;
|
|
109
|
+
return a.position.row - b.position.row || a.position.column - b.position.column || a.i - b.i;
|
|
110
|
+
});
|
|
111
|
+
return placed.map(({ component, position }) => ({ component, position }));
|
|
112
|
+
}
|
|
113
|
+
const AGGREGATE_WORDS = { s: "Sum of", a: "Average of", m: "Smallest", mx: "Largest", u: "Unique" };
|
|
114
|
+
function measureLabel(name) {
|
|
115
|
+
if (name === "RowCount")
|
|
116
|
+
return "Record Count";
|
|
117
|
+
const prefix = /^([a-z]+)!/.exec(name)?.[1];
|
|
118
|
+
const words = prefix ? AGGREGATE_WORDS[prefix] : undefined;
|
|
119
|
+
return words ? `${words} ${humanize(name)}` : humanize(name);
|
|
120
|
+
}
|
|
121
|
+
export function summarizeDashboard(describe, options) {
|
|
122
|
+
const names = options.reportNames ?? new Map();
|
|
123
|
+
const nameOf = (id) => (id ? (names.get(id) ?? names.get(id.slice(0, 15)) ?? null) : null);
|
|
124
|
+
return {
|
|
125
|
+
id: describe.id,
|
|
126
|
+
name: describe.name,
|
|
127
|
+
developerName: describe.developerName ?? null,
|
|
128
|
+
description: describe.description ?? null,
|
|
129
|
+
folder: { id: describe.folderId ?? null, name: describe.folderName ?? null },
|
|
130
|
+
type: describe.dashboardType ?? null,
|
|
131
|
+
runningUser: runningUserWords(describe),
|
|
132
|
+
owner: describe.owner?.displayName ?? null,
|
|
133
|
+
url: dashboardUrl(options.baseUrl, describe.id),
|
|
134
|
+
filters: (describe.filters ?? []).map((f) => ({
|
|
135
|
+
name: f.name,
|
|
136
|
+
dataType: f.dataType ?? null,
|
|
137
|
+
options: (f.options ?? []).map(optionLabel),
|
|
138
|
+
})),
|
|
139
|
+
layout: `Components are listed in layout order: top to bottom, then left to right, on a grid ${describe.layout?.numColumns ?? 12} columns wide.`,
|
|
140
|
+
components: inLayoutOrder(describe).map(({ component: c, position }) => {
|
|
141
|
+
const props = c.properties ?? {};
|
|
142
|
+
const reportId = c.reportId ?? null;
|
|
143
|
+
return {
|
|
144
|
+
id: c.id,
|
|
145
|
+
header: c.header ?? null,
|
|
146
|
+
title: c.title ?? null,
|
|
147
|
+
footer: c.footer ?? null,
|
|
148
|
+
visualizationType: props.visualizationType ?? null,
|
|
149
|
+
report: { id: reportId, name: nameOf(reportId), url: reportUrl(options.baseUrl, reportId) },
|
|
150
|
+
groupings: (props.groupings ?? []).map((g) => g.name),
|
|
151
|
+
measures: (props.aggregates ?? []).map((a) => ({ name: a.name, label: measureLabel(a.name) })),
|
|
152
|
+
position,
|
|
153
|
+
};
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** GET /analytics/dashboards/{id} to a DashboardResult. Each component's reportResult is shaped by shapeResult. */
|
|
158
|
+
export function shapeDashboard(results, options) {
|
|
159
|
+
const meta = results.dashboardMetadata ?? {};
|
|
160
|
+
const applied = options.appliedFilters ?? [];
|
|
161
|
+
const data = new Map((results.componentData ?? []).map((d) => [d.componentId, d]));
|
|
162
|
+
const notes = [];
|
|
163
|
+
const id = meta.id ?? results.attributes?.dashboardId;
|
|
164
|
+
const components = inLayoutOrder(meta).map(({ component: c }) => {
|
|
165
|
+
const entry = data.get(c.id);
|
|
166
|
+
const status = entry?.status ?? {};
|
|
167
|
+
const props = c.properties ?? {};
|
|
168
|
+
const out = {
|
|
169
|
+
id: c.id,
|
|
170
|
+
header: c.header ?? null,
|
|
171
|
+
title: c.title ?? null,
|
|
172
|
+
footer: c.footer ?? null,
|
|
173
|
+
visualizationType: props.visualizationType ?? null,
|
|
174
|
+
status: componentStatus(entry),
|
|
175
|
+
refreshedAt: status.refreshDate ?? null,
|
|
176
|
+
};
|
|
177
|
+
if (out.status === "error") {
|
|
178
|
+
out.error = { code: String(status.errorCode ?? status.componentDataStatus ?? "UNKNOWN"), message: String(status.errorMessage ?? "Salesforce gave no message.") };
|
|
179
|
+
}
|
|
180
|
+
if (out.status === "data") {
|
|
181
|
+
const raw = entry.reportResult;
|
|
182
|
+
const result = shapeResult(raw, {
|
|
183
|
+
maxRows: options.maxRows,
|
|
184
|
+
includeDetail: raw.reportMetadata?.reportFormat === "TABULAR",
|
|
185
|
+
baseUrl: options.baseUrl,
|
|
186
|
+
});
|
|
187
|
+
// reportMetadata shows the source report's own filters, never the dashboard's.
|
|
188
|
+
if ((props.filterColumns ?? []).length)
|
|
189
|
+
result.filters.push(...applied.map((w) => `Dashboard filter ${w}`));
|
|
190
|
+
out.result = result;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
});
|
|
194
|
+
const label = (c) => c.header ?? c.title ?? c.id;
|
|
195
|
+
const withData = components.filter((c) => c.status === "data");
|
|
196
|
+
const noData = components.filter((c) => c.status === "no data");
|
|
197
|
+
const forWhat = applied.length ? ` for ${applied.join(", ")}` : "";
|
|
198
|
+
if (components.length && noData.length === components.length) {
|
|
199
|
+
notes.push(`No component has data${forWhat}: the dashboard has not been refreshed${forWhat ? " with these filters" : ""}. Call refresh_dashboard with the same filters to get numbers.`);
|
|
200
|
+
}
|
|
201
|
+
else if (noData.length) {
|
|
202
|
+
notes.push(`${noData.length} of ${components.length} components have no data${forWhat} (${noData.map(label).join(", ")}). Call refresh_dashboard with the same filters to fill them.`);
|
|
203
|
+
}
|
|
204
|
+
for (const c of components.filter((x) => x.status === "error"))
|
|
205
|
+
notes.push(`${label(c)} failed in Salesforce: ${c.error.code}: ${c.error.message}`);
|
|
206
|
+
const dates = withData.map((c) => c.refreshedAt).filter((d) => Boolean(d));
|
|
207
|
+
const refreshedAt = dates.length ? dates.reduce((a, b) => (time(b) < time(a) ? b : a)) : null;
|
|
208
|
+
if (refreshedAt && withData.some((c) => data.get(c.id)?.status?.refreshStatus === "STALE")) {
|
|
209
|
+
notes.push(`Salesforce marks this data stale: the dashboard was refreshed since with other filters. These numbers are from ${refreshedAt}; call refresh_dashboard with the same filters for current ones.`);
|
|
210
|
+
}
|
|
211
|
+
if (refreshedAt) {
|
|
212
|
+
const age = options.now.getTime() - time(refreshedAt);
|
|
213
|
+
if (age > DAY_MS) {
|
|
214
|
+
const days = Math.floor(age / DAY_MS);
|
|
215
|
+
notes.push(`These numbers were refreshed ${days} day${days === 1 ? "" : "s"} ago, at ${refreshedAt}. Call refresh_dashboard for current ones.`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const c of withData)
|
|
219
|
+
for (const n of c.result.notes)
|
|
220
|
+
notes.push(`${label(c)}: ${n}`);
|
|
221
|
+
return {
|
|
222
|
+
dashboard: {
|
|
223
|
+
id,
|
|
224
|
+
name: meta.name ?? results.attributes?.dashboardName ?? "Dashboard",
|
|
225
|
+
developerName: meta.developerName ?? null,
|
|
226
|
+
folder: { id: meta.folderId ?? null, name: meta.folderName ?? null },
|
|
227
|
+
url: dashboardUrl(options.baseUrl, id),
|
|
228
|
+
},
|
|
229
|
+
runningUser: runningUserWords(meta),
|
|
230
|
+
filters: options.filterWords,
|
|
231
|
+
refreshedAt,
|
|
232
|
+
components,
|
|
233
|
+
notes,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function componentStatus(entry) {
|
|
237
|
+
const status = entry?.status ?? {};
|
|
238
|
+
if (status.errorCode || status.componentDataStatus === "ERROR")
|
|
239
|
+
return "error";
|
|
240
|
+
if (entry?.reportResult)
|
|
241
|
+
return "data";
|
|
242
|
+
if (!entry || status.componentDataStatus === "NO_DATA" || status.componentDataStatus === "DATA")
|
|
243
|
+
return "no data";
|
|
244
|
+
return "error";
|
|
245
|
+
}
|
|
246
|
+
/** The once-a-minute refusal: 403 FORBIDDEN, "You can't refresh this dashboard more than once in a minute." */
|
|
247
|
+
export function isRefreshLimit(error) {
|
|
248
|
+
return error instanceof SalesforceError && error.status === 403 && (error.code === "FORBIDDEN" || /minute/i.test(error.message));
|
|
249
|
+
}
|
|
250
|
+
/** A component is done when it is not running and has an error, or data newer than before the refresh. */
|
|
251
|
+
export function refreshDone(status, before) {
|
|
252
|
+
return (status.componentStatus ?? []).every((c) => {
|
|
253
|
+
if (c.refreshStatus === "RUNNING")
|
|
254
|
+
return false;
|
|
255
|
+
if (c.errorCode || c.componentDataStatus === "ERROR")
|
|
256
|
+
return true;
|
|
257
|
+
return c.componentDataStatus === "DATA" && Boolean(c.refreshDate) && c.refreshDate !== before.get(c.componentId);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Starts a refresh and waits for it: PUT with the filters on the query string (a body is ignored), then the
|
|
262
|
+
* status every 3 seconds until every component is done or 60 seconds pass. Returns notes for the result.
|
|
263
|
+
* The once-a-minute 403 becomes a note; any other failure is thrown.
|
|
264
|
+
*/
|
|
265
|
+
export async function refreshAndWait(reports, id, filters, now) {
|
|
266
|
+
const before = new Map();
|
|
267
|
+
try {
|
|
268
|
+
for (const c of ((await reports.dashboardStatus(id, filters)).componentStatus ?? []))
|
|
269
|
+
before.set(c.componentId, c.refreshDate ?? null);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* without the earlier dates, any data counts as new */
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
await reports.refreshDashboard(id, filters);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!isRefreshLimit(error))
|
|
279
|
+
throw error;
|
|
280
|
+
// A minute after this filter's last refresh when that is still ahead, else a minute from now (the last refresh
|
|
281
|
+
// may have been for other filters, which this status does not show).
|
|
282
|
+
const last = [...before.values()].filter((d) => Boolean(d)).map(time).reduce((a, b) => Math.max(a, b), 0);
|
|
283
|
+
const nowMs = now().getTime();
|
|
284
|
+
const retry = last + 60_000 > nowMs ? last + 60_000 : nowMs + 60_000;
|
|
285
|
+
const after = new Date(retry).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
286
|
+
return [
|
|
287
|
+
`Salesforce refreshes a dashboard at most once a minute, and this one was refreshed less than a minute ago, so these are the cached results. Try again after ${after}.`,
|
|
288
|
+
];
|
|
289
|
+
}
|
|
290
|
+
for (let poll = 0; poll < REFRESH_POLLS; poll++) {
|
|
291
|
+
await reports.pause(REFRESH_POLL_MS);
|
|
292
|
+
try {
|
|
293
|
+
if (refreshDone(await reports.dashboardStatus(id, filters), before))
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* a failed poll is retried at the next one */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return [`The refresh had not finished after ${(REFRESH_POLL_MS * REFRESH_POLLS) / 1000} seconds, so some components may show older data. Call run_dashboard in a minute to read the refreshed results.`];
|
|
301
|
+
}
|
|
302
|
+
function same(a, b) {
|
|
303
|
+
return typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
304
|
+
}
|
|
305
|
+
/** Salesforce dates end in "+0000", which is not ISO 8601; add the colon before parsing. */
|
|
306
|
+
function time(date) {
|
|
307
|
+
return Date.parse(date.replace(/([+-]\d{2})(\d{2})$/, "$1:$2"));
|
|
308
|
+
}
|