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,148 @@
1
+ module ForestAdminDatasourceIntercom
2
+ # Paces on Intercom's x-ratelimit-* headers. Workspace budget is 25 000/min but allocated in 10s windows
3
+ # (measured x-ratelimit-limit 1667), so the instantaneous rate matters. Paces, does not queue: callers
4
+ # woken at a reset can burst unmetered until the next response; the 429 retry behind this absorbs it.
5
+ class RateLimiter
6
+ # Intercom's allocation window. Only used as the ceiling below: the reset
7
+ # instant itself always comes from the response.
8
+ WINDOW = 10.0
9
+
10
+ # How far past the reset a request may be held. A little over one window, so
11
+ # a full window can be waited out, and no more: past this the wait is not
12
+ # Intercom's window emptying but a clock disagreeing.
13
+ DEFAULT_MAX_WAIT = 12.0
14
+
15
+ attr_reader :max_wait
16
+
17
+ def initialize(max_wait: DEFAULT_MAX_WAIT, now: nil, sleeper: nil)
18
+ @max_wait = max_wait.to_f
19
+ # Wall clock rather than monotonic on purpose: `X-RateLimit-Reset` is an
20
+ # absolute epoch second on Intercom's clock, so the two have to be
21
+ # comparable. `clamp_wait` is what keeps a skewed clock from turning that
22
+ # comparison into a long sleep.
23
+ @now = now || -> { Time.now.to_f }
24
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
25
+ @mutex = Mutex.new
26
+ @remaining = nil
27
+ @reset_at = nil
28
+ @limit = nil
29
+ @warned_at = nil
30
+ end
31
+
32
+ # Blocks until the current window has room, then returns. Called once per
33
+ # attempt, retries included: a replayed request spends the budget a first
34
+ # one did.
35
+ def acquire
36
+ wait, declined = @mutex.synchronize { plan_wait }
37
+
38
+ warn_saturated(declined) if declined
39
+ return if wait <= 0
40
+
41
+ @sleeper.call(wait)
42
+ end
43
+
44
+ # What a response says about the window it was answered in. Called on every
45
+ # response, the 429 included -- that one carries the most useful reset of
46
+ # all.
47
+ def observe(headers)
48
+ limit = integer_header(headers, 'x-ratelimit-limit')
49
+ remaining = integer_header(headers, 'x-ratelimit-remaining')
50
+ reset_at = integer_header(headers, 'x-ratelimit-reset')
51
+ return if remaining.nil? && reset_at.nil?
52
+
53
+ @mutex.synchronize { record(limit, remaining, reset_at) }
54
+ end
55
+
56
+ private
57
+
58
+ # Decrements locally so in-flight requests do not all pass on the same stale `remaining`; a
59
+ # `remaining` of 1 still allows this request. The declined wait is returned only once per window.
60
+ def plan_wait
61
+ unless exhausted?
62
+ @remaining -= 1 if @remaining
63
+ return [0, nil]
64
+ end
65
+
66
+ wait = clamp_wait(@reset_at - @now.call)
67
+ return [0, nil] if wait <= 0
68
+ return [0, first_warning? ? wait : nil] if wait > @max_wait
69
+
70
+ [wait, nil]
71
+ end
72
+
73
+ # Nothing left in a window that has not refilled yet. An unknown state --
74
+ # before the first response -- is not exhaustion: the first request is what
75
+ # discovers the budget.
76
+ def exhausted?
77
+ !@remaining.nil? && @remaining <= 0 && !@reset_at.nil?
78
+ end
79
+
80
+ # Intercom's reset is a timestamp from its clock, and the two clocks can
81
+ # disagree by more than the window is long. A wait longer than a window plus
82
+ # its own slack is that disagreement rather than a window emptying, so it is
83
+ # cut back to something a caller can afford to wait.
84
+ def clamp_wait(seconds)
85
+ return 0.0 if seconds <= 0
86
+
87
+ [seconds, WINDOW + @max_wait].min
88
+ end
89
+
90
+ # A window is adopted whole. A response answered in an older window than the
91
+ # one already recorded is ignored: replies come back out of order, and one
92
+ # from the previous window would otherwise resurrect a budget already spent.
93
+ #
94
+ # Within the same window the smaller `remaining` wins, so the local
95
+ # decrements of in-flight requests are not undone by a response that left
96
+ # Intercom before they were made.
97
+ def record(limit, remaining, reset_at)
98
+ return if reset_at && @reset_at && reset_at < @reset_at
99
+
100
+ new_window = reset_at && (@reset_at.nil? || reset_at > @reset_at)
101
+ @reset_at = reset_at if reset_at
102
+ @limit = limit if limit
103
+ return if remaining.nil?
104
+
105
+ @remaining = new_window || @remaining.nil? ? remaining : [remaining, @remaining].min
106
+ end
107
+
108
+ # One line per window. What the warning reports is a saturation that lasts,
109
+ # so a line per request puts one on every request it describes -- hundreds
110
+ # of them, burying the first, which is the only one the operator needed.
111
+ def first_warning?
112
+ now = @now.call
113
+ return false if @warned_at && now - @warned_at < WINDOW
114
+
115
+ @warned_at = now
116
+ true
117
+ end
118
+
119
+ def warn_saturated(wait)
120
+ ForestAdminDatasourceIntercom.logger.warn(
121
+ "[forest_admin_datasource_intercom] the Intercom rate-limit window is spent (limit #{@limit || "unknown"} " \
122
+ "per #{WINDOW.round}s) and its reset is #{wait.round(1)}s out, past the #{@max_wait.round(1)}s this waits. " \
123
+ 'Letting the request through -- Intercom may answer 429, which the client retries. A reset this far out ' \
124
+ "usually means this host's clock disagrees with Intercom's. Reported once per #{WINDOW.round}s."
125
+ )
126
+ end
127
+
128
+ def integer_header(headers, name)
129
+ value = header_value(headers, name)
130
+ return nil if value.nil? || value.to_s.strip.empty?
131
+
132
+ Integer(value.to_s.strip, exception: false)
133
+ end
134
+
135
+ # Faraday hands over headers that look themselves up case-insensitively, but
136
+ # what reaches here is whatever the middleware was given -- a plain hash
137
+ # included -- and HTTP header names are case-insensitive on the wire.
138
+ def header_value(headers, name)
139
+ return nil if headers.nil?
140
+
141
+ direct = headers[name]
142
+ return direct unless direct.nil?
143
+ return nil unless headers.respond_to?(:find)
144
+
145
+ headers.find { |key, _value| key.to_s.downcase == name }&.last
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,95 @@
1
+ module ForestAdminDatasourceIntercom
2
+ # Everything governing how the client reacts to a failed request, in one
3
+ # place: which statuses and exceptions are worth another attempt, on which
4
+ # verbs, and how long to wait.
5
+ class RetryPolicy
6
+ # Intercom allocates its quota in 10-second windows, so a 429 is recovered
7
+ # from within one of them -- unlike an API metering by the minute. The cap
8
+ # still has to cover a whole window: faraday-retry gives up outright when
9
+ # Retry-After exceeds max_interval, which would turn the 429 retry into an
10
+ # immediate give-up exactly when it matters.
11
+ DEFAULT_MAX_INTERVAL = 12
12
+
13
+ STATUSES = [429, 500, 502, 503, 504].freeze
14
+
15
+ # Named rather than referenced: the persistent adapter is a gem only loaded
16
+ # when it is the configured adapter -- `adapter: :net_http` never requires
17
+ # it -- so this file cannot name the class outright. Both places that need
18
+ # it resolve it at the moment they are asked, and answer that it cannot have
19
+ # been raised when nothing defined it.
20
+ PERSISTENT_ADAPTER_ERROR = 'Net::HTTP::Persistent::Error'.freeze
21
+
22
+ # faraday-retry's defaults plus ConnectionFailed, and the persistent adapter's own error, which it
23
+ # re-raises unwrapped for messages it does not translate.
24
+ EXCEPTIONS = [
25
+ Errno::ETIMEDOUT, 'Timeout::Error', Faraday::TimeoutError,
26
+ Faraday::RetriableResponse, Faraday::ConnectionFailed,
27
+ PERSISTENT_ADAPTER_ERROR
28
+ ].freeze
29
+
30
+ # The verbs that change nothing, so any transient failure is worth another
31
+ # attempt. Narrower than faraday-retry's idempotent default: a 502 or a
32
+ # dropped connection on the way back from a POST Intercom did perform would
33
+ # be replayed into a second reply on the conversation, or a second ticket.
34
+ RETRYABLE_METHODS = %i[get head options].freeze
35
+
36
+ # The paths Intercom answers a *read* on through POST: its three search
37
+ # endpoints, and the one listing that paginates by offset. Nothing that
38
+ # writes is spelled this way -- a reply is `POST /conversations/{id}/reply`,
39
+ # a ticket `POST /tickets` -- so the exemption below cannot reach one.
40
+ READ_ONLY_POST_PATHS = %r{/(search|list)\z}
41
+
42
+ # Widens `methods` (ORed by faraday-retry) to a 429 on any verb, and to a dropped connection on a
43
+ # read-only POST -- keep-alive makes an idle socket closed by the server an ordinary failure.
44
+ RETRY_IF = lambda do |env, exception|
45
+ env[:status] == 429 || (RetryPolicy.connection_failure?(exception) &&
46
+ READ_ONLY_POST_PATHS.match?(env[:url]&.path.to_s))
47
+ end
48
+
49
+ # Whether another attempt would be dialling again rather than re-sending.
50
+ # See `PERSISTENT_ADAPTER_ERROR` for why one of the two is named.
51
+ def self.connection_failure?(exception)
52
+ return true if exception.is_a?(Faraday::ConnectionFailed)
53
+ return false unless Object.const_defined?(PERSISTENT_ADAPTER_ERROR)
54
+
55
+ exception.is_a?(Object.const_get(PERSISTENT_ADAPTER_ERROR))
56
+ end
57
+
58
+ # The cap for a call that must not hold the boot, deliberately below a
59
+ # rate-limit window where DEFAULT_MAX_INTERVAL sits above it: a Retry-After
60
+ # past the cap makes faraday-retry abandon outright, which is what turns a
61
+ # 429 at boot into an immediate give-up rather than a window of waiting per
62
+ # attempt.
63
+ BOOT_MAX_INTERVAL = 2
64
+
65
+ BACKOFF_FACTOR = 2
66
+
67
+ attr_reader :max_retries, :interval, :max_interval
68
+
69
+ # One retry rather than none, for what is read once and never revisited: a
70
+ # transient failure there costs its result for the whole life of the
71
+ # process, and half a second absorbs the hiccup without waiting a 429 out.
72
+ def self.boot
73
+ new(max_retries: 1, interval: 0.5, max_interval: BOOT_MAX_INTERVAL)
74
+ end
75
+
76
+ def initialize(max_retries: 3, interval: 0.5, max_interval: DEFAULT_MAX_INTERVAL)
77
+ @max_retries = max_retries
78
+ @interval = interval
79
+ @max_interval = max_interval
80
+ end
81
+
82
+ def to_faraday_options
83
+ {
84
+ max: @max_retries,
85
+ interval: @interval,
86
+ max_interval: @max_interval,
87
+ backoff_factor: BACKOFF_FACTOR,
88
+ retry_statuses: STATUSES,
89
+ exceptions: EXCEPTIONS,
90
+ methods: RETRYABLE_METHODS,
91
+ retry_if: RETRY_IF
92
+ }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,52 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Schema
3
+ # What the two introspectors do identically: turn the name a workspace gave
4
+ # an attribute into a name a Forest schema can carry, and say which
5
+ # attribute was left out when two of them land on one column.
6
+ #
7
+ # They differ in where the attributes come from -- `/ticket_types` per
8
+ # ticket type, `/data_attributes` per model -- and in the data types they
9
+ # map, which is why `COLUMN_TYPES` stays with each of them.
10
+ module AttributeNaming
11
+ # What a column name may not contain, and it has nothing to do with
12
+ # Intercom: Forest lists the fields of a request in a **comma-separated**
13
+ # query parameter, and uses a colon to name a field through a relation.
14
+ # A workspace names its attributes in free text -- measured, one is called
15
+ # `ID de l'objet en question (immo, facture, user)` -- and a comma in
16
+ # there splits the projection into fields no collection has, which the
17
+ # agent rejects as a 400 before the page is ever read.
18
+ UNSAFE_IN_A_COLUMN_NAME = /[,:]/
19
+
20
+ private
21
+
22
+ # Intercom hands these back HTML-escaped -- `Ce que j&#39;ai vérifié` --
23
+ # which is an artefact of where they were typed, not part of the name.
24
+ def column_name_for(name)
25
+ CGI.unescapeHTML(name).gsub(UNSAFE_IN_A_COLUMN_NAME, ' ').squeeze(' ').strip
26
+ end
27
+
28
+ # An unknown data type reads as a string rather than being dropped:
29
+ # showing the value Intercom sent beats hiding a column because its type
30
+ # is new.
31
+ def column_type_for(definition)
32
+ self.class::COLUMN_TYPES.fetch(definition['data_type'].to_s, self.class::DEFAULT_COLUMN_TYPE)
33
+ end
34
+
35
+ # Two attributes reading as one column: the second is left out rather than
36
+ # sharing the first's entry, which would show its values under the first's
37
+ # name -- wrong values rather than missing ones, and worse.
38
+ #
39
+ # `attribute_kind` is the workspace's own vocabulary for these, which is
40
+ # what the operator has to go and rename.
41
+ def warn_collision(name, kept, column)
42
+ ForestAdminDatasourceIntercom.logger.warn(
43
+ "[forest_admin_datasource_intercom] the #{attribute_kind} attribute #{name.inspect} is left out: it " \
44
+ "reads as the column #{column.inspect}, which #{kept.inspect} already carries. Rename one of them in " \
45
+ 'Intercom to publish both.'
46
+ )
47
+ end
48
+
49
+ def attribute_kind = raise(NotImplementedError, "#{self.class} did not implement attribute_kind")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,88 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Schema
3
+ # Declared per model, so each column maps to exactly one attribute. Still display-only: which operators
4
+ # Intercom answers on `custom_attributes.{name}` is unmeasured. `api_writable` is carried for lot 4b.
5
+ class DataAttributesIntrospector
6
+ include AttributeNaming
7
+
8
+ # Intercom's attribute data types, mapped onto what Forest can render.
9
+ COLUMN_TYPES = {
10
+ 'string' => 'String', 'integer' => 'Number', 'float' => 'Number', 'decimal' => 'Number',
11
+ 'boolean' => 'Boolean', 'date' => 'Date', 'datetime' => 'Date'
12
+ }.freeze
13
+
14
+ DEFAULT_COLUMN_TYPE = 'String'.freeze
15
+
16
+ # `name` is the key `custom_attributes` uses, `column_name` the one the
17
+ # schema publishes; they differ whenever the workspace's own name cannot
18
+ # travel through Forest's query string.
19
+ Attribute = Struct.new(:name, :column_name, :column_type, :data_type, :api_writable, keyword_init: true)
20
+
21
+ def initialize(client, model:)
22
+ @client = client
23
+ @model = model
24
+ end
25
+
26
+ # A token without the permission on this model costs the custom-attribute
27
+ # columns, never the boot of the agent. Any other failure fails the boot:
28
+ # degraded on a timeout or a 5xx, this pod would publish a schema missing
29
+ # columns the next one has, and keep it for the life of the process.
30
+ def attributes
31
+ @attributes ||= build
32
+ rescue APIError => e
33
+ raise unless e.status == 403
34
+
35
+ ForestAdminDatasourceIntercom.logger.warn(
36
+ "[forest_admin_datasource_intercom] could not read the #{@model} attributes (#{e.message}); " \
37
+ 'the collection boots without its custom-attribute columns.'
38
+ )
39
+ @attributes = []
40
+ end
41
+
42
+ private
43
+
44
+ def build
45
+ # Read on the boot connection: this happens while Rails is starting, and
46
+ # a slow Intercom must not turn that into minutes the operator sits
47
+ # through.
48
+ @client.fetch_all('data_attributes', params: { 'model' => @model }, boot: true)
49
+ .each_with_object({}) { |definition, union| collect(definition, union) }
50
+ .values
51
+ end
52
+
53
+ # The standard attributes are left out: they are columns this datasource
54
+ # declares by hand, with the filters the search table measured, and
55
+ # publishing them a second time under their `custom_attributes` name would
56
+ # show one fact twice -- the unfilterable copy winning nothing.
57
+ def collect(definition, union)
58
+ return unless definition.is_a?(Hash) && definition['custom'] && !definition['archived']
59
+
60
+ name = definition['name'].to_s
61
+ return if name.empty?
62
+
63
+ column = column_name_for(name)
64
+ return if column.empty?
65
+
66
+ add(union, name, column, definition)
67
+ end
68
+
69
+ def add(union, name, column, definition)
70
+ entry = union[column]
71
+ return union[column] = attribute_from(name, column, definition) if entry.nil?
72
+ return if entry.name == name
73
+
74
+ warn_collision(name, entry.name, column)
75
+ end
76
+
77
+ def attribute_from(name, column, definition)
78
+ Attribute.new(name: name, column_name: column, column_type: column_type_for(definition),
79
+ data_type: definition['data_type'], api_writable: definition['api_writable'] == true)
80
+ end
81
+
82
+ # These are declared per model, so that is what the log calls them: it is
83
+ # the workspace's own vocabulary, and where the operator goes to rename
84
+ # the one that was left out.
85
+ def attribute_kind = @model
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,133 @@
1
+ module ForestAdminDatasourceIntercom
2
+ module Schema
3
+ # Declared per ticket type: one name has a different id on each type (measured: 14162161 vs 14162165) and
4
+ # Intercom filters by id only, so the union columns ship unfilterable (R7). Ids are kept per type for a
5
+ # future per-type filter translation, sparing a second boot-time round trip.
6
+ class TicketAttributesIntrospector
7
+ include AttributeNaming
8
+
9
+ # Intercom's attribute data types, mapped onto what Forest can render. A
10
+ # `list` is a single choice among values the workspace defined, so it reads
11
+ # as a string rather than as a Json blob; `files` is a list of attachments
12
+ # and has no scalar form at all.
13
+ COLUMN_TYPES = {
14
+ 'string' => 'String', 'list' => 'String', 'integer' => 'Number', 'decimal' => 'Number',
15
+ 'boolean' => 'Boolean', 'datetime' => 'Date', 'date' => 'Date', 'files' => 'Json'
16
+ }.freeze
17
+
18
+ DEFAULT_COLUMN_TYPE = 'String'.freeze
19
+
20
+ # `name` is the key the payload uses, `column_name` the one the schema
21
+ # publishes; they differ whenever the workspace's own name cannot travel
22
+ # through Forest's query string.
23
+ #
24
+ # `conflicting_ticket_types` names the ticket types declaring the same name
25
+ # with another type: their value would not be what the column holds.
26
+ Attribute = Struct.new(:name, :column_name, :column_type, :data_type, :ids_by_ticket_type,
27
+ :conflicting_ticket_types, keyword_init: true)
28
+
29
+ def initialize(client)
30
+ @client = client
31
+ end
32
+
33
+ # The union, one entry per attribute name. A token without the
34
+ # ticket-types permission costs the attribute columns, never the boot of
35
+ # the agent; any other failure fails the boot, for the reason
36
+ # `DataAttributesIntrospector#attributes` gives.
37
+ def attributes
38
+ @attributes ||= build
39
+ rescue APIError => e
40
+ raise unless e.status == 403
41
+
42
+ ForestAdminDatasourceIntercom.logger.warn(
43
+ "[forest_admin_datasource_intercom] could not read the ticket types (#{e.message}); " \
44
+ 'the Tickets collection boots without its attribute columns.'
45
+ )
46
+ @attributes = []
47
+ end
48
+
49
+ private
50
+
51
+ def build
52
+ # Read on the boot connection: this happens while Rails is starting, and
53
+ # a slow Intercom must not turn that into minutes the operator sits
54
+ # through.
55
+ @client.fetch_all('ticket_types', boot: true)
56
+ .each_with_object({}) { |ticket_type, union| collect(ticket_type, union) }
57
+ .values
58
+ end
59
+
60
+ def collect(ticket_type, union)
61
+ return unless ticket_type.is_a?(Hash)
62
+
63
+ type_id = ticket_type['id'].to_s
64
+ definitions(ticket_type).each do |definition|
65
+ entry = entry_for(definition, union, type_id)
66
+ next if entry.nil?
67
+
68
+ entry.ids_by_ticket_type[type_id] = definition['id'].to_s
69
+ end
70
+ end
71
+
72
+ # The union is keyed by column name rather than by the workspace's own,
73
+ # since that is what has to be unique in a schema. Two different attributes
74
+ # landing on one column would otherwise share an entry, and the second's
75
+ # values would be read under the first's name -- wrong values rather than
76
+ # missing ones, which is worse.
77
+ def entry_for(definition, union, type_id)
78
+ name = definition['name'].to_s
79
+ # An archived attribute is not offered any more, and a nameless one has
80
+ # nothing to be a column of.
81
+ return nil if name.empty? || definition['archived']
82
+
83
+ column = column_name_for(name)
84
+ return nil if column.empty?
85
+
86
+ entry = union[column]
87
+ return union[column] = attribute_from(name, column, definition) if entry.nil?
88
+ return same_name(entry, definition, type_id) if entry.name == name
89
+
90
+ warn_collision(name, entry.name, column)
91
+ nil
92
+ end
93
+
94
+ # The same name declared with another type by another ticket type: that
95
+ # ticket type's value is left out of the column rather than shown under a
96
+ # type it does not have.
97
+ def same_name(entry, definition, type_id)
98
+ column_type = column_type_for(definition)
99
+ return entry if column_type == entry.column_type
100
+
101
+ entry.conflicting_ticket_types << type_id
102
+ warn_type_conflict(entry, column_type, type_id)
103
+ nil
104
+ end
105
+
106
+ def warn_type_conflict(entry, column_type, type_id)
107
+ ForestAdminDatasourceIntercom.logger.warn(
108
+ "[forest_admin_datasource_intercom] the ticket attribute #{entry.name.inspect} is a #{column_type} on " \
109
+ "ticket type #{type_id} and a #{entry.column_type} elsewhere; the column stays a #{entry.column_type} " \
110
+ 'and reads as empty on that ticket type. Give the two attributes different names to publish both.'
111
+ )
112
+ end
113
+
114
+ def attribute_from(name, column, definition)
115
+ Attribute.new(name: name, column_name: column, column_type: column_type_for(definition),
116
+ data_type: definition['data_type'], ids_by_ticket_type: {}, conflicting_ticket_types: [])
117
+ end
118
+
119
+ def definitions(ticket_type)
120
+ container = ticket_type['ticket_type_attributes']
121
+ return [] unless container.is_a?(Hash)
122
+
123
+ list = container['data']
124
+ list.is_a?(Array) ? list.grep(Hash) : []
125
+ end
126
+
127
+ # These are declared per ticket type, and that is what the log calls
128
+ # them: the workspace's own vocabulary, and where the operator goes to
129
+ # rename the one that was left out.
130
+ def attribute_kind = 'ticket'
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,19 @@
1
+ module ForestAdminDatasourceIntercom
2
+ # Holds a request until the rate-limit window has room, and feeds the window
3
+ # back what the response says about it. A middleware rather than a call in
4
+ # each client method: there is one code path for every request here, where the
5
+ # client has one per endpoint, and this one also covers the requests the client
6
+ # never issues itself -- the replays `retry` performs.
7
+ class Throttle < Faraday::Middleware
8
+ def initialize(app, limiter:)
9
+ super(app)
10
+ @limiter = limiter
11
+ end
12
+
13
+ def call(env)
14
+ @limiter.acquire
15
+
16
+ @app.call(env).on_complete { |response_env| @limiter.observe(response_env[:response_headers]) }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ module ForestAdminDatasourceIntercom
2
+ VERSION = "1.49.0"
3
+ end
@@ -0,0 +1,57 @@
1
+ require_relative 'forest_admin_datasource_intercom/version'
2
+ require 'cgi/escape'
3
+ require 'json'
4
+ require 'logger'
5
+ require 'set'
6
+ require 'time'
7
+ require 'uri'
8
+ require 'yaml'
9
+ require 'zeitwerk'
10
+ # Required explicitly rather than relied on via the toolkit: `active_support` brings the autoload
11
+ # `Time.use_zone` needs, `active_support/time` brings `Time.zone` (see `Query::CallerZone`).
12
+ require 'active_support'
13
+ require 'active_support/time'
14
+ require 'faraday'
15
+ require 'faraday/retry'
16
+ require 'forest_admin_datasource_toolkit'
17
+
18
+ loader = Zeitwerk::Loader.for_gem
19
+ loader.setup
20
+
21
+ module ForestAdminDatasourceIntercom
22
+ class Error < StandardError; end
23
+ class ConfigurationError < Error; end
24
+
25
+ # Descends from the toolkit's ValidationError so the agent answers 400 with the message, not a 500:
26
+ # the datasource refuses a filter Intercom cannot express exactly rather than approximating it.
27
+ class UnsupportedOperatorError < ForestAdminDatasourceToolkit::Exceptions::ValidationError; end
28
+
29
+ # Raised when an Intercom API call fails. Carries the HTTP status and the
30
+ # parsed response body so callers can surface Intercom's own error message
31
+ # instead of a generic string.
32
+ class APIError < Error
33
+ attr_reader :status, :body
34
+
35
+ def initialize(message, status: nil, body: nil)
36
+ super(message)
37
+ @status = status
38
+ @body = body
39
+ end
40
+ end
41
+
42
+ class << self
43
+ attr_writer :logger
44
+
45
+ def logger
46
+ @logger ||= default_logger
47
+ end
48
+
49
+ private
50
+
51
+ def default_logger
52
+ return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
53
+
54
+ Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_intercom' }
55
+ end
56
+ end
57
+ end