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