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,294 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# Intercom joins nothing, so relation filters must be answered here: the agent publishes a
|
|
4
|
+
# many-to-one filterable as soon as any target column is. A null foreign key matches no
|
|
5
|
+
# relation filter, negated ones included, like a join.
|
|
6
|
+
module Relations # rubocop:disable Metrics/ModuleLength
|
|
7
|
+
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
|
|
8
|
+
ManyToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema
|
|
9
|
+
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
|
|
10
|
+
Filter = ForestAdminDatasourceToolkit::Components::Query::Filter
|
|
11
|
+
Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
|
|
12
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
13
|
+
Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch
|
|
14
|
+
Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
|
|
15
|
+
|
|
16
|
+
# What a relation condition comes to when the target matched no record: no
|
|
17
|
+
# row can satisfy it. It is a value rather than an empty condition because
|
|
18
|
+
# the two tiers spell "match nothing" differently -- in memory an `in []`
|
|
19
|
+
# says it, while Intercom's search DSL has no way to.
|
|
20
|
+
NOTHING = :matches_nothing
|
|
21
|
+
|
|
22
|
+
# The group a relation condition expands into, told apart from a group the
|
|
23
|
+
# operator wrote. Two things hang on the difference: this one may be
|
|
24
|
+
# inlined into a parent aggregating the same way -- the level it adds is
|
|
25
|
+
# one nobody budgeted for -- and a tier refusing it can name the relation
|
|
26
|
+
# rather than a shape the operator never wrote.
|
|
27
|
+
class RelationBranch < Branch
|
|
28
|
+
attr_reader :relation_field
|
|
29
|
+
|
|
30
|
+
def initialize(aggregator, conditions, relation_field)
|
|
31
|
+
@relation_field = relation_field
|
|
32
|
+
super(aggregator, conditions)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
protected
|
|
37
|
+
|
|
38
|
+
# Every relation of this package points at a collection keyed by `id` and
|
|
39
|
+
# is read-only: nothing in this lot writes, and an editable relation would
|
|
40
|
+
# offer an association the collection cannot perform.
|
|
41
|
+
#
|
|
42
|
+
# Published unfilterable where the endpoint filters nothing on the key, so
|
|
43
|
+
# the interface does not offer the traversal `check_relation_filterable!`
|
|
44
|
+
# would refuse.
|
|
45
|
+
def add_many_to_one(name, foreign_collection:, foreign_key:)
|
|
46
|
+
add_field(name, ManyToOneSchema.new(foreign_collection: foreign_collection,
|
|
47
|
+
foreign_key: foreign_key,
|
|
48
|
+
foreign_key_target: 'id',
|
|
49
|
+
is_read_only: true,
|
|
50
|
+
is_filterable: relation_key_filterable?(foreign_key)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Served by the agent as `origin_key equals <id>` on the target, which must answer it.
|
|
54
|
+
def add_one_to_many(name, foreign_collection:, origin_key:)
|
|
55
|
+
add_field(name, OneToManySchema.new(foreign_collection: foreign_collection,
|
|
56
|
+
origin_key: origin_key,
|
|
57
|
+
origin_key_target: 'id',
|
|
58
|
+
is_read_only: true))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_many_to_many(name, foreign_collection:, through_collection:, origin_key:, foreign_key:)
|
|
62
|
+
add_field(name, ManyToManySchema.new(foreign_collection: foreign_collection,
|
|
63
|
+
through_collection: through_collection,
|
|
64
|
+
origin_key: origin_key, origin_key_target: 'id',
|
|
65
|
+
foreign_key: foreign_key, foreign_key_target: 'id',
|
|
66
|
+
is_read_only: true))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `records` are the unprojected rows, where the foreign keys live. One read per target
|
|
70
|
+
# collection per page, shared by every relation pointing at it.
|
|
71
|
+
def embed_relations(caller, records, rows, projection)
|
|
72
|
+
asked = many_to_one_asked(projection)
|
|
73
|
+
return if asked.empty?
|
|
74
|
+
|
|
75
|
+
indexed = indexed_targets(caller, records, asked)
|
|
76
|
+
|
|
77
|
+
asked.each do |name, relation, wanted|
|
|
78
|
+
rows_of_target = indexed[relation.foreign_collection]
|
|
79
|
+
|
|
80
|
+
# Nil rather than absent when an id names no record: a teammate who
|
|
81
|
+
# left the workspace reads as no teammate, not as a broken row. Sliced
|
|
82
|
+
# back to what this relation asked for, the read having been widened to
|
|
83
|
+
# the union of what every relation on that collection did.
|
|
84
|
+
records.each_with_index do |record, index|
|
|
85
|
+
target_row = rows_of_target[record[relation.foreign_key]]
|
|
86
|
+
rows[index][name] = target_row&.slice(*wanted)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Trades each `relation:field` leaf for a condition on the foreign key, built by the block
|
|
92
|
+
# from the ids the target matched.
|
|
93
|
+
def rewrite_relation_conditions(caller, node, &builder)
|
|
94
|
+
case node
|
|
95
|
+
when Branch then rewrite_branch(caller, node, &builder)
|
|
96
|
+
when Leaf then relation_leaf?(node) ? rewrite_relation_leaf(caller, node, &builder) : node
|
|
97
|
+
else node
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def relation_leaf?(node)
|
|
104
|
+
node.is_a?(Leaf) && node.field.to_s.include?(':')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def relations_asked(projection)
|
|
108
|
+
Projection.new(Array(projection).map(&:to_s)).relations
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The many-to-one relations the projection named, each with the columns it
|
|
112
|
+
# asked of its target. The target's own key travels with them whether or
|
|
113
|
+
# not it was asked for: it is what the rows are indexed by here, and what
|
|
114
|
+
# makes the nested row a link rather than a label in the interface.
|
|
115
|
+
def many_to_one_asked(projection)
|
|
116
|
+
relations_asked(projection).filter_map do |relation_name, sub_projection|
|
|
117
|
+
relation = fields[relation_name]
|
|
118
|
+
next unless relation.is_a?(ManyToOneSchema)
|
|
119
|
+
|
|
120
|
+
columns = Array(sub_projection).map(&:to_s)
|
|
121
|
+
refuse_two_hop_projection!(relation_name, relation, columns)
|
|
122
|
+
|
|
123
|
+
[relation_name, relation, columns.union([relation.foreign_key_target])]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `admin_assignee:teams:name` is a projection Forest's own parser builds
|
|
128
|
+
# and this cannot answer: it nests one target row under the relation name,
|
|
129
|
+
# not a tree of them, so the second hop would be dropped by the target's
|
|
130
|
+
# projection and the column would come back missing from a row that looks
|
|
131
|
+
# complete. Refused by name, like the filter that reaches that deep --
|
|
132
|
+
# `refuse_two_hops!` is its counterpart.
|
|
133
|
+
def refuse_two_hop_projection!(relation_name, relation, columns)
|
|
134
|
+
deep = columns.select { |column| column.include?(':') }
|
|
135
|
+
return if deep.empty?
|
|
136
|
+
|
|
137
|
+
raise UnsupportedOperatorError,
|
|
138
|
+
"#{name} cannot read #{deep.map { |column| "#{relation_name}:#{column}".inspect }.join(", ")}: it " \
|
|
139
|
+
'reaches through two relations, and this datasource resolves one. Read the column from a record ' \
|
|
140
|
+
"page of #{relation.foreign_collection}, one hop away."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def indexed_targets(caller, records, asked)
|
|
144
|
+
asked.group_by { |_, relation, _| relation.foreign_collection }
|
|
145
|
+
.transform_values { |group| target_rows(caller, records, group) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Past what the target resolves at all, refused: a partly resolved page would show nil where
|
|
149
|
+
# a record exists.
|
|
150
|
+
def target_rows(caller, records, group)
|
|
151
|
+
relation = group.first[1]
|
|
152
|
+
ids = group.flat_map { |_, rel, _| records.filter_map { |record| record[rel.foreign_key] } }.uniq
|
|
153
|
+
return {} if ids.empty?
|
|
154
|
+
|
|
155
|
+
collection = foreign_collection(relation)
|
|
156
|
+
refuse_relation_fan_out!(group, collection, ids.size) if beyond_reach?(collection, ids.size)
|
|
157
|
+
|
|
158
|
+
target = relation.foreign_key_target
|
|
159
|
+
wanted = Projection.new(group.flat_map { |_, _, columns| columns }.uniq)
|
|
160
|
+
|
|
161
|
+
read_targets(caller, collection, target, ids, wanted).to_h { |row| [row[target], row] }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def beyond_reach?(collection, count)
|
|
165
|
+
limit = collection.max_resolvable_ids
|
|
166
|
+
|
|
167
|
+
!limit.nil? && count > limit
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read_targets(caller, collection, target, ids, wanted)
|
|
171
|
+
chunk = collection.ids_per_read
|
|
172
|
+
slices = chunk ? ids.each_slice(chunk).to_a : [ids]
|
|
173
|
+
|
|
174
|
+
slices.flat_map do |slice|
|
|
175
|
+
collection.list(caller, Filter.new(condition_tree: Leaf.new(target, Operators::IN, slice)), wanted)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Names the relations rather than the key they resolve to: what the
|
|
180
|
+
# operator can act on is the column they put in the view or the export,
|
|
181
|
+
# and how many rows they asked for at once.
|
|
182
|
+
def refuse_relation_fan_out!(group, collection, count)
|
|
183
|
+
asked = group.map { |relation_name, _, _| relation_name }.join(', ')
|
|
184
|
+
|
|
185
|
+
raise UnsupportedOperatorError,
|
|
186
|
+
"#{name} cannot resolve #{asked} over this read: it names #{count} distinct #{collection.name} " \
|
|
187
|
+
'records and Intercom has no bulk read for that collection, so this resolves at most ' \
|
|
188
|
+
"#{collection.max_resolvable_ids} of them per read -- one request each. Read fewer rows at a time, " \
|
|
189
|
+
'or leave the relation out of the projection: a relation resolved for part of a page would show no ' \
|
|
190
|
+
'record where one exists.'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def rewrite_branch(caller, branch, &builder)
|
|
194
|
+
rewritten = Array(branch.conditions).map { |node| rewrite_relation_conditions(caller, node, &builder) }
|
|
195
|
+
|
|
196
|
+
if branch.aggregator.to_s.casecmp('or').zero?
|
|
197
|
+
kept = rewritten.reject { |node| node == NOTHING }
|
|
198
|
+
kept.empty? ? NOTHING : Branch.new(branch.aggregator, absorb_groups(branch.aggregator, kept))
|
|
199
|
+
elsif rewritten.include?(NOTHING)
|
|
200
|
+
NOTHING
|
|
201
|
+
else
|
|
202
|
+
Branch.new(branch.aggregator, absorb_groups(branch.aggregator, rewritten))
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# `or(x, or(a, b))` becomes `or(x, a, b)`, saving the nesting level a relation expansion
|
|
207
|
+
# adds. Only relation groups are inlined, never groups the operator wrote.
|
|
208
|
+
def absorb_groups(aggregator, nodes)
|
|
209
|
+
absorbed = nodes.flat_map do |node|
|
|
210
|
+
inlinable_group?(node, aggregator) ? Array(node.conditions) : [node]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
absorb_relation_group?(absorbed.size) ? absorbed : nodes
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def inlinable_group?(node, aggregator)
|
|
217
|
+
node.is_a?(RelationBranch) && node.aggregator.to_s.casecmp(aggregator.to_s).zero?
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Hook for a tier that bounds how many conditions a group may hold: past
|
|
221
|
+
# that, the nested form is the one that fits, and the level it costs is
|
|
222
|
+
# spent rather than the width.
|
|
223
|
+
def absorb_relation_group?(_size) = true
|
|
224
|
+
|
|
225
|
+
def rewrite_relation_leaf(caller, leaf)
|
|
226
|
+
name, path = leaf.field.to_s.split(':', 2)
|
|
227
|
+
relation = fields[name]
|
|
228
|
+
refuse_unknown_relation!(leaf, name) unless relation.is_a?(ManyToOneSchema)
|
|
229
|
+
refuse_two_hops!(leaf) if path.include?(':')
|
|
230
|
+
|
|
231
|
+
check_relation_filterable!(leaf, relation)
|
|
232
|
+
|
|
233
|
+
ids = matching_ids(caller, relation, Leaf.new(path, leaf.operator, leaf.value))
|
|
234
|
+
return NOTHING if ids.empty?
|
|
235
|
+
|
|
236
|
+
yield(relation.foreign_key, ids, leaf)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Hooks for a tier that cannot filter on every foreign key it declares a
|
|
240
|
+
# relation on. Answered before the target is read: a refusal that spends a
|
|
241
|
+
# request first costs exactly what it refuses to do.
|
|
242
|
+
def relation_key_filterable?(_key) = true
|
|
243
|
+
|
|
244
|
+
def check_relation_filterable!(_leaf, _relation); end
|
|
245
|
+
|
|
246
|
+
# Which records of the target the condition names, asked of the target
|
|
247
|
+
# itself: it owns what its columns can be filtered with, and it answers
|
|
248
|
+
# over every record Intercom holds rather than over a page of them.
|
|
249
|
+
def matching_ids(caller, relation, leaf)
|
|
250
|
+
target = relation.foreign_key_target
|
|
251
|
+
filter = Filter.new(condition_tree: leaf, page: match_page)
|
|
252
|
+
|
|
253
|
+
foreign_collection(relation)
|
|
254
|
+
.list(caller, filter, Projection.new([target]))
|
|
255
|
+
.filter_map { |row| row[target] }
|
|
256
|
+
.uniq
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# How much of the target a relation condition may read; unbounded for in-memory targets.
|
|
260
|
+
def match_page = nil
|
|
261
|
+
|
|
262
|
+
# The target as the datasource holds it, undecorated -- so a permission
|
|
263
|
+
# scope or a segment defined on the target does not narrow what a relation
|
|
264
|
+
# resolves. That is how a native datasource behaves too: it joins the table
|
|
265
|
+
# without applying the scopes of the collection mapped to it.
|
|
266
|
+
def foreign_collection(relation)
|
|
267
|
+
datasource.get_collection(relation.foreign_collection)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Either a name this collection carries no relation under -- a condition
|
|
271
|
+
# from a scope or a segment written against another schema -- or a
|
|
272
|
+
# many-to-many, which is published unfilterable: resolving one would mean
|
|
273
|
+
# reading the collection it travels through once per value.
|
|
274
|
+
def refuse_unknown_relation!(leaf, relation_name)
|
|
275
|
+
filterable = fields.select { |_, field| field.is_a?(ManyToOneSchema) }.keys
|
|
276
|
+
|
|
277
|
+
raise UnsupportedOperatorError,
|
|
278
|
+
"#{name} cannot filter #{leaf.field.inspect}: #{relation_name.inspect} is not a relation it filters " \
|
|
279
|
+
"through. #{filterable.empty? ? "It has none." : "Those it does: #{filterable.join(", ")}."} Filter " \
|
|
280
|
+
'on a column of the collection next door instead.'
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Two hops would mean resolving a relation of a relation, one read per
|
|
284
|
+
# level, and nothing in this datasource publishes a filter that deep -- a
|
|
285
|
+
# many-to-many is published unfilterable. It is refused by name rather than
|
|
286
|
+
# half-answered.
|
|
287
|
+
def refuse_two_hops!(leaf)
|
|
288
|
+
raise UnsupportedOperatorError,
|
|
289
|
+
"#{name} cannot filter #{leaf.field.inspect}: it reaches through two relations, and this datasource " \
|
|
290
|
+
'resolves one. Filter on a column of the collection next door instead.'
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# The inbox teams a conversation can be assigned to, rather than a single
|
|
4
|
+
# teammate.
|
|
5
|
+
class Team < FetchAllCollection
|
|
6
|
+
def initialize(datasource)
|
|
7
|
+
super(datasource, 'IntercomTeam')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
# Like `/admins`, `/teams` uses its own key instead of the `data` envelope.
|
|
13
|
+
def fetch_all
|
|
14
|
+
client.fetch_all('teams', list_key: 'teams')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def serialize(team)
|
|
18
|
+
attrs = team.is_a?(Hash) ? team : {}
|
|
19
|
+
|
|
20
|
+
{ 'id' => stringify_id(attrs['id']),
|
|
21
|
+
'name' => attrs['name'],
|
|
22
|
+
# Not a column: it is what the names below are read from, and what the
|
|
23
|
+
# membership collection turns into the relation. Intercom types these
|
|
24
|
+
# as numbers here and as strings on the admin itself, so they are
|
|
25
|
+
# stringified for both sides to carry the same id.
|
|
26
|
+
'admin_ids' => Array(attrs['admin_ids']).map { |id| stringify_id(id) },
|
|
27
|
+
'admin_names' => nil }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The teammates of the team, by name, and only when a projection asked for
|
|
31
|
+
# them: one read of `/admins` for the whole page, never one per team. A
|
|
32
|
+
# token that cannot read the teammates costs the column and nothing else.
|
|
33
|
+
def enrich(records, rows, projection)
|
|
34
|
+
return unless column_asked?(projection, 'admin_names')
|
|
35
|
+
|
|
36
|
+
names = admin_names
|
|
37
|
+
return if names.nil?
|
|
38
|
+
|
|
39
|
+
records.each_with_index do |record, index|
|
|
40
|
+
rows[index]['admin_names'] = Array(record['admin_ids']).filter_map { |id| names[id] }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def admin_names
|
|
47
|
+
client.fetch_all('admins', list_key: 'admins')
|
|
48
|
+
.to_h { |admin| [stringify_id(admin['id']), admin['name']] }
|
|
49
|
+
rescue APIError => e
|
|
50
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
51
|
+
"[forest_admin_datasource_intercom] #{name} could not read the teammates of the workspace (HTTP " \
|
|
52
|
+
"#{e.status || "-"}); the names are left unknown. The relation to IntercomAdmin is unaffected."
|
|
53
|
+
)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def define_schema
|
|
58
|
+
add_column('id', 'String', is_primary_key: true)
|
|
59
|
+
add_column('name', 'String')
|
|
60
|
+
# A list, so neither filterable nor sortable -- as the array of ids it
|
|
61
|
+
# replaces was. It reads the team without a join; the relation below is
|
|
62
|
+
# what navigates it, and filtering happens on the Admins collection.
|
|
63
|
+
add_column('admin_names', 'Json')
|
|
64
|
+
add_many_to_many('admins', foreign_collection: 'IntercomAdmin',
|
|
65
|
+
through_collection: 'IntercomTeamMembership',
|
|
66
|
+
origin_key: 'team_id', foreign_key: 'admin_id')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
# Synthetic through-collection for the Team <-> Admin many-to-many, which Intercom has no
|
|
4
|
+
# resource for. Pairs whose teammate `/admins` no longer answers are dropped: `admin_ids`
|
|
5
|
+
# keeps naming a teammate who left until Intercom drops the pair.
|
|
6
|
+
class TeamMembership < FetchAllCollection
|
|
7
|
+
def initialize(datasource)
|
|
8
|
+
super(datasource, 'IntercomTeamMembership')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
def fetch_all
|
|
14
|
+
admin_ids = client.fetch_all('admins', list_key: 'admins')
|
|
15
|
+
.filter_map { |admin| stringify_id(admin['id']) if admin.is_a?(Hash) }.to_set
|
|
16
|
+
client.fetch_all('teams', list_key: 'teams').flat_map { |team| pairs_of(team, admin_ids) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Keyed by both ids rather than by a counter: a related list is read over
|
|
20
|
+
# two requests, and a key that changed between them would move the rows
|
|
21
|
+
# under the operator.
|
|
22
|
+
def serialize(pair)
|
|
23
|
+
{ 'id' => "#{pair["team_id"]}:#{pair["admin_id"]}",
|
|
24
|
+
'team_id' => pair['team_id'],
|
|
25
|
+
'admin_id' => pair['admin_id'] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def pairs_of(team, admin_ids)
|
|
31
|
+
return [] unless team.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
team_id = stringify_id(team['id'])
|
|
34
|
+
return [] if team_id.nil?
|
|
35
|
+
|
|
36
|
+
Array(team['admin_ids']).filter_map do |admin_id|
|
|
37
|
+
id = stringify_id(admin_id)
|
|
38
|
+
{ 'team_id' => team_id, 'admin_id' => id } if admin_ids.include?(id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def define_schema
|
|
43
|
+
add_column('id', 'String', is_primary_key: true)
|
|
44
|
+
add_column('team_id', 'String')
|
|
45
|
+
add_column('admin_id', 'String')
|
|
46
|
+
add_many_to_one('team', foreign_collection: 'IntercomTeam', foreign_key: 'team_id')
|
|
47
|
+
add_many_to_one('admin', foreign_collection: 'IntercomAdmin', foreign_key: 'admin_id')
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < CursorCollection
|
|
4
|
+
# Derived from the parts: a ticket has no `statistics` block (measured on 81 142 tickets). Display only
|
|
5
|
+
# for good: /tickets/search filters on neither and silently ignores a sort on them.
|
|
6
|
+
module DerivedColumns
|
|
7
|
+
# A ticket is not "closed" on Intercom, it enters a state whose category
|
|
8
|
+
# is resolved.
|
|
9
|
+
RESOLVED = 'resolved'.freeze
|
|
10
|
+
|
|
11
|
+
# Matched on the prefix, never on the full `ticket_state_updated_by_admin`
|
|
12
|
+
# the sample showed: a workspace running workflows closes tickets through
|
|
13
|
+
# other variants of the same event, and a closure nobody can see is worse
|
|
14
|
+
# than a column nobody offers.
|
|
15
|
+
STATE_CHANGE_PREFIX = 'ticket_state_updated'.freeze
|
|
16
|
+
|
|
17
|
+
# A reply to the customer. A `note` is an internal touch, not an answer:
|
|
18
|
+
# counting it would name as "last responder" someone who never wrote to
|
|
19
|
+
# the person waiting.
|
|
20
|
+
REPLY_PART = 'comment'.freeze
|
|
21
|
+
|
|
22
|
+
# The columns a truncated timeline leaves unknown rather than empty, and
|
|
23
|
+
# therefore the ones whose presence in a projection is worth a warning.
|
|
24
|
+
CLOSURE_COLUMNS = %w[closed_at closed_by_name].freeze
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def define_derived_columns
|
|
29
|
+
add_column('closed_at', 'Date')
|
|
30
|
+
add_column('closed_by_name', 'String')
|
|
31
|
+
add_column('last_reply_at', 'Date')
|
|
32
|
+
add_column('last_responder_name', 'String')
|
|
33
|
+
# `admin` or `contact`: whether the last word came from the team or
|
|
34
|
+
# from the customer is what tells a queue who owes the next one.
|
|
35
|
+
add_column('last_responder_type', 'String')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def derived_columns_for(attrs)
|
|
39
|
+
parts = parts_of(attrs)
|
|
40
|
+
closure = last_closure(parts)
|
|
41
|
+
reply = last_reply(parts)
|
|
42
|
+
|
|
43
|
+
{ 'closed_at' => stamp(closure&.dig('created_at')),
|
|
44
|
+
'closed_by_name' => author_of(closure)['name'],
|
|
45
|
+
'last_reply_at' => stamp(reply&.dig('created_at')),
|
|
46
|
+
'last_responder_name' => author_of(reply)['name'],
|
|
47
|
+
'last_responder_type' => author_of(reply)['type'] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The hook of the base's `enrich`: nothing to read here, since
|
|
51
|
+
# `serialize` already derived everything. What is left is telling the
|
|
52
|
+
# operator when a value is missing because the timeline was truncated
|
|
53
|
+
# rather than because the event never happened.
|
|
54
|
+
def embed_derived_columns(records, _rows, projection)
|
|
55
|
+
return unless any_column_asked?(projection, CLOSURE_COLUMNS)
|
|
56
|
+
|
|
57
|
+
unknown = records.count { |record| closure_unknown?(record) }
|
|
58
|
+
warn_unknown_closures(unknown) if unknown.positive?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A resolved ticket with no closure in hand, on a timeline Intercom
|
|
62
|
+
# truncated: the date is *unknown*, not absent. A Date column cannot say
|
|
63
|
+
# that, so the log does.
|
|
64
|
+
def closure_unknown?(record)
|
|
65
|
+
state = record['ticket_state'].is_a?(Hash) ? record['ticket_state'] : {}
|
|
66
|
+
return false unless state['category'] == RESOLVED
|
|
67
|
+
|
|
68
|
+
last_closure(parts_of(record)).nil? && truncated?(record)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Intercom keeps the 500 most recent parts of a ticket. Past that, the
|
|
72
|
+
# transition that closed it may have fallen out of the window.
|
|
73
|
+
def truncated?(record)
|
|
74
|
+
total = parts_total(record)
|
|
75
|
+
total ? total > Array(parts_of(record)).size : false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def last_closure(parts)
|
|
79
|
+
closures = Array(parts).select { |part| state_change?(part) && part['ticket_state'] == RESOLVED }
|
|
80
|
+
|
|
81
|
+
closures.max_by { |part| part['created_at'].to_i }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A part can record a transition to the state the ticket was already in
|
|
85
|
+
# -- measured -- and that is not an event.
|
|
86
|
+
def state_change?(part)
|
|
87
|
+
part['part_type'].to_s.start_with?(STATE_CHANGE_PREFIX) &&
|
|
88
|
+
part['ticket_state'] != part['previous_ticket_state']
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def last_reply(parts)
|
|
92
|
+
Array(parts).select { |part| part['part_type'] == REPLY_PART }.max_by { |part| part['created_at'].to_i }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def author_of(part)
|
|
96
|
+
author = (part || {})['author']
|
|
97
|
+
author.is_a?(Hash) ? author : {}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# nil rather than an empty list when the payload carries no parts: an
|
|
101
|
+
# empty thread would read as a ticket nothing happened to.
|
|
102
|
+
def parts_of(record)
|
|
103
|
+
container = (record || {})['ticket_parts']
|
|
104
|
+
parts = container['ticket_parts'] if container.is_a?(Hash)
|
|
105
|
+
parts.is_a?(Array) ? parts : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parts_total(record)
|
|
109
|
+
container = (record || {})['ticket_parts']
|
|
110
|
+
container.is_a?(Hash) ? container['total_count'] : nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def warn_truncated_timelines(truncated)
|
|
114
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
115
|
+
"[forest_admin_datasource_intercom] #{name}: the timeline of #{truncated} ticket(s) of this page " \
|
|
116
|
+
'holds only their 500 most recent parts, Intercom keeping no more. Earlier events are left out.'
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def warn_unknown_closures(unknown)
|
|
121
|
+
ForestAdminDatasourceIntercom.logger.warn(
|
|
122
|
+
"[forest_admin_datasource_intercom] #{name}: #{unknown} resolved ticket(s) of this page show no " \
|
|
123
|
+
'closure date because Intercom truncated their timeline at 500 parts, not because they were never ' \
|
|
124
|
+
'closed. The column is unknown for those rows.'
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module ForestAdminDatasourceIntercom
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < CursorCollection
|
|
4
|
+
# One Intercom ticket flattened into the row the schema declares. Nothing
|
|
5
|
+
# here reads a sub-resource: the state, the type and the attributes all
|
|
6
|
+
# travel with the ticket.
|
|
7
|
+
module Serializer
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
# Intercom keys the attribute values by **name**, which is what lets a
|
|
11
|
+
# single collection display the union of every ticket type's -- and what
|
|
12
|
+
# stops it from filtering on them, the filter being written by an id
|
|
13
|
+
# that differs from one type to the next. A ticket of another type
|
|
14
|
+
# simply does not carry the key, and the column reads as empty.
|
|
15
|
+
def serialize(ticket)
|
|
16
|
+
attrs = ticket.is_a?(Hash) ? ticket : {}
|
|
17
|
+
|
|
18
|
+
native(attrs)
|
|
19
|
+
.merge(state_of(attrs))
|
|
20
|
+
.merge(type_of(attrs['ticket_type']))
|
|
21
|
+
.merge(contact_columns_for(attrs))
|
|
22
|
+
.merge(ticket_attribute_values(attrs))
|
|
23
|
+
.merge(derived_columns_for(attrs))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def native(attrs)
|
|
29
|
+
{ 'id' => stringify_id(attrs['id']),
|
|
30
|
+
'ticket_id' => stringify_id(attrs['ticket_id']),
|
|
31
|
+
'category' => attrs['category'],
|
|
32
|
+
'open' => attrs['open'],
|
|
33
|
+
'is_shared' => attrs['is_shared'],
|
|
34
|
+
'created_at' => stamp(attrs['created_at']),
|
|
35
|
+
'updated_at' => stamp(attrs['updated_at']),
|
|
36
|
+
'admin_assignee_id' => stringify_id(attrs['admin_assignee_id']),
|
|
37
|
+
'team_assignee_id' => stringify_id(attrs['team_assignee_id']),
|
|
38
|
+
'company_id' => stringify_id(attrs['company_id']),
|
|
39
|
+
'part_count' => parts_total(attrs) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def state_of(attrs)
|
|
43
|
+
state = attrs['ticket_state'].is_a?(Hash) ? attrs['ticket_state'] : {}
|
|
44
|
+
|
|
45
|
+
# `internal_label` is what the support team reads. The category and the
|
|
46
|
+
# customer-facing label are a hop away, on the `state` relation.
|
|
47
|
+
{ 'state_id' => stringify_id(state['id']),
|
|
48
|
+
'state_label' => state['internal_label'],
|
|
49
|
+
'previous_state_id' => stringify_id(attrs['previous_ticket_state_id']) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ticket_attribute_values(attrs)
|
|
53
|
+
values = attribute_values(attrs['ticket_attributes'])
|
|
54
|
+
type_id = stringify_id(attrs.dig('ticket_type', 'id')) if attrs['ticket_type'].is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
attribute_columns.each do |attribute|
|
|
57
|
+
values[attribute.column_name] = nil if Array(attribute.conflicting_ticket_types).include?(type_id)
|
|
58
|
+
end
|
|
59
|
+
values
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def type_of(ticket_type)
|
|
63
|
+
attrs = ticket_type.is_a?(Hash) ? ticket_type : {}
|
|
64
|
+
|
|
65
|
+
{ 'ticket_type_id' => stringify_id(attrs['id']), 'ticket_type_name' => attrs['name'] }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|