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
|
@@ -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
|