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,77 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # Intercom nests only contact ids, so `contact_name` costs one bulk read per page, never per row.
4
+ module ContactIdentity
5
+ COLUMNS = %w[contact_name].freeze
6
+
7
+ # Must match the `contact` relation's chunking so `Client#with_read_scope` dedups the two
8
+ # reads into one request (guarded by `ticket_list_budget_spec`).
9
+ CONTACT_CHUNK = Contact::IDS_PER_READ
10
+
11
+ private
12
+
13
+ def define_contact_columns
14
+ add_column('contact_id', 'String')
15
+ add_column('contact_count', 'Number')
16
+ add_column('contact_name', 'String')
17
+ end
18
+
19
+ # A group conversation, or a ticket opened for several people, has more
20
+ # than one contact: the row names the first and counts them, rather than
21
+ # presenting one of several as the one. The `contact` relation resolves
22
+ # that same first contact, so the column and the relation cannot disagree;
23
+ # the others are reached through the contact's own conversations.
24
+ def contact_columns_for(attrs)
25
+ ids = nested_list(attrs['contacts'], 'contacts').filter_map { |contact| stringify_id(contact['id']) }
26
+
27
+ { 'contact_id' => ids.first, 'contact_count' => ids.size,
28
+ # Filled by the bulk read below, and left nil when the projection did
29
+ # not ask for it.
30
+ 'contact_name' => nil }
31
+ end
32
+
33
+ def first_contact_id(record)
34
+ contact = nested_list((record || {})['contacts'], 'contacts').first
35
+ contact.is_a?(Hash) ? stringify_id(contact['id']) : nil
36
+ end
37
+
38
+ # The Contacts endpoint as the table spells it, rather than a path written
39
+ # a second time here: this is the same `/contacts/search` the Contacts
40
+ # collection reads itself through, and a table that renamed it would
41
+ # otherwise leave this one behind.
42
+ def contact_search_path
43
+ @contact_search_path ||= Query::SearchFields.fetch('contacts').path
44
+ end
45
+
46
+ def embed_contact_identity(records, rows, projection)
47
+ return unless any_column_asked?(projection, COLUMNS)
48
+
49
+ identities = contact_identities(records)
50
+ records.each_with_index do |record, index|
51
+ identity = identities[first_contact_id(record)] || {}
52
+ rows[index]['contact_name'] = identity['name'] if rows[index].key?('contact_name')
53
+ end
54
+ end
55
+
56
+ # A failure costs the column and nothing else: an identity that could not
57
+ # be read is not a page that could not be served.
58
+ def contact_identities(records)
59
+ ids = records.filter_map { |record| first_contact_id(record) }.uniq
60
+ return {} if ids.empty?
61
+
62
+ ids.each_slice(CONTACT_CHUNK).with_object({}) do |chunk, indexed|
63
+ page = client.search_page(contact_search_path, per_page: chunk.size,
64
+ query: { 'field' => 'id', 'operator' => 'IN',
65
+ 'value' => chunk })
66
+ page.records.each { |contact| indexed[contact['id'].to_s] = contact }
67
+ end
68
+ rescue APIError => e
69
+ ForestAdminDatasourceIntercom.logger.warn(
70
+ "[forest_admin_datasource_intercom] #{name} could not read the contacts of this page (HTTP " \
71
+ "#{e.status || "-"}); the name column is left empty for it."
72
+ )
73
+ {}
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,75 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ class Conversation < CursorCollection
4
+ # One Intercom conversation flattened into the row the schema declares.
5
+ # Nothing here reads a sub-resource: every value comes from the payload the
6
+ # listing already returned.
7
+ module Serializer
8
+ protected
9
+
10
+ def serialize(conversation)
11
+ attrs = conversation.is_a?(Hash) ? conversation : {}
12
+
13
+ native(attrs)
14
+ .merge(contact_columns_for(attrs))
15
+ .merge(source_of(attrs['source']))
16
+ .merge(statistics_of(attrs['statistics']))
17
+ end
18
+
19
+ private
20
+
21
+ def native(attrs)
22
+ company = attrs['company'].is_a?(Hash) ? attrs['company'] : {}
23
+
24
+ {
25
+ 'id' => stringify_id(attrs['id']),
26
+ 'title' => attrs['title'],
27
+ 'state' => attrs['state'],
28
+ 'priority' => attrs['priority'],
29
+ 'open' => attrs['open'],
30
+ 'read' => attrs['read'],
31
+ 'created_at' => stamp(attrs['created_at']),
32
+ 'updated_at' => stamp(attrs['updated_at']),
33
+ 'waiting_since' => stamp(attrs['waiting_since']),
34
+ 'snoozed_until' => stamp(attrs['snoozed_until']),
35
+ 'admin_assignee_id' => stringify_id(attrs['admin_assignee_id']),
36
+ 'team_assignee_id' => stringify_id(attrs['team_assignee_id']),
37
+ 'company_id' => stringify_id(company['id']),
38
+ 'company_name' => company['name'],
39
+ 'tag_names' => nested_list(attrs['tags'], 'tags').filter_map { |tag| tag['name'] if tag.is_a?(Hash) },
40
+ 'ai_agent_participated' => attrs['ai_agent_participated']
41
+ }
42
+ end
43
+
44
+ def source_of(source)
45
+ attrs = source.is_a?(Hash) ? source : {}
46
+ author = attrs['author'].is_a?(Hash) ? attrs['author'] : {}
47
+
48
+ { 'source_type' => attrs['type'],
49
+ 'source_subject' => attrs['subject'],
50
+ # Plaintext, because `display_as=plaintext` rides on every read: the
51
+ # bodies are HTML written by end customers.
52
+ 'source_body' => attrs['body'],
53
+ 'source_author_name' => author['name'],
54
+ 'source_author_email' => author['email'],
55
+ 'source_delivered_as' => attrs['delivered_as'] }
56
+ end
57
+
58
+ # `statistics` is null on a conversation Intercom has computed nothing
59
+ # for yet; every column then reads as absent rather than as zero.
60
+ def statistics_of(statistics)
61
+ attrs = statistics.is_a?(Hash) ? statistics : {}
62
+
63
+ { 'closed_at' => stamp(attrs['last_close_at']),
64
+ 'first_closed_at' => stamp(attrs['first_close_at']),
65
+ 'closed_by_id' => stringify_id(attrs['last_closed_by_id']),
66
+ 'first_contact_reply_at' => stamp(attrs['first_contact_reply_at']),
67
+ 'last_contact_reply_at' => stamp(attrs['last_contact_reply_at']),
68
+ 'last_admin_reply_at' => stamp(attrs['last_admin_reply_at']),
69
+ 'reopen_count' => attrs['count_reopens'],
70
+ 'part_count' => attrs['count_conversation_parts'] }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,40 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ class Conversation < CursorCollection
4
+ # What a conversation adds to the shared thread: where its parts live, and
5
+ # the entry that opens it.
6
+ #
7
+ # The opening message lives in `source`, not in the parts -- a timeline
8
+ # built from the parts alone opens on the first reply and loses what the
9
+ # customer actually asked.
10
+ module Timeline
11
+ # The pseudo type of the opening entry. Not an Intercom part type: it is
12
+ # the source, and calling it `comment` would make it indistinguishable
13
+ # from the replies that follow.
14
+ SOURCE_PART_TYPE = 'conversation_started'.freeze
15
+
16
+ private
17
+
18
+ # nil rather than an empty list when the payload carries no parts at all:
19
+ # a listing response has none, and reading that as "this conversation is
20
+ # empty" is exactly the answer that looks complete without being it.
21
+ def parts_of(conversation)
22
+ container = (conversation || {})['conversation_parts']
23
+ return nil unless container.is_a?(Hash)
24
+
25
+ parts = container['conversation_parts']
26
+ parts.is_a?(Array) ? parts : nil
27
+ end
28
+
29
+ def opening_entry(attrs)
30
+ source = attrs['source']
31
+ return nil unless source.is_a?(Hash)
32
+
33
+ entry(part_type: SOURCE_PART_TYPE, created_at: attrs['created_at'], author: source['author'],
34
+ body: source['body'], attachments: source['attachments'])
35
+ .merge('id' => stringify_id(source['id']))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,153 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Collections
3
+ # Records come back under `conversations`, not `data`. Every read sends `display_as=plaintext`: bodies
4
+ # are customer-written HTML, unsafe to render inside Forest (R10).
5
+ # Long by line count only: most of it declares the columns, one call each.
6
+ class Conversation < CursorCollection
7
+ include ContactIdentity
8
+ include Conversation::Serializer
9
+ # The shared thread, then the hooks this collection puts over it.
10
+ include Collections::Timeline
11
+ include Conversation::Timeline
12
+
13
+ # How many conversations of one page may have their timeline read. The
14
+ # parts are absent from the listing response -- Intercom returns them only
15
+ # when retrieving a single conversation -- so a timeline asked for in a
16
+ # list view costs one request per row. Bounded rather than turned into a
17
+ # page the operator waits half a minute for; rows past the cap are left at
18
+ # nil, which reads as "unknown", never as "this conversation is empty".
19
+ MAX_TIMELINE_READS = 10
20
+
21
+ def initialize(datasource)
22
+ super(datasource, 'IntercomConversation')
23
+ # The one collection of this datasource Intercom matches text on: `~` on
24
+ # `source.body`, which is the message that opened the conversation.
25
+ enable_search
26
+ end
27
+
28
+ protected
29
+
30
+ def list_endpoint = 'conversations'
31
+ def list_key = 'conversations'
32
+ def searchable = 'conversations'
33
+ def search_column = 'source_body'
34
+
35
+ # Sent on the search too, where Intercom does not document it: the bodies
36
+ # are HTML written by end customers (R10), and a parameter it ignores costs
37
+ # a query string while the one it honours saves every filtered row from
38
+ # coming back as markup.
39
+ def read_params = { 'display_as' => 'plaintext' }
40
+
41
+ # The contact identity and the timeline, each read only when the projection
42
+ # names it: neither is on the conversation payload, and a page that never
43
+ # asked for them must not pay for them.
44
+ def enrich(records, rows, projection)
45
+ embed_contact_identity(records, rows, projection)
46
+ embed_timeline(records, rows, projection)
47
+ end
48
+
49
+ private
50
+
51
+ def define_schema
52
+ add_column('id', 'String', is_primary_key: true)
53
+ add_column('title', 'String')
54
+ # Left plain strings rather than enums: the values a workspace really
55
+ # serves are worth measuring before the interface offers them as a
56
+ # closed list, and nothing filters on them in this lot anyway.
57
+ add_column('state', 'String')
58
+ add_column('priority', 'String')
59
+ add_column('open', 'Boolean')
60
+ add_column('read', 'Boolean')
61
+ add_column('created_at', 'Date')
62
+ add_column('updated_at', 'Date')
63
+ add_column('waiting_since', 'Date')
64
+ add_column('snoozed_until', 'Date')
65
+ define_assignment_columns
66
+ define_contact_columns
67
+ define_source_columns
68
+ define_statistics_columns
69
+ add_column('tag_names', 'Json')
70
+ add_column('ai_agent_participated', 'Boolean')
71
+ add_column('timeline', 'Json')
72
+ define_relations
73
+ end
74
+
75
+ # /conversations/search filters on all three keys and on a contact id (measured), so `contact:email` is
76
+ # resolved through /contacts/search. No company relation: Intercom filters no company field here. A group
77
+ # conversation names its first contact.
78
+ def define_relations
79
+ add_many_to_one('admin_assignee', foreign_collection: 'IntercomAdmin', foreign_key: 'admin_assignee_id')
80
+ add_many_to_one('team_assignee', foreign_collection: 'IntercomTeam', foreign_key: 'team_assignee_id')
81
+ add_many_to_one('closed_by', foreign_collection: 'IntercomAdmin', foreign_key: 'closed_by_id')
82
+ add_many_to_one('contact', foreign_collection: 'IntercomContact', foreign_key: 'contact_id')
83
+ end
84
+
85
+ # Who the conversation sits with, and which account it belongs to. The
86
+ # conversation carries its company as a whole object, so the account name
87
+ # is free here -- unlike on a ticket, which carries the id alone.
88
+ def define_assignment_columns
89
+ add_column('admin_assignee_id', 'String')
90
+ add_column('team_assignee_id', 'String')
91
+ add_column('company_id', 'String')
92
+ add_column('company_name', 'String')
93
+ end
94
+
95
+ # The message that opened the conversation lives in `source`, not in the
96
+ # parts. A timeline built from the parts alone loses it, which is the one
97
+ # message nobody opens a conversation without wanting to read.
98
+ def define_source_columns
99
+ add_column('source_type', 'String')
100
+ add_column('source_subject', 'String')
101
+ add_column('source_body', 'String')
102
+ add_column('source_author_name', 'String')
103
+ add_column('source_author_email', 'String')
104
+ add_column('source_delivered_as', 'String')
105
+ end
106
+
107
+ # Intercom keeps the lifecycle of a conversation in `statistics`, which is
108
+ # where the closure date and the reply timestamps come from. Flattened onto
109
+ # the row: they cost nothing, they are exact, and they are what an ops lead
110
+ # reads a queue for.
111
+ def define_statistics_columns
112
+ add_column('closed_at', 'Date')
113
+ add_column('first_closed_at', 'Date')
114
+ add_column('closed_by_id', 'String')
115
+ add_column('first_contact_reply_at', 'Date')
116
+ add_column('last_contact_reply_at', 'Date')
117
+ add_column('last_admin_reply_at', 'Date')
118
+ add_column('reopen_count', 'Number')
119
+ add_column('part_count', 'Number')
120
+ end
121
+
122
+ # A record read through the record endpoint already carries its parts, so
123
+ # its timeline is free; one read from the listing does not, and pays a
124
+ # request. Rows past the cap keep the nil the projection put there.
125
+ def embed_timeline(records, rows, projection)
126
+ return unless column_asked?(projection, 'timeline')
127
+
128
+ unread = []
129
+ records.each_with_index do |record, index|
130
+ if parts_of(record)
131
+ rows[index]['timeline'] = build_timeline(record)
132
+ else
133
+ unread << index
134
+ end
135
+ end
136
+ read = unread.first(MAX_TIMELINE_READS)
137
+ details = client.fetch_records(list_endpoint, read.map { |index| records[index]['id'] }, params: read_params)
138
+ read.zip(details) { |index, detail| rows[index]['timeline'] = detail && build_timeline(detail) }
139
+
140
+ missing = unread.size - read.size
141
+ warn_truncated_timelines(missing) if missing.positive?
142
+ end
143
+
144
+ def warn_truncated_timelines(missing)
145
+ ForestAdminDatasourceIntercom.logger.warn(
146
+ "[forest_admin_datasource_intercom] #{name} left the timeline of #{missing} row(s) unread: Intercom " \
147
+ 'returns the parts only when retrieving one conversation, so a list view pays a request per row and ' \
148
+ "this reads at most #{MAX_TIMELINE_READS}. Those rows show no timeline rather than an empty one."
149
+ )
150
+ end
151
+ end
152
+ end
153
+ end