forest_admin_datasource_intercom 1.49.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/LICENSE +202 -0
- data/README.md +811 -0
- data/Rakefile +6 -0
- data/exe/forest_admin_intercom_probe +287 -0
- data/forest_admin_datasource_intercom.gemspec +38 -0
- data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
- data/lib/forest_admin_datasource_intercom/client.rb +546 -0
- data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
- data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
- data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
- data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
- data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
- data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
- data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
- data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
- data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
- data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
- data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
- data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
- data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
- data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
- data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
- data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
- data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
- data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
- data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
- data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
- data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
- data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
- data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
- data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
- data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
- data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
- data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
- data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
- data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
- data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
- data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
- data/lib/forest_admin_datasource_intercom/version.rb +3 -0
- data/lib/forest_admin_datasource_intercom.rb +57 -0
- metadata +180 -0
data/README.md
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
# Forest — Intercom datasource
|
|
2
|
+
|
|
3
|
+
Surface [Intercom](https://www.intercom.com) conversations, tickets, contacts, companies, teammates,
|
|
4
|
+
teams, ticket types and ticket states as Forest collections.
|
|
5
|
+
|
|
6
|
+
This release is **read only**. Rows, record details, exact counts, server-side filtering, the
|
|
7
|
+
relations between collections and the conversation thread work; writes, notes, tags, segments and
|
|
8
|
+
group-by on the paginated collections arrive in the lots after it, business actions as plugins the
|
|
9
|
+
agent opts into with `collection.use` — see "What is not here yet".
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
gem 'forest_admin_datasource_intercom'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# app/lib/forest_admin_rails/create_agent.rb
|
|
22
|
+
ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(
|
|
23
|
+
ForestAdminDatasourceIntercom::Datasource.new(
|
|
24
|
+
access_token: ENV['INTERCOM_ACCESS_TOKEN'],
|
|
25
|
+
region: :eu # :us (default), :eu or :au
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The token is the access token of a private app, created in Intercom's Developer Hub under
|
|
31
|
+
*Configure › Authentication*. OAuth is out of scope: it belongs to a control plane distributing a
|
|
32
|
+
connector, not to an agent reading one workspace.
|
|
33
|
+
|
|
34
|
+
`Client#me` is the health check — it returns the admin the token belongs to, and is the one call
|
|
35
|
+
that verifies the pinned API version was honoured.
|
|
36
|
+
|
|
37
|
+
### Configuration
|
|
38
|
+
|
|
39
|
+
| Option | Default | What it is for |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `access_token` | — | Required. The private app's bearer token. |
|
|
42
|
+
| `region` | `:us` | `:us`, `:eu`, `:au`. A workspace answers in its own region only. |
|
|
43
|
+
| `base_url` | from `region` | Wins over `region`. For a gateway or a mock server. Refused if it carries credentials: the `Authorization` header holds the Intercom token, so a credentialed egress proxy goes in `HTTPS_PROXY` instead. |
|
|
44
|
+
| `api_version` | `'2.16'` | Sent as `Intercom-Version` on every request. |
|
|
45
|
+
| `open_timeout` / `timeout` | `5` / `30` | A request that already has a page on screen. |
|
|
46
|
+
| `boot_open_timeout` / `boot_timeout` | `3` / `10` | The one read performed while the agent starts. |
|
|
47
|
+
| `retry_policy` | `RetryPolicy.new` | Statuses, verbs and backoff. |
|
|
48
|
+
| `boot_retry_policy` | `RetryPolicy.boot` | One quick retry; gives up rather than waiting a 429 out. |
|
|
49
|
+
| `rate_limiter` | `RateLimiter.new` | `nil` takes the pacing out of the stack. |
|
|
50
|
+
| `reference_cache_ttl` | `60` | Seconds the workspace's reference lists are held for. `0` re-reads them every time. |
|
|
51
|
+
| `adapter` | persistent, 25 connections | The Faraday adapter. `:net_http` opts out of keep-alive; `[name, options]` sizes the pool, which is read against the server's thread count. |
|
|
52
|
+
| `concurrent_reads` | `1` | How many records a read by id fetches at once. Every read in flight holds a connection of the pool — see [Reading records by id in parallel](#reading-records-by-id-in-parallel). |
|
|
53
|
+
|
|
54
|
+
**Pin the region explicitly.** `api.intercom.io` does route to the right one, but a workspace under
|
|
55
|
+
GDPR wants its requests reaching the European host and nothing else.
|
|
56
|
+
|
|
57
|
+
**The version is pinned on purpose.** Without the header a request follows the workspace's own
|
|
58
|
+
default version, which an operator can change on Intercom's side — and the payloads change shape
|
|
59
|
+
underneath. Intercom echoes the version it served, so `me` compares the two and logs a warning when
|
|
60
|
+
the pin was not honoured, rather than raising: running against a version we did not ask for still
|
|
61
|
+
beats not running.
|
|
62
|
+
|
|
63
|
+
### Token permissions
|
|
64
|
+
|
|
65
|
+
A read-only token is enough, and is what to recommend for this lot. A permission the token lacks
|
|
66
|
+
costs **columns or a collection, never the boot of the agent**: the three boot-time introspections
|
|
67
|
+
each degrade to no attribute column, a collection whose endpoint answers 403 fails its own page, and
|
|
68
|
+
a token that cannot read `/admins` or `/teams` leaves the `admin_names` / `team_names` column `nil`
|
|
69
|
+
(unknown) rather than failing the page it is on. A token denied contacts or companies costs those two
|
|
70
|
+
collections and the `contact_name` column, and leaves everything else standing.
|
|
71
|
+
|
|
72
|
+
A **relation is the exception**, and it is worth knowing before scoping a token: resolving one reads
|
|
73
|
+
the target endpoint, and that read is not guarded the way the names above are. A token denied
|
|
74
|
+
`/admins` fails any page projecting `admin_assignee:name`, and fails the related list behind
|
|
75
|
+
`IntercomTeam#admins` — the failure lands on the collection being read, not on the one that was
|
|
76
|
+
denied. Scope the token to the endpoints in the table below, or to none of them.
|
|
77
|
+
|
|
78
|
+
## Collections
|
|
79
|
+
|
|
80
|
+
| Collection | Endpoint | Paginated | Countable |
|
|
81
|
+
| --- | --- | --- | --- |
|
|
82
|
+
| `IntercomConversation` | `GET /conversations`, `POST /conversations/search`, `GET /conversations/{id}` | cursor | yes, exactly |
|
|
83
|
+
| `IntercomTicket` | `POST /tickets/search`, `GET /tickets/{id}` | cursor | yes, exactly |
|
|
84
|
+
| `IntercomAdmin` | `GET /admins` | read whole | yes, exactly |
|
|
85
|
+
| `IntercomTeam` | `GET /teams` | read whole | yes, exactly |
|
|
86
|
+
| `IntercomTeamMembership` | `GET /teams`, `GET /admins` | read whole | yes, exactly |
|
|
87
|
+
| `IntercomTicketType` | `GET /ticket_types` | read whole | yes, exactly |
|
|
88
|
+
| `IntercomTicketState` | `GET /ticket_states` | read whole | yes, exactly |
|
|
89
|
+
| `IntercomContact` | `GET /contacts`, `POST /contacts/search`, `GET /companies/{id}/contacts` | cursor | yes, exactly |
|
|
90
|
+
| `IntercomCompany` | `POST /companies/list`, `GET /companies?...`, `GET /companies/{id}` | **offset** | yes, exactly |
|
|
91
|
+
|
|
92
|
+
Three tiers, and they behave differently on purpose.
|
|
93
|
+
|
|
94
|
+
**Read whole** — admins, teams, team memberships, ticket types, ticket states. Their endpoints answer in one response,
|
|
95
|
+
so filtering, sorting, paging and counting them in memory is *exact*: the records in hand are every
|
|
96
|
+
record Intercom holds. These are the only collections that can be filtered, sorted and grouped in
|
|
97
|
+
this lot, and the only ones a chart may group by. The cost is bandwidth, not correctness.
|
|
98
|
+
|
|
99
|
+
**Cursor** — conversations, tickets and contacts. What is in hand is a page of something far larger,
|
|
100
|
+
so nothing is filtered or sorted in memory. Three routes and no fourth: no condition walks the
|
|
101
|
+
listing, `id equals X` reads the record through its own endpoint, and anything else is translated
|
|
102
|
+
into Intercom's search DSL and walked through the search endpoint. What the translation cannot
|
|
103
|
+
express is **refused by name** — see [Filtering](#filtering). Contacts add two routes of their own,
|
|
104
|
+
both described under [Contacts](#contacts).
|
|
105
|
+
|
|
106
|
+
**Offset** — companies, and nothing else. `POST /companies/list` takes a **page number**, which is
|
|
107
|
+
what a list view asks for: page 7 is one request rather than six pages walked to reach it, with no
|
|
108
|
+
cap and no truncation warning. It is the one place the [first limitation
|
|
109
|
+
below](#what-the-api-cannot-do-and-what-this-does-about-it) does not apply. What it pays for that is
|
|
110
|
+
filtering — there is no company search endpoint at all, so what a filter may say is a handful of
|
|
111
|
+
exact lookups and nothing else. See [Companies](#companies).
|
|
112
|
+
|
|
113
|
+
## Relations
|
|
114
|
+
|
|
115
|
+
Intercom joins nothing: a ticket carries an assignee id, and the teammate behind it is a second read
|
|
116
|
+
of a second endpoint. What makes these relations affordable is what the collection on the far end
|
|
117
|
+
costs to read by id, and the three tiers do not cost the same:
|
|
118
|
+
|
|
119
|
+
| Target | Read by id | A page of rows costs | Fan-out |
|
|
120
|
+
| --- | --- | --- | --- |
|
|
121
|
+
| `IntercomAdmin`, `IntercomTeam`, `IntercomTicketState`, `IntercomTicketType` | read whole | **one request**, whatever the page holds | unbounded |
|
|
122
|
+
| `IntercomContact` | `id IN [...]`, 15 at a time | one request per 15 distinct contacts | unbounded |
|
|
123
|
+
| `IntercomCompany` | `GET /companies/{id}` | **one request per distinct account** | bounded, see below |
|
|
124
|
+
|
|
125
|
+
The price is per target *collection*, not per relation: a ticket's `state` and `previous_state` are
|
|
126
|
+
one read of `/ticket_states`, over the ids both of them name.
|
|
127
|
+
|
|
128
|
+
**The one relation with a ceiling is `IntercomContact#company`.** There is no bulk read for a
|
|
129
|
+
company — `/companies/scroll` is [deliberately rejected](#companies) — so each distinct account on
|
|
130
|
+
the page costs a request, and past
|
|
131
|
+
[`MAX_RELATION_READS`](lib/forest_admin_datasource_intercom/collections/offset_collection.rb)
|
|
132
|
+
distinct accounts the read is **refused by name** rather than resolved for the first slice and left
|
|
133
|
+
nil for the rest. Every page size a list view offers sits under that figure; an export or a segment
|
|
134
|
+
resolved whole does not, and the message says to leave the column out or read fewer rows at a time.
|
|
135
|
+
The alternative — a nil where an account exists — is the one answer this datasource must not give,
|
|
136
|
+
and a log line nobody reads is not a substitute for it.
|
|
137
|
+
|
|
138
|
+
*Exactly*, with one more bound worth naming: "read whole" is what the endpoint answers, and
|
|
139
|
+
`fetch_all` stops after [`MAX_COLLECTED_PAGES`](lib/forest_admin_datasource_intercom/client.rb) pages
|
|
140
|
+
if Intercom paginates one of these on its own — it logs when it does, and does not cache the partial
|
|
141
|
+
list, so the next read tries again and logs again. A workspace whose `/admins` or `/teams` runs past
|
|
142
|
+
that cap resolves the relations pointing at the records it dropped as empty. The figure is sized for
|
|
143
|
+
reference collections, which is what the first tier above is.
|
|
144
|
+
|
|
145
|
+
| Collection | Relation | Target | Filterable through |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| `IntercomConversation` | `admin_assignee`, `closed_by` | `IntercomAdmin` | yes |
|
|
148
|
+
| `IntercomConversation` | `team_assignee` | `IntercomTeam` | yes |
|
|
149
|
+
| `IntercomTicket` | `admin_assignee` | `IntercomAdmin` | yes |
|
|
150
|
+
| `IntercomTicket` | `team_assignee` | `IntercomTeam` | yes |
|
|
151
|
+
| `IntercomTicket` | `ticket_type` | `IntercomTicketType` | yes |
|
|
152
|
+
| `IntercomTicket` | `state`, `previous_state` | `IntercomTicketState` | **no** — read and navigate only |
|
|
153
|
+
| `IntercomTeam` | `admins` | `IntercomAdmin` | no (many-to-many) |
|
|
154
|
+
| `IntercomAdmin` | `teams` | `IntercomTeam` | no (many-to-many) |
|
|
155
|
+
| `IntercomTeamMembership` | `team`, `admin` | `IntercomTeam`, `IntercomAdmin` | yes |
|
|
156
|
+
| `IntercomConversation` | `contact` | `IntercomContact` | yes |
|
|
157
|
+
| `IntercomTicket` | `contact` | `IntercomContact` | **spec, unprobed** — see below |
|
|
158
|
+
| `IntercomContact` | `owner` | `IntercomAdmin` | yes |
|
|
159
|
+
| `IntercomContact` | `company` | `IntercomCompany` | **no** — read and navigate only |
|
|
160
|
+
| `IntercomContact` | `conversations`, `tickets` | `IntercomConversation`, `IntercomTicket` | no (one-to-many) |
|
|
161
|
+
| `IntercomCompany` | `contacts` | `IntercomContact` | no (one-to-many) |
|
|
162
|
+
|
|
163
|
+
Every one of them is **read-only**: this lot writes nothing, and Intercom exposes no endpoint that
|
|
164
|
+
writes a team membership at all.
|
|
165
|
+
|
|
166
|
+
**`IntercomTeamMembership` exists because Intercom's does not.** The workspace carries the
|
|
167
|
+
membership on the team (`admin_ids`) and on the teammate (`team_ids`) both and exposes no resource
|
|
168
|
+
for the pair, while a many-to-many needs a collection to travel through. It is synthesized from
|
|
169
|
+
`GET /teams`, one record per pair, keyed `teamId:adminId`. Without it, both sides read as an array of
|
|
170
|
+
ids nobody can click.
|
|
171
|
+
|
|
172
|
+
Two consequences of travelling through it are worth knowing. A **related list of teammates is
|
|
173
|
+
ordered by the membership, not by the teammate**: the agent hands the through collection the columns
|
|
174
|
+
of the collection the relation reaches, so an order on `name` or `email` cannot be resolved there and
|
|
175
|
+
is logged rather than silently dropped. And a `admin_ids` entry naming a teammate `/admins` does not
|
|
176
|
+
answer -- one who left, one outside the token's reach -- **drops out of the related list and of its
|
|
177
|
+
count** instead of appearing as an empty row: the memberships are read against `/admins`, which the
|
|
178
|
+
reference cache shares with `IntercomAdmin`.
|
|
179
|
+
|
|
180
|
+
Alongside it, a team names its teammates (`admin_names`) and a teammate its teams (`team_names`) on
|
|
181
|
+
the row itself, so a list view reads without a join. **Those replace the arrays of ids** the first
|
|
182
|
+
lots published: one readable form plus a relation to navigate, rather than two ways to read one fact.
|
|
183
|
+
They are read only when a projection asks for them, and a token that cannot read the other side
|
|
184
|
+
costs the column and nothing else — never the page, and never the relation.
|
|
185
|
+
|
|
186
|
+
**The 360 degrees is those last four rows.** From a ticket or a conversation, `contact` reaches the
|
|
187
|
+
person who wrote in; from them, `conversations` and `tickets` list everything they ever opened, and
|
|
188
|
+
`company` reaches their account, whose `contacts` lists their colleagues. Each of those lists is one
|
|
189
|
+
request: `/conversations/search` matches a conversation against one of its contact ids, and
|
|
190
|
+
`GET /companies/{id}/contacts` answers the contacts of an account — which is the one relation
|
|
191
|
+
`/contacts/search` could not have resolved, filtering no company field.
|
|
192
|
+
|
|
193
|
+
**A conversation has several contacts, and the relation names the first of them** — the same one
|
|
194
|
+
`contact_name` and `contact_count` describe, so the column and the relation cannot disagree. The
|
|
195
|
+
others are a hop away: open that contact and read their conversations. The alternative, a
|
|
196
|
+
many-to-many through a join collection, would have been the honest cardinality at the price of three
|
|
197
|
+
collections of plumbing in the interface; naming the first contact and counting them is what lot 1
|
|
198
|
+
already published, and lot 4 promotes it rather than replacing it.
|
|
199
|
+
|
|
200
|
+
Two of these carry a caveat worth reading before scoping a token or writing a segment. The **ticket
|
|
201
|
+
side is a `spec` row the probe has not confirmed**: whether `/tickets/search` filters on
|
|
202
|
+
`contact_ids` at all is unmeasured, and if it does not, the relation stays navigable and the filter
|
|
203
|
+
moves to the refusal table — exactly what happened to the ticket `state`. And **the company
|
|
204
|
+
traversal is not offered**: `/contacts/search` filters no company field, so the relation is
|
|
205
|
+
published unfilterable and the agent refuses `company:name` before it reaches this datasource. Filter
|
|
206
|
+
from the company side instead: open the account and read its `contacts`.
|
|
207
|
+
|
|
208
|
+
The same rule settled the ticket labels: `state_label` and `ticket_type_name` stay on the row,
|
|
209
|
+
`state_category` and `state_external_label` are gone — they are a hop away, on the `state` relation,
|
|
210
|
+
and neither was ever filterable, so no segment, scope or saved filter could rest on them.
|
|
211
|
+
|
|
212
|
+
A relation reads its target **undecorated**, so a permission scope or a segment defined on the target
|
|
213
|
+
does not narrow what a relation resolves — the same way a native datasource joins a table without
|
|
214
|
+
applying the scopes of the collection mapped to it.
|
|
215
|
+
|
|
216
|
+
One semantic worth stating plainly: **a row whose foreign key is null matches no relation filter**,
|
|
217
|
+
the way a join drops it, negated filters included. A ticket with no assignee is not "assigned to
|
|
218
|
+
someone other than Marie".
|
|
219
|
+
|
|
220
|
+
## What the API cannot do, and what this does about it
|
|
221
|
+
|
|
222
|
+
Where Forest asks for something Intercom has no equivalent for, this datasource **refuses with a
|
|
223
|
+
message naming the reason** rather than answering something that looks right and is not. Those
|
|
224
|
+
arrive as a 400 carrying the text.
|
|
225
|
+
|
|
226
|
+
- **No offset pagination, except on companies.** Intercom hands out the page after a cursor and
|
|
227
|
+
documents that jumping to page N is unsupported, so reaching page 20 costs 20 sequential requests.
|
|
228
|
+
The walk is capped at 50 pages / 7 500 records and **every route out of it that is short of what
|
|
229
|
+
was asked for is logged**, naming the window it stopped in: the two caps, and the two defensive
|
|
230
|
+
stops — a page that advertises a next cursor and holds nothing, and a cursor already followed.
|
|
231
|
+
Intercom does neither of the last two today, which is exactly why they are reported rather than
|
|
232
|
+
taken for the end of the data. `POST /companies/list` is the exception and takes a page number,
|
|
233
|
+
which is why companies escape the walker and its caps entirely.
|
|
234
|
+
- **Duplicates on a moving dataset.** Intercom documents that records modified between two paginated
|
|
235
|
+
requests can be served twice; the walk deduplicates by id. The missed counterpart is inherent to
|
|
236
|
+
cursor pagination and cannot be repaired — it is documented rather than papered over.
|
|
237
|
+
- **One endpoint sorts, and it is `/contacts/search`.** Everything else comes back in the order the
|
|
238
|
+
API imposes: `POST /companies/list` has no order parameter at all, and the other two search
|
|
239
|
+
endpoints **accept a `sort` and ignore it** — measured, it raises nothing and changes nothing.
|
|
240
|
+
Since that is undetectable at runtime, no column of `IntercomConversation`, `IntercomTicket` or
|
|
241
|
+
`IntercomCompany` is declared sortable and a requested order is reported in the log. The
|
|
242
|
+
collections read whole sort in memory, exactly, and Contacts sort server-side on the columns the
|
|
243
|
+
measured table declares — see [Contacts](#contacts). One route of Contacts cannot carry it either:
|
|
244
|
+
a read by id cuts the window in the order the ids were named, so ordering what comes back would
|
|
245
|
+
order a slice picked by something else. That order is reported in the log too.
|
|
246
|
+
- **No aggregate endpoint.** Counting is free and exact — `total_count` counts what the query names,
|
|
247
|
+
not what a page held — so the record counter is one request. Anything beyond a count is refused on
|
|
248
|
+
the cursor collections: grouping over the pages a walk collected would look exact while answering
|
|
249
|
+
a fraction.
|
|
250
|
+
- **`per_page` is refused past 150**, with `invalid_per_page` and no silent downgrade, so the page
|
|
251
|
+
size is bounded before the request leaves. Tickets are bounded far lower still: **25**, because
|
|
252
|
+
the search response carries the whole timeline of every ticket and Intercom offers no field
|
|
253
|
+
selection. Provisional, pending measurement against real response sizes.
|
|
254
|
+
- **No `GET /tickets` at all.** Even an unfiltered ticket list goes through `POST /tickets/search`
|
|
255
|
+
with a predicate matching everything.
|
|
256
|
+
- **The envelope key is not always `data`.** Measured: `/tickets/search` answers under `tickets`,
|
|
257
|
+
`/admins` under `admins`, `/teams` under `teams`. A response carrying neither the expected key nor
|
|
258
|
+
`data` is refused rather than read as an empty page.
|
|
259
|
+
|
|
260
|
+
## Filtering
|
|
261
|
+
|
|
262
|
+
`POST /conversations/search` and `POST /tickets/search` answer the condition trees Forest sends, on
|
|
263
|
+
the fields Intercom really filters and with the operators each endpoint really validates. Anything
|
|
264
|
+
else is **refused with a message naming what to change** — a condition dropped on the way out comes
|
|
265
|
+
back as an unfiltered page that looks filtered, which is the one answer this datasource must not
|
|
266
|
+
give. A refusal costs no request: it is raised before anything leaves the process.
|
|
267
|
+
|
|
268
|
+
### The table is data, and it says where each row comes from
|
|
269
|
+
|
|
270
|
+
The fields a search endpoint filters are not the fields its specification lists. Measured:
|
|
271
|
+
`/tickets/search` refuses `company_id` with `invalid_field` although every ticket carries one. So
|
|
272
|
+
the source of truth is a committed table — `lib/forest_admin_datasource_intercom/query/search_fields.yml`
|
|
273
|
+
— one row per column, each carrying its provenance:
|
|
274
|
+
|
|
275
|
+
| `source` | What it means |
|
|
276
|
+
| --- | --- |
|
|
277
|
+
| `measured` | observed against a real workspace, by `forest_admin_intercom_probe` or during the spike |
|
|
278
|
+
| `spec` | read off Intercom's documentation, and therefore still a candidate |
|
|
279
|
+
|
|
280
|
+
Every `filter_operators` a column publishes is **derived** from that table, so a column cannot
|
|
281
|
+
advertise a filter the translator would then refuse, and a column the table does not carry
|
|
282
|
+
advertises nothing at all. That is the mechanism, and it holds whatever the rows say.
|
|
283
|
+
|
|
284
|
+
**What the rows say today is mostly `spec`: 18 of 89 are measured, and no endpoint has been probed
|
|
285
|
+
end to end** — all three carry `measured_at: null`, which is what `Endpoint#measured?` reports. The
|
|
286
|
+
measured rows are the ones a spike went out of its way to check: the date operators on each
|
|
287
|
+
endpoint, which disagree between them, `id IN` on `/contacts/search`, `contact_ids` on
|
|
288
|
+
`/conversations/search`, and the refusals a read confirmed — `company_id` on `/tickets/search`
|
|
289
|
+
above all, alongside the columns the agent derives rather than reads. Everything else is Intercom's
|
|
290
|
+
documentation, and the disagreement above is why that is a candidate rather than a promise. Those
|
|
291
|
+
two figures are asserted against the file, so they cannot drift from it.
|
|
292
|
+
|
|
293
|
+
So the first thing to do against a customer's workspace is to run the probe. The rows worth watching
|
|
294
|
+
first, in the order they will hurt:
|
|
295
|
+
|
|
296
|
+
1. **`admin_assignee_id` and `team_assignee_id`**, on both search endpoints. Typed `string` here;
|
|
297
|
+
Intercom documents them as `Integer` and answers `data_invalid` on a value whose type it does not
|
|
298
|
+
accept. These carry the `admin_assignee` and `team_assignee` relations — the filter an ops team
|
|
299
|
+
reaches for first — so a wrong type here is the most expensive `spec` row in the file;
|
|
300
|
+
2. **`state_id` on `/tickets/search`** — the table carries no filter on it at all, which is what
|
|
301
|
+
keeps the `state` relation read-only. If the endpoint does filter one, a support queue becomes
|
|
302
|
+
filterable by state;
|
|
303
|
+
3. **`contact_ids` on `/tickets/search`** — the contact relation of a ticket rests on it;
|
|
304
|
+
4. **the operators Intercom answers on `custom_attributes.{name}`**, per data type, which is the only
|
|
305
|
+
thing keeping those columns display-only;
|
|
306
|
+
5. **whether `POST /conversations/search` honours `display_as=plaintext`** — it is sent either way,
|
|
307
|
+
and an ignored parameter costs a query string where the honoured one saves every filtered row
|
|
308
|
+
from coming back as markup.
|
|
309
|
+
|
|
310
|
+
To measure a workspace of your own. The probe ships with the gem — it is what measures the
|
|
311
|
+
customer's workspace, and whoever runs it there has the gem installed rather than a clone of
|
|
312
|
+
`agent-ruby` — so `bundle install` puts it on the path of the application the datasource is
|
|
313
|
+
mounted in:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
INTERCOM_ACCESS_TOKEN=... bundle exec forest_admin_intercom_probe --endpoint tickets --out measured.yml
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
It sends one search per (field, operator) cell, reads Intercom's refusal codes — `invalid_field` for
|
|
320
|
+
a field the endpoint does not filter, `data_invalid` for an operator it refuses on that field — and
|
|
321
|
+
prints what the committed table promises that Intercom refuses, plus what Intercom accepts that the
|
|
322
|
+
table does not know about. It writes evidence rather than rewriting the table, which carries the
|
|
323
|
+
prose a generated file would drop.
|
|
324
|
+
|
|
325
|
+
### A date filter is day-granular, and the day is the UTC one
|
|
326
|
+
|
|
327
|
+
Intercom truncates a date search to the day, at the **UTC** boundary — measured, and against its own
|
|
328
|
+
documentation, which promises the workspace's timezone. `> V` answers from the start of the day
|
|
329
|
+
*after* V; `< V` answers before the start of V's own day.
|
|
330
|
+
|
|
331
|
+
Sent as they come, the two bounds an interval is rewritten into cancel each other out: `today`
|
|
332
|
+
reaches the datasource as `> 00:00` of one day and `< 00:00` of the next, which Intercom reads as
|
|
333
|
+
"from tomorrow" *and* "before tomorrow" — no rows at all, to the most ordinary filter there is. So
|
|
334
|
+
each bound is moved to the boundary that makes Intercom answer the day the filter named.
|
|
335
|
+
|
|
336
|
+
What follows from that:
|
|
337
|
+
|
|
338
|
+
- a bound naming **a time inside a day is refused**, naming the column and the instant: answered for
|
|
339
|
+
the whole day, it would return rows outside the window, on a page that looks filtered. That rules
|
|
340
|
+
out `future`, `after_x_hours_ago`, `before_x_hours_ago`, and `before` / `after` given a time of day;
|
|
341
|
+
- one exception, the upper bound **at the present** that `past` and the `*_to_date` family end on:
|
|
342
|
+
widened to midnight, it only adds rows dated after now, and every filterable date but
|
|
343
|
+
`snoozed_until` holds past instants. On `snoozed_until` — a snooze can end later today — it is
|
|
344
|
+
refused like the others;
|
|
345
|
+
- a caller in UTC gets exactly the day they asked for;
|
|
346
|
+
- a caller in another timezone gets the UTC days their window overlaps — up to a day wider at each
|
|
347
|
+
end — and the agent logs that once per filter;
|
|
348
|
+
- a date column publishes `>` and `<` only, and no equality. Everything an operator actually uses —
|
|
349
|
+
`before`, `after`, `today`, `yesterday`, `past`, the whole `previous_*` family — is rewritten by
|
|
350
|
+
the agent into a pair of those bounds. An equality on an instant is what stays out, and a
|
|
351
|
+
day-granular filter could not have honoured it anyway.
|
|
352
|
+
|
|
353
|
+
### What is filterable
|
|
354
|
+
|
|
355
|
+
| Collection | Filterable on |
|
|
356
|
+
| --- | --- |
|
|
357
|
+
| `IntercomConversation` | `id`, `state`, `priority`, `open`, `read`, `title`, `admin_assignee_id`, `team_assignee_id`, `source_type`, `source_subject`, `source_body`, `source_delivered_as`, `source_author_email`, `closed_by_id`, `reopen_count`, `part_count`, `ai_agent_participated`, `contact_id`, and the dates `created_at`, `updated_at`, `waiting_since`, `snoozed_until`, `closed_at`, `first_closed_at`, `first_contact_reply_at`, `last_contact_reply_at`, `last_admin_reply_at` |
|
|
358
|
+
| `IntercomTicket` | `id`, `open`, `category`, `ticket_type_id`, `admin_assignee_id`, `team_assignee_id`, `contact_id`, `created_at`, `updated_at` |
|
|
359
|
+
| `IntercomContact` | `id`, `role`, `name`, `email`, `email_domain`, `phone`, `external_id`, `owner_id`, `unsubscribed_from_emails`, `has_hard_bounced`, `marked_email_as_spam`, `language_override`, `browser`, `browser_language`, `os`, `location_country`, `location_region`, `location_city`, and the dates `created_at`, `updated_at`, `signed_up_at`, `last_seen_at`, `last_contacted_at`, `last_replied_at`, `last_email_opened_at`, `last_email_clicked_at` |
|
|
360
|
+
| `IntercomCompany` | `id`, `company_id`, `name` — four lookups and no search endpoint, see [Companies](#companies) |
|
|
361
|
+
|
|
362
|
+
**The primary key** is filterable like any other column, but a filter naming it *alone* is not
|
|
363
|
+
answered by a search: `id equals X` and `id in [...]` read the record endpoint directly, one request
|
|
364
|
+
per record. The search answers it only when something else is filtered alongside it — a permission
|
|
365
|
+
scope, a segment, or a second filter.
|
|
366
|
+
|
|
367
|
+
**A ticket's `category`** is filtered on the label the ticket shows — `Customer`, `Back-office`,
|
|
368
|
+
`Tracker` — and sent as the word `/tickets/search` takes for it: `request`, `task`, `tracker`. Any
|
|
369
|
+
other value is refused rather than sent, since Intercom would compare it against a vocabulary the
|
|
370
|
+
column never shows.
|
|
371
|
+
|
|
372
|
+
**Free-text search** is answered on two collections, each on the one column its endpoint matches text
|
|
373
|
+
on:
|
|
374
|
+
|
|
375
|
+
| Collection | Searched on |
|
|
376
|
+
| --- | --- |
|
|
377
|
+
| `IntercomConversation` | `~` on `source.body` — the message that opened the conversation |
|
|
378
|
+
| `IntercomContact` | `~` on `email` — what an ops team types when they are looking for someone |
|
|
379
|
+
|
|
380
|
+
Intercom matches `~` **per word, not as a substring**: searching `fact` does not find `facture`, and
|
|
381
|
+
searching `acme` does not find `camille@acme.test`. `IntercomTicket` exposes no text column its
|
|
382
|
+
endpoint matches and refuses a search by name; `IntercomCompany` has no search endpoint at all and
|
|
383
|
+
refuses one the same way. Both still show a search bar, so a search typed there — on the list, on a
|
|
384
|
+
related list, or in Forest's search across collections — comes back as that refusal rather than as an
|
|
385
|
+
empty page. `collection.disable_search` in the agent's customization removes the bar.
|
|
386
|
+
|
|
387
|
+
### What is not filterable, and why
|
|
388
|
+
|
|
389
|
+
- **every column of a company but two.** There is no `/companies/search`: Intercom looks a company
|
|
390
|
+
up by `name`, by `company_id`, by `tag_id` or by `segment_id`, one exact value at a time, and the
|
|
391
|
+
first two are the ones that name a column of the collection. Everything else — the industry, the
|
|
392
|
+
plan, the monthly spend — is refused by name. Filtering by tag or by segment belongs with the lot
|
|
393
|
+
that adds those collections;
|
|
394
|
+
- **a contact's `company_count`, `avatar` and `session_count`**, which the endpoint does not filter,
|
|
395
|
+
and **its `company_id` beyond a bare `equals`** — the search filters no company field, so
|
|
396
|
+
`company_id equals X` is answered by `GET /companies/{id}/contacts`, which is also what serves the
|
|
397
|
+
account's `contacts` relation, in one request — **and answers alone**. That
|
|
398
|
+
endpoint returns the contacts of the account whole and narrows nothing, so a `company_id equals X`
|
|
399
|
+
carrying anything else cannot be answered at all: there is no request that takes both halves.
|
|
400
|
+
Which means the related list of an account **is refused as soon as a permission scope or a segment
|
|
401
|
+
is defined on `IntercomContact`**, since that is what the agent intersects into the condition. The
|
|
402
|
+
refusal names the condition it could not carry alongside the account. Resolving it properly would
|
|
403
|
+
mean reading the account's contact ids first and handing `id IN [...]` plus the rest of the tree to
|
|
404
|
+
`/contacts/search` — which that endpoint does answer, and which is not in this lot;
|
|
405
|
+
- **a set of ids larger than a bulk read fetches**, listed or counted. `id in [...]` reads one
|
|
406
|
+
record per id, 25 at most (300 contacts, read 15 per search), and past that both the page and the
|
|
407
|
+
count are refused rather than answered with what the truncation left: 25 rows, or "25", where the
|
|
408
|
+
question named forty records looks like the whole answer. A list view never reaches it, since the
|
|
409
|
+
ids are cut to the page first; an export or an API caller naming more ids at once does;
|
|
410
|
+
- **a list of more than 15 values** in an `in` or a `not in`, wherever it stands in the filter.
|
|
411
|
+
Intercom refuses it with a 400 that names neither the field nor the condition;
|
|
412
|
+
- **the custom attributes of a contact or a company.** They are filtered as
|
|
413
|
+
`custom_attributes.{name}`, by name — the ambiguity that keeps ticket attributes display-only does
|
|
414
|
+
not arise here — but which operators Intercom answers on each data type has not been measured, and
|
|
415
|
+
this package publishes no filter it has not seen work. They ship typed and display-only, and the
|
|
416
|
+
probe is what turns that around;
|
|
417
|
+
- **the columns a ticket derives from its parts** — `closed_at`, `closed_by_name`, `last_reply_at`,
|
|
418
|
+
`last_responder_name`, `last_responder_type`. They exist nowhere in Intercom; `/tickets/search`
|
|
419
|
+
filters none of them and ignores a sort on them without a word;
|
|
420
|
+
- **the account of a ticket** — `company_id`, refused by the endpoint itself with `invalid_field`;
|
|
421
|
+
- **the state of a ticket** — the measured table carries no filter on a state id, so `state_id`,
|
|
422
|
+
`previous_state_id` and the `state` relation are read and navigated rather than filtered. Whether
|
|
423
|
+
the endpoint filters one at all is one of the probe's open questions;
|
|
424
|
+
- **the ticket attributes** — filtered as `ticket_attribute.{id}`, and the same attribute carries a
|
|
425
|
+
different id per ticket type, so a union column has no single id to translate to. See
|
|
426
|
+
[Tickets](#tickets);
|
|
427
|
+
- **the tag names, the company name and the contact identity of a conversation** — read from
|
|
428
|
+
somewhere the search endpoint does not filter, or filtered by an id the column does not hold;
|
|
429
|
+
- **absence** — `present`, `blank` and `missing` are derived by the agent from an equality and
|
|
430
|
+
rewritten into a comparison with an empty value. Intercom's search matches values and has no
|
|
431
|
+
operator for the lack of one, so the rewritten condition is refused rather than sent as a
|
|
432
|
+
comparison against the empty string;
|
|
433
|
+
- **group-by**, on either cursor collection: there is no aggregate endpoint, and grouping over the
|
|
434
|
+
pages a walk collected would look exact while answering a fraction.
|
|
435
|
+
|
|
436
|
+
### Through a relation
|
|
437
|
+
|
|
438
|
+
A relation is published filterable as soon as *any* column of its target is, so the interface offers
|
|
439
|
+
`admin_assignee:name` the moment the relation exists — unless the endpoint filters nothing on the
|
|
440
|
+
foreign key. `IntercomTicket#state`, `IntercomTicket#previous_state` and `IntercomContact#company` are
|
|
441
|
+
published unfilterable for that reason: they are there to be read and navigated, and the agent refuses
|
|
442
|
+
a condition through them before it reaches this datasource.
|
|
443
|
+
What Intercom is really filtered on is the foreign key: the **target says which of its records
|
|
444
|
+
match**, over every record it holds rather than over a page, and the ids it names become the
|
|
445
|
+
condition the search carries.
|
|
446
|
+
|
|
447
|
+
That is exact, and it has four visible edges:
|
|
448
|
+
|
|
449
|
+
- **The target is read one record past what a group may hold, and no further.** Against a collection
|
|
450
|
+
read whole that costs nothing — every record is in hand — but Contacts are a page of something
|
|
451
|
+
far larger, and resolving `contact:email contains "@"` over a whole workspace to then refuse the
|
|
452
|
+
fan-out it comes to would spend a full cursor walk on a filter that was never going to be
|
|
453
|
+
answered. So the read is bounded, and the refusal says "more than fifteen" rather than a count it
|
|
454
|
+
deliberately did not go and measure.
|
|
455
|
+
- Intercom takes no membership operator on these fields, so several matches become **one equality per
|
|
456
|
+
match**, inside an `OR` — which counts against the fifteen conditions a group allows. A relation
|
|
457
|
+
condition matching more records than that is refused by name rather than sent and answered with a
|
|
458
|
+
400 naming neither the limit nor the filter that hit it. That `OR` is **inlined into a parent that
|
|
459
|
+
aggregates the same way**, so it costs no level of nesting where it does not have to: the two
|
|
460
|
+
levels Intercom allows are spent on the filter that was written, not on the expansion of a
|
|
461
|
+
relation. Where inlining it would take the parent past fifteen conditions it stays nested, width
|
|
462
|
+
being the scarcer of the two.
|
|
463
|
+
- A condition the target matched **no record** with names no row, and the DSL cannot say so: the
|
|
464
|
+
search is skipped entirely rather than sent as a filter that would come back with everything.
|
|
465
|
+
- A relation whose foreign key the endpoint does not filter — the ticket `state`, a contact's
|
|
466
|
+
`company` — is refused with a message saying which of the two it is: the relation is there to be
|
|
467
|
+
read and navigated. Whether `/tickets/search` filters a state id or a contact id at all is one of
|
|
468
|
+
the probe's open questions; the answer lands in the table, not in an assumption.
|
|
469
|
+
|
|
470
|
+
On the collections read whole the same condition costs nothing: they filter in memory, so the ids go
|
|
471
|
+
in as a plain membership and none of the DSL's limits apply.
|
|
472
|
+
|
|
473
|
+
A **many-to-many is published unfilterable** — `admins` and `teams` — and a condition written on one
|
|
474
|
+
anyway, in a scope or a segment, is refused before it reaches this datasource: the agent's own
|
|
475
|
+
validator answers a 400 naming the field and its type. Filter on a column of the collection next
|
|
476
|
+
door instead.
|
|
477
|
+
|
|
478
|
+
### The limits of a search, checked before the request leaves
|
|
479
|
+
|
|
480
|
+
Intercom nests a search **two levels** deep and takes **fifteen conditions per group**. Past either
|
|
481
|
+
it answers a 400 whose body names neither the limit nor the part of the filter that reached it, so
|
|
482
|
+
both are checked here and refused with a message naming what to simplify.
|
|
483
|
+
|
|
484
|
+
Fifteen is reached without trying: a scope, a segment and an operator's own filter add up, and a
|
|
485
|
+
condition naming several values arrives expanded into **one condition per value** — Intercom accepts
|
|
486
|
+
no membership operator on these fields. Branches carrying a single condition are unwrapped and spend
|
|
487
|
+
no level.
|
|
488
|
+
|
|
489
|
+
## Conversations
|
|
490
|
+
|
|
491
|
+
The row carries what a queue is read for: state, priority, assignee and team ids, the company, the
|
|
492
|
+
tags, and the lifecycle Intercom keeps in `statistics` — `closed_at`, `closed_by_id`,
|
|
493
|
+
`first_contact_reply_at`, `last_contact_reply_at`, `last_admin_reply_at`, `reopen_count`.
|
|
494
|
+
|
|
495
|
+
**The timeline opens on `source`, not on the parts.** The message that started the conversation
|
|
496
|
+
lives there; a thread built from the parts alone opens on the first reply and loses what the
|
|
497
|
+
customer actually asked. Every entry keeps its `part_type` — an assignment, a note and a reply are
|
|
498
|
+
different events.
|
|
499
|
+
|
|
500
|
+
Intercom returns the parts **only when retrieving a single conversation**, so:
|
|
501
|
+
|
|
502
|
+
- a record detail gets its timeline for free;
|
|
503
|
+
- a list view asking for the `timeline` column pays one request per row, bounded to 10. The rows past
|
|
504
|
+
that keep a `nil`, which reads as *unknown* — never as an empty thread.
|
|
505
|
+
|
|
506
|
+
A conversation is capped at its **500 most recent parts**; a very long thread is therefore partial,
|
|
507
|
+
and says so nowhere but here.
|
|
508
|
+
|
|
509
|
+
**The internal notes of the team are in the thread**, next to what the customer was told. That is
|
|
510
|
+
what a thread is on Intercom, and publishing half of it would be the more surprising answer — but it
|
|
511
|
+
is worth knowing before opening the collection to a role that should not read them.
|
|
512
|
+
|
|
513
|
+
The contact's name is denormalized onto the row by **one bulk read per page**, not one per row, and
|
|
514
|
+
only when the projection names it. A failure there costs that column, not the page. The e-mail is a
|
|
515
|
+
hop away, on the `contact` relation.
|
|
516
|
+
|
|
517
|
+
## Tickets
|
|
518
|
+
|
|
519
|
+
A ticket carries **no `statistics` block** — measured against a workspace of 81 142 tickets — so
|
|
520
|
+
neither a closure date nor a last responder exists as a field. Both are derived from the parts,
|
|
521
|
+
which ride along in the search response whether or not anything asks for them, and therefore cost
|
|
522
|
+
nothing:
|
|
523
|
+
|
|
524
|
+
| Column | Derived from |
|
|
525
|
+
| --- | --- |
|
|
526
|
+
| `closed_at`, `closed_by_name` | the last transition into a state of category `resolved` |
|
|
527
|
+
| `last_reply_at`, `last_responder_name`, `last_responder_type` | the last `comment` part |
|
|
528
|
+
|
|
529
|
+
Four things to know about them:
|
|
530
|
+
|
|
531
|
+
- a ticket is not "closed" on Intercom, it enters a **resolved** state;
|
|
532
|
+
- the state-change event is matched on its **prefix**, not on `ticket_state_updated_by_admin`: a
|
|
533
|
+
workspace running workflows closes tickets through other variants, and an invisible closure is
|
|
534
|
+
worse than an absent column;
|
|
535
|
+
- a transition whose target equals the previous state is ignored — measured, they exist;
|
|
536
|
+
- **a resolved ticket showing no closure date may have been closed all the same**: past the 500-part
|
|
537
|
+
ceiling the transition falls out of the window. That case is detected and logged, since a Date
|
|
538
|
+
column cannot say "unknown".
|
|
539
|
+
|
|
540
|
+
Both are **display only**, and not temporarily: `/tickets/search` filters on neither and ignores a
|
|
541
|
+
sort, so neither advertises an operator.
|
|
542
|
+
|
|
543
|
+
**The thread is published too, and it is free here.** The same `timeline` column a conversation
|
|
544
|
+
carries — who said what, when, and through which kind of event, internal notes and state changes
|
|
545
|
+
included — built from the parts the response already holds. No request per row and no cap: a
|
|
546
|
+
ticket carries its parts, so an empty list means an empty thread, and a payload that carries none
|
|
547
|
+
leaves the column `nil` for *unknown*, as a conversation read from a listing does. Both reads ask
|
|
548
|
+
Intercom for `display_as=plaintext`: the bodies are HTML written by end customers, and rendering
|
|
549
|
+
third-party markup inside Forest is neither safe nor useful. The 500-part ceiling applies here as
|
|
550
|
+
well, which is the same truncation that can hide a closure date, and a page whose tickets reach it
|
|
551
|
+
says so in the log.
|
|
552
|
+
|
|
553
|
+
The attributes a workspace declares on its ticket types are introspected once at boot and published
|
|
554
|
+
as the **union** of every type's, keyed by name the way the payload is. Filtering one is a different
|
|
555
|
+
matter: Intercom filters an attribute by id (`ticket_attribute.{id}`), and the same name carries a
|
|
556
|
+
different id from one ticket type to the next — measured, `_default_title_` is `14162161` on one
|
|
557
|
+
type and `14162165` on another. A union column has no single id to translate to, so filtering on a
|
|
558
|
+
ticket attribute means one collection per ticket type — more collections in the interface, and a
|
|
559
|
+
schema that changes shape whenever the customer adds a type. Until that trade is worth paying for,
|
|
560
|
+
the attributes stay display-only and advertise no operator. The ids are kept per type so the day the
|
|
561
|
+
answer changes costs no second boot round trip.
|
|
562
|
+
|
|
563
|
+
## Contacts
|
|
564
|
+
|
|
565
|
+
The people who write in, users and leads alike. Cursor-paginated like conversations and tickets,
|
|
566
|
+
with two routes of its own and one thing no other collection has.
|
|
567
|
+
|
|
568
|
+
**Intercom sorts this one.** `POST /contacts/search` is the only endpoint of the whole API that
|
|
569
|
+
takes a `sort` and applies it, so these are the only sortable columns of the datasource: `name`,
|
|
570
|
+
`email`, `created_at`, `updated_at`, `signed_up_at`, `last_seen_at`, `last_contacted_at`,
|
|
571
|
+
`last_replied_at`. The set is deliberately narrower than the documentation implies — nothing has
|
|
572
|
+
been measured, and a sort Intercom refuses is a list view that fails rather than one that comes back
|
|
573
|
+
unordered. A sort on any other column, or on two columns at once, is reported in the log and the
|
|
574
|
+
rows come back in the API's order: Intercom takes a single `{ field, order }`, and honouring the
|
|
575
|
+
first clause of two would order the page by something nobody asked for.
|
|
576
|
+
|
|
577
|
+
An order is also what routes a plain list view through the search endpoint, the listing sorting
|
|
578
|
+
nothing: the read then carries the predicate matching everything that Tickets already send.
|
|
579
|
+
|
|
580
|
+
**Its date operators are narrower than the other two endpoints'** — measured, 25 August 2026:
|
|
581
|
+
`/contacts/search` refuses `>=`, `<=` and `!=` on a date where `/conversations/search` and
|
|
582
|
+
`/tickets/search` take them. Nothing is lost that an operator can see, a Date column publishing the
|
|
583
|
+
two bounds alone everywhere in this datasource, but it is why the operator table is per endpoint.
|
|
584
|
+
|
|
585
|
+
**A set of ids is read in one request** — `id IN [...]`, which this endpoint answers and no other
|
|
586
|
+
does — fifteen at a time, the most one `IN` takes, rather than one request per record. It is what
|
|
587
|
+
makes a related list of contacts affordable.
|
|
588
|
+
|
|
589
|
+
**`company_id equals X` reads `GET /companies/{id}/contacts`.** The search filters no company field,
|
|
590
|
+
so without that route the contacts of an account would be a refusal rather than a list. It is a bare
|
|
591
|
+
equality only: an `and` also carrying a permission scope names a narrower set than the account does,
|
|
592
|
+
and answering it with the account alone would serve contacts the scope excludes.
|
|
593
|
+
|
|
594
|
+
That is a limit worth knowing before scoping permissions, because it is not a slower route but no
|
|
595
|
+
route at all: the account endpoint returns its contacts whole and narrows nothing, and the search
|
|
596
|
+
filters no company field, so **a scope or a segment on this collection turns the related list of an
|
|
597
|
+
account into a refusal**. The message names the condition it could not carry alongside the account,
|
|
598
|
+
rather than telling the operator to open the account and read its contacts — which is what they were
|
|
599
|
+
doing. What would answer it is a read of the account's contact ids followed by `id IN [...]` plus the
|
|
600
|
+
rest of the tree on `/contacts/search`, which that endpoint takes; it is not in this lot.
|
|
601
|
+
|
|
602
|
+
One more thing the two routes do not agree on, and it is visible: the `company_id` column names **the
|
|
603
|
+
first** of the accounts a contact belongs to, and the `company` relation resolves that same first
|
|
604
|
+
one — while `company_id equals X` returns **every** contact of X. So an account's related list can
|
|
605
|
+
show a contact whose `company` points somewhere else. The column is the payload's reading; the filter
|
|
606
|
+
is the account endpoint's, and it is the more useful of the two.
|
|
607
|
+
|
|
608
|
+
**A merged contact reads as gone, not as an error.** Intercom drops it from the listing and from the
|
|
609
|
+
search, and the record lives on under the id it was merged into. A row pointing at the old id comes
|
|
610
|
+
back empty rather than failing the page.
|
|
611
|
+
|
|
612
|
+
The custom attributes a workspace declares on its contacts are introspected once at boot from
|
|
613
|
+
`GET /data_attributes?model=contact`, typed from `data_type`, and published display-only.
|
|
614
|
+
|
|
615
|
+
## Companies
|
|
616
|
+
|
|
617
|
+
The accounts contacts belong to, and the collection that behaves least like the others.
|
|
618
|
+
|
|
619
|
+
**Paginated by offset**, which is the tier above. **Looked up, not searched**: `name` and
|
|
620
|
+
`company_id` — the identifier the customer's own system gave the account, not Intercom's — are the
|
|
621
|
+
two filters it publishes, each an exact equality, and anything else is refused by name. A record is
|
|
622
|
+
read through `GET /companies/{id}`, and a set of ids one request each, 25 at most: past that the
|
|
623
|
+
read is refused rather than truncated.
|
|
624
|
+
|
|
625
|
+
`GET /companies/scroll` exists and is **deliberately rejected**: one open scroll per application,
|
|
626
|
+
expiring after a minute, cannot serve two operators looking at a list at the same time.
|
|
627
|
+
|
|
628
|
+
A contact carries its accounts as a list of ids and nothing else, so **projecting `company:name` on
|
|
629
|
+
a contact list costs one request per distinct account on the page**. Reading the account from the
|
|
630
|
+
contact's record page, or listing contacts from the account, both cost one.
|
|
631
|
+
|
|
632
|
+
Which is why that projection is the one relation of the datasource with a ceiling: past
|
|
633
|
+
[`MAX_RELATION_READS`](lib/forest_admin_datasource_intercom/collections/offset_collection.rb)
|
|
634
|
+
distinct accounts on a single read, it is **refused rather than resolved for part of the rows**. Every
|
|
635
|
+
page size a list view offers stays under it. A read that does not — an export, which batches a
|
|
636
|
+
thousand rows at a time, or a segment resolved whole — has to leave the column out. See
|
|
637
|
+
[Relations](#relations).
|
|
638
|
+
|
|
639
|
+
Custom attributes are introspected at boot the same way, from `GET /data_attributes?model=company`,
|
|
640
|
+
and published display-only for the same reason.
|
|
641
|
+
|
|
642
|
+
## What a page costs, and what is held
|
|
643
|
+
|
|
644
|
+
A list view is a handful of requests rather than one: Intercom joins nothing, so every relation on
|
|
645
|
+
the page is a read of its own, and they are issued one after another. Two things keep that from
|
|
646
|
+
being paid twice.
|
|
647
|
+
|
|
648
|
+
**The workspace's reference lists are held for `reference_cache_ttl`** — the teammates, the teams,
|
|
649
|
+
the ticket types and the ticket states, the tier read whole. They are what four of a ticket's five
|
|
650
|
+
relations resolve through, and they change a few times a year; without a window, every page, every
|
|
651
|
+
count and every filter traversing one of them re-reads all four. What the window trades is bounded
|
|
652
|
+
staleness: a teammate added mid-session appears within the minute rather than on the next request.
|
|
653
|
+
Set `reference_cache_ttl: 0` to pay the requests instead, or drop what is held without waiting the
|
|
654
|
+
window out — a customizer that has just written to the workspace can call
|
|
655
|
+
`datasource.configuration.reference_cache.clear`.
|
|
656
|
+
|
|
657
|
+
**Records are never held.** What the search and listing endpoints answer is the customer's data, and
|
|
658
|
+
serving a page of it from a store would show an operator a row they have just edited in its previous
|
|
659
|
+
state. The one deduplication that applies to records is scoped to a single page being built: a
|
|
660
|
+
ticket list asks for the same contacts twice — once for the `contact_name` column, once for the
|
|
661
|
+
`contact` relation — and the second read is answered by the first, for the length of that call and
|
|
662
|
+
no longer.
|
|
663
|
+
|
|
664
|
+
**Connections are reused.** Several requests to one host means several TLS handshakes on Faraday's
|
|
665
|
+
default adapter, and they cost more than some of the requests they carry, so the client builds on
|
|
666
|
+
the persistent adapter. An adapter the process cannot load, named in `adapter` or the persistent
|
|
667
|
+
one itself, is refused when the agent starts.
|
|
668
|
+
|
|
669
|
+
The pool holds 25 connections, and that figure is read against **the web server's thread count**
|
|
670
|
+
rather than against this datasource. A thread that finds the pool full waits half a second for a
|
|
671
|
+
connection and then fails — and it fails as a Faraday timeout, which reads as Intercom being slow
|
|
672
|
+
rather than as the pool being narrow. The searches a list view is built on travel over POST, which
|
|
673
|
+
the retry policy does not replay, so that failure costs an operator their page. Size the pool above
|
|
674
|
+
the threads that can reach it if the server runs wide:
|
|
675
|
+
|
|
676
|
+
```ruby
|
|
677
|
+
ForestAdminDatasourceIntercom::Datasource.new(
|
|
678
|
+
access_token: ENV.fetch('INTERCOM_ACCESS_TOKEN'),
|
|
679
|
+
adapter: [:net_http_persistent, { pool_size: 50 }]
|
|
680
|
+
)
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
Measured on a page of 15 tickets projecting five relations
|
|
684
|
+
([`ticket_list_budget_spec.rb`](spec/forest_admin_datasource_intercom/collections/ticket_list_budget_spec.rb)):
|
|
685
|
+
|
|
686
|
+
| | Requests |
|
|
687
|
+
| --- | --- |
|
|
688
|
+
| Before | 7 |
|
|
689
|
+
| First page after boot | 5 |
|
|
690
|
+
| Every page after it, within the window | 2 |
|
|
691
|
+
|
|
692
|
+
What none of this touches is the search response itself. Intercom offers no field selection, so a
|
|
693
|
+
page of tickets carries every ticket's whole timeline whatever the projection says — which is why
|
|
694
|
+
[`MAX_TICKETS_PER_PAGE`](lib/forest_admin_datasource_intercom/collections/ticket.rb) is 25, and why
|
|
695
|
+
asking for 50 or 100 rows a page, or for page 5, costs that response two, four or three times over.
|
|
696
|
+
|
|
697
|
+
### Reading records by id in parallel
|
|
698
|
+
|
|
699
|
+
Intercom has no bulk read for a company, a conversation or a ticket, so some reads cost one request
|
|
700
|
+
per record: a related list or a page resolving a `company` relation (up to 100 companies), a record
|
|
701
|
+
read by id, and the timelines of a conversation list (up to 10 conversations). By default those
|
|
702
|
+
requests go **one after another** — at about 150 ms each, a page resolving 100 companies waits around
|
|
703
|
+
15 seconds.
|
|
704
|
+
|
|
705
|
+
`concurrent_reads` sends that many of them at once. At 4, the same page waits around 4 seconds. The
|
|
706
|
+
order of the rows, the caps and the refusals are unchanged, and so is the rate limiting: every
|
|
707
|
+
request still goes through the limiter.
|
|
708
|
+
|
|
709
|
+
It is 1 by default because **every read in flight holds a connection of the pool** for as long as it
|
|
710
|
+
waits. A request using four of them leaves four fewer for the other threads, and a thread that finds
|
|
711
|
+
the pool full fails as a timeout (see above). Size the pool against both figures together — at
|
|
712
|
+
least the server's thread count times `concurrent_reads`:
|
|
713
|
+
|
|
714
|
+
```ruby
|
|
715
|
+
# Puma with 5 threads, 4 reads at once: 20 connections at the busiest.
|
|
716
|
+
ForestAdminDatasourceIntercom::Datasource.new(
|
|
717
|
+
access_token: ENV.fetch('INTERCOM_ACCESS_TOKEN'),
|
|
718
|
+
concurrent_reads: 4,
|
|
719
|
+
adapter: [:net_http_persistent, { pool_size: 25 }]
|
|
720
|
+
)
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
`concurrent_reads: 1` is also the way back to the sequential behaviour if a wider setting misbehaves.
|
|
724
|
+
|
|
725
|
+
## Rate limits
|
|
726
|
+
|
|
727
|
+
Intercom meters the app and, above it, the whole workspace — 25 000 requests a minute shared with
|
|
728
|
+
every other private app the customer runs — and allocates that budget in **10-second windows**: the
|
|
729
|
+
measured `x-ratelimit-limit` is 1667, not 10 000. A burst therefore takes a 429 while the minute's
|
|
730
|
+
budget is barely touched, which is why what matters is the instantaneous rate.
|
|
731
|
+
|
|
732
|
+
The limiter is driven by the headers Intercom returns on every response rather than by a table: it
|
|
733
|
+
waits out the reset when the window is spent, and counts its own in-flight requests down so several
|
|
734
|
+
of them do not go out on the same stale figure. A reset further out than a window is a clock
|
|
735
|
+
disagreement rather than a window emptying — the request goes through and the log says so, once per
|
|
736
|
+
window.
|
|
737
|
+
|
|
738
|
+
It sits **in front of** the 429 retry, not instead of it: the retry remains the defence against the
|
|
739
|
+
part of the workspace budget spent by traffic this process cannot see. Pass `rate_limiter: nil` to
|
|
740
|
+
meter on your own side instead.
|
|
741
|
+
|
|
742
|
+
## Privacy
|
|
743
|
+
|
|
744
|
+
The body of a conversation is raw personal data, and this datasource is built on that assumption.
|
|
745
|
+
|
|
746
|
+
- **Nothing logs a body.** Logs carry the operation, the counts and Intercom's request id — never
|
|
747
|
+
content. A response that fails to parse is reported by name, never quoted: a JSON parser opens its
|
|
748
|
+
message with the characters it choked on, and on a 200 those are the payload.
|
|
749
|
+
- **`display_as=plaintext` on every conversation read.** The bodies are HTML written by end
|
|
750
|
+
customers; rendering third-party HTML inside Forest is neither safe nor useful.
|
|
751
|
+
- **The regional host is configurable** so a workspace's data stays in its region.
|
|
752
|
+
- Ticket list pages carry customer message bodies whether or not anything asks for them — Intercom
|
|
753
|
+
offers no field selection. Restrict the body columns with Forest's field-level permissions where
|
|
754
|
+
that matters.
|
|
755
|
+
|
|
756
|
+
## Boot-time introspection
|
|
757
|
+
|
|
758
|
+
Constructing the datasource performs exactly **four** reads.
|
|
759
|
+
|
|
760
|
+
Three are of one kind: `GET /ticket_types` for the attribute columns of `IntercomTicket`, and
|
|
761
|
+
`GET /data_attributes?model=contact` and `?model=company` for those of `IntercomContact` and
|
|
762
|
+
`IntercomCompany`. A payload carries the values of the attributes that record happens to have been
|
|
763
|
+
given, never their definitions, which is why they cannot be discovered from the records.
|
|
764
|
+
|
|
765
|
+
The fourth is `GET /me`, and it reads no column: Intercom echoes in a response header the API
|
|
766
|
+
version it served, and it serves the workspace's own default when the pin is not honoured — whose
|
|
767
|
+
payloads are shaped differently from the ones this expects. That echo is the only place the
|
|
768
|
+
substitution shows, so it is checked while the agent starts and reported as a warning.
|
|
769
|
+
|
|
770
|
+
All four run on the boot connection — short timeouts, one quick retry — so a slow Intercom cannot
|
|
771
|
+
turn a Rails boot into minutes the operator sits through. A token missing a permission costs the
|
|
772
|
+
columns it could not read, or the version check, never the agent: a 403 degrades to a warning.
|
|
773
|
+
Any other failure of an attribute read — a timeout, a 5xx, a 429 past the retry, a revoked token —
|
|
774
|
+
fails the boot instead: degraded, that pod would publish a schema missing columns its siblings
|
|
775
|
+
have, and keep it until it restarts.
|
|
776
|
+
|
|
777
|
+
`api_writable` is read alongside each attribute and kept, although every column of this lot is
|
|
778
|
+
published read-only: it is what tells an attribute the API may write from one Intercom fills in
|
|
779
|
+
itself, and reading it again later would be a second boot-time round trip.
|
|
780
|
+
|
|
781
|
+
Everything else is read when a collection is listed, so no other endpoint can hold up or fail the boot.
|
|
782
|
+
|
|
783
|
+
## What is not here yet
|
|
784
|
+
|
|
785
|
+
| Lot | What it brings |
|
|
786
|
+
| --- | --- |
|
|
787
|
+
| 3 | Writes on tickets and conversations: reply, close, snooze, reopen, assign, tag, convert. Actions ship as plugins (`collection.use`), as in the Pylon datasource, never declared by a collection |
|
|
788
|
+
| 4b | Writes on contacts and companies: create, update, archive, block, merge, attach and detach |
|
|
789
|
+
| 5 | Notes, tags, segments |
|
|
790
|
+
| 6 | Bounded group-by and the reporting export |
|
|
791
|
+
|
|
792
|
+
Two questions this lot leaves in the table rather than in an assumption, both for
|
|
793
|
+
`forest_admin_intercom_probe` to answer against the customer's workspace: whether `/tickets/search`
|
|
794
|
+
filters on `contact_ids`, and which operators `/contacts/search` answers on a custom attribute.
|
|
795
|
+
|
|
796
|
+
## Development
|
|
797
|
+
|
|
798
|
+
```bash
|
|
799
|
+
cd packages/forest_admin_datasource_intercom
|
|
800
|
+
BUNDLE_GEMFILE=Gemfile-test bundle install
|
|
801
|
+
BUNDLE_GEMFILE=Gemfile-test bundle exec rspec
|
|
802
|
+
bundle exec rubocop # from the repository root
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`exe/forest_admin_intercom_probe` ships with the gem rather than living in the repository alone:
|
|
806
|
+
what it measures is the customer's workspace, and whoever runs it there has the gem installed and
|
|
807
|
+
not a clone of this repository. From a checkout it runs in place, `exe/forest_admin_intercom_probe`.
|
|
808
|
+
|
|
809
|
+
Specs stub the HTTP layer with WebMock. Every payload they feed in is **hand-written from the
|
|
810
|
+
OpenAPI 2.16 specification**, never captured from a workspace: a conversation body is personal data,
|
|
811
|
+
and a fixture is read by everyone who clones the repository.
|