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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/LICENSE +202 -0
  4. data/README.md +811 -0
  5. data/Rakefile +6 -0
  6. data/exe/forest_admin_intercom_probe +287 -0
  7. data/forest_admin_datasource_intercom.gemspec +38 -0
  8. data/lib/forest_admin_datasource_intercom/cache.rb +84 -0
  9. data/lib/forest_admin_datasource_intercom/client.rb +546 -0
  10. data/lib/forest_admin_datasource_intercom/collections/admin.rb +83 -0
  11. data/lib/forest_admin_datasource_intercom/collections/base_collection.rb +161 -0
  12. data/lib/forest_admin_datasource_intercom/collections/company/serializer.rb +44 -0
  13. data/lib/forest_admin_datasource_intercom/collections/company.rb +69 -0
  14. data/lib/forest_admin_datasource_intercom/collections/contact/serializer.rb +106 -0
  15. data/lib/forest_admin_datasource_intercom/collections/contact.rb +266 -0
  16. data/lib/forest_admin_datasource_intercom/collections/contact_identity.rb +77 -0
  17. data/lib/forest_admin_datasource_intercom/collections/conversation/serializer.rb +75 -0
  18. data/lib/forest_admin_datasource_intercom/collections/conversation/timeline.rb +40 -0
  19. data/lib/forest_admin_datasource_intercom/collections/conversation.rb +153 -0
  20. data/lib/forest_admin_datasource_intercom/collections/cursor_collection.rb +363 -0
  21. data/lib/forest_admin_datasource_intercom/collections/custom_attributes.rb +55 -0
  22. data/lib/forest_admin_datasource_intercom/collections/fetch_all_collection.rb +196 -0
  23. data/lib/forest_admin_datasource_intercom/collections/offset_collection.rb +311 -0
  24. data/lib/forest_admin_datasource_intercom/collections/records_by_id.rb +97 -0
  25. data/lib/forest_admin_datasource_intercom/collections/relations.rb +294 -0
  26. data/lib/forest_admin_datasource_intercom/collections/team.rb +70 -0
  27. data/lib/forest_admin_datasource_intercom/collections/team_membership.rb +51 -0
  28. data/lib/forest_admin_datasource_intercom/collections/ticket/derived_columns.rb +130 -0
  29. data/lib/forest_admin_datasource_intercom/collections/ticket/serializer.rb +70 -0
  30. data/lib/forest_admin_datasource_intercom/collections/ticket.rb +143 -0
  31. data/lib/forest_admin_datasource_intercom/collections/ticket_state.rb +40 -0
  32. data/lib/forest_admin_datasource_intercom/collections/ticket_type.rb +46 -0
  33. data/lib/forest_admin_datasource_intercom/collections/timeline.rb +40 -0
  34. data/lib/forest_admin_datasource_intercom/configuration.rb +226 -0
  35. data/lib/forest_admin_datasource_intercom/datasource.rb +78 -0
  36. data/lib/forest_admin_datasource_intercom/pagination/cursor_walker.rb +123 -0
  37. data/lib/forest_admin_datasource_intercom/query/caller_zone.rb +48 -0
  38. data/lib/forest_admin_datasource_intercom/query/condition_tree_translator.rb +169 -0
  39. data/lib/forest_admin_datasource_intercom/query/day_bounds.rb +75 -0
  40. data/lib/forest_admin_datasource_intercom/query/filter_value.rb +177 -0
  41. data/lib/forest_admin_datasource_intercom/query/operator_table.rb +54 -0
  42. data/lib/forest_admin_datasource_intercom/query/search_fields.rb +225 -0
  43. data/lib/forest_admin_datasource_intercom/query/search_fields.yml +663 -0
  44. data/lib/forest_admin_datasource_intercom/rate_limiter.rb +148 -0
  45. data/lib/forest_admin_datasource_intercom/retry_policy.rb +95 -0
  46. data/lib/forest_admin_datasource_intercom/schema/attribute_naming.rb +52 -0
  47. data/lib/forest_admin_datasource_intercom/schema/data_attributes_introspector.rb +88 -0
  48. data/lib/forest_admin_datasource_intercom/schema/ticket_attributes_introspector.rb +133 -0
  49. data/lib/forest_admin_datasource_intercom/throttle.rb +19 -0
  50. data/lib/forest_admin_datasource_intercom/version.rb +3 -0
  51. data/lib/forest_admin_datasource_intercom.rb +57 -0
  52. metadata +180 -0
@@ -0,0 +1,663 @@
1
+ # The table of (endpoint x field x operator): what Intercom's search endpoints
2
+ # really filter, and with which operators. It is the single source of truth of
3
+ # this datasource's filtering -- the schema derives every column's
4
+ # filter_operators from it, so no collection can advertise a filter the
5
+ # translator would then refuse.
6
+ #
7
+ # `source` is the provenance of a row, and it is not decoration:
8
+ #
9
+ # measured -- observed against a real workspace, by `forest_admin_intercom_probe`
10
+ # or during a spike, and recorded here with the date;
11
+ # spec -- read off Intercom's documentation and nothing else.
12
+ #
13
+ # The distinction exists because the two disagree. Measured during lot 1:
14
+ # `company_id` is documented on a ticket and refused by `/tickets/search` with
15
+ # `invalid_field`. A `spec` row is therefore a candidate, never a promise, and
16
+ # the probe is what turns it into one. Run it against the customer's workspace
17
+ # and merge the evidence it writes into this table by hand.
18
+ #
19
+ # `type` decides how a column's Forest operators are derived from the Intercom
20
+ # ones, and how a value reaches the wire:
21
+ #
22
+ # string -- an exact-match field
23
+ # text -- a field Intercom also matches per word (`~`)
24
+ # date -- epoch seconds on the wire, truncated to the UTC day on read
25
+ # boolean, number
26
+ version: 1
27
+
28
+ endpoints:
29
+ conversations:
30
+ path: conversations/search
31
+ # Nothing has been probed yet: fill this in with the probe's output.
32
+ measured_at: null
33
+ fields:
34
+ # The primary key, and the one column whose Forest operators are not
35
+ # derived from this table: every collection publishes `equal` and `in` on
36
+ # its key whatever is written here, the toolkit refusing a collection
37
+ # whose key carries neither. It has to be declared all the same, or the
38
+ # schema advertises a filter the translator has no row to write -- which
39
+ # is what a record detail becomes the moment a permission scope turns
40
+ # `id equals X` into an `and` the record endpoint cannot answer alone.
41
+ #
42
+ # A bare `id equals X` still reads the record endpoint, one request
43
+ # instead of a search. This row is for the compound case.
44
+ id:
45
+ field: id
46
+ type: string
47
+ operators: ['=', 'IN']
48
+ source: spec
49
+ state:
50
+ field: state
51
+ type: string
52
+ operators: ['=', '!=']
53
+ source: spec
54
+ open:
55
+ field: open
56
+ type: boolean
57
+ operators: ['=']
58
+ source: spec
59
+ read:
60
+ field: read
61
+ type: boolean
62
+ operators: ['=']
63
+ source: spec
64
+ priority:
65
+ field: priority
66
+ type: string
67
+ operators: ['=', '!=']
68
+ source: spec
69
+ title:
70
+ field: title
71
+ type: text
72
+ operators: ['=', '!=', '~', '!~', '^', '$']
73
+ source: spec
74
+ admin_assignee_id:
75
+ field: admin_assignee_id
76
+ type: string
77
+ operators: ['=', '!=']
78
+ source: spec
79
+ team_assignee_id:
80
+ field: team_assignee_id
81
+ type: string
82
+ operators: ['=', '!=']
83
+ source: spec
84
+ source_type:
85
+ field: source.type
86
+ type: string
87
+ operators: ['=', '!=']
88
+ source: spec
89
+ source_delivered_as:
90
+ field: source.delivered_as
91
+ type: string
92
+ operators: ['=', '!=']
93
+ source: spec
94
+ source_subject:
95
+ field: source.subject
96
+ type: text
97
+ operators: ['=', '!=', '~', '!~', '^', '$']
98
+ source: spec
99
+ # The full-text search of the collection: Intercom matches `~` per word,
100
+ # not as a substring, which the README states rather than the interface
101
+ # implying otherwise.
102
+ source_body:
103
+ field: source.body
104
+ type: text
105
+ operators: ['~', '!~']
106
+ source: spec
107
+ source_author_email:
108
+ field: source.author.email
109
+ type: text
110
+ operators: ['=', '!=', '~', '!~', '^', '$']
111
+ source: spec
112
+ # The date operators are measured, and they are not the same on every
113
+ # endpoint: `/contacts/search` refuses `>=`, `<=` and `!=` where this one
114
+ # accepts them (25 August 2026, API 2.16). Whatever this table allows, a
115
+ # Date column publishes the two bounds alone -- see the translator.
116
+ created_at:
117
+ field: created_at
118
+ type: date
119
+ operators: ['>', '<', '>=', '<=', '=', '!=']
120
+ source: measured
121
+ updated_at:
122
+ field: updated_at
123
+ type: date
124
+ operators: ['>', '<', '>=', '<=', '=', '!=']
125
+ source: measured
126
+ waiting_since:
127
+ field: waiting_since
128
+ type: date
129
+ operators: ['>', '<', '>=', '<=', '=', '!=']
130
+ source: spec
131
+ snoozed_until:
132
+ field: snoozed_until
133
+ type: date
134
+ operators: ['>', '<', '>=', '<=', '=', '!=']
135
+ source: spec
136
+ closed_at:
137
+ field: statistics.last_close_at
138
+ type: date
139
+ operators: ['>', '<', '>=', '<=', '=', '!=']
140
+ source: spec
141
+ first_closed_at:
142
+ field: statistics.first_close_at
143
+ type: date
144
+ operators: ['>', '<', '>=', '<=', '=', '!=']
145
+ source: spec
146
+ closed_by_id:
147
+ field: statistics.last_closed_by_id
148
+ type: string
149
+ operators: ['=', '!=']
150
+ source: spec
151
+ first_contact_reply_at:
152
+ field: statistics.first_contact_reply_at
153
+ type: date
154
+ operators: ['>', '<', '>=', '<=', '=', '!=']
155
+ source: spec
156
+ last_contact_reply_at:
157
+ field: statistics.last_contact_reply_at
158
+ type: date
159
+ operators: ['>', '<', '>=', '<=', '=', '!=']
160
+ source: spec
161
+ last_admin_reply_at:
162
+ field: statistics.last_admin_reply_at
163
+ type: date
164
+ operators: ['>', '<', '>=', '<=', '=', '!=']
165
+ source: spec
166
+ reopen_count:
167
+ field: statistics.count_reopens
168
+ type: number
169
+ operators: ['=', '!=', '>', '<']
170
+ source: spec
171
+ part_count:
172
+ field: statistics.count_conversation_parts
173
+ type: number
174
+ operators: ['=', '!=', '>', '<']
175
+ source: spec
176
+ ai_agent_participated:
177
+ field: ai_agent_participated
178
+ type: boolean
179
+ operators: ['=']
180
+ source: spec
181
+ # The foreign key of the `contact` relation, and the reason lot 4 could
182
+ # turn the denormalized contact columns into one: Intercom matches a
183
+ # conversation against one of the contacts it carries. The column is
184
+ # singular and the wire field plural on purpose -- the row names its first
185
+ # contact, the endpoint asks "is this contact one of yours".
186
+ #
187
+ # `!=` is deliberately absent: on a field holding a list, "not that
188
+ # contact" is a question Intercom's DSL does not clearly answer, and the
189
+ # one thing this table may not do is publish a filter whose meaning is a
190
+ # guess.
191
+ contact_id:
192
+ field: contact_ids
193
+ type: string
194
+ operators: ['=']
195
+ source: measured
196
+ # Columns this collection publishes and will not filter, with the reason an
197
+ # operator reads when they try. A column absent from both tables is refused
198
+ # by name too -- these are the ones whose refusal is permanent and has an
199
+ # explanation worth giving.
200
+ refused:
201
+ tag_names:
202
+ reason: >-
203
+ Intercom filters conversations by tag id, and this column holds the
204
+ tag names. Filtering it would mean resolving a name to an id per
205
+ request, and a name the workspace renamed would silently match
206
+ nothing.
207
+ source: spec
208
+ company_name:
209
+ reason: >-
210
+ The conversation carries its company as an object read from the
211
+ payload; the search endpoint filters no company field.
212
+ source: spec
213
+ company_id:
214
+ reason: >-
215
+ The search endpoint filters no company field. Measured on
216
+ `/tickets/search`, which refuses `company_id` with `invalid_field`
217
+ although a ticket carries one; the same is assumed here until probed.
218
+ source: spec
219
+ timeline:
220
+ reason: >-
221
+ Built by the agent from the parts of a conversation, which the search
222
+ endpoint does not read.
223
+ source: measured
224
+ contact_name:
225
+ reason: >-
226
+ Read from the Contacts endpoint, not from the conversation. Filter
227
+ through the `contact` relation, which resolves against
228
+ `/contacts/search` and rewrites onto the contact id -- or on
229
+ `source_author_email`, which is on the conversation itself.
230
+ source: spec
231
+ contact_count:
232
+ reason: Counted by the agent from the contacts the payload carries.
233
+ source: measured
234
+ source_author_name:
235
+ reason: >-
236
+ The endpoint matches the author of the opening message by e-mail
237
+ alone -- `source.author.email` is a field, `source.author.name` is
238
+ not. Filter on `source_author_email`.
239
+ source: spec
240
+ # What `forest_admin_intercom_probe` enumerates on top of the fields above:
241
+ # names the documentation mentions, or that an ops team would plausibly
242
+ # search on. A candidate that turns out to be filterable becomes a field
243
+ # above -- with a column to expose it on, or with none, in which case it is
244
+ # a column the next lot may add.
245
+ candidates:
246
+ - source.id
247
+ - source.author.id
248
+ - source.author.type
249
+ - source.author.name
250
+ - statistics.time_to_assignment
251
+ - statistics.time_to_admin_reply
252
+ - statistics.time_to_first_close
253
+ - statistics.median_time_to_reply
254
+ - statistics.first_assignment_at
255
+ - statistics.first_admin_reply_at
256
+ - statistics.last_assignment_at
257
+ - statistics.count_assignments
258
+ - conversation_rating.score
259
+ - conversation_rating.remark
260
+ - conversation_rating.contact_id
261
+ - ai_agent.resolution_state
262
+ - ai_agent.last_answer_type
263
+ - ai_agent.rating
264
+ - channel_initiated
265
+ - tag_ids
266
+ - teammate_ids
267
+ - company_id
268
+ - topics
269
+
270
+ tickets:
271
+ path: tickets/search
272
+ measured_at: null
273
+ fields:
274
+ # The primary key, declared here for the same reason as on conversations:
275
+ # the schema publishes `equal` and `in` on it whatever this table says,
276
+ # so a row has to exist for the translator to write. `GET /tickets/{id}`
277
+ # answers the key on its own; the search answers it in an `and`.
278
+ id:
279
+ field: id
280
+ type: string
281
+ operators: ['=', 'IN']
282
+ source: spec
283
+ open:
284
+ field: open
285
+ type: boolean
286
+ operators: ['=']
287
+ source: spec
288
+ category:
289
+ field: category
290
+ type: string
291
+ operators: ['=', '!=']
292
+ source: spec
293
+ # A ticket reads its category as a label; the search takes another
294
+ # word for each (OpenAPI 2.16, "Searching by Category").
295
+ values:
296
+ Customer: request
297
+ Back-office: task
298
+ Tracker: tracker
299
+ ticket_type_id:
300
+ field: ticket_type_id
301
+ type: string
302
+ operators: ['=', '!=']
303
+ source: spec
304
+ admin_assignee_id:
305
+ field: admin_assignee_id
306
+ type: string
307
+ operators: ['=', '!=']
308
+ source: spec
309
+ team_assignee_id:
310
+ field: team_assignee_id
311
+ type: string
312
+ operators: ['=', '!=']
313
+ source: spec
314
+ created_at:
315
+ field: created_at
316
+ type: date
317
+ operators: ['>', '<', '>=', '<=', '=', '!=']
318
+ source: measured
319
+ updated_at:
320
+ field: updated_at
321
+ type: date
322
+ operators: ['>', '<', '>=', '<=', '=', '!=']
323
+ source: measured
324
+ # The foreign key of the `contact` relation. `spec` rather than
325
+ # `measured`, and the difference is visible to an operator: it is what
326
+ # decides whether the contacts of a ticket can be filtered at all, and the
327
+ # probe has not run on this endpoint. If Intercom refuses it, this row
328
+ # moves to the refused table and the relation stays navigable without
329
+ # being filterable -- the way `state` already is.
330
+ contact_id:
331
+ field: contact_ids
332
+ type: string
333
+ operators: ['=']
334
+ source: spec
335
+ refused:
336
+ company_id:
337
+ reason: >-
338
+ Measured during lot 1: `/tickets/search` refuses `company_id` with
339
+ `invalid_field`, although a ticket carries one. Filtering tickets by
340
+ account is not something this endpoint does.
341
+ source: measured
342
+ closed_at:
343
+ reason: >-
344
+ Derived by the agent from the parts of the ticket. The search endpoint
345
+ filters nothing of the sort, and ignores a sort on it without a word.
346
+ source: measured
347
+ closed_by_name:
348
+ reason: Derived by the agent from the parts of the ticket.
349
+ source: measured
350
+ last_reply_at:
351
+ reason: Derived by the agent from the parts of the ticket.
352
+ source: measured
353
+ last_responder_name:
354
+ reason: Derived by the agent from the parts of the ticket.
355
+ source: measured
356
+ last_responder_type:
357
+ reason: Derived by the agent from the parts of the ticket.
358
+ source: measured
359
+ state_label:
360
+ reason: >-
361
+ Read off the state object the ticket embeds. Whether the endpoint
362
+ filters a ticket state at all, and under which name, is one of the
363
+ probe's questions.
364
+ source: spec
365
+ ticket_type_name:
366
+ reason: >-
367
+ Read off the type object the ticket embeds. Filter on
368
+ `ticket_type_id`, which the endpoint does take.
369
+ source: spec
370
+ ticket_id:
371
+ reason: >-
372
+ The number the support team says out loud, which is not the id the
373
+ API answers by. The search endpoint carries no field for it. Filter
374
+ on `id`, which takes the value the url and the record page use.
375
+ source: spec
376
+ is_shared:
377
+ reason: >-
378
+ Whether the ticket is visible to the customer. On the payload and not
379
+ in the search DSL; the probe lists it as a candidate.
380
+ source: spec
381
+ state_id:
382
+ reason: >-
383
+ Read off the state object the ticket embeds. The endpoint takes no
384
+ filter on a ticket state under any name this table knows, which is
385
+ what keeps the `state` relation navigable without being filterable.
386
+ Whether it filters one at all is the probe's second question. Filter
387
+ on `open` or on `category` meanwhile.
388
+ source: spec
389
+ previous_state_id:
390
+ reason: >-
391
+ The state the ticket left, read off its payload. Filterable no more
392
+ than the state it is in, and for the same reason.
393
+ source: spec
394
+ contact_name:
395
+ reason: >-
396
+ Read from the Contacts endpoint, not from the ticket. Filter through
397
+ the `contact` relation, which resolves against `/contacts/search` and
398
+ rewrites onto `contact_ids`.
399
+ source: spec
400
+ contact_count:
401
+ reason: Counted by the agent from the contacts the payload carries.
402
+ source: spec
403
+ part_count:
404
+ reason: Counted by the agent from the parts the payload carries.
405
+ source: measured
406
+ timeline:
407
+ reason: >-
408
+ Built by the agent from the parts of the ticket, which the search
409
+ endpoint does not read. Filter on `last_reply_at` or on the state
410
+ instead.
411
+ source: measured
412
+ # R7, and it is a product decision rather than an implementation choice: a
413
+ # ticket attribute is filtered as `ticket_attribute.{id}`, and a same-named
414
+ # attribute carries a different id per ticket type (measured:
415
+ # `_default_title_` is 14162161 on one type and 14162165 on another). One
416
+ # union column cannot know which id a row's type uses, so filtering it
417
+ # server-side would take one collection per ticket type -- more collections
418
+ # in the interface, and a schema that changes shape when the customer adds a
419
+ # type. Until the customer says that trade is worth it, the attributes stay
420
+ # display-only, as lot 1 published them.
421
+ ticket_attributes:
422
+ filterable: false
423
+ reason: >-
424
+ An Intercom ticket attribute is filtered through an id that differs from
425
+ one ticket type to the next, so a column showing the attribute of every
426
+ type at once cannot say which id to filter on. Filter on the ticket type
427
+ and a native field instead.
428
+ source: measured
429
+
430
+ candidates:
431
+ - ticket_id
432
+ - state
433
+ - ticket_state.category
434
+ - ticket_state.id
435
+ - state_id
436
+ - is_shared
437
+ - previous_state_id
438
+ - company_ids
439
+ - source.author.email
440
+ - source.subject
441
+ - source.body
442
+ - title
443
+ - description
444
+
445
+ # The one endpoint of the whole API that sorts. `sortable: true` is what a
446
+ # column reads to publish `isSortable`, and it appears nowhere else in this
447
+ # file on purpose: `/conversations/search` accepts a `sort` and ignores it
448
+ # without a word (measured), so a row that stayed silent there would be a
449
+ # column promising an order Intercom never applies.
450
+ #
451
+ # The set is deliberately narrower than what the documentation implies. It
452
+ # says the results may be sorted by any attribute; nothing has been measured,
453
+ # and a sort Intercom refuses is a list view that fails rather than one that
454
+ # comes back unordered. These are the fields an ops team orders a contact list
455
+ # by, and the probe is what may widen the set.
456
+ contacts:
457
+ path: contacts/search
458
+ measured_at: null
459
+ fields:
460
+ # Measured rather than taken from the documentation: lot 1 reads the
461
+ # contacts of a page through `id IN [...]` on this endpoint, which is the
462
+ # membership operator every other field refuses.
463
+ id:
464
+ field: id
465
+ type: string
466
+ operators: ['=', 'IN']
467
+ source: measured
468
+ role:
469
+ field: role
470
+ type: string
471
+ operators: ['=', '!=']
472
+ source: spec
473
+ name:
474
+ field: name
475
+ type: text
476
+ operators: ['=', '!=', '~', '!~', '^', '$']
477
+ source: spec
478
+ sortable: true
479
+ email:
480
+ field: email
481
+ type: text
482
+ operators: ['=', '!=', '~', '!~', '^', '$']
483
+ source: spec
484
+ sortable: true
485
+ # Derived by the agent from the address, and filtered by Intercom all the
486
+ # same: the endpoint carries a field of its own for it, which is what
487
+ # makes "everyone at this customer" a filter rather than a wildcard.
488
+ email_domain:
489
+ field: email_domain
490
+ type: string
491
+ operators: ['=', '!=']
492
+ source: spec
493
+ phone:
494
+ field: phone
495
+ type: text
496
+ operators: ['=', '!=', '~', '!~', '^', '$']
497
+ source: spec
498
+ external_id:
499
+ field: external_id
500
+ type: string
501
+ operators: ['=', '!=']
502
+ source: spec
503
+ owner_id:
504
+ field: owner_id
505
+ type: string
506
+ operators: ['=', '!=']
507
+ source: spec
508
+ # Measured on 25 August 2026, API 2.16, and this is the asymmetry the
509
+ # per-endpoint table exists for: `/conversations/search` and
510
+ # `/tickets/search` accept `>=`, `<=` and `!=` on a date where this
511
+ # endpoint answers `data_invalid`. `IN` is refused on a date everywhere.
512
+ #
513
+ # Nothing is lost that an operator can see: a Date column publishes the
514
+ # two bounds alone whatever this table allows -- see the operator table --
515
+ # and the toolkit derives `before`, `after`, `today` and the whole
516
+ # `previous_*` family from them.
517
+ created_at:
518
+ field: created_at
519
+ type: date
520
+ operators: ['>', '<']
521
+ source: measured
522
+ sortable: true
523
+ updated_at:
524
+ field: updated_at
525
+ type: date
526
+ operators: ['>', '<']
527
+ source: measured
528
+ sortable: true
529
+ signed_up_at:
530
+ field: signed_up_at
531
+ type: date
532
+ operators: ['>', '<']
533
+ source: spec
534
+ sortable: true
535
+ last_seen_at:
536
+ field: last_seen_at
537
+ type: date
538
+ operators: ['>', '<']
539
+ source: spec
540
+ sortable: true
541
+ last_contacted_at:
542
+ field: last_contacted_at
543
+ type: date
544
+ operators: ['>', '<']
545
+ source: spec
546
+ sortable: true
547
+ last_replied_at:
548
+ field: last_replied_at
549
+ type: date
550
+ operators: ['>', '<']
551
+ source: spec
552
+ sortable: true
553
+ last_email_opened_at:
554
+ field: last_email_opened_at
555
+ type: date
556
+ operators: ['>', '<']
557
+ source: spec
558
+ last_email_clicked_at:
559
+ field: last_email_clicked_at
560
+ type: date
561
+ operators: ['>', '<']
562
+ source: spec
563
+ unsubscribed_from_emails:
564
+ field: unsubscribed_from_emails
565
+ type: boolean
566
+ operators: ['=']
567
+ source: spec
568
+ has_hard_bounced:
569
+ field: has_hard_bounced
570
+ type: boolean
571
+ operators: ['=']
572
+ source: spec
573
+ marked_email_as_spam:
574
+ field: marked_email_as_spam
575
+ type: boolean
576
+ operators: ['=']
577
+ source: spec
578
+ language_override:
579
+ field: language_override
580
+ type: string
581
+ operators: ['=', '!=']
582
+ source: spec
583
+ browser:
584
+ field: browser
585
+ type: string
586
+ operators: ['=', '!=']
587
+ source: spec
588
+ browser_language:
589
+ field: browser_language
590
+ type: string
591
+ operators: ['=', '!=']
592
+ source: spec
593
+ os:
594
+ field: os
595
+ type: string
596
+ operators: ['=', '!=']
597
+ source: spec
598
+ location_country:
599
+ field: location.country
600
+ type: string
601
+ operators: ['=', '!=']
602
+ source: spec
603
+ location_region:
604
+ field: location.region
605
+ type: string
606
+ operators: ['=', '!=']
607
+ source: spec
608
+ location_city:
609
+ field: location.city
610
+ type: string
611
+ operators: ['=', '!=']
612
+ source: spec
613
+ refused:
614
+ avatar:
615
+ reason: >-
616
+ The url of the contact's picture. Nothing filters an image, and
617
+ Intercom's search does not carry the field.
618
+ source: spec
619
+ company_id:
620
+ reason: >-
621
+ The first of the companies the contact belongs to, read off the
622
+ payload. `/contacts/search` filters no company field, so a company
623
+ list is reached from the company side -- open the company and read
624
+ its contacts.
625
+ source: spec
626
+ company_count:
627
+ reason: Counted by the agent from the companies the payload carries.
628
+ source: spec
629
+ session_count:
630
+ reason: >-
631
+ On the contact payload and not in the search DSL. Filter on
632
+ `last_seen_at` instead, which the endpoint does take.
633
+ source: spec
634
+ # Contact custom attributes are filtered as `custom_attributes.{name}` --
635
+ # by name, unlike a ticket attribute, which carries a different id per
636
+ # ticket type. So the obstacle is not R7 here, it is measurement: the
637
+ # operator set Intercom answers on a custom attribute of each data type has
638
+ # not been probed, and this package does not publish a filter it has not
639
+ # seen work. They ship typed and display-only, and the probe is what turns
640
+ # that around.
641
+ custom_attributes:
642
+ filterable: false
643
+ reason: >-
644
+ Contact custom attributes are published for reading only: which
645
+ operators Intercom answers on `custom_attributes.{name}` has not been
646
+ measured on a real workspace, and this datasource does not offer a
647
+ filter it has not seen work. Filter on a native field instead.
648
+ source: spec
649
+ candidates:
650
+ - tag_ids
651
+ - segment_id
652
+ - company_id
653
+ - companies.id
654
+ - companies.name
655
+ - android_app_name
656
+ - android_last_seen_at
657
+ - ios_app_name
658
+ - ios_last_seen_at
659
+ - utm_source
660
+ - utm_campaign
661
+ - utm_medium
662
+ - referrer
663
+ - session_count