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,161 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # Read-only for now: writes arrive with lots 3 and 4b, actions as plugins, never on a collection. A relation to
4
+ # a collection not served yet is a schema the agent refuses to boot on.
5
+ class BaseCollection < ForestAdminDatasourceToolkit::Collection
6
+ include Relations
7
+
8
+ ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
9
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
10
+ Equivalent = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeEquivalent
11
+ Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
12
+
13
+ def initialize(datasource, name)
14
+ super
15
+ define_schema
16
+ end
17
+
18
+ def client
19
+ datasource.client
20
+ end
21
+
22
+ # How many ids this collection resolves per `list`, and in total, as a relation target.
23
+ # Nil means unbounded: the tier read whole answers any number of ids in one request.
24
+ def ids_per_read = nil
25
+ def max_resolvable_ids = nil
26
+
27
+ protected
28
+
29
+ def define_schema = raise(NotImplementedError, "#{self.class} did not implement define_schema")
30
+
31
+ # A missing field yields nil rather than an absent key; relation paths are left to
32
+ # `embed_relations`. No projection means every column, not every serialized key:
33
+ # some keys are internal plumbing (e.g. the ids behind a membership).
34
+ def project(record, projection)
35
+ asked = Array(projection).map(&:to_s)
36
+ return record.slice(*column_names) if asked.empty?
37
+
38
+ asked.reject { |field| field.include?(':') }.to_h { |field| [field, record[field]] }
39
+ end
40
+
41
+ # Whether a projection asks for a column. No projection at all asks for
42
+ # every declared column, which is how `project` reads it -- an enrichment
43
+ # guarded on the column being named would leave nil the very column the
44
+ # projection publishes.
45
+ def column_asked?(projection, column)
46
+ asked = Array(projection).map(&:to_s)
47
+
48
+ asked.empty? || asked.include?(column)
49
+ end
50
+
51
+ # Whether a projection asks for any of a group of columns, read the same
52
+ # way: no projection at all asks for every one of them.
53
+ def any_column_asked?(projection, columns)
54
+ asked = Array(projection).map(&:to_s)
55
+
56
+ asked.empty? || columns.any? { |column| asked.include?(column) }
57
+ end
58
+
59
+ # The window a list view asked for, cut out of records already in hand.
60
+ #
61
+ # A filter with no page -- or a page naming no limit -- asks for every
62
+ # record it matched, and there is nothing to cut. How far the read that
63
+ # collected them went is a different question, answered by the walker and
64
+ # its caps.
65
+ def page_window(records, filter)
66
+ page = filter&.page
67
+ return records if page.nil?
68
+
69
+ offset = page.offset.to_i.clamp(0, nil)
70
+ limit = page_limit(page)
71
+ return records.drop(offset) if limit.nil?
72
+
73
+ records[offset, limit] || []
74
+ end
75
+
76
+ # Nil when the page names no limit, and an explicit zero kept as one: the
77
+ # toolkit's `Page#apply` reads it as an empty window, never as "all".
78
+ def page_limit(page)
79
+ page.limit&.to_i&.clamp(0, nil)
80
+ end
81
+
82
+ def column_names
83
+ @column_names ||= fields.select { |_, field| field.is_a?(ColumnSchema) }.keys
84
+ end
85
+
86
+ # The columns a workspace's own attributes became. None here; the
87
+ # collections that carry any override this through `CustomAttributes`.
88
+ # The translator reads them to answer a filter on one with the refusal
89
+ # covering the family rather than with the message for a column nobody
90
+ # declared.
91
+ def attribute_column_names = []
92
+
93
+ # The timezone in-memory date comparisons are evaluated in. The caller's,
94
+ # since that is whose "today" the filter was written against.
95
+ def timezone_for(caller)
96
+ caller.respond_to?(:timezone) ? caller.timezone : nil
97
+ end
98
+
99
+ # Ids reach this datasource as strings -- a filter value from Forest, a
100
+ # segment, a url -- while Intercom types them inconsistently: a team id is
101
+ # a string, the same team's id inside `admin_ids` is a number. Left as it
102
+ # comes, an integer id would never match the string the filter carries.
103
+ def stringify_id(value)
104
+ value&.to_s
105
+ end
106
+
107
+ # Intercom nests its lists twice -- `{"type": "contact.list", "contacts":
108
+ # [...]}` -- and answers a null instead of an empty list when there is
109
+ # nothing.
110
+ def nested_list(container, key)
111
+ return [] unless container.is_a?(Hash)
112
+
113
+ list = container[key]
114
+ list.is_a?(Array) ? list : []
115
+ end
116
+
117
+ # Intercom dates travel as epoch seconds; Forest reads a Date column as an
118
+ # ISO8601 string, and a filter carries one too, so comparing the two is the
119
+ # ordering itself. UTC deliberately: that is where Intercom stores and
120
+ # truncates, and rendering a local time here would hide the very shift that
121
+ # makes a day-granular date filter wrong.
122
+ def stamp(seconds)
123
+ return nil unless seconds.is_a?(Numeric) && seconds.positive?
124
+
125
+ Time.at(seconds).utc.iso8601
126
+ end
127
+
128
+ # --- Reading a sort clause -------------------------------------------
129
+ #
130
+ # Forest hands a clause keyed with symbols or with strings depending on
131
+ # where it was written, and all three tiers have to read one.
132
+
133
+ def primary_key
134
+ @primary_key ||= fields.find do |_name, field|
135
+ field.respond_to?(:is_primary_key) && field.is_primary_key
136
+ end&.first
137
+ end
138
+
139
+ def sort_field(clause) = clause[:field] || clause['field']
140
+
141
+ # `key?` rather than `||`: a descending clause carries `false`, which an
142
+ # `||` fallback reads as "absent" -- so an explicit `?sort=-id` would be
143
+ # taken for the ascending default the agent injects.
144
+ def ascending?(clause)
145
+ clause.key?(:ascending) ? clause[:ascending] : clause['ascending']
146
+ end
147
+
148
+ # The ascending primary-key sort the agent injects when a request names
149
+ # none. Not an order anybody asked for, so a tier that cannot honour one
150
+ # stays quiet about this one.
151
+ def default_pk_sort?(clauses)
152
+ return false unless clauses.size == 1
153
+
154
+ clause = clauses.first
155
+ return false unless sort_field(clause).to_s == primary_key
156
+
157
+ ascending?(clause) != false
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,44 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ class Company < OffsetCollection
4
+ # One Intercom company flattened into the row the schema declares.
5
+ module Serializer
6
+ protected
7
+
8
+ def serialize(company)
9
+ attrs = company.is_a?(Hash) ? company : {}
10
+ plan = attrs['plan'].is_a?(Hash) ? attrs['plan'] : {}
11
+
12
+ identity(attrs).merge(
13
+ 'plan_name' => plan['name'],
14
+ 'user_count' => attrs['user_count'],
15
+ 'session_count' => attrs['session_count']
16
+ ).merge(dates_of(attrs)).merge(attribute_values(attrs['custom_attributes']))
17
+ end
18
+
19
+ private
20
+
21
+ def identity(attrs)
22
+ {
23
+ 'id' => stringify_id(attrs['id']),
24
+ 'company_id' => stringify_id(attrs['company_id']),
25
+ 'name' => attrs['name'],
26
+ 'size' => attrs['size'],
27
+ 'industry' => attrs['industry'],
28
+ 'website' => attrs['website'],
29
+ 'monthly_spend' => attrs['monthly_spend']
30
+ }
31
+ end
32
+
33
+ def dates_of(attrs)
34
+ {
35
+ 'created_at' => stamp(attrs['created_at']),
36
+ 'updated_at' => stamp(attrs['updated_at']),
37
+ 'last_request_at' => stamp(attrs['last_request_at']),
38
+ 'remote_created_at' => stamp(attrs['remote_created_at'])
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,69 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # No `/companies/search`: `GET /companies` answers exact lookups on `name`, `company_id`,
4
+ # `tag_id` and `segment_id`. Tag and segment filters wait for those collections (lot 5).
5
+ class Company < OffsetCollection
6
+ include Company::Serializer
7
+ include CustomAttributes
8
+
9
+ # The column each lookup is written on, and the query parameter Intercom
10
+ # answers it under. They happen to share a name; keeping the mapping
11
+ # explicit is what lets a column be renamed without silently dropping the
12
+ # lookup.
13
+ LOOKUPS = { 'name' => 'name', 'company_id' => 'company_id' }.freeze
14
+
15
+ def initialize(datasource, attributes: [])
16
+ @attributes = attributes
17
+ super(datasource, 'IntercomCompany')
18
+ end
19
+
20
+ protected
21
+
22
+ def list_path = 'companies/list'
23
+ def record_endpoint = 'companies'
24
+ def lookups = LOOKUPS
25
+
26
+ private
27
+
28
+ def define_schema
29
+ add_column('id', 'String', is_primary_key: true)
30
+ # Intercom's id and the workspace's own are two different things, and an
31
+ # ops team knows the second one: it is what their billing system calls
32
+ # the account.
33
+ add_column('company_id', 'String')
34
+ add_column('name', 'String')
35
+ define_profile_columns
36
+ define_activity_columns
37
+ # Before the attribute columns, so an attribute whose name lands on the
38
+ # relation is skipped with a warning rather than taking the boot with
39
+ # it.
40
+ add_one_to_many('contacts', foreign_collection: 'IntercomContact', origin_key: 'company_id')
41
+ register_attribute_columns
42
+ end
43
+
44
+ def define_profile_columns
45
+ add_column('plan_name', 'String')
46
+ add_column('size', 'Number')
47
+ add_column('industry', 'String')
48
+ add_column('website', 'String')
49
+ add_column('monthly_spend', 'Number')
50
+ end
51
+
52
+ def define_activity_columns
53
+ add_column('user_count', 'Number')
54
+ add_column('session_count', 'Number')
55
+ add_column('created_at', 'Date')
56
+ add_column('updated_at', 'Date')
57
+ add_column('last_request_at', 'Date')
58
+ # When the account was created in the customer's own system, which is
59
+ # not when Intercom heard about it.
60
+ add_column('remote_created_at', 'Date')
61
+ end
62
+
63
+ # Typed from `GET /data_attributes?model=company`, and unfilterable for
64
+ # the same reason as everything else here: this collection is looked up,
65
+ # not searched.
66
+ def attribute_kind = 'company'
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,106 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ class Contact < CursorCollection
4
+ # One Intercom contact flattened into the row the schema declares.
5
+ # Nothing here reads a sub-resource: every value comes from the payload
6
+ # the search already returned.
7
+ module Serializer
8
+ protected
9
+
10
+ def serialize(contact)
11
+ attrs = contact.is_a?(Hash) ? contact : {}
12
+
13
+ native(attrs)
14
+ .merge(account_of(attrs['companies']))
15
+ .merge(location_of(attrs['location']))
16
+ .merge(attribute_values(attrs['custom_attributes']))
17
+ end
18
+
19
+ private
20
+
21
+ def native(attrs)
22
+ identity(attrs).merge(dates_of(attrs)).merge(flags(attrs)).merge(device_of(attrs))
23
+ end
24
+
25
+ def identity(attrs)
26
+ {
27
+ 'id' => stringify_id(attrs['id']),
28
+ 'role' => attrs['role'],
29
+ 'name' => attrs['name'],
30
+ 'email' => attrs['email'],
31
+ # Derived rather than read, and filterable all the same: the search
32
+ # endpoint carries a field of its own for it, which is what turns
33
+ # "everyone at this customer" into a filter instead of a wildcard.
34
+ 'email_domain' => domain_of(attrs['email']),
35
+ 'phone' => attrs['phone'],
36
+ 'external_id' => attrs['external_id'],
37
+ 'avatar' => attrs['avatar'],
38
+ 'owner_id' => stringify_id(attrs['owner_id']),
39
+ 'session_count' => attrs['session_count']
40
+ }
41
+ end
42
+
43
+ def dates_of(attrs)
44
+ {
45
+ 'created_at' => stamp(attrs['created_at']),
46
+ 'updated_at' => stamp(attrs['updated_at']),
47
+ 'signed_up_at' => stamp(attrs['signed_up_at']),
48
+ 'last_seen_at' => stamp(attrs['last_seen_at']),
49
+ 'last_contacted_at' => stamp(attrs['last_contacted_at']),
50
+ 'last_replied_at' => stamp(attrs['last_replied_at']),
51
+ 'last_email_opened_at' => stamp(attrs['last_email_opened_at']),
52
+ 'last_email_clicked_at' => stamp(attrs['last_email_clicked_at'])
53
+ }
54
+ end
55
+
56
+ def flags(attrs)
57
+ {
58
+ 'unsubscribed_from_emails' => attrs['unsubscribed_from_emails'],
59
+ 'has_hard_bounced' => attrs['has_hard_bounced'],
60
+ 'marked_email_as_spam' => attrs['marked_email_as_spam']
61
+ }
62
+ end
63
+
64
+ def device_of(attrs)
65
+ {
66
+ 'language_override' => attrs['language_override'],
67
+ 'browser' => attrs['browser'],
68
+ 'browser_language' => attrs['browser_language'],
69
+ 'os' => attrs['os']
70
+ }
71
+ end
72
+
73
+ # A contact belongs to several accounts, and the row names the first of
74
+ # them and counts them -- the same reading a conversation gives its
75
+ # contacts. The whole list is a hop away, on the `company` relation.
76
+ def account_of(companies)
77
+ list = nested_list(companies, 'data')
78
+ first = list.first.is_a?(Hash) ? list.first : {}
79
+
80
+ { 'company_id' => stringify_id(first['id']), 'company_count' => account_count(companies, list) }
81
+ end
82
+
83
+ # Intercom caps the accounts it nests on a contact and says how many
84
+ # there really are, so the count is read rather than measured on the
85
+ # list -- a contact belonging to twelve accounts must not read as
86
+ # belonging to the ten the payload had room for.
87
+ def account_count(companies, list)
88
+ declared = companies['total_count'] if companies.is_a?(Hash)
89
+
90
+ declared.is_a?(Numeric) ? declared : list.size
91
+ end
92
+
93
+ def location_of(location)
94
+ attrs = location.is_a?(Hash) ? location : {}
95
+
96
+ { 'location_country' => attrs['country'], 'location_region' => attrs['region'],
97
+ 'location_city' => attrs['city'] }
98
+ end
99
+
100
+ def domain_of(email)
101
+ email.to_s[/@(.+)\z/, 1]
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,266 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # `/contacts/search` is the only endpoint that honours a sort and answers `id IN`. It filters
4
+ # no company field, hence the `GET /companies/{id}/contacts` route. A merged contact vanishes
5
+ # from search and listing. Long by line count only: most of it declares the columns.
6
+ class Contact < CursorCollection # rubocop:disable Metrics/ClassLength
7
+ include Contact::Serializer
8
+ include CustomAttributes
9
+
10
+ # `/contacts/search` demands a query, so a read with no condition of its
11
+ # own -- a list view asking for an order -- sends the least noisy
12
+ # predicate that matches everything. Every contact has a creation date,
13
+ # and a bound at the epoch keeps whatever the day-granular truncation does
14
+ # to it harmless. The same predicate `Ticket` sends, for the same reason.
15
+ MATCH_EVERY_CONTACT = { 'field' => 'created_at', 'operator' => '>', 'value' => '0' }.freeze
16
+
17
+ # `ContactIdentity::CONTACT_CHUNK` must equal IDS_PER_READ, or the enrichment and the
18
+ # relation send different request bodies and the page pays for both.
19
+ IDS_PER_READ = Query::FilterValue::MAX_LIST_SIZE
20
+ MAX_IDS_READ = 300
21
+
22
+ def initialize(datasource, attributes: [])
23
+ @attributes = attributes
24
+ super(datasource, 'IntercomContact')
25
+ # Answered on the e-mail address, which is what an ops team types when
26
+ # they are looking for someone. Per word, not as a substring -- see the
27
+ # README.
28
+ enable_search
29
+ end
30
+
31
+ # This endpoint answers `id IN [...]`, so fifteen ids cost one request
32
+ # rather than fifteen: a relation pointing here resolves a whole page of
33
+ # rows for a handful of requests, and there is no fan-out to refuse. That
34
+ # is why `max_resolvable_ids` is nil where the other two tiers bound it --
35
+ # the read is proportional to the rows already collected, and the walk
36
+ # that collected them is bounded.
37
+ def ids_per_read = IDS_PER_READ
38
+ def max_resolvable_ids = nil
39
+
40
+ protected
41
+
42
+ def list_endpoint = 'contacts'
43
+ def searchable = 'contacts'
44
+ def search_column = 'email'
45
+ def match_all_query = MATCH_EVERY_CONTACT
46
+ def max_id_reads = MAX_IDS_READ
47
+ def routed_operators = { 'company_id' => [Operators::EQUAL] }
48
+
49
+ private
50
+
51
+ def define_schema
52
+ add_column('id', 'String', is_primary_key: true)
53
+ define_identity_columns
54
+ define_date_columns
55
+ define_reachability_columns
56
+ define_device_columns
57
+ # Before the attribute columns rather than after: a workspace attribute
58
+ # whose name lands on a relation is then skipped with a warning, the way
59
+ # one landing on a column already is. Declared after, it would collide
60
+ # and take the boot with it.
61
+ define_relations
62
+ register_attribute_columns
63
+ end
64
+
65
+ def define_identity_columns
66
+ add_column('role', 'String')
67
+ add_column('name', 'String')
68
+ add_column('email', 'String')
69
+ add_column('email_domain', 'String')
70
+ add_column('phone', 'String')
71
+ add_column('external_id', 'String')
72
+ add_column('avatar', 'String')
73
+ add_column('owner_id', 'String')
74
+ add_column('session_count', 'Number')
75
+ # The first of the accounts the contact belongs to, and how many there
76
+ # are: the same reading a conversation gives its contacts, and the
77
+ # foreign key the `company` relation is built on.
78
+ add_column('company_id', 'String')
79
+ add_column('company_count', 'Number')
80
+ end
81
+
82
+ def define_date_columns
83
+ add_column('created_at', 'Date')
84
+ add_column('updated_at', 'Date')
85
+ add_column('signed_up_at', 'Date')
86
+ add_column('last_seen_at', 'Date')
87
+ add_column('last_contacted_at', 'Date')
88
+ add_column('last_replied_at', 'Date')
89
+ add_column('last_email_opened_at', 'Date')
90
+ add_column('last_email_clicked_at', 'Date')
91
+ end
92
+
93
+ def define_reachability_columns
94
+ add_column('unsubscribed_from_emails', 'Boolean')
95
+ add_column('has_hard_bounced', 'Boolean')
96
+ add_column('marked_email_as_spam', 'Boolean')
97
+ end
98
+
99
+ def define_device_columns
100
+ add_column('language_override', 'String')
101
+ add_column('browser', 'String')
102
+ add_column('browser_language', 'String')
103
+ add_column('os', 'String')
104
+ add_column('location_country', 'String')
105
+ add_column('location_region', 'String')
106
+ add_column('location_city', 'String')
107
+ end
108
+
109
+ # Typed from `GET /data_attributes?model=contact` rather than guessed from
110
+ # a payload, and published unfilterable: which operators Intercom answers
111
+ # on `custom_attributes.{name}` has not been measured, and this package
112
+ # offers no filter it has not seen work. `api_writable` travels on the
113
+ # introspected attribute for lot 4b, not on the column -- everything here
114
+ # is read-only.
115
+ def attribute_kind = 'contact'
116
+
117
+ # The owner is a teammate, read whole in one request, and
118
+ # `/contacts/search` filters on the key -- so that relation is readable,
119
+ # navigable and filterable alike.
120
+ #
121
+ # The company is none of those last two: the endpoint filters no company
122
+ # field, so the traversal is refused by name (see `check_relation_filterable!`
123
+ # on the tier). The two lists are the 360 degrees this lot exists for.
124
+ def define_relations
125
+ add_many_to_one('owner', foreign_collection: 'IntercomAdmin', foreign_key: 'owner_id')
126
+ add_many_to_one('company', foreign_collection: 'IntercomCompany', foreign_key: 'company_id')
127
+ add_one_to_many('conversations', foreign_collection: 'IntercomConversation', origin_key: 'contact_id')
128
+ add_one_to_many('tickets', foreign_collection: 'IntercomTicket', origin_key: 'contact_id')
129
+ end
130
+
131
+ # The contacts of an account, which `/contacts/search` cannot answer and
132
+ # `GET /companies/{id}/contacts` can. Anything else goes the usual way.
133
+ def fetch_records(caller, filter, sort = nil)
134
+ company = company_lookup(filter)
135
+ unless company
136
+ refuse_narrowed_company!(filter) if narrowed_company?(filter)
137
+ return super
138
+ end
139
+
140
+ warn_unordered_company_contacts(Array(filter&.sort)) if sort
141
+ offset, limit = translate_page(filter&.page)
142
+
143
+ walker.walk(offset: offset, limit: limit) do |per_page, cursor|
144
+ read_company_page(company, per_page: per_page, cursor: cursor)
145
+ end
146
+ end
147
+
148
+ def count_records(caller, filter)
149
+ company = company_lookup(filter)
150
+ unless company
151
+ refuse_narrowed_company!(filter) if narrowed_company?(filter)
152
+ return super
153
+ end
154
+
155
+ exact_count(read_company_page(company, per_page: 1, cursor: nil))
156
+ end
157
+
158
+ # A bare equality and nothing else: an `and` also carrying a scope names a
159
+ # narrower set than the account does, and answering it with the account
160
+ # alone would serve contacts the scope excludes.
161
+ def company_lookup(filter)
162
+ tree = filter&.condition_tree
163
+ return nil unless tree.is_a?(Leaf) && tree.field.to_s == 'company_id'
164
+ return nil unless tree.operator == Operators::EQUAL && blank_search?(filter)
165
+
166
+ tree.value&.to_s
167
+ end
168
+
169
+ # `company_id` plus another condition (e.g. a scope) or a search: `GET /companies/{id}/contacts`
170
+ # narrows nothing and `/contacts/search` filters no company field, so no request answers both.
171
+ def narrowed_company?(filter)
172
+ tree = filter&.condition_tree
173
+ return false if tree.nil?
174
+ return false if tree.is_a?(Leaf) && blank_search?(filter)
175
+
176
+ tree.some_leaf { |leaf| leaf.field.to_s == 'company_id' && leaf.operator == Operators::EQUAL }
177
+ end
178
+
179
+ def refuse_narrowed_company!(filter)
180
+ raise UnsupportedOperatorError,
181
+ "#{name} cannot answer this filter: the contacts of an account are read through " \
182
+ 'GET /companies/{id}/contacts, which returns them whole and narrows nothing, and ' \
183
+ "#{search_endpoint.path} filters no company field -- so the account and the " \
184
+ "#{narrowing_cause(filter)} cannot be asked for in one request. Read the account's contacts " \
185
+ 'without it, or filter on a column this collection is searched on: ' \
186
+ "#{search_endpoint.filterable_columns.join(", ")}."
187
+ end
188
+
189
+ # A scope and a segment are what put a condition next to the account's in
190
+ # practice, and the operator can act on neither the same way -- so the
191
+ # message names what it can see rather than guessing.
192
+ def narrowing_cause(filter)
193
+ others = []
194
+ filter.condition_tree.some_leaf do |leaf|
195
+ others << leaf.field.to_s unless leaf.field.to_s == 'company_id'
196
+ false
197
+ end
198
+
199
+ causes = []
200
+ causes << "condition on #{others.uniq.join(", ")}" unless others.empty?
201
+ causes << "search #{filter.search.to_s.strip.inspect}" unless blank_search?(filter)
202
+ causes.empty? ? 'condition filtered alongside it' : causes.join(' and the ')
203
+ end
204
+
205
+ # An account Intercom no longer answers for -- deleted, or moved outside
206
+ # the token's reach between the moment the row was rendered and the
207
+ # moment its related list was opened -- reads as an account with no
208
+ # contact rather than as a failed page, the way a record read by its id
209
+ # already does.
210
+ def read_company_page(company, per_page:, cursor:)
211
+ client.list_page("companies/#{Faraday::Utils.escape(company)}/contacts",
212
+ per_page: [per_page, max_page_size].min, starting_after: cursor)
213
+ rescue APIError => e
214
+ raise unless e.status == 404
215
+
216
+ Client::Page.new(records: [], next_cursor: nil, total_count: 0)
217
+ end
218
+
219
+ # One request per fifteen ids instead of one per id: this endpoint answers
220
+ # `id IN [...]`, which is what lot 1 already reads the contacts of a page
221
+ # through. A contact that was merged away is simply absent from the
222
+ # answer -- the row reads as gone, not as a failure.
223
+ #
224
+ # Put back in the order the ids were named: the search answers in its own,
225
+ # and the window was cut on the ids.
226
+ def records_by_ids(ids)
227
+ refuse_id_reads!(ids.size, 'list') if ids.size > max_id_reads
228
+
229
+ records = ids.each_slice(IDS_PER_READ).flat_map do |chunk|
230
+ client.search_page(search_endpoint.path, per_page: chunk.size,
231
+ query: { 'field' => 'id', 'operator' => 'IN',
232
+ 'value' => chunk }).records
233
+ end
234
+ position = ids.each_with_index.to_h
235
+ records.sort_by { |record| position.fetch(record['id'].to_s, ids.size) }
236
+ end
237
+
238
+ # The same searches, each asked for one record: Intercom counts the slice
239
+ # in `total_count`, so nothing needs reading to be counted.
240
+ def count_by_ids(ids)
241
+ refuse_id_reads!(ids.size, 'count') if ids.size > max_id_reads
242
+
243
+ ids.each_slice(IDS_PER_READ).sum do |chunk|
244
+ exact_count(client.search_page(search_endpoint.path, per_page: 1,
245
+ query: { 'field' => 'id', 'operator' => 'IN',
246
+ 'value' => chunk }))
247
+ end
248
+ end
249
+
250
+ # `sort` here is the clause `server_sort` found `/contacts/search` does
251
+ # honour, which is why nothing has reported it yet: it is this route that
252
+ # cannot carry the order, not the column. Saying Intercom does not sort
253
+ # this collection would be wrong -- it is the one collection it sorts --
254
+ # so this is the counterpart of `warn_unordered_ids`, for the other route
255
+ # that leaves the search behind.
256
+ def warn_unordered_company_contacts(clauses)
257
+ ForestAdminDatasourceIntercom.logger.warn(
258
+ "[forest_admin_datasource_intercom] #{name} was asked to sort on " \
259
+ "#{clauses.map { |clause| sort_field(clause) }.join(", ")} while reading the contacts of an account, " \
260
+ 'which Intercom answers through GET /companies/{id}/contacts -- a route that takes no order, unlike ' \
261
+ 'the search this collection is otherwise read through. The rows come back in the order the API imposes.'
262
+ )
263
+ end
264
+ end
265
+ end
266
+ end